Merge branch 'stable-3.10'
* stable-3.10:
Do not warn when change's parent commit isn't a change
Fix click toggle on gr-result-row summary
Update JGit to cfdfb01f4
Clarify that comment links work on html-escaped text.
Release-Notes: skip
Change-Id: I020b6970e846332acb31d3cfb98ed758c14b5f79
diff --git a/.bazelrc b/.bazelrc
index 480bea7..74427f3 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -1,6 +1,7 @@
# TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel
# https://issues.gerritcodereview.com/issues/303819949
common --noenable_bzlmod
+common --incompatible_enable_proto_toolchain_resolution
build --workspace_status_command="python3 ./tools/workspace_status.py"
build --repository_cache=~/.gerritcodereview/bazel-cache/repository
@@ -51,10 +52,6 @@
build:remote21_bb --config=config_bb
build:remote21_bb --config=build_java21_shared
-# Enable modern C++ features
-build --cxxopt=-std=c++17
-build --host_cxxopt=-std=c++17
-
# Enable strict_action_env flag to. For more information on this feature see
# https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
# This will be the new default behavior at some point (and the flag was flipped
diff --git a/.bazelversion b/.bazelversion
index 66ce77b..b26a34e 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-7.0.0
+7.2.1
diff --git a/.github/pull_request_termplate.md b/.github/pull_request_termplate.md
new file mode 100644
index 0000000..e4b3563
--- /dev/null
+++ b/.github/pull_request_termplate.md
@@ -0,0 +1,14 @@
+Thank you for contributing to Gerrit Code Review!
+
+- Gerrit uses [gerrit-review.googlesource.com](https://gerrit-review.googlesource.com)
+for code changes and review
+- The [gerrit repository on github](https://github.com/GerritCodeReview/gerrit)
+is a read-only mirror
+- Therefore **pull requests in this repository cannot be merged**.
+
+Find documentation how to contribute to Gerrit here
+- [Submitting Patches](../SUBMITTING_PATCHES)
+- [How to Contribute](https://gerrit-review.googlesource.com/Documentation/dev-community.html#how-to-contribute)
+- [Crafting Changes](https://gerrit-review.googlesource.com/Documentation/dev-crafting-changes.html)
+- [Developer Setup](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
+- [Gerrit Code Review Workflow](https://gerrit-review.googlesource.com/Documentation/intro-user.html#code-review)
diff --git a/.gitignore b/.gitignore
index 0bbcaba..bc489bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@
/.classpath
/.factorypath
/.idea
+/.aswb
/.ijwb
/.metadata
/.project
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 2e90cff..7d531ff 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -97,10 +97,11 @@
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:
+Gerrit comes with three predefined groups:
-* Administrators
-* Service Users
+* link:#administrators[Administrators]
+* link:#service_users[Service Users]
+* link:#blocked_users[Blocked Users]
[[administrators]]
@@ -138,6 +139,15 @@
Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'.
+[[blocked_users]]
+=== Blocked Users
+
+This is a predefined group, created on Gerrit site initialization, for which
+the link:#category_read[Read] access right is globally blocked.
+
+link:#administrators[Administrators] can add spammers to this group in order to
+block them from accessing Gerrit so that they cannot post any further spam.
+
== Account Groups
Account groups contain a list of zero or more user account members,
@@ -834,11 +844,16 @@
Users without this access right can still remove their own votes.
+Note, removing votes is generally disallowed for merged changes as this could
+remove approvals that were necessary for the submission and it's confusing to
+see a merged change which doesn't have the necessary approvals to fulfill the
+submit requirements.
+
[[category_remove_reviewer]]
=== Remove Reviewer
This category permits users to remove other users from the list of
-reviewers on a change.
+reviewers on a change, including their votes.
Change owners can always remove reviewers who have given a zero or positive
score (even without having the `Remove Reviewer` access right assigned).
@@ -849,6 +864,11 @@
Users without this access right can only remove themselves from the
reviewer list on a change.
+Note, removing reviewers with non-zero votes is generally disallowed for merged
+changes as this could remove approvals that were necessary for the submission
+and it's confusing to see a merged change which doesn't have the necessary
+approvals to fulfill the submit requirements.
+
[[category_review_labels]]
=== Review Labels
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 9760a36..0c218c8 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1719,7 +1719,7 @@
With a configured 30 second delay a server with 4900 active users will
typically need to dedicate 1 CPU to the update check. 4900 users
divided by an average delay of 30 seconds is 163 requests arriving per
-second. If requests are served at \~6 ms response time, 1 CPU is
+second. If requests are served at ~6 ms response time, 1 CPU is
necessary to keep up with the update request traffic. On a smaller
user base of 500 active users, the default 30 second delay is only 17
requests per second and requires ~10% CPU.
@@ -2595,6 +2595,18 @@
link:access-control.html#capability_queryLimit[queryLimit]
which is defaulted to 500 entries.
+[[gerrit.projectStatePredicateEnabled]]
++
+Indicates whether the link:rest-api-projects.html[/projects/] REST API endpoint
+supports filtering projects by state. The value is exposed in
+link:rest-api-config.html[/config/server/info] REST API endpoint.
++
+Instances having a custom implementation of ProjectQueryBuilder might have
+disabled the `state` predicate in which case the setting should be set to
+`false`.
++
+Default: `true`.
+
[[gerrit.primaryWeblinkName]]gerrit.primaryWeblinkName::
+
Name of the link:dev-plugins.html#links-to-external-tools[Weblink] that should
@@ -2743,6 +2755,13 @@
+
By default empty.
+[[gerrit.requireChangeForConfigUpdate]]gerrit.requireChangeForConfigUpdate::
++
+If true, all attempts to update a project config directly using any REST API are rejected.
+Instead, users should always use APIs which create a config change (for review).
++
+By default `false`.
+
[[gerrit.serverId]]gerrit.serverId::
+
Used by NoteDb to, amongst other things, identify author identities from
@@ -5957,6 +5976,19 @@
+
By default, unset (all projects are matched).
+[[tracing.metric]]
+==== Subsection tracing.metric
+
+Section to control for which operations latency and counts should be recorded
+in the link:metrics.html#performance[performance metrics].
+
+[[tracing.metric.operation]]tracing.metric.operation::
++
+Name of a Gerrit operation for which latency and counts should be recorded in
+the link:metrics.html#performance[performance metrics].
++
+The operation name must match the operation name that is used with TraceTimer.
+
[[deadline.id]]
==== Subsection deadline.<id>
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 29d1b85..857725d 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -339,7 +339,7 @@
Gerrit currently supports the following predicates:
[[changekind]]
-==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE}
+==== changekind:{NO_CHANGE,NO_CODE_CHANGE,MERGE_FIRST_PARENT_UPDATE,REWORK,TRIVIAL_REBASE,TRIVIAL_REBASE_WITH_MESSAGE_UPDATE}
Matches if the diff between two patch sets was of a certain change kind:
@@ -410,6 +410,10 @@
For the pre-installed Code-Review label this predicate is used by
default.
+* [[trivial_rebase_with_message_update]]`TRIVIAL_REBASE_WITH_MESSAGE_UPDATE`:
++
+Same as TRIVIAL_REBASE, but commit message can be different.
+
* [[rework]]`REWORK`:
+
Matches all kind of change kinds because any other change kind
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index bcc96b4..8c1b1ce 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -144,6 +144,40 @@
This enables plugins to influence other plugins by customizing or extending the
their behaviour.
+When a plugin wants to expose an API that *must* not be further overridden by
+other plugins, it could use the additional annotation `@DynamicItem.Final` which
+also gives the option to further limit the name of the plugin that is designated
+to bind the only implementation available.
+
+For example, a `plugin B` may declare an API as `@DynamicItem.Final` which is then
+bound in its `ApiModule`.
+
+```
+ @DynamicItem.Final(implementedByPlugin = "plugin-b-impl")
+ public interface PluginAPI {}
+
+ public class PluginApiModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ DynamicItem.itemOf(binder(), PluginAPI.class);
+ }
+ }
+```
+
+The above definition of the `PluginApi` would be allowed to bound only by
+the `plugin-b-impl` which would associate its implementation class.
+
+```
+public class PluginImpl implements PluginAPI {}
+
+ public class PluginImplModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ DynamicItem.bind(binder(), PluginAPI.class).to(PluginImpl.class);
+ }
+ }
+```
+
*Gotchas and Limitations*:
- A `plugin A` depending on a `plugin B` (declaring a `Gerrit-ApiModule`),
@@ -623,6 +657,105 @@
.to(MyListener.class);
----
+Blocking inside onStop() is a good choice for QOS limits which are
+attempting to restrict total usage of resources as might be done to
+to prevent a server overload. In these cases, when a server's resources
+are being exhausted, it is important to throttle all `Tasks`, and blocking
+the current thread from being used by any Task makes sense. However,
+Task parking (see below) is more appropriate if it desirable to limit a
+specific resource usage in favor of other resources, such as when
+prioritization or fairness policies are desired.
+
+[[taskParker]]
+== TaskParkers
+
+It is possible to park `com.google.gerrit.server.git.WorkQueue$Task`s
+before they run without depriving other `Tasks` of a thread. Parking is
+particularly useful for (de-)prioritizing certain `Tasks` based on resource
+quotas without blocking `Tasks` not using those resources. For example,
+when there is a desire to limit how many commands a single user can run
+concurrently it is typically also desirable to not limit the total amount
+of concurrently running commands to the same limit. The Task parking
+mechanism is useful for such Task limiting scenarios.
+
+The `TaskParker` interface works well with a Semaphore's `tryAcquire()`
+method even when the Semaphore is unavailable. However, blocking should
+not be done with a `TaskParker` and if it is desired, such as when using a
+Semaphore's `acquire()` method, use a `TaskListener` interface instead.
+
+To make use of Task parking, implement a
+`com.google.gerrit.server.git.WorkQueue$TaskParker` and register the
+TaskParker (as a TaskListener) from a plugin like this:
+
+[source,java]
+----
+ public class MyParker implements TaskParker {
+ Semaphore semaphore = new Semaphore(3);
+
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ try {
+ return semaphore.tryAcquire();
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {
+ semaphore.release();
+ }
+
+ @Override
+ public void onStart(Task<?> task) {}
+
+ @Override
+ public void onStop(Task<?> task) {
+ semaphore.release();
+ }
+ }
+
+ bind(TaskListener.class)
+ .annotatedWith(Exports.named("MyParker"))
+ .to(MyParker.class);
+----
+
+Before running a Task, the executor will query each `TaskParker` to see
+if the Task may be run by calling `isReadyToStart()`. If any `TaskParker`
+returns `false` from `isReadyToStart()`, then the Task will get parked
+and the executor will wait until another Task completes before
+attempting to run a parked task again.
+
+Since parked `Tasks` are not actually running and consuming resources,
+they generally should also not be contributing towards those resource
+quotas which caused the task to be parked. For this reason, once it is
+determined that a Task will be parked, the executor will call
+`onNotReadyToStart()` on every `TaskParker` that previously returned `true`
+from `isReadyToStart()`. This allows those TaskParkers to reduce their
+resource usage counts which they bumped up in `isReadyToStart()` with
+the expectation that the Task may run. Since the Task is not running and
+the resource is not being used, reducing the resource usage count allows
+other `Tasks` needing that resource to run while the Task is parked.
+
+Once a running Task completes, the executor will attempt to run
+parked `Tasks` (in the order in which they were parked) by again calling
+`isReadyToStart()` on the TaskParkers, even the TaskParkers which
+previously returned a `true` before the Task was parked. This is
+necessary because although a Task may not have exceeded a specific
+resource limit before it was parked, another Task may since have been
+allowed to run and its usage of that resource may now cause the parked
+task under evaluation to need to be throttled and parked again.
+
+Note, the reason that it is important to not block inside the
+`isReadyToStart()` method is to avoid delaying the executor from calling
+`onNotReadyToStart()` on other TaskParkers holding resources, as this
+would prevent them from freeing those resources. Also, just as it is
+important to later release any resources acquired within
+`isReadyToStart()` in `onStop()`, it is even more important to release
+those resources in `onNotReadyToStart()` since `isReadyToStart()` may
+be called many times per `TaskParker`, but `onStop()` will only ever be
+be called once.
+
[[change-message-modifier]]
== Change Message Modifier
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index c2a1f86..2e87150 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -385,7 +385,7 @@
== Escalation channel to Google
If anything urgent is blocking that requires the attention of a Googler you may
-escalate this by writing an email to Han-Wen Nienhuys: hanwen@google.com
+escalate this by writing an email to Chris Poucet: poucet@google.com
[[deprecating-features]]
== Deprecating features
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index 8b6049e..ab77d1b 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -1137,6 +1137,45 @@
----
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. 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.
+
+3. 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.
+
+----
+
+
[[highlightjs-structured-text]]
highlightjs-structured-text
diff --git a/Documentation/json.txt b/Documentation/json.txt
index dc82ad1..94f9de9 100644
--- a/Documentation/json.txt
+++ b/Documentation/json.txt
@@ -118,7 +118,11 @@
REWORK;; Nontrivial content changes.
- TRIVIAL_REBASE;; Conflict-free merge between the new parent and the prior patch set.
+ TRIVIAL_REBASE;; Conflict-free merge between the new parent and the prior patch set; same commit
+ message.
+
+ TRIVIAL_REBASE_WITH_MESSAGE_UPDATE;; Conflict-free merge between the new parent and the prior
+ patch set.
MERGE_FIRST_PARENT_UPDATE;; Conflict-free change of first (left) parent of a merge commit.
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 7e3fa9a..91b6227 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -4021,6 +4021,45 @@
----
+[[highlightjs-epp]]
+highlightjs-epp
+
+* highlightjs-epp
+
+[[highlightjs-epp_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2024, highlight.js
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. 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.
+
+3. 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.
+
+----
+
+
[[highlightjs-structured-text]]
highlightjs-structured-text
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 4302a35..a9b82d6 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -73,6 +73,30 @@
** `cancellation_type`:
The cancellation type (graceful or forceful).
+[[performance]]
+=== Performance
+
+* `performance/operations`: Latency of performing operations
+** `operation_name`:
+ The operation that was performed.
+** `request`:
+ The request for which the operation was performed (format = '<request-type>
+ <redacted-request-uri>').
+** `plugin`:
+ The name of the plugin that performed the operation.
+* `performance/operations_count`: Number of performed operations
+** `operation_name`:
+ The operation that was performed.
+** `request`:
+ The request for which the operation was performed (format = '<request-type>
+ <redacted-request-uri>').
+** `plugin`:
+ The name of the plugin that performed the operation.
+
+Performance metrics can be enabled via the
+link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
+setting.
+
=== Pushes
* `receivecommits/changes`: histogram of number of changes processed in a single
@@ -98,6 +122,16 @@
** `type`:
The type of the update (CREATE, UPDATE, CREATE/UPDATE, UPDATE_NONFASTFORWARD,
DELETE).
+* `receivecommits/reject_count`: number of rejected pushes
+** `kind`:
+ The push kind ('magic push'/'magic push by service user' if it was a push for
+ code review, 'direct push'/'direct push by service user' if it was a direct
+ push, 'magic push by service, 'magic or direct push'/'magic or direct push by
+ service user' if the push kind couldn't be detected).
+** `reason`:
+ The rejection reason.
+** `status`:
+ The HTTP status code.
=== Process
diff --git a/Documentation/pgm-passwd.txt b/Documentation/pgm-passwd.txt
index 133fb03..02c87e5 100644
--- a/Documentation/pgm-passwd.txt
+++ b/Documentation/pgm-passwd.txt
@@ -27,9 +27,9 @@
== ARGUMENTS
-SECTION.KEY::
- Section and key in the `secure.config` file for setting or editing the
- password value.
+SECTION.[SUBSECTION.]KEY::
+ Section, subsection and key separated by a dot of the
+ password to set. Subsection is optional.
PASSWORD::
New password to set in `secure.config` associated to the section and key.
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index 991f36c..2e1bc43 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -415,6 +415,9 @@
Links to the history of the configuration file governing this project's access
rights as list of link:rest-api-changes.html#web-link-info[WebLinkInfo]
entities.
+|`require_change_for_config_update` |not set if `false`|
+Whether the calling user must create a change for updating project config.
+If true, all API requests which directly update project config are rejected.
|==================================
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index d0c4553..384585f 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1317,6 +1317,7 @@
"work_in_progress_by_default": true,
"allow_browser_notifications": true,
"allow_suggest_code_while_commenting": true,
+ "allow_autocompleting_comments": true,
"diff_page_sidebar": "plugin-foo",
"default_base_for_merges": "FIRST_PARENT",
"my": [
@@ -1372,6 +1373,7 @@
"disable_token_highlighting": true,
"allow_browser_notifications": false,
"allow_suggest_code_while_commenting": false,
+ "allow_autocompleting_comments": false,
"diff_page_sidebar": "NONE",
"diff_view": "SIDE_BY_SIDE",
"mute_common_path_prefixes": true,
@@ -2723,6 +2725,9 @@
|`allow_suggest_code_while_commenting` |not set if `false`|
Whether to receive suggested code while writing comments. This feature needs
a plugin implementation.
+|`allow_autocompleting_comments` |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
|`diff_page_sidebar` |optional|
String indicating which sidebar should be open on the diff page. Set to "NONE"
if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
@@ -2801,6 +2806,9 @@
|`allow_suggest_code_while_commenting` |not set if `false`|
Whether to receive suggested code while writing comments. This feature needs
a plugin implementation.
+|`allow_autocompleting_comments` |not set if `false`|
+Whether to receive autocompletions while writing comments. This feature needs
+a plugin implementation.
|`diff_page_sidebar` |optional|
String indicating which sidebar should be open on the diff page. Set to "NONE"
if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 4ed05bd..c6e5623 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3842,6 +3842,9 @@
Adds one user or all members of one group as reviewer to the change.
+NOTE: Adding multiple reviewers at once is possible via the
+link:#set-review[Set Review] REST endpoint.
+
The reviewer to be added to the change must be provided in the request
body as a link:#reviewer-input[ReviewerInput] entity.
@@ -4081,9 +4084,16 @@
If another user removed a user's vote, the user with the deleted vote will be
added to the attention set.
+Note, removing votes is generally disallowed for merged changes as this could
+remove approvals that were necessary for the submission and it's confusing to
+see a merged change which doesn't have the necessary approvals to fulfill the
+submit requirements.
+
The request returns:
* '204 No Content' if the vote is deleted successfully;
* '404 Not Found' when the vote to be deleted is zero or not present.
+ * '409 Conflict' when the change is merged and hence deleting votes is not
+ allowed
.Request
----
@@ -6975,6 +6985,8 @@
|=================================
|Field Name ||Description
|`patch` |required|
+|`allow_conflicts` |optional|
+If true, tolerate conflicts and add conflict markers where required.
The patch to be applied. Must be compatible with `git diff` output.
For example, link:#get-patch[Get Patch] output.
The patch must be provided as UTF-8 text, either directly or base64-encoded.
@@ -7214,12 +7226,10 @@
Number of inserted lines.
|`deletions` ||
Number of deleted lines.
-|`total_comment_count` |optional|
-Total number of inline comments across all patch sets. Not set if the current
-change index doesn't have the data.
-|`unresolved_comment_count` |optional|
-Number of unresolved inline comment threads across all patch sets. Not set if
-the current change index doesn't have the data.
+|`total_comment_count` ||
+Total number of inline comments across all patch sets.
+|`unresolved_comment_count` ||
+Number of unresolved inline comment threads across all patch sets.
|`_number` ||
The change number. (The underscore is just a relict of a prior
attempt to deprecate the change number.)
@@ -7348,9 +7358,10 @@
Only set if this change info is returned in response to a request that
creates a new change or patch set and conflicts are allowed. In
particular this field is only populated if the change info is returned
-by one of the following REST endpoints: link:#create-change[Create
-Change], link:#create-merge-patch-set-for-change[Create Merge Patch Set
-For Change], link:#cherry-pick[Cherry Pick Revision],
+by one of the following REST endpoints:link:#apply-patch[Apply Patch],
+link:#create-change[Create Change],
+link:#create-merge-patch-set-for-change[Create Merge Patch Set For Change],
+link:#cherry-pick[Cherry Pick Revision],
link:rest-api-project.html#cherry-pick-commit[Cherry Pick Commit],
link:#rebase-change[Rebase Change]
|==================================
@@ -8889,7 +8900,8 @@
|===========================
|Field Name ||Description
|`kind` ||The change kind. Valid values are `REWORK`, `TRIVIAL_REBASE`,
-`MERGE_FIRST_PARENT_UPDATE`, `NO_CODE_CHANGE`, and `NO_CHANGE`.
+`TRIVIAL_REBASE_WITH_MESSAGE_UPDATE`, `MERGE_FIRST_PARENT_UPDATE`,
+`NO_CODE_CHANGE`, and `NO_CHANGE`.
|`_number` ||The patch set number, or `edit` if the patch set is an edit.
|`created` ||
The link:rest-api.html#timestamp[timestamp] of when the patch set was
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 37121350..b893245 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -152,7 +152,8 @@
"gerrit": {
"all_projects": "All-Projects",
"all_users": "All-Users"
- "doc_search": true
+ "doc_search": true,
+ "project_state_predicate_enabled": true
},
"sshd": {},
"suggest": {
@@ -164,6 +165,27 @@
}
----
+[[account-deactivation]]
+=== AccountDeactivation
+--
+'POST /config/server/deactivate.stale.accounts'
+--
+Queues the link:config-gerrit.html#accountDeactivation[account deactivator] task.
+
+.Request
+----
+ POST /config/server/deactivate.stale.accounts HTTP/1.0
+----
+
+.Response
+----
+ HTTP/1.1 202 Accepted
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ "Account deactivator task added to work queue."
+----
+
[[check-consistency]]
=== Check Consistency
--
@@ -1749,6 +1771,33 @@
)]}'
----
+[[cleanup.changes]]
+=== Cleanup of stale changes
+
+This endpoint allows Gerrit administrators to abandon changes older than some given
+time. This allows to run change cleanup manually outside of the configured schedule or
+if change cleanup has been deactivated.
+
+The change cleanup will run asynchronously.
+
+.Request
+----
+ POST /config/server/cleanup.changes HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "after": "3 months",
+ "if_mergeable": true,
+ "message": "Abandoning stale changes."
+ }
+----
+
+.Response
+----
+ HTTP/1.1 202 Accepted
+ Content-Disposition: attachment
+----
+
[[experiment-endpoints]]
== Experiment Endpoints
@@ -2248,6 +2297,8 @@
Gerrit base path even if this value is unset.)
|`edit_gpg_keys` |not set if `false`|
Whether to enable the web UI for editing GPG keys.
+|`project_state_predicate_enabled` ||
+link:config-gerrit.html#gerrit.projectStatePredicateEnabled[Whether the instance supports filtering projects by state].
|`report_bug_url` |optional|
link:config-gerrit.html#gerrit.reportBugUrl[URL to report bugs].
|`instance_id` |optional|
@@ -2616,6 +2667,22 @@
name of the user is not set.
|====================================
+[[clean-changes-input]]
+=== CleanChanges.Input
+The `CleanChanges.Input` entity is being used to configure a run
+of change cleanup.
+
+[options="header",cols="1,^1,5"]
+|=============================
+|Field Name||Description
+|`after`||Abandon all changes that weren't updated in the
+timespan given here
+|`if_mergeable`|default: `false`|Whether to also abandon changes
+that are mergeable
+|`message`|optional|Message to post to changes abandoned by the
+cleanup
+|=============================
+
GERRIT
------
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index b0e1b49..b9fa35a 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -984,6 +984,68 @@
}
----
+[[create-config-change]]
+=== Create Config Change for review.
+--
+'PUT /projects/link:#project-name[\{project-name\}]/config:review'
+--
+
+Sets the configuration of a project.
+
+This takes the same input as link:#set-config[Set Config], but creates a pending
+change for review. Like link:#create-change[Create Change], it returns
+a link:#change-info[ChangeInfo] entity describing the resulting change.
+
+.Request
+----
+ PUT /projects/myproject/config:review HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "description": "demo project",
+ "use_contributor_agreements": "FALSE",
+ "use_content_merge": "INHERIT",
+ "use_signed_off_by": "INHERIT",
+ "create_new_change_for_all_not_in_target": "INHERIT",
+ "enable_signed_push": "INHERIT",
+ "require_signed_push": "INHERIT",
+ "reject_implicit_merges": "INHERIT",
+ "require_change_id": "TRUE",
+ "max_object_size_limit": "10m",
+ "submit_type": "REBASE_IF_NECESSARY",
+ "state": "ACTIVE"
+ }
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+ "project": "testproj",
+ "branch": "refs/meta/config",
+ "hashtags": [],
+ "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+ "subject": "Review access change",
+ "status": "NEW",
+ "created": "2017-09-07 14:31:11.852000000",
+ "updated": "2017-09-07 14:31:11.852000000",
+ "submit_type": "CHERRY_PICK",
+ "mergeable": true,
+ "insertions": 2,
+ "deletions": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "_number": 7,
+ "owner": {
+ "_account_id": 1000000
+ }
+ }
+----
+
[[run-gc]]
=== Run GC
--
@@ -2514,12 +2576,12 @@
]
----
-SortBy(sortby)::
+SortBy(sort-by)::
Sort the returned tags by one of the supported sort options: ref (default), creation_time.
+
.Request
----
- GET /projects/work%2Fmy-project/tags?sortby=creation_time HTTP/1.0
+ GET /projects/work%2Fmy-project/tags?sort-by=creation_time HTTP/1.0
----
+
.Response
@@ -3586,6 +3648,82 @@
HTTP/1.1 200 OK
----
+[[create-labels-change]]
+=== Create Labels Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/labels:review'
+--
+
+Creates/updates/deletes multiple label definitions in this project at once.
+
+This takes the same input as link:#batch-update-labels[Batch Updates Labels],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+ POST /projects/testproj/config:review HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "commit_message": "Update Labels",
+ "delete": [
+ "Old-Review",
+ "Unused-Review"
+ ],
+ "create": [
+ {
+ "name": "Foo-Review",
+ "values": {
+ " 0": "No score",
+ "-1": "I would prefer this is not submitted as is",
+ "-2": "This shall not be submitted",
+ "+1": "Looks good to me, but someone else must approve",
+ "+2": "Looks good to me, approved"
+ }
+ ],
+ "update:" {
+ "Bar-Review": {
+ "function": "MaxWithBlock"
+ },
+ "Baz-Review": {
+ "copy_condition": "is:MIN"
+ }
+ }
+ }
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+ "project": "testproj",
+ "branch": "refs/meta/config",
+ "hashtags": [],
+ "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+ "subject": "Review access change",
+ "status": "NEW",
+ "created": "2017-09-07 14:31:11.852000000",
+ "updated": "2017-09-07 14:31:11.852000000",
+ "submit_type": "CHERRY_PICK",
+ "mergeable": true,
+ "insertions": 2,
+ "deletions": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "_number": 7,
+ "owner": {
+ "_account_id": 1000000
+ }
+ }
+----
+
+
[[submit-requirement-endpoints]]
== Submit Requirement Endpoints
@@ -3779,6 +3917,103 @@
HTTP/1.1 204 No Content
----
+[[batch-update-submit-requirements]]
+=== Batch Update Submit Requirements
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements/'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+The calling user must have write access to the `refs/meta/config` branch of the
+project.
+
+The updates must be specified in the request body as
+link:#batch-submit-requirement-input[BatchSubmitRequirementInput] entity.
+
+The updates are processed in the following order:
+
+1. submit requirements deletions
+2. submit requirements creations
+3. submit requirements updates
+
+.Request
+----
+ POST /projects/My-Project/submit_requirements/ HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "commit_message": "Update Submit Requirements",
+ "delete": [
+ "Old-Review",
+ "Unused-Review"
+ ]
+ }
+----
+
+If the submit requirements updates were done successfully the response is "`200 OK`".
+
+.Response
+----
+ HTTP/1.1 200 OK
+----
+
+[[create-submit-requirements-change]]
+=== Create Submit Requirements Change for review.
+--
+'POST /projects/link:#project-name[\{project-name\}]/submit_requirements:review'
+--
+
+Creates/updates/deletes multiple submit requirements definitions in this project at once.
+
+This takes the same input as link:#batch-update-submit-requirements[Batch Update Submit Requirements],
+but creates a pending change for review. Like
+link:#create-change[Create Change], it returns a link:#change-info[ChangeInfo]
+entity describing the resulting change.
+
+.Request
+----
+ POST /projects/testproj/submit_requirements:review HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "commit_message": "Update Submit Requirements",
+ "delete": [
+ "Old-Review",
+ "Unused-Review"
+ ]
+ }
+----
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "id": "testproj~refs%2Fmeta%2Fconfig~Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+ "project": "testproj",
+ "branch": "refs/meta/config",
+ "hashtags": [],
+ "change_id": "Ieaf185bf90a1fc3b58461e399385e158a20b31a2",
+ "subject": "Review access change",
+ "status": "NEW",
+ "created": "2017-09-07 14:31:11.852000000",
+ "updated": "2017-09-07 14:31:11.852000000",
+ "submit_type": "CHERRY_PICK",
+ "mergeable": true,
+ "insertions": 2,
+ "deletions": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "_number": 7,
+ "owner": {
+ "_account_id": 1000000
+ }
+ }
+----
+
[[ids]]
== IDs
@@ -4171,11 +4406,13 @@
Whether empty commits should be rejected when a change is merged.
Can be `TRUE`, `FALSE` or `INHERIT`. +
If not set, this setting is not updated.
-|commentlinks |optional|
+|`commentlinks` |optional|
Map of commentlink names to link:#commentlink-input[CommentLinkInput]
entities to add or update on the project. If the given commentlink
already exists, it will be updated with the given values, otherwise
it will be created. If the value is null, that entry is deleted.
+|`message` |optional|
+A commit message for this change.
|======================================================
[[config-parameter-info]]
@@ -4521,6 +4758,27 @@
entities that describe the updates that should be done for the labels.
|=============================
+[[batch-submit-requirement-input]]
+=== BatchSubmitRequirementInput
+The `BatchSubmitRequirementInput` entity contains information for batch updating submit requirements
+definitions in a project.
+
+[options="header",cols="1,^2,4"]
+|=============================
+|Field Name ||Description
+|`commit_message`|optional|
+Message that should be used to commit the submit requirements updates in the
+`project.config` file to the `refs/meta/config` branch.
+|`delete` |optional|
+List of submit requirements that should be deleted.
+|`create` |optional|
+List of link:#submit-requirement-input[SubmitRequirementInput] entities that
+describe submit requirements that should be created.
+|`update` |optional|
+Map of label names to link:#submit-requirement-input[SubmitRequirementInput]
+entities that describe the updates that should be done for the submit requirements.
+|=============================
+
[[project-access-input]]
=== ProjectAccessInput
The `ProjectAccessInput` describes changes that should be applied to a project
diff --git a/WORKSPACE b/WORKSPACE
index 1c168c6..3d32937 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -31,17 +31,9 @@
load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies")
http_archive(
- name = "rules_nodejs",
- patch_args = ["-p1"],
- patches = ["//tools:rules_nodejs-5.8.4-node_versions.bzl.patch"],
- sha256 = "8fc8e300cb67b89ceebd5b8ba6896ff273c84f6099fc88d23f24e7102319d8fd",
- urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.4/rules_nodejs-core-5.8.4.tar.gz"],
-)
-
-http_archive(
name = "build_bazel_rules_nodejs",
- sha256 = "709cc0dcb51cf9028dd57c268066e5bc8f03a119ded410a13b5c3925d6e43c48",
- urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.4/rules_nodejs-5.8.4.tar.gz"],
+ sha256 = "a1295b168f183218bc88117cf00674bcd102498f294086ff58318f830dd9d9d1",
+ urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.5/rules_nodejs-5.8.5.tar.gz"],
)
load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -117,7 +109,7 @@
load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
node_repositories(
- node_version = "20.9.0",
+ node_version = "20.14.0",
yarn_version = "1.22.19",
)
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 96a6d32..7dc070c 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -75,6 +75,7 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -1550,6 +1551,17 @@
assertThat(res).isEqualTo(expectedContent);
}
+ protected void assertLastCommitAuthorAndShortMessage(
+ String refName, String expectedAuthor, String expectedShortMessage) throws Exception {
+ try (Repository repo = repoManager.openRepository(project);
+ RevWalk rw = new RevWalk(repo)) {
+ Ref exactRef = repo.exactRef(refName);
+ RevCommit revCommit = rw.parseCommit(exactRef.getObjectId());
+ assertThat(revCommit.getAuthorIdent().getName()).isEqualTo(expectedAuthor);
+ assertThat(revCommit.getShortMessage()).isEqualTo(expectedShortMessage);
+ }
+ }
+
@CanIgnoreReturnValue
protected RevCommit createNewCommitWithoutChangeId(String branch, String file, String content)
throws Exception {
@@ -1576,6 +1588,18 @@
ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
}
+ /** Creates a submit requirement with all required field. */
+ protected void configSubmitRequirement(Project.NameKey project, String name) throws Exception {
+ configSubmitRequirement(
+ project,
+ SubmitRequirement.builder()
+ .setName(name)
+ .setAllowOverrideInChildProjects(true)
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("-label:Code-Review=MIN"))
+ .build());
+ }
+
protected void configSubmitRequirement(
Project.NameKey project, SubmitRequirement submitRequirement) throws Exception {
try (ProjectConfigUpdate u = updateProject(project)) {
diff --git a/java/com/google/gerrit/acceptance/AssertUtil.java b/java/com/google/gerrit/acceptance/AssertUtil.java
index a1d3e79..f72c6d3 100644
--- a/java/com/google/gerrit/acceptance/AssertUtil.java
+++ b/java/com/google/gerrit/acceptance/AssertUtil.java
@@ -25,9 +25,9 @@
public class AssertUtil {
public static <T> void assertPrefs(T actual, T expected, String... fieldsToExclude)
throws IllegalArgumentException, IllegalAccessException {
- Set<String> exludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
+ Set<String> excludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
for (Field field : actual.getClass().getDeclaredFields()) {
- if (exludedFields.contains(field.getName()) || skipField(field)) {
+ if (excludedFields.contains(field.getName()) || skipField(field)) {
continue;
}
Object actualVal = field.get(actual);
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index d2051d5..a35e427 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -45,7 +45,6 @@
import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
import com.google.gerrit.server.ExceptionHook;
import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.change.ChangeETagComputation;
import com.google.gerrit.server.change.FilterIncludedIn;
import com.google.gerrit.server.change.ReviewerSuggestion;
import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -81,7 +80,6 @@
private final DynamicSet<SubmitRule> submitRules;
private final DynamicSet<SubmitRequirement> submitRequirements;
private final DynamicSet<ChangeMessageModifier> changeMessageModifiers;
- private final DynamicSet<ChangeETagComputation> changeETagComputations;
private final DynamicSet<ActionVisitor> actionVisitors;
private final DynamicMap<DownloadScheme> downloadSchemes;
private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
@@ -128,7 +126,6 @@
DynamicSet<SubmitRule> submitRules,
DynamicSet<SubmitRequirement> submitRequirements,
DynamicSet<ChangeMessageModifier> changeMessageModifiers,
- DynamicSet<ChangeETagComputation> changeETagComputations,
DynamicSet<ActionVisitor> actionVisitors,
DynamicMap<DownloadScheme> downloadSchemes,
DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
@@ -170,7 +167,6 @@
this.submitRules = submitRules;
this.submitRequirements = submitRequirements;
this.changeMessageModifiers = changeMessageModifiers;
- this.changeETagComputations = changeETagComputations;
this.actionVisitors = actionVisitors;
this.downloadSchemes = downloadSchemes;
this.refOperationValidationListeners = refOperationValidationListeners;
@@ -286,11 +282,6 @@
}
@CanIgnoreReturnValue
- public Registration add(ChangeETagComputation changeETagComputation) {
- return add(changeETagComputations, changeETagComputation);
- }
-
- @CanIgnoreReturnValue
public Registration add(ActionVisitor actionVisitor) {
return add(actionVisitors, actionVisitor);
}
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 53c952e..9d8674f 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -26,6 +26,7 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest.TestTicker;
import com.google.gerrit.acceptance.FakeGroupAuditService.FakeGroupAuditServiceModule;
+import com.google.gerrit.acceptance.GrantDirectPushPermissionsOnStartup.GrantDirectPushPermissionsOnStartupModule;
import com.google.gerrit.acceptance.ReindexGroupsAtStartup.ReindexGroupsAtStartupModule;
import com.google.gerrit.acceptance.ReindexProjectsAtStartup.ReindexProjectsAtStartupModule;
import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
@@ -529,7 +530,9 @@
},
new ConfigExperimentFeaturesModule()));
daemon.addAdditionalSysModuleForTesting(
- new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
+ new GrantDirectPushPermissionsOnStartupModule(),
+ new ReindexProjectsAtStartupModule(),
+ new ReindexGroupsAtStartupModule());
daemon.start();
return new GerritServer(desc, null, createTestInjector(daemon), Optional.of(daemon), null);
}
@@ -554,7 +557,9 @@
throws Exception {
requireNonNull(site);
daemon.addAdditionalSysModuleForTesting(
- new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
+ new GrantDirectPushPermissionsOnStartupModule(),
+ new ReindexProjectsAtStartupModule(),
+ new ReindexGroupsAtStartupModule());
ExecutorService daemonService = Executors.newSingleThreadExecutor();
String[] args =
Stream.concat(
diff --git a/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java b/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java
new file mode 100644
index 0000000..fcf4635
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GrantDirectPushPermissionsOnStartup.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.db.Groups;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.inject.Inject;
+import com.google.inject.Scopes;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class GrantDirectPushPermissionsOnStartup implements LifecycleListener {
+ public static class GrantDirectPushPermissionsOnStartupModule extends LifecycleModule {
+ @Override
+ protected void configure() {
+ listener().to(GrantDirectPushPermissionsOnStartup.class).in(Scopes.SINGLETON);
+ }
+ }
+
+ private final AllProjectsName allProjects;
+ private final MetaDataUpdate.Server metaDataUpdateFactory;
+ private final ProjectConfig.Factory projectConfigFactory;
+ private final Groups groups;
+
+ @Inject
+ GrantDirectPushPermissionsOnStartup(
+ AllProjectsName allProjects,
+ MetaDataUpdate.Server metaDataUpdateFactory,
+ ProjectConfig.Factory projectConfigFactory,
+ Groups groups) {
+ this.allProjects = allProjects;
+ this.metaDataUpdateFactory = metaDataUpdateFactory;
+ this.projectConfigFactory = projectConfigFactory;
+ this.groups = groups;
+ }
+
+ @Override
+ public void start() {
+ try (RefUpdateContext ctx = openTestRefUpdateContext();
+ MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(allProjects)) {
+ ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+ GroupReference adminGroupRef = findAdminGroup().orElseThrow();
+ adminGroupRef = projectConfig.resolve(adminGroupRef);
+ PermissionRule.Builder rule = PermissionRule.builder(adminGroupRef).setAction(Action.ALLOW);
+ projectConfig.upsertAccessSection(
+ RefNames.REFS_HEADS + "*", as -> as.upsertPermission(Permission.PUSH).add(rule));
+ projectConfig.upsertAccessSection(
+ RefNames.REFS_CONFIG, as -> as.upsertPermission(Permission.PUSH).add(rule));
+ projectConfig.commit(metaDataUpdate);
+ } catch (IOException | ConfigInvalidException e) {
+ throw new IllegalStateException(
+ "Unable to assign direct push permissions, tests may fail", e);
+ }
+ }
+
+ @Override
+ public void stop() {}
+
+ private Optional<GroupReference> findAdminGroup() throws IOException, ConfigInvalidException {
+ for (GroupReference groupRef : groups.getAllGroupReferences().collect(toImmutableList())) {
+ InternalGroup group = groups.getGroup(groupRef.getUUID()).orElseThrow();
+ if (group.getName().equals("Administrators")) {
+ return Optional.of(groupRef);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/TestConfigRule.java b/java/com/google/gerrit/acceptance/TestConfigRule.java
index a7f051a..e2ae416 100644
--- a/java/com/google/gerrit/acceptance/TestConfigRule.java
+++ b/java/com/google/gerrit/acceptance/TestConfigRule.java
@@ -47,8 +47,11 @@
@Override
public void evaluate() throws Throwable {
setTestConfigFromDescription(description);
- statement.evaluate();
- clear();
+ try {
+ statement.evaluate();
+ } finally {
+ clear();
+ }
}
};
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 5b5895f..bdda27a 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -28,12 +28,12 @@
import com.google.gerrit.server.account.Accounts;
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdFactory;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.Optional;
-import java.util.function.Consumer;
import org.eclipse.jgit.errors.ConfigInvalidException;
/**
@@ -73,7 +73,7 @@
protected Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
Account.Id accountId = Account.id(seq.nextAccountId());
- Consumer<AccountDelta.Builder> accountCreation =
+ ConfigureStatelessDelta accountCreation =
deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
AccountState createdAccount =
accountsUpdate.insert("Create Test Account", accountId, accountCreation);
@@ -234,7 +234,7 @@
if (testAccountInvalidation.preferredEmailWithoutExternalId().isPresent()) {
updateAccount(
- (account, deltaBuilder) ->
+ (unusedState, deltaBuilder) ->
deltaBuilder.setPreferredEmail(
testAccountInvalidation.preferredEmailWithoutExternalId().get()));
}
diff --git a/java/com/google/gerrit/common/BUILD b/java/com/google/gerrit/common/BUILD
index 8f85311..ff43bbd 100644
--- a/java/com/google/gerrit/common/BUILD
+++ b/java/com/google/gerrit/common/BUILD
@@ -3,6 +3,7 @@
ANNOTATIONS = [
"Nullable.java",
"UsedAt.java",
+ "ConvertibleToProto.java",
]
java_library(
diff --git a/java/com/google/gerrit/common/ConvertibleToProto.java b/java/com/google/gerrit/common/ConvertibleToProto.java
new file mode 100644
index 0000000..65074d4
--- /dev/null
+++ b/java/com/google/gerrit/common/ConvertibleToProto.java
@@ -0,0 +1,12 @@
+package com.google.gerrit.common;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/** An annotation used to mark Java entities that have equivalent proto representations. */
+@Retention(RUNTIME)
+@Target({TYPE})
+public @interface ConvertibleToProto {}
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index efbac97..4b7694a 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -22,6 +22,7 @@
import com.google.common.base.MoreObjects;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import java.time.Instant;
@@ -58,6 +59,7 @@
/** Key local to Gerrit to identify a user. */
@AutoValue
+ @ConvertibleToProto
public abstract static class Id implements Comparable<Id> {
/** Parse an Account.Id out of a string representation. */
public static Optional<Id> tryParse(String str) {
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 3ad7e03..51585f3 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -18,6 +18,7 @@
import com.google.auto.value.AutoValue;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gson.Gson;
@@ -104,6 +105,7 @@
/** The numeric change ID */
@AutoValue
+ @ConvertibleToProto
public abstract static class Id {
/**
* Parse a Change.Id out of a string representation.
@@ -271,6 +273,7 @@
* "Ixxxxxx...", and is stored in the Change-Id footer of a commit.
*/
@AutoValue
+ @ConvertibleToProto
public abstract static class Key {
// TODO(dborowitz): This hardly seems worth it: why would someone pass a URL-encoded change key?
// Ideally the standard key() factory method would enforce the format and throw IAE.
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index dea070f..c8fc7d2 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -15,6 +15,7 @@
package com.google.gerrit.entities;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import java.time.Instant;
import java.util.Objects;
@@ -34,6 +35,7 @@
}
@AutoValue
+ @ConvertibleToProto
public abstract static class Key {
public abstract Change.Id changeId();
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 1e48f11..325bd6c 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -14,6 +14,7 @@
package com.google.gerrit.entities;
+import com.google.gerrit.common.ConvertibleToProto;
import java.time.Instant;
import java.util.Objects;
@@ -26,6 +27,7 @@
*
* <p>Consider updating {@link #getApproximateSize()} when adding/changing fields.
*/
+@ConvertibleToProto
public class HumanComment extends Comment {
public boolean unresolved;
diff --git a/java/com/google/gerrit/entities/LabelId.java b/java/com/google/gerrit/entities/LabelId.java
index 2426818..e3b3024 100644
--- a/java/com/google/gerrit/entities/LabelId.java
+++ b/java/com/google/gerrit/entities/LabelId.java
@@ -15,8 +15,10 @@
package com.google.gerrit.entities;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.ConvertibleToProto;
@AutoValue
+@ConvertibleToProto
public abstract class LabelId {
public static final String LEGACY_SUBMIT_NAME = "SUBM";
public static final String CODE_REVIEW = "Code-Review";
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
index d3123c4..42baebc 100644
--- a/java/com/google/gerrit/entities/NotifyConfig.java
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -19,6 +19,7 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import java.util.EnumSet;
import java.util.Set;
@@ -31,6 +32,7 @@
BCC
}
+ @ConvertibleToProto
public enum NotifyType {
// sort by name, except 'ALL' which should stay last
ABANDONED_CHANGES,
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index e8759fa..6f71874 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -23,6 +23,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import java.time.Instant;
import java.util.List;
@@ -31,6 +32,7 @@
/** A single revision of a {@link Change}. */
@AutoValue
+@ConvertibleToProto
public abstract class PatchSet {
/** Is the reference name a change reference? */
public static boolean isChangeRef(String name) {
@@ -67,6 +69,7 @@
}
@AutoValue
+ @ConvertibleToProto
public abstract static class Id implements Comparable<Id> {
/** Parse a PatchSet.Id out of a string representation. */
public static Id parse(String str) {
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index 608cf0d..f78167b 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -16,6 +16,7 @@
import com.google.auto.value.AutoValue;
import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.ConvertibleToProto;
import java.time.Instant;
import java.util.Optional;
@@ -27,6 +28,7 @@
}
@AutoValue
+ @ConvertibleToProto
public abstract static class Key {
public abstract PatchSet.Id patchSetId();
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 9c2866c..7b02597 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -20,6 +20,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.extensions.client.ProjectState;
@@ -57,6 +58,7 @@
* <p>This class is immutable and thread safe.
*/
@Immutable
+ @ConvertibleToProto
public static class NameKey implements Serializable, Comparable<NameKey> {
private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
index 1e846fb..6106090 100644
--- a/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/AccountIdProtoConverter.java
@@ -20,7 +20,7 @@
import com.google.protobuf.Parser;
@Immutable
-public enum AccountIdProtoConverter implements ProtoConverter<Entities.Account_Id, Account.Id> {
+public enum AccountIdProtoConverter implements SafeProtoConverter<Entities.Account_Id, Account.Id> {
INSTANCE;
@Override
@@ -37,4 +37,14 @@
public Parser<Entities.Account_Id> getParser() {
return Entities.Account_Id.parser();
}
+
+ @Override
+ public Class<Entities.Account_Id> getProtoClass() {
+ return Entities.Account_Id.class;
+ }
+
+ @Override
+ public Class<Account.Id> getEntityClass() {
+ return Account.Id.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
index fc862b0..0634671 100644
--- a/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverter.java
@@ -36,6 +36,9 @@
if (applyPatchInput.patch != null) {
builder.setPatch(applyPatchInput.patch);
}
+ if (applyPatchInput.allowConflicts != null) {
+ builder.setAllowConflicts(applyPatchInput.allowConflicts);
+ }
return builder.build();
}
@@ -45,6 +48,9 @@
if (proto.hasPatch()) {
applyPatchInput.patch = proto.getPatch();
}
+ if (proto.hasAllowConflicts()) {
+ applyPatchInput.allowConflicts = proto.getAllowConflicts();
+ }
return applyPatchInput;
}
diff --git a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
index 0d4ec70..909b4d3 100644
--- a/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeIdProtoConverter.java
@@ -17,10 +17,11 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Id;
import com.google.protobuf.Parser;
@Immutable
-public enum ChangeIdProtoConverter implements ProtoConverter<Entities.Change_Id, Change.Id> {
+public enum ChangeIdProtoConverter implements SafeProtoConverter<Entities.Change_Id, Change.Id> {
INSTANCE;
@Override
@@ -37,4 +38,14 @@
public Parser<Entities.Change_Id> getParser() {
return Entities.Change_Id.parser();
}
+
+ @Override
+ public Class<Change_Id> getProtoClass() {
+ return Change_Id.class;
+ }
+
+ @Override
+ public Class<Change.Id> getEntityClass() {
+ return Change.Id.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
index f3ccdfa..0620c70 100644
--- a/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeKeyProtoConverter.java
@@ -17,10 +17,11 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Change_Key;
import com.google.protobuf.Parser;
@Immutable
-public enum ChangeKeyProtoConverter implements ProtoConverter<Entities.Change_Key, Change.Key> {
+public enum ChangeKeyProtoConverter implements SafeProtoConverter<Entities.Change_Key, Change.Key> {
INSTANCE;
@Override
@@ -37,4 +38,14 @@
public Parser<Entities.Change_Key> getParser() {
return Entities.Change_Key.parser();
}
+
+ @Override
+ public Class<Change_Key> getProtoClass() {
+ return Change_Key.class;
+ }
+
+ @Override
+ public Class<Change.Key> getEntityClass() {
+ return Change.Key.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
index 3e93c5a..a76ab98 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageKeyProtoConverter.java
@@ -18,11 +18,12 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.ChangeMessage_Key;
import com.google.protobuf.Parser;
@Immutable
public enum ChangeMessageKeyProtoConverter
- implements ProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
+ implements SafeProtoConverter<Entities.ChangeMessage_Key, ChangeMessage.Key> {
INSTANCE;
private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -45,4 +46,14 @@
public Parser<Entities.ChangeMessage_Key> getParser() {
return Entities.ChangeMessage_Key.parser();
}
+
+ @Override
+ public Class<ChangeMessage_Key> getProtoClass() {
+ return ChangeMessage_Key.class;
+ }
+
+ @Override
+ public Class<ChangeMessage.Key> getEntityClass() {
+ return ChangeMessage.Key.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
index 6e8c907..316a042 100644
--- a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -35,7 +35,7 @@
*/
@Immutable
public enum HumanCommentProtoConverter
- implements ProtoConverter<Entities.HumanComment, HumanComment> {
+ implements SafeProtoConverter<Entities.HumanComment, HumanComment> {
INSTANCE;
private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter =
@@ -142,4 +142,14 @@
public Parser<Entities.HumanComment> getParser() {
return Entities.HumanComment.parser();
}
+
+ @Override
+ public Class<Entities.HumanComment> getProtoClass() {
+ return Entities.HumanComment.class;
+ }
+
+ @Override
+ public Class<HumanComment> getEntityClass() {
+ return HumanComment.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
index a1894ac..e6e1be7f 100644
--- a/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/LabelIdProtoConverter.java
@@ -20,7 +20,7 @@
import com.google.protobuf.Parser;
@Immutable
-public enum LabelIdProtoConverter implements ProtoConverter<Entities.LabelId, LabelId> {
+public enum LabelIdProtoConverter implements SafeProtoConverter<Entities.LabelId, LabelId> {
INSTANCE;
@Override
@@ -37,4 +37,14 @@
public Parser<Entities.LabelId> getParser() {
return Entities.LabelId.parser();
}
+
+ @Override
+ public Class<Entities.LabelId> getProtoClass() {
+ return Entities.LabelId.class;
+ }
+
+ @Override
+ public Class<LabelId> getEntityClass() {
+ return LabelId.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
index 201dd78..fc963df 100644
--- a/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/NotifyInfoProtoConverter.java
@@ -26,7 +26,8 @@
* com.google.gerrit.proto.Entities.NotifyInfo}.
*/
@Immutable
-public enum NotifyInfoProtoConverter implements ProtoConverter<Entities.NotifyInfo, NotifyInfo> {
+public enum NotifyInfoProtoConverter
+ implements SafeProtoConverter<Entities.NotifyInfo, NotifyInfo> {
INSTANCE;
@Override
@@ -47,4 +48,14 @@
public Parser<Entities.NotifyInfo> getParser() {
return Entities.NotifyInfo.parser();
}
+
+ @Override
+ public Class<Entities.NotifyInfo> getProtoClass() {
+ return Entities.NotifyInfo.class;
+ }
+
+ @Override
+ public Class<NotifyInfo> getEntityClass() {
+ return NotifyInfo.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
index c7d1714..3ea14e6 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalKeyProtoConverter.java
@@ -20,11 +20,12 @@
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSetApproval_Key;
import com.google.protobuf.Parser;
@Immutable
public enum PatchSetApprovalKeyProtoConverter
- implements ProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
+ implements SafeProtoConverter<Entities.PatchSetApproval_Key, PatchSetApproval.Key> {
INSTANCE;
private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -55,4 +56,14 @@
public Parser<Entities.PatchSetApproval_Key> getParser() {
return Entities.PatchSetApproval_Key.parser();
}
+
+ @Override
+ public Class<PatchSetApproval_Key> getProtoClass() {
+ return PatchSetApproval_Key.class;
+ }
+
+ @Override
+ public Class<PatchSetApproval.Key> getEntityClass() {
+ return PatchSetApproval.Key.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
index 60c13f1..f6671cf 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetIdProtoConverter.java
@@ -18,10 +18,12 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.PatchSet_Id;
import com.google.protobuf.Parser;
@Immutable
-public enum PatchSetIdProtoConverter implements ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
+public enum PatchSetIdProtoConverter
+ implements SafeProtoConverter<Entities.PatchSet_Id, PatchSet.Id> {
INSTANCE;
private final ProtoConverter<Entities.Change_Id, Change.Id> changeIdConverter =
@@ -44,4 +46,14 @@
public Parser<Entities.PatchSet_Id> getParser() {
return Entities.PatchSet_Id.parser();
}
+
+ @Override
+ public Class<PatchSet_Id> getProtoClass() {
+ return PatchSet_Id.class;
+ }
+
+ @Override
+ public Class<PatchSet.Id> getEntityClass() {
+ return PatchSet.Id.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 196deca..22985d9 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -24,7 +24,7 @@
import org.eclipse.jgit.lib.ObjectId;
@Immutable
-public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet, PatchSet> {
+public enum PatchSetProtoConverter implements SafeProtoConverter<Entities.PatchSet, PatchSet> {
INSTANCE;
private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
@@ -103,4 +103,14 @@
public Parser<Entities.PatchSet> getParser() {
return Entities.PatchSet.parser();
}
+
+ @Override
+ public Class<Entities.PatchSet> getProtoClass() {
+ return Entities.PatchSet.class;
+ }
+
+ @Override
+ public Class<PatchSet> getEntityClass() {
+ return PatchSet.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
index 6bb0f79..320b8fc 100644
--- a/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ProjectNameKeyProtoConverter.java
@@ -16,12 +16,14 @@
import com.google.errorprone.annotations.Immutable;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
import com.google.gerrit.proto.Entities;
+import com.google.gerrit.proto.Entities.Project_NameKey;
import com.google.protobuf.Parser;
@Immutable
public enum ProjectNameKeyProtoConverter
- implements ProtoConverter<Entities.Project_NameKey, Project.NameKey> {
+ implements SafeProtoConverter<Entities.Project_NameKey, Project.NameKey> {
INSTANCE;
@Override
@@ -38,4 +40,14 @@
public Parser<Entities.Project_NameKey> getParser() {
return Entities.Project_NameKey.parser();
}
+
+ @Override
+ public Class<Project_NameKey> getProtoClass() {
+ return Project_NameKey.class;
+ }
+
+ @Override
+ public Class<NameKey> getEntityClass() {
+ return NameKey.class;
+ }
}
diff --git a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
new file mode 100644
index 0000000..f4a66a0
--- /dev/null
+++ b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
@@ -0,0 +1,29 @@
+package com.google.gerrit.entities.converter;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.protobuf.Message;
+
+/**
+ * An extension to {@link ProtoConverter} that enforces the Entity class and the Proto class to stay
+ * in sync. The enforcement is done by {@link SafeProtoConverterTest}.
+ *
+ * <p>Requirements:
+ *
+ * <ul>
+ * <li>Implementing classes must be enums with a single value. Please prefer descriptive enum and
+ * instance names, such as {@code MyTypeConverter::MY_TYPE_CONVERTER}.
+ * <li>The Java Entity class must be annotated with {@link ConvertibleToProto}.
+ * </ul>
+ *
+ * <p>All safe converters are tested using {@link SafeProtoConverterTest}. Therefore, unless your
+ * Entity class has a {@code defaults()} method, or other methods besides simple getters and
+ * setters, there is no need to explicitly test your safe converter.
+ */
+@Immutable
+public interface SafeProtoConverter<P extends Message, C> extends ProtoConverter<P, C> {
+
+ Class<P> getProtoClass();
+
+ Class<C> getEntityClass();
+}
diff --git a/java/com/google/gerrit/extensions/api/access/GerritPermission.java b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
index 02afbdc..cd80a09 100644
--- a/java/com/google/gerrit/extensions/api/access/GerritPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/GerritPermission.java
@@ -27,6 +27,8 @@
*/
String describeForException();
+ String permissionName();
+
static String describeEnumValue(Enum<?> value) {
return value.name().toLowerCase(Locale.US).replace('_', ' ');
}
diff --git a/java/com/google/gerrit/extensions/api/access/PluginPermission.java b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
index 1dc5cb6..f536b2c 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginPermission.java
@@ -52,6 +52,11 @@
}
@Override
+ public String permissionName() {
+ return pluginName + "~" + capability;
+ }
+
+ @Override
public int hashCode() {
return Objects.hash(pluginName, capability);
}
diff --git a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
index b3680ea..8fa1e55 100644
--- a/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
+++ b/java/com/google/gerrit/extensions/api/access/PluginProjectPermission.java
@@ -54,6 +54,11 @@
}
@Override
+ public String permissionName() {
+ return pluginName + "~" + permission;
+ }
+
+ @Override
public int hashCode() {
return Objects.hash(pluginName, permission);
}
diff --git a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
index 8273d84..4ad29df 100644
--- a/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
+++ b/java/com/google/gerrit/extensions/api/access/ProjectAccessInfo.java
@@ -31,6 +31,7 @@
public Boolean canAdd;
public Boolean canAddTags;
public Boolean configVisible;
+ public Boolean requireChangeForConfigUpdate;
public Map<String, GroupInfo> groups;
public List<WebLinkInfo> configWebLinks;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
index 493329c..b9707e9 100644
--- a/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -14,6 +14,8 @@
package com.google.gerrit.extensions.api.changes;
+import com.google.gerrit.common.Nullable;
+
/** Information about a patch to apply. */
public class ApplyPatchInput {
/**
@@ -22,4 +24,10 @@
* <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
*/
public String patch;
+
+ /**
+ * If {@code true}, the operation will succeed if a conflict is detected. Conflict markers will be
+ * added to the conflicting files.
+ */
+ @Nullable public Boolean allowConflicts;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
index dd29635..21bf886 100644
--- a/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
+++ b/java/com/google/gerrit/extensions/api/changes/NotifyInfo.java
@@ -15,10 +15,12 @@
package com.google.gerrit.extensions.api.changes;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.List;
import java.util.Objects;
/** Detailed information about who should be notified about an update. */
+@ConvertibleToProto
public class NotifyInfo {
public List<String> accounts;
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 906fc4c..805e769 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -40,4 +40,5 @@
public ProjectState state;
public Map<String, Map<String, ConfigValue>> pluginConfigValues;
public Map<String, CommentLinkInput> commentLinks;
+ public String commitMessage;
}
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 0b1b6b0..58fd93a8 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -20,6 +20,7 @@
import com.google.gerrit.extensions.api.config.AccessCheckInfo;
import com.google.gerrit.extensions.api.config.AccessCheckInput;
import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.common.ListTagSortOption;
@@ -59,6 +60,9 @@
@CanIgnoreReturnValue
ConfigInfo config(ConfigInput in) throws RestApiException;
+ @CanIgnoreReturnValue
+ ChangeInfo configReview(ConfigInput in) throws RestApiException;
+
Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
throws RestApiException;
@@ -284,6 +288,25 @@
*/
void labels(BatchLabelInput input) throws RestApiException;
+ /** Same as {@link #labels(BatchLabelInput)}, but creates a change with required updates. */
+ @CanIgnoreReturnValue
+ ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException;
+
+ /**
+ * Adds, updates and deletes submit requirements definitions in a batch.
+ *
+ * @param input input that describes additions, updates and deletions of submit requirements
+ */
+ void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException;
+
+ /**
+ * Creates a change with required submit requirements updates.
+ *
+ * <p>See {@link #submitRequirements(BatchSubmitRequirementInput)} for details
+ */
+ @CanIgnoreReturnValue
+ ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input) throws RestApiException;
+
/**
* A default implementation which allows source compatibility when adding new methods to the
* interface.
@@ -345,6 +368,11 @@
}
@Override
+ public ChangeInfo configReview(ConfigInput in) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
throws RestApiException {
throw new NotImplementedException();
@@ -489,5 +517,21 @@
public void labels(BatchLabelInput input) throws RestApiException {
throw new NotImplementedException();
}
+
+ @Override
+ public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+ throws RestApiException {
+ throw new NotImplementedException();
+ }
}
}
diff --git a/java/com/google/gerrit/extensions/client/ChangeKind.java b/java/com/google/gerrit/extensions/client/ChangeKind.java
index 6240bba..6c6069d 100644
--- a/java/com/google/gerrit/extensions/client/ChangeKind.java
+++ b/java/com/google/gerrit/extensions/client/ChangeKind.java
@@ -22,6 +22,12 @@
/** Conflict-free merge between the new parent and the prior patch set. */
TRIVIAL_REBASE,
+ /**
+ * Conflict-free merge between the new parent and the prior patch set, accompanied with a change
+ * to commit message.
+ */
+ TRIVIAL_REBASE_WITH_MESSAGE_UPDATE,
+
/** Conflict-free change of first (left) parent of a merge commit. */
MERGE_FIRST_PARENT_UPDATE,
@@ -39,6 +45,8 @@
return true;
case TRIVIAL_REBASE:
return isTrivialRebase();
+ case TRIVIAL_REBASE_WITH_MESSAGE_UPDATE:
+ return isTrivialRebaseWithMessageUpdate();
case MERGE_FIRST_PARENT_UPDATE:
return isMergeFirstParentUpdate(isMerge);
case NO_CHANGE:
@@ -59,6 +67,14 @@
return this == NO_CHANGE || this == TRIVIAL_REBASE;
}
+ public boolean isTrivialRebaseWithMessageUpdate() {
+ // TRIVIAL_REBASE is more strict condition and hence matched as well
+ return this == NO_CHANGE
+ || this == NO_CODE_CHANGE
+ || this == TRIVIAL_REBASE
+ || this == TRIVIAL_REBASE_WITH_MESSAGE_UPDATE;
+ }
+
public boolean isMergeFirstParentUpdate(boolean isMerge) {
if (!isMerge) {
return false;
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index 109afd6..ad494cb 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -15,8 +15,10 @@
package com.google.gerrit.extensions.client;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.Objects;
+@ConvertibleToProto
public class DiffPreferencesInfo {
/** Default number of lines of context. */
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
index 0a3ec0a..5da211e 100644
--- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -15,9 +15,11 @@
package com.google.gerrit.extensions.client;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.Objects;
/* This class is stored in Git config file. */
+@ConvertibleToProto
public class EditPreferencesInfo {
public Integer tabSize;
public Integer lineLength;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ed9793..44fc4d5 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -15,10 +15,12 @@
package com.google.gerrit.extensions.client;
import com.google.common.base.MoreObjects;
+import com.google.gerrit.common.ConvertibleToProto;
import java.util.List;
import java.util.Objects;
/** Preferences about a single user. */
+@ConvertibleToProto
public class GeneralPreferencesInfo {
/** Default number of items to display per page. */
@@ -143,6 +145,8 @@
public List<String> changeTable;
public Boolean allowBrowserNotifications;
public Boolean allowSuggestCodeWhileCommenting;
+ public Boolean allowAutocompletingComments;
+
/**
* The sidebar section that the user prefers to have open on the diff page, or "NONE" if all
* sidebars should be closed.
@@ -214,6 +218,9 @@
&& Objects.equals(this.my, other.my)
&& Objects.equals(this.changeTable, other.changeTable)
&& Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications)
+ && Objects.equals(
+ this.allowSuggestCodeWhileCommenting, other.allowSuggestCodeWhileCommenting)
+ && Objects.equals(this.allowAutocompletingComments, other.allowAutocompletingComments)
&& Objects.equals(this.diffPageSidebar, other.diffPageSidebar);
}
@@ -242,6 +249,8 @@
my,
changeTable,
allowBrowserNotifications,
+ allowSuggestCodeWhileCommenting,
+ allowAutocompletingComments,
diffPageSidebar);
}
@@ -270,6 +279,8 @@
.add("my", my)
.add("changeTable", changeTable)
.add("allowBrowserNotifications", allowBrowserNotifications)
+ .add("allowSuggestCodeWhileCommenting", allowSuggestCodeWhileCommenting)
+ .add("allowAutocompletingComments", allowAutocompletingComments)
.add("diffPageSidebar", diffPageSidebar)
.toString();
}
@@ -296,8 +307,9 @@
p.disableTokenHighlighting = false;
p.workInProgressByDefault = false;
p.allowBrowserNotifications = true;
- p.diffPageSidebar = "NONE";
p.allowSuggestCodeWhileCommenting = true;
+ p.allowAutocompletingComments = true;
+ p.diffPageSidebar = "NONE";
return p;
}
}
diff --git a/java/com/google/gerrit/extensions/common/AbstractBatchInput.java b/java/com/google/gerrit/extensions/common/AbstractBatchInput.java
new file mode 100644
index 0000000..b872b18
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/AbstractBatchInput.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.List;
+import java.util.Map;
+
+/** Input for the REST API that describes additions, updates and deletions items in a collection. */
+public abstract class AbstractBatchInput<T> {
+ public String commitMessage;
+ public List<String> delete;
+ public List<T> create;
+ public Map<String, T> update;
+}
diff --git a/java/com/google/gerrit/extensions/common/BatchLabelInput.java b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
index eb4c581..aa91314 100644
--- a/java/com/google/gerrit/extensions/common/BatchLabelInput.java
+++ b/java/com/google/gerrit/extensions/common/BatchLabelInput.java
@@ -14,13 +14,5 @@
package com.google.gerrit.extensions.common;
-import java.util.List;
-import java.util.Map;
-
/** Input for the REST API that describes additions, updates and deletions of label definitions. */
-public class BatchLabelInput {
- public String commitMessage;
- public List<String> delete;
- public List<LabelDefinitionInput> create;
- public Map<String, LabelDefinitionInput> update;
-}
+public class BatchLabelInput extends AbstractBatchInput<LabelDefinitionInput> {}
diff --git a/java/com/google/gerrit/extensions/restapi/ETagView.java b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
similarity index 64%
rename from java/com/google/gerrit/extensions/restapi/ETagView.java
rename to java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
index 9ac1706..8a5f30d 100644
--- a/java/com/google/gerrit/extensions/restapi/ETagView.java
+++ b/java/com/google/gerrit/extensions/common/BatchSubmitRequirementInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 The Android Open Source Project
+// Copyright (C) 2024 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.extensions.restapi;
+package com.google.gerrit.extensions.common;
-/** A view which may change, although the underlying resource did not change */
-public interface ETagView<R extends RestResource> extends RestReadView<R> {
- String getETag(R rsrc);
-}
+/**
+ * Input for the REST API that describes additions, updates and deletions of submit requirements.
+ */
+public class BatchSubmitRequirementInput extends AbstractBatchInput<SubmitRequirementInput> {}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 52127e4..1576e68 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -93,6 +93,7 @@
* <p>Only set if this change info is returned in response to a request that creates a new change
* or patch set and conflicts are allowed. In particular this field is only populated if the
* change info is returned by one of the following REST endpoints: {@link
+ * com.google.gerrit.server.restapi.change.ApplyPatch},{@link
* com.google.gerrit.server.restapi.change.CreateChange}, {@link
* com.google.gerrit.server.restapi.change.CreateMergePatchSet}, {@link
* com.google.gerrit.server.restapi.change.CherryPick}, {@link
diff --git a/java/com/google/gerrit/extensions/common/GerritInfo.java b/java/com/google/gerrit/extensions/common/GerritInfo.java
index 547e606..fd682c1 100644
--- a/java/com/google/gerrit/extensions/common/GerritInfo.java
+++ b/java/com/google/gerrit/extensions/common/GerritInfo.java
@@ -25,4 +25,5 @@
public String primaryWeblinkName;
public String instanceId;
public String defaultBranch;
+ public Boolean projectStatePredicateEnabled;
}
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java
index 4464af7..4cc22f9 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItem.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -14,9 +14,12 @@
package com.google.gerrit.extensions.registration;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.inject.Binder;
+import com.google.inject.BindingAnnotation;
import com.google.inject.Key;
import com.google.inject.Provider;
import com.google.inject.ProvisionException;
@@ -25,6 +28,9 @@
import com.google.inject.binder.LinkedBindingBuilder;
import com.google.inject.util.Providers;
import com.google.inject.util.Types;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
import java.util.concurrent.atomic.AtomicReference;
/**
@@ -36,6 +42,15 @@
* exception is thrown.
*/
public class DynamicItem<T> {
+
+ /** Annotate a DynamicItem to be final and being bound at most once. */
+ @Target({ElementType.TYPE})
+ @Retention(RUNTIME)
+ @BindingAnnotation
+ public @interface Final {
+ String implementedByPlugin() default "";
+ }
+
/**
* Declare a singleton {@code DynamicItem<T>} with a binder.
*
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
index 5b528cb..982ff98 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicTypes.java
@@ -14,12 +14,14 @@
package com.google.gerrit.extensions.registration;
+import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.inject.Binding;
import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Key;
+import com.google.inject.ProvisionException;
import com.google.inject.TypeLiteral;
import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
@@ -97,6 +99,26 @@
DynamicItem<Object> item = (DynamicItem<Object>) e.getValue();
for (Binding<Object> b : bindings(src, type)) {
+ Class<? super Object> rawType = type.getRawType();
+ DynamicItem.Final annotation = rawType.getAnnotation(DynamicItem.Final.class);
+ if (annotation != null) {
+ Object existingBinding = item.get();
+ if (existingBinding != null) {
+ throw new ProvisionException(
+ String.format(
+ "Attempting to bind a @DynamicItem.Final %s twice: it was already bound to %s and tried to bind again to %s",
+ rawType.getName(), existingBinding, b));
+ }
+
+ String implementedByPlugin = annotation.implementedByPlugin();
+ if (!Strings.isNullOrEmpty(implementedByPlugin)
+ && !implementedByPlugin.equals(pluginName)) {
+ throw new ProvisionException(
+ String.format(
+ "Attempting to bind a @DynamicItem.Final %s to unexpected plugin: it was supposed to be bound to %s plugin but tried bind to %s plugin",
+ rawType.getName(), implementedByPlugin, pluginName));
+ }
+ }
handles.add(item.set(b.getKey(), b.getProvider(), pluginName));
}
}
diff --git a/java/com/google/gerrit/extensions/restapi/RestResource.java b/java/com/google/gerrit/extensions/restapi/RestResource.java
index 3c8144a..30916bc 100644
--- a/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -29,9 +29,4 @@
/** Returns time for the Last-Modified header. HTTP truncates the header value to seconds. */
Timestamp getLastModified();
}
-
- /** A resource with an ETag. */
- public interface HasETag {
- String getETag();
- }
}
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 6951398..0844ca9 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -40,7 +40,6 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
-import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
@@ -65,7 +64,6 @@
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.CacheControl;
import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.ETagView;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.NeedsParams;
@@ -110,12 +108,10 @@
import com.google.gerrit.server.change.ChangeFinder;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.group.GroupAuditService;
-import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.PerformanceLogContext;
import com.google.gerrit.server.logging.PerformanceLogger;
import com.google.gerrit.server.logging.RequestId;
import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -497,7 +493,7 @@
checkRequiresCapability(viewData);
}
- if (notModified(req, traceContext, viewData, rsrc)) {
+ if (notModified(req, rsrc)) {
logger.atFinest().log("REST call succeeded: %d", SC_NOT_MODIFIED);
res.sendError(SC_NOT_MODIFIED);
return;
@@ -607,7 +603,7 @@
statusCode = response.statusCode();
response.headers().forEach((k, v) -> res.setHeader(k, v));
- configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+ configureCaching(req, res, rsrc, response.caching());
res.setStatus(statusCode);
logger.atFinest().log("REST call succeeded: %d", statusCode);
}
@@ -800,47 +796,6 @@
globals.webSession.get().resetRefUpdatedEvents();
}
- private String getEtagWithRetry(
- HttpServletRequest req,
- TraceContext traceContext,
- ViewData viewData,
- ETagView<RestResource> view,
- RestResource rsrc) {
- try (TraceTimer ignored =
- TraceContext.newTimer(
- "RestApiServlet#getEtagWithRetry:view",
- Metadata.builder().restViewName(getViewName(viewData)).build())) {
- return invokeRestEndpointWithRetry(
- req,
- traceContext,
- getViewName(viewData) + "#etag",
- ActionType.REST_READ_REQUEST,
- () -> view.getETag(rsrc));
- } catch (Exception e) {
- Throwables.throwIfUnchecked(e);
- throw new IllegalStateException("Failed to get ETag for view", e);
- }
- }
-
- @Nullable
- private String getEtagWithRetry(
- HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
- try (TraceTimer ignored =
- TraceContext.newTimer(
- "RestApiServlet#getEtagWithRetry:resource",
- Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
- return invokeRestEndpointWithRetry(
- req,
- traceContext,
- rsrc.getClass().getSimpleName() + "#etag",
- ActionType.REST_READ_REQUEST,
- () -> rsrc.getETag());
- } catch (Exception e) {
- Throwables.throwIfUnchecked(e);
- throw new IllegalStateException("Failed to get ETag for resource", e);
- }
- }
-
private RestResource parseResourceWithRetry(
HttpServletRequest req,
TraceContext traceContext,
@@ -1012,30 +967,11 @@
return defaultMessage;
}
- private boolean notModified(
- HttpServletRequest req, TraceContext traceContext, ViewData viewData, RestResource rsrc) {
+ private boolean notModified(HttpServletRequest req, RestResource rsrc) {
if (!isRead(req)) {
return false;
}
- RestView<RestResource> view = viewData.view;
- if (view instanceof ETagView) {
- String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
- if (have != null) {
- String eTag =
- getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
- return have.equals(eTag);
- }
- }
-
- if (rsrc instanceof RestResource.HasETag) {
- String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
- if (!Strings.isNullOrEmpty(have)) {
- String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
- return have.equals(eTag);
- }
- }
-
if (rsrc instanceof RestResource.HasLastModified) {
Timestamp m = ((RestResource.HasLastModified) rsrc).getLastModified();
long d = req.getDateHeader(HttpHeaders.IF_MODIFIED_SINCE);
@@ -1047,12 +983,7 @@
}
private <R extends RestResource> void configureCaching(
- HttpServletRequest req,
- HttpServletResponse res,
- TraceContext traceContext,
- R rsrc,
- ViewData viewData,
- CacheControl cacheControl) {
+ HttpServletRequest req, HttpServletResponse res, R rsrc, CacheControl cacheControl) {
setCacheHeaders(req, res, cacheControl);
if (isRead(req)) {
switch (cacheControl.getType()) {
@@ -1060,10 +991,10 @@
default:
break;
case PRIVATE:
- addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+ addResourceStateHeaders(res, rsrc);
break;
case PUBLIC:
- addResourceStateHeaders(req, res, traceContext, viewData, rsrc);
+ addResourceStateHeaders(res, rsrc);
break;
}
}
@@ -1095,23 +1026,7 @@
}
}
- private void addResourceStateHeaders(
- HttpServletRequest req,
- HttpServletResponse res,
- TraceContext traceContext,
- ViewData viewData,
- RestResource rsrc) {
- RestView<RestResource> view = viewData.view;
- if (view instanceof ETagView) {
- String eTag =
- getEtagWithRetry(req, traceContext, viewData, (ETagView<RestResource>) view, rsrc);
- res.setHeader(HttpHeaders.ETAG, eTag);
- } else if (rsrc instanceof RestResource.HasETag) {
- String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
- if (!Strings.isNullOrEmpty(eTag)) {
- res.setHeader(HttpHeaders.ETAG, eTag);
- }
- }
+ private void addResourceStateHeaders(HttpServletResponse res, RestResource rsrc) {
if (rsrc instanceof RestResource.HasLastModified) {
res.setDateHeader(
HttpHeaders.LAST_MODIFIED,
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index d59595a..2d91515 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -72,11 +72,11 @@
* @param unit time unit of the value
*/
public final void record(long value, TimeUnit unit) {
- long durationMs = unit.toMillis(value);
+ long durationNanos = unit.toNanos(value);
LoggingContext.getInstance()
- .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
- logger.atFinest().log("%s took %dms", name, durationMs);
+ .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos));
+ logger.atFinest().log("%s took %.2f ms", name, durationNanos / 1000000.0);
doRecord(value, unit);
RequestStateContext.abortIfCancelled();
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index eefd462..cdf457c 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -83,7 +83,7 @@
* @param unit time unit of the value
*/
public final void record(F1 fieldValue, long value, TimeUnit unit) {
- long durationMs = unit.toMillis(value);
+ long durationNanos = unit.toNanos(value);
Metadata.Builder metadataBuilder = Metadata.builder();
field.metadataMapper().accept(metadataBuilder, fieldValue);
@@ -91,8 +91,10 @@
if (!suppressLogging) {
LoggingContext.getInstance()
- .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
- logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
+ .addPerformanceLogRecord(
+ () -> PerformanceLogRecord.create(name, durationNanos, metadata));
+ logger.atFinest().log(
+ "%s (%s = %s) took %.2f ms", name, field.name(), fieldValue, durationNanos / 1000000.0);
}
doRecord(fieldValue, value, unit);
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index 09878ad..9f08711 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -90,7 +90,7 @@
* @param unit time unit of the value
*/
public final void record(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit) {
- long durationMs = unit.toMillis(value);
+ long durationNanos = unit.toNanos(value);
Metadata.Builder metadataBuilder = Metadata.builder();
field1.metadataMapper().accept(metadataBuilder, fieldValue1);
@@ -99,10 +99,11 @@
if (!suppressLogging) {
LoggingContext.getInstance()
- .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+ .addPerformanceLogRecord(
+ () -> PerformanceLogRecord.create(name, durationNanos, metadata));
logger.atFinest().log(
- "%s (%s = %s, %s = %s) took %dms",
- name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
+ "%s (%s = %s, %s = %s) took %.2f ms",
+ name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationNanos / 1000000.0);
}
doRecord(fieldValue1, fieldValue2, value, unit);
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 5d5c424..2a01d5d 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -98,7 +98,7 @@
*/
public final void record(
F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit) {
- long durationMs = unit.toMillis(value);
+ long durationNanos = unit.toNanos(value);
Metadata.Builder metadataBuilder = Metadata.builder();
field1.metadataMapper().accept(metadataBuilder, fieldValue1);
@@ -108,9 +108,10 @@
if (!suppressLogging) {
LoggingContext.getInstance()
- .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+ .addPerformanceLogRecord(
+ () -> PerformanceLogRecord.create(name, durationNanos, metadata));
logger.atFinest().log(
- "%s (%s = %s, %s = %s, %s = %s) took %dms",
+ "%s (%s = %s, %s = %s, %s = %s) took %.2f ms",
name,
field1.name(),
fieldValue1,
@@ -118,7 +119,7 @@
fieldValue2,
field3.name(),
fieldValue3,
- durationMs);
+ durationNanos / 1000000.0);
}
doRecord(fieldValue1, fieldValue2, fieldValue3, value, unit);
diff --git a/java/com/google/gerrit/pgm/Passwd.java b/java/com/google/gerrit/pgm/Passwd.java
index 10ed07d..dbb5318 100644
--- a/java/com/google/gerrit/pgm/Passwd.java
+++ b/java/com/google/gerrit/pgm/Passwd.java
@@ -36,33 +36,40 @@
public class Passwd extends SiteProgram {
private String section;
+ private String subsection;
private String key;
@Argument(
- metaVar = "SECTION.KEY",
+ metaVar = "SECTION.[SUBSECTION.]KEY",
index = 0,
required = true,
- usage = "Section and key separated by a dot of the password to set")
- private String sectionAndKey;
+ usage =
+ "Section, subsection and key separated by a dot of the password to set. Subsection is optional")
+ private String sectionSubsectionAndKey;
@Argument(metaVar = "PASSWORD", index = 1, required = false, usage = "Password to set")
private String password;
private void init() {
- List<String> varParts = Splitter.on('.').splitToList(sectionAndKey);
- if (varParts.size() != 2) {
+ List<String> varParts = Splitter.on('.').splitToList(sectionSubsectionAndKey);
+ if (varParts.size() != 2 && varParts.size() != 3) {
throw new IllegalArgumentException(
- "Invalid name '" + sectionAndKey + "': expected section.key format");
+ "Invalid name '"
+ + sectionSubsectionAndKey
+ + "': expected section.[subsection.]key format");
}
section = varParts.get(0);
- key = varParts.get(1);
+ if (varParts.size() == 3) {
+ subsection = varParts.get(1);
+ }
+ key = varParts.get(varParts.size() - 1);
}
@Override
public int run() throws Exception {
init();
SetPasswd setPasswd = getSysInjector().getInstance(SetPasswd.class);
- setPasswd.run(section, key, password);
+ setPasswd.run(section, subsection, key, password);
return 0;
}
diff --git a/java/com/google/gerrit/pgm/SetPasswd.java b/java/com/google/gerrit/pgm/SetPasswd.java
index c3f6a7b..edf3870 100644
--- a/java/com/google/gerrit/pgm/SetPasswd.java
+++ b/java/com/google/gerrit/pgm/SetPasswd.java
@@ -14,6 +14,7 @@
package com.google.gerrit.pgm;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.pgm.init.api.ConsoleUI;
import com.google.gerrit.pgm.init.api.Section;
import com.google.inject.Inject;
@@ -29,8 +30,9 @@
this.sections = sections;
}
- public void run(String section, String key, String password) throws Exception {
- Section passwordSection = sections.get(section, null);
+ public void run(String section, @Nullable String subsection, String key, String password)
+ throws Exception {
+ Section passwordSection = sections.get(section, subsection);
if (ui.isBatch()) {
passwordSection.setSecure(key, password);
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
new file mode 100644
index 0000000..3d21fed
--- /dev/null
+++ b/java/com/google/gerrit/server/PerformanceMetrics.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/** Performance logger that records the execution times as a metric. */
+@Singleton
+public class PerformanceMetrics implements PerformanceLogger {
+ private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
+ private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
+
+ private final ImmutableList<String> tracedOperations;
+ public final Timer3<String, String, String> operationsLatency;
+ public final Counter3<String, String, String> operationsCounter;
+
+ @Inject
+ PerformanceMetrics(@GerritServerConfig Config cfg, MetricMaker metricMaker) {
+ this.tracedOperations =
+ ImmutableList.copyOf(cfg.getStringList("tracing", "metric", "operation"));
+
+ Field<String> operationNameField =
+ Field.ofString(
+ "operation_name",
+ (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
+ .description("The operation that was performed.")
+ .build();
+ Field<String> requestField =
+ Field.ofString("request", (metadataBuilder, fieldValue) -> {})
+ .description(
+ "The request for which the operation was performed"
+ + " (format = '<request-type> <redacted-request-uri>').")
+ .build();
+ Field<String> pluginField =
+ Field.ofString(
+ "plugin", (metadataBuilder, fieldValue) -> metadataBuilder.pluginName(fieldValue))
+ .description("The name of the plugin that performed the operation.")
+ .build();
+
+ this.operationsLatency =
+ metricMaker
+ .newTimer(
+ OPERATION_LATENCY_METRIC_NAME,
+ new Description("Latency of performing operations")
+ .setCumulative()
+ .setUnit(Description.Units.MILLISECONDS),
+ operationNameField,
+ requestField,
+ pluginField)
+ .suppressLogging();
+ this.operationsCounter =
+ metricMaker.newCounter(
+ OPERATION_COUNT_METRIC_NAME,
+ new Description("Number of performed operations").setRate(),
+ operationNameField,
+ requestField,
+ pluginField);
+ }
+
+ @Override
+ public void logNanos(String operation, long durationNanos, Instant endTime) {
+ logNanos(operation, durationNanos, endTime, /* metadata= */ null);
+ }
+
+ @Override
+ public void logNanos(
+ String operation, long durationNanos, Instant endTime, @Nullable Metadata metadata) {
+ if (!tracedOperations.contains(operation)) {
+ return;
+ }
+
+ String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
+ String pluginTag = TraceContext.getPluginTag().orElse("");
+ operationsLatency.record(operation, requestTag, pluginTag, durationNanos, TimeUnit.NANOSECONDS);
+ operationsCounter.increment(operation, requestTag, pluginTag);
+ }
+}
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index f074522..cac515c 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -10,7 +10,7 @@
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
-// limitations under the Licens
+// limitations under the License
package com.google.gerrit.server.account;
@@ -410,7 +410,8 @@
* @return the builder
*/
@CanIgnoreReturnValue
- public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+ public Builder updateProjectWatches(
+ Map<ProjectWatchKey, ? extends Set<NotifyType>> projectWatches) {
updatedProjectWatchesBuilder().putAll(projectWatches);
return this;
}
@@ -615,7 +616,8 @@
}
@Override
- public Builder updateProjectWatches(Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+ public Builder updateProjectWatches(
+ Map<ProjectWatchKey, ? extends Set<NotifyType>> projectWatches) {
delegate.updateProjectWatches(projectWatches);
return this;
}
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index a037046..93b4cd3 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -98,7 +98,9 @@
* @return limit according to {@link GlobalCapability#QUERY_LIMIT}.
*/
public int getQueryLimit() {
- return getRange(GlobalCapability.QUERY_LIMIT).getMax();
+ return user.isInternalUser()
+ ? Integer.MAX_VALUE
+ : getRange(GlobalCapability.QUERY_LIMIT).getMax();
}
/** Returns true if the user has a permission rule specifying the range. */
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index c260401..42687987 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -21,6 +21,9 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
@@ -90,7 +93,9 @@
}
public void fill() throws PermissionBackendException {
- directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
+ try (TraceTimer timer = TraceContext.newTimer("Fill accounts", Metadata.empty())) {
+ directory.fillAccountInfo(Iterables.concat(created.values(), provided), options);
+ }
}
public void fill(Collection<? extends AccountInfo> infos) throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 61012d7..51948f9 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -38,6 +38,7 @@
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.Sequences;
import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureStatelessDelta;
import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdFactory;
@@ -60,7 +61,6 @@
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.function.Consumer;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
@@ -269,7 +269,7 @@
private void update(AuthRequest who, ExternalId extId)
throws IOException, ConfigInvalidException, AccountException {
IdentifiedUser user = userFactory.create(extId.accountId());
- List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
+ List<ConfigureStatelessDelta> accountUpdates = new ArrayList<>();
// If the email address was modified by the authentication provider,
// update our records to match the changed email.
@@ -311,7 +311,7 @@
.update(
"Update Account on Login",
user.getAccountId(),
- AccountsUpdate.joinConsumers(accountUpdates));
+ AccountsUpdate.joinDeltaConfigures(accountUpdates));
if (!updatedAccount.isPresent()) {
throw new StorageException("Account " + user.getAccountId() + " has been deleted");
}
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 5951a73..5ec97ff 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -20,20 +20,23 @@
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.externalids.ExternalIds;
import com.google.inject.BindingAnnotation;
import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.List;
import java.util.Optional;
-import java.util.function.Consumer;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.PersonIdent;
@@ -49,6 +52,7 @@
* <p>See the implementing classes for more information.
*/
public abstract class AccountsUpdate {
+ /** Loader for {@link AccountsUpdate}s. */
public interface AccountsUpdateLoader {
/**
* Creates an {@code AccountsUpdate} which uses the identity of the specified user as author for
@@ -87,24 +91,74 @@
public static class UpdateArguments {
public final String message;
public final Account.Id accountId;
- public final AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState;
+ public final ConfigureDeltaFromStateAndContext configureDelta;
public UpdateArguments(
- String message,
- Account.Id accountId,
- AccountsUpdate.ConfigureDeltaFromState configureDeltaFromState) {
+ String message, Account.Id accountId, ConfigureStatelessDelta configureDelta) {
+ this(message, accountId, withContext(configureDelta));
+ }
+
+ public UpdateArguments(
+ String message, Account.Id accountId, ConfigureDeltaFromState configureDelta) {
+ this(message, accountId, withContext(configureDelta));
+ }
+
+ public UpdateArguments(
+ String message, Account.Id accountId, ConfigureDeltaFromStateAndContext configureDelta) {
this.message = message;
this.accountId = accountId;
- this.configureDeltaFromState = configureDeltaFromState;
+ this.configureDelta = configureDelta;
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("message", message)
+ .add("accountId", accountId)
+ .toString();
}
}
/**
- * Account updates are commonly performed by evaluating the current account state and creating a
- * delta to be applied to it in a later step. This is done by implementing this interface.
+ * Storage readers/writers which are accessible through {@link ConfigureDeltaFromStateAndContext}.
*
- * <p>If the current account state is not needed, use a {@link Consumer} of {@link
- * com.google.gerrit.server.account.AccountDelta.Builder} instead.
+ * <p>If you need to perform extra reads/writes during the update, prefer using these, to avoid
+ * mishmash between different storage systems where multiple ones are supported. If you need an
+ * accessor which is not yet here, please do add it.
+ */
+ public interface InUpdateStorageAccessors {
+ ExternalIds externalIdsReader();
+ }
+
+ /**
+ * The most basic interface for updating the account delta, providing no state nor context.
+ *
+ * <p>Account updates that do not need to know the current account state, nor read/write any extra
+ * data during the update, should use this interface.
+ *
+ * <p>If the above capabilities are needed, use {@link ConfigureDeltaFromState} or {@link
+ * ConfigureDeltaFromStateAndContext} instead.
+ */
+ @FunctionalInterface
+ public interface ConfigureStatelessDelta {
+ /**
+ * Configures an {@link com.google.gerrit.server.account.AccountDelta.Builder} with changes to
+ * the account.
+ *
+ * @param delta the changes to be applied
+ */
+ void configure(AccountDelta.Builder delta);
+ }
+
+ /**
+ * Interface for updating the account delta, providing the current state.
+ *
+ * <p>Account updates are commonly performed by evaluating the current account state and creating
+ * a delta to be applied to it in a later step. This is done by implementing this interface.
+ *
+ * <p>If the current account state is not needed, use {@link ConfigureStatelessDelta} instead.
+ * Alternatively, if you need to perform extra storage reads/writes during the update, use {@link
+ * ConfigureDeltaFromStateAndContext}.
*/
@FunctionalInterface
public interface ConfigureDeltaFromState {
@@ -118,42 +172,68 @@
void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
}
- /** Returns an instance that runs all specified consumers. */
- public static ConfigureDeltaFromState joinConsumers(
- List<Consumer<AccountDelta.Builder>> consumers) {
- return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
+ /**
+ * Interface for updating the account delta, providing the current state and storage accessors.
+ *
+ * <p>Account updates which need to perform extra storage reads/writes during the update, should
+ * use this interface.
+ *
+ * <p>If storage accessors are not needed, use {@link ConfigureStatelessDelta} or {@link
+ * ConfigureDeltaFromState} instead.
+ */
+ @FunctionalInterface
+ public interface ConfigureDeltaFromStateAndContext {
+ /**
+ * Receives {@link InUpdateStorageAccessors} for reading/modifying data on the respective
+ * storage system, as well as the current {@link AccountState} (which is immutable). Configures
+ * an {@link com.google.gerrit.server.account.AccountDelta.Builder} with changes to the account.
+ *
+ * @param inUpdateStorageAccessors storage accessor which have the context of the update (e.g.,
+ * use the same storage system as the calling updater)
+ * @param accountState the state of the account that is being updated
+ * @param delta the changes to be applied
+ * @see InUpdateStorageAccessors
+ */
+ void configure(
+ InUpdateStorageAccessors inUpdateStorageAccessors,
+ AccountState accountState,
+ AccountDelta.Builder delta)
+ throws IOException;
}
- static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
- return (a, u) -> consumer.accept(u);
+ /** Returns an instance that runs all specified consumers. */
+ public static ConfigureStatelessDelta joinDeltaConfigures(
+ List<ConfigureStatelessDelta> deltaConfigures) {
+ return (update) -> deltaConfigures.forEach(c -> c.configure(update));
}
protected final PersonIdent committerIdent;
protected final PersonIdent authorIdent;
protected final Optional<IdentifiedUser> currentUser;
+ private final InUpdateStorageAccessors inUpdateStorageAccessors;
- protected AccountsUpdate(PersonIdent serverIdent, Optional<IdentifiedUser> user) {
+ @SuppressWarnings("Convert2Lambda")
+ protected AccountsUpdate(
+ PersonIdent serverIdent, Optional<IdentifiedUser> user, ExternalIds externalIdsReader) {
this.currentUser = user;
this.committerIdent = serverIdent;
this.authorIdent = createPersonIdent(serverIdent, user);
- }
-
- /**
- * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
- * instead, i.e. the update does not depend on the current account state (which, for insertion,
- * would only contain the account ID).
- */
- @CanIgnoreReturnValue
- public AccountState insert(
- String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
- throws IOException, ConfigInvalidException {
- return insert(message, accountId, AccountsUpdate.fromConsumer(init));
+ this.inUpdateStorageAccessors =
+ new InUpdateStorageAccessors() {
+ @Override
+ public ExternalIds externalIdsReader() {
+ return externalIdsReader;
+ }
+ };
}
/**
* Inserts a new account.
*
+ * <p>If the current account state is not needed, use {@link #insert(String, Account.Id,
+ * ConfigureStatelessDelta)} instead.
+ *
* @param message commit message for the account creation, must not be {@code null or empty}
* @param accountId ID of the new account
* @param init to populate the new account
@@ -162,19 +242,36 @@
* @throws IOException if creating the user branch fails due to an IO error
* @throws ConfigInvalidException if any of the account fields has an invalid value
*/
+ @CanIgnoreReturnValue
public abstract AccountState insert(
- String message, Account.Id accountId, ConfigureDeltaFromState init)
+ String message, Account.Id accountId, ConfigureDeltaFromStateAndContext init)
throws IOException, ConfigInvalidException;
/**
- * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
- * instead, i.e. the update does not depend on the current account state.
+ * Like {@link #insert(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+ * ConfigureDeltaFromState} instead. I.e. the update does not require any extra storage
+ * reads/writes, except for the current {@link AccountState}.
+ *
+ * <p>If the current account state is not needed as well, use {@link #insert(String, Account.Id,
+ * ConfigureStatelessDelta)} instead.
*/
@CanIgnoreReturnValue
- public Optional<AccountState> update(
- String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+ public final AccountState insert(
+ String message, Account.Id accountId, ConfigureDeltaFromState init)
throws IOException, ConfigInvalidException {
- return update(message, accountId, AccountsUpdate.fromConsumer(update));
+ return insert(message, accountId, withContext(init));
+ }
+
+ /**
+ * Like {@link #insert(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+ * ConfigureStatelessDelta} instead. I.e. the update does not depend on the current account state,
+ * nor requires any extra storage reads/writes.
+ */
+ @CanIgnoreReturnValue
+ public final AccountState insert(
+ String message, Account.Id accountId, ConfigureStatelessDelta init)
+ throws IOException, ConfigInvalidException {
+ return insert(message, accountId, withContext(init));
}
/**
@@ -182,10 +279,12 @@
*
* <p>Changing the registration date of an account is not supported.
*
+ * <p>If the current account state is not needed, use {@link #update(String, Account.Id,
+ * ConfigureStatelessDelta)} instead.
+ *
* @param message commit message for the account update, must not be {@code null or empty}
* @param accountId ID of the account
- * @param configureDeltaFromState deltaBuilder to update the account, only invoked if the account
- * exists
+ * @param configureDelta deltaBuilder to update the account, only invoked if the account exists
* @return the updated account, {@link Optional#empty} if the account doesn't exist
* @throws IOException if updating the user branch fails due to an IO error
* @throws LockFailureException if updating the user branch still fails due to concurrent updates
@@ -193,15 +292,41 @@
* @throws ConfigInvalidException if any of the account fields has an invalid value
*/
@CanIgnoreReturnValue
- public Optional<AccountState> update(
- String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
+ public final Optional<AccountState> update(
+ String message, Account.Id accountId, ConfigureDeltaFromStateAndContext configureDelta)
throws IOException, ConfigInvalidException {
- return updateBatch(
- ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+ return updateBatch(ImmutableList.of(new UpdateArguments(message, accountId, configureDelta)))
.get(0);
}
/**
+ * Like {@link #update(String, Account.Id, ConfigureDeltaFromStateAndContext)}, but using {@link
+ * ConfigureDeltaFromState} instead. I.e. the update does not require any extra storage
+ * reads/writes, except for the current {@link AccountState}.
+ *
+ * <p>If the current account state is not needed as well, use {@link #update(String, Account.Id,
+ * ConfigureStatelessDelta)} instead.
+ */
+ @CanIgnoreReturnValue
+ public final Optional<AccountState> update(
+ String message, Account.Id accountId, ConfigureDeltaFromState configureDelta)
+ throws IOException, ConfigInvalidException {
+ return update(message, accountId, withContext(configureDelta));
+ }
+
+ /**
+ * Like {@link #update(String, Account.Id, ConfigureDeltaFromStateAndContext)} , but using {@link
+ * ConfigureStatelessDelta} instead. I.e. the update does not depend on the current account state,
+ * nor requires any extra storage reads/writes.
+ */
+ @CanIgnoreReturnValue
+ public final Optional<AccountState> update(
+ String message, Account.Id accountId, ConfigureStatelessDelta configureDelta)
+ throws IOException, ConfigInvalidException {
+ return update(message, accountId, withContext(configureDelta));
+ }
+
+ /**
* Updates multiple different accounts atomically. This will only store a single new value (aka
* set of all external IDs of the host) in the external ID cache, which is important for storage
* economy. All {@code updates} must be for different accounts.
@@ -212,7 +337,7 @@
* together have this property) will always prevent the entire batch from being executed.
*/
@CanIgnoreReturnValue
- public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+ public final ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
throws IOException, ConfigInvalidException {
checkArgument(
updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
@@ -231,9 +356,33 @@
public abstract void delete(String message, Account.Id accountId)
throws IOException, ConfigInvalidException;
- protected abstract ImmutableList<Optional<AccountState>> executeUpdates(
+ @VisibleForTesting // productionVisibility: protected
+ public abstract ImmutableList<Optional<AccountState>> executeUpdates(
List<UpdateArguments> updates) throws ConfigInvalidException, IOException;
+ /**
+ * Intended for internal usage only. This is public because some implementations are calling this
+ * method for other instances.
+ */
+ @UsedAt(UsedAt.Project.GOOGLE)
+ public final void configureDelta(
+ ConfigureDeltaFromStateAndContext configureDelta,
+ AccountState accountState,
+ AccountDelta.Builder delta)
+ throws IOException {
+ configureDelta.configure(inUpdateStorageAccessors, accountState, delta);
+ }
+
+ private static ConfigureDeltaFromStateAndContext withContext(
+ ConfigureDeltaFromState configureDelta) {
+ return (unusedAccessors, accountState, delta) -> configureDelta.configure(accountState, delta);
+ }
+
+ private static ConfigureDeltaFromStateAndContext withContext(
+ ConfigureStatelessDelta configureDelta) {
+ return (unusedAccessors, unusedAccountState, delta) -> configureDelta.configure(delta);
+ }
+
private static PersonIdent createPersonIdent(
PersonIdent serverIdent, Optional<IdentifiedUser> user) {
return user.isPresent() ? user.get().newCommitterIdent(serverIdent) : serverIdent;
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 86132d3..341b493 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -29,6 +29,7 @@
import com.google.common.collect.Multimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Sets;
+import com.google.gerrit.common.ConvertibleToProto;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.NotifyConfig;
@@ -79,6 +80,7 @@
*/
public class ProjectWatches {
@AutoValue
+ @ConvertibleToProto
public abstract static class ProjectWatchKey {
public static ProjectWatchKey create(Project.NameKey project, @Nullable String filter) {
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index db030f9..a05baf5 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -19,6 +19,9 @@
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.inject.AbstractModule;
import com.google.inject.Module;
import com.google.inject.Scopes;
@@ -63,41 +66,43 @@
@Override
public boolean isServiceUser(Account.Id user) {
- Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
- if (!maybeGroup.isPresent()) {
+ try (TraceTimer timer = TraceContext.newTimer("isServiceUser", Metadata.empty())) {
+ 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.atFine().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;
}
- 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.atFine().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/account/storage/notedb/AccountsUpdateNoteDbImpl.java b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
index ad3681d..551cd78 100644
--- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
+++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -61,7 +61,6 @@
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
-import java.util.function.Consumer;
import javax.inject.Inject;
import javax.inject.Singleton;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -83,8 +82,8 @@
* com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState}. The account updater
* reads the current {@link AccountState} and prepares updates to the account by calling setters on
* the provided {@link com.google.gerrit.server.account.AccountDelta.Builder}. If the current
- * account state is of no interest the caller may also provide a {@link Consumer} for {@link
- * com.google.gerrit.server.account.AccountDelta.Builder} instead of the account updater.
+ * account state is of no interest the caller may also provide a {@link ConfigureStatelessDelta}
+ * instead of the account updater.
*
* <p>The provided commit message is used for the update of the user branch. Using a precise and
* unique commit message allows to identify the code from which an update was made when looking at a
@@ -272,7 +271,7 @@
PersonIdent committerIdent,
Runnable afterReadRevision,
Runnable beforeCommit) {
- super(committerIdent, currentUser);
+ super(committerIdent, currentUser, externalIds);
this.repoManager = requireNonNull(repoManager, "repoManager");
this.gitRefUpdated = requireNonNull(gitRefUpdated, "gitRefUpdated");
this.allUsersName = requireNonNull(allUsersName, "allUsersName");
@@ -286,7 +285,8 @@
}
@Override
- public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
+ public AccountState insert(
+ String message, Account.Id accountId, ConfigureDeltaFromStateAndContext init)
throws IOException, ConfigInvalidException {
return execute(
ImmutableList.of(
@@ -295,7 +295,7 @@
Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant());
AccountState accountState = AccountState.forAccount(account);
AccountDelta.Builder deltaBuilder = AccountDelta.builder();
- init.configure(accountState, deltaBuilder);
+ configureDelta(init, accountState, deltaBuilder);
AccountDelta accountDelta = deltaBuilder.build();
accountConfig.setAccountDelta(accountDelta);
@@ -315,8 +315,7 @@
public void delete(String message, Account.Id accountId)
throws IOException, ConfigInvalidException {
ImmutableSet<ExternalId> accountExternalIds = externalIds.byAccount(accountId);
- Consumer<AccountDelta.Builder> delta =
- deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
+ ConfigureStatelessDelta delta = deltaBuilder -> deltaBuilder.deleteAccount(accountExternalIds);
update(message, accountId, delta);
}
@@ -332,7 +331,7 @@
}
AccountDelta.Builder deltaBuilder = AccountDelta.builder();
- updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
+ configureDelta(updateArguments.configureDelta, accountState.get(), deltaBuilder);
AccountDelta delta = deltaBuilder.build();
updateExternalIdNotes(
@@ -380,7 +379,8 @@
}
@Override
- protected ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
+ @VisibleForTesting
+ public ImmutableList<Optional<AccountState>> executeUpdates(List<UpdateArguments> updates)
throws ConfigInvalidException, IOException {
return execute(updates.stream().map(this::createExecutableUpdate).collect(toImmutableList()));
}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 5c24ddc..66914b7 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -48,6 +48,7 @@
import com.google.gerrit.extensions.api.projects.TagApi;
import com.google.gerrit.extensions.api.projects.TagInfo;
import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.Input;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
@@ -86,8 +87,12 @@
import com.google.gerrit.server.restapi.project.ListSubmitRequirements;
import com.google.gerrit.server.restapi.project.ListTags;
import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.gerrit.server.restapi.project.PostLabelsReview;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirements;
+import com.google.gerrit.server.restapi.project.PostSubmitRequirementsReview;
import com.google.gerrit.server.restapi.project.ProjectsCollection;
import com.google.gerrit.server.restapi.project.PutConfig;
+import com.google.gerrit.server.restapi.project.PutConfigReview;
import com.google.gerrit.server.restapi.project.PutDescription;
import com.google.gerrit.server.restapi.project.SetAccess;
import com.google.gerrit.server.restapi.project.SetHead;
@@ -127,6 +132,7 @@
private final CreateAccessChange createAccessChange;
private final GetConfig getConfig;
private final PutConfig putConfig;
+ private final PutConfigReview putConfigReview;
private final CommitsIncludedInRefs commitsIncludedInRefs;
private final Provider<ListBranches> listBranches;
private final Provider<ListTags> listTags;
@@ -147,6 +153,10 @@
private final Provider<ListLabels> listLabels;
private final Provider<ListSubmitRequirements> listSubmitRequirements;
private final PostLabels postLabels;
+ private final PostLabelsReview postLabelsReview;
+
+ private final PostSubmitRequirements postSubmitRequirements;
+ private final PostSubmitRequirementsReview postSubmitRequirementsReview;
private final LabelApiImpl.Factory labelApi;
private final SubmitRequirementApiImpl.Factory submitRequirementApi;
@@ -168,6 +178,7 @@
CreateAccessChange createAccessChange,
GetConfig getConfig,
PutConfig putConfig,
+ PutConfigReview putConfigReview,
CommitsIncludedInRefs commitsIncludedInRefs,
Provider<ListBranches> listBranches,
Provider<ListTags> listTags,
@@ -188,8 +199,11 @@
Provider<ListLabels> listLabels,
Provider<ListSubmitRequirements> listSubmitRequirements,
PostLabels postLabels,
+ PostLabelsReview postLabelsReview,
LabelApiImpl.Factory labelApi,
SubmitRequirementApiImpl.Factory submitRequirementApi,
+ PostSubmitRequirements postSubmitRequirements,
+ PostSubmitRequirementsReview postSubmitRequirementsReview,
@Assisted ProjectResource project) {
this(
permissionBackend,
@@ -208,6 +222,7 @@
createAccessChange,
getConfig,
putConfig,
+ putConfigReview,
commitsIncludedInRefs,
listBranches,
listTags,
@@ -229,8 +244,11 @@
listLabels,
listSubmitRequirements,
postLabels,
+ postLabelsReview,
labelApi,
submitRequirementApi,
+ postSubmitRequirements,
+ postSubmitRequirementsReview,
null);
}
@@ -252,6 +270,7 @@
CreateAccessChange createAccessChange,
GetConfig getConfig,
PutConfig putConfig,
+ PutConfigReview putConfigReview,
CommitsIncludedInRefs commitsIncludedInRefs,
Provider<ListBranches> listBranches,
Provider<ListTags> listTags,
@@ -272,8 +291,11 @@
Provider<ListLabels> listLabels,
Provider<ListSubmitRequirements> listSubmitRequirements,
PostLabels postLabels,
+ PostLabelsReview postLabelsReview,
LabelApiImpl.Factory labelApi,
SubmitRequirementApiImpl.Factory submitRequirementApi,
+ PostSubmitRequirements postSubmitRequirements,
+ PostSubmitRequirementsReview postSubmitRequirementsReview,
@Assisted String name) {
this(
permissionBackend,
@@ -292,6 +314,7 @@
createAccessChange,
getConfig,
putConfig,
+ putConfigReview,
commitsIncludedInRefs,
listBranches,
listTags,
@@ -313,8 +336,11 @@
listLabels,
listSubmitRequirements,
postLabels,
+ postLabelsReview,
labelApi,
submitRequirementApi,
+ postSubmitRequirements,
+ postSubmitRequirementsReview,
name);
}
@@ -335,6 +361,7 @@
CreateAccessChange createAccessChange,
GetConfig getConfig,
PutConfig putConfig,
+ PutConfigReview putConfigReview,
CommitsIncludedInRefs commitsIncludedInRefs,
Provider<ListBranches> listBranches,
Provider<ListTags> listTags,
@@ -356,8 +383,11 @@
Provider<ListLabels> listLabels,
Provider<ListSubmitRequirements> listSubmitRequirements,
PostLabels postLabels,
+ PostLabelsReview postLabelsReview,
LabelApiImpl.Factory labelApi,
SubmitRequirementApiImpl.Factory submitRequirementApi,
+ PostSubmitRequirements postSubmitRequirements,
+ PostSubmitRequirementsReview postSubmitRequirementsReview,
String name) {
this.permissionBackend = permissionBackend;
this.createProject = createProject;
@@ -375,6 +405,7 @@
this.setAccess = setAccess;
this.getConfig = getConfig;
this.putConfig = putConfig;
+ this.putConfigReview = putConfigReview;
this.commitsIncludedInRefs = commitsIncludedInRefs;
this.listBranches = listBranches;
this.listTags = listTags;
@@ -397,8 +428,11 @@
this.listLabels = listLabels;
this.listSubmitRequirements = listSubmitRequirements;
this.postLabels = postLabels;
+ this.postLabelsReview = postLabelsReview;
this.labelApi = labelApi;
this.submitRequirementApi = submitRequirementApi;
+ this.postSubmitRequirements = postSubmitRequirements;
+ this.postSubmitRequirementsReview = postSubmitRequirementsReview;
}
@Override
@@ -519,6 +553,15 @@
}
@Override
+ public ChangeInfo configReview(ConfigInput p) throws RestApiException {
+ try {
+ return putConfigReview.apply(checkExists(), p).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot put config change", e);
+ }
+ }
+
+ @Override
public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
throws RestApiException {
try {
@@ -814,4 +857,33 @@
throw asRestApiException("Cannot update labels", e);
}
}
+
+ @Override
+ public ChangeInfo labelsReview(BatchLabelInput input) throws RestApiException {
+ try {
+ return postLabelsReview.apply(checkExists(), input).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot create change for labels update", e);
+ }
+ }
+
+ @Override
+ public void submitRequirements(BatchSubmitRequirementInput input) throws RestApiException {
+ try {
+ @SuppressWarnings("unused")
+ var unused = postSubmitRequirements.apply(checkExists(), input);
+ } catch (Exception e) {
+ throw asRestApiException("Cannot update submit requirements", e);
+ }
+ }
+
+ @Override
+ public ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input)
+ throws RestApiException {
+ try {
+ return postSubmitRequirementsReview.apply(checkExists(), input).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot create change for submit requirements update", e);
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 07280ba..1a2c63d 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -23,7 +23,6 @@
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.config.ChangeCleanupConfig;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeQueryProcessor;
@@ -40,7 +39,6 @@
public class AbandonUtil {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private final ChangeCleanupConfig cfg;
private final Provider<ChangeQueryProcessor> queryProvider;
private final Supplier<ChangeQueryBuilder> queryBuilderSupplier;
private final BatchAbandon batchAbandon;
@@ -48,27 +46,27 @@
@Inject
AbandonUtil(
- ChangeCleanupConfig cfg,
InternalUser.Factory internalUserFactory,
Provider<ChangeQueryProcessor> queryProvider,
Provider<ChangeQueryBuilder> queryBuilderProvider,
BatchAbandon batchAbandon) {
- this.cfg = cfg;
this.queryProvider = queryProvider;
this.queryBuilderSupplier = Suppliers.memoize(queryBuilderProvider::get);
this.batchAbandon = batchAbandon;
internalUser = internalUserFactory.create();
}
- public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
- if (cfg.getAbandonAfter() <= 0) {
+ public void abandonInactiveOpenChanges(
+ BatchUpdate.Factory updateFactory,
+ long abandonAfterMillis,
+ boolean abandonIfMergeable,
+ String message) {
+ if (abandonAfterMillis <= 0) {
return;
}
-
try {
- String query =
- "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(cfg.getAbandonAfter()) + "m";
- if (!cfg.getAbandonIfMergeable()) {
+ String query = "status:new age:" + TimeUnit.MILLISECONDS.toMinutes(abandonAfterMillis) + "m";
+ if (!abandonIfMergeable) {
query += " -is:mergeable";
}
@@ -86,7 +84,6 @@
int count = 0;
ImmutableListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
- String message = cfg.getAbandonMessage();
for (Project.NameKey project : abandons.keySet()) {
List<ChangeData> changes = getValidChanges(abandons.get(project), query);
try {
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 2f2cff9..728830c 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.change;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.lifecycle.LifecycleModule;
@@ -25,6 +26,8 @@
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
/** Runnable to enable scheduling change cleanups to run periodically */
public class ChangeCleanupRunner implements Runnable {
@@ -34,18 +37,25 @@
@Override
protected void configure() {
listener().to(Lifecycle.class);
+ factory(Factory.class);
}
}
+ public interface Factory {
+ ChangeCleanupRunner create();
+
+ ChangeCleanupRunner create(long abandonAfterMillis, boolean abandonIfMergeable, String message);
+ }
+
static class Lifecycle implements LifecycleListener {
private final WorkQueue queue;
private final ChangeCleanupRunner runner;
private final ChangeCleanupConfig cfg;
@Inject
- Lifecycle(WorkQueue queue, ChangeCleanupRunner runner, ChangeCleanupConfig cfg) {
+ Lifecycle(WorkQueue queue, ChangeCleanupRunner.Factory runner, ChangeCleanupConfig cfg) {
this.queue = queue;
- this.runner = runner;
+ this.runner = runner.create();
this.cfg = cfg;
}
@@ -63,13 +73,38 @@
private final OneOffRequestContext oneOffRequestContext;
private final AbandonUtil abandonUtil;
private final RetryHelper retryHelper;
+ private final long abandonAfterMillis;
+ private final boolean abandonIfMergeable;
+ @Nullable private final String message;
- @Inject
+ @AssistedInject
ChangeCleanupRunner(
- OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper) {
+ OneOffRequestContext oneOffRequestContext,
+ AbandonUtil abandonUtil,
+ RetryHelper retryHelper,
+ @Assisted long abandonAfterMillis,
+ @Assisted boolean abandonIfMergeable,
+ @Assisted @Nullable String message) {
this.oneOffRequestContext = oneOffRequestContext;
this.abandonUtil = abandonUtil;
this.retryHelper = retryHelper;
+ this.abandonAfterMillis = abandonAfterMillis;
+ this.abandonIfMergeable = abandonIfMergeable;
+ this.message = message;
+ }
+
+ @AssistedInject
+ ChangeCleanupRunner(
+ OneOffRequestContext oneOffRequestContext,
+ AbandonUtil abandonUtil,
+ RetryHelper retryHelper,
+ ChangeCleanupConfig cfg) {
+ this.oneOffRequestContext = oneOffRequestContext;
+ this.abandonUtil = abandonUtil;
+ this.retryHelper = retryHelper;
+ this.abandonAfterMillis = cfg.getAbandonAfter();
+ this.abandonIfMergeable = cfg.getAbandonIfMergeable();
+ this.message = cfg.getAbandonMessage();
}
@Override
@@ -85,7 +120,8 @@
.changeUpdate(
"abandonInactiveOpenChanges",
updateFactory -> {
- abandonUtil.abandonInactiveOpenChanges(updateFactory);
+ abandonUtil.abandonInactiveOpenChanges(
+ updateFactory, abandonAfterMillis, abandonIfMergeable, message);
return null;
})
.call();
diff --git a/java/com/google/gerrit/server/change/ChangeETagComputation.java b/java/com/google/gerrit/server/change/ChangeETagComputation.java
deleted file mode 100644
index 2fd5755..0000000
--- a/java/com/google/gerrit/server/change/ChangeETagComputation.java
+++ /dev/null
@@ -1,63 +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.gerrit.entities.Project;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-/**
- * Allows plugins to contribute a value to the change ETag computation.
- *
- * <p>Plugins can affect the result of the get change / get change details REST endpoints by:
- *
- * <ul>
- * <li>providing plugin defined attributes to {@link
- * com.google.gerrit.extensions.common.ChangeInfo#plugins} (see {@link
- * ChangePluginDefinedInfoFactory})
- * <li>implementing a {@link com.google.gerrit.server.rules.SubmitRule} which affects the
- * computation of {@link com.google.gerrit.extensions.common.ChangeInfo#submittable}
- * </ul>
- *
- * <p>If the plugin defined part of {@link com.google.gerrit.extensions.common.ChangeInfo} depends
- * on plugin specific data, callers that use the change ETags to avoid unneeded recomputations of
- * ChangeInfos may see outdated plugin attributes and/or outdated submittable information, because a
- * ChangeInfo is only reloaded if the change ETag changes.
- *
- * <p>By implementating this interface plugins can contribute to the change ETag computation and
- * thus ensure that the ETag changes when the plugin data was changed. This way it is ensured that
- * callers do not see outdated ChangeInfos.
- *
- * @see ChangeResource#getETag()
- */
-@ExtensionPoint
-public interface ChangeETagComputation {
- /**
- * Computes an ETag of plugin-specific data for the given change.
- *
- * <p><strong>Note:</strong> Change ETags are computed very frequently and the computation must be
- * cheap. Take good care to not perform any expensive computations when implementing this.
- *
- * <p>If an error is encountered during the ETag computation the plugin can indicate this by
- * throwing any RuntimeException. In this case no value will be included in the change ETag
- * computation. This means if the error is transient, the ETag will differ when the computation
- * succeeds on a follow-up run.
- *
- * @param projectName the name of the project that contains the change
- * @param changeId ID of the change for which the ETag should be computed
- * @return the ETag
- */
- String getETag(Project.NameKey projectName, Change.Id changeId);
-}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index d4b837f..46f3e0e 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -112,8 +112,10 @@
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.ChangePermission;
+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.permissions.RefPermission;
import com.google.gerrit.server.project.RemoveReviewerControl;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.query.change.ChangeData;
@@ -942,7 +944,12 @@
// of "fixed" from "removable", because not all of their approvals can be
// removed.
Collection<LabelInfo> labels = out.labels.values();
- Set<Account.Id> fixed = Sets.newHashSetWithExpectedSize(labels.size());
+ Set<Account.Id> fixed = new HashSet<>();
+
+ // the submitter cannot be removed since the submission is recorded by a SUBM approval which
+ // must not be removed by removing the submitter
+ cd.getSubmitApproval().ifPresent(submitApproval -> fixed.add(submitApproval.accountId()));
+
Set<Account.Id> removable = new HashSet<>();
// Add all reviewers, which will later be removed if they are in the "fixed" set.
@@ -953,23 +960,33 @@
.collect(Collectors.toSet()));
// Check if the user has the permission to remove a reviewer. This means we can bypass the
- // testRemoveReviewer check for a specific reviewer in the loop saving potentially many
- // permission checks.
+ // permission checks for a specific reviewer in the loop saving potentially many permission
+ // checks.
+ PermissionBackend.WithUser withUser = permissionBackend.user(userProvider.get());
boolean canRemoveAnyReviewer =
- permissionBackend
- .user(userProvider.get())
- .change(cd)
- .test(ChangePermission.REMOVE_REVIEWER);
+ withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER)
+ || withUser
+ .project(cd.project())
+ .ref(cd.change().getDest().branch())
+ .test(RefPermission.WRITE_CONFIG)
+ || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+
for (LabelInfo label : labels) {
if (label.all == null) {
continue;
}
for (ApprovalInfo ai : label.all) {
Account.Id id = Account.id(ai._accountId);
+ if (fixed.contains(id)) {
+ // we already found that this reviewer cannot be removed, no need to check again
+ continue;
+ }
- if (!canRemoveAnyReviewer
- && !removeReviewerControl.testRemoveReviewer(
- cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
+ int value = MoreObjects.firstNonNull(ai.value, 0);
+ if ((cd.change().isMerged() && value != 0)
+ || (!canRemoveAnyReviewer
+ && !RemoveReviewerControl.canRemoveReviewerWithoutPermissionCheck(
+ cd.change(), userProvider.get(), id, value))) {
fixed.add(id);
}
}
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index 74aa373..90752c0 100644
--- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -193,7 +193,7 @@
@Override
public ChangeKind call() throws IOException {
if (Objects.equals(key.prior(), key.next())) {
- return ChangeKind.NO_CODE_CHANGE;
+ return ChangeKind.NO_CHANGE;
}
RevWalk rw = alreadyOpenRw;
@@ -210,15 +210,10 @@
RevCommit next = rw.parseCommit(key.next());
rw.parseBody(next);
- if (!next.getFullMessage().equals(prior.getFullMessage())) {
- if (isSameDeltaAndTree(rw, prior, next)) {
- return ChangeKind.NO_CODE_CHANGE;
- }
- return ChangeKind.REWORK;
- }
+ boolean commitMessageChanged = !next.getFullMessage().equals(prior.getFullMessage());
if (isSameDeltaAndTree(rw, prior, next)) {
- return ChangeKind.NO_CHANGE;
+ return commitMessageChanged ? ChangeKind.NO_CODE_CHANGE : ChangeKind.NO_CHANGE;
}
if (prior.getParentCount() == 0 || next.getParentCount() == 0) {
@@ -243,9 +238,11 @@
if (merger.merge(next.getParent(0), prior)
&& merger.getResultTreeId().equals(next.getTree())) {
if (prior.getParentCount() == 1) {
- return ChangeKind.TRIVIAL_REBASE;
+ return commitMessageChanged
+ ? ChangeKind.TRIVIAL_REBASE_WITH_MESSAGE_UPDATE
+ : ChangeKind.TRIVIAL_REBASE;
}
- return ChangeKind.MERGE_FIRST_PARENT_UPDATE;
+ return commitMessageChanged ? ChangeKind.REWORK : ChangeKind.MERGE_FIRST_PARENT_UPDATE;
}
} catch (LargeObjectException e) {
// Some object is too large for the merge attempt to succeed. Assume
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 8300541..a7fa6f4 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -14,54 +14,20 @@
package com.google.gerrit.server.change;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
-import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.StarredChangesReader;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.TypeLiteral;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
-import java.util.HashSet;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-public class ChangeResource implements RestResource, HasETag {
- /**
- * JSON format version number for ETag computations.
- *
- * <p>Should be bumped on any JSON format change (new fields, etc.) so that otherwise unmodified
- * changes get new ETags.
- */
- public static final int JSON_FORMAT_VERSION = 1;
-
+public class ChangeResource implements RestResource {
public static final TypeLiteral<RestView<ChangeResource>> CHANGE_KIND = new TypeLiteral<>() {};
public interface Factory {
@@ -70,59 +36,27 @@
ChangeResource create(ChangeData changeData, CurrentUser user);
}
- private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
-
- private final AccountCache accountCache;
- private final ApprovalsUtil approvalUtil;
- private final PatchSetUtil patchSetUtil;
private final PermissionBackend permissionBackend;
- private final StarredChangesReader starredChangesReader;
- private final ProjectCache projectCache;
- private final PluginSetContext<ChangeETagComputation> changeETagComputation;
private final ChangeData changeData;
private final CurrentUser user;
@AssistedInject
ChangeResource(
- AccountCache accountCache,
- ApprovalsUtil approvalUtil,
- PatchSetUtil patchSetUtil,
PermissionBackend permissionBackend,
- StarredChangesReader starredChangesReader,
- ProjectCache projectCache,
- PluginSetContext<ChangeETagComputation> changeETagComputation,
ChangeData.Factory changeDataFactory,
@Assisted ChangeNotes notes,
@Assisted CurrentUser user) {
- this.accountCache = accountCache;
- this.approvalUtil = approvalUtil;
- this.patchSetUtil = patchSetUtil;
this.permissionBackend = permissionBackend;
- this.starredChangesReader = starredChangesReader;
- this.projectCache = projectCache;
- this.changeETagComputation = changeETagComputation;
this.changeData = changeDataFactory.create(notes);
this.user = user;
}
@AssistedInject
ChangeResource(
- AccountCache accountCache,
- ApprovalsUtil approvalUtil,
- PatchSetUtil patchSetUtil,
PermissionBackend permissionBackend,
- StarredChangesReader starredChangesReader,
- ProjectCache projectCache,
- PluginSetContext<ChangeETagComputation> changeETagComputation,
@Assisted ChangeData changeData,
@Assisted CurrentUser user) {
- this.accountCache = accountCache;
- this.approvalUtil = approvalUtil;
- this.patchSetUtil = patchSetUtil;
this.permissionBackend = permissionBackend;
- this.starredChangesReader = starredChangesReader;
- this.projectCache = projectCache;
- this.changeETagComputation = changeETagComputation;
this.changeData = changeData;
this.user = user;
}
@@ -164,106 +98,4 @@
public Change.Id getVirtualId() {
return getChangeData().virtualId();
}
-
- // This includes all information relevant for ETag computation
- // unrelated to the UI.
- public void prepareETag(Hasher h, CurrentUser user) {
- h.putInt(JSON_FORMAT_VERSION)
- .putLong(getChange().getLastUpdatedOn().toEpochMilli())
- .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
-
- if (user.isIdentifiedUser()) {
- for (AccountGroup.UUID uuid : user.getEffectiveGroups().getKnownGroups()) {
- h.putBytes(uuid.get().getBytes(UTF_8));
- }
- }
-
- byte[] buf = new byte[20];
- Set<Account.Id> accounts = new HashSet<>();
- accounts.add(getChange().getOwner());
- try {
- patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
-
- // It's intentional to include the states for *all* reviewers into the ETag computation.
- // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
- // Including removed reviewers is a cheap way of making sure that the states of accounts that
- // posted a message on the change are included. Loading all change messages to find the exact
- // set of accounts that posted a message is too expensive. However everyone who posts a
- // message is automatically added as reviewer. Hence if we include removed reviewers we can
- // be sure that we have all accounts that posted messages on the change.
- accounts.addAll(approvalUtil.getReviewers(getNotes()).all());
- } catch (StorageException e) {
- // This ETag will be invalidated if it loads next time.
- }
-
- for (Account.Id accountId : accounts) {
- Optional<AccountState> accountState = accountCache.get(accountId);
- if (accountState.isPresent()) {
- hashAccount(h, accountState.get(), buf);
- } else {
- h.putInt(accountId.get());
- }
- }
-
- ObjectId noteId;
- try {
- noteId = getNotes().loadRevision();
- } catch (StorageException e) {
- noteId = null; // This ETag will be invalidated if it loads next time.
- }
- hashObjectId(h, noteId, buf);
- // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
- // and edits.
-
- Iterable<ProjectState> projectStateTree =
- projectCache.get(getProject()).orElseThrow(illegalState(getProject())).tree();
- for (ProjectState p : projectStateTree) {
- hashObjectId(h, p.getConfig().getRevision().orElse(null), buf);
- }
-
- changeETagComputation.runEach(
- c -> {
- String pluginETag = c.getETag(changeData.project(), changeData.getId());
- if (pluginETag != null) {
- h.putString(pluginETag, UTF_8);
- }
- });
- }
-
- @Override
- public String getETag() {
- try (TraceTimer ignored =
- TraceContext.newTimer(
- "Compute change ETag",
- Metadata.builder()
- .changeId(changeData.getId().get())
- .projectName(changeData.project().get())
- .build())) {
- Hasher h = Hashing.murmur3_128().newHasher();
- if (user.isIdentifiedUser()) {
- h.putBoolean(starredChangesReader.isStarred(user.getAccountId(), getVirtualId()));
- }
- prepareETag(h, user);
- return h.hash().toString();
- }
- }
-
- private void hashObjectId(Hasher h, @Nullable ObjectId id, byte[] buf) {
- MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
- h.putBytes(buf);
- }
-
- private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
- h.putInt(accountState.account().id().get());
- // Based on the code, it seems the metaId should never be null in this place and so the
- // uniqueTag.
- // However, the null-check for metaId has been existed here for some time already - for safety
- // the same check is applied to uniqueTag.
- h.putString(
- MoreObjects.firstNonNull(
- accountState.account().uniqueTag(),
- MoreObjects.firstNonNull(accountState.account().metaId(), ZERO_ID_STRING)),
- UTF_8);
- accountState.externalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
- }
}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 4f001fb..b6eadb6 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -30,6 +30,7 @@
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.IdentifiedUser;
@@ -117,7 +118,8 @@
@Override
public boolean updateChange(ChangeContext ctx)
- throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
+ throws AuthException, ResourceNotFoundException, PermissionBackendException,
+ ResourceConflictException, IOException {
Account.Id reviewerId = reviewer.id();
// Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 93fcbc6..4e487e1 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -420,6 +420,15 @@
// Gerrit change.
return ObjectId.fromString(inputBase);
}
+
+ // Support "refs/heads/..."
+ Ref ref = git.getRefDatabase().exactRef(inputBase);
+ if (ref != null
+ && isBaseRevisionInDestBranch(
+ rw, ObjectId.toString(ref.getObjectId()), git, change.getDest())) {
+ return ref.getObjectId();
+ }
+
throw new ResourceConflictException(
"base revision is missing from the destination branch: " + inputBase);
}
diff --git a/java/com/google/gerrit/server/change/RevisionResource.java b/java/com/google/gerrit/server/change/RevisionResource.java
index 4a10158..a09cb1f 100644
--- a/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/java/com/google/gerrit/server/change/RevisionResource.java
@@ -14,26 +14,20 @@
package com.google.gerrit.server.change;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
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.extensions.restapi.RestResource;
-import com.google.gerrit.extensions.restapi.RestResource.HasETag;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.inject.TypeLiteral;
import java.util.Optional;
-public class RevisionResource implements RestResource, HasETag {
+public class RevisionResource implements RestResource {
public static final TypeLiteral<RestView<RevisionResource>> REVISION_KIND =
new TypeLiteral<>() {};
@@ -90,28 +84,6 @@
return ps;
}
- @Override
- public String getETag() {
- try (TraceTimer ignored =
- TraceContext.newTimer(
- "Compute revision ETag",
- Metadata.builder()
- .changeId(changeResource.getId().get())
- .patchSetId(ps.number())
- .projectName(changeResource.getProject().get())
- .build())) {
- Hasher h = Hashing.murmur3_128().newHasher();
- prepareETag(h, getUser());
- return h.hash().toString();
- }
- }
-
- public void prepareETag(Hasher h, CurrentUser user) {
- // Conservative estimate: refresh the revision if its parent change has changed, so we don't
- // have to check whether a given modification affected this revision specifically.
- changeResource.prepareETag(h, user);
- }
-
public Account.Id getAccountId() {
return getUser().getAccountId();
}
diff --git a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java b/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
deleted file mode 100644
index 344b9b3..0000000
--- a/java/com/google/gerrit/server/change/testing/TestChangeETagComputation.java
+++ /dev/null
@@ -1,30 +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.testing;
-
-import com.google.gerrit.server.change.ChangeETagComputation;
-
-public class TestChangeETagComputation {
-
- public static ChangeETagComputation withETag(String etag) {
- return (p, id) -> etag;
- }
-
- public static ChangeETagComputation withException(RuntimeException e) {
- return (p, id) -> {
- throw e;
- };
- }
-}
diff --git a/java/com/google/gerrit/server/change/testing/package-info.java b/java/com/google/gerrit/server/change/testing/package-info.java
deleted file mode 100644
index 3cd4da3..0000000
--- a/java/com/google/gerrit/server/change/testing/package-info.java
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-@CheckReturnValue
-package com.google.gerrit.server.change.testing;
-
-import com.google.errorprone.annotations.CheckReturnValue;
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 6e957e6..309d1fd 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -47,6 +47,7 @@
public Optional<CachedPreferencesProto> nonEmptyConfig() {
return config().equals(EMPTY.config()) ? Optional.empty() : Optional.of(config());
}
+
/** Returns a cache-able representation of the preferences proto. */
public static CachedPreferences fromUserPreferencesProto(UserPreferences proto) {
return fromCachedPreferencesProto(
@@ -126,7 +127,8 @@
switch (userPreferencesProto.getPreferencesCase()) {
case USER_PREFERENCES:
PreferencesT pref =
- preferencesParser.fromUserPreferences(userPreferencesProto.getUserPreferences());
+ preferencesParser.fromUserPreferences(
+ userPreferencesProto.getUserPreferences(), configOrNull(defaultPreferences));
return preferencesParser.parse(pref, configOrNull(defaultPreferences));
case LEGACY_GIT_CONFIG:
return preferencesParser.parse(
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index a38a3fc..c4ecc80 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -89,6 +89,7 @@
import com.google.gerrit.server.ExceptionHookImpl;
import com.google.gerrit.server.ExternalUser;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PerformanceMetrics;
import com.google.gerrit.server.RequestListener;
import com.google.gerrit.server.TraceRequestListener;
import com.google.gerrit.server.account.AccountControl;
@@ -114,7 +115,6 @@
import com.google.gerrit.server.cache.CacheRemovalListener;
import com.google.gerrit.server.change.AbandonOp;
import com.google.gerrit.server.change.AccountPatchReviewStore;
-import com.google.gerrit.server.change.ChangeETagComputation;
import com.google.gerrit.server.change.ChangeFinder;
import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeKindCacheImpl;
@@ -456,9 +456,9 @@
DynamicSet.setOf(binder(), SubmitRequirement.class);
DynamicSet.setOf(binder(), QuotaEnforcer.class);
DynamicSet.setOf(binder(), PerformanceLogger.class);
+ DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
DynamicSet.setOf(binder(), RequestListener.class);
DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
- DynamicSet.setOf(binder(), ChangeETagComputation.class);
DynamicSet.setOf(binder(), ExceptionHook.class);
DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index 93df926..9249b3c 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -17,6 +17,9 @@
import static com.google.gerrit.server.config.ConfigUtil.loadSection;
import static com.google.gerrit.server.config.ConfigUtil.mergeWithDefaults;
import static com.google.gerrit.server.config.ConfigUtil.skipField;
+import static com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter.DIFF_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter.EDIT_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter.GENERAL_PREFERENCES_INFO_CONVERTER;
import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE;
import static com.google.gerrit.server.git.UserConfigSections.CHANGE_TABLE_COLUMN;
import static com.google.gerrit.server.git.UserConfigSections.KEY_ID;
@@ -317,13 +320,14 @@
}
/** Provides methods for parsing user configs */
- public interface PreferencesParser<T> {
+ interface PreferencesParser<T> {
T parse(Config cfg, @Nullable Config defaultConfig, @Nullable T input)
throws ConfigInvalidException;
T parse(T cfg, @Nullable Config defaultConfig) throws ConfigInvalidException;
- T fromUserPreferences(UserPreferences userPreferences);
+ T fromUserPreferences(UserPreferences userPreferences, @Nullable Config defaultCfg)
+ throws ConfigInvalidException;
T getJavaDefaults();
}
@@ -349,9 +353,10 @@
}
@Override
- public GeneralPreferencesInfo fromUserPreferences(UserPreferences p) {
- return UserPreferencesConverter.GeneralPreferencesInfoConverter.fromProto(
- p.getGeneralPreferencesInfo());
+ public GeneralPreferencesInfo fromUserPreferences(
+ UserPreferences p, @Nullable Config defaultCfg) throws ConfigInvalidException {
+ return PreferencesParserUtil.parseGeneralPreferences(
+ GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(p.getGeneralPreferencesInfo()), defaultCfg);
}
@Override
@@ -380,9 +385,10 @@
}
@Override
- public EditPreferencesInfo fromUserPreferences(UserPreferences p) {
- return UserPreferencesConverter.EditPreferencesInfoConverter.fromProto(
- p.getEditPreferencesInfo());
+ public EditPreferencesInfo fromUserPreferences(UserPreferences p, @Nullable Config defaultCfg)
+ throws ConfigInvalidException {
+ return PreferencesParserUtil.parseEditPreferences(
+ EDIT_PREFERENCES_INFO_CONVERTER.fromProto(p.getEditPreferencesInfo()), defaultCfg);
}
@Override
@@ -411,9 +417,10 @@
}
@Override
- public DiffPreferencesInfo fromUserPreferences(UserPreferences p) {
- return UserPreferencesConverter.DiffPreferencesInfoConverter.fromProto(
- p.getDiffPreferencesInfo());
+ public DiffPreferencesInfo fromUserPreferences(UserPreferences p, @Nullable Config defaultCfg)
+ throws ConfigInvalidException {
+ return PreferencesParserUtil.parseDiffPreferences(
+ DIFF_PREFERENCES_INFO_CONVERTER.fromProto(p.getDiffPreferencesInfo()), defaultCfg);
}
@Override
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
index 7eae7d0..4b2f6d2 100644
--- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java
+++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -17,12 +17,14 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.converter.SafeProtoConverter;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.EditPreferencesInfo;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
import com.google.gerrit.extensions.client.MenuItem;
import com.google.gerrit.proto.Entities.UserPreferences;
import com.google.protobuf.Message;
+import com.google.protobuf.Parser;
import com.google.protobuf.ProtocolMessageEnum;
import java.util.function.Function;
@@ -33,8 +35,13 @@
* equivalents in Spanner.
*/
public final class UserPreferencesConverter {
- public static final class GeneralPreferencesInfoConverter {
- public static UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
+ public enum GeneralPreferencesInfoConverter
+ implements
+ SafeProtoConverter<UserPreferences.GeneralPreferencesInfo, GeneralPreferencesInfo> {
+ GENERAL_PREFERENCES_INFO_CONVERTER;
+
+ @Override
+ public UserPreferences.GeneralPreferencesInfo toProto(GeneralPreferencesInfo info) {
UserPreferences.GeneralPreferencesInfo.Builder builder =
UserPreferences.GeneralPreferencesInfo.newBuilder();
builder = setIfNotNull(builder, builder::setChangesPerPage, info.changesPerPage);
@@ -112,11 +119,20 @@
builder =
setIfNotNull(
builder, builder::setAllowBrowserNotifications, info.allowBrowserNotifications);
+ builder =
+ setIfNotNull(
+ builder,
+ builder::setAllowSuggestCodeWhileCommenting,
+ info.allowSuggestCodeWhileCommenting);
+ builder =
+ setIfNotNull(
+ builder, builder::setAllowAutocompletingComments, info.allowAutocompletingComments);
builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar);
return builder.build();
}
- public static GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
+ @Override
+ public GeneralPreferencesInfo fromProto(UserPreferences.GeneralPreferencesInfo proto) {
GeneralPreferencesInfo res = new GeneralPreferencesInfo();
res.changesPerPage = proto.hasChangesPerPage() ? proto.getChangesPerPage() : null;
res.downloadScheme = proto.hasDownloadScheme() ? proto.getDownloadScheme() : null;
@@ -173,10 +189,21 @@
res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null;
res.allowBrowserNotifications =
proto.hasAllowBrowserNotifications() ? proto.getAllowBrowserNotifications() : null;
+ res.allowSuggestCodeWhileCommenting =
+ proto.hasAllowSuggestCodeWhileCommenting()
+ ? proto.getAllowSuggestCodeWhileCommenting()
+ : null;
+ res.allowAutocompletingComments =
+ proto.hasAllowAutocompletingComments() ? proto.getAllowAutocompletingComments() : null;
res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null;
return res;
}
+ @Override
+ public Parser<UserPreferences.GeneralPreferencesInfo> getParser() {
+ return UserPreferences.GeneralPreferencesInfo.parser();
+ }
+
private static UserPreferences.GeneralPreferencesInfo.MenuItem menuItemToProto(
MenuItem javaItem) {
UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder =
@@ -201,11 +228,23 @@
proto.hasId() ? proto.getId().trim() : null);
}
- private GeneralPreferencesInfoConverter() {}
+ @Override
+ public Class<UserPreferences.GeneralPreferencesInfo> getProtoClass() {
+ return UserPreferences.GeneralPreferencesInfo.class;
+ }
+
+ @Override
+ public Class<GeneralPreferencesInfo> getEntityClass() {
+ return GeneralPreferencesInfo.class;
+ }
}
- public static final class DiffPreferencesInfoConverter {
- public static UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
+ public enum DiffPreferencesInfoConverter
+ implements SafeProtoConverter<UserPreferences.DiffPreferencesInfo, DiffPreferencesInfo> {
+ DIFF_PREFERENCES_INFO_CONVERTER;
+
+ @Override
+ public UserPreferences.DiffPreferencesInfo toProto(DiffPreferencesInfo info) {
UserPreferences.DiffPreferencesInfo.Builder builder =
UserPreferences.DiffPreferencesInfo.newBuilder();
builder = setIfNotNull(builder, builder::setContext, info.context);
@@ -241,7 +280,8 @@
return builder.build();
}
- public static DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
+ @Override
+ public DiffPreferencesInfo fromProto(UserPreferences.DiffPreferencesInfo proto) {
DiffPreferencesInfo res = new DiffPreferencesInfo();
res.context = proto.hasContext() ? proto.getContext() : null;
res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
@@ -276,11 +316,28 @@
return res;
}
- private DiffPreferencesInfoConverter() {}
+ @Override
+ public Parser<UserPreferences.DiffPreferencesInfo> getParser() {
+ return UserPreferences.DiffPreferencesInfo.parser();
+ }
+
+ @Override
+ public Class<UserPreferences.DiffPreferencesInfo> getProtoClass() {
+ return UserPreferences.DiffPreferencesInfo.class;
+ }
+
+ @Override
+ public Class<DiffPreferencesInfo> getEntityClass() {
+ return DiffPreferencesInfo.class;
+ }
}
- public static final class EditPreferencesInfoConverter {
- public static UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
+ public enum EditPreferencesInfoConverter
+ implements SafeProtoConverter<UserPreferences.EditPreferencesInfo, EditPreferencesInfo> {
+ EDIT_PREFERENCES_INFO_CONVERTER;
+
+ @Override
+ public UserPreferences.EditPreferencesInfo toProto(EditPreferencesInfo info) {
UserPreferences.EditPreferencesInfo.Builder builder =
UserPreferences.EditPreferencesInfo.newBuilder();
builder = setIfNotNull(builder, builder::setTabSize, info.tabSize);
@@ -300,7 +357,8 @@
return builder.build();
}
- public static EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
+ @Override
+ public EditPreferencesInfo fromProto(UserPreferences.EditPreferencesInfo proto) {
EditPreferencesInfo res = new EditPreferencesInfo();
res.tabSize = proto.hasTabSize() ? proto.getTabSize() : null;
res.lineLength = proto.hasLineLength() ? proto.getLineLength() : null;
@@ -320,7 +378,20 @@
return res;
}
- private EditPreferencesInfoConverter() {}
+ @Override
+ public Parser<UserPreferences.EditPreferencesInfo> getParser() {
+ return UserPreferences.EditPreferencesInfo.parser();
+ }
+
+ @Override
+ public Class<UserPreferences.EditPreferencesInfo> getProtoClass() {
+ return UserPreferences.EditPreferencesInfo.class;
+ }
+
+ @Override
+ public Class<EditPreferencesInfo> getEntityClass() {
+ return EditPreferencesInfo.class;
+ }
}
private static <ValueT, BuilderT extends Message.Builder> BuilderT setIfNotNull(
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index b3ce564..ae80fe4 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -42,40 +42,49 @@
private final ObjectId baseTreeId;
private final ImmutableList<? extends ObjectId> baseParents;
private final Optional<ObjectInserter> objectInserter;
+ private final Optional<ObjectReader> objectReader;
private final List<TreeModification> treeModifications = new ArrayList<>();
public static TreeCreator basedOn(RevCommit baseCommit) {
requireNonNull(baseCommit, "baseCommit is required");
return new TreeCreator(
- baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()), Optional.empty());
+ baseCommit.getTree(),
+ ImmutableList.copyOf(baseCommit.getParents()),
+ Optional.empty(),
+ Optional.empty());
}
@UsedAt(UsedAt.Project.GOOGLE)
- public static TreeCreator basedOn(RevCommit baseCommit, ObjectInserter objectInserter) {
+ public static TreeCreator basedOn(
+ RevCommit baseCommit, ObjectInserter objectInserter, ObjectReader objectReader) {
requireNonNull(baseCommit, "baseCommit is required");
return new TreeCreator(
baseCommit.getTree(),
ImmutableList.copyOf(baseCommit.getParents()),
- Optional.of(objectInserter));
+ Optional.of(objectInserter),
+ Optional.of(objectReader));
}
public static TreeCreator basedOnTree(
ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
requireNonNull(baseTreeId, "baseTreeId is required");
- return new TreeCreator(baseTreeId, baseParents, Optional.empty());
+ return new TreeCreator(baseTreeId, baseParents, Optional.empty(), Optional.empty());
}
public static TreeCreator basedOnEmptyTree() {
- return new TreeCreator(ObjectId.zeroId(), ImmutableList.of(), Optional.empty());
+ return new TreeCreator(
+ ObjectId.zeroId(), ImmutableList.of(), Optional.empty(), Optional.empty());
}
private TreeCreator(
ObjectId baseTreeId,
ImmutableList<? extends ObjectId> baseParents,
- Optional<ObjectInserter> objectInserter) {
+ Optional<ObjectInserter> objectInserter,
+ Optional<ObjectReader> objectReader) {
this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
this.baseParents = baseParents;
this.objectInserter = objectInserter;
+ this.objectReader = objectReader;
}
/**
@@ -138,14 +147,22 @@
}
private DirCache readBaseTree(Repository repository) throws IOException {
- try (ObjectReader objectReader = repository.newObjectReader()) {
- DirCache dirCache = DirCache.newInCore();
+ ObjectReader or = objectReader.orElseGet(() -> repository.newObjectReader());
+ try {
+ DirCache dirCache =
+ ObjectId.zeroId().equals(baseTreeId)
+ ? DirCache.newInCore()
+ : DirCache.read(or, baseTreeId);
DirCacheBuilder dirCacheBuilder = dirCache.builder();
if (!ObjectId.zeroId().equals(baseTreeId)) {
- dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+ dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, or, baseTreeId);
}
dirCacheBuilder.finish();
return dirCache;
+ } finally {
+ if (objectReader.isEmpty()) {
+ or.close();
+ }
}
}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 7c8777f..c9b9f7a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -96,7 +96,12 @@
public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
ChangeData cd = changeDataFactory.create(project, ps.id().changeId());
- return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
+ return revisionInfo(cd, ps);
+ }
+
+ public RevisionInfo revisionInfo(ChangeData changeData, PatchSet ps)
+ throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException {
+ return revisionJsonFactory.create(changeOptions).getRevisionInfo(changeData, ps);
}
@Nullable
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index a60d982..f6d5881 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -78,7 +78,7 @@
Event event =
new Event(
util.changeInfo(changeData),
- util.revisionInfo(changeData.project(), patchSet),
+ util.revisionInfo(changeData, patchSet),
util.accountInfo(uploader),
when,
notify.handling());
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 307ec5c..60cbeed 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -39,15 +39,20 @@
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
+import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Delayed;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
+import java.util.concurrent.PriorityBlockingQueue;
import java.util.concurrent.RunnableScheduledFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
@@ -56,7 +61,9 @@
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.lang3.mutable.MutableBoolean;
import org.eclipse.jgit.lib.Config;
/** Delayed execution of tasks using a background thread pool. */
@@ -88,6 +95,54 @@
void onStop(Task<?> task);
}
+ /**
+ * Register a TaskParker from a plugin like this:
+ *
+ * <p>bind(TaskListener.class).annotatedWith(Exports.named("MyParker")).to(MyParker.class);
+ */
+ public interface TaskParker extends TaskListener {
+ class NoOp extends TaskListener.NoOp implements TaskParker {
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ return true;
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {}
+ }
+
+ /**
+ * Determine whether a {@link Task} is ready to run or whether it should get parked.
+ *
+ * <p>Tasks that are not ready to run will get parked and will not run until all {@link
+ * TaskParker}s return {@code true} from this method for the {@link Task}. This method may be
+ * called more than once, but will always be followed by a call to {@link
+ * #onNotReadyToStart(Task)} before being called again.
+ *
+ * <p>Resources should be acquired in this method via non-blocking means to avoid delaying the
+ * executor from calling {@link #onNotReadyToStart(Task)} on other {@link TaskParker}s holding
+ * resources.
+ *
+ * @param task the {@link Task} being considered for starting/parking
+ * @return a boolean indicating if the given {@link Task} is ready to run ({@code true}) or
+ * should be parked ({@code false})
+ */
+ boolean isReadyToStart(Task<?> task);
+
+ /**
+ * This method will be called after this {@link TaskParker} returns {@code true} from {@link
+ * #isReadyToStart(Task)} and another {@link TaskParker} returns {@code false}, thus preventing
+ * the start.
+ *
+ * <p>Implementors should use this method to free any resources acquired in {@link
+ * #isReadyToStart(Task)} based on the expectation that the task would start. Those resources
+ * can be re-acquired when {@link #isReadyToStart(Task)} is called again later.
+ *
+ * @param task the {@link Task} that was prevented from starting by another {@link TaskParker}
+ */
+ void onNotReadyToStart(Task<?> task);
+ }
+
public static class Lifecycle implements LifecycleListener {
private final WorkQueue workQueue;
@@ -288,9 +343,75 @@
/** An isolated queue. */
private class Executor extends ScheduledThreadPoolExecutor {
+ private class ParkedTask implements Comparable<ParkedTask> {
+ public final CancellableCountDownLatch latch = new CancellableCountDownLatch(1);
+ public final Task<?> task;
+ private final Long priority = priorityGenerator.getAndIncrement();
+
+ public ParkedTask(Task<?> task) {
+ this.task = task;
+ }
+
+ @Override
+ public int compareTo(ParkedTask o) {
+ return priority.compareTo(o.priority);
+ }
+
+ /**
+ * Cancel a parked {@link Task}.
+ *
+ * <p>Tasks awaiting in {@link #onStart(Task)} to be un-parked can be interrupted using this
+ * method.
+ */
+ public void cancel() {
+ latch.cancel();
+ }
+
+ public boolean isEqualTo(Task task) {
+ return this.task.taskId == task.taskId;
+ }
+ }
+
+ private class CancellableCountDownLatch extends CountDownLatch {
+ protected volatile boolean cancelled = false;
+
+ public CancellableCountDownLatch(int count) {
+ super(count);
+ }
+
+ /**
+ * Unblocks threads which are waiting until the latch has counted down to zero.
+ *
+ * <p>If the current count is zero, then this method returns immediately.
+ *
+ * <p>If the current count is greater than zero, then it decrements until the count reaches
+ * zero and causes all threads waiting on the latch using {@link CountDownLatch#await()} to
+ * throw an {@link InterruptedException}.
+ */
+ public void cancel() {
+ if (getCount() == 0) {
+ return;
+ }
+ this.cancelled = true;
+ while (getCount() > 0) {
+ countDown();
+ }
+ }
+
+ @Override
+ public void await() throws InterruptedException {
+ super.await();
+ if (cancelled) {
+ throw new InterruptedException();
+ }
+ }
+ }
+
private final ConcurrentHashMap<Integer, Task<?>> all;
private final ConcurrentHashMap<Runnable, Long> nanosPeriodByRunnable;
private final String queueName;
+ private final AtomicLong priorityGenerator = new AtomicLong();
+ private final PriorityBlockingQueue<ParkedTask> parked = new PriorityBlockingQueue<>();
Executor(int corePoolSize, final String queueName) {
super(
@@ -488,7 +609,17 @@
}
void remove(Task<?> task) {
- all.remove(task.getTaskId(), task);
+ boolean isRemoved = all.remove(task.getTaskId(), task);
+ if (isRemoved && !listeners.isEmpty()) {
+ cancelIfParked(task);
+ }
+ }
+
+ void cancelIfParked(Task<?> task) {
+ Optional<ParkedTask> parkedTask = parked.stream().filter(p -> p.isEqualTo(task)).findFirst();
+ if (parkedTask.isPresent()) {
+ parkedTask.get().cancel();
+ }
}
Task<?> getTask(int id) {
@@ -503,12 +634,86 @@
return all.values();
}
+ public void waitUntilReadyToStart(Task<?> task) {
+ if (!listeners.isEmpty() && !isReadyToStart(task)) {
+ ParkedTask parkedTask = new ParkedTask(task);
+ parked.offer(parkedTask);
+ task.runningState.set(Task.State.PARKED);
+ incrementCorePoolSizeBy(1);
+ try {
+ parkedTask.latch.await();
+ } catch (InterruptedException e) {
+ logger.atSevere().withCause(e).log("Parked Task(%s) Interrupted", task);
+ parked.remove(parkedTask);
+ } finally {
+ incrementCorePoolSizeBy(-1);
+ }
+ }
+ }
+
public void onStart(Task<?> task) {
listeners.runEach(extension -> extension.get().onStart(task));
}
public void onStop(Task<?> task) {
listeners.runEach(extension -> extension.get().onStop(task));
+ updateParked();
+ }
+
+ protected boolean isReadyToStart(Task<?> task) {
+ MutableBoolean isReady = new MutableBoolean(true);
+ Set<TaskParker> readyParkers = new HashSet<>();
+ listeners.runEach(
+ extension -> {
+ if (isReady.isTrue()) {
+ TaskListener listener = extension.get();
+ if (listener instanceof TaskParker) {
+ TaskParker parker = (TaskParker) listener;
+ if (parker.isReadyToStart(task)) {
+ readyParkers.add(parker);
+ } else {
+ isReady.setFalse();
+ }
+ }
+ }
+ });
+
+ if (isReady.isFalse()) {
+ listeners.runEach(
+ extension -> {
+ TaskListener listener = extension.get();
+ if (readyParkers.contains(listener)) {
+ ((TaskParker) listener).onNotReadyToStart(task);
+ }
+ });
+ }
+ return isReady.getValue();
+ }
+
+ public void updateParked() {
+ ParkedTask ready = parked.poll();
+ if (ready == null) {
+ return;
+ }
+ List<ParkedTask> notReady = new ArrayList<>();
+ while (ready != null && !isReadyToStart(ready.task)) {
+ notReady.add(ready);
+ ready = parked.poll();
+ }
+ parked.addAll(notReady);
+
+ if (ready != null) {
+ ready.latch.countDown();
+ }
+ }
+
+ public synchronized void incrementCorePoolSizeBy(int i) {
+ super.setCorePoolSize(getCorePoolSize() + i);
+ }
+
+ @Override
+ public synchronized void setCorePoolSize(int s) {
+ super.setCorePoolSize(s);
}
}
@@ -556,13 +761,14 @@
// Ordered like this so ordinal matches the order we would
// prefer to see tasks sorted in: done before running,
// stopping before running, running before starting,
- // starting before ready, ready before sleeping.
+ // starting before parked, parked before ready, ready before sleeping.
//
DONE,
CANCELLED,
STOPPING,
RUNNING,
STARTING,
+ PARKED,
READY,
SLEEPING,
OTHER
@@ -694,12 +900,14 @@
@Override
public void run() {
- if (runningState.compareAndSet(null, State.STARTING)) {
+ if (runningState.compareAndSet(null, State.READY)) {
String oldThreadName = Thread.currentThread().getName();
try {
+ Thread.currentThread().setName(oldThreadName + "[" + this + "]");
+ executor.waitUntilReadyToStart(this); // Transitions to PARKED while not ready to start
+ runningState.set(State.STARTING);
executor.onStart(this);
runningState.set(State.RUNNING);
- Thread.currentThread().setName(oldThreadName + "[" + this + "]");
task.run();
} finally {
Thread.currentThread().setName(oldThreadName);
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 4194275..b849719 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -26,6 +26,7 @@
"//lib:args4j",
"//lib:guava",
"//lib:jgit",
+ "//lib:servlet-api",
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
"//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index a5e20c6..536e1fb 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -38,6 +38,7 @@
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
@@ -112,6 +113,7 @@
import com.google.gerrit.server.CreateGroupPermissionSyncer;
import com.google.gerrit.server.DeadlineChecker;
import com.google.gerrit.server.DraftCommentsReader;
+import com.google.gerrit.server.ExceptionHook;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.InvalidDeadlineException;
import com.google.gerrit.server.PatchSetUtil;
@@ -121,6 +123,8 @@
import com.google.gerrit.server.RequestListener;
import com.google.gerrit.server.Sequences;
import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.cancellation.RequestCancelledException;
import com.google.gerrit.server.cancellation.RequestStateContext;
@@ -145,6 +149,7 @@
import com.google.gerrit.server.git.ReceivePackInitializer;
import com.google.gerrit.server.git.TagCache;
import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.receive.RejectionReason.MetricBucket;
import com.google.gerrit.server.git.validators.CommentCountValidator;
import com.google.gerrit.server.git.validators.CommentSizeValidator;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
@@ -230,6 +235,7 @@
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
+import java.util.logging.Level;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
@@ -333,6 +339,7 @@
private static class Metrics {
private final Counter0 psRevisionMissing;
private final Counter3<String, String, String> pushCount;
+ private final Counter3<String, String, Integer> rejectCount;
@Inject
Metrics(MetricMaker metricMaker) {
@@ -357,6 +364,19 @@
"The type of the update (CREATE, UPDATE, CREATE/UPDATE,"
+ " UPDATE_NONFASTFORWARD, DELETE).")
.build());
+ rejectCount =
+ metricMaker.newCounter(
+ "receivecommits/reject_count",
+ new Description("number of rejected pushes"),
+ Field.ofString("kind", (metadataBuilder, fieldValue) -> {})
+ .description("The push kind (direct vs. magic).")
+ .build(),
+ Field.ofString("reason", (metadataBuilder, fieldValue) -> {})
+ .description("The rejection reason.")
+ .build(),
+ Field.ofInteger("status", (metadataBuilder, fieldValue) -> {})
+ .description("The HTTP status code.")
+ .build());
}
}
@@ -371,6 +391,7 @@
private final BatchUpdates batchUpdates;
private final CancellationMetrics cancellationMetrics;
private final ChangeEditUtil editUtil;
+ private final PluginSetContext<ExceptionHook> exceptionHooks;
private final ChangeIndexer indexer;
private final ChangeInserter.Factory changeInserterFactory;
private final ChangeNotes.Factory notesFactory;
@@ -395,7 +416,6 @@
private final DynamicSet<PerformanceLogger> performanceLoggers;
private final PermissionBackend permissionBackend;
private final ProjectCache projectCache;
- private final Provider<InternalChangeQuery> queryProvider;
private final Provider<MergeOp> mergeOpProvider;
private final Provider<MergeOpRepoManager> ormProvider;
private final ReceiveConfig receiveConfig;
@@ -408,6 +428,7 @@
private final Sequences seq;
private final SetHashtagsOp.Factory hashtagsFactory;
private final SetTopicOp.Factory setTopicFactory;
+ private final ServiceUserClassifier serviceUserClassifier;
private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
private final TagCache tagCache;
private final ProjectConfig.Factory projectConfigFactory;
@@ -462,6 +483,7 @@
ProjectConfig.Factory projectConfigFactory,
@GerritServerConfig Config config,
ChangeEditUtil editUtil,
+ PluginSetContext<ExceptionHook> exceptionHooks,
ChangeIndexer indexer,
ChangeInserter.Factory changeInserterFactory,
ChangeNotes.Factory notesFactory,
@@ -485,7 +507,6 @@
DynamicSet<PerformanceLogger> performanceLoggers,
PermissionBackend permissionBackend,
ProjectCache projectCache,
- Provider<InternalChangeQuery> queryProvider,
Provider<MergeOp> mergeOpProvider,
Provider<MergeOpRepoManager> ormProvider,
PublishCommentsOp.Factory publishCommentsOp,
@@ -498,6 +519,7 @@
Sequences seq,
SetHashtagsOp.Factory hashtagsFactory,
SetTopicOp.Factory setTopicFactory,
+ ServiceUserClassifier serviceUserClassifier,
@SuperprojectUpdateOnSubmission
ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
TagCache tagCache,
@@ -529,8 +551,10 @@
this.deadlineCheckerFactory = deadlineCheckerFactory;
this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory;
this.editUtil = editUtil;
+ this.exceptionHooks = exceptionHooks;
this.hashtagsFactory = hashtagsFactory;
this.setTopicFactory = setTopicFactory;
+ this.serviceUserClassifier = serviceUserClassifier;
this.indexer = indexer;
this.initializers = initializers;
this.mergeOpProvider = mergeOpProvider;
@@ -547,7 +571,6 @@
this.psUtil = psUtil;
this.performanceLoggers = performanceLoggers;
this.publishCommentsOp = publishCommentsOp;
- this.queryProvider = queryProvider;
this.receiveConfig = receiveConfig;
this.refValidatorsFactory = refValidatorsFactory;
this.replaceOpFactory = replaceOpFactory;
@@ -696,9 +719,12 @@
.addRequestStateProvider(
deadlineCheckerFactory.create(start, requestInfo, clientProvidedDeadlineValue))) {
processCommandsUnsafe(commands, progress);
- rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+ rejectRemaining(
+ commands,
+ RejectionReason.create(MetricBucket.INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR));
} catch (InvalidDeadlineException e) {
- rejectRemaining(commands, e.getMessage());
+ rejectRemaining(
+ commands, RejectionReason.create(MetricBucket.INVALID_DEADLINE, e.getMessage()));
} catch (RuntimeException e) {
Optional<RequestCancelledException> requestCancelledException =
RequestCancelledException.getFromCausalChain(e);
@@ -714,7 +740,21 @@
String.format(
" (%s)", requestCancelledException.get().getCancellationMessage().get()));
}
- rejectRemaining(commands, msg.toString());
+
+ MetricBucket metricBucket = MetricBucket.INTERNAL_SERVER_ERROR;
+ switch (requestCancelledException.get().getCancellationReason()) {
+ case CLIENT_CLOSED_REQUEST:
+ metricBucket = MetricBucket.CLIENT_CLOSED_REQUEST;
+ break;
+ case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+ metricBucket = MetricBucket.CLIENT_PROVIDED_DEADLINE_EXCEEDED;
+ break;
+ case SERVER_DEADLINE_EXCEEDED:
+ metricBucket = MetricBucket.SERVER_DEADLINE_EXCEEDED;
+ break;
+ }
+
+ rejectRemaining(commands, RejectionReason.create(metricBucket, msg.toString()));
}
// This sends error messages before the 'done' string of the progress monitor is sent.
@@ -725,11 +765,39 @@
commandProgress.end();
loggingTags = traceContext.getTags();
logger.atFine().log("Processing commands done.");
+ } catch (RuntimeException e) {
+ String formattedCause = getFormattedCause(e).orElse(e.getClass().getSimpleName());
+ int statusCode =
+ getStatus(e).map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
+ logger.at(statusCode < SC_INTERNAL_SERVER_ERROR ? Level.INFO : Level.SEVERE).withCause(e).log(
+ "ReceiveCommits failed due to %s", formattedCause);
+ String pushKind = "magic or direct push";
+ if (serviceUserClassifier.isServiceUser(user.getAccountId())) {
+ pushKind += " by service user";
+ }
+ metrics.rejectCount.increment(pushKind, formattedCause, statusCode);
+ throw e;
}
progress.end();
return result.build();
}
+ private Optional<String> getFormattedCause(Throwable t) {
+ return exceptionHooks.stream()
+ .map(h -> h.formatCause(t))
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst();
+ }
+
+ private Optional<ExceptionHook.Status> getStatus(Throwable err) {
+ return exceptionHooks.stream()
+ .map(h -> h.getStatus(err))
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .findFirst();
+ }
+
// Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
private void processCommandsUnsafe(
Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
@@ -740,7 +808,11 @@
if (!projectState.getProject().getState().permitsWrite()) {
for (ReceiveCommand cmd : commands) {
- reject(cmd, "prohibited by Gerrit: project state does not permit write");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.PROJECT_NOT_WRITABLE,
+ "prohibited by Gerrit: project state does not permit write"));
}
return;
}
@@ -762,7 +834,11 @@
}
if (!magicCommands.isEmpty() && !regularCommands.isEmpty()) {
- rejectRemaining(commands, "cannot combine normal pushes and magic pushes");
+ rejectRemaining(
+ commands,
+ RejectionReason.create(
+ MetricBucket.CANNOT_COMBINE_NORMAL_AND_MAGIC_PUSHES,
+ "cannot combine normal pushes and magic pushes"));
return;
}
@@ -795,7 +871,8 @@
if (first) {
first = false;
} else {
- reject(cmd, "duplicate request");
+ reject(
+ cmd, RejectionReason.create(MetricBucket.DUPLICATE_REQUEST, "duplicate request"));
}
}
} catch (PermissionBackendException | NoSuchProjectException | IOException err) {
@@ -1084,10 +1161,15 @@
}
} catch (ResourceConflictException e) {
addError(e.getMessage());
- reject(magicBranchCmd, "conflict");
+ reject(magicBranchCmd, RejectionReason.create(MetricBucket.CONFLICT, "conflict"));
+ } catch (UnresolvableAccountException e) {
+ logger.atFine().log("Rejecting because account cannot be resolved: %s", e.getMessage());
+ reject(
+ magicBranchCmd,
+ RejectionReason.create(MetricBucket.ACCOUNT_NOT_FOUND, e.getMessage()));
} catch (BadRequestException | UnprocessableEntityException | AuthException e) {
logger.atFine().withCause(e).log("Rejecting due to client error");
- reject(magicBranchCmd, e.getMessage());
+ reject(magicBranchCmd, RejectionReason.create(MetricBucket.CLIENT_ERROR, e.getMessage()));
} catch (RestApiException | IOException | UpdateException e) {
throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
}
@@ -1097,7 +1179,7 @@
submit(newChanges, replaceByChange.values());
} catch (ResourceConflictException e) {
addError(e.getMessage());
- reject(magicBranchCmd, "conflict");
+ reject(magicBranchCmd, RejectionReason.create(MetricBucket.CONFLICT, "conflict"));
} catch (RestApiException
| StorageException
| UpdateException
@@ -1105,7 +1187,9 @@
| ConfigInvalidException
| PermissionBackendException e) {
logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
- reject(magicBranchCmd, "error during submit");
+ reject(
+ magicBranchCmd,
+ RejectionReason.create(MetricBucket.SUBMIT_ERROR, "error during submit"));
}
}
}
@@ -1182,8 +1266,8 @@
magicBranchCmd.setResult(OK);
}
for (ReplaceRequest replace : replaceByChange.values()) {
- String rejectMessage = replace.getRejectMessage();
- if (rejectMessage == null) {
+ Optional<RejectionReason> rejectionReason = replace.getRejectionReason();
+ if (!rejectionReason.isPresent()) {
if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
// Not necessarily the magic branch, so need to set OK on the original
// value.
@@ -1191,7 +1275,7 @@
}
} else {
logger.atFine().log("Rejecting due to message from ReplaceOp");
- reject(replace.inputCommand, rejectMessage);
+ reject(replace.inputCommand, rejectionReason.get());
}
}
}
@@ -1296,7 +1380,7 @@
}
if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
- reject(cmd, "not valid ref");
+ reject(cmd, RejectionReason.create(MetricBucket.INVALID_REF, "not valid ref"));
return;
}
if (RefNames.isNoteDbMetaRef(cmd.getRefName())) {
@@ -1314,14 +1398,20 @@
// NoteDb refs.
reject(
cmd,
- "NoteDb update requires -o "
- + NoteDbPushOption.OPTION_NAME
- + "="
- + NoteDbPushOption.ALLOW.value());
+ RejectionReason.create(
+ MetricBucket.NOTEDB_UPDATE_WITHOUT_ALLOW_OPTION,
+ "NoteDb update requires -o "
+ + NoteDbPushOption.OPTION_NAME
+ + "="
+ + NoteDbPushOption.ALLOW.value()));
return;
}
if (!permissionBackend.user(user).test(GlobalPermission.ACCESS_DATABASE)) {
- reject(cmd, "NoteDb update requires access database permission");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.NOTEDB_UPDATE_WITHOUT_ACCESS_DATABASE_PERMISSION,
+ "NoteDb update requires access database permission"));
return;
}
}
@@ -1344,7 +1434,11 @@
break;
default:
- reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.UNKNOWN_COMMAND_TYPE,
+ "prohibited by Gerrit: unknown command type " + cmd.getType()));
return;
}
@@ -1366,9 +1460,11 @@
if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
reject(
cmd,
- String.format(
- "must be either project owner or have %s permission",
- ProjectPermission.WRITE_CONFIG.describeForException()));
+ RejectionReason.create(
+ MetricBucket.PROJECT_CONFIG_UPDATE_NOT_ALLOWED,
+ String.format(
+ "must be either project owner or have %s permission",
+ ProjectPermission.WRITE_CONFIG.describeForException())));
return;
}
@@ -1384,7 +1480,11 @@
for (ValidationError err : cfg.getValidationErrors()) {
addError(" " + err.getMessage());
}
- reject(cmd, "invalid project configuration");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+ "invalid project configuration"));
logger.atSevere().log(
"User %s tried to push invalid project configuration %s for %s",
user.getLoggableName(), cmd.getNewId().name(), project.getName());
@@ -1395,7 +1495,11 @@
if (oldParent == null) {
// update of the 'All-Projects' project
if (newParent != null) {
- reject(cmd, "invalid project configuration: root project cannot have parent");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+ "invalid project configuration: root project cannot have parent"));
return;
}
} else {
@@ -1406,25 +1510,40 @@
.project(project.getNameKey())
.test(ProjectPermission.WRITE_CONFIG)) {
reject(
- cmd, "invalid project configuration: only project owners can set parent");
+ cmd,
+ RejectionReason.create(
+ MetricBucket.PROJECT_CONFIG_UPDATE_NOT_ALLOWED,
+ "invalid project configuration: only project owners can set parent"));
return;
}
} else {
if (!permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
- reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.PROJECT_CONFIG_UPDATE_NOT_ALLOWED,
+ "invalid project configuration: only Gerrit admin can set parent"));
return;
}
}
}
if (!projectCache.get(newParent).isPresent()) {
- reject(cmd, "invalid project configuration: parent does not exist");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+ "invalid project configuration: parent does not exist"));
return;
}
}
validatePluginConfig(cmd, cfg);
} catch (Exception e) {
- reject(cmd, "invalid project configuration");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+ "invalid project configuration"));
logger.atSevere().withCause(e).log(
"User %s tried to push invalid project configuration %s for %s",
user.getLoggableName(), cmd.getNewId().name(), project.getName());
@@ -1438,8 +1557,10 @@
default:
reject(
cmd,
- "prohibited by Gerrit: don't know how to handle config update of type "
- + cmd.getType());
+ RejectionReason.create(
+ MetricBucket.UNKNOWN_COMMAND_TYPE,
+ "prohibited by Gerrit: don't know how to handle config update of type "
+ + cmd.getType()));
}
}
}
@@ -1468,10 +1589,12 @@
&& !configEntry.isEditable(projectState)) {
reject(
cmd,
- String.format(
- "invalid project configuration: Not allowed to set parameter"
- + " '%s' of plugin '%s' on project '%s'.",
- e.getExportName(), e.getPluginName(), project.getName()));
+ RejectionReason.create(
+ MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+ String.format(
+ "invalid project configuration: Not allowed to set parameter"
+ + " '%s' of plugin '%s' on project '%s'.",
+ e.getExportName(), e.getPluginName(), project.getName())));
continue;
}
@@ -1480,10 +1603,12 @@
&& !configEntry.getPermittedValues().contains(value)) {
reject(
cmd,
- String.format(
- "invalid project configuration: The value '%s' is "
- + "not permitted for parameter '%s' of plugin '%s'.",
- value, e.getExportName(), e.getPluginName()));
+ RejectionReason.create(
+ MetricBucket.INVALID_PROJECT_CONFIGURATION_UPDATE,
+ String.format(
+ "invalid project configuration: The value '%s' is "
+ + "not permitted for parameter '%s' of plugin '%s'.",
+ value, e.getExportName(), e.getPluginName())));
}
}
}
@@ -1494,7 +1619,10 @@
if (repo.resolve(cmd.getRefName()) != null) {
reject(
cmd,
- String.format("Cannot create ref '%s' because it already exists.", cmd.getRefName()));
+ RejectionReason.create(
+ MetricBucket.CANNOT_CREATE_REF_BECAUSE_IT_ALREADY_EXISTS,
+ String.format(
+ "Cannot create ref '%s' because it already exists.", cmd.getRefName())));
return;
}
RevObject obj;
@@ -1522,7 +1650,10 @@
rejectProhibited(cmd, denied);
return;
} catch (ResourceConflictException denied) {
- reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.CONFLICT, "prohibited by Gerrit: " + denied.getMessage()));
return;
}
@@ -1540,7 +1671,8 @@
Optional<AuthException> err = checkRefPermission(cmd, RefPermission.UPDATE);
if (!err.isPresent()) {
if (isHead(cmd) && !isCommit(globalRevWalk, cmd)) {
- reject(cmd, "head must point to commit");
+ reject(
+ cmd, RejectionReason.create(MetricBucket.INVALID_HEAD, "head must point to commit"));
return;
}
if (validRefOperation(cmd)) {
@@ -1570,7 +1702,7 @@
if (obj instanceof RevCommit) {
return true;
}
- reject(cmd, "not a commit");
+ reject(cmd, RejectionReason.create(MetricBucket.NOT_A_COMMIT, "not a commit"));
return false;
}
@@ -1579,10 +1711,16 @@
logger.atFine().log("Deleting %s", cmd);
if (cmd.getRefName().startsWith(REFS_CHANGES)) {
errors.put(CANNOT_DELETE_CHANGES, cmd.getRefName());
- reject(cmd, "cannot delete changes");
+ reject(
+ cmd,
+ RejectionReason.create(MetricBucket.CANNOT_DELETE_CHANGES, "cannot delete changes"));
} else if (isConfigRef(cmd.getRefName())) {
errors.put(CANNOT_DELETE_CONFIG, cmd.getRefName());
- reject(cmd, "cannot delete project configuration");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.CANNOT_DELETE_PROJECT_CONFIGURATION,
+ "cannot delete project configuration"));
}
Optional<AuthException> err = checkRefPermission(cmd, RefPermission.DELETE);
@@ -1592,6 +1730,19 @@
} else {
rejectProhibited(cmd, err.get());
}
+ if (ObjectId.zeroId().equals(cmd.getOldId())) {
+ // Git CLI sends DELETE 0..0 0...0 when the server doesn't send the deleted ref during
+ // negotiation. The server usually doesn't send it when ref doesn't exist or when it
+ // is not visible to a caller - so the message that the ref doesn't exist should be ok
+ // here.
+ // Without this check, such delete always fails with the "internal error" message, caused
+ // by the checkArgument in the ChainedReceiveCommands#add.
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.REF_NOT_FOUND,
+ String.format("The ref %s doesn't exist", cmd.getRefName())));
+ }
}
}
@@ -1644,18 +1795,18 @@
reject(cmd, prohibited(err, cmd.getRefName()));
}
- private static String prohibited(AuthException e, String alreadyDisplayedResource) {
- String msg = e.getMessage();
+ private static RejectionReason prohibited(AuthException e, String alreadyDisplayedResource) {
if (e instanceof PermissionDeniedException) {
PermissionDeniedException pde = (PermissionDeniedException) e;
if (pde.getResource().isPresent()
&& pde.getResource().get().equals(alreadyDisplayedResource)) {
// Avoid repeating resource name if exactly the given name was already displayed by the
// generic git push machinery.
- msg = PermissionDeniedException.MESSAGE_PREFIX + pde.describePermission();
+ return RejectionReason.create(pde);
}
}
- return "prohibited by Gerrit: " + msg;
+ return RejectionReason.create(
+ MetricBucket.PROHIBITED, "prohibited by Gerrit: " + e.getMessage());
}
static class MagicBranchInput {
@@ -2005,7 +2156,7 @@
} catch (CmdLineException e) {
if (!magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
logger.atFine().log("Invalid branch syntax");
- reject(cmd, e.getMessage());
+ reject(cmd, RejectionReason.create(MetricBucket.INVALID_BRANCH_SYNTAX, e.getMessage()));
return;
}
ref = null; // never happens
@@ -2014,14 +2165,20 @@
if (magicBranch.skipValidation) {
reject(
cmd,
- String.format(
- "\"--%s\" option is only supported for direct push", PUSH_OPTION_SKIP_VALIDATION));
+ RejectionReason.create(
+ MetricBucket.CANNOT_SKIP_VALIDATION_FOR_MAGIC_PUSH,
+ String.format(
+ "\"--%s\" option is only supported for direct push",
+ PUSH_OPTION_SKIP_VALIDATION)));
return;
}
if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
reject(
- cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
+ cmd,
+ RejectionReason.create(
+ MetricBucket.TOPIC_TOO_LARGE,
+ String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH)));
}
if (magicBranch.cmdLineParser.wasHelpRequestedByOption()) {
@@ -2043,7 +2200,7 @@
}
addMessage(w.toString());
- reject(cmd, "see help");
+ reject(cmd, RejectionReason.create(MetricBucket.HELP_REQUESTED, "see help"));
return;
}
if (projectState.isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
@@ -2061,9 +2218,11 @@
logger.atFine().log("Ref %s not found", ref);
if (ref.startsWith(Constants.R_HEADS)) {
String n = ref.substring(Constants.R_HEADS.length());
- reject(cmd, "branch " + n + " not found");
+ reject(
+ cmd,
+ RejectionReason.create(MetricBucket.BRANCH_NOT_FOUND, "branch " + n + " not found"));
} else {
- reject(cmd, ref + " not found");
+ reject(cmd, RejectionReason.create(MetricBucket.REF_NOT_FOUND, ref + " not found"));
}
return;
}
@@ -2081,7 +2240,11 @@
}
if (magicBranch.isPrivate && magicBranch.removePrivate) {
- reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_OPTION,
+ "the options 'private' and 'remove-private' are mutually exclusive"));
return;
}
@@ -2094,17 +2257,26 @@
magicBranch.isPrivate || (privateByDefault && !magicBranch.removePrivate);
if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
- reject(cmd, "private changes are disabled");
+ reject(
+ cmd,
+ RejectionReason.create(MetricBucket.INVALID_OPTION, "private changes are disabled"));
return;
}
if (magicBranch.workInProgress && magicBranch.ready) {
- reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_OPTION,
+ "the options 'wip' and 'ready' are mutually exclusive"));
return;
}
if (magicBranch.publishComments && magicBranch.noPublishComments) {
reject(
- cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+ cmd,
+ RejectionReason.create(
+ MetricBucket.INVALID_OPTION,
+ "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive"));
return;
}
@@ -2131,17 +2303,25 @@
try {
if (magicBranch.merged) {
if (magicBranch.base != null) {
- reject(cmd, "cannot use merged with base");
+ reject(
+ cmd,
+ RejectionReason.create(MetricBucket.INVALID_OPTION, "cannot use merged with base"));
return;
}
Ref refTip = receivePackRefCache.exactRef(magicBranch.dest.branch());
if (refTip == null) {
- reject(cmd, magicBranch.dest.branch() + " not found");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.BRANCH_NOT_FOUND, magicBranch.dest.branch() + " not found"));
return;
}
RevCommit branchTip = globalRevWalk.parseCommit(refTip.getObjectId());
if (!globalRevWalk.isMergedInto(tip, branchTip)) {
- reject(cmd, "not merged into branch");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.NOT_MERGED_INTO_BRANCH, "not merged into branch"));
return;
}
}
@@ -2163,10 +2343,11 @@
try {
magicBranch.baseCommit.add(globalRevWalk.parseCommit(id));
} catch (IncorrectObjectTypeException notCommit) {
- reject(cmd, "base must be a commit");
+ reject(
+ cmd, RejectionReason.create(MetricBucket.INVALID_BASE, "base must be a commit"));
return;
} catch (MissingObjectException e) {
- reject(cmd, "base not found");
+ reject(cmd, RejectionReason.create(MetricBucket.INVALID_BASE, "base not found"));
return;
} catch (IOException e) {
throw new StorageException(
@@ -2188,7 +2369,10 @@
// branch does not exist yet. This allows to push initial code for review to an empty
// repository and to review an initial project configuration.
if (!ref.equals(readHEAD(repo)) && !ref.equals(RefNames.REFS_CONFIG)) {
- reject(cmd, magicBranch.dest.branch() + " not found");
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.BRANCH_NOT_FOUND, magicBranch.dest.branch() + " not found"));
return;
}
}
@@ -2242,7 +2426,8 @@
globalRevWalk.markStart(tip);
globalRevWalk.markStart(h);
if (globalRevWalk.next() == null) {
- reject(cmd, "no common ancestry");
+ reject(
+ cmd, RejectionReason.create(MetricBucket.NO_COMMON_ANCESTRY, "no common ancestry"));
return false;
}
} finally {
@@ -2285,15 +2470,17 @@
if (change.isClosed()) {
reject(
cmd,
- changeFormatter.changeClosed(
- ChangeReportFormatter.Input.builder().setChange(change).build()));
+ RejectionReason.create(
+ MetricBucket.CHANGE_IS_CLOSED,
+ changeFormatter.changeClosed(
+ ChangeReportFormatter.Input.builder().setChange(change).build())));
return false;
}
ReplaceRequest req =
new ReplaceRequest(globalRevWalk, change.getId(), newCommit, cmd, checkMergedInto);
if (replaceByChange.containsKey(req.ontoChange)) {
- reject(cmd, "duplicate request");
+ reject(cmd, RejectionReason.create(MetricBucket.DUPLICATE_REQUEST, "duplicate request"));
return false;
}
@@ -2426,7 +2613,10 @@
logger.atFine().log("%d changes exceeds limit of %d", n, maxBatchChanges);
reject(
magicBranch.cmd,
- "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
+ RejectionReason.create(
+ MetricBucket.TOO_MANY_CHANGES,
+ "the number of pushed changes in a batch exceeds the max limit "
+ + maxBatchChanges));
return ImmutableList.of();
}
@@ -2469,8 +2659,10 @@
if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
reject(
magicBranch.cmd,
- "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
- + "to override please set the base manually");
+ RejectionReason.create(
+ MetricBucket.CANNOT_PUSH_MERGE_WITH_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+ "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
+ + "to override please set the base manually"));
logger.atFine().log("Rejecting merge commit %s with newChangeForAllNotInTarget", name);
// TODO(dborowitz): Should we early return here?
}
@@ -2498,7 +2690,10 @@
if (newChangeIds.contains(p.changeKey)) {
logger.atFine().log("Multiple commits with Change-Id %s", p.changeKey);
- reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(
+ MetricBucket.DUPLICATE_CHANGE_ID, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES));
return ImmutableList.of();
}
@@ -2514,7 +2709,10 @@
// a different Change-Id. In practice, we should never see
// this error message as Change-Id should be unique per branch.
//
- reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(
+ MetricBucket.DUPLICATE_CHANGE, p.changeKey.get() + " has duplicates"));
return ImmutableList.of();
}
@@ -2528,7 +2726,11 @@
if (pending.size() == 1) {
// There are no commits left to check, all commits in pending were already
// current PatchSet of the corresponding target changes.
- reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(
+ MetricBucket.COMMIT_ALREADY_EXISTS_IN_CHANGE,
+ "commit(s) already exists (as current patchset)"));
} else {
// Commit is already current PatchSet.
// Remove from pending and try next commit.
@@ -2545,7 +2747,9 @@
if (changes.isEmpty()) {
if (!isValidChangeId(p.changeKey.get())) {
- reject(magicBranch.cmd, "invalid Change-Id");
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(MetricBucket.INVALID_CHANGE_ID, "invalid Change-Id"));
return ImmutableList.of();
}
@@ -2553,7 +2757,11 @@
// double check against the existing refs
if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
if (pending.size() == 1) {
- reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(
+ MetricBucket.COMMIT_ALREADY_EXISTS_IN_CHANGE,
+ "commit(s) already exists (as current patchset)"));
return ImmutableList.of();
}
itr.remove();
@@ -2573,11 +2781,15 @@
}
if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
- reject(magicBranch.cmd, "no new changes");
+ reject(
+ magicBranch.cmd, RejectionReason.create(MetricBucket.NO_NEW_CHANGES, "no new changes"));
return ImmutableList.of();
}
if (!newChanges.isEmpty() && magicBranch.edit) {
- reject(magicBranch.cmd, "edit is not supported for new changes");
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(
+ MetricBucket.CANNOT_EDIT_NEW_CHANGE, "edit is not supported for new changes"));
return ImmutableList.copyOf(newChanges);
}
@@ -2683,7 +2895,9 @@
+ c.getShortMessage(),
ValidationMessage.Type.ERROR));
}
- reject(magicBranch.cmd, "implicit merges detected");
+ reject(
+ magicBranch.cmd,
+ RejectionReason.create(MetricBucket.IMPLICIT_MERGE, "implicit merges detected"));
}
}
}
@@ -2734,18 +2948,27 @@
private ChangeLookup lookupByChangeKey(RevCommit c, Change.Key key) {
try (TraceTimer traceTimer = newTimer("lookupByChangeKey")) {
- List<ChangeData> byBranchKeyExactMatch =
- queryProvider.get().byBranchKey(magicBranch.dest, key).stream()
- .filter(cd -> cd.change().getKey().equals(key))
- .collect(toList());
- return new ChangeLookup(c, key, byBranchKeyExactMatch);
+ List<ChangeData> byBranchKey =
+ retryHelper
+ .changeIndexQuery(
+ "lookupByChangeKey",
+ q ->
+ q.byBranchKey(magicBranch.dest, key).stream()
+ .filter(cd -> cd.change().getKey().equals(key))
+ .collect(toList()))
+ .call();
+ return new ChangeLookup(c, key, byBranchKey);
}
}
private ChangeLookup lookupByCommit(RevCommit c) {
try (TraceTimer traceTimer = newTimer("lookupByCommit")) {
- return new ChangeLookup(
- c, null, queryProvider.get().byBranchCommit(magicBranch.dest, c.getName()));
+ List<ChangeData> byBranchCommit =
+ retryHelper
+ .changeIndexQuery(
+ "lookupByCommit", q -> q.byBranchCommit(magicBranch.dest, c.getName()))
+ .call();
+ return new ChangeLookup(c, null, byBranchCommit);
}
}
@@ -3042,7 +3265,10 @@
throws IOException, PermissionBackendException {
try (TraceTimer traceTimer = newTimer("validateNewPatchSetNoteDb")) {
if (notes == null) {
- reject(inputCommand, "change " + ontoChange + " not found");
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.CHANGE_NOT_FOUND, "change " + ontoChange + " not found"));
return false;
}
@@ -3060,7 +3286,10 @@
.limit(100) // Enough for "normal" changes.
.map(PatchSet.Id::getId)
.collect(Collectors.toList())));
- reject(inputCommand, "change " + ontoChange + " missing revisions");
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.MISSING_REVISION, "change " + ontoChange + " missing revisions"));
return false;
}
@@ -3068,24 +3297,36 @@
// Not allowed to create a new patch set if the current patch set is locked.
if (psUtil.isPatchSetLocked(notes)) {
- reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.PATCH_SET_LOCKED, "cannot add patch set to " + ontoChange + "."));
return false;
}
if (!permissions.change(notes).test(ChangePermission.ADD_PATCH_SET)) {
- reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.CANNOT_ADD_PATCH_SET,
+ "cannot add patch set to " + ontoChange + "."));
return false;
}
if (change.isClosed()) {
- reject(inputCommand, "change " + ontoChange + " closed");
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.CHANGE_IS_CLOSED, "change " + ontoChange + " closed"));
return false;
} else if (revisions.containsKey(newCommit)) {
reject(
inputCommand,
- String.format(
- "commit %s already exists in change %s",
- newCommit.name().substring(0, 10), change.getId()));
+ RejectionReason.create(
+ MetricBucket.COMMIT_ALREADY_EXISTS_IN_CHANGE,
+ String.format(
+ "commit %s already exists in change %s",
+ newCommit.name().substring(0, 10), change.getId())));
return false;
}
@@ -3096,8 +3337,10 @@
// without the option to turn that off.
reject(
inputCommand,
- "commit already exists (in the project): "
- + existingPatchSetsWithSameCommit.get(0).toRefName());
+ RejectionReason.create(
+ MetricBucket.COMMIT_ALREADY_EXISTS_IN_PROJECT,
+ "commit already exists (in the project): "
+ + existingPatchSetsWithSameCommit.get(0).toRefName()));
return false;
}
@@ -3107,7 +3350,10 @@
// very common error due to users making a new commit rather than
// amending when trying to address review comments.
if (globalRevWalk.isMergedInto(prior, newCommit)) {
- reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.DUPLICATE_CHANGE_ID, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES));
return false;
}
}
@@ -3125,7 +3371,11 @@
&& !user.getAccountId().equals(change.getOwner())) {
if (!permissions.test(ProjectPermission.WRITE_CONFIG)) {
if (!permissions.change(notes).test(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE)) {
- reject(inputCommand, ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP);
+ reject(
+ inputCommand,
+ RejectionReason.create(
+ MetricBucket.CANNOT_TOGGLE_WIP,
+ ONLY_USERS_WITH_TOGGLE_WIP_STATE_PERM_CAN_MODIFY_WIP));
}
}
}
@@ -3192,7 +3442,10 @@
// replace edit
cmd =
new ReceiveCommand(
- edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+ edit.get().getEditCommit(),
+ newCommitId,
+ edit.get().getRefName(),
+ ReceiveCommand.Type.UPDATE_NONFASTFORWARD);
} else {
// delete old edit ref on rebase
prev =
@@ -3290,9 +3543,8 @@
}
}
- @Nullable
- String getRejectMessage() {
- return replaceOp != null ? replaceOp.getRejectMessage() : null;
+ Optional<RejectionReason> getRejectionReason() {
+ return replaceOp != null ? replaceOp.getRejectionReason() : Optional.empty();
}
Optional<String> getOutdatedApprovalsMessage() {
@@ -3387,7 +3639,7 @@
messages.addAll(refValidators.validateForRefOperation());
} catch (RefOperationValidationException e) {
messages.addAll(e.getMessages());
- reject(cmd, e.getMessage());
+ reject(cmd, RejectionReason.create(MetricBucket.REJECTED_BY_VALIDATOR, e.getMessage()));
return false;
}
@@ -3412,7 +3664,11 @@
&& pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION);
if (skipValidation) {
if (projectState.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
- reject(cmd, "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION);
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.SIGNED_OFF_BY_REQUIRED,
+ "requireSignedOffBy prevents option " + PUSH_OPTION_SKIP_VALIDATION));
return;
}
@@ -3423,7 +3679,11 @@
return;
}
if (!Iterables.isEmpty(rejectCommits)) {
- reject(cmd, "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+ reject(
+ cmd,
+ RejectionReason.create(
+ MetricBucket.BANNED_COMMIT,
+ "reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION));
}
}
@@ -3447,8 +3707,11 @@
logger.atFine().log("Number of new commits exceeds limit of %d", limit);
reject(
cmd,
- String.format(
- "more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
+ RejectionReason.create(
+ MetricBucket.TOO_MANY_COMMITS,
+ String.format(
+ "more than %d commits, and %s not set",
+ limit, PUSH_OPTION_SKIP_VALIDATION)));
return;
}
if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
@@ -3689,17 +3952,24 @@
return TraceContext.newTimer(clazz.getSimpleName() + "#" + name, metadataBuilder.build());
}
- private static void reject(ReceiveCommand cmd, String why) {
- logger.atFine().log("Rejecting command '%s': %s", cmd, why);
- cmd.setResult(REJECTED_OTHER_REASON, why);
+ private void reject(ReceiveCommand cmd, RejectionReason reason) {
+ logger.atFine().log("Rejecting command '%s': %s", cmd, reason.why());
+
+ String pushKind = (MagicBranch.isMagicBranch(cmd.getRefName()) ? "magic push" : "direct push");
+ if (serviceUserClassifier.isServiceUser(user.getAccountId())) {
+ pushKind += " by service user";
+ }
+ metrics.rejectCount.increment(pushKind, reason.metricBucket(), reason.statusCode());
+
+ cmd.setResult(REJECTED_OTHER_REASON, reason.why());
}
- private static void rejectRemaining(Collection<ReceiveCommand> commands, String why) {
- rejectRemaining(commands.stream(), why);
+ private void rejectRemaining(Collection<ReceiveCommand> commands, RejectionReason reason) {
+ rejectRemaining(commands.stream(), reason);
}
- private static void rejectRemaining(Stream<ReceiveCommand> commands, String why) {
- commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, why));
+ private void rejectRemaining(Stream<ReceiveCommand> commands, RejectionReason reason) {
+ commands.filter(cmd -> cmd.getResult() == NOT_ATTEMPTED).forEach(cmd -> reject(cmd, reason));
}
private static boolean isHead(ReceiveCommand cmd) {
diff --git a/java/com/google/gerrit/server/git/receive/RejectionReason.java b/java/com/google/gerrit/server/git/receive/RejectionReason.java
new file mode 100644
index 0000000..ef36538
--- /dev/null
+++ b/java/com/google/gerrit/server/git/receive/RejectionReason.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.git.receive;
+
+import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
+import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
+import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+import static javax.servlet.http.HttpServletResponse.SC_METHOD_NOT_ALLOWED;
+import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
+import static javax.servlet.http.HttpServletResponse.SC_OK;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.server.permissions.PermissionDeniedException;
+import java.util.Locale;
+
+@AutoValue
+public abstract class RejectionReason {
+ private static final int SC_CLIENT_CLOSED_REQUEST = 499;
+
+ public enum MetricBucket {
+ ACCOUNT_NOT_FOUND(SC_NOT_FOUND),
+ CANNOT_ADD_PATCH_SET(SC_FORBIDDEN),
+ CANNOT_COMBINE_NORMAL_AND_MAGIC_PUSHES(SC_BAD_REQUEST),
+ CANNOT_CREATE_REF_BECAUSE_IT_ALREADY_EXISTS(SC_CONFLICT),
+ CANNOT_DELETE_CHANGES(SC_METHOD_NOT_ALLOWED),
+ CANNOT_DELETE_PROJECT_CONFIGURATION(SC_METHOD_NOT_ALLOWED),
+ CANNOT_EDIT_NEW_CHANGE(SC_CONFLICT),
+ CANNOT_PUSH_MERGE_WITH_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET(SC_BAD_REQUEST),
+ CANNOT_SKIP_VALIDATION_FOR_MAGIC_PUSH(SC_BAD_REQUEST),
+ CANNOT_TOGGLE_WIP(SC_FORBIDDEN),
+ CHANGE_IS_CLOSED(SC_CONFLICT),
+ CHANGE_NOT_FOUND(SC_NOT_FOUND),
+ CLIENT_CLOSED_REQUEST(SC_CLIENT_CLOSED_REQUEST),
+ CLIENT_ERROR(SC_BAD_REQUEST),
+ CLIENT_PROVIDED_DEADLINE_EXCEEDED(SC_REQUEST_TIMEOUT),
+ COMMIT_ALREADY_EXISTS_IN_CHANGE(SC_CONFLICT),
+ COMMIT_ALREADY_EXISTS_IN_PROJECT(SC_CONFLICT),
+ CONFLICT(SC_CONFLICT),
+ BANNED_COMMIT(SC_CONFLICT),
+ BRANCH_NOT_FOUND(SC_NOT_FOUND),
+ DUPLICATE_CHANGE(SC_BAD_REQUEST),
+ DUPLICATE_CHANGE_ID(SC_BAD_REQUEST),
+ DUPLICATE_REQUEST(SC_BAD_REQUEST),
+ HELP_REQUESTED(SC_OK),
+ IMPLICIT_MERGE(SC_BAD_REQUEST),
+ INTERNAL_SERVER_ERROR(SC_INTERNAL_SERVER_ERROR),
+ INVALID_BASE(SC_BAD_REQUEST),
+ INVALID_BRANCH_SYNTAX(SC_BAD_REQUEST),
+ INVALID_CHANGE_ID(SC_BAD_REQUEST),
+ INVALID_DEADLINE(SC_BAD_REQUEST),
+ INVALID_HEAD(SC_BAD_REQUEST),
+ INVALID_OPTION(SC_BAD_REQUEST),
+ INVALID_PROJECT_CONFIGURATION_UPDATE(SC_BAD_REQUEST),
+ INVALID_REF(SC_BAD_REQUEST),
+ MISSING_REVISION(SC_INTERNAL_SERVER_ERROR),
+ NO_COMMON_ANCESTRY(SC_BAD_REQUEST),
+ NO_NEW_CHANGES(SC_BAD_REQUEST),
+ NOT_A_COMMIT(SC_BAD_REQUEST),
+ NOT_MERGED_INTO_BRANCH(SC_BAD_REQUEST),
+ NOTEDB_UPDATE_WITHOUT_ACCESS_DATABASE_PERMISSION(SC_FORBIDDEN),
+ NOTEDB_UPDATE_WITHOUT_ALLOW_OPTION(SC_BAD_REQUEST),
+ PATCH_SET_LOCKED(SC_CONFLICT),
+ PROHIBITED(SC_FORBIDDEN),
+ PROJECT_CONFIG_UPDATE_NOT_ALLOWED(SC_FORBIDDEN),
+ PROJECT_NOT_WRITABLE(SC_CONFLICT),
+ REF_NOT_FOUND(SC_NOT_FOUND),
+ REJECTED_BY_VALIDATOR(SC_BAD_REQUEST),
+ SERVER_DEADLINE_EXCEEDED(SC_INTERNAL_SERVER_ERROR),
+ SIGNED_OFF_BY_REQUIRED(SC_BAD_REQUEST),
+ SUBMIT_ERROR(SC_INTERNAL_SERVER_ERROR),
+ TOPIC_TOO_LARGE(SC_BAD_REQUEST),
+ TOO_MANY_CHANGES(SC_BAD_REQUEST),
+ TOO_MANY_COMMITS(SC_BAD_REQUEST),
+ UNKNOWN_COMMAND_TYPE(SC_BAD_REQUEST);
+
+ private final int statusCode;
+
+ private MetricBucket(int statusCode) {
+ this.statusCode = statusCode;
+ }
+ }
+
+ public static RejectionReason create(MetricBucket metricBucket, String why) {
+ return new AutoValue_RejectionReason(metricBucket.statusCode, metricBucket.name(), why);
+ }
+
+ public static RejectionReason create(PermissionDeniedException permissionDenied) {
+ return new AutoValue_RejectionReason(
+ SC_FORBIDDEN,
+ "CANNOT_"
+ + permissionDenied
+ .getPermission()
+ .permissionName()
+ .toUpperCase(Locale.US)
+ .replaceAll(" ", "_"),
+ "prohibited by Gerrit: "
+ + PermissionDeniedException.MESSAGE_PREFIX
+ + permissionDenied.describePermission());
+ }
+
+ public abstract int statusCode();
+
+ public abstract String metricBucket();
+
+ public abstract String why();
+}
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index c0ffde3..e31f3ac 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -64,6 +64,7 @@
import com.google.gerrit.server.extensions.events.RevisionCreated;
import com.google.gerrit.server.git.MergedByPushOp;
import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
+import com.google.gerrit.server.git.receive.RejectionReason.MetricBucket;
import com.google.gerrit.server.git.validators.TopicValidator;
import com.google.gerrit.server.mail.MailUtil.MailRecipients;
import com.google.gerrit.server.notedb.ChangeNotes;
@@ -158,7 +159,7 @@
private ChangeKind changeKind;
private String mailMessage;
private ApprovalCopier.Result approvalCopierResult;
- private String rejectMessage;
+ private RejectionReason rejectionReason;
private MergedByPushOp mergedByPushOp;
private ReviewerModificationList reviewerAdditions;
private MailRecipients oldRecipients;
@@ -262,7 +263,7 @@
notes = ctx.getNotes();
Change change = notes.getChange();
if (change == null || change.isClosed()) {
- rejectMessage = CHANGE_IS_CLOSED;
+ rejectionReason = RejectionReason.create(MetricBucket.CHANGE_IS_CLOSED, CHANGE_IS_CLOSED);
return false;
}
if (groups.isEmpty()) {
@@ -453,6 +454,8 @@
+ ".";
case TRIVIAL_REBASE:
return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
+ case TRIVIAL_REBASE_WITH_MESSAGE_UPDATE:
+ return ": Patch Set " + priorPatchSetId.get() + " was rebased. Commit message was updated.";
case NO_CHANGE:
return ": New patch set was added with same tree, parent "
+ (commit.getParentCount() != 1 ? "trees" : "tree")
@@ -588,8 +591,8 @@
return notes.getChange();
}
- public String getRejectMessage() {
- return rejectMessage;
+ public Optional<RejectionReason> getRejectionReason() {
+ return Optional.ofNullable(rejectionReason);
}
public Optional<String> getOutdatedApprovalsMessage() {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 5c7d524..2311240 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -356,6 +356,7 @@
throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages);
}
} else if (idList.size() > 1) {
+ messages.add(getMultipleChangeIdsErrorMsg(idList));
throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
} else {
String v = idList.get(0).trim();
@@ -391,6 +392,24 @@
ValidationMessage.Type.ERROR);
}
+ private CommitValidationMessage getMultipleChangeIdsErrorMsg(List<String> idList) {
+ return new CommitValidationMessage(
+ MULTIPLE_CHANGE_ID_MSG
+ + "\n"
+ + "\nHint: the following Change-Ids were found:\n"
+ + idList.stream()
+ .map(
+ id ->
+ "* "
+ + id
+ + " ["
+ + (CHANGE_ID.matcher(id.trim()).matches() ? "VALID" : "INVALID")
+ + "]")
+ .collect(Collectors.joining("\n"))
+ + "\n",
+ ValidationMessage.Type.ERROR);
+ }
+
private String getCommitMessageHookInstallationHint() {
if (installCommitMsgHookCommand != null) {
return installCommitMsgHookCommand;
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
index 07d9b90..2f6c420 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -30,30 +30,31 @@
* Creates a performance log record without meta data.
*
* @param operation the name of operation the is was performed
- * @param durationMs the execution time in milliseconds
+ * @param durationNanos the execution time in nanoseconds
* @return the performance log record
*/
- public static PerformanceLogRecord create(String operation, long durationMs) {
+ public static PerformanceLogRecord create(String operation, long durationNanos) {
return new AutoValue_PerformanceLogRecord(
- operation, durationMs, Instant.now(), Optional.empty());
+ operation, durationNanos, Instant.now(), Optional.empty());
}
/**
* Creates a performance log record with meta data.
*
* @param operation the name of operation the is was performed
- * @param durationMs the execution time in milliseconds
+ * @param durationNanos the execution time in nanoseconds
* @param metadata metadata
* @return the performance log record
*/
- public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
+ public static PerformanceLogRecord create(
+ String operation, long durationNanos, Metadata metadata) {
return new AutoValue_PerformanceLogRecord(
- operation, durationMs, Instant.now(), Optional.of(metadata));
+ operation, durationNanos, Instant.now(), Optional.of(metadata));
}
public abstract String operation();
- public abstract long durationMs();
+ public abstract long durationNanos();
public abstract Instant endTime();
@@ -61,9 +62,9 @@
void writeTo(PerformanceLogger performanceLogger) {
if (metadata().isPresent()) {
- performanceLogger.log(operation(), durationMs(), endTime(), metadata().get());
+ performanceLogger.logNanos(operation(), durationNanos(), endTime(), metadata().get());
} else {
- performanceLogger.log(operation(), durationMs(), endTime());
+ performanceLogger.logNanos(operation(), durationNanos(), endTime());
}
}
}
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
index bed53ba..fa61eb4 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogger.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -34,18 +34,18 @@
* Record the execution time of an operation in a performance log.
*
* @param operation operation that was performed
- * @param durationMs time that the execution of the operation took (in milliseconds)
+ * @param durationNanos time that the execution of the operation took (in nanoseconds)
*/
- default void log(String operation, long durationMs, Instant endTime) {
- log(operation, durationMs, endTime, Metadata.empty());
+ default void logNanos(String operation, long durationNanos, Instant endTime) {
+ logNanos(operation, durationNanos, endTime, Metadata.empty());
}
/**
* Record the execution time of an operation in a performance log.
*
* @param operation operation that was performed
- * @param durationMs time that the execution of the operation took (in milliseconds)
+ * @param durationNanos time that the execution of the operation took (in nanoseconds)
* @param metadata metadata
*/
- void log(String operation, long durationMs, Instant endTime, Metadata metadata);
+ void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata);
}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index fb698f7..a5eef65 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -188,10 +188,11 @@
private TraceTimer(String operation) {
this(
() -> logger.atFine().log("Starting timer for %s", operation),
- elapsedMs -> {
+ elapsedNanos -> {
LoggingContext.getInstance()
- .addPerformanceLogRecord(() -> PerformanceLogRecord.create(operation, elapsedMs));
- logger.atFine().log("%s done (%d ms)", operation, elapsedMs);
+ .addPerformanceLogRecord(
+ () -> PerformanceLogRecord.create(operation, elapsedNanos));
+ logger.atFine().log("%s done (%.2f ms)", operation, elapsedNanos / 1000000.0);
});
}
@@ -200,12 +201,13 @@
() ->
logger.atFine().log(
"Starting timer for %s (%s)", operation, metadata.toStringForLoggingLazy()),
- elapsedMs -> {
+ elapsedNanos -> {
LoggingContext.getInstance()
.addPerformanceLogRecord(
- () -> PerformanceLogRecord.create(operation, elapsedMs, metadata));
+ () -> PerformanceLogRecord.create(operation, elapsedNanos, metadata));
logger.atFine().log(
- "%s (%s) done (%d ms)", operation, metadata.toStringForLoggingLazy(), elapsedMs);
+ "%s (%s) done (%.2f ms)",
+ operation, metadata.toStringForLoggingLazy(), elapsedNanos / 1000000.0);
});
}
@@ -219,7 +221,7 @@
@Override
public void close() {
stopwatch.stop();
- doneLogFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+ doneLogFn.accept(stopwatch.elapsed(TimeUnit.NANOSECONDS));
RequestStateContext.abortIfCancelled();
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index a7da7ad..233998d 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -54,6 +54,9 @@
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.query.change.ChangeData;
@@ -595,8 +598,12 @@
private void loadRobotComments() {
if (robotCommentNotes == null) {
- robotCommentNotes = new RobotCommentNotes(args, change);
- robotCommentNotes.load();
+ try (TraceTimer timer =
+ TraceContext.newTimer(
+ "Load Robot Comments", Metadata.builder().changeId(change.getId().get()).build())) {
+ robotCommentNotes = new RobotCommentNotes(args, change);
+ robotCommentNotes.load();
+ }
}
}
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
index 622f0cf..ba7caed 100644
--- a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -53,7 +53,8 @@
protected abstract String permissionPrefix();
- protected String permissionName() {
+ @Override
+ public String permissionName() {
if (forUser == ON_BEHALF_OF) {
return permissionPrefix() + "As";
}
@@ -119,8 +120,6 @@
return label.value();
}
- public abstract String permissionName();
-
@Override
public final String describeForException() {
if (forUser == ON_BEHALF_OF) {
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 664ffa2..5d79d09 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -31,19 +31,26 @@
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.assistedinject.Assisted;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
+import javax.inject.Inject;
/** Access control management for a user accessing a single change. */
-class ChangeControl {
+public class ChangeControl {
+ public interface Factory {
+ ChangeControl create(RefControl refControl, ChangeData changeData);
+ }
+
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final RefControl refControl;
private final ChangeData changeData;
- ChangeControl(RefControl refControl, ChangeData changeData) {
+ @Inject
+ protected ChangeControl(@Assisted RefControl refControl, @Assisted ChangeData changeData) {
this.refControl = refControl;
this.changeData = changeData;
}
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index d9f83c7..b9868dd 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -101,6 +101,11 @@
}
@Override
+ public String permissionName() {
+ return GerritPermission.describeEnumValue(this);
+ }
+
+ @Override
public Optional<String> hintForException() {
return Optional.ofNullable(hint);
}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
index 3f84dff..b5ec4c9 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackendModule.java
@@ -14,23 +14,32 @@
package com.google.gerrit.server.permissions;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.inject.AbstractModule;
+import com.google.inject.PrivateModule;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
/** Binds the default {@link PermissionBackend}. */
-public class DefaultPermissionBackendModule extends AbstractModule {
+public class DefaultPermissionBackendModule extends PrivateModule {
+
@Override
protected void configure() {
- install(new LegacyControlsModule());
+ // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
+ install(new FactoryModuleBuilder().build(DefaultRefFilter.Factory.class));
+ installRefControlFactory();
+ installChangeControlFactory();
+ installProjectControlFactory();
+ // Expose only ProjectControl.Factory, so other modules can't use RefControl and ChangeControl.
+ expose(ProjectControl.Factory.class);
}
- /** Binds legacy ProjectControl, RefControl, ChangeControl. */
- public static class LegacyControlsModule extends FactoryModule {
- @Override
- protected void configure() {
- // TODO(hiesel) Hide ProjectControl, RefControl, ChangeControl related bindings.
- factory(ProjectControl.Factory.class);
- factory(DefaultRefFilter.Factory.class);
- }
+ protected void installProjectControlFactory() {
+ install(new FactoryModuleBuilder().build(ProjectControl.Factory.class));
+ }
+
+ protected void installChangeControlFactory() {
+ install(new FactoryModuleBuilder().build(ChangeControl.Factory.class));
+ }
+
+ protected void installRefControlFactory() {
+ install(new FactoryModuleBuilder().build(RefControl.Factory.class));
}
}
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index f179045..3d8ae4f 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -57,10 +57,10 @@
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
-class DefaultRefFilter {
+public class DefaultRefFilter {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- interface Factory {
+ public interface Factory {
DefaultRefFilter create(ProjectControl projectControl);
}
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index 3429978..d83353c 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -160,4 +160,9 @@
public String describeForException() {
return GerritPermission.describeEnumValue(this);
}
+
+ @Override
+ public String permissionName() {
+ return GerritPermission.describeEnumValue(this);
+ }
}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index ac9ac98..dbdd26f 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,6 +21,8 @@
import com.google.common.collect.ImmutableList;
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.entities.BranchNameKey;
import com.google.gerrit.entities.LabelType;
@@ -261,6 +263,19 @@
}
return allowed;
}
+
+ /**
+ * Additional filter for changes query for reducing the cardinality of the results for current
+ * user.
+ *
+ * @return additional query filter to add to all user's change queries, null if no filters are
+ * required.
+ * @since 3.11
+ */
+ @UsedAt(UsedAt.Project.MODULE_VIRTUALHOST)
+ public @Nullable String filterQueryChanges() {
+ return null;
+ }
}
/** PermissionBackend scoped to a user and project. */
diff --git a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
index b9e86cd..a007cf7 100644
--- a/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
+++ b/java/com/google/gerrit/server/permissions/PermissionDeniedException.java
@@ -55,4 +55,8 @@
public Optional<String> getResource() {
return resource;
}
+
+ public GerritPermission getPermission() {
+ return permission;
+ }
}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index fab894e..9a6db5d 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -22,6 +22,7 @@
import com.google.common.collect.Sets;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BranchNameKey;
@@ -38,10 +39,13 @@
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.GroupMembership;
import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.GitReceivePackGroups;
import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
@@ -59,53 +63,54 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
/** Access control management for a user accessing a project's data. */
-class ProjectControl {
- interface Factory {
+public class ProjectControl {
+ public interface Factory {
ProjectControl create(CurrentUser who, ProjectState ps);
}
private final Set<AccountGroup.UUID> uploadGroups;
private final Set<AccountGroup.UUID> receiveGroups;
private final PermissionBackend permissionBackend;
- private final RefVisibilityControl refVisibilityControl;
- private final GitRepositoryManager repositoryManager;
private final CurrentUser user;
private final ProjectState state;
private final PermissionCollection.Factory permissionFilter;
private final DefaultRefFilter.Factory refFilterFactory;
- private final ChangeData.Factory changeDataFactory;
private final AllUsersName allUsersName;
+ private final RefControl.Factory refControlFactory;
+ private final ChangeControl.Factory changeControlFactory;
private List<SectionMatcher> allSections;
private Map<String, RefControl> refControls;
private Boolean declaredOwner;
+ private Config cfg;
@Inject
- ProjectControl(
+ protected ProjectControl(
@GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
@GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
PermissionCollection.Factory permissionFilter,
PermissionBackend permissionBackend,
- RefVisibilityControl refVisibilityControl,
- GitRepositoryManager repositoryManager,
DefaultRefFilter.Factory refFilterFactory,
- ChangeData.Factory changeDataFactory,
AllUsersName allUsersName,
+ @GerritServerConfig Config cfg,
+ RefControl.Factory refControlFactory,
+ ChangeControl.Factory changeControlFactory,
@Assisted CurrentUser who,
@Assisted ProjectState ps) {
this.uploadGroups = uploadGroups;
this.receiveGroups = receiveGroups;
this.permissionFilter = permissionFilter;
this.permissionBackend = permissionBackend;
- this.refVisibilityControl = refVisibilityControl;
- this.repositoryManager = repositoryManager;
this.refFilterFactory = refFilterFactory;
- this.changeDataFactory = changeDataFactory;
this.allUsersName = allUsersName;
+ this.cfg = cfg;
+ this.refControlFactory = refControlFactory;
+ this.changeControlFactory = changeControlFactory;
user = who;
state = ps;
}
@@ -115,7 +120,7 @@
}
ChangeControl controlFor(ChangeData cd) {
- return new ChangeControl(controlForRef(cd.branchOrThrow()), cd);
+ return changeControlFactory.create(controlForRef(cd.branchOrThrow()), cd);
}
RefControl controlForRef(BranchNameKey ref) {
@@ -129,19 +134,17 @@
RefControl ctl = refControls.get(refName);
if (ctl == null) {
PermissionCollection relevant = permissionFilter.filter(access(), refName, user);
- ctl =
- new RefControl(
- changeDataFactory, refVisibilityControl, this, repositoryManager, refName, relevant);
+ ctl = refControlFactory.create(this, refName, relevant);
refControls.put(refName, ctl);
}
return ctl;
}
- CurrentUser getUser() {
+ protected CurrentUser getUser() {
return user;
}
- ProjectState getProjectState() {
+ protected ProjectState getProjectState() {
return state;
}
@@ -290,24 +293,29 @@
}
private boolean canPerformOnAllRefs(String permission, Set<String> ignore) {
- boolean canPerform = false;
- Set<String> patterns = allRefPatterns(permission);
- if (patterns.contains(ALL)) {
- // Only possible if granted on the pattern that
- // matches every possible reference. Check all
- // patterns also have the permission.
- //
- for (String pattern : patterns) {
- if (controlForRef(pattern).canPerform(permission)) {
- canPerform = true;
- } else if (ignore.contains(pattern)) {
- continue;
- } else {
- return false;
+ try (TraceTimer timer =
+ TraceContext.newTimer(
+ "ProjectControl#canPerformOnAllRefs",
+ Metadata.builder().projectName(getProject().getName()).build())) {
+ boolean canPerform = false;
+ Set<String> patterns = allRefPatterns(permission);
+ if (patterns.contains(ALL)) {
+ // Only possible if granted on the pattern that
+ // matches every possible reference. Check all
+ // patterns also have the permission.
+ //
+ for (String pattern : patterns) {
+ if (controlForRef(pattern).canPerform(permission)) {
+ canPerform = true;
+ } else if (ignore.contains(pattern)) {
+ continue;
+ } else {
+ return false;
+ }
}
}
+ return canPerform;
}
- return canPerform;
}
private Set<String> allRefPatterns(String permissionName) {
@@ -347,6 +355,13 @@
}
}
+ @UsedAt(UsedAt.Project.GOOGLE)
+ protected boolean canUpdateConfigWithoutCreatingChange() {
+ // In google, the implementation use more complicated logic - this is why it is placed inside
+ // a ProjectControl.
+ return !cfg.getBoolean("gerrit", "requireChangeForConfigUpdate", false);
+ }
+
private class ForProjectImpl extends ForProject {
private String resourcePath;
@@ -468,6 +483,9 @@
case READ_REFLOG:
case WRITE_CONFIG:
return isOwner();
+
+ case UPDATE_CONFIG_WITHOUT_CREATING_CHANGE:
+ return canUpdateConfigWithoutCreatingChange();
}
throw new PermissionBackendException(perm + " unsupported");
}
diff --git a/java/com/google/gerrit/server/permissions/ProjectPermission.java b/java/com/google/gerrit/server/permissions/ProjectPermission.java
index fc31e96..c3e9740 100644
--- a/java/com/google/gerrit/server/permissions/ProjectPermission.java
+++ b/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -100,7 +100,10 @@
READ_REFLOG,
/** Can push to at least one reference within the repository. */
- PUSH_AT_LEAST_ONE_REF("push to at least one ref");
+ PUSH_AT_LEAST_ONE_REF("push to at least one ref"),
+
+ /** Can use restapi to update project config without review. */
+ UPDATE_CONFIG_WITHOUT_CREATING_CHANGE("update config without creating a change using rest api");
private final String description;
@@ -116,4 +119,9 @@
public String describeForException() {
return description != null ? description : GerritPermission.describeEnumValue(this);
}
+
+ @Override
+ public String permissionName() {
+ return GerritPermission.describeEnumValue(this);
+ }
}
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 0aa68e2..ce8548d 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -37,6 +37,8 @@
import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.util.MagicBranch;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
import java.util.Collection;
import java.util.EnumSet;
@@ -47,8 +49,17 @@
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
-/** Manages access control for Git references (aka branches, tags). */
-class RefControl {
+/**
+ * Manages access control for Git references (aka branches, tags).
+ *
+ * <p>Do not use this class directly - instead use {@link ProjectControl} class. This class is
+ * public only because it is extended in google-owned implementation.
+ */
+public class RefControl {
+ public interface Factory {
+ RefControl create(ProjectControl projectControl, String ref, PermissionCollection relevant);
+ }
+
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final ChangeData.Factory changeDataFactory;
@@ -67,22 +78,23 @@
private Boolean canForgeCommitter;
private Boolean hasReadPermissionOnRef;
- RefControl(
+ @Inject
+ protected RefControl(
ChangeData.Factory changeDataFactory,
RefVisibilityControl refVisibilityControl,
- ProjectControl projectControl,
GitRepositoryManager repositoryManager,
- String ref,
- PermissionCollection relevant) {
+ @Assisted ProjectControl projectControl,
+ @Assisted String ref,
+ @Assisted PermissionCollection relevant) {
this.changeDataFactory = changeDataFactory;
this.refVisibilityControl = refVisibilityControl;
- this.projectControl = projectControl;
this.repositoryManager = repositoryManager;
+ this.projectControl = projectControl;
this.refName = ref;
this.relevant = relevant;
}
- ProjectControl getProjectControl() {
+ protected ProjectControl getProjectControl() {
return projectControl;
}
@@ -90,6 +102,10 @@
return projectControl.getUser();
}
+ protected String getRefName() {
+ return refName;
+ }
+
/** Is this user a ref owner? */
boolean isOwner() {
if (owner == null) {
@@ -139,7 +155,7 @@
}
/** Returns true if this user can submit patch sets to this ref */
- boolean canSubmit(boolean isChangeOwner) {
+ protected boolean canSubmit(boolean isChangeOwner) {
if (RefNames.REFS_CONFIG.equals(refName)) {
// Always allow project owners to submit configuration changes.
// Submitting configuration changes modifies the access control
@@ -297,7 +313,7 @@
return pr.getAction() == Action.BLOCK && (!pr.getForce() || withForce);
}
- private PermissionRange toRange(String permissionName, boolean isChangeOwner) {
+ protected PermissionRange toRange(String permissionName, boolean isChangeOwner) {
int blockAllowMin = Integer.MIN_VALUE, blockAllowMax = Integer.MAX_VALUE;
projectLoop:
@@ -444,7 +460,7 @@
pr.getGroup().getUUID().get(),
pr);
LoggingContext.getInstance().addAclLogRecord(logMessage);
- logger.atFine().log(logMessage);
+ logger.atFine().log("%s", logMessage);
}
return true;
}
@@ -460,7 +476,7 @@
projectControl.getProject().getName(),
refName);
LoggingContext.getInstance().addAclLogRecord(logMessage);
- logger.atFine().log(logMessage);
+ logger.atFine().log("%s", logMessage);
}
return false;
}
@@ -609,98 +625,98 @@
public BooleanCondition testCond(RefPermission perm) {
return new PermissionBackendCondition.ForRef(this, perm, getUser());
}
+ }
- private boolean can(RefPermission perm) throws PermissionBackendException {
- switch (perm) {
- case READ:
- /* Internal users such as plugin users should be able to read all refs. */
- if (getUser().isInternalUser()) {
- return true;
- }
- if (refName.startsWith(Constants.R_TAGS)) {
- return isTagVisible();
- }
- return refVisibilityControl.isVisible(projectControl, refName);
- case CREATE:
- // TODO This isn't an accurate test.
- return canPerform(refPermissionName(perm));
- case DELETE:
- return canDelete();
- case UPDATE:
- return canUpdate();
- case FORCE_UPDATE:
- return canForceUpdate();
- case SET_HEAD:
- return projectControl.isOwner();
+ protected boolean can(RefPermission perm) throws PermissionBackendException {
+ switch (perm) {
+ case READ:
+ /* Internal users such as plugin users should be able to read all refs. */
+ if (getUser().isInternalUser()) {
+ return true;
+ }
+ if (refName.startsWith(Constants.R_TAGS)) {
+ return isTagVisible();
+ }
+ return refVisibilityControl.isVisible(projectControl, refName);
+ case CREATE:
+ // TODO This isn't an accurate test.
+ return canPerform(refPermissionName(perm));
+ case DELETE:
+ return canDelete();
+ case UPDATE:
+ return canUpdate();
+ case FORCE_UPDATE:
+ return canForceUpdate();
+ case SET_HEAD:
+ return projectControl.isOwner();
- case FORGE_AUTHOR:
- return canForgeAuthor();
- case FORGE_COMMITTER:
- return canForgeCommitter();
- case FORGE_SERVER:
- return canForgeGerritServerIdentity();
- case MERGE:
- return canUploadMerges();
+ case FORGE_AUTHOR:
+ return canForgeAuthor();
+ case FORGE_COMMITTER:
+ return canForgeCommitter();
+ case FORGE_SERVER:
+ return canForgeGerritServerIdentity();
+ case MERGE:
+ return canUploadMerges();
- case CREATE_CHANGE:
- return canUpload();
+ case CREATE_CHANGE:
+ return canUpload();
- case CREATE_TAG:
- case CREATE_SIGNED_TAG:
- return canPerform(refPermissionName(perm));
+ case CREATE_TAG:
+ case CREATE_SIGNED_TAG:
+ return canPerform(refPermissionName(perm));
- case UPDATE_BY_SUBMIT:
- return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
+ case UPDATE_BY_SUBMIT:
+ return projectControl.controlForRef(MagicBranch.NEW_CHANGE + refName).canSubmit(true);
- case READ_PRIVATE_CHANGES:
- return canPerform(Permission.VIEW_PRIVATE_CHANGES);
+ case READ_PRIVATE_CHANGES:
+ return canPerform(Permission.VIEW_PRIVATE_CHANGES);
- case READ_CONFIG:
- return projectControl
- .controlForRef(RefNames.REFS_CONFIG)
- .canPerform(RefPermission.READ.name());
- case WRITE_CONFIG:
- return isOwner();
+ case READ_CONFIG:
+ return projectControl
+ .controlForRef(RefNames.REFS_CONFIG)
+ .canPerform(RefPermission.READ.name());
+ case WRITE_CONFIG:
+ return isOwner();
- case SKIP_VALIDATION:
- return canForgeAuthor()
- && canForgeCommitter()
- && canForgeGerritServerIdentity()
- && canUploadMerges();
- }
- throw new PermissionBackendException(perm + " unsupported");
+ case SKIP_VALIDATION:
+ return canForgeAuthor()
+ && canForgeCommitter()
+ && canForgeGerritServerIdentity()
+ && canUploadMerges();
+ }
+ throw new PermissionBackendException(perm + " unsupported");
+ }
+
+ private boolean isTagVisible() throws PermissionBackendException {
+ if (projectControl.asForProject().test(ProjectPermission.READ)) {
+ // The user has READ on refs/* with no effective block permission. This is the broadest
+ // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
+ // so we have to assume that these users can see all tags because there could be tags that
+ // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
+ // matches Gerrit's historic behavior.
+ // This makes it so that these users could see commits that they can't see otherwise
+ // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
+ // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
+ // is a negligible risk.
+ return true;
}
- private boolean isTagVisible() throws PermissionBackendException {
- if (projectControl.asForProject().test(ProjectPermission.READ)) {
- // The user has READ on refs/* with no effective block permission. This is the broadest
- // permission one can assign. There is no way to grant access to (specific) tags in Gerrit,
- // so we have to assume that these users can see all tags because there could be tags that
- // aren't reachable by any visible ref while the user can see all non-Gerrit refs. This
- // matches Gerrit's historic behavior.
- // This makes it so that these users could see commits that they can't see otherwise
- // (e.g. a private change ref) if a tag was attached to it. Tags are meant to be used on
- // the regular Git tree that users interact with, not on any of the Gerrit trees, so this
- // is a negligible risk.
- return true;
+ try (Repository repo =
+ repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
+ // Tag visibility requires going through RefFilter because it entails loading all taggable
+ // refs and filtering them all by visibility.
+ Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
+ if (resolvedRef == null) {
+ return false;
}
-
- try (Repository repo =
- repositoryManager.openRepository(projectControl.getProject().getNameKey())) {
- // Tag visibility requires going through RefFilter because it entails loading all taggable
- // refs and filtering them all by visibility.
- Ref resolvedRef = repo.getRefDatabase().exactRef(refName);
- if (resolvedRef == null) {
- return false;
- }
- return projectControl.asForProject()
- .filter(
- ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
- .stream()
- .anyMatch(r -> refName.equals(r.getName()));
- } catch (IOException e) {
- throw new PermissionBackendException(e);
- }
+ return projectControl.asForProject()
+ .filter(
+ ImmutableList.of(resolvedRef), repo, PermissionBackend.RefFilterOptions.defaults())
+ .stream()
+ .anyMatch(r -> refName.equals(r.getName()));
+ } catch (IOException e) {
+ throw new PermissionBackendException(e);
}
}
diff --git a/java/com/google/gerrit/server/permissions/RefPermission.java b/java/com/google/gerrit/server/permissions/RefPermission.java
index 09eed24..34c46af 100644
--- a/java/com/google/gerrit/server/permissions/RefPermission.java
+++ b/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -88,4 +88,9 @@
public String describeForException() {
return description != null ? description : GerritPermission.describeEnumValue(this);
}
+
+ @Override
+ public String permissionName() {
+ return GerritPermission.describeEnumValue(this);
+ }
}
diff --git a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
index c2d1139..bae1fef 100644
--- a/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
+++ b/java/com/google/gerrit/server/permissions/RefVisibilityControl.java
@@ -38,7 +38,7 @@
* authoritatively tell if a ref is accessible by a user.
*/
@Singleton
-class RefVisibilityControl {
+public class RefVisibilityControl {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final PermissionBackend permissionBackend;
diff --git a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 10e5b59..8cfc6f3 100644
--- a/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -52,6 +52,7 @@
import com.google.inject.Singleton;
import com.google.inject.TypeLiteral;
import com.google.inject.internal.UniqueAnnotations;
+import com.google.inject.util.Modules;
import java.lang.annotation.Annotation;
import java.lang.reflect.ParameterizedType;
import java.util.Collections;
@@ -175,16 +176,7 @@
final Module db = copy(dbInjector);
final Module cm = copy(cfgInjector);
final Module sm = copy(sysInjector);
- sysModule =
- new AbstractModule() {
- @Override
- protected void configure() {
- install(copyConfigModule);
- install(db);
- install(cm);
- install(sm);
- }
- };
+ sysModule = Modules.combine(copyConfigModule, db, cm, sm);
}
public void setSshInjector(Injector injector) {
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index c7d62d1..fb7cbe2 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -171,7 +171,7 @@
@Override
protected boolean canReload() {
Attributes main = manifest.getMainAttributes();
- String apiModule = main.getValue("Gerrit-ApiModule");
+ String apiModule = main.getValue(API_MODULE);
if (apiModule != null) {
return false;
}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index b940ccc..4bd195c 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -141,7 +141,7 @@
.maximumWeight(0);
cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
- .maximumWeight(1)
+ .maximumWeight(4)
.loader(Lister.class);
bind(ProjectCacheImpl.class);
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 3fda87a..ea62d7d 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -18,6 +18,7 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
@@ -45,10 +46,16 @@
*
* @throws AuthException if this user is not allowed to remove this approval.
* @throws PermissionBackendException on failure of permission checks.
+ * @throws ResourceConflictException if the approval cannot be removed because the change is
+ * merged
*/
public void checkRemoveReviewer(
ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
- throws PermissionBackendException, AuthException {
+ throws PermissionBackendException, AuthException, ResourceConflictException {
+ if (notes.getChange().isMerged() && approval.value() != 0) {
+ throw new ResourceConflictException("cannot remove votes from merged change");
+ }
+
checkRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
}
@@ -82,35 +89,43 @@
public boolean testRemoveReviewer(
ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
throws PermissionBackendException {
- if (canRemoveReviewerWithoutPermissionCheck(
- permissionBackend, cd.change(), currentUser, reviewer, value)) {
- return true;
- }
- return permissionBackend.user(currentUser).change(cd).test(ChangePermission.REMOVE_REVIEWER);
- }
-
- private void checkRemoveReviewer(
- ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int val)
- throws PermissionBackendException, AuthException {
- if (canRemoveReviewerWithoutPermissionCheck(
- permissionBackend, notes.getChange(), currentUser, reviewer, val)) {
- return;
- }
-
- permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
- }
-
- private static boolean canRemoveReviewerWithoutPermissionCheck(
- PermissionBackend permissionBackend,
- Change change,
- CurrentUser currentUser,
- Account.Id reviewer,
- int value)
- throws PermissionBackendException {
- if (change.isMerged() && value != 0) {
+ if (cd.change().isMerged() && value != 0) {
return false;
}
+ if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
+ return true;
+ }
+
+ // Users with the remove reviewer permission, the branch owner, project
+ // owner and site admin can remove anyone
+ PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
+ PermissionBackend.ForProject forProject = withUser.project(cd.project());
+ return (forProject.ref(cd.change().getDest().branch()).test(RefPermission.WRITE_CONFIG)
+ || withUser.test(GlobalPermission.ADMINISTRATE_SERVER))
+ || withUser.change(cd).test(ChangePermission.REMOVE_REVIEWER);
+ }
+
+ private void checkRemoveReviewer(
+ ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+ throws PermissionBackendException, AuthException {
+ if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, value)) {
+ return;
+ }
+
+ // Users with the remove reviewer permission, the branch owner, project
+ // owner and site admin can remove anyone
+ PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
+ PermissionBackend.ForProject forProject = withUser.project(notes.getProjectName());
+ if (forProject.ref(notes.getChange().getDest().branch()).test(RefPermission.WRITE_CONFIG)
+ || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
+ return;
+ }
+ permissionBackend.user(currentUser).change(notes).check(ChangePermission.REMOVE_REVIEWER);
+ }
+
+ public static boolean canRemoveReviewerWithoutPermissionCheck(
+ Change change, CurrentUser currentUser, Account.Id reviewer, int value) {
if (currentUser.isIdentifiedUser()) {
Account.Id aId = currentUser.getAccountId();
if (aId.equals(reviewer)) {
@@ -120,14 +135,6 @@
}
}
- // Users with the remove reviewer permission, the branch owner, project
- // owner and site admin can remove anyone
- PermissionBackend.WithUser withUser = permissionBackend.user(currentUser);
- PermissionBackend.ForProject forProject = withUser.project(change.getProject());
- if (forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
- || withUser.test(GlobalPermission.ADMINISTRATE_SERVER)) {
- return true;
- }
return false;
}
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
index 95c287a..ecd1fe2 100644
--- a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -19,6 +19,9 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.server.config.GerritImportedServerIds;
import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.inject.Inject;
import com.google.inject.ProvisionException;
import com.google.inject.Singleton;
@@ -68,20 +71,24 @@
}
int changeNum = changeNumId.get();
+ try (TraceTimer timer =
+ TraceContext.newTimer(
+ "ChangeNumberBitmapMaskAlgorithm", Metadata.builder().changeId(changeNum).build())) {
+ if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Change number %d is too large to be converted into a virtual id", changeNum));
+ }
- if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
- throw new IllegalArgumentException(
- String.format(
- "Change number %d is too large to be converted into a virtual id", changeNum));
+ Integer encodedServerId = serverIdCodes.get(changeServerId);
+ if (encodedServerId == null) {
+ throw new IllegalArgumentException(
+ String.format(
+ "ServerId %s is not part of the GerritImportedServerIds", changeServerId));
+ }
+ int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
+
+ return Change.id(virtualId);
}
-
- Integer encodedServerId = serverIdCodes.get(changeServerId);
- if (encodedServerId == null) {
- throw new IllegalArgumentException(
- String.format("ServerId %s is not part of the GerritImportedServerIds", changeServerId));
- }
- int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
-
- return Change.id(virtualId);
}
}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index ade6606..c70f00f 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -23,6 +23,8 @@
* Provides methods required for parsing projects queries.
*
* <p>Internally (at google), this interface has a different implementation, comparing to upstream.
+ * For example, Google disables the `state` predicate which can expose it by setting
+ * `gerrit.projectStatePredicateEnabled = false`.
*/
public interface ProjectQueryBuilder {
String FIELD_LIMIT = "limit";
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index 73991c9..359393e 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
import com.google.gerrit.server.plugins.PluginRestApiModule;
import com.google.gerrit.server.restapi.access.AccessRestApiModule;
import com.google.gerrit.server.restapi.account.AccountRestApiModule;
@@ -42,5 +43,6 @@
install(new PluginRestApiModule());
install(new ProjectRestApiModule());
install(new ProjectRestApiModule.BatchModule());
+ install(new ChangeCleanupRunnerModule());
}
}
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index b1af85e..1c7b1ca 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -33,7 +33,6 @@
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdFactory;
-import com.google.gerrit.server.account.externalids.ExternalIds;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -63,7 +62,6 @@
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
private final Provider<AccountsUpdate> accountsUpdateProvider;
- private final ExternalIds externalIds;
private final ExternalIdFactory externalIdFactory;
@Inject
@@ -71,12 +69,10 @@
Provider<CurrentUser> self,
PermissionBackend permissionBackend,
@ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
- ExternalIds externalIds,
ExternalIdFactory externalIdFactory) {
this.self = self;
this.permissionBackend = permissionBackend;
this.accountsUpdateProvider = accountsUpdateProvider;
- this.externalIds = externalIds;
this.externalIdFactory = externalIdFactory;
}
@@ -99,7 +95,7 @@
.update(
"Set Preferred Email via API",
user.getAccountId(),
- (a, u) -> {
+ (r, a, u) -> {
if (preferredEmail.equals(a.account().preferredEmail())) {
alreadyPreferred.set(true);
} else {
@@ -125,7 +121,7 @@
if (user.hasEmailAddress(preferredEmail)) {
// but Realm says the user is allowed to use this email
ImmutableSet<ExternalId> existingExtIdsWithThisEmail =
- externalIds.byEmail(preferredEmail);
+ r.externalIdsReader().byEmail(preferredEmail);
if (!existingExtIdsWithThisEmail.isEmpty()) {
// but the email is already assigned to another account
logger.atWarning().log(
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
index 7d8c793..8d5247d 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -16,117 +16,88 @@
import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project.NameKey;
import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.PreconditionFailedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.git.CommitUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ContributorAgreementsChecker;
import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.update.context.RefUpdateContext;
-import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.time.Instant;
-import java.time.ZoneId;
import java.util.List;
-import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
import org.eclipse.jgit.revwalk.FooterLine;
import org.eclipse.jgit.revwalk.RevCommit;
@Singleton
public class ApplyPatch implements RestModifyView<ChangeResource, ApplyPatchPatchSetInput> {
- private final ChangeJson.Factory jsonFactory;
+
private final ContributorAgreementsChecker contributorAgreements;
- private final Provider<IdentifiedUser> user;
private final GitRepositoryManager gitManager;
- private final BatchUpdate.Factory batchUpdateFactory;
- private final PatchSetInserter.Factory patchSetInserterFactory;
private final Provider<InternalChangeQuery> queryProvider;
- private final ZoneId serverZoneId;
private final ProjectCache projectCache;
private final ChangeUtil changeUtil;
+ private final PatchSetCreator patchSetCreator;
@Inject
ApplyPatch(
- ChangeJson.Factory jsonFactory,
ContributorAgreementsChecker contributorAgreements,
- Provider<IdentifiedUser> user,
GitRepositoryManager gitManager,
- BatchUpdate.Factory batchUpdateFactory,
- PatchSetInserter.Factory patchSetInserterFactory,
Provider<InternalChangeQuery> queryProvider,
- @GerritPersonIdent PersonIdent myIdent,
ProjectCache projectCache,
- ChangeUtil changeUtil) {
- this.jsonFactory = jsonFactory;
+ ChangeUtil changeUtil,
+ PatchSetCreator patchSetCreator) {
this.contributorAgreements = contributorAgreements;
- this.user = user;
this.gitManager = gitManager;
- this.batchUpdateFactory = batchUpdateFactory;
- this.patchSetInserterFactory = patchSetInserterFactory;
this.queryProvider = queryProvider;
- this.serverZoneId = myIdent.getZoneId();
this.projectCache = projectCache;
this.changeUtil = changeUtil;
+ this.patchSetCreator = patchSetCreator;
}
@Override
public Response<ChangeInfo> apply(ChangeResource rsrc, ApplyPatchPatchSetInput input)
throws IOException, UpdateException, RestApiException, PermissionBackendException,
ConfigInvalidException, NoSuchProjectException, InvalidChangeOperationException {
- NameKey project = rsrc.getProject();
- contributorAgreements.check(project, rsrc.getUser());
- BranchNameKey destBranch = rsrc.getChange().getDest();
-
if (input == null || input.patch == null || input.patch.patch == null) {
throw new BadRequestException("patch required");
}
+ NameKey project = rsrc.getProject();
+ contributorAgreements.check(project, rsrc.getUser());
+ BranchNameKey destBranch = rsrc.getChange().getDest();
+
try (Repository repo = gitManager.openRepository(project);
// This inserter and revwalk *must* be passed to any BatchUpdates
// created later on, to ensure the applied commit is flushed
@@ -140,22 +111,7 @@
String.format("Branch %s does not exist.", destBranch.branch()));
}
ChangeData destChange = rsrc.getChangeData();
- if (destChange == null) {
- throw new PreconditionFailedException(
- "patch:apply cannot be called without a destination change.");
- }
-
- if (destChange.change().isClosed()) {
- throw new PreconditionFailedException(
- String.format(
- "patch:apply with Change-Id %s could not update the existing change %d "
- + "in destination branch %s of project %s, because the change was closed (%s)",
- destChange.getId(),
- destChange.getId().get(),
- destBranch.branch(),
- destBranch.project(),
- destChange.change().getStatus().name()));
- }
+ patchSetCreator.validateChangeCanBeAppended(destChange, destBranch);
if (!Strings.isNullOrEmpty(input.base) && Boolean.TRUE.equals(input.amend)) {
throw new BadRequestException("amend only works with existing revisions. omit base.");
@@ -173,7 +129,8 @@
if (latestPatchset.getParentCount() != 1) {
throw new BadRequestException(
String.format(
- "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
+ "Cannot parse base commit for a change with none or multiple parents. Change ID:"
+ + " %s.",
destChange.getId()));
}
if (Boolean.TRUE.equals(input.amend)) {
@@ -184,50 +141,41 @@
parents = ImmutableList.of(baseCommit);
}
}
+
+ List<ListChangesOption> opts = input.responseFormatOptions;
+ if (opts == null) {
+ opts = ImmutableList.of();
+ }
+
PatchApplier.Result applyResult =
ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
- ObjectId treeId = applyResult.getTreeId();
- Instant now = TimeUtil.now();
- PersonIdent committerIdent =
- Optional.ofNullable(latestPatchset.getCommitterIdent())
- .map(
- ident ->
- user.get()
- .newCommitterIdent(ident.getEmailAddress(), now, serverZoneId)
- .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId)))
- .orElseGet(() -> user.get().newCommitterIdent(now, serverZoneId));
- PersonIdent authorIdent =
- input.author == null
- ? committerIdent
- : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
String commitMessage =
buildFullCommitMessage(
project,
latestPatchset,
input,
- ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
+ ApplyPatchUtil.getResultPatch(
+ repo, reader, baseCommit, revWalk.lookupTree(applyResult.getTreeId())),
applyResult.getErrors());
- ObjectId appliedCommit =
- CommitUtil.createCommitWithTree(
- oi, authorIdent, committerIdent, parents, commitMessage, treeId);
- CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
- oi.flush();
-
- Change resultChange;
- try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
- bu.setRepository(repo, revWalk, oi);
- resultChange =
- insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
- } catch (NoSuchChangeException | RepositoryNotFoundException e) {
- throw new ResourceConflictException(e.getMessage());
+ ChangeInfo changeInfo =
+ patchSetCreator.createPatchSetWithSuppliedTree(
+ project,
+ destChange,
+ latestPatchset,
+ parents,
+ input.author,
+ opts,
+ repo,
+ oi,
+ revWalk,
+ applyResult.getTreeId(),
+ commitMessage);
+ if (changeInfo.containsGitConflicts == null
+ && applyResult.getErrors().stream().anyMatch(Error::isGitConflict)) {
+ changeInfo.containsGitConflicts = true;
}
- List<ListChangesOption> opts = input.responseFormatOptions;
- if (opts == null) {
- opts = ImmutableList.of();
- }
- ChangeInfo changeInfo = jsonFactory.create(opts).format(resultChange);
return Response.ok(changeInfo);
}
}
@@ -262,7 +210,7 @@
}
String commitMessage =
ApplyPatchUtil.buildCommitMessage(
- messageWithNoFooters, footerLines, input.patch.patch, resultPatch, errors);
+ messageWithNoFooters, footerLines, input.patch, resultPatch, errors);
boolean changeIdRequired =
projectCache
@@ -275,28 +223,6 @@
return commitMessage;
}
- private static Change insertPatchSet(
- BatchUpdate bu,
- Repository git,
- PatchSetInserter.Factory patchSetInserterFactory,
- ChangeNotes destNotes,
- CodeReviewCommit commit)
- throws IOException, UpdateException, RestApiException {
- try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
- Change destChange = destNotes.getChange();
- PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
- PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
- inserter.setMessage(buildMessageForPatchSet(psId));
- bu.addOp(destChange.getId(), inserter);
- bu.execute();
- return inserter.getChange();
- }
- }
-
- private static String buildMessageForPatchSet(PatchSet.Id psId) {
- return new StringBuilder(String.format("Uploaded patch set %s.", psId.get())).toString();
- }
-
private String removeFooters(String originalMessage, List<FooterLine> footerLines) {
if (footerLines.isEmpty()) {
return originalMessage;
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
index 56b3842..8b8aaa6 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -41,6 +41,7 @@
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.patch.Patch;
import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
import org.eclipse.jgit.revwalk.FooterLine;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevTree;
@@ -80,6 +81,9 @@
}
try {
PatchApplier applier = new PatchApplier(repo, tip, oi);
+ if (Boolean.TRUE.equals(input.allowConflicts)) {
+ applier.allowConflicts();
+ }
PatchApplier.Result applyResult = applier.applyPatch(patch);
return applyResult;
} catch (IOException e) {
@@ -105,7 +109,7 @@
*
* @param message the first message piece, excluding footers
* @param footerLines footer lines to append to the message
- * @param originalPatch to compare the result patch to
+ * @param patchInput API input that triggered this action
* @param resultPatch to validate accuracy for
* @return the commit message
* @throws BadRequestException if the commit message cannot be sanitized
@@ -113,7 +117,7 @@
public static String buildCommitMessage(
String message,
List<FooterLine> footerLines,
- String originalPatch,
+ ApplyPatchInput patchInput,
String resultPatch,
List<PatchApplier.Result.Error> errors)
throws BadRequestException {
@@ -121,13 +125,19 @@
boolean appendOriginalPatch = false;
boolean appendResultPatch = false;
- String decodedOriginalPatch = decodeIfNecessary(originalPatch);
+ String decodedOriginalPatch = decodeIfNecessary(patchInput.patch);
if (!errors.isEmpty()) {
- res.append(
- "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
- + "\nPLEASE REVIEW CAREFULLY.\nErrors:\n"
- + errors.stream().map(Objects::toString).collect(Collectors.joining("\n")));
- appendOriginalPatch = true;
+ if (errors.stream().allMatch(Error::isGitConflict)) {
+ res.append(
+ "\n\nATTENTION: Conflicts occurred while applying the patch.\n"
+ + "Please resolve conflict markers.");
+ } else {
+ res.append(
+ "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+ + "\nPLEASE REVIEW CAREFULLY.\nErrors:\n"
+ + errors.stream().map(Objects::toString).collect(Collectors.joining("\n")));
+ appendOriginalPatch = true;
+ }
} else {
// Only surface the diff if no explicit errors occurred.
Optional<String> patchDiff = verifyAppliedPatch(decodedOriginalPatch, resultPatch);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index e254bfc..746313b 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -37,7 +37,6 @@
import com.google.gerrit.exceptions.InvalidMergeStrategyException;
import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.ListChangesOption;
@@ -115,6 +114,7 @@
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.TreeFormatter;
import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.patch.PatchApplier.Result.Error;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.ChangeIdUtil;
@@ -202,7 +202,8 @@
@FunctionalInterface
public interface CommitTreeSupplier {
@NonNull
- ObjectId get(Repository repo, ObjectInserter oi, ChangeInput input, RevCommit mergeTip)
+ ObjectId get(
+ Repository repo, ObjectInserter oi, ObjectReader or, ChangeInput input, RevCommit mergeTip)
throws IOException, RestApiException;
}
@@ -439,6 +440,7 @@
String commitMessage = getCommitMessage(input.subject, me);
CodeReviewCommit c;
+ boolean hasGitConflicts = false;
if (input.merge != null) {
// create a merge commit
c =
@@ -459,14 +461,38 @@
}
} else if (input.patch != null) {
// create a commit with the given patch.
+ if (mergeTip == null) {
+ throw new BadRequestException("Cannot apply patch on top of an empty tree.");
+ }
+ PatchApplier.Result applyResult =
+ ApplyPatchUtil.applyPatch(git, oi, input.patch, mergeTip);
+ ObjectId treeId = applyResult.getTreeId();
+ logger.atFine().log("tree ID after applying patch: %s", treeId.name());
+ String appliedPatchCommitMessage =
+ getCommitMessage(
+ ApplyPatchUtil.buildCommitMessage(
+ input.subject,
+ ImmutableList.of(),
+ input.patch,
+ ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
+ applyResult.getErrors()),
+ me);
c =
- createCommitWithPatch(
- git, reader, oi, rw, mergeTip, input.patch, input.subject, author, committer, me);
+ rw.parseCommit(
+ CommitUtil.createCommitWithTree(
+ oi,
+ author,
+ committer,
+ ImmutableList.of(mergeTip),
+ appliedPatchCommitMessage,
+ treeId));
+ hasGitConflicts = applyResult.getErrors().stream().anyMatch(Error::isGitConflict);
} else if (commitTreeSupplier.isPresent()) {
c =
createCommitWithSuppliedTree(
git,
oi,
+ reader,
rw,
mergeTip,
input,
@@ -523,7 +549,8 @@
opts = ImmutableList.of();
}
ChangeInfo changeInfo = jsonFactory.create(opts).format(ins.getChange());
- changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
+ changeInfo.containsGitConflicts =
+ (!c.getFilesWithGitConflicts().isEmpty() || hasGitConflicts) ? true : null;
return changeInfo;
} catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
throw new BadRequestException(e.getMessage());
@@ -661,46 +688,10 @@
oi, authorIdent, committerIdent, parents, commitMessage, treeId));
}
- private CodeReviewCommit createCommitWithPatch(
- Repository repo,
- ObjectReader reader,
- ObjectInserter oi,
- CodeReviewRevWalk rw,
- RevCommit mergeTip,
- ApplyPatchInput patch,
- String subject,
- PersonIdent authorIdent,
- PersonIdent committerIdent,
- IdentifiedUser me)
- throws IOException, RestApiException {
- if (mergeTip == null) {
- throw new BadRequestException("Cannot apply patch on top of an empty tree.");
- }
- PatchApplier.Result applyResult = ApplyPatchUtil.applyPatch(repo, oi, patch, mergeTip);
- ObjectId treeId = applyResult.getTreeId();
- logger.atFine().log("tree ID after applying patch: %s", treeId.name());
- String appliedPatchCommitMessage =
- getCommitMessage(
- ApplyPatchUtil.buildCommitMessage(
- subject,
- ImmutableList.of(),
- patch.patch,
- ApplyPatchUtil.getResultPatch(repo, reader, mergeTip, rw.lookupTree(treeId)),
- applyResult.getErrors()),
- me);
- return rw.parseCommit(
- CommitUtil.createCommitWithTree(
- oi,
- authorIdent,
- committerIdent,
- ImmutableList.of(mergeTip),
- appliedPatchCommitMessage,
- treeId));
- }
-
private static CodeReviewCommit createCommitWithSuppliedTree(
Repository repo,
ObjectInserter oi,
+ ObjectReader or,
CodeReviewRevWalk rw,
RevCommit mergeTip,
ChangeInput input,
@@ -712,7 +703,7 @@
if (mergeTip == null) {
throw new BadRequestException("`CommitTreeSupplier` cannot be used on top of an empty tree.");
}
- ObjectId treeId = commitTreeSupplier.get(repo, oi, input, mergeTip);
+ ObjectId treeId = commitTreeSupplier.get(repo, oi, or, input, mergeTip);
return rw.parseCommit(
CommitUtil.createCommitWithTree(
oi, authorIdent, committerIdent, ImmutableList.of(mergeTip), commitMessage, treeId));
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index b3d7fa2..4c6ee9a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -22,6 +22,7 @@
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -79,6 +80,9 @@
ReviewerResource r = rsrc.getReviewer();
Change change = r.getChange();
+ if (change.isMerged()) {
+ throw new ResourceConflictException("cannot remove votes from merged change");
+ }
if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
}
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 96e5645..07e4372 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -17,8 +17,6 @@
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
-import com.google.common.hash.Hasher;
-import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
@@ -31,10 +29,10 @@
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.CacheControl;
import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.ETagView;
import com.google.gerrit.extensions.restapi.IdString;
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.extensions.restapi.RestView;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.PatchSetUtil;
@@ -47,7 +45,6 @@
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffOperations;
import com.google.gerrit.server.patch.DiffOptions;
-import com.google.gerrit.server.patch.PatchListKey;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.patch.filediff.FileDiffOutput;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -100,7 +97,7 @@
return new FileResource(rev, id.get());
}
- public static final class ListFiles implements ETagView<RevisionResource> {
+ public static final class ListFiles implements RestReadView<RevisionResource> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@Option(name = "--base", metaVar = "revision-id")
@@ -353,16 +350,6 @@
return this;
}
- @Override
- public String getETag(RevisionResource resource) {
- Hasher h = Hashing.murmur3_128().newHasher();
- resource.prepareETag(h, resource.getUser());
- // File list comes from the PatchListCache, so any change to the key or value should
- // invalidate ETag.
- h.putLong(PatchListKey.serialVersionUID);
- return h.hash().toString();
- }
-
@Nullable
private ObjectId getOldId(Map<String, FileDiffOutput> fileDiffList) {
return fileDiffList.isEmpty()
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index d126d8a..c1a8f45 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -37,6 +37,9 @@
import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
import com.google.gerrit.server.change.RevisionResource;
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.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.notedb.MissingMetaObjectException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
@@ -45,6 +48,7 @@
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
+import java.util.Optional;
import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
@@ -104,9 +108,14 @@
@Override
public Response<ChangeInfo> apply(ChangeResource rsrc) throws RestApiException {
try {
- Change change = rsrc.getChange();
- ObjectId changeMetaRevId = getMetaRevId(change);
- return Response.withMustRevalidate(newChangeJson().format(change, changeMetaRevId));
+ Optional<ObjectId> changeMetaRevId = getMetaRevId(rsrc.getChange());
+ ChangeInfo changeInfo;
+ if (changeMetaRevId.isPresent()) {
+ changeInfo = newChangeJson().format(rsrc.getChange(), changeMetaRevId.get());
+ } else {
+ changeInfo = newChangeJson().format(rsrc.getChangeData());
+ }
+ return Response.withMustRevalidate(changeInfo);
} catch (MissingMetaObjectException e) {
throw new PreconditionFailedException(e.getMessage());
}
@@ -116,22 +125,25 @@
return Response.withMustRevalidate(newChangeJson().format(rsrc));
}
- @Nullable
- private ObjectId getMetaRevId(Change change) throws RestApiException {
+ private Optional<ObjectId> getMetaRevId(Change change) throws RestApiException {
if (metaRevId.isEmpty()) {
- return null;
+ return Optional.empty();
}
- // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
- // without having to fetch the entire /meta ref. If we do so, we have to be careful that
- // the error messages can't be abused to fetch hidden data.
- ObjectId metaRevObjectId;
- try {
- metaRevObjectId = ObjectId.fromString(metaRevId);
- } catch (InvalidObjectIdException e) {
- throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+ try (TraceTimer timer =
+ TraceContext.newTimer(
+ "Get meta rev ID", Metadata.builder().changeId(change.getId().get()).build())) {
+ // It might be interesting to also allow {SHA1}^^, so callers can walk back into history
+ // without having to fetch the entire /meta ref. If we do so, we have to be careful that
+ // the error messages can't be abused to fetch hidden data.
+ ObjectId metaRevObjectId;
+ try {
+ metaRevObjectId = ObjectId.fromString(metaRevId);
+ } catch (InvalidObjectIdException e) {
+ throw new BadRequestException("invalid meta SHA1: " + metaRevId, e);
+ }
+ return verifyMetaId(change, metaRevObjectId);
}
- return verifyMetaId(change, metaRevObjectId);
}
private ChangeJson newChangeJson() {
@@ -144,10 +156,10 @@
cds, this, Streams.stream(pdiFactories.entries()));
}
- @Nullable
- private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
+ private Optional<ObjectId> verifyMetaId(Change change, @Nullable ObjectId id)
+ throws RestApiException {
if (id == null) {
- return null;
+ return Optional.empty();
}
String changeMetaRefName = RefNames.changeMetaRef(change.getId());
@@ -159,7 +171,7 @@
rw.markStart(tip);
for (RevCommit rev : rw) {
if (id.equals(rev)) {
- return id;
+ return Optional.of(id);
}
}
} catch (IOException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index d8946a7..749a241 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static java.nio.charset.StandardCharsets.UTF_8;
+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;
@@ -51,6 +52,10 @@
@Option(name = "--path")
private String path;
+ /** 1-based index of the parent's position in the commit object. */
+ @Option(name = "--parent", metaVar = "parent-number")
+ private Integer parentNum;
+
@Inject
GetPatch(GitRepositoryManager repoManager) {
this.repoManager = repoManager;
@@ -58,7 +63,8 @@
@Override
public Response<BinaryResult> apply(RevisionResource rsrc)
- throws ResourceConflictException, IOException, ResourceNotFoundException {
+ throws BadRequestException, ResourceConflictException, IOException,
+ ResourceNotFoundException {
final Repository repo = repoManager.openRepository(rsrc.getProject());
boolean close = true;
try {
@@ -67,12 +73,16 @@
try {
final RevCommit commit = rw.parseCommit(rsrc.getPatchSet().commitId());
RevCommit[] parents = commit.getParents();
- if (parents.length > 1) {
+ if (parentNum == null && parents.length > 1) {
throw new ResourceConflictException("Revision has more than 1 parent.");
- } else if (parents.length == 0) {
+ }
+ if (parents.length == 0) {
throw new ResourceConflictException("Revision has no parent.");
}
- final RevCommit base = parents[0];
+ if (parentNum != null && (parentNum < 1 || parentNum > parents.length)) {
+ throw new BadRequestException(String.format("invalid parent number: %d", parentNum));
+ }
+ final RevCommit base = parents[parentNum == null ? 0 : parentNum - 1];
rw.parseBody(base);
bin =
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
new file mode 100644
index 0000000..60c8e89
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static java.util.Objects.requireNonNull;
+
+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.Project;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+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.change.ChangeJson;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import java.util.Optional;
+import javax.inject.Inject;
+import javax.inject.Provider;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** A utility class for creating a patch set on an existing change. */
+@Singleton
+public class PatchSetCreator {
+ private final Provider<IdentifiedUser> ident;
+ private final BatchUpdate.Factory batchUpdateFactory;
+ private final PatchSetInserter.Factory patchSetInserterFactory;
+ private final ChangeJson.Factory jsonFactory;
+ private final ZoneId serverZoneId;
+
+ @Inject
+ PatchSetCreator(
+ Provider<IdentifiedUser> ident,
+ BatchUpdate.Factory batchUpdateFactory,
+ PatchSetInserter.Factory patchSetInserterFactory,
+ @GerritPersonIdent PersonIdent myIdent,
+ ChangeJson.Factory jsonFactory) {
+ this.ident = ident;
+ this.batchUpdateFactory = batchUpdateFactory;
+ this.patchSetInserterFactory = patchSetInserterFactory;
+ this.serverZoneId = myIdent.getZoneId();
+ this.jsonFactory = jsonFactory;
+ }
+
+ public ChangeInfo createPatchSetWithSuppliedTree(
+ Project.NameKey project,
+ ChangeData destChange,
+ RevCommit latestPatchset,
+ List<RevCommit> parents,
+ @Nullable AccountInput author,
+ List<ListChangesOption> outputOptions,
+ Repository repo,
+ ObjectInserter oi,
+ CodeReviewRevWalk revWalk,
+ ObjectId commitTree,
+ String commitMessage)
+ throws IOException, RestApiException, UpdateException {
+ requireNonNull(destChange);
+ requireNonNull(latestPatchset);
+ requireNonNull(parents);
+ requireNonNull(outputOptions);
+
+ Instant now = TimeUtil.now();
+ PersonIdent committerIdent =
+ Optional.ofNullable(latestPatchset.getCommitterIdent())
+ .map(
+ id ->
+ ident
+ .get()
+ .newCommitterIdent(id.getEmailAddress(), now, serverZoneId)
+ .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId)))
+ .orElseGet(() -> ident.get().newCommitterIdent(now, serverZoneId));
+ PersonIdent authorIdent =
+ author == null
+ ? committerIdent
+ : new PersonIdent(author.name, author.email, now, serverZoneId);
+
+ ObjectId appliedCommit =
+ CommitUtil.createCommitWithTree(
+ oi, authorIdent, committerIdent, parents, commitMessage, commitTree);
+ CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+ oi.flush();
+
+ Change resultChange;
+ try (BatchUpdate bu = batchUpdateFactory.create(project, ident.get(), TimeUtil.now())) {
+ bu.setRepository(repo, revWalk, oi);
+ resultChange = insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
+ } catch (NoSuchChangeException | RepositoryNotFoundException e) {
+ throw new ResourceConflictException(e.getMessage());
+ }
+
+ return jsonFactory.create(outputOptions).format(resultChange);
+ }
+
+ public void validateChangeCanBeAppended(@Nullable ChangeData destChange, BranchNameKey destBranch)
+ throws PreconditionFailedException {
+ if (destChange == null) {
+ throw new PreconditionFailedException(
+ "cannot write a patch set without a destination change.");
+ }
+
+ if (destChange.change().isClosed()) {
+ throw new PreconditionFailedException(
+ String.format(
+ "patch:apply with Change-Id %s could not update the existing change %d "
+ + "in destination branch %s of project %s, because the change was closed (%s)",
+ destChange.getId(),
+ destChange.getId().get(),
+ destBranch.branch(),
+ destBranch.project(),
+ destChange.change().getStatus().name()));
+ }
+ }
+
+ private static Change insertPatchSet(
+ BatchUpdate bu,
+ Repository git,
+ PatchSetInserter.Factory patchSetInserterFactory,
+ ChangeNotes destNotes,
+ CodeReviewCommit commit)
+ throws IOException, UpdateException, RestApiException {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ Change destChange = destNotes.getChange();
+ PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+ PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
+ inserter.setMessage(buildMessageForPatchSet(psId));
+ bu.addOp(destChange.getId(), inserter);
+ bu.execute();
+ return inserter.getChange();
+ }
+ }
+
+ private static String buildMessageForPatchSet(PatchSet.Id psId) {
+ return String.format("Uploaded patch set %s.", psId.get());
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 490ff490..511cb17 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -425,11 +425,14 @@
}
/**
- * Returns the subset of {@code inputComments} that do not have a matching comment (with same id)
- * neither in {@code existingComments} nor in {@code drafts}.
+ * Returns the subset of {@code inputComments} that should be added to the change.
*
- * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
- * removed.
+ * <p>If the matching comment (with the same id) already exists in {@code existingComments} then
+ * the comment is filtered out. This assumes that the comment has been already published earlier.
+ *
+ * <p>If the matching comment is found in {@code drafts}, then it's removed from drafts and the
+ * comment is kept in the output. This assumes that the comment in the input is the newer version
+ * of the previously existing draft.
*
* @param inputComments new comments provided as {@link CommentInput} entries in the API.
* @param existingComments existing published comments in the database.
@@ -464,6 +467,7 @@
comment.writtenOn = Timestamp.from(ctx.getWhen());
comment.side = inputComment.side();
comment.message = inputComment.message;
+ comment.unresolved = inputComment.unresolved;
}
commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 812711a..2878fe2 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.change;
+import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.extensions.client.ListChangesOption;
@@ -155,6 +156,7 @@
throws BadRequestException, AuthException, PermissionBackendException {
List<List<ChangeInfo>> out;
try {
+ applyPermissionBackendFilter();
out = query();
} catch (QueryRequiresAuthException e) {
throw new AuthException("Must be signed-in to use this operator", e);
@@ -165,6 +167,22 @@
return Response.ok(out.size() == 1 ? out.get(0) : out);
}
+ private void applyPermissionBackendFilter() {
+ String queryFilter = permissionBackend.currentUser().filterQueryChanges();
+ if (Strings.isNullOrEmpty(queryFilter)) {
+ return;
+ }
+
+ if (queries == null || queries.isEmpty()) {
+ addQuery(queryFilter);
+ return;
+ }
+
+ for (int i = 0; i < queries.size(); i++) {
+ queries.set(i, "(" + queries.get(i) + ") AND (" + queryFilter + ")");
+ }
+ }
+
private List<List<ChangeInfo>> query()
throws BadRequestException, QueryParseException, PermissionBackendException {
ChangeQueryProcessor queryProcessor = queryProcessorProvider.get();
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 36b859c..0a47d62 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.stream.Collectors.joining;
@@ -71,10 +72,10 @@
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.List;
+import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -99,8 +100,6 @@
"Submit all ${topicSize} changes of the same topic "
+ "(${submitSize} changes including ancestors and other "
+ "changes related by topic)";
- private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
- "This change depends on other hidden changes which are not ready";
private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
@@ -240,48 +239,15 @@
*/
@Nullable
private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
- try {
- if (cs.furtherHiddenChanges()) {
- logger.atFine().log(
- "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
- cd.getId().get(), user.getLoggableName(), cs.nonVisibleChanges());
- return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
- }
- for (ChangeData c : cs.changes()) {
- Set<ChangePermission> can =
- permissionBackend
- .user(user)
- .change(c)
- .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
- if (!can.contains(ChangePermission.READ)) {
- logger.atFine().log(
- "Change %d cannot be submitted by user %s because it depends on change %d which the user cannot read",
- cd.getId().get(), user.getLoggableName(), c.getId().get());
- return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
- }
- if (!can.contains(ChangePermission.SUBMIT)) {
- return "You don't have permission to submit change " + c.getId();
- }
- if (c.change().isWorkInProgress()) {
- return "Change " + c.getId() + " is marked work in progress";
- }
- try {
- // The data in the change index may be stale (e.g. if submit requirements have been
- // changed). For that one change for which the submit action is computed, use the
- // freshly loaded ChangeData instance 'cd' instead of the potentially stale ChangeData
- // instance 'c' that was loaded from the index. This makes a difference if the ChangeSet
- // 'cs' only contains this one single change. If the ChangeSet contains further changes
- // those may still be stale.
- MergeOp.checkSubmitRequirements(cd.getId().equals(c.getId()) ? cd : c);
- } catch (ResourceConflictException e) {
- return (c.getId() == cd.getId())
- ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
- : String.format(
- "Change %s must be submitted with change %s but %s is not ready: %s",
- cd.getId(), c.getId(), c.getId(), e.getMessage());
- }
- }
+ Optional<String> reason =
+ MergeOp.checkCommonSubmitProblems(cd.change(), cs, false, permissionBackend, user).stream()
+ .findFirst()
+ .map(MergeOp.ChangeProblem::getProblem);
+ if (reason.isPresent()) {
+ return reason.get();
+ }
+ try {
if (!useMergeabilityCheck) {
return null;
}
@@ -298,7 +264,7 @@
return "Problems with change(s): "
+ unmergeable.stream().map(c -> c.getId().toString()).collect(joining(", "));
}
- } catch (PermissionBackendException | IOException e) {
+ } catch (IOException e) {
logger.atSevere().withCause(e).log("Error checking if change is submittable");
throw new StorageException("Could not determine problems for the change", e);
}
@@ -331,6 +297,15 @@
mergeSuperSet
.get()
.completeChangeSet(cd.change(), resource.getUser(), /*includingTopicClosure= */ false);
+ // Replace potentially stale ChangeData for the current change with the fresher one.
+ cs =
+ new ChangeSet(
+ cs.changes().stream()
+ .map(csChange -> csChange.getId().equals(cd.getId()) ? cd : csChange)
+ .collect(toImmutableList()),
+ cs.nonVisibleChanges());
+ String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
+
String topic = change.getTopic();
int topicSize = 0;
if (!Strings.isNullOrEmpty(topic)) {
@@ -338,8 +313,6 @@
}
boolean treatWithTopic = submitWholeTopic && !Strings.isNullOrEmpty(topic) && topicSize > 1;
- String submitProblems = problemsForSubmittingChangeset(cd, cs, resource.getUser());
-
if (submitProblems != null) {
return new UiAction.Description()
.setLabel(treatWithTopic ? submitTopicLabel : (cs.size() > 1) ? labelWithParents : label)
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 0035a03..b09ce21 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -29,9 +29,9 @@
public class SuggestReviewers {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private static final int DEFAULT_MAX_SUGGESTED = 10;
+ public static final boolean DEFAULT_SKIP_SERVICE_USERS = true;
- private static final boolean DEFAULT_SKIP_SERVICE_USERS = true;
+ private static final int DEFAULT_MAX_SUGGESTED = 10;
protected final ReviewersUtil reviewersUtil;
diff --git a/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java b/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java
new file mode 100644
index 0000000..ca366f4
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/AccountDeactivation.java
@@ -0,0 +1,59 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.Input;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.account.AccountDeactivator;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@Singleton
+public class AccountDeactivation implements RestModifyView<ConfigResource, Input> {
+ private final AccountDeactivator deactivator;
+ private final WorkQueue workQueue;
+
+ @Inject
+ AccountDeactivation(WorkQueue workQueue, AccountDeactivator deactivator) {
+ this.deactivator = deactivator;
+ this.workQueue = workQueue;
+ }
+
+ @Override
+ public Response<?> apply(ConfigResource rsrc, Input unusedInput) {
+ if (taskAlreadyScheduled()) {
+ return Response.ok("Account deactivator already in queue.");
+ }
+ @SuppressWarnings("unused")
+ Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> deactivator.run());
+ return Response.accepted("Account deactivator task added to work queue.");
+ }
+
+ private boolean taskAlreadyScheduled() {
+ for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+ if (task.toString().contains(deactivator.getClass().getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/CleanupChanges.java b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
new file mode 100644
index 0000000..9ae3637
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/config/CleanupChanges.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.config;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
+import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.restapi.config.CleanupChanges.Input;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+@RequiresCapability(GlobalCapability.MAINTAIN_SERVER)
+@Singleton
+public class CleanupChanges implements RestModifyView<ConfigResource, Input> {
+ private final ChangeCleanupRunner.Factory runnerFactory;
+ private final WorkQueue workQueue;
+
+ public static class Input {
+ String after;
+ boolean ifMergeable;
+ String message;
+ }
+
+ @Inject
+ CleanupChanges(WorkQueue workQueue, ChangeCleanupRunner.Factory runnerFactory) {
+ this.runnerFactory = runnerFactory;
+ this.workQueue = workQueue;
+ }
+
+ @Override
+ public Response<?> apply(ConfigResource rsrc, Input input) throws BadRequestException {
+ if (taskAlreadyScheduled()) {
+ return Response.ok("Change cleaner already in queue.");
+ }
+ if (input.after == null) {
+ throw new BadRequestException("`after` must be specified.");
+ }
+ ChangeCleanupRunner runner =
+ runnerFactory.create(
+ ConfigUtil.getTimeUnit(input.after, 0, TimeUnit.MILLISECONDS),
+ input.ifMergeable,
+ input.message);
+ @SuppressWarnings("unused")
+ Future<?> possiblyIgnoredError = workQueue.getDefaultQueue().submit(() -> runner.run());
+ return Response.accepted("Change cleaner task added to work queue.");
+ }
+
+ private boolean taskAlreadyScheduled() {
+ for (WorkQueue.Task<?> task : workQueue.getTasks()) {
+ if (task.toString().contains(ChangeCleanupRunner.class.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index a17305f..2f21da6 100644
--- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -38,6 +38,7 @@
child(CONFIG_KIND, "capabilities").to(CapabilitiesCollection.class);
post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
+ post(CONFIG_KIND, "deactivate.stale.accounts").to(AccountDeactivation.class);
put(CONFIG_KIND, "email.confirm").to(ConfirmEmail.class);
child(CONFIG_KIND, "experiments").to(ExperimentsCollection.class);
@@ -53,6 +54,7 @@
put(CONFIG_KIND, "preferences.edit").to(SetEditPreferences.class);
post(CONFIG_KIND, "reload").to(ReloadConfig.class);
post(CONFIG_KIND, "snapshot.indexes").to(SnapshotIndexes.class);
+ post(CONFIG_KIND, "cleanup.changes").to(CleanupChanges.class);
child(CONFIG_KIND, "tasks").to(TasksCollection.class);
delete(TASK_KIND).to(DeleteTask.class);
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index a9f16e7..91d24b9 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -296,6 +296,8 @@
info.primaryWeblinkName = config.getString("gerrit", null, "primaryWeblinkName");
info.instanceId = instanceId;
info.defaultBranch = config.getString("gerrit", null, "defaultBranch");
+ info.projectStatePredicateEnabled =
+ config.getBoolean("gerrit", null, "projectStatePredicateEnabled", true);
return info;
}
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index 77af0f3..c76f0a4 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -77,6 +77,7 @@
int tasksTotal = pending.size();
int tasksStopping = 0;
int tasksRunning = 0;
+ int tasksParked = 0;
int tasksStarting = 0;
int tasksReady = 0;
int tasksSleeping = 0;
@@ -88,6 +89,9 @@
case RUNNING:
tasksRunning++;
break;
+ case PARKED:
+ tasksParked++;
+ break;
case STARTING:
tasksStarting++;
break;
@@ -108,6 +112,7 @@
taskSummary.total = toInteger(tasksTotal);
taskSummary.stopping = toInteger(tasksStopping);
taskSummary.running = toInteger(tasksRunning);
+ taskSummary.parked = toInteger(tasksParked);
taskSummary.starting = toInteger(tasksStarting);
taskSummary.ready = toInteger(tasksReady);
taskSummary.sleeping = toInteger(tasksSleeping);
@@ -245,6 +250,7 @@
public Integer total;
public Integer stopping;
public Integer running;
+ public Integer parked;
public Integer starting;
public Integer ready;
public Integer sleeping;
diff --git a/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
new file mode 100644
index 0000000..b3ad599
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/AbstractPostCollection.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.common.AbstractBatchInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/** Base class for a rest API batch update. */
+public abstract class AbstractPostCollection<
+ TId,
+ TResource extends RestResource,
+ TItemInput,
+ TBatchInput extends AbstractBatchInput<TItemInput>>
+ implements RestCollectionModifyView<ProjectResource, TResource, TBatchInput> {
+ private final Provider<CurrentUser> user;
+ private final RepoMetaDataUpdater updater;
+
+ public AbstractPostCollection(RepoMetaDataUpdater updater, Provider<CurrentUser> user) {
+ this.user = user;
+ this.updater = updater;
+ }
+
+ @Override
+ public Response<?> apply(ProjectResource rsrc, TBatchInput input)
+ throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
+ ConfigInvalidException, BadRequestException, ResourceConflictException,
+ MethodNotAllowedException {
+ if (!user.get().isIdentifiedUser()) {
+ throw new AuthException("Authentication required");
+ }
+ if (input == null) {
+ return Response.ok("");
+ }
+
+ try (var configUpdater =
+ updater.configUpdater(rsrc.getNameKey(), input.commitMessage, defaultCommitMessage())) {
+ ProjectConfig config = configUpdater.getConfig();
+ if (updateProjectConfig(config, input)) {
+ configUpdater.commitConfigUpdate();
+ }
+ }
+ return Response.ok("");
+ }
+
+ public boolean updateProjectConfig(ProjectConfig config, AbstractBatchInput<TItemInput> input)
+ throws UnprocessableEntityException, ResourceConflictException, BadRequestException {
+ boolean configChanged = false;
+ if (input.delete != null && !input.delete.isEmpty()) {
+ for (String name : input.delete) {
+ if (Strings.isNullOrEmpty(name)) {
+ throw new BadRequestException("The delete property contains null or empty name");
+ }
+ deleteItem(config, name.trim());
+ }
+ configChanged = true;
+ }
+ if (input.create != null && !input.create.isEmpty()) {
+ for (TItemInput labelInput : input.create) {
+ if (labelInput == null) {
+ throw new BadRequestException("The create property contains a null item");
+ }
+ createItem(config, labelInput);
+ }
+ configChanged = true;
+ }
+ if (input.update != null && !input.update.isEmpty()) {
+ for (var e : input.update.entrySet()) {
+ if (e.getKey() == null) {
+ throw new BadRequestException("The update property contains a null key");
+ }
+ if (e.getValue() == null) {
+ throw new BadRequestException("The update property contains a null value");
+ }
+ configChanged |= updateItem(config, e.getKey().trim(), e.getValue());
+ }
+ }
+ return configChanged;
+ }
+
+ /** Provides default commit message when user doesn't specify one in the input. */
+ public abstract String defaultCommitMessage();
+
+ protected abstract boolean updateItem(ProjectConfig config, String name, TItemInput resource)
+ throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+
+ protected abstract void createItem(ProjectConfig config, TItemInput resource)
+ throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+
+ protected abstract void deleteItem(ProjectConfig config, String name)
+ throws BadRequestException, ResourceConflictException, UnprocessableEntityException;
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index 3e8002b..3a50275 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -14,10 +14,8 @@
package com.google.gerrit.server.restapi.project;
-import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.extensions.api.access.ProjectAccessInput;
@@ -27,9 +25,10 @@
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
import com.google.gerrit.server.update.UpdateException;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -39,23 +38,18 @@
@Singleton
public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
private final SetAccessUtil setAccess;
- private final ChangeJson.Factory jsonFactory;
private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
- CreateAccessChange(
- SetAccessUtil accessUtil,
- ChangeJson.Factory jsonFactory,
- RepoMetaDataUpdater repoMetaDataUpdater) {
+ CreateAccessChange(SetAccessUtil accessUtil, RepoMetaDataUpdater repoMetaDataUpdater) {
this.setAccess = accessUtil;
- this.jsonFactory = jsonFactory;
this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
- throws PermissionBackendException, IOException, ConfigInvalidException, InvalidNameException,
- UpdateException, RestApiException {
+ throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+ RestApiException {
ImmutableList<AccessSection> removals =
setAccess.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
ImmutableList<AccessSection> additions =
@@ -63,28 +57,23 @@
Project.NameKey newParentProjectName =
input.parent == null ? null : Project.nameKey(input.parent);
- String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Review access change";
- try {
- Change change =
- repoMetaDataUpdater.updateAndCreateChangeForReview(
- rsrc.getNameKey(),
- rsrc.getUser(),
- message,
- config -> {
- setAccess.validateChanges(config, removals, additions);
- setAccess.applyChanges(config, removals, additions);
- try {
- setAccess.setParentName(
- rsrc.getUser().asIdentifiedUser(),
- config,
- rsrc.getNameKey(),
- newParentProjectName,
- false);
- } catch (AuthException e) {
- throw new IllegalStateException(e);
- }
- });
- return Response.created(jsonFactory.noOptions().format(change));
+ try (ConfigChangeCreator creator =
+ repoMetaDataUpdater.configChangeCreator(
+ rsrc.getNameKey(), input.message, "Review access change")) {
+ ProjectConfig config = creator.getConfig();
+ setAccess.validateChanges(config, removals, additions);
+ setAccess.applyChanges(config, removals, additions);
+ try {
+ setAccess.setParentName(
+ rsrc.getUser().asIdentifiedUser(),
+ config,
+ rsrc.getNameKey(),
+ newParentProjectName,
+ false);
+ } catch (AuthException e) {
+ throw new IllegalStateException(e);
+ }
+ return creator.createChange();
} catch (InvalidNameException e) {
throw new BadRequestException(e.toString());
}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index a233834..6bec417 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -23,23 +23,18 @@
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.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.LabelDefinitionJson;
import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.List;
@@ -49,43 +44,22 @@
@Singleton
public class CreateLabel
implements RestCollectionCreateView<ProjectResource, LabelResource, LabelDefinitionInput> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
- private final ProjectCache projectCache;
private final ApprovalQueryBuilder approvalQueryBuilder;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
public CreateLabel(
- Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
- ProjectCache projectCache,
- ApprovalQueryBuilder approvalQueryBuilder) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
- this.projectCache = projectCache;
+ ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
this.approvalQueryBuilder = approvalQueryBuilder;
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<LabelDefinitionInfo> apply(
ProjectResource rsrc, IdString id, LabelDefinitionInput input)
throws AuthException, BadRequestException, ResourceConflictException,
- PermissionBackendException, IOException, ConfigInvalidException {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
- }
-
- permissionBackend
- .currentUser()
- .project(rsrc.getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
+ PermissionBackendException, IOException, ConfigInvalidException,
+ MethodNotAllowedException {
if (input == null) {
input = new LabelDefinitionInput();
}
@@ -93,22 +67,10 @@
if (input.name != null && !input.name.equals(id.get())) {
throw new BadRequestException("name in input must match name in URL");
}
-
- try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
-
- LabelType labelType = createLabel(config, id.get(), input);
-
- if (input.commitMessage != null) {
- md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
- } else {
- md.setMessage("Update label");
- }
-
- config.commit(md);
-
- projectCache.evictAndReindex(rsrc.getProjectState().getProject());
-
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(rsrc.getNameKey(), input.commitMessage, "Update label")) {
+ LabelType labelType = createLabel(configUpdater.getConfig(), id.get(), input);
+ configUpdater.commitConfigUpdate();
return Response.created(LabelDefinitionJson.format(rsrc.getNameKey(), labelType));
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
index a46211c..3fa4905 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateSubmitRequirement.java
@@ -23,15 +23,11 @@
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.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
@@ -39,7 +35,6 @@
import com.google.gerrit.server.project.SubmitRequirementResource;
import com.google.gerrit.server.project.SubmitRequirementsUtil;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Optional;
@@ -50,42 +45,43 @@
public class CreateSubmitRequirement
implements RestCollectionCreateView<
ProjectResource, SubmitRequirementResource, SubmitRequirementInput> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
- private final ProjectCache projectCache;
private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+ private final RepoMetaDataUpdater updater;
@Inject
public CreateSubmitRequirement(
- Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
- ProjectCache projectCache,
- SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
- this.projectCache = projectCache;
+ SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+ RepoMetaDataUpdater updater) {
this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+ this.updater = updater;
}
@Override
public Response<SubmitRequirementInfo> apply(
ProjectResource rsrc, IdString id, SubmitRequirementInput input)
- throws AuthException, BadRequestException, IOException, PermissionBackendException {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
+ throws AuthException, BadRequestException, IOException, PermissionBackendException,
+ MethodNotAllowedException {
+ String defaultMessage = String.format("Create Submit Requirement %s", id.get());
+ try (var configUpdater =
+ updater.configUpdater(
+ rsrc.getNameKey(),
+ /** message= */
+ null,
+ defaultMessage)) {
+ ProjectConfig config = configUpdater.getConfig();
+ SubmitRequirement submitRequirement = updateConfig(config, id, input);
+
+ configUpdater.commitConfigUpdate();
+ return Response.created(SubmitRequirementJson.format(submitRequirement));
+ } catch (ConfigInvalidException e) {
+ throw new IOException("Failed to read project config", e);
+ } catch (ResourceConflictException e) {
+ throw new BadRequestException("Failed to create submit requirement", e);
}
+ }
- permissionBackend
- .currentUser()
- .project(rsrc.getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
+ SubmitRequirement updateConfig(ProjectConfig config, IdString id, SubmitRequirementInput input)
+ throws ResourceConflictException, BadRequestException {
if (input == null) {
input = new SubmitRequirementInput();
}
@@ -93,23 +89,7 @@
if (input.name != null && !input.name.equals(id.get())) {
throw new BadRequestException("name in input must match name in URL");
}
-
- try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
-
- SubmitRequirement submitRequirement = createSubmitRequirement(config, id.get(), input);
-
- md.setMessage(String.format("Create Submit Requirement %s", submitRequirement.name()));
- config.commit(md);
-
- projectCache.evict(rsrc.getProjectState().getProject().getNameKey());
-
- return Response.created(SubmitRequirementJson.format(submitRequirement));
- } catch (ConfigInvalidException e) {
- throw new IOException("Failed to read project config", e);
- } catch (ResourceConflictException e) {
- throw new BadRequestException("Failed to create submit requirement", e);
- }
+ return createSubmitRequirement(config, id.get(), input);
}
public SubmitRequirement createSubmitRequirement(
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
index 8a1927a..1c3ed5d 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteLabel.java
@@ -14,84 +14,50 @@
package com.google.gerrit.server.restapi.project;
-import com.google.common.base.Strings;
import com.google.gerrit.extensions.common.InputWithCommitMessage;
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.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@Singleton
public class DeleteLabel implements RestModifyView<LabelResource, InputWithCommitMessage> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
- private final ProjectCache projectCache;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
- public DeleteLabel(
- Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
- ProjectCache projectCache) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
- this.projectCache = projectCache;
+ public DeleteLabel(RepoMetaDataUpdater repoMetaDataUpdater) {
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<?> apply(LabelResource rsrc, InputWithCommitMessage input)
throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
- ConfigInvalidException {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
- }
-
- permissionBackend
- .currentUser()
- .project(rsrc.getProject().getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
+ ConfigInvalidException, BadRequestException, MethodNotAllowedException {
if (input == null) {
input = new InputWithCommitMessage();
}
- try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(
+ rsrc.getProject().getNameKey(), input.commitMessage, "Delete label")) {
+ ProjectConfig config = configUpdater.getConfig();
if (!deleteLabel(config, rsrc.getLabelType().getName())) {
throw new ResourceNotFoundException(IdString.fromDecoded(rsrc.getLabelType().getName()));
}
-
- if (input.commitMessage != null) {
- md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
- } else {
- md.setMessage("Delete label");
- }
-
- config.commit(md);
+ configUpdater.commitConfigUpdate();
}
- projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
-
return Response.none();
}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
index 1be4a5f..64e2399 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteSubmitRequirement.java
@@ -15,57 +15,30 @@
package com.google.gerrit.server.restapi.project;
import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.SubmitRequirementResource;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
@Singleton
public class DeleteSubmitRequirement implements RestModifyView<SubmitRequirementResource, Input> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
- private final ProjectCache projectCache;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
- public DeleteSubmitRequirement(
- Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
- ProjectCache projectCache) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
- this.projectCache = projectCache;
+ public DeleteSubmitRequirement(RepoMetaDataUpdater repoMetaDataUpdater) {
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<?> apply(SubmitRequirementResource rsrc, Input input) throws Exception {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
- }
-
- permissionBackend
- .currentUser()
- .project(rsrc.getProject().getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
- try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(
+ rsrc.getProject().getNameKey(), null, "Delete submit requirement")) {
+ ProjectConfig config = configUpdater.getConfig();
if (!deleteSubmitRequirement(config, rsrc.getSubmitRequirement().name())) {
// This code is unreachable because the exception is thrown when rsrc was parsed
@@ -75,12 +48,9 @@
IdString.fromDecoded(rsrc.getSubmitRequirement().name())));
}
- md.setMessage("Delete submit requirement");
- config.commit(md);
+ configUpdater.commitConfigUpdate();
}
- projectCache.evict(rsrc.getProject().getProjectState().getProject().getNameKey());
-
return Response.none();
}
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index e1a3c0c..d83605e 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -17,6 +17,7 @@
import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_TAG_REF;
+import static com.google.gerrit.server.permissions.ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE;
import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
import static com.google.gerrit.server.permissions.RefPermission.READ;
import static com.google.gerrit.server.permissions.RefPermission.WRITE_CONFIG;
@@ -269,6 +270,8 @@
info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
info.canAddTags = toBoolean(perm.testOrFalse(CREATE_TAG_REF));
info.configVisible = canReadConfig || canWriteConfig;
+ info.requireChangeForConfigUpdate =
+ toBoolean(!perm.testOrFalse(UPDATE_CONFIG_WITHOUT_CREATING_CHANGE));
info.groups =
groups.entrySet().stream()
diff --git a/java/com/google/gerrit/server/restapi/project/ListTags.java b/java/com/google/gerrit/server/restapi/project/ListTags.java
index 24bbb24..2132153 100644
--- a/java/com/google/gerrit/server/restapi/project/ListTags.java
+++ b/java/com/google/gerrit/server/restapi/project/ListTags.java
@@ -105,10 +105,7 @@
this.matchRegex = matchRegex;
}
- @Option(
- name = "--sort-by",
- aliases = {"-sortby"},
- usage = "sort the tags")
+ @Option(name = "--sort-by", usage = "sort the tags")
private void setSortBy(ListTagSortOption sortBy) {
this.sortBy = sortBy;
}
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 3616f4b..7f502ff 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -14,138 +14,76 @@
package com.google.gerrit.server.restapi.project;
-import com.google.common.base.Strings;
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;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectResource;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
/** REST endpoint that allows to add, update and delete label definitions in a batch. */
@Singleton
public class PostLabels
- implements RestCollectionModifyView<ProjectResource, LabelResource, BatchLabelInput> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
+ extends AbstractPostCollection<String, LabelResource, LabelDefinitionInput, BatchLabelInput> {
private final DeleteLabel deleteLabel;
private final CreateLabel createLabel;
private final SetLabel setLabel;
- private final ProjectCache projectCache;
@Inject
public PostLabels(
Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
DeleteLabel deleteLabel,
CreateLabel createLabel,
SetLabel setLabel,
- ProjectCache projectCache) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
+ RepoMetaDataUpdater updater) {
+ super(updater, user);
this.deleteLabel = deleteLabel;
this.createLabel = createLabel;
this.setLabel = setLabel;
- this.projectCache = projectCache;
}
@Override
- public Response<?> apply(ProjectResource rsrc, BatchLabelInput input)
- throws AuthException, UnprocessableEntityException, PermissionBackendException, IOException,
- ConfigInvalidException, BadRequestException, ResourceConflictException {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
+ public String defaultCommitMessage() {
+ return "Update labels";
+ }
+
+ @Override
+ protected boolean updateItem(ProjectConfig config, String name, LabelDefinitionInput resource)
+ throws BadRequestException, ResourceConflictException, UnprocessableEntityException {
+ LabelType labelType = config.getLabelSections().get(name);
+ if (labelType == null) {
+ throw new UnprocessableEntityException(String.format("label %s not found", name));
+ }
+ if (resource.commitMessage != null) {
+ throw new BadRequestException("commit message on label definition input not supported");
}
- permissionBackend
- .currentUser()
- .project(rsrc.getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
+ return setLabel.updateLabel(config, labelType, resource);
+ }
- if (input == null) {
- input = new BatchLabelInput();
+ @Override
+ protected void createItem(ProjectConfig config, LabelDefinitionInput labelInput)
+ throws BadRequestException, ResourceConflictException {
+ if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
+ throw new BadRequestException("label name is required for new label");
}
-
- try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
- boolean dirty = false;
-
- ProjectConfig config = projectConfigFactory.read(md);
-
- if (input.delete != null && !input.delete.isEmpty()) {
- for (String labelName : input.delete) {
- if (!deleteLabel.deleteLabel(config, labelName.trim())) {
- throw new UnprocessableEntityException(String.format("label %s not found", labelName));
- }
- }
- dirty = true;
- }
-
- if (input.create != null && !input.create.isEmpty()) {
- for (LabelDefinitionInput labelInput : input.create) {
- if (labelInput.name == null || labelInput.name.trim().isEmpty()) {
- throw new BadRequestException("label name is required for new label");
- }
- if (labelInput.commitMessage != null) {
- throw new BadRequestException("commit message on label definition input not supported");
- }
- @SuppressWarnings("unused")
- var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
- }
- dirty = true;
- }
-
- if (input.update != null && !input.update.isEmpty()) {
- for (Map.Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
- LabelType labelType = config.getLabelSections().get(e.getKey().trim());
- if (labelType == null) {
- throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
- }
- if (e.getValue().commitMessage != null) {
- throw new BadRequestException("commit message on label definition input not supported");
- }
-
- if (setLabel.updateLabel(config, labelType, e.getValue())) {
- dirty = true;
- }
- }
- }
-
- if (input.commitMessage != null) {
- md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
- } else {
- md.setMessage("Update labels");
- }
-
- if (dirty) {
- config.commit(md);
- projectCache.evictAndReindex(rsrc.getProjectState().getProject());
- }
+ if (labelInput.commitMessage != null) {
+ throw new BadRequestException("commit message on label definition input not supported");
}
+ @SuppressWarnings("unused")
+ var unused = createLabel.createLabel(config, labelInput.name.trim(), labelInput);
+ }
- return Response.ok("");
+ @Override
+ protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+ if (!deleteLabel.deleteLabel(config, name)) {
+ throw new UnprocessableEntityException(String.format("label %s not found", name));
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
new file mode 100644
index 0000000..7c0936f
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostLabelsReview.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostLabelsReview implements RestModifyView<ProjectResource, BatchLabelInput> {
+
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
+ private final PostLabels postLabels;
+
+ @Inject
+ PostLabelsReview(RepoMetaDataUpdater repoMetaDataUpdater, PostLabels postLabels) {
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
+ this.postLabels = postLabels;
+ }
+
+ @Override
+ public Response<ChangeInfo> apply(ProjectResource rsrc, BatchLabelInput input)
+ throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+ RestApiException {
+ try (ConfigChangeCreator creator =
+ repoMetaDataUpdater.configChangeCreator(
+ rsrc.getNameKey(), input.commitMessage, "Review labels change")) {
+ ProjectConfig config = creator.getConfig();
+ var unused = postLabels.updateProjectConfig(config, input);
+ return creator.createChange();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
new file mode 100644
index 0000000..71080a5
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirements.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.SubmitRequirementResource;
+import com.google.inject.Provider;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+@Singleton
+public class PostSubmitRequirements
+ extends AbstractPostCollection<
+ IdString, SubmitRequirementResource, SubmitRequirementInput, BatchSubmitRequirementInput> {
+ CreateSubmitRequirement createSubmitRequirement;
+ DeleteSubmitRequirement deleteSubmitRequirement;
+ UpdateSubmitRequirement updateSubmitRequirement;
+
+ @Inject
+ public PostSubmitRequirements(
+ RepoMetaDataUpdater updater,
+ Provider<CurrentUser> user,
+ CreateSubmitRequirement createSubmitRequirement,
+ DeleteSubmitRequirement deleteSubmitRequirement,
+ UpdateSubmitRequirement updateSubmitRequirement) {
+ super(updater, user);
+ this.createSubmitRequirement = createSubmitRequirement;
+ this.deleteSubmitRequirement = deleteSubmitRequirement;
+ this.updateSubmitRequirement = updateSubmitRequirement;
+ }
+
+ @Override
+ public String defaultCommitMessage() {
+ return "Update Submit Requirements";
+ }
+
+ @Override
+ protected boolean updateItem(ProjectConfig config, String name, SubmitRequirementInput input)
+ throws BadRequestException, UnprocessableEntityException {
+ // The name and input.name can be different - the item should be renamed.
+ if (config.getSubmitRequirementSections().remove(name) == null) {
+ throw new UnprocessableEntityException(
+ String.format("Submit requirement %s not found", name));
+ }
+ var unused = updateSubmitRequirement.updateSubmitRequirement(config, input.name, input);
+ return true;
+ }
+
+ @Override
+ protected void createItem(ProjectConfig config, SubmitRequirementInput input)
+ throws BadRequestException, ResourceConflictException {
+ var unused = createSubmitRequirement.createSubmitRequirement(config, input.name, input);
+ }
+
+ @Override
+ protected void deleteItem(ProjectConfig config, String name) throws UnprocessableEntityException {
+ if (!deleteSubmitRequirement.deleteSubmitRequirement(config, name)) {
+ throw new UnprocessableEntityException(
+ String.format("Submit requirement %s not found", name));
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
new file mode 100644
index 0000000..82761e7
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PostSubmitRequirementsReview.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PostSubmitRequirementsReview
+ implements RestModifyView<ProjectResource, BatchSubmitRequirementInput> {
+
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
+ private final PostSubmitRequirements postSubmitRequirements;
+
+ @Inject
+ PostSubmitRequirementsReview(
+ RepoMetaDataUpdater repoMetaDataUpdater, PostSubmitRequirements postSubmitRequirements) {
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
+ this.postSubmitRequirements = postSubmitRequirements;
+ }
+
+ @Override
+ public Response<ChangeInfo> apply(ProjectResource rsrc, BatchSubmitRequirementInput input)
+ throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+ RestApiException {
+ try (ConfigChangeCreator creator =
+ repoMetaDataUpdater.configChangeCreator(
+ rsrc.getNameKey(), input.commitMessage, "Review submit requirements change")) {
+ ProjectConfig config = creator.getConfig();
+ var unused = postSubmitRequirements.updateProjectConfig(config, input);
+ // If config isn't updated, the createChange throws BadRequestException. We don't need
+ // to explicitly check the updateProjectConfig result here.
+ return creator.createChange();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index a7e7894..5c8bf3d 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -84,6 +84,7 @@
get(PROJECT_KIND, "config").to(GetConfig.class);
put(PROJECT_KIND, "config").to(PutConfig.class);
+ put(PROJECT_KIND, "config:review").to(PutConfigReview.class);
post(PROJECT_KIND, "create.change").to(CreateChange.class);
@@ -102,10 +103,11 @@
child(PROJECT_KIND, "labels").to(LabelsCollection.class);
create(LABEL_KIND).to(CreateLabel.class);
- postOnCollection(LABEL_KIND).to(PostLabels.class);
get(LABEL_KIND).to(GetLabel.class);
put(LABEL_KIND).to(SetLabel.class);
delete(LABEL_KIND).to(DeleteLabel.class);
+ postOnCollection(LABEL_KIND).to(PostLabels.class);
+ post(PROJECT_KIND, "labels:review").to(PostLabelsReview.class);
get(PROJECT_KIND, "parent").to(GetParent.class);
put(PROJECT_KIND, "parent").to(SetParent.class);
@@ -115,6 +117,8 @@
put(SUBMIT_REQUIREMENT_KIND).to(UpdateSubmitRequirement.class);
get(SUBMIT_REQUIREMENT_KIND).to(GetSubmitRequirement.class);
delete(SUBMIT_REQUIREMENT_KIND).to(DeleteSubmitRequirement.class);
+ postOnCollection(SUBMIT_REQUIREMENT_KIND).to(PostSubmitRequirements.class);
+ post(PROJECT_KIND, "submit_requirements:review").to(PostSubmitRequirementsReview.class);
child(PROJECT_KIND, "tags").to(TagsCollection.class);
create(TAG_KIND).to(CreateTag.class);
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index d5f61ce..76b536a 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -34,7 +34,9 @@
import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
import com.google.gerrit.extensions.client.InheritableBoolean;
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.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
@@ -47,15 +49,14 @@
import com.google.gerrit.server.config.PluginConfigFactory;
import com.google.gerrit.server.config.ProjectConfigEntry;
import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.BooleanProjectConfigTransformations;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -65,7 +66,6 @@
import java.util.Map;
import java.util.regex.Pattern;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Config;
@Singleton
@@ -76,8 +76,6 @@
Pattern.compile("^[a-zA-Z0-9]+[a-zA-Z0-9-]*$");
private final boolean serverEnableSignedPush;
- private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
- private final ProjectCache projectCache;
private final ProjectState.Factory projectStateFactory;
private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
private final PluginConfigFactory cfgFactory;
@@ -88,11 +86,11 @@
private final PermissionBackend permissionBackend;
private final ProjectConfig.Factory projectConfigFactory;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
+
@Inject
PutConfig(
@EnableSignedPush boolean serverEnableSignedPush,
- Provider<MetaDataUpdate.User> metaDataUpdateFactory,
- ProjectCache projectCache,
ProjectState.Factory projectStateFactory,
DynamicMap<ProjectConfigEntry> pluginConfigEntries,
PluginConfigFactory cfgFactory,
@@ -101,10 +99,9 @@
DynamicMap<RestView<ProjectResource>> views,
Provider<CurrentUser> user,
PermissionBackend permissionBackend,
- ProjectConfig.Factory projectConfigFactory) {
+ ProjectConfig.Factory projectConfigFactory,
+ RepoMetaDataUpdater repoMetaDataUpdater) {
this.serverEnableSignedPush = serverEnableSignedPush;
- this.metaDataUpdateFactory = metaDataUpdateFactory;
- this.projectCache = projectCache;
this.projectStateFactory = projectStateFactory;
this.pluginConfigEntries = pluginConfigEntries;
this.cfgFactory = cfgFactory;
@@ -114,6 +111,7 @@
this.user = user;
this.permissionBackend = permissionBackend;
this.projectConfigFactory = projectConfigFactory;
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
@@ -127,72 +125,74 @@
}
public ConfigInfo apply(ProjectState projectState, ConfigInput input)
- throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+ throws ResourceNotFoundException, BadRequestException, ResourceConflictException,
+ PermissionBackendException, AuthException, MethodNotAllowedException {
Project.NameKey projectName = projectState.getNameKey();
if (input == null) {
throw new BadRequestException("config is required");
}
-
- try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
- ProjectConfig projectConfig = projectConfigFactory.read(md);
- projectConfig.updateProject(
- p -> {
- p.setDescription(Strings.emptyToNull(input.description));
- for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
- InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
- if (val != null) {
- p.setBooleanConfig(cfg, val);
- }
- }
- if (input.maxObjectSizeLimit != null) {
- p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
- }
- if (input.submitType != null) {
- p.setSubmitType(input.submitType);
- }
- if (input.state != null) {
- p.setState(input.state);
- }
- });
-
- if (input.pluginConfigValues != null) {
- setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
- }
-
- if (input.commentLinks != null) {
- updateCommentLinks(projectConfig, input.commentLinks);
- }
-
- md.setMessage("Modified project settings\n");
- try {
- projectConfig.commit(md);
- projectCache.evictAndReindex(projectConfig.getProject());
- md.getRepository().setGitwebDescription(projectConfig.getProject().getDescription());
- } catch (IOException e) {
- if (e.getCause() instanceof ConfigInvalidException) {
- throw new ResourceConflictException(
- "Cannot update " + projectName + ": " + e.getCause().getMessage());
- }
- logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
- throw new ResourceConflictException("Cannot update " + projectName);
- }
-
- ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
+ try (ConfigUpdater updater =
+ repoMetaDataUpdater.configUpdater(
+ projectName, input.commitMessage, "Modified project settings")) {
+ updateConfig(projectState, updater.getConfig(), input);
+ updater.commitConfigUpdate();
+ updater
+ .getRepository()
+ .setGitwebDescription(updater.getConfig().getProject().getDescription());
+ ProjectState newProjectState =
+ projectStateFactory.create(
+ projectConfigFactory.read(updater.getRepository(), projectName).getCacheable());
return ConfigInfoCreator.constructInfo(
serverEnableSignedPush,
- state,
+ newProjectState,
user.get(),
pluginConfigEntries,
cfgFactory,
allProjects,
uiActions,
views);
- } catch (RepositoryNotFoundException notFound) {
- throw new ResourceNotFoundException(projectName.get(), notFound);
+
+ } catch (IOException e) {
+ if (e.getCause() instanceof ConfigInvalidException) {
+ throw new ResourceConflictException(
+ "Cannot update " + projectName + ": " + e.getCause().getMessage());
+ }
+ logger.atWarning().withCause(e).log("Failed to update config of project %s.", projectName);
+ throw new ResourceConflictException("Cannot update " + projectName);
} catch (ConfigInvalidException err) {
throw new ResourceConflictException("Cannot read project " + projectName, err);
- } catch (IOException err) {
- throw new ResourceConflictException("Cannot update project " + projectName, err);
+ }
+ }
+
+ public void updateConfig(
+ ProjectState projectState, ProjectConfig projectConfig, ConfigInput input)
+ throws BadRequestException {
+ projectConfig.updateProject(
+ p -> {
+ p.setDescription(Strings.emptyToNull(input.description));
+ for (BooleanProjectConfig cfg : BooleanProjectConfig.values()) {
+ InheritableBoolean val = BooleanProjectConfigTransformations.get(cfg, input);
+ if (val != null) {
+ p.setBooleanConfig(cfg, val);
+ }
+ }
+ if (input.maxObjectSizeLimit != null) {
+ p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
+ }
+ if (input.submitType != null) {
+ p.setSubmitType(input.submitType);
+ }
+ if (input.state != null) {
+ p.setState(input.state);
+ }
+ });
+
+ if (input.pluginConfigValues != null) {
+ setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
+ }
+
+ if (input.commentLinks != null) {
+ updateCommentLinks(projectConfig, input.commentLinks);
}
}
@@ -302,7 +302,7 @@
}
}
- private void updateCommentLinks(
+ private static void updateCommentLinks(
ProjectConfig projectConfig, Map<String, CommentLinkInput> input) {
for (Map.Entry<String, CommentLinkInput> e : input.entrySet()) {
String name = e.getKey();
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfigReview.java b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
new file mode 100644
index 0000000..5c51003
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/PutConfigReview.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator;
+import com.google.gerrit.server.update.UpdateException;
+import java.io.IOException;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class PutConfigReview implements RestModifyView<ProjectResource, ConfigInput> {
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
+ private final PutConfig putConfig;
+
+ @Inject
+ PutConfigReview(RepoMetaDataUpdater repoMetaDataUpdater, PutConfig putConfig) {
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
+ this.putConfig = putConfig;
+ }
+
+ @Override
+ public Response<ChangeInfo> apply(ProjectResource rsrc, ConfigInput input)
+ throws PermissionBackendException, IOException, ConfigInvalidException, UpdateException,
+ RestApiException {
+ try (ConfigChangeCreator creator =
+ repoMetaDataUpdater.configChangeCreator(
+ rsrc.getNameKey(), input.commitMessage, "Review config change")) {
+ putConfig.updateConfig(rsrc.getProjectState(), creator.getConfig(), input);
+ return creator.createChange();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/PutDescription.java b/java/com/google/gerrit/server/restapi/project/PutDescription.java
index ec42035..20f439c 100644
--- a/java/com/google/gerrit/server/restapi/project/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/project/PutDescription.java
@@ -14,24 +14,19 @@
package com.google.gerrit.server.restapi.project;
-import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.gerrit.extensions.api.projects.DescriptionInput;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -39,53 +34,30 @@
@Singleton
public class PutDescription implements RestModifyView<ProjectResource, DescriptionInput> {
- private final ProjectCache cache;
- private final Provider<MetaDataUpdate.Server> updateFactory;
- private final PermissionBackend permissionBackend;
- private final ProjectConfig.Factory projectConfigFactory;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
- PutDescription(
- ProjectCache cache,
- Provider<MetaDataUpdate.Server> updateFactory,
- PermissionBackend permissionBackend,
- ProjectConfig.Factory projectConfigFactory) {
- this.cache = cache;
- this.updateFactory = updateFactory;
- this.permissionBackend = permissionBackend;
- this.projectConfigFactory = projectConfigFactory;
+ PutDescription(RepoMetaDataUpdater repoMetaDataUpdater) {
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<String> apply(ProjectResource resource, DescriptionInput input)
throws AuthException, ResourceConflictException, ResourceNotFoundException, IOException,
- PermissionBackendException {
+ PermissionBackendException, BadRequestException, MethodNotAllowedException {
if (input == null) {
input = new DescriptionInput(); // Delete would set description to null.
}
- IdentifiedUser user = resource.getUser().asIdentifiedUser();
- permissionBackend
- .user(user)
- .project(resource.getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
- try (MetaDataUpdate md = updateFactory.get().create(resource.getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(
+ resource.getNameKey(), input.commitMessage, "Update description")) {
+ ProjectConfig config = configUpdater.getConfig();
String desc = input.description;
config.updateProject(p -> p.setDescription(Strings.emptyToNull(desc)));
- String msg =
- MoreObjects.firstNonNull(
- Strings.emptyToNull(input.commitMessage), "Update description\n");
- if (!msg.endsWith("\n")) {
- msg += "\n";
- }
- md.setAuthor(user);
- md.setMessage(msg);
- config.commit(md);
- cache.evictAndReindex(resource.getProjectState().getProject());
- md.getRepository().setGitwebDescription(config.getProject().getDescription());
+ configUpdater.commitConfigUpdate();
+ configUpdater.getRepository().setGitwebDescription(config.getProject().getDescription());
return Strings.isNullOrEmpty(config.getProject().getDescription())
? Response.none()
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
index c45a009..9fb536c 100644
--- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
+++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -15,24 +15,29 @@
package com.google.gerrit.server.restapi.project;
import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.MustBeClosed;
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.InvalidNameException;
+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.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.CreateGroupPermissionSyncer;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.Sequences;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.git.meta.MetaDataUpdate.User;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -49,18 +54,20 @@
import javax.inject.Inject;
import javax.inject.Provider;
import javax.inject.Singleton;
+import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
/** Updates repo refs/meta/config content. */
@Singleton
public class RepoMetaDataUpdater {
- private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
private final Provider<User> metaDataUpdateFactory;
+ private final Provider<CurrentUser> user;
private final ProjectConfig.Factory projectConfigFactory;
private final ProjectCache projectCache;
private final ChangeInserter.Factory changeInserterFactory;
@@ -69,38 +76,61 @@
private final BatchUpdate.Factory updateFactory;
private final PermissionBackend permissionBackend;
+ private final ChangeJson.Factory jsonFactory;
@Inject
RepoMetaDataUpdater(
- CreateGroupPermissionSyncer createGroupPermissionSyncer,
Provider<User> metaDataUpdateFactory,
+ Provider<CurrentUser> user,
ProjectConfig.Factory projectConfigFactory,
ProjectCache projectCache,
ChangeInserter.Factory changeInserterFactory,
Sequences seq,
BatchUpdate.Factory updateFactory,
- PermissionBackend permissionBackend) {
- this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+ PermissionBackend permissionBackend,
+ ChangeJson.Factory jsonFactory) {
this.metaDataUpdateFactory = metaDataUpdateFactory;
+ this.user = user;
this.projectConfigFactory = projectConfigFactory;
this.projectCache = projectCache;
this.changeInserterFactory = changeInserterFactory;
this.seq = seq;
this.updateFactory = updateFactory;
this.permissionBackend = permissionBackend;
+ this.jsonFactory = jsonFactory;
}
- public Change updateAndCreateChangeForReview(
- Project.NameKey projectName,
- CurrentUser user,
- String message,
- ProjectConfigUpdater projectConfigUpdater)
- throws ConfigInvalidException, IOException, RestApiException, UpdateException,
- InvalidNameException, PermissionBackendException {
- checkArgument(!message.isBlank(), "The message must not be empty");
- message = validateMessage(message);
-
- PermissionBackend.ForProject forProject = permissionBackend.user(user).project(projectName);
+ /**
+ * Returns a creator for creating project config changes.
+ *
+ * <p>The method checks that user has required permissions.
+ *
+ * <p>Usage:
+ *
+ * <pre>{@code
+ * try(var changeCreator =
+ * repoMetaDataUpdater.configChangeCreator(projectName, message, defaultMessage)) {
+ * ProjectConfig config = changeCreator.getConfig();
+ * // ... update project config
+ * // Create change - if the createChange method is not called, all updates are ignored and no
+ * // change is created.
+ * Response<ChangeInfo> result = changeCreator.createChange();
+ * }
+ * }</pre>
+ *
+ * @param projectName the name of the project whose config should be updated
+ * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+ * empty) - the {@code defaultMessage} is used.
+ * @param defaultMessage the default commit message if the user doesn't provide one.
+ */
+ @MustBeClosed
+ public ConfigChangeCreator configChangeCreator(
+ Project.NameKey projectName, @Nullable String message, String defaultMessage)
+ throws PermissionBackendException, AuthException, ResourceConflictException, IOException,
+ ConfigInvalidException {
+ message = validateMessage(message, defaultMessage);
+ PermissionBackend.ForProject forProject =
+ permissionBackend.user(user.get()).project(projectName);
if (!check(forProject, ProjectPermission.READ_CONFIG)) {
throw new AuthException(RefNames.REFS_CONFIG + " not visible");
}
@@ -112,15 +142,166 @@
}
}
projectCache.get(projectName).orElseThrow(illegalState(projectName)).checkStatePermitsWrite();
-
- try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
- ProjectConfig config = projectConfigFactory.read(md);
- ObjectId oldCommit = config.getRevision();
- String oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
-
- projectConfigUpdater.update(config);
- md.setMessage(message);
+ // The MetaDataUpdate instance gets closed in the ConfigChangeCreator.close() method.
+ MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+ try {
md.setInsertChangeId(true);
+ md.setMessage(message);
+ ProjectConfig config = projectConfigFactory.read(md);
+ return new ConfigChangeCreator(md, projectName, user.get(), config);
+ } catch (Throwable t) {
+ try (md) {
+ throw t;
+ }
+ }
+ }
+
+ /**
+ * Returns an updater for updating project config without review.
+ *
+ * <p>The method checks that user has required permissions and that user can update config without
+ * review.
+ *
+ * <p>When the update is saved (using the {@link ConfigUpdater#commitConfigUpdate} method), the
+ * project cache is updated automatically.
+ *
+ * <p>Usage:
+ *
+ * <pre>{@code
+ * try(var configUpdater =
+ * repoMetaDataUpdater.configUpdater(projectName, message, defaultMessage)) {
+ * ProjectConfig config = changeCreator.getConfig();
+ * // ... update project config
+ * // Save updated config - if the commitConfigUpdate method is not called, all updates are ignored.
+ * configUpdater.commitConfigUpdate();
+ * }
+ * }</pre>
+ *
+ * @param projectName the name of the project whose config should be updated
+ * @param message the user-provided commit message. If it is not provided (i.e. it is null or
+ * empty) - the {@code defaultMessage} is used.
+ * @param defaultMessage the default commit message if the user doesn't provide one.
+ */
+ @MustBeClosed
+ public ConfigUpdater configUpdater(
+ Project.NameKey projectName, @Nullable String message, String defaultMessage)
+ throws AuthException, PermissionBackendException, ConfigInvalidException, IOException,
+ BadRequestException, MethodNotAllowedException {
+ if (!user.get().isIdentifiedUser()) {
+ throw new AuthException("Authentication required");
+ }
+ permissionBackend.user(user.get()).project(projectName).check(ProjectPermission.WRITE_CONFIG);
+ return configUpdaterWithoutPermissionsCheck(projectName, message, defaultMessage);
+ }
+
+ /**
+ * Returns an updater for updating project config without review and skips some permissions
+ * checks.
+ *
+ * <p>The method only checks that user can update config without review and doesn't do any other
+ * permissions checks. It should be used only when standard permissions checks from {@link
+ * #configUpdater} can't be used.
+ *
+ * <p>See {@link #configUpdater} for details.
+ */
+ @MustBeClosed
+ public ConfigUpdater configUpdaterWithoutPermissionsCheck(
+ Project.NameKey projectName, @Nullable String message, String defaultMessage)
+ throws IOException, ConfigInvalidException, MethodNotAllowedException,
+ PermissionBackendException {
+ if (!permissionBackend
+ .currentUser()
+ .project(projectName)
+ .test(ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE)) {
+ throw new MethodNotAllowedException(
+ "Updating project config without review is disabled. Please create a change and send it "
+ + "for review. Some rest API methods have alternatives for creating required changes "
+ + "automatically - please check gerrit documentation.");
+ }
+ message = validateMessage(message, defaultMessage);
+ // The MetaDataUpdate instance gets closed in the ConfigUpdater.close() method.
+ MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName);
+ try {
+ ProjectConfig config = projectConfigFactory.read(md);
+ md.setMessage(message);
+ return new ConfigUpdater(md, config);
+ } catch (Throwable t) {
+ try (md) {
+ throw t;
+ }
+ }
+ }
+
+ /**
+ * Updater for a project config without review.
+ *
+ * <p>See {@link #configUpdater} and {@link #configUpdaterWithoutPermissionsCheck} for details and
+ * usages.
+ */
+ public class ConfigUpdater implements AutoCloseable {
+ private final MetaDataUpdate md;
+ private final ProjectConfig config;
+
+ private ConfigUpdater(MetaDataUpdate md, ProjectConfig config) {
+ this.md = md;
+ this.config = config;
+ }
+
+ public ProjectConfig getConfig() {
+ return config;
+ }
+
+ public void commitConfigUpdate() throws IOException {
+ config.commit(md);
+ projectCache.evictAndReindex(config.getProject());
+ }
+
+ public Repository getRepository() {
+ return md.getRepository();
+ }
+
+ @Override
+ public void close() {
+ md.close();
+ }
+ }
+
+ /**
+ * Creates a change for a project config update.
+ *
+ * <p>See {@link #createChange} for details and usages.
+ */
+ public class ConfigChangeCreator implements AutoCloseable {
+ private final MetaDataUpdate md;
+ private final String oldCommitSha1;
+ private final Project.NameKey projectName;
+ private final CurrentUser user;
+ private final ProjectConfig config;
+ private boolean changeCreated;
+
+ private ConfigChangeCreator(
+ MetaDataUpdate md, Project.NameKey projectName, CurrentUser user, ProjectConfig config) {
+ this.md = md;
+ this.config = config;
+ this.projectName = projectName;
+ this.user = user;
+ ObjectId oldCommit = config.getRevision();
+ oldCommitSha1 = oldCommit == null ? null : oldCommit.getName();
+ }
+
+ @Override
+ public void close() {
+ md.close();
+ }
+
+ public ProjectConfig getConfig() {
+ return config;
+ }
+
+ public Response<ChangeInfo> createChange()
+ throws IOException, UpdateException, RestApiException {
+ checkState(!changeCreated, "Change has been already created");
+ changeCreated = true;
Change.Id changeId = Change.id(seq.nextChangeId());
try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -140,62 +321,38 @@
ChangeInserter ins = newInserter(changeId, commit);
bu.insertChange(ins);
bu.execute();
- return ins.getChange();
+ Change change = ins.getChange();
+ return Response.created(jsonFactory.noOptions().format(change));
}
}
}
- }
- public void updateWithoutReview(
- Project.NameKey projectName, String message, ProjectConfigUpdater projectConfigUpdater)
- throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
- ResourceConflictException, InvalidNameException, BadRequestException {
- updateWithoutReview(
- projectName, message, /*skipPermissionsCheck=*/ false, projectConfigUpdater);
- }
-
- public void updateWithoutReview(
- Project.NameKey projectName,
- String message,
- boolean skipPermissionsCheck,
- ProjectConfigUpdater projectConfigUpdater)
- throws ConfigInvalidException, IOException, PermissionBackendException, AuthException,
- ResourceConflictException, InvalidNameException, BadRequestException {
- message = validateMessage(message);
- if (!skipPermissionsCheck) {
- permissionBackend.currentUser().project(projectName).check(ProjectPermission.WRITE_CONFIG);
- }
-
- try (MetaDataUpdate md = metaDataUpdateFactory.get().create(projectName)) {
- ProjectConfig config = projectConfigFactory.read(md);
-
- projectConfigUpdater.update(config);
- md.setMessage(message);
- config.commit(md);
- projectCache.evictAndReindex(config.getProject());
- createGroupPermissionSyncer.syncIfNeeded();
+ // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+ @SuppressWarnings("deprecation")
+ private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+ return changeInserterFactory
+ .create(changeId, commit, RefNames.REFS_CONFIG)
+ .setMessage(
+ // Same message as in ReceiveCommits.CreateRequest.
+ ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+ .setValidate(false)
+ .setUpdateRef(false);
}
}
- private String validateMessage(String message) {
+ private String validateMessage(@Nullable String message, String defaultMessage) {
+ if (Strings.isNullOrEmpty(message)) {
+ message = defaultMessage;
+ } else {
+ message = message.trim();
+ }
+ checkArgument(!message.isBlank(), "The message must not be empty");
if (!message.endsWith("\n")) {
return message + "\n";
}
return message;
}
- // ProjectConfig doesn't currently support fusing into a BatchUpdate.
- @SuppressWarnings("deprecation")
- private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
- return changeInserterFactory
- .create(changeId, commit, RefNames.REFS_CONFIG)
- .setMessage(
- // Same message as in ReceiveCommits.CreateRequest.
- ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
- .setValidate(false)
- .setUpdateRef(false);
- }
-
private boolean check(PermissionBackend.ForProject perm, ProjectPermission p)
throws PermissionBackendException {
try {
@@ -205,11 +362,4 @@
return false;
}
}
-
- @FunctionalInterface
- public interface ProjectConfigUpdater {
- void update(ProjectConfig config)
- throws BadRequestException, InvalidNameException, PermissionBackendException,
- ResourceConflictException, AuthException;
- }
}
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 75fe280..65851c0 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -14,7 +14,6 @@
package com.google.gerrit.server.restapi.project;
-import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.gerrit.entities.AccessSection;
@@ -29,12 +28,15 @@
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CreateGroupPermissionSyncer;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -49,6 +51,7 @@
private final Provider<IdentifiedUser> identifiedUser;
private final SetAccessUtil accessUtil;
private final RepoMetaDataUpdater repoMetaDataUpdater;
+ private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
@Inject
private SetAccess(
@@ -57,6 +60,7 @@
GetAccess getAccess,
Provider<IdentifiedUser> identifiedUser,
SetAccessUtil accessUtil,
+ CreateGroupPermissionSyncer createGroupPermissionSyncer,
RepoMetaDataUpdater repoMetaDataUpdater) {
this.groupBackend = groupBackend;
this.permissionBackend = permissionBackend;
@@ -64,6 +68,7 @@
this.identifiedUser = identifiedUser;
this.accessUtil = accessUtil;
this.repoMetaDataUpdater = repoMetaDataUpdater;
+ this.createGroupPermissionSyncer = createGroupPermissionSyncer;
}
@Override
@@ -75,42 +80,40 @@
accessUtil.getAccessSections(input.remove, /* rejectNonResolvableGroups= */ false);
ImmutableList<AccessSection> additions =
accessUtil.getAccessSections(input.add, /* rejectNonResolvableGroups= */ true);
- String message = !Strings.isNullOrEmpty(input.message) ? input.message : "Modify access rules";
- try {
- this.repoMetaDataUpdater.updateWithoutReview(
+
+ try (ConfigUpdater updater =
+ repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+ rsrc.getNameKey(), input.message, "Modify access rules")) {
+ ProjectConfig config = updater.getConfig();
+ boolean checkedAdmin = false;
+ for (AccessSection section : Iterables.concat(additions, removals)) {
+ boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+ if (isGlobalCapabilities) {
+ if (!checkedAdmin) {
+ permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
+ checkedAdmin = true;
+ }
+ } else {
+ permissionBackend
+ .currentUser()
+ .project(rsrc.getNameKey())
+ .ref(section.getName())
+ .check(RefPermission.WRITE_CONFIG);
+ }
+ }
+
+ accessUtil.validateChanges(config, removals, additions);
+ accessUtil.applyChanges(config, removals, additions);
+
+ accessUtil.setParentName(
+ identifiedUser.get(),
+ config,
rsrc.getNameKey(),
- message,
- /*skipPermissionsCheck=*/ true,
- config -> {
- // Check that the user has the right permissions.
- boolean checkedAdmin = false;
- for (AccessSection section : Iterables.concat(additions, removals)) {
- boolean isGlobalCapabilities =
- AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
- if (isGlobalCapabilities) {
- if (!checkedAdmin) {
- permissionBackend.currentUser().check(GlobalPermission.ADMINISTRATE_SERVER);
- checkedAdmin = true;
- }
- } else {
- permissionBackend
- .currentUser()
- .project(rsrc.getNameKey())
- .ref(section.getName())
- .check(RefPermission.WRITE_CONFIG);
- }
- }
+ input.parent == null ? null : Project.nameKey(input.parent),
+ !checkedAdmin);
- accessUtil.validateChanges(config, removals, additions);
- accessUtil.applyChanges(config, removals, additions);
-
- accessUtil.setParentName(
- identifiedUser.get(),
- config,
- rsrc.getNameKey(),
- input.parent == null ? null : Project.nameKey(input.parent),
- !checkedAdmin);
- });
+ updater.commitConfigUpdate();
+ createGroupPermissionSyncer.syncIfNeeded();
} catch (InvalidNameException e) {
throw new BadRequestException(e.toString());
} catch (ConfigInvalidException e) {
diff --git a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
index 853d7df..a46ee32 100644
--- a/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
+++ b/java/com/google/gerrit/server/restapi/project/SetDefaultDashboard.java
@@ -14,7 +14,6 @@
package com.google.gerrit.server.restapi.project;
-import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.gerrit.extensions.api.projects.DashboardInfo;
import com.google.gerrit.extensions.api.projects.SetDashboardInput;
@@ -25,12 +24,8 @@
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.DashboardResource;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
import com.google.inject.Inject;
@@ -41,30 +36,21 @@
import org.kohsuke.args4j.Option;
class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
- private final ProjectCache cache;
- private final MetaDataUpdate.Server updateFactory;
private final DashboardsCollection dashboards;
private final Provider<GetDashboard> get;
- private final PermissionBackend permissionBackend;
- private final ProjectConfig.Factory projectConfigFactory;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Option(name = "--inherited", usage = "set dashboard inherited by children")
boolean inherited;
@Inject
SetDefaultDashboard(
- ProjectCache cache,
- MetaDataUpdate.Server updateFactory,
DashboardsCollection dashboards,
Provider<GetDashboard> get,
- PermissionBackend permissionBackend,
- ProjectConfig.Factory projectConfigFactory) {
- this.cache = cache;
- this.updateFactory = updateFactory;
+ RepoMetaDataUpdater repoMetaDataUpdater) {
this.dashboards = dashboards;
this.get = get;
- this.permissionBackend = permissionBackend;
- this.projectConfigFactory = projectConfigFactory;
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
@@ -75,11 +61,6 @@
}
input.id = Strings.emptyToNull(input.id);
- permissionBackend
- .user(rsrc.getUser())
- .project(rsrc.getProjectState().getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
DashboardResource target = null;
if (input.id != null) {
try {
@@ -93,29 +74,22 @@
throw new ResourceConflictException(e.getMessage());
}
}
+ String defaultMessage =
+ input.id == null
+ ? "Removed default dashboard.\n"
+ : String.format("Changed default dashboard to %s.\n", input.id);
- try (MetaDataUpdate md = updateFactory.create(rsrc.getProjectState().getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(
+ rsrc.getProjectState().getNameKey(), input.commitMessage, defaultMessage)) {
+ ProjectConfig config = configUpdater.getConfig();
String id = input.id;
if (inherited) {
config.updateProject(p -> p.setDefaultDashboard(id));
} else {
config.updateProject(p -> p.setLocalDefaultDashboard(id));
}
-
- String msg =
- MoreObjects.firstNonNull(
- Strings.emptyToNull(input.commitMessage),
- input.id == null
- ? "Removed default dashboard.\n"
- : String.format("Changed default dashboard to %s.\n", input.id));
- if (!msg.endsWith("\n")) {
- msg += "\n";
- }
- md.setAuthor(rsrc.getUser().asIdentifiedUser());
- md.setMessage(msg);
- config.commit(md);
- cache.evictAndReindex(rsrc.getProjectState().getProject());
+ configUpdater.commitConfigUpdate();
if (target != null) {
Response<DashboardInfo> response = get.get().apply(target);
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index edd165d..0779fdd 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -20,22 +20,17 @@
import com.google.gerrit.extensions.common.LabelDefinitionInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.project.LabelDefinitionJson;
import com.google.gerrit.server.project.LabelResource;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Optional;
@@ -43,63 +38,37 @@
@Singleton
public class SetLabel implements RestModifyView<LabelResource, LabelDefinitionInput> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
- private final ProjectCache projectCache;
private final ApprovalQueryBuilder approvalQueryBuilder;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
public SetLabel(
- Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
- ProjectCache projectCache,
- ApprovalQueryBuilder approvalQueryBuilder) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
- this.projectCache = projectCache;
+ ApprovalQueryBuilder approvalQueryBuilder, RepoMetaDataUpdater repoMetaDataUpdater) {
this.approvalQueryBuilder = approvalQueryBuilder;
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<LabelDefinitionInfo> apply(LabelResource rsrc, LabelDefinitionInput input)
throws AuthException, BadRequestException, ResourceConflictException,
- PermissionBackendException, IOException, ConfigInvalidException {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
- }
-
- permissionBackend
- .currentUser()
- .project(rsrc.getProject().getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
+ PermissionBackendException, IOException, ConfigInvalidException,
+ MethodNotAllowedException {
if (input == null) {
input = new LabelDefinitionInput();
}
LabelType labelType = rsrc.getLabelType();
- try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(
+ rsrc.getProject().getNameKey(), input.commitMessage, "Update label")) {
+ ProjectConfig config = configUpdater.getConfig();
if (updateLabel(config, labelType, input)) {
- if (input.commitMessage != null) {
- md.setMessage(Strings.emptyToNull(input.commitMessage.trim()));
- } else {
- md.setMessage("Update label");
- }
String newName = Strings.nullToEmpty(input.name).trim();
labelType =
config.getLabelSections().get(newName.isEmpty() ? labelType.getName() : newName);
-
- config.commit(md);
- projectCache.evictAndReindex(rsrc.getProject().getProjectState().getProject());
+ configUpdater.commitConfigUpdate();
}
}
return Response.ok(LabelDefinitionJson.format(rsrc.getProject().getNameKey(), labelType));
diff --git a/java/com/google/gerrit/server/restapi/project/SetParent.java b/java/com/google/gerrit/server/restapi/project/SetParent.java
index ef31dc5..fedd240 100644
--- a/java/com/google/gerrit/server/restapi/project/SetParent.java
+++ b/java/com/google/gerrit/server/restapi/project/SetParent.java
@@ -24,6 +24,7 @@
import com.google.gerrit.extensions.api.projects.ParentInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
@@ -38,7 +39,6 @@
import com.google.gerrit.server.config.ConfigUpdatedEvent.UpdateResult;
import com.google.gerrit.server.config.GerritConfigListener;
import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -48,7 +48,6 @@
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -60,61 +59,52 @@
implements RestModifyView<ProjectResource, ParentInput>, GerritConfigListener {
private final ProjectCache cache;
private final PermissionBackend permissionBackend;
- private final Provider<MetaDataUpdate.Server> updateFactory;
private final AllProjectsName allProjects;
private final AllUsersName allUsers;
- private final ProjectConfig.Factory projectConfigFactory;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
private volatile boolean allowProjectOwnersToChangeParent;
@Inject
SetParent(
ProjectCache cache,
PermissionBackend permissionBackend,
- Provider<MetaDataUpdate.Server> updateFactory,
AllProjectsName allProjects,
AllUsersName allUsers,
- ProjectConfig.Factory projectConfigFactory,
- @GerritServerConfig Config config) {
+ @GerritServerConfig Config config,
+ RepoMetaDataUpdater repoMetaDataUpdater) {
this.cache = cache;
this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
this.allProjects = allProjects;
this.allUsers = allUsers;
- this.projectConfigFactory = projectConfigFactory;
this.allowProjectOwnersToChangeParent =
config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<String> apply(ProjectResource rsrc, ParentInput input)
throws AuthException, ResourceConflictException, ResourceNotFoundException,
UnprocessableEntityException, IOException, PermissionBackendException,
- BadRequestException {
+ BadRequestException, MethodNotAllowedException {
return Response.ok(apply(rsrc, input, true));
}
public String apply(ProjectResource rsrc, ParentInput input, boolean checkIfAdmin)
throws AuthException, ResourceConflictException, ResourceNotFoundException,
UnprocessableEntityException, IOException, PermissionBackendException,
- BadRequestException {
+ BadRequestException, MethodNotAllowedException {
IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
String parentName =
MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
- try (MetaDataUpdate md = updateFactory.get().create(rsrc.getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(
+ rsrc.getNameKey(),
+ input.commitMessage,
+ String.format("Changed parent to %s.\n", parentName))) {
+ ProjectConfig config = configUpdater.getConfig();
config.updateProject(p -> p.setParent(parentName));
-
- String msg = Strings.emptyToNull(input.commitMessage);
- if (msg == null) {
- msg = String.format("Changed parent to %s.\n", parentName);
- } else if (!msg.endsWith("\n")) {
- msg += "\n";
- }
- md.setAuthor(user);
- md.setMessage(msg);
- config.commit(md);
- cache.evictAndReindex(rsrc.getProjectState().getProject());
+ configUpdater.commitConfigUpdate();
Project.NameKey parent = config.getProject().getParent(allProjects);
requireNonNull(parent);
diff --git a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
index 3e1104e..2f264b5 100644
--- a/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
+++ b/java/com/google/gerrit/server/restapi/project/UpdateSubmitRequirement.java
@@ -22,21 +22,16 @@
import com.google.gerrit.extensions.common.SubmitRequirementInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.permissions.ProjectPermission;
-import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.SubmitRequirementExpressionsValidator;
import com.google.gerrit.server.project.SubmitRequirementJson;
import com.google.gerrit.server.project.SubmitRequirementResource;
import com.google.gerrit.server.project.SubmitRequirementsUtil;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Optional;
@@ -48,42 +43,22 @@
@Singleton
public class UpdateSubmitRequirement
implements RestModifyView<SubmitRequirementResource, SubmitRequirementInput> {
- private final Provider<CurrentUser> user;
- private final PermissionBackend permissionBackend;
- private final MetaDataUpdate.User updateFactory;
- private final ProjectConfig.Factory projectConfigFactory;
- private final ProjectCache projectCache;
private final SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator;
+ private final RepoMetaDataUpdater repoMetaDataUpdater;
@Inject
public UpdateSubmitRequirement(
- Provider<CurrentUser> user,
- PermissionBackend permissionBackend,
- MetaDataUpdate.User updateFactory,
- ProjectConfig.Factory projectConfigFactory,
- ProjectCache projectCache,
- SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator) {
- this.user = user;
- this.permissionBackend = permissionBackend;
- this.updateFactory = updateFactory;
- this.projectConfigFactory = projectConfigFactory;
- this.projectCache = projectCache;
+ SubmitRequirementExpressionsValidator submitRequirementExpressionsValidator,
+ RepoMetaDataUpdater repoMetaDataUpdater) {
this.submitRequirementExpressionsValidator = submitRequirementExpressionsValidator;
+ this.repoMetaDataUpdater = repoMetaDataUpdater;
}
@Override
public Response<SubmitRequirementInfo> apply(
SubmitRequirementResource rsrc, SubmitRequirementInput input)
- throws AuthException, BadRequestException, PermissionBackendException, IOException {
- if (!user.get().isIdentifiedUser()) {
- throw new AuthException("Authentication required");
- }
-
- permissionBackend
- .currentUser()
- .project(rsrc.getProject().getNameKey())
- .check(ProjectPermission.WRITE_CONFIG);
-
+ throws AuthException, BadRequestException, PermissionBackendException, IOException,
+ MethodNotAllowedException {
if (input == null) {
input = new SubmitRequirementInput();
}
@@ -92,16 +67,17 @@
throw new BadRequestException("name in input must match name in URL");
}
- try (MetaDataUpdate md = updateFactory.create(rsrc.getProject().getNameKey())) {
- ProjectConfig config = projectConfigFactory.read(md);
+ try (var configUpdater =
+ repoMetaDataUpdater.configUpdater(
+ rsrc.getProject().getNameKey(),
+ null,
+ String.format("Update Submit Requirement %s", rsrc.getSubmitRequirement().name()))) {
+ ProjectConfig config = configUpdater.getConfig();
SubmitRequirement submitRequirement =
- createSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
+ updateSubmitRequirement(config, rsrc.getSubmitRequirement().name(), input);
- md.setMessage(String.format("Update Submit Requirement %s", submitRequirement.name()));
- config.commit(md);
-
- projectCache.evict(rsrc.getProject().getNameKey());
+ configUpdater.commitConfigUpdate();
return Response.created(SubmitRequirementJson.format(submitRequirement));
} catch (ConfigInvalidException e) {
@@ -109,7 +85,7 @@
}
}
- public SubmitRequirement createSubmitRequirement(
+ public SubmitRequirement updateSubmitRequirement(
ProjectConfig config, String name, SubmitRequirementInput input) throws BadRequestException {
validateSRName(name);
if (Strings.isNullOrEmpty(input.submittabilityExpression)) {
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 123a873..55ec9b0 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -19,6 +19,7 @@
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
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.schema.AclUtil.block;
import static com.google.gerrit.server.schema.AclUtil.grant;
import static com.google.gerrit.server.schema.AclUtil.rule;
import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
@@ -33,6 +34,8 @@
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.Sequence;
import com.google.gerrit.server.config.AllProjectsName;
@@ -44,6 +47,7 @@
import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
+import java.util.Optional;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -131,11 +135,16 @@
// init labels.
input.codeReviewLabel().ifPresent(codeReviewLabel -> config.upsertLabelType(codeReviewLabel));
+ // init access sections.
if (input.initDefaultAcls()) {
- // init access sections.
initDefaultAcls(config, input);
}
+ // init submit requirement sections.
+ if (input.initDefaultSubmitRequirements()) {
+ initDefaultSubmitRequirements(config);
+ }
+
// commit all the above configs as a commit in "refs/meta/config" branch of the All-Projects.
config.commitToNewRef(md, RefNames.REFS_CONFIG);
@@ -155,7 +164,10 @@
config.upsertAccessSection(
AccessSection.HEADS,
- heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
+ heads -> {
+ initDefaultAclsForAnonymousUsers(heads, config);
+ initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
+ });
config.upsertAccessSection(
AccessSection.GLOBAL_CAPABILITIES,
@@ -163,42 +175,64 @@
input
.serviceUsersGroup()
.ifPresent(
- batchUsersGroup ->
- initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup)));
+ serviceUsersGroup ->
+ initDefaultAclsForServiceUsers(capabilities, config, serviceUsersGroup)));
+
+ input
+ .blockedUsersGroup()
+ .ifPresent(blockedUsersGrouo -> initDefaultAclsForBlockedUsers(config, blockedUsersGrouo));
input
.administratorsGroup()
.ifPresent(adminsGroup -> initDefaultAclsForAdmins(config, codeReviewLabel, adminsGroup));
}
- private void initDefaultAclsForRegisteredUsers(
- AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
- config.upsertAccessSection(
- "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
+ private void initDefaultSubmitRequirements(ProjectConfig config) {
+ config.upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName("No-Unresolved-Comments")
+ .setDescription(
+ Optional.of("Changes that have unresolved comments are not submittable."))
+ .setApplicabilityExpression(SubmitRequirementExpression.of("has:unresolved"))
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("-has:unresolved"))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ }
+
+ private void initDefaultAclsForAnonymousUsers(AccessSection.Builder heads, ProjectConfig config) {
+ grant(config, heads, Permission.READ, anonymous);
config.upsertAccessSection(
"refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
+ }
+ private void initDefaultAclsForRegisteredUsers(
+ AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
grant(config, heads, codeReviewLabel, -1, 1, registered);
grant(config, heads, Permission.FORGE_AUTHOR, registered);
- grant(config, heads, Permission.READ, anonymous);
- grant(config, heads, Permission.REVERT, registered);
config.upsertAccessSection(
- "refs/for/" + AccessSection.ALL,
- magic -> {
- grant(config, magic, Permission.PUSH, registered);
- grant(config, magic, Permission.PUSH_MERGE, registered);
+ "refs/for/*",
+ refsFor -> {
+ grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
+ grant(config, refsFor, Permission.PUSH, registered);
+ grant(config, refsFor, Permission.PUSH_MERGE, registered);
});
}
- private void initDefaultAclsForBatchUsers(
- AccessSection.Builder capabilities, ProjectConfig config, GroupReference batchUsersGroup) {
+ private void initDefaultAclsForServiceUsers(
+ AccessSection.Builder capabilities, ProjectConfig config, GroupReference serviceUsersGroup) {
Permission.Builder priority = capabilities.upsertPermission(GlobalCapability.PRIORITY);
- priority.add(rule(config, batchUsersGroup).setAction(Action.BATCH));
+ priority.add(rule(config, serviceUsersGroup).setAction(Action.BATCH));
Permission.Builder stream = capabilities.upsertPermission(GlobalCapability.STREAM_EVENTS);
- stream.add(rule(config, batchUsersGroup));
+ stream.add(rule(config, serviceUsersGroup));
+ }
+
+ private void initDefaultAclsForBlockedUsers(
+ ProjectConfig config, GroupReference blockedUsersGroup) {
+ config.upsertAccessSection(
+ AccessSection.ALL, all -> block(config, all, Permission.READ, blockedUsersGroup));
}
private void initDefaultAclsForAdmins(
@@ -216,10 +250,10 @@
heads -> {
grant(config, heads, codeReviewLabel, -2, 2, adminsGroup, owners);
grant(config, heads, Permission.CREATE, adminsGroup, owners);
- grant(config, heads, Permission.PUSH, adminsGroup, owners);
grant(config, heads, Permission.SUBMIT, adminsGroup, owners);
grant(config, heads, Permission.FORGE_COMMITTER, adminsGroup, owners);
grant(config, heads, Permission.EDIT_TOPIC_NAME, true, adminsGroup, owners);
+ grant(config, heads, Permission.REVERT, adminsGroup, owners);
});
config.upsertAccessSection(
@@ -237,7 +271,6 @@
grant(config, meta, Permission.READ, adminsGroup, owners);
grant(config, meta, codeReviewLabel, -2, 2, adminsGroup, owners);
grant(config, meta, Permission.CREATE, adminsGroup, owners);
- grant(config, meta, Permission.PUSH, adminsGroup, owners);
grant(config, meta, Permission.SUBMIT, adminsGroup, owners);
});
}
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index 8db5b1a..f692691 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -69,6 +69,9 @@
/** The group which gets stream-events permission granted and appropriate properties set. */
public abstract Optional<GroupReference> serviceUsersGroup();
+ /** The group for which read access gets blocked. */
+ public abstract Optional<GroupReference> blockedUsersGroup();
+
/** The commit message used when commit the project config change. */
public abstract Optional<String> commitMessage();
@@ -89,6 +92,9 @@
/** Whether initializing default access sections in All-Projects. */
public abstract boolean initDefaultAcls();
+ /** Whether default submit requirements should be initialized in All-Projects. */
+ public abstract boolean initDefaultSubmitRequirements();
+
public abstract Builder toBuilder();
public static Builder builder() {
@@ -96,7 +102,8 @@
new AutoValue_AllProjectsInput.Builder()
.codeReviewLabel(getDefaultCodeReviewLabel())
.firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
- .initDefaultAcls(true);
+ .initDefaultAcls(true)
+ .initDefaultSubmitRequirements(true);
DEFAULT_BOOLEAN_PROJECT_CONFIGS.forEach(builder::addBooleanProjectConfig);
return builder;
@@ -110,7 +117,9 @@
public abstract static class Builder {
public abstract Builder administratorsGroup(GroupReference adminGroup);
- public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
+ public abstract Builder serviceUsersGroup(GroupReference serviceUsersGroup);
+
+ public abstract Builder blockedUsersGroup(GroupReference blockedUsersGroup);
public abstract Builder commitMessage(String commitMessage);
@@ -135,6 +144,8 @@
@UsedAt(UsedAt.Project.GOOGLE)
public abstract Builder initDefaultAcls(boolean initDefaultACLs);
+ public abstract Builder initDefaultSubmitRequirements(boolean initDefaultSubmitRequirements);
+
public abstract AllProjectsInput build();
}
}
diff --git a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
index d993c4a..9dca2d9 100644
--- a/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/CloudSpannerAccountPatchReviewStore.java
@@ -28,9 +28,7 @@
@Singleton
public class CloudSpannerAccountPatchReviewStore extends JdbcAccountPatchReviewStore {
- private static final int ERR_DUP_KEY = 1022;
- private static final int ERR_DUP_ENTRY = 1062;
- private static final int ERR_DUP_UNIQUE = 1169;
+ private static final int ERR_DUP_KEY = 6;
@Inject
CloudSpannerAccountPatchReviewStore(
@@ -44,8 +42,6 @@
public StorageException convertError(String op, SQLException err) {
switch (err.getErrorCode()) {
case ERR_DUP_KEY:
- case ERR_DUP_ENTRY:
- case ERR_DUP_UNIQUE:
return new DuplicateKeyException("ACCOUNT_PATCH_REVIEWS", err);
default:
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 56c6fa8..fcd2264 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -54,6 +54,8 @@
// confusing and could stand to be reworked. Another smell is that this is an interface only for
// testing purposes.
public class SchemaCreatorImpl implements SchemaCreator {
+ public static final String BLOCKED_USERS = "Blocked Users";
+
private final GitRepositoryManager repoManager;
private final AllProjectsCreator allProjectsCreator;
private final AllUsersCreator allUsersCreator;
@@ -92,11 +94,13 @@
try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
GroupReference admins = createGroupReference("Administrators");
GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference blockedUsers = createGroupReference(BLOCKED_USERS);
AllProjectsInput allProjectsInput =
AllProjectsInput.builder()
.administratorsGroup(admins)
.serviceUsersGroup(serviceUsers)
+ .blockedUsersGroup(blockedUsers)
.build();
allProjectsCreator.create(allProjectsInput);
// We have to create the All-Users repository before we can use it to store the groups in it.
@@ -104,7 +108,8 @@
try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
createAdminsGroup(allUsersRepo, admins);
- createBatchUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+ createServiceUsersGroup(allUsersRepo, serviceUsers, admins.getUUID());
+ createBlockedUsersGroup(allUsersRepo, blockedUsers, admins.getUUID());
}
}
}
@@ -127,7 +132,7 @@
createGroup(allUsersRepo, groupCreation, groupDelta);
}
- private void createBatchUsersGroup(
+ private void createServiceUsersGroup(
Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
throws IOException, ConfigInvalidException {
InternalGroupCreation groupCreation = getGroupCreation(groupReference);
@@ -135,6 +140,20 @@
GroupDelta.builder()
.setDescription("Users who perform batch actions on Gerrit")
.setOwnerGroupUUID(adminsGroupUuid)
+ .setVisibleToAll(true)
+ .build();
+
+ createGroup(allUsersRepo, groupCreation, groupDelta);
+ }
+
+ private void createBlockedUsersGroup(
+ Repository allUsersRepo, GroupReference groupReference, AccountGroup.UUID adminsGroupUuid)
+ throws IOException, ConfigInvalidException {
+ InternalGroupCreation groupCreation = getGroupCreation(groupReference);
+ GroupDelta groupDelta =
+ GroupDelta.builder()
+ .setDescription("Blocked users. Add spammers to this group.")
+ .setOwnerGroupUUID(adminsGroupUuid)
.build();
createGroup(allUsersRepo, groupCreation, groupDelta);
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 7243bdf..650c425 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -54,14 +54,15 @@
ImmutableList.of(
"[access \"refs/*\"]",
" read = group Administrators",
+ " read = block group Blocked Users",
"[access \"refs/for/*\"]",
" addPatchSet = group Registered Users",
- "[access \"refs/for/refs/*\"]",
" push = group Registered Users",
" pushMerge = group Registered Users",
"[access \"refs/heads/*\"]",
" read = group Anonymous Users",
- " revert = group Registered Users",
+ " revert = group Administrators",
+ " revert = group Project Owners",
" create = group Administrators",
" create = group Project Owners",
" editTopicName = +force group Administrators",
@@ -72,8 +73,6 @@
" label-Code-Review = -2..+2 group Administrators",
" label-Code-Review = -2..+2 group Project Owners",
" label-Code-Review = -1..+1 group Registered Users",
- " push = group Administrators",
- " push = group Project Owners",
" submit = group Administrators",
" submit = group Project Owners",
"[access \"refs/meta/config\"]",
@@ -82,8 +81,6 @@
" create = group Project Owners",
" label-Code-Review = -2..+2 group Administrators",
" label-Code-Review = -2..+2 group Project Owners",
- " push = group Administrators",
- " push = group Project Owners",
" read = group Administrators",
" read = group Project Owners",
" submit = group Administrators",
@@ -108,6 +105,13 @@
" value = 0 No score",
" value = +1 Looks good to me, but someone else must approve",
" value = +2 Looks good to me, approved");
+ private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION =
+ ImmutableList.of(
+ "[submit-requirement \"No-Unresolved-Comments\"]",
+ " description = Changes that have unresolved comments are not submittable.",
+ " applicableIf = has:unresolved",
+ " submittableIf = -has:unresolved",
+ " canOverrideInChildProjects = false");
public static String getDefaultAllProjectsWithAllDefaultSections() {
return Streams.stream(
@@ -117,7 +121,8 @@
DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
- DEFAULT_ALL_PROJECTS_LABEL_SECTION))
+ DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+ DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
.collect(Collectors.joining("\n"));
}
@@ -127,6 +132,19 @@
DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+ DEFAULT_ALL_PROJECTS_LABEL_SECTION,
+ DEFAULT_ALL_PROJECTS_SUBMIT_REQUIREMENT_SECTION))
+ .collect(Collectors.joining("\n"));
+ }
+
+ public static String getAllProjectsWithoutDefaultSubmitRequirements() {
+ return Streams.stream(
+ Iterables.concat(
+ DEFAULT_ALL_PROJECTS_PROJECT_SECTION,
+ DEFAULT_ALL_PROJECTS_RECEIVE_SECTION,
+ DEFAULT_ALL_PROJECTS_SUBMIT_SECTION,
+ DEFAULT_ALL_PROJECTS_CAPABILITY_SECTION,
+ DEFAULT_ALL_PROJECTS_ACCESS_SECTION,
DEFAULT_ALL_PROJECTS_LABEL_SECTION))
.collect(Collectors.joining("\n"));
}
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
index b56d9ef..ed07f2f 100644
--- a/java/com/google/gerrit/server/submit/MergeMetrics.java
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -54,32 +54,36 @@
.setRate());
}
- public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
- if (isRebaseOnBehalfOfUploader(cd)
- && hasCodeReviewApprovalOfRealUploader(cd)
- && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
- && ignoresCodeReviewApprovalsOfUploader(cd)) {
- // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
- // The uploader of the patch set is the original uploader on whom's behalf the rebase was
- // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
- // clicking on the rebase button).
- //
- // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
- //
- // 3. The change doesn't have a Code-Review approval of any other user (a user that is not the
- // real uploader).
- //
- // 4. Code-Review approvals of the uploader are ignored.
- //
- // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
- // rebaser would have been the uploader of the patch set. In this case the Code-Review
- // approval of the rebaser would not have counted since Code-Review approvals of the uploader
- // are ignored.
- //
- // In this case we assume that the change would not be submittable if a normal rebase had been
- // done. This is not always correct (e.g. if there are approvals of multiple reviewers) but
- // it's good enough for the metric.
- countChangesThatWereSubmittedWithRebaserApproval.increment();
+ public void countChangesThatWereSubmittedWithRebaserApproval(ChangeSet cs) {
+ for (ChangeData cd : cs.changes()) {
+ if (isRebaseOnBehalfOfUploader(cd)
+ && hasCodeReviewApprovalOfRealUploader(cd)
+ && !hasCodeReviewApprovalOfUserThatIsNotTheRealUploader(cd)
+ && ignoresCodeReviewApprovalsOfUploader(cd)) {
+ // 1. The patch set that is being submitted was created by rebasing on behalf of the
+ // uploader.
+ //
+ // The uploader of the patch set is the original uploader on whose behalf the rebase was
+ // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g.
+ // by clicking on the rebase button).
+ //
+ // 2. The change has a Code-Review approval of the real uploader (aka the rebaser).
+ //
+ // 3. The change doesn't have a Code-Review approval of any other user (a user that is not
+ // the real uploader).
+ //
+ // 4. Code-Review approvals of the uploader are ignored.
+ //
+ // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
+ // rebaser would have been the uploader of the patch set. In this case the Code-Review
+ // approval of the rebaser would not have counted since Code-Review approvals of the
+ // uploader are ignored.
+ //
+ // In this case we assume that the change would not be submittable if a normal rebase had
+ // been done. This is not always correct (e.g. if there are approvals of multiple reviewers)
+ // but it's good enough for the metric.
+ countChangesThatWereSubmittedWithRebaserApproval.increment();
+ }
}
}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index eb41690..233f00e 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -69,6 +69,7 @@
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.InternalUser;
import com.google.gerrit.server.change.NotifyResolver;
@@ -83,6 +84,8 @@
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
+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.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
@@ -112,6 +115,7 @@
import java.util.ArrayList;
import java.util.Collection;
import java.util.Deque;
+import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
@@ -292,6 +296,7 @@
private final ChangeData.Factory changeDataFactory;
private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
private final MergeMetrics mergeMetrics;
+ private final PermissionBackend permissionBackend;
// Changes that were updated by this MergeOp.
private final Map<Change.Id, Change> updatedChanges;
@@ -336,7 +341,8 @@
MergeMetrics mergeMetrics,
ProjectCache projectCache,
ExperimentFeatures experimentFeatures,
- @GerritServerConfig Config config) {
+ @GerritServerConfig Config config,
+ PermissionBackend permissionBackend) {
this.cmUtil = cmUtil;
this.batchUpdateFactory = batchUpdateFactory;
this.batchUpdates = batchUpdates;
@@ -362,6 +368,7 @@
hasImplicitMergeTimeoutSeconds =
ConfigUtil.getTimeUnit(
config, "change", null, "implicitMergeCalculationTimeout", 60, TimeUnit.SECONDS);
+ this.permissionBackend = permissionBackend;
}
@Override
@@ -371,6 +378,12 @@
}
}
+ /**
+ * Check that SRs are fulfilled or throw otherwise
+ *
+ * @param cd change that is being checked
+ * @throws ResourceConflictException the exception that is thrown if the SR is not fulfilled
+ */
public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException {
PatchSet patchSet = cd.currentPatchSet();
if (patchSet == null) {
@@ -425,32 +438,119 @@
return cd.submitRecords(submitRuleOptions(/* allowClosed= */ false));
}
- private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
- throws ResourceConflictException {
- checkArgument(
- !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
+ @AutoValue
+ public abstract static class ChangeProblem {
+ public abstract Change.Id getChangeId();
+
+ public abstract String getProblem();
+
+ public static ChangeProblem create(Change.Id changeId, String problem) {
+ return new AutoValue_MergeOp_ChangeProblem(changeId, problem);
+ }
+ }
+
+ /**
+ * Returns a list of messages describing what prevents the current change from being submitted.
+ *
+ * <p>The method checks all changes in the {@code cs} for their current status, submitability and
+ * permissions.
+ *
+ * @param triggeringChange Change for which merge/submit action was initiated
+ * @param cs Set of changes that the current change depends on
+ * @param allowMerged True if change being already merged is not a problem to be reported
+ * @param permissionBackend Interface for checking user ACLs
+ * @param caller The user who is triggering a merge
+ * @return List of problems preventing merge
+ */
+ public static ImmutableList<ChangeProblem> checkCommonSubmitProblems(
+ Change triggeringChange,
+ ChangeSet cs,
+ boolean allowMerged,
+ PermissionBackend permissionBackend,
+ CurrentUser caller) {
+ ImmutableList.Builder<ChangeProblem> problems = ImmutableList.builder();
+ if (cs.furtherHiddenChanges()) {
+ logger.atFine().log(
+ "Change %d cannot be submitted by user %s because it depends on hidden changes: %s",
+ triggeringChange.getId().get(), caller.getLoggableName(), cs.nonVisibleChanges());
+ problems.add(
+ ChangeProblem.create(
+ triggeringChange.getId(),
+ String.format(
+ "Change %d depends on other hidden changes", triggeringChange.getId().get())));
+ }
for (ChangeData cd : cs.changes()) {
try {
- if (!cd.change().isNew()) {
+ Set<ChangePermission> can =
+ permissionBackend
+ .user(caller)
+ .change(cd)
+ .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
+ if (!can.contains(ChangePermission.READ)) {
+ // The READ permission should already be handled during generation of ChangeSet, however
+ // MergeSuperSetComputation is an interface and on API level doesn't guarantee that this
+ // have been verified for all changes. Additionally, this protects against potential
+ // issues due to staleness.
+ logger.atFine().log(
+ "Change %d cannot be submitted by user %s because it depends on change %d which the"
+ + "user cannot read",
+ triggeringChange.getId().get(), caller.getLoggableName(), cd.getId().get());
+ problems.add(
+ ChangeProblem.create(
+ cd.getId(),
+ String.format(
+ "Change %d depends on other hidden changes",
+ triggeringChange.getId().get())));
+ } else if (!can.contains(ChangePermission.SUBMIT)) {
+ logger.atFine().log(
+ "Change %d cannot be submitted by user %s because it depends on change %d which the"
+ + "user cannot submit",
+ triggeringChange.getId().get(), caller.getLoggableName(), cd.getId().get());
+ problems.add(
+ ChangeProblem.create(
+ cd.getId(),
+ String.format("Insufficient permission to submit change %d", cd.getId().get())));
+ } else if (!cd.change().isNew()) {
if (!(cd.change().isMerged() && allowMerged)) {
- commitStatus.problem(
- cd.getId(), "Change " + cd.getId() + " is " + ChangeUtil.status(cd.change()));
+ problems.add(
+ ChangeProblem.create(
+ cd.getId(),
+ String.format(
+ "Change %d is %s", cd.getId().get(), ChangeUtil.status(cd.change()))));
}
} else if (cd.change().isWorkInProgress()) {
- commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
+ problems.add(
+ ChangeProblem.create(
+ cd.getId(),
+ String.format("Change %d is marked work in progress", cd.getId().get())));
} else {
checkSubmitRequirements(cd);
- mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cd);
}
} catch (ResourceConflictException e) {
- commitStatus.problem(cd.getId(), e.getMessage());
- } catch (StorageException e) {
+ // Exception is thrown means submit requirement is not fulfilled.
+ problems.add(
+ ChangeProblem.create(
+ cd.getId(),
+ triggeringChange.getId().equals(cd.getId())
+ ? String.format("Change %s is not ready: %s", cd.getId(), e.getMessage())
+ : String.format(
+ "Change %s must be submitted with change %s but %s is not ready: %s",
+ triggeringChange.getId(), cd.getId(), cd.getId(), e.getMessage())));
+ } catch (StorageException | PermissionBackendException e) {
String msg = "Error checking submit rules for change";
- logger.atWarning().withCause(e).log("%s %s", msg, cd.getId());
- commitStatus.problem(cd.getId(), msg);
+ logger.atWarning().withCause(e).log("%s %s", msg, triggeringChange.getId());
+ problems.add(ChangeProblem.create(cd.getId(), msg));
}
}
+ return problems.build();
+ }
+
+ private void checkSubmitRulesAndState(Change triggeringChange, ChangeSet cs, boolean allowMerged)
+ throws ResourceConflictException {
+ checkCommonSubmitProblems(triggeringChange, cs, allowMerged, permissionBackend, caller).stream()
+ .forEach(cp -> commitStatus.problem(cp.getChangeId(), cp.getProblem()));
commitStatus.maybeFailVerbose();
+ mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cs);
}
private void bypassSubmitRulesAndRequirements(ChangeSet cs) {
@@ -580,7 +680,7 @@
this.commitStatus = new CommitStatus(filteredNoteDbChangeSet, isRetry);
if (checkSubmitRules) {
logger.atFine().log("Checking submit rules and state");
- checkSubmitRulesAndState(filteredNoteDbChangeSet, isRetry);
+ checkSubmitRulesAndState(change, filteredNoteDbChangeSet, isRetry);
} else {
logger.atFine().log("Bypassing submit rules");
bypassSubmitRulesAndRequirements(filteredNoteDbChangeSet);
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 712715e..8bdf6fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -22,6 +22,7 @@
import com.google.gerrit.extensions.common.ProjectInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -129,7 +130,10 @@
err.append("error: insuffient access rights to change parent of '")
.append(name)
.append("'\n");
- } catch (ResourceConflictException | ResourceNotFoundException | BadRequestException e) {
+ } catch (ResourceConflictException
+ | ResourceNotFoundException
+ | BadRequestException
+ | MethodNotAllowedException e) {
err.append("error: ").append(e.getMessage()).append("'\n");
} catch (UnprocessableEntityException | IOException e) {
throw new Failure(1, "failure in request", e);
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 00361ad..14915bf 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -134,6 +134,7 @@
switch (task.state) {
case DONE:
case CANCELLED:
+ case PARKED:
case STARTING:
case RUNNING:
case STOPPING:
@@ -212,6 +213,8 @@
return "";
case STARTING:
return "starting ...";
+ case PARKED:
+ return "parked .....";
case READY:
return "waiting ....";
case SLEEPING:
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index 3101c48..67134de 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -244,7 +244,7 @@
try {
result.add(
new ConfigRunner(
- clazz, parameterField, nameField, null, callConfigMethod(defaultConfig)));
+ clazz, parameterField, nameField, DEFAULT, callConfigMethod(defaultConfig)));
for (Method m : configs) {
result.add(
new ConfigRunner(clazz, parameterField, nameField, m.getName(), callConfigMethod(m)));
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index b0045e3..8e065b9 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -57,6 +57,7 @@
import com.google.gerrit.server.audit.AuditModule;
import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
import com.google.gerrit.server.change.FileInfoJsonModule;
import com.google.gerrit.server.config.AllProjectsConfigProvider;
import com.google.gerrit.server.config.AllProjectsName;
@@ -209,6 +210,7 @@
install(new RepoSequenceModule());
install(new NoteDbDraftCommentsModule());
install(new NoteDbStarredChangesModule());
+ install(new ChangeCleanupRunnerModule());
AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
install(new AuthModule(authConfig));
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index fdf7457..b720a0dc 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -2765,10 +2765,10 @@
int initialCommits = countExternalIdsCommits();
AccountsUpdate.UpdateArguments ua1 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+ "Add External ID", admin.id(), u -> u.addExternalId(extId1));
AccountsUpdate.UpdateArguments ua2 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+ "Add External ID", user.id(), u -> u.addExternalId(extId2));
ImmutableList<Optional<AccountState>> accountStates =
accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
assertThat(accountStates).hasSize(2);
@@ -2811,11 +2811,11 @@
AccountsUpdate.UpdateArguments ua1 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+ "Add External ID", admin.id(), u -> u.addExternalId(extId1));
// Another update for the same account is not allowed.
AccountsUpdate.UpdateArguments ua2 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId2));
+ "Add External ID", admin.id(), u -> u.addExternalId(extId2));
IllegalArgumentException e =
assertThrows(
IllegalArgumentException.class,
@@ -2834,10 +2834,10 @@
AccountsUpdate.UpdateArguments ua1 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", admin.id(), (a, u) -> u.addExternalId(extIdAdmin));
+ "Add External ID", admin.id(), u -> u.addExternalId(extIdAdmin));
AccountsUpdate.UpdateArguments ua2 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", user.id(), (a, u) -> u.addExternalId(extIdUser));
+ "Add External ID", user.id(), u -> u.addExternalId(extIdUser));
DuplicateExternalIdKeyException e =
assertThrows(
DuplicateExternalIdKeyException.class,
@@ -2856,10 +2856,10 @@
AccountsUpdate.UpdateArguments ua1 =
new AccountsUpdate.UpdateArguments(
- "first message", admin.id(), (a, u) -> u.addExternalId(extId1));
+ "first message", admin.id(), u -> u.addExternalId(extId1));
AccountsUpdate.UpdateArguments ua2 =
new AccountsUpdate.UpdateArguments(
- "second message", user.id(), (a, u) -> u.addExternalId(extId2));
+ "second message", user.id(), u -> u.addExternalId(extId2));
accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
try (Repository allUsersRepo = repoManager.openRepository(allUsers);
@@ -2877,7 +2877,7 @@
getExternalIdFactory()
.createWithEmail(externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
- accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+ accountsUpdateProvider.get().update("foobar", admin.id(), u -> u.addExternalId(extId));
try (Repository allUsersRepo = repoManager.openRepository(allUsers);
RevWalk rw = new RevWalk(allUsersRepo)) {
@@ -2997,7 +2997,7 @@
ExternalId externalId = getExternalIdFactory().create("custom", "value", admin.id());
accountsUpdateProvider
.get()
- .update("Add External ID", admin.id(), (a, u) -> u.addExternalId(externalId));
+ .update("Add External ID", admin.id(), u -> u.addExternalId(externalId));
assertExternalIds(
admin.id(), ImmutableSet.of("mailto:admin@example.com", "username:admin", "custom:value"));
@@ -3013,7 +3013,7 @@
ExternalId externalId = createEmailExternalId(admin.id(), "admin@example.com");
accountsUpdateProvider
.get()
- .update("Remove External ID", admin.id(), (a, u) -> u.deleteExternalId(externalId));
+ .update("Remove External ID", admin.id(), u -> u.deleteExternalId(externalId));
assertExternalIds(admin.id(), ImmutableSet.of("username:admin"));
AccountState updatedState = accountCache.get(admin.id()).get();
@@ -3031,7 +3031,7 @@
SCHEME_MAILTO, "secondary@non.google", admin.id(), "secondary@non.google");
accountsUpdateProvider
.get()
- .update("Update External ID", admin.id(), (a, u) -> u.updateExternalId(externalId));
+ .update("Update External ID", admin.id(), u -> u.updateExternalId(externalId));
assertExternalIds(
admin.id(),
ImmutableSet.of(
@@ -3055,11 +3055,7 @@
accountsUpdateProvider
.get()
.update(
- "Replace External ID",
- admin.id(),
- (a, u) -> {
- u.replaceExternalId(oldExternalId, externalId);
- });
+ "Replace External ID", admin.id(), u -> u.replaceExternalId(oldExternalId, externalId));
assertExternalIds(admin.id(), ImmutableSet.of("mailto:secondary@non.google", "username:admin"));
AccountState updatedState = accountCache.get(admin.id()).get();
@@ -3089,10 +3085,10 @@
AccountsUpdate.UpdateArguments ua1 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+ "Add External ID", admin.id(), u -> u.addExternalId(extId1));
AccountsUpdate.UpdateArguments ua2 =
new AccountsUpdate.UpdateArguments(
- "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+ "Add External ID", user.id(), u -> u.addExternalId(extId2));
AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
try (Registration registration =
extensionRegistry.newRegistration().add(accountIndexedCounter)) {
@@ -3129,12 +3125,12 @@
requestScopeOperations.setApiUser(admin.id());
AccountsUpdate.UpdateArguments ua1 =
new AccountsUpdate.UpdateArguments(
- "Update Display Name", admin.id(), (a, u) -> u.setDisplayName("DN"));
+ "Update Display Name", admin.id(), u -> u.setDisplayName("DN"));
AccountsUpdate.UpdateArguments ua2 =
new AccountsUpdate.UpdateArguments(
"Remove external Id",
user.id(),
- (a, u) -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
+ u -> u.deleteExternalId(createEmailExternalId(user.id(), user.email())));
AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter();
try (Registration registration =
extensionRegistry.newRegistration().add(accountIndexedCounter)) {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index ead4c40..f462614 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -16,6 +16,8 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+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 static java.util.Comparator.comparing;
@@ -26,6 +28,7 @@
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.config.GerritConfig;
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.RawInputUtil;
import com.google.gerrit.entities.AccountGroup;
@@ -33,6 +36,7 @@
import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -63,6 +67,7 @@
private ContributorAgreement caAutoVerify;
private ContributorAgreement caNoAutoVerify;
@Inject private GroupOperations groupOperations;
+ @Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
protected void setUseContributorAgreements(InheritableBoolean value) throws Exception {
@@ -298,6 +303,11 @@
gApi.changes().id(change.changeId).current().submit(new SubmitInput());
// Revert in excluded project is allowed even when CLA is required but not signed
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
requestScopeOperations.setApiUser(user.id());
setUseContributorAgreements(InheritableBoolean.TRUE);
gApi.changes().id(change.changeId).revert();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index 6802333..74829a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.api.accounts;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
@@ -60,7 +61,7 @@
EditPreferencesInfo info = gApi.accounts().id(admin.id().get()).setEditPreferences(out);
- assertEditPreferences(info, out);
+ assertPrefs(info, out);
// Partially filled input record
EditPreferencesInfo in = new EditPreferencesInfo();
@@ -69,24 +70,6 @@
info = gApi.accounts().id(admin.id().get()).setEditPreferences(in);
out.tabSize = in.tabSize;
- assertEditPreferences(info, out);
- }
-
- private void assertEditPreferences(EditPreferencesInfo out, EditPreferencesInfo in)
- throws Exception {
- assertThat(out.lineLength).isEqualTo(in.lineLength);
- assertThat(out.indentUnit).isEqualTo(in.indentUnit);
- assertThat(out.tabSize).isEqualTo(in.tabSize);
- assertThat(out.cursorBlinkRate).isEqualTo(in.cursorBlinkRate);
- assertThat(out.hideTopMenu).isEqualTo(in.hideTopMenu);
- assertThat(out.showTabs).isNull();
- assertThat(out.showWhitespaceErrors).isEqualTo(in.showWhitespaceErrors);
- assertThat(out.syntaxHighlighting).isNull();
- assertThat(out.hideLineNumbers).isEqualTo(in.hideLineNumbers);
- assertThat(out.matchBrackets).isNull();
- assertThat(out.lineWrapping).isEqualTo(in.lineWrapping);
- assertThat(out.indentWithTabs).isEqualTo(in.indentWithTabs);
- assertThat(out.autoCloseBrackets).isEqualTo(in.autoCloseBrackets);
- assertThat(out.showBase).isEqualTo(in.showBase);
+ assertPrefs(info, out);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 0b28f6f..afa9bca 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -85,6 +85,7 @@
i.signedOffBy ^= true;
i.allowBrowserNotifications ^= false;
i.allowSuggestCodeWhileCommenting ^= false;
+ i.allowAutocompletingComments ^= false;
i.diffPageSidebar = "plugin-insight";
i.diffView = DiffView.UNIFIED_DIFF;
i.my = new ArrayList<>();
@@ -99,6 +100,7 @@
assertThat(o.theme).isEqualTo(i.theme);
assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications);
assertThat(o.allowSuggestCodeWhileCommenting).isEqualTo(i.allowSuggestCodeWhileCommenting);
+ assertThat(o.allowAutocompletingComments).isEqualTo(i.allowAutocompletingComments);
assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar);
assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index f0f262f..18969ad 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -40,7 +40,7 @@
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.AbandonUtil;
+import com.google.gerrit.server.change.ChangeCleanupRunner;
import com.google.gerrit.server.config.ChangeCleanupConfig;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.testing.TestTimeUtil;
@@ -53,7 +53,7 @@
import org.junit.Test;
public class AbandonIT extends AbstractDaemonTest {
- @Inject private AbandonUtil abandonUtil;
+ @Inject private ChangeCleanupRunner.Factory cleanupRunner;
@Inject private ChangeCleanupConfig cleanupConfig;
@Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@@ -140,7 +140,7 @@
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id1, id2, id3);
assertThat(query("is:abandoned")).isEmpty();
- abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+ cleanupRunner.create().run();
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id3);
assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id1, id2);
}
@@ -182,7 +182,7 @@
assertThat(toChangeNumbers(query("is:merged"))).containsExactly(id3);
assertThat(toChangeNumbers(query("-is:mergeable"))).containsExactly(id4);
- abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+ cleanupRunner.create().run();
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5, id2, id1);
assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4);
}
@@ -229,7 +229,7 @@
assertThrows(BadRequestException.class, () -> query("-is:mergeable"));
assertThat(thrown).hasMessageThat().contains("operator is not supported");
- abandonUtil.abandonInactiveOpenChanges(batchUpdateFactory);
+ cleanupRunner.create().run();
assertThat(toChangeNumbers(query("is:open"))).containsExactly(id5);
assertThat(toChangeNumbers(query("is:abandoned"))).containsExactly(id4, id2, id1);
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
index 78361a1..eff783e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -80,6 +80,18 @@
+ "+Second added line\n"
+ "\\ No newline at end of file\n";
+ private static final String CONFLICTING_FILE_NAME = "conflicting_file.txt";
+ private static final String CONFLICTING_FILE_ORIGINAL_CONTENT =
+ "First original line\nSecond original line";
+ private static final String CONFLICTING_FILE_DIFF =
+ "diff --git a/conflicting_file.txt b/conflicting_file.txt\n"
+ + "--- a/conflicting_file.txt\n"
+ + "+++ b/conflicting_file.txt\n"
+ + "@@ -1,2 +1 @@\n"
+ + "-First original line\n"
+ + "-Third original line\n"
+ + "+Modified line\n";
+
@Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ChangeOperations changeOperations;
@@ -353,6 +365,36 @@
}
@Test
+ public void applyPatchWithConflict_createsConflictMarkers() throws Exception {
+ initBaseWithFile(CONFLICTING_FILE_NAME, CONFLICTING_FILE_ORIGINAL_CONTENT);
+ String patch = CONFLICTING_FILE_DIFF;
+ ApplyPatchPatchSetInput in = buildInput(patch);
+ in.commitMessage = "subject";
+ in.patch.allowConflicts = true;
+
+ ChangeInfo result = applyPatch(in);
+ assertThat(
+ gApi.changes()
+ .id(result.id)
+ .current()
+ .file("conflicting_file.txt")
+ .content()
+ .asString())
+ .isEqualTo(
+ "<<<<<<< HEAD\n"
+ + "First original line\n"
+ + "Second original line\n"
+ + "=======\n"
+ + "Modified line\n"
+ + ">>>>>>> PATCH");
+ assertThat(gApi.changes().id(result.id).current().commit(false).message)
+ .contains(
+ "ATTENTION: Conflicts occurred while applying the patch.\n"
+ + "Please resolve conflict markers.");
+ assertThat(result.containsGitConflicts).isTrue();
+ }
+
+ @Test
public void applyPatchWithConflict_appendErrorsToCommitMessageWithLargeOriginalPatch()
throws Exception {
initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 4758254..f92702e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -111,7 +111,6 @@
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -170,8 +169,11 @@
import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeMessages;
import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.ChangeMessageModifier;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.index.change.ChangeIndex;
import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -1539,7 +1541,6 @@
private void testAddReviewerViaPostReview(AddReviewerCaller addReviewer) throws Exception {
PushOneCommit.Result r = createChange();
ChangeResource rsrc = parseResource(r);
- String oldETag = rsrc.getETag();
Instant oldTs = rsrc.getChange().getLastUpdatedOn();
addReviewer.call(r.getChangeId(), user.email());
@@ -1563,16 +1564,9 @@
// Nobody was added as CC.
assertThat(c.reviewers.get(CC)).isNull();
- // Ensure ETag and lastUpdatedOn are updated.
+ // Ensure lastUpdatedOn is updated.
rsrc = parseResource(r);
- assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
-
- // Change status of reviewer and ensure ETag is updated.
- oldETag = rsrc.getETag();
- accountOperations.account(user.id()).forUpdate().status("new status").update();
- rsrc = parseResource(r);
- assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
}
@Test
@@ -1655,7 +1649,6 @@
public void addReviewerThatIsNotPerfectMatch() throws Exception {
PushOneCommit.Result r = createChange();
ChangeResource rsrc = parseResource(r);
- String oldETag = rsrc.getETag();
Instant oldTs = rsrc.getChange().getLastUpdatedOn();
// create a group named "ab" with one user: testUser
@@ -1693,9 +1686,8 @@
assertThat(reviewers).hasSize(1);
assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfTestUser.get());
- // Ensure ETag and lastUpdatedOn are updated.
+ // Ensure lastUpdatedOn is updated.
rsrc = parseResource(r);
- assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
}
@@ -1704,7 +1696,6 @@
public void addGroupAsReviewersWhenANotPerfectMatchedUserExists() throws Exception {
PushOneCommit.Result r = createChange();
ChangeResource rsrc = parseResource(r);
- String oldETag = rsrc.getETag();
Instant oldTs = rsrc.getChange().getLastUpdatedOn();
// create a group named "kobe" with one user: lee
@@ -1754,9 +1745,8 @@
assertThat(reviewers).hasSize(1);
assertThat(reviewers.iterator().next()._accountId).isEqualTo(accountIdOfGroupUser.get());
- // Ensure ETag and lastUpdatedOn are updated.
+ // Ensure lastUpdatedOn is updated.
rsrc = parseResource(r);
- assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
}
@@ -1805,7 +1795,6 @@
public void addSelfAsReviewer() throws Exception {
PushOneCommit.Result r = createChange();
ChangeResource rsrc = parseResource(r);
- String oldETag = rsrc.getETag();
Instant oldTs = rsrc.getChange().getLastUpdatedOn();
ReviewerInput in = new ReviewerInput();
@@ -1823,9 +1812,8 @@
assertThat(reviewers).hasSize(1);
assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
- // Ensure ETag and lastUpdatedOn are updated.
+ // Ensure lastUpdatedOn is updated.
rsrc = parseResource(r);
- assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
assertThat(rsrc.getChange().getLastUpdatedOn()).isNotEqualTo(oldTs);
}
@@ -1916,56 +1904,6 @@
}
@Test
- public void eTagChangesWhenOwnerUpdatesAccountStatus() throws Exception {
- PushOneCommit.Result r = createChange();
- ChangeResource rsrc = parseResource(r);
- String oldETag = rsrc.getETag();
-
- accountOperations.account(admin.id()).forUpdate().status("new status").update();
- rsrc = parseResource(r);
- assertThat(rsrc.getETag()).isNotEqualTo(oldETag);
- }
-
- @Test
- public void pluginCanContributeToETagComputation() throws Exception {
- PushOneCommit.Result r = createChange();
- String oldETag = parseResource(r).getETag();
-
- try (Registration registration =
- extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag("foo"))) {
- assertThat(parseResource(r).getETag()).isNotEqualTo(oldETag);
- }
-
- assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
- }
-
- @Test
- public void returningNullFromETagComputationDoesNotBreakGerrit() throws Exception {
- PushOneCommit.Result r = createChange();
- String oldETag = parseResource(r).getETag();
-
- try (Registration registration =
- extensionRegistry.newRegistration().add(TestChangeETagComputation.withETag(null))) {
- assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
- }
- }
-
- @Test
- public void throwingExceptionFromETagComputationDoesNotBreakGerrit() throws Exception {
- PushOneCommit.Result r = createChange();
- String oldETag = parseResource(r).getETag();
-
- try (Registration registration =
- extensionRegistry
- .newRegistration()
- .add(
- TestChangeETagComputation.withException(
- new StorageException("exception during test")))) {
- assertThat(parseResource(r).getETag()).isEqualTo(oldETag);
- }
- }
-
- @Test
public void emailNotificationForFileLevelComment() throws Exception {
String changeId = createChange().getChangeId();
@@ -2320,7 +2258,7 @@
}
@Test
- public void removeReviewerSelfFromMergedChangeNotPermitted() throws Exception {
+ public void removeReviewerSelfFromMergedChangeNotPossible() throws Exception {
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -2332,11 +2270,11 @@
gApi.changes().id(changeId).revision(r.getCommit().name()).submit();
requestScopeOperations.setApiUser(user.id());
- AuthException thrown =
+ ResourceConflictException thrown =
assertThrows(
- AuthException.class,
+ ResourceConflictException.class,
() -> gApi.changes().id(r.getChangeId()).reviewer("self").remove());
- assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
}
@Test
@@ -2521,7 +2459,25 @@
}
@Test
- public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+ public void deleteVoteFromMergedChangeNotPossible() throws Exception {
+ PushOneCommit.Result r = createChange();
+ approve(r.getChangeId());
+ gApi.changes().id(r.getChangeId()).current().submit();
+
+ requestScopeOperations.setApiUser(user.id());
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () ->
+ gApi.changes()
+ .id(r.getChangeId())
+ .reviewer(admin.id().toString())
+ .deleteVote(LabelId.CODE_REVIEW));
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+ }
+
+ @Test
+ public void deleteVoteFromOpenChangeAlwaysPermittedForSelfVotes() throws Exception {
projectOperations
.project(project)
.forUpdate()
@@ -2542,7 +2498,7 @@
String changeId = r.getChangeId();
requestScopeOperations.setApiUser(user.id());
- gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+ approve(changeId);
gApi.changes()
.id(r.getChangeId())
@@ -2551,7 +2507,38 @@
}
@Test
- public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+ public void deleteVoteFromClosedChangeNotPossibleForSelfVotes() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS))
+ .add(
+ allowLabel(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ requestScopeOperations.setApiUser(user.id());
+ approve(changeId);
+ gApi.changes().id(changeId).current().submit();
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () ->
+ gApi.changes()
+ .id(r.getChangeId())
+ .reviewer(user.id().toString())
+ .deleteVote(LabelId.CODE_REVIEW));
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+ }
+
+ @Test
+ public void deleteVoteFromOpenChangeAlwaysPermittedForAdmin() throws Exception {
projectOperations
.project(project)
.forUpdate()
@@ -2572,7 +2559,7 @@
String changeId = r.getChangeId();
requestScopeOperations.setApiUser(user.id());
- gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+ approve(changeId);
requestScopeOperations.setApiUser(admin.id());
gApi.changes()
@@ -2582,6 +2569,38 @@
}
@Test
+ public void deleteVoteFromMergedChangeNotPossibleForAdmin() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ requestScopeOperations.setApiUser(user.id());
+ approve(changeId);
+
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes().id(changeId).current().submit();
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () ->
+ gApi.changes()
+ .id(r.getChangeId())
+ .reviewer(user.id().toString())
+ .deleteVote(LabelId.CODE_REVIEW));
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+ }
+
+ @Test
public void nonVotingReviewerStaysAfterSubmit() throws Exception {
LabelType verified =
label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -4204,6 +4223,24 @@
}
@Test
+ public void changeCommitMessageInvokesCommitValidators() throws Exception {
+ PushOneCommit.Result r = createChange();
+ r.assertOkStatus();
+ assertThat(getCommitMessage(r.getChangeId()))
+ .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+ requestScopeOperations.setApiUser(admin.id());
+ String newMessage = "modified commit message\nChange-Id: " + r.getChangeId() + "\n";
+
+ TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+ gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+ assertThat(testCommitValidationListener.receiveEvent).isNotNull();
+ }
+ }
+
+ @Test
public void fourByteEmoji() throws Exception {
// U+1F601 GRINNING FACE WITH SMILING EYES
String smile = new String(Character.toChars(0x1f601));
@@ -4595,6 +4632,11 @@
gApi.changes().id(change.getChangeId()).addReviewer(user.email());
int number = gApi.changes().id(change.getChangeId()).get()._number;
+
+ // Note: Computing the description of some UI actions does access the index. If the index is
+ // disabled computing these descriptions fails. UiActions#describe catches and ignores these
+ // exceptions so that the request is still successful. In this case the description of the UI
+ // action is omitted in the response.
try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
.isEqualTo(change.getChangeId());
@@ -4846,4 +4888,15 @@
private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
}
+
+ private static class TestCommitValidationListener implements CommitValidationListener {
+ public CommitReceivedEvent receiveEvent;
+
+ @Override
+ public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+ throws CommitValidationException {
+ this.receiveEvent = receiveEvent;
+ return ImmutableList.of();
+ }
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
new file mode 100644
index 0000000..9af7a2d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/DefaultSubmitRequirementsIT.java
@@ -0,0 +1,80 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import java.util.Comparator;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class DefaultSubmitRequirementsIT extends AbstractDaemonTest {
+ /**
+ * Tests the "No-Unresolved-Comments" submit requirement that is created during the site
+ * initialization.
+ */
+ @Test
+ public void cannotSubmitChangeWithUnresolvedComment() throws Exception {
+ TestRepository<InMemoryRepository> repo = cloneProject(project);
+ PushOneCommit.Result r =
+ createChange(repo, "master", "Add a file", "foo", "content", /* topic= */ null);
+ String changeId = r.getChangeId();
+ CommentInfo commentInfo =
+ addComment(changeId, "foo", "message", /* unresolved= */ true, /* inReplyTo= */ null);
+ assertThat(commentInfo.unresolved).isTrue();
+ approve(changeId);
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class, () -> gApi.changes().id(changeId).current().submit());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Failed to submit 1 change due to the following problems:\n"
+ + "Change %1$s: Change %1$s is not ready: "
+ + "submit requirement 'No-Unresolved-Comments' is unsatisfied.",
+ r.getChange().getId().get()));
+
+ // Resolve the comment and check that the change can be submitted now.
+ CommentInfo commentInfo2 =
+ addComment(
+ changeId, "foo", "reply", /* unresolved= */ false, /* inReplyTo= */ commentInfo.id);
+ assertThat(commentInfo2.unresolved).isFalse();
+ gApi.changes().id(changeId).current().submit();
+ }
+
+ @CanIgnoreReturnValue
+ private CommentInfo addComment(
+ String changeId, String file, String message, boolean unresolved, @Nullable String inReplyTo)
+ throws Exception {
+ ReviewInput in = new ReviewInput();
+ CommentInput commentInput = new CommentInput();
+ commentInput.path = file;
+ commentInput.line = 1;
+ commentInput.message = message;
+ commentInput.unresolved = unresolved;
+ commentInput.inReplyTo = inReplyTo;
+ in.comments = ImmutableMap.of(file, ImmutableList.of(commentInput));
+ gApi.changes().id(changeId).current().review(in);
+
+ return gApi.changes().id(changeId).commentsRequest().getAsList().stream()
+ .filter(commentInfo -> commentInput.message.equals(commentInfo.message))
+ // if there are multiple comments with the same message, take the one was created last
+ .max(
+ Comparator.comparing(commentInfo1 -> commentInfo1.updated.toInstant().getEpochSecond()))
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format("comment '%s' not found", commentInput.message)));
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
new file mode 100644
index 0000000..a6c3c27
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesFilterPermissionBackendIT.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.DefaultPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Test;
+
+public class QueryChangesFilterPermissionBackendIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
+
+ @Singleton
+ public static class TestPermissionBackend extends PermissionBackend {
+ private final DefaultPermissionBackend defaultPermissionBackend;
+ private final AtomicReference<String> extraQueryFilter;
+
+ public static class Module extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(PermissionBackend.class).to(TestPermissionBackend.class).in(Scopes.SINGLETON);
+ }
+ }
+
+ @Inject
+ TestPermissionBackend(DefaultPermissionBackend defaultPermissionBackend) {
+ this.defaultPermissionBackend = defaultPermissionBackend;
+ this.extraQueryFilter = new AtomicReference<>();
+ }
+
+ @Override
+ public WithUser currentUser() {
+ return new TestPermissionWithUser(defaultPermissionBackend.currentUser());
+ }
+
+ @Override
+ public WithUser user(CurrentUser user) {
+ return new TestPermissionWithUser(defaultPermissionBackend.user(user));
+ }
+
+ @Override
+ public WithUser absentUser(Account.Id id) {
+ return new TestPermissionWithUser(defaultPermissionBackend.absentUser(id));
+ }
+
+ public String getExtraQueryFilter() {
+ return extraQueryFilter.get();
+ }
+
+ public void setExtraQueryFilter(String extraQueryFilter) {
+ this.extraQueryFilter.set(extraQueryFilter);
+ }
+
+ class TestPermissionWithUser extends WithUser {
+
+ private final WithUser defaultPermissioBackendWithUser;
+
+ TestPermissionWithUser(WithUser defaultPermissioBackendWithUser) {
+ this.defaultPermissioBackendWithUser = defaultPermissioBackendWithUser;
+ }
+
+ @Override
+ public ForProject project(Project.NameKey project) {
+ return defaultPermissioBackendWithUser.project(project);
+ }
+
+ @Override
+ public void check(GlobalOrPluginPermission perm)
+ throws AuthException, PermissionBackendException {
+ defaultPermissioBackendWithUser.check(perm);
+ }
+
+ @Override
+ public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+ throws PermissionBackendException {
+ return defaultPermissioBackendWithUser.test(permSet);
+ }
+
+ @Override
+ public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+ return defaultPermissioBackendWithUser.testCond(perm);
+ }
+
+ @Override
+ public String filterQueryChanges() {
+ return extraQueryFilter.get();
+ }
+ }
+ }
+
+ @Override
+ public Module createModule() {
+ return new TestPermissionBackend.Module();
+ }
+
+ @Test
+ public void filterHidenProjectByAuthenticationBackend() throws Exception {
+ String projectChangeId = createChange().getChangeId();
+
+ Project.NameKey hiddenProject = projectOperations.newProject().create();
+ TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+ createChange(hiddenRepo);
+
+ String changeQuery = "author:self OR status:open";
+ assertThat(gApi.changes().query(changeQuery).get()).hasSize(2);
+
+ server
+ .getTestInjector()
+ .getInstance(TestPermissionBackend.class)
+ .setExtraQueryFilter("-project:" + hiddenProject);
+ List<ChangeInfo> projectChanges = gApi.changes().query(changeQuery).get();
+ assertThat(projectChanges.stream().map(c -> c.changeId)).containsExactly(projectChangeId);
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
index 7f21eb6..a5de579 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -858,6 +858,24 @@
}
@Test
+ public void rebaseChangeWithRefsHeadsMaster() throws Exception {
+ RevCommit desiredBase =
+ createNewCommitWithoutChangeId(/*branch=*/ "refs/heads/master", "file", "content");
+ PushOneCommit.Result child = createChange();
+ RebaseInput ri = new RebaseInput();
+
+ // rebase child onto desiredBase (referenced by ref)
+ ri.base = "refs/heads/master";
+ rebaseCallWithInput.call(child.getChangeId(), ri);
+
+ PatchSet ps2 = child.getPatchSet();
+ assertThat(ps2.id().get()).isEqualTo(2);
+ RevisionInfo childInfo =
+ get(child.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(childInfo.commit.parents.get(0).commit).isEqualTo(desiredBase.name());
+ }
+
+ @Test
public void cannotRebaseChangeWithInvalidBaseCommit() throws Exception {
// Create another branch and push the desired parent commit to it.
String branchName = "foo";
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index cd3e76d..10dd5e3 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -15,6 +15,7 @@
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.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -413,6 +414,12 @@
@Test
@GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
public void revertWithNonVisibleUsers() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
// Define readable names for the users we use in this test.
TestAccount reverter = user;
TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 42af666..a5d86ce 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -92,6 +92,7 @@
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Before;
import org.junit.Test;
@NoHttpd
@@ -103,6 +104,11 @@
@Inject private ExtensionRegistry extensionRegistry;
@Inject private IndexOperations.Change changeIndexOperations;
+ @Before
+ public void setup() throws RestApiException {
+ removeDefaultSubmitRequirements();
+ }
+
@Test
public void submitRecords() throws Exception {
PushOneCommit.Result r = createChange();
@@ -3160,4 +3166,8 @@
r.assertOkStatus();
return r;
}
+
+ private void removeDefaultSubmitRequirements() throws RestApiException {
+ gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 035b567..72b3b39 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -32,6 +32,7 @@
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.GitUtil;
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.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.data.GlobalCapability;
@@ -57,6 +58,7 @@
import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.webui.FileHistoryWebLink;
import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -1179,6 +1181,33 @@
new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false));
}
+ @Test
+ public void canUpdateConfigWithoutCreatingChangeNullByDefault() throws Exception {
+ assertThat(pApi().access().requireChangeForConfigUpdate).isNull();
+ assertThat(gApi.projects().name(allProjects.get()).access().requireChangeForConfigUpdate)
+ .isNull();
+ }
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void canUpdateConfigWithoutCreatingChangeUsesConfigValue() throws Exception {
+ assertThat(pApi().access().requireChangeForConfigUpdate).isTrue();
+ assertThat(gApi.projects().name(allProjects.get()).access().requireChangeForConfigUpdate)
+ .isTrue();
+ }
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_postAccessRejected() {
+ ProjectAccessInput accessInput = newProjectAccessInput();
+ AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+ accessInput.add.put(REFS_HEADS, accessSectionInfo);
+ MethodNotAllowedException e =
+ assertThrows(MethodNotAllowedException.class, () -> pApi().access(accessInput));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
private ProjectApi pApi() throws Exception {
return gApi.projects().name(newProjectName.get());
}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
new file mode 100644
index 0000000..694cfc9
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/ConfigReviewIT.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ConfigReviewIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
+
+ private Project.NameKey defaultMessageProject;
+ private Project.NameKey customMessageProject;
+
+ @Before
+ public void setUp() throws Exception {
+ defaultMessageProject = projectOperations.newProject().create();
+ customMessageProject = projectOperations.newProject().create();
+ }
+
+ @Test
+ public void createConfigChangeWithDefaultMessage() throws Exception {
+ ConfigInput in = new ConfigInput();
+ in.description = "Test project description";
+
+ ChangeInfo changeInfo = gApi.projects().name(defaultMessageProject.get()).configReview(in);
+
+ assertThat(changeInfo.subject).isEqualTo("Review config change");
+ Config config = new Config();
+ config.fromText(
+ gApi.changes()
+ .id(changeInfo.changeId)
+ .revision(1)
+ .file("project.config")
+ .content()
+ .asString());
+ assertThat(config.getString("project", null, "description"))
+ .isEqualTo("Test project description");
+ }
+
+ @Test
+ public void createConfigChangeWithCustomMessage() throws Exception {
+ ConfigInput in = new ConfigInput();
+ in.description = "Test project description";
+ String customMessage = "test custom message";
+ in.commitMessage = customMessage;
+
+ ChangeInfo changeInfo = gApi.projects().name(customMessageProject.get()).configReview(in);
+
+ assertThat(changeInfo.subject).isEqualTo(customMessage);
+ Config config = new Config();
+ config.fromText(
+ gApi.changes()
+ .id(changeInfo.changeId)
+ .revision(1)
+ .file("project.config")
+ .content()
+ .asString());
+ assertThat(config.getString("project", null, "description"))
+ .isEqualTo("Test project description");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index b9cbbcd..8f96f6a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -23,13 +23,16 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
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.DashboardInfo;
import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
import com.google.gerrit.extensions.api.projects.ProjectApi;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -102,19 +105,39 @@
DashboardInfo info = createTestDashboard();
assertThat(info.isDefault).isNull();
project().dashboard(info.id).setDefault();
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG,
+ "Administrator",
+ String.format("Changed default dashboard to %s.", info.id));
assertThat(project().dashboard(info.id).get().isDefault).isTrue();
assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
}
@Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_setDefaultDashboardReject() throws Exception {
+ DashboardInfo info = createTestDashboard();
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class, () -> project().dashboard(info.id).setDefault());
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
+ @Test
public void setDefaultDashboardByProject() throws Exception {
DashboardInfo info = createTestDashboard();
assertThat(info.isDefault).isNull();
project().defaultDashboard(info.id);
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG,
+ "Administrator",
+ String.format("Changed default dashboard to %s.", info.id));
assertThat(project().dashboard(info.id).get().isDefault).isTrue();
assertThat(project().defaultDashboard().get().id).isEqualTo(info.id);
project().removeDefaultDashboard();
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG, "Administrator", "Removed default dashboard.");
assertThat(project().dashboard(info.id).get().isDefault).isNull();
assertThrows(ResourceNotFoundException.class, () -> project().defaultDashboard().get());
diff --git a/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java
new file mode 100644
index 0000000..8e64325
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/LabelsReviewIT.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.BatchLabelInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class LabelsReviewIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
+
+ @Test
+ public void createLabelsChangeWithDefaultMessage() throws Exception {
+ Project.NameKey testProject = projectOperations.newProject().create();
+ LabelDefinitionInput fooInput = new LabelDefinitionInput();
+ fooInput.name = "Foo";
+ fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+ BatchLabelInput input = new BatchLabelInput();
+ input.create = ImmutableList.of(fooInput);
+
+ ChangeInfo changeInfo = gApi.projects().name(testProject.get()).labelsReview(input);
+
+ assertThat(changeInfo.subject).isEqualTo("Review labels change");
+ Config config = new Config();
+ config.fromText(
+ gApi.changes()
+ .id(changeInfo.changeId)
+ .revision(1)
+ .file("project.config")
+ .content()
+ .asString());
+ assertThat(config.getStringList("label", "Foo", "value"))
+ .asList()
+ .containsExactly("+1 Looks Good", "0 Don't Know", "-1 Looks Bad");
+ }
+
+ @Test
+ public void createLabelsChangeWithCustomMessage() throws Exception {
+ Project.NameKey testProject = projectOperations.newProject().create();
+ LabelDefinitionInput fooInput = new LabelDefinitionInput();
+ fooInput.name = "Foo";
+ fooInput.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+ BatchLabelInput input = new BatchLabelInput();
+ input.create = ImmutableList.of(fooInput);
+ String customMessage = "test custom message";
+ input.commitMessage = customMessage;
+
+ ChangeInfo changeInfo = gApi.projects().name(testProject.get()).labelsReview(input);
+
+ assertThat(changeInfo.subject).isEqualTo(customMessage);
+ Config config = new Config();
+ config.fromText(
+ gApi.changes()
+ .id(changeInfo.changeId)
+ .revision(1)
+ .file("project.config")
+ .content()
+ .asString());
+ assertThat(config.getStringList("label", "Foo", "value"))
+ .asList()
+ .containsExactly("+1 Looks Good", "0 Don't Know", "-1 Looks Bad");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index e34b985..80d6b5c 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -70,6 +70,7 @@
import com.google.gerrit.extensions.events.ProjectIndexedListener;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.config.ProjectConfigEntry;
@@ -393,6 +394,17 @@
assertThat(info.state).isEqualTo(input.state);
}
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_setConfigRejected() {
+ ConfigInput input = createTestConfigInput();
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(project.get()).config(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
@SuppressWarnings("deprecation")
@Test
public void setPartialConfig() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
new file mode 100644
index 0000000..34af339
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/PutDescriptionIT.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.DescriptionInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import org.junit.Test;
+
+public class PutDescriptionIT extends AbstractDaemonTest {
+ @Test
+ public void setDescription() throws Exception {
+ DescriptionInput input = new DescriptionInput();
+ input.description = "test project description";
+ gApi.projects().name(project.get()).description(input);
+ assertThat(gApi.projects().name(project.get()).description())
+ .isEqualTo("test project description");
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG, "Administrator", "Update description");
+ }
+
+ @Test
+ public void setDescriptionWithCustomCommitMessage() throws Exception {
+ DescriptionInput input = new DescriptionInput();
+ input.description = "test project description with test commit message";
+ input.commitMessage = "test commit message";
+ gApi.projects().name(project.get()).description(input);
+ assertThat(gApi.projects().name(project.get()).description())
+ .isEqualTo("test project description with test commit message");
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG, "Administrator", "test commit message");
+ }
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_setDescription() throws Exception {
+ DescriptionInput input = new DescriptionInput();
+ input.description = "test project description";
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(project.get()).description(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 2bdbe50..8e1cbe5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -25,8 +25,10 @@
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.config.AllProjectsNameProvider;
@@ -89,12 +91,16 @@
gApi.projects().name(project.get()).parent(parent);
assertThat(gApi.projects().name(project.get()).parent()).isEqualTo(parent);
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG, "Administrator", String.format("Changed parent to %s.", parent));
// When the parent name is not explicitly set, it should be
// set to "All-Projects".
gApi.projects().name(project.get()).parent(null);
assertThat(gApi.projects().name(project.get()).parent())
.isEqualTo(AllProjectsNameProvider.DEFAULT);
+ assertLastCommitAuthorAndShortMessage(
+ RefNames.REFS_CONFIG, "Administrator", "Changed parent to All-Projects.");
}
@Test
@@ -169,4 +175,16 @@
BadRequestException.class, () -> gApi.projects().name(allUsers.get()).parent(parent));
assertThat(thrown).hasMessageThat().contains("All-Users must inherit from All-Projects");
}
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_postParentRejected() {
+ String parent = projectOperations.newProject().create().get();
+
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(project.get()).parent(parent));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
index e388dd1..933b538 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsAPIIT.java
@@ -20,15 +20,21 @@
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.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
+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.entities.Permission;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.SubmitRequirementInfo;
import com.google.gerrit.extensions.common.SubmitRequirementInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.inject.Inject;
@@ -582,7 +588,7 @@
infos = gApi.projects().name(project.get()).submitRequirements().withInherited(true).get();
- assertThat(names(infos)).containsExactly("base-sr", "sr-1", "sr-2");
+ assertThat(names(infos)).containsExactly("No-Unresolved-Comments", "base-sr", "sr-1", "sr-2");
}
@Test
@@ -639,6 +645,53 @@
assertThat(names(infos)).containsExactly("verified");
}
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_createSubmitRequirementRejected() {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.description = "At least one +2 vote to the code-review label";
+ input.submittabilityExpression = "label:code-review=+2";
+ input.overrideExpression = "label:build-cop-override=+1";
+
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").create(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_updateSubmitRequirementRejected() throws Exception {
+ createSubmitRequirementWithReview(project.get(), "code-review");
+
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = "code-review";
+ input.applicabilityExpression = "topic:foo";
+ input.submittabilityExpression = "label:code-review=+2";
+
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () ->
+ gApi.projects().name(project.get()).submitRequirement("code-review").update(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_deleteSubmitRequirementRejected() throws Exception {
+ createSubmitRequirementWithReview(project.get(), "code-review");
+
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(project.get()).submitRequirement("code-review").delete());
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
private SubmitRequirementInfo createSubmitRequirement(String srName) throws RestApiException {
return createSubmitRequirement(project.get(), srName);
}
@@ -652,6 +705,21 @@
return gApi.projects().name(project).submitRequirement(srName).create(input).get();
}
+ private void createSubmitRequirementWithReview(String project, String srName)
+ throws RestApiException {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = srName;
+ input.submittabilityExpression = "label:dummy=+2";
+ BatchSubmitRequirementInput batchInput = new BatchSubmitRequirementInput();
+ batchInput.create = ImmutableList.of(input);
+
+ ChangeInfo change = gApi.projects().name(project).submitRequirementsReview(batchInput);
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.label("Code-Review", 2);
+ gApi.changes().id(change.project, change._number).current().review(reviewInput);
+ gApi.changes().id(change.project, change._number).current().submit();
+ }
+
private List<String> names(List<SubmitRequirementInfo> infos) {
return infos.stream().map(sr -> sr.name).collect(Collectors.toList());
}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
new file mode 100644
index 0000000..8c8e7a6
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/SubmitRequirementsReviewIT.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+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.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+public class SubmitRequirementsReviewIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
+
+ @Test
+ public void createSubmitRequirementsChangeWithDefaultMessage() throws Exception {
+ Project.NameKey testProject = projectOperations.newProject().create();
+ SubmitRequirementInput fooSR = new SubmitRequirementInput();
+ fooSR.name = "Foo";
+ fooSR.description = "SR description";
+ fooSR.applicabilityExpression = "topic:foo";
+ fooSR.submittabilityExpression = "label:code-review=+2";
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(fooSR);
+
+ ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+
+ assertThat(changeInfo.subject).isEqualTo("Review submit requirements change");
+ Config config = new Config();
+ config.fromText(
+ gApi.changes()
+ .id(changeInfo.changeId)
+ .revision(1)
+ .file("project.config")
+ .content()
+ .asString());
+ assertThat(config.getString("submit-requirement", "Foo", "description"))
+ .isEqualTo("SR description");
+ assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+ .isEqualTo("topic:foo");
+ assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+ .isEqualTo("label:code-review=+2");
+ }
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_batchUpdateRejected() {
+ Project.NameKey testProject = projectOperations.newProject().create();
+ SubmitRequirementInput fooSR = new SubmitRequirementInput();
+ fooSR.name = "Foo";
+ fooSR.description = "SR description";
+ fooSR.applicabilityExpression = "topic:foo";
+ fooSR.submittabilityExpression = "label:code-review=+2";
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(fooSR);
+
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(testProject.get()).submitRequirements(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
+
+ @Test
+ public void createSubmitRequirementsChangeWithCustomMessage() throws Exception {
+ Project.NameKey testProject = projectOperations.newProject().create();
+ SubmitRequirementInput fooSR = new SubmitRequirementInput();
+ fooSR.name = "Foo";
+ fooSR.description = "SR description";
+ fooSR.applicabilityExpression = "topic:foo";
+ fooSR.submittabilityExpression = "label:code-review=+2";
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(fooSR);
+ String customMessage = "test custom message";
+ input.commitMessage = customMessage;
+
+ ChangeInfo changeInfo = gApi.projects().name(testProject.get()).submitRequirementsReview(input);
+ assertThat(changeInfo.subject).isEqualTo(customMessage);
+
+ Config config = new Config();
+ config.fromText(
+ gApi.changes()
+ .id(changeInfo.changeId)
+ .revision(1)
+ .file("project.config")
+ .content()
+ .asString());
+ assertThat(config.getString("submit-requirement", "Foo", "description"))
+ .isEqualTo("SR description");
+ assertThat(config.getString("submit-requirement", "Foo", "applicableIf"))
+ .isEqualTo("topic:foo");
+ assertThat(config.getString("submit-requirement", "Foo", "submittableIf"))
+ .isEqualTo("label:code-review=+2");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 62a095f..9f673c8 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -103,6 +103,7 @@
private static final String FILE_NAME = "foo";
private static final String FILE_NAME2 = "foo2";
private static final String FILE_NAME3 = "foo3";
+ private static final String FILE_NAME4 = "foo4";
private static final int FILE_MODE = 100644;
private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
@@ -348,6 +349,16 @@
}
@Test
+ public void updateMultipleExistingFiles() throws Exception {
+ createEmptyEditFor(changeId);
+ gApi.changes().id(changeId).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
+ gApi.changes().id(changeId).edit().modifyFile(FILE_NAME2, RawInputUtil.create(CONTENT_NEW));
+ assertThat(getEdit(changeId)).isPresent();
+ ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_NEW);
+ ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME2), CONTENT_NEW);
+ }
+
+ @Test
public void updateExistingFileAfterUpdatingPreferredEmail() throws Exception {
String emailOne = "email1@example.com";
Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();
@@ -1182,6 +1193,41 @@
}
@Test
+ public void addMultipleNewFiles() throws Exception {
+ createEmptyEditFor(changeId);
+ Optional<EditInfo> originalEdit =
+ gApi.changes()
+ .id(changeId)
+ .edit()
+ .detail()
+ .withOption(ChangeEditDetailOption.LIST_FILES)
+ .get();
+ assertThat(originalEdit)
+ .value()
+ .files()
+ .keys()
+ .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2);
+
+ gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
+ ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME3), CONTENT_NEW);
+ gApi.changes().id(changeId).edit().modifyFile(FILE_NAME4, RawInputUtil.create(CONTENT_NEW));
+ ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME4), CONTENT_NEW);
+
+ Optional<EditInfo> adjustedEdit =
+ gApi.changes()
+ .id(changeId)
+ .edit()
+ .detail()
+ .withOption(ChangeEditDetailOption.LIST_FILES)
+ .get();
+ assertThat(adjustedEdit)
+ .value()
+ .files()
+ .keys()
+ .containsExactly(COMMIT_MSG, FILE_NAME, FILE_NAME2, FILE_NAME3, FILE_NAME4);
+ }
+
+ @Test
public void addNewFileAndAmend() throws Exception {
createEmptyEditFor(changeId);
gApi.changes().id(changeId).edit().modifyFile(FILE_NAME3, RawInputUtil.create(CONTENT_NEW));
@@ -1371,8 +1417,21 @@
@Test
public void canCombineEdits() throws Exception {
- createEmptyEditFor(changeId);
+ String baseFileToDelete = "base_file_to_delete";
+ String baseFileToRename = "base_file_to_rename";
+ String baseFileNewName = "base_file_new_name";
+ String currPatchSetFileToRename = "current_patchset_file_to_rename";
+ String currPatchSetFileNewName = "current_patchset_file_new_name";
+ // Re-clone empty repo; TestRepository doesn't let us reset to unborn head.
+ testRepo = cloneProject(project);
+ String baseChangeId = newChangeWithFile(admin.newIdent(), baseFileToDelete, "content");
+ addNewPatchSetWithModifiedFile(baseChangeId, baseFileToRename, "content2");
+ gApi.changes().id(baseChangeId).current().review(ReviewInput.approve());
+ gApi.changes().id(baseChangeId).current().submit();
+ String changeId = newChange(admin.newIdent());
+ addNewPatchSetWithModifiedFile(changeId, currPatchSetFileToRename, "content3");
+ createEmptyEditFor(changeId);
// update author
gApi.changes()
.id(changeId)
@@ -1394,11 +1453,20 @@
.modifyIdentity(
"Test Committer", "test.committer@example.com", ChangeEditIdentityType.COMMITTER);
- // delete file
+ // delete current patch-set file
gApi.changes().id(changeId).edit().deleteFile(FILE_NAME);
- // rename file
- gApi.changes().id(changeId).edit().renameFile(FILE_NAME2, FILE_NAME3);
+ // delete base file
+ gApi.changes().id(changeId).edit().deleteFile(baseFileToDelete);
+
+ // rename current patch-set file
+ gApi.changes()
+ .id(changeId)
+ .edit()
+ .renameFile(currPatchSetFileToRename, currPatchSetFileNewName);
+
+ // rename base file
+ gApi.changes().id(changeId).edit().renameFile(baseFileToRename, baseFileNewName);
// publish edit
PublishChangeEditInput publishInput = new PublishChangeEditInput();
@@ -1419,7 +1487,12 @@
assertThat(currentCommit.author.name).isEqualTo("Test Author");
assertThat(currentCommit.author.email).isEqualTo("test.author@example.com");
assertThat(currentCommit.message).isEqualTo(msg);
- assertThat(currentRevision.files.keySet()).containsExactly(newFile, FILE_NAME3);
+ assertThat(currentRevision.files.keySet())
+ .containsExactly(newFile, baseFileToDelete, baseFileNewName, currPatchSetFileNewName);
+ assertThat(currentRevision.files.get(newFile).status).isEqualTo('A');
+ assertThat(currentRevision.files.get(baseFileToDelete).status).isEqualTo('D');
+ assertThat(currentRevision.files.get(baseFileNewName).status).isEqualTo('R');
+ assertThat(currentRevision.files.get(currPatchSetFileNewName).status).isEqualTo('A');
}
private void createArbitraryEditFor(String changeId) throws Exception {
@@ -1451,6 +1524,13 @@
return push.to("refs/for/master").getChangeId();
}
+ private String newChangeWithFile(PersonIdent ident, String filePath, String fileContent)
+ throws Exception {
+ PushOneCommit push =
+ pushFactory.create(ident, testRepo, PushOneCommit.SUBJECT, filePath, fileContent);
+ return push.to("refs/for/master").getChangeId();
+ }
+
private void addNewPatchSet(String changeId) throws Exception {
addNewPatchSetWithModifiedFile(changeId, FILE_NAME2, new String(CONTENT_NEW2, UTF_8));
}
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 8861a9e..aa96d1b 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -1028,6 +1028,19 @@
// verify that the re-indexing was triggered for the change
assertThat(query("has:edit")).hasSize(1);
+
+ // update the existing edit
+ r = amendChange(r.getChangeId(), "refs/for/master%edit");
+ r.assertOkStatus();
+ r.assertMessage(
+ canonicalWebUrl.get()
+ + "c/"
+ + project.get()
+ + "/+/"
+ + r.getChange().getId()
+ + " "
+ + editInfo.commit.subject
+ + " [EDIT]\n");
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 6c5febd..c9f469e 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -57,6 +57,7 @@
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Inject;
import java.util.ArrayList;
@@ -87,7 +88,8 @@
@Inject private IndexOperations.Change changeIndexOperations;
private AccountGroup.UUID admins;
- private AccountGroup.UUID nonInteractiveUsers;
+ private AccountGroup.UUID serviceUsers;
+ private AccountGroup.UUID blockedUsers;
private RevCommit rcMaster;
private RevCommit rcBranch;
@@ -118,7 +120,8 @@
@Before
public void setUp() throws Exception {
admins = adminGroupUuid();
- nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+ serviceUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
+ blockedUsers = groupUuid(SchemaCreatorImpl.BLOCKED_USERS);
setUpPermissions();
setUpChanges();
}
@@ -1239,7 +1242,8 @@
assertThat(getGroupRefs(git))
.containsExactly(
RefNames.refsGroups(admins),
- RefNames.refsGroups(nonInteractiveUsers),
+ RefNames.refsGroups(serviceUsers),
+ RefNames.refsGroups(blockedUsers),
RefNames.refsGroups(users));
}
}
@@ -1261,7 +1265,8 @@
assertThat(getGroupRefs(git))
.containsExactly(
RefNames.refsGroups(admins),
- RefNames.refsGroups(nonInteractiveUsers),
+ RefNames.refsGroups(serviceUsers),
+ RefNames.refsGroups(blockedUsers),
RefNames.refsGroups(users));
}
}
@@ -1413,7 +1418,8 @@
RefNames.REFS_EXTERNAL_IDS,
RefNames.REFS_GROUPNAMES,
RefNames.refsGroups(admins),
- RefNames.refsGroups(nonInteractiveUsers),
+ RefNames.refsGroups(serviceUsers),
+ RefNames.refsGroups(blockedUsers),
RefNames.REFS_SEQUENCES + Sequence.NAME_ACCOUNTS,
RefNames.REFS_SEQUENCES + Sequence.NAME_GROUPS,
RefNames.REFS_CONFIG,
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 2d73e97..c675634 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -38,6 +38,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmitRecord;
@@ -68,6 +69,7 @@
import java.util.Optional;
import java.util.Set;
import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.junit.TestRepository;
import org.junit.Rule;
import org.junit.Test;
@@ -91,6 +93,7 @@
@Inject private ExtensionRegistry extensionRegistry;
@Inject private WorkQueue workQueue;
+ @Inject private ProjectOperations projectOperations;
@Test
public void restCallWithoutTrace() throws Exception {
@@ -386,7 +389,8 @@
extensionRegistry.newRegistration().add(testPerformanceLogger)) {
RestResponse response = adminRestSession.put("/projects/new10");
assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
- verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
+ verify(testPerformanceLogger, timeout(5000).atLeastOnce())
+ .logNanos(anyString(), anyLong(), any());
}
}
@@ -399,7 +403,8 @@
PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
PushOneCommit.Result r = push.to("refs/heads/master");
r.assertOkStatus();
- verify(testPerformanceLogger, timeout(5000).atLeastOnce()).log(anyString(), anyLong(), any());
+ verify(testPerformanceLogger, timeout(5000).atLeastOnce())
+ .logNanos(anyString(), anyLong(), any());
}
}
@@ -592,6 +597,40 @@
}
@Test
+ @GerritConfig(name = "tracing.issue123.requestType", value = "GIT_RECEIVE")
+ @GerritConfig(name = "tracing.issue123.projectPattern", value = "traced-project")
+ public void traceGitReceiveForProject() throws Exception {
+ Project.NameKey tracedProject = projectOperations.newProject().name("traced-project").create();
+ TestRepository<?> tracedRepo = cloneProject(tracedProject);
+
+ TraceValidatingCommitValidationListener commitValidationListener =
+ new TraceValidatingCommitValidationListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(commitValidationListener)) {
+ PushOneCommit push = pushFactory.create(admin.newIdent(), tracedRepo);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+ assertThat(commitValidationListener.traceId).isEqualTo("issue123");
+ assertThat(commitValidationListener.isLoggingForced).isTrue();
+ assertThat(commitValidationListener.tags.get("project")).containsExactly(tracedProject.get());
+ }
+
+ // other project is not traced
+ commitValidationListener = new TraceValidatingCommitValidationListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(commitValidationListener)) {
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+ assertThat(commitValidationListener.traceId).isNull();
+ assertThat(commitValidationListener.isLoggingForced).isFalse();
+
+ // The logging tag with the project name is also set if tracing is off.
+ assertThat(commitValidationListener.tags.get("project")).containsExactly(project.get());
+ }
+ }
+
+ @Test
@GerritConfig(name = "tracing.issue123.requestType", value = "FOO")
public void traceProjectInvalidRequestType() throws Exception {
TraceValidatingProjectCreationValidationListener projectCreationListener =
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
index 6667421..bfc64a6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ConfigRestApiBindingsIT.java
@@ -63,9 +63,11 @@
RestCall.put("/config/server/preferences.edit"),
RestCall.post("/config/server/reload"),
RestCall.get("/config/server/summary"),
+ RestCall.post("/config/server/deactivate.stale.accounts"),
RestCall.get("/config/server/tasks"),
RestCall.get("/config/server/top-menus"),
- RestCall.get("/config/server/version"));
+ RestCall.get("/config/server/version"),
+ RestCall.post("/config/server/cleanup.changes"));
/**
* Cache REST endpoints to be tested, the URLs contain a placeholder for the cache identifier.
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index db9c1e7..18c435f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -70,6 +70,7 @@
RestCall.get("/projects/%s/commits:in"),
RestCall.get("/projects/%s/config"),
RestCall.put("/projects/%s/config"),
+ RestCall.put("/projects/%s/config:review"),
RestCall.post("/projects/%s/create.change"),
RestCall.get("/projects/%s/dashboards"),
RestCall.get("/projects/%s/description"),
@@ -82,12 +83,15 @@
RestCall.post("/projects/%s/index.changes"),
RestCall.get("/projects/%s/labels"),
RestCall.post("/projects/%s/labels/"),
+ RestCall.post("/projects/%s/labels:review"),
RestCall.put("/projects/%s/labels/new-label"),
RestCall.get("/projects/%s/parent"),
RestCall.put("/projects/%s/parent"),
RestCall.get("/projects/%s/statistics.git"),
RestCall.get("/projects/%s/submit_requirements"),
RestCall.put("/projects/%s/submit_requirements/new-sr"),
+ RestCall.post("/projects/%s/submit_requirements/"),
+ RestCall.post("/projects/%s/submit_requirements:review"),
RestCall.get("/projects/%s/tags"),
RestCall.put("/projects/%s/tags/new-tag"),
RestCall.post("/projects/%s/tags:delete"));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 25af040..0b86406 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -589,7 +589,7 @@
+ num
+ ": Change "
+ num
- + " is work in progress");
+ + " is marked work in progress");
}
@Test
@@ -607,7 +607,7 @@
+ num
+ ": Change "
+ num
- + " is work in progress");
+ + " is marked work in progress");
}
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java
new file mode 100644
index 0000000..c33de03
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeEditIT.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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 com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class ChangeEditIT extends AbstractDaemonTest {
+ private static final String FILE_NAME = "foo";
+ private static final String FILE_NAME2 = "foo2";
+
+ private static final String FILE_CONTENT = "content";
+ private static final String FILE_CONTENT2 = "content2";
+
+ @Inject private ChangeOperations changeOperations;
+
+ @Test
+ public void modifyMultipleFilesInOneChangeEdit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ RestResponse response =
+ adminRestSession.putRaw(
+ String.format("/changes/%s/edit/%s", changeId, FILE_NAME),
+ RawInputUtil.create(FILE_CONTENT));
+ assertThat(response.getStatusCode()).isEqualTo(204);
+ RestResponse response2 =
+ adminRestSession.putRaw(
+ String.format("/changes/%s/edit/%s", changeId, FILE_NAME2),
+ RawInputUtil.create(FILE_CONTENT2));
+ assertThat(response2.getStatusCode()).isEqualTo(204);
+ RestResponse publishResponse =
+ adminRestSession.post(String.format("/changes/%s/edit:publish", changeId));
+ assertThat(publishResponse.getStatusCode()).isEqualTo(204);
+ assertThat(gApi.changes().id(changeId.get()).current().files().keySet())
+ .containsExactly("/COMMIT_MSG", FILE_NAME, FILE_NAME2);
+ // Created an initial change, then applied a single edit with two files resulting in one more
+ // patchset.
+ assertThat(gApi.changes().id(changeId.get()).get().currentRevisionNumber).isEqualTo(2);
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index ff4fa9d..d618e2f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -61,6 +61,7 @@
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.change.ReviewerModifier;
import com.google.gerrit.testing.FakeEmailSender.Message;
import com.google.gson.stream.JsonReader;
@@ -728,11 +729,11 @@
gApi.changes().id(r.getChangeId()).current().submit();
assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
- AuthException thrown =
+ ResourceConflictException thrown =
assertThrows(
- AuthException.class,
+ ResourceConflictException.class,
() -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
- assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
}
@Test
@@ -755,11 +756,11 @@
requestScopeOperations.setApiUser(user.id());
assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
- AuthException thrown =
+ ResourceConflictException thrown =
assertThrows(
- AuthException.class,
+ ResourceConflictException.class,
() -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
- assertThat(thrown).hasMessageThat().contains("remove reviewer not permitted");
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
}
@Test
@@ -837,8 +838,79 @@
}
@Test
+ public void removeReviewerWithVoteFromMergedChangeFailsWithRemoveReviewerPermission()
+ throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+
+ requestScopeOperations.setApiUser(user.id());
+ gApi.changes()
+ .id(r.getChangeId())
+ .current()
+ .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes().id(r.getChangeId()).current().submit();
+
+ TestAccount newUser = createAccounts(1, name("foo")).get(0);
+ requestScopeOperations.setApiUser(newUser.id());
+ assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(r.getChangeId()).reviewer(user.email()).remove());
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+ }
+
+ @Test
+ public void removeSubmitterFromMergedChangeFailsWithRemoveReviewerPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(allow(Permission.REMOVE_REVIEWER).ref(RefNames.REFS + "*").group(REGISTERED_USERS))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+
+ requestScopeOperations.setApiUser(user.id());
+ gApi.changes()
+ .id(r.getChangeId())
+ .current()
+ .review(new ReviewInput().label(LabelId.CODE_REVIEW, 2));
+
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes().id(r.getChangeId()).current().submit();
+
+ TestAccount newUser = createAccounts(1, name("foo")).get(0);
+ requestScopeOperations.setApiUser(newUser.id());
+ assertThat(gApi.changes().id(r.getChangeId()).get().removableReviewers).isEmpty();
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(r.getChangeId()).reviewer(admin.email()).remove());
+ assertThat(thrown).hasMessageThat().contains("cannot remove votes from merged change");
+ }
+
+ @Test
@Sandboxed
- public void removeReviewerWithoutVoteWithPermissionSucceeds() throws Exception {
+ public void removeReviewerWithoutVoteFromOpenChangeWithPermissionSucceeds() throws Exception {
PushOneCommit.Result r = createChange();
// This test creates a new user so that it can explicitly check the REMOVE_REVIEWER permission
// rather than bypassing the check because of project or ref ownership.
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 698eac8..1e03f2d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -1174,7 +1174,7 @@
ApplyPatchInput applyPatchInput = new ApplyPatchInput();
applyPatchInput.patch = PATCH_INPUT;
CommitTreeSupplier commitTreeSupplier =
- (repo, oi, in, mergeTip) ->
+ (repo, oi, or, in, mergeTip) ->
ApplyPatchUtil.applyPatch(repo, oi, applyPatchInput, mergeTip).getTreeId();
ChangeInfo info = assertCreateWithCommitTreeSupplierSucceeds(input, commitTreeSupplier);
@@ -1186,6 +1186,52 @@
}
@Test
+ public void changePatch_multipleParents_success() throws Exception {
+ changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=1");
+ patchResp.assertOK();
+ assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+ .contains("+B content");
+
+ patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=2");
+ patchResp.assertOK();
+ assertThat(new String(Base64.decode(patchResp.getEntityContent()), UTF_8))
+ .contains("+A content");
+ }
+
+ @Test
+ public void changePatch_multipleParents_failure() throws Exception {
+ changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + change.id + "/revisions/current/patch");
+ // Maintaining historic logic of failing with 409 Conflict in this case.
+ patchResp.assertConflict();
+ }
+
+ @Test
+ public void changePatch_parent_badRequest() throws Exception {
+ changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt");
+ ChangeInput in = newMergeChangeInput("branchA", "branchB", "");
+ ChangeInfo change = assertCreateSucceeds(in);
+
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=3");
+ // Parent 3 does not exist.
+ patchResp.assertBadRequest();
+
+ patchResp = userRestSession.get("/changes/" + change.id + "/revisions/current/patch?parent=0");
+ // Parent 0 does not exist.
+ patchResp.assertBadRequest();
+ }
+
+ @Test
@UseSystemTime
public void sha1sOfTwoNewChangesDiffer() throws Exception {
ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
index 26e37f4..ace7b52 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/GetMetaDiffIT.java
@@ -42,15 +42,15 @@
@Test
public void metaDiff() throws Exception {
- PushOneCommit.Result ch = createChange();
- ChangeApi chApi = gApi.changes().id(ch.getChangeId());
- chApi.topic(TOPIC);
- ChangeInfo oldInfo = chApi.get();
- chApi.topic(TOPIC + "-2");
- chApi.setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
- ChangeInfo newInfo = chApi.get();
+ String changeId = createChange().getChangeId();
+ gApi.changes().id(changeId).topic(TOPIC);
+ ChangeInfo oldInfo = gApi.changes().id(changeId).get();
+ gApi.changes().id(changeId).topic(TOPIC + "-2");
+ gApi.changes().id(changeId).setHashtags(new HashtagsInput(ImmutableSet.of(HASHTAG)));
+ ChangeInfo newInfo = gApi.changes().id(changeId).get();
- ChangeInfoDifference difference = chApi.metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
+ ChangeInfoDifference difference =
+ gApi.changes().id(changeId).metaDiff(oldInfo.metaRevId, newInfo.metaRevId);
assertThat(difference.added().topic).isEqualTo(newInfo.topic);
assertThat(difference.added().hashtags).isNotNull();
@@ -161,13 +161,12 @@
@Test
public void metaDiffNoOldMetaGivenUsesPatchSetBeforeNew() throws Exception {
- PushOneCommit.Result ch = createChange();
- ChangeApi chApi = gApi.changes().id(ch.getChangeId());
- chApi.topic(TOPIC);
- ChangeInfo newInfo = chApi.get();
- chApi.topic(TOPIC + "2");
+ String changeId = createChange().getChangeId();
+ gApi.changes().id(changeId).topic(TOPIC);
+ ChangeInfo newInfo = gApi.changes().id(changeId).get();
+ gApi.changes().id(changeId).topic(TOPIC + "2");
- ChangeInfoDifference difference = chApi.metaDiff(null, newInfo.metaRevId);
+ ChangeInfoDifference difference = gApi.changes().id(changeId).metaDiff(null, newInfo.metaRevId);
assertThat(difference.added().topic).isEqualTo(TOPIC);
assertThat(difference.removed().topic).isNull();
@@ -202,17 +201,18 @@
@Test
public void metaDiffWithOptionIncludesExtraInformation() throws Exception {
- PushOneCommit.Result ch = createChange();
- ChangeApi chApi = gApi.changes().id(ch.getChangeId());
- ChangeInfo oldInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
- amendChange(ch.getChangeId());
- ChangeInfo newInfo = chApi.get(ListChangesOption.CURRENT_REVISION);
+ String changeId = createChange().getChangeId();
+ ChangeInfo oldInfo = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+ amendChange(changeId);
+ ChangeInfo newInfo = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
ChangeInfoDifference difference =
- chApi.metaDiff(
- oldInfo.metaRevId,
- newInfo.metaRevId,
- ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
+ gApi.changes()
+ .id(changeId)
+ .metaDiff(
+ oldInfo.metaRevId,
+ newInfo.metaRevId,
+ ImmutableSet.of(ListChangesOption.CURRENT_REVISION));
assertThat(newInfo.currentRevision).isNotNull();
assertThat(oldInfo.currentRevision).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 1d8e0b8..5f1a982 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -95,12 +95,15 @@
PushOneCommit.Result change2 = createChange();
Change.Id id1 = change1.getPatchSetId().changeId();
+ Change.Id id2 = change2.getPatchSetId().changeId();
submitWithConflict(
change2.getChangeId(),
- "Failed to submit 2 changes due to the following problems:\n"
- + "Change "
- + id1
- + ": submit requirement 'Code-Review' is unsatisfied.");
+ String.format(
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change %d"
+ + ": Change %d must be submitted with change %d but %d is not ready: "
+ + "submit requirement 'Code-Review' is unsatisfied.",
+ id1.get(), id2.get(), id1.get(), id1.get()));
RevCommit updatedHead = projectOperations.project(project).getHead("master");
assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index 4453345..00d8f4e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -20,6 +20,7 @@
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.extensions.common.GroupInfo;
import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.schema.SchemaCreatorImpl;
import com.google.gson.reflect.TypeToken;
import java.util.Map;
import org.junit.Test;
@@ -34,6 +35,7 @@
newGson()
.fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
assertThat(groupMap.keySet())
- .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
+ .containsExactly(
+ "Administrators", SchemaCreatorImpl.BLOCKED_USERS, ServiceUserClassifier.SERVICE_USERS);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 462c76f..6b61252 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -23,6 +23,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
+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.entities.LabelFunction;
@@ -33,6 +34,7 @@
import com.google.gerrit.extensions.common.LabelDefinitionInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.inject.Inject;
import org.junit.Test;
@@ -512,4 +514,18 @@
assertThat(projectOperations.project(project).getHead(RefNames.REFS_CONFIG).getShortMessage())
.isEqualTo("Add Foo Label");
}
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_createLabelRejected() {
+ LabelDefinitionInput input = new LabelDefinitionInput();
+ input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+ input.description = "Foo label description";
+
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(project.get()).label("Foo").create(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index 6a8c9d8..4597819 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -21,12 +21,14 @@
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.gerrit.acceptance.AbstractDaemonTest;
+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.entities.LabelId;
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.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.inject.Inject;
import org.junit.Test;
@@ -123,4 +125,14 @@
@SuppressWarnings("unused")
var unused = gApi.changes().id(changeId).get(DETAILED_LABELS);
}
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_deleteLabelRejected() {
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete());
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
new file mode 100644
index 0000000..adc0a8e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostSubmitRequirementsIT.java
@@ -0,0 +1,386 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+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.ImmutableMap;
+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.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.BatchSubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.restapi.project.PostLabels;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Tests for the {@link PostLabels} REST endpoint. */
+public class PostSubmitRequirementsIT extends AbstractDaemonTest {
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private ProjectOperations projectOperations;
+
+ @Test
+ public void anonymous() throws Exception {
+ requestScopeOperations.setApiUserAnonymous();
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ gApi.projects()
+ .name(allProjects.get())
+ .submitRequirements(new BatchSubmitRequirementInput()));
+ assertThat(thrown).hasMessageThat().contains("Authentication required");
+ }
+
+ @Test
+ public void notAllowed() throws Exception {
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+ .update();
+
+ requestScopeOperations.setApiUser(user.id());
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ gApi.projects()
+ .name(allProjects.get())
+ .submitRequirements(new BatchSubmitRequirementInput()));
+ assertThat(thrown).hasMessageThat().contains("write refs/meta/config not permitted");
+ }
+
+ @Test
+ public void deleteNonExistingSR() throws Exception {
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of("Foo");
+
+ UnprocessableEntityException thrown =
+ assertThrows(
+ UnprocessableEntityException.class,
+ () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+ assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+ }
+
+ @Test
+ public void deleteSR() throws Exception {
+ configSubmitRequirement(project, "Foo");
+ configSubmitRequirement(project, "Bar");
+ assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of("Foo", "Bar");
+ gApi.projects().name(project.get()).submitRequirements(input);
+ assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+ }
+
+ @Test
+ public void deleteSR_namesAreTrimmed() throws Exception {
+ configSubmitRequirement(project, "Foo");
+ configSubmitRequirement(project, "Bar");
+ assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isNotEmpty();
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of(" Foo ", " Bar ");
+ gApi.projects().name(project.get()).submitRequirements(input);
+ assertThat(gApi.projects().name(project.get()).submitRequirements().get()).isEmpty();
+ }
+
+ @Test
+ public void cannotDeleteTheSameSRTwice() throws Exception {
+ configSubmitRequirement(allProjects, "Foo");
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of("Foo", "Foo");
+
+ UnprocessableEntityException thrown =
+ assertThrows(
+ UnprocessableEntityException.class,
+ () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+ assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+ }
+
+ @Test
+ public void cannotCreateSRWithNameThatIsAlreadyInUse() throws Exception {
+ configSubmitRequirement(allProjects, "Foo");
+ SubmitRequirementInput srInput = new SubmitRequirementInput();
+ srInput.name = "Foo";
+ srInput.allowOverrideInChildProjects = false;
+ srInput.submittabilityExpression = "label:code-review=+2";
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(srInput);
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+ }
+
+ @Test
+ public void cannotCreateTwoSRWithTheSameName() throws Exception {
+ SubmitRequirementInput srInput = new SubmitRequirementInput();
+ srInput.name = "Foo";
+ srInput.allowOverrideInChildProjects = false;
+ srInput.submittabilityExpression = "label:code-review=+2";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(srInput, srInput);
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.projects().name(project.get()).submitRequirements(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("submit requirement \"Foo\" conflicts with existing submit requirement \"Foo\"");
+ }
+
+ @Test
+ public void cannotCreateTwoSrWithConflictingNames() throws Exception {
+ SubmitRequirementInput sr1Input = new SubmitRequirementInput();
+ sr1Input.name = "Foo";
+ sr1Input.allowOverrideInChildProjects = false;
+ sr1Input.submittabilityExpression = "label:code-review=+2";
+
+ SubmitRequirementInput sr2Input = new SubmitRequirementInput();
+ sr2Input.name = "foo";
+ sr2Input.allowOverrideInChildProjects = false;
+ sr2Input.submittabilityExpression = "label:code-review=+2";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(sr1Input, sr2Input);
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.projects().name(project.get()).submitRequirements(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("submit requirement \"foo\" conflicts with existing submit requirement \"Foo\"");
+ }
+
+ @Test
+ public void createSubmitRequirements() throws Exception {
+ SubmitRequirementInput fooInput = new SubmitRequirementInput();
+ fooInput.name = "Foo";
+ fooInput.allowOverrideInChildProjects = false;
+ fooInput.submittabilityExpression = "label:code-review=+2";
+
+ SubmitRequirementInput barInput = new SubmitRequirementInput();
+ barInput.name = "Bar";
+ barInput.allowOverrideInChildProjects = false;
+ barInput.submittabilityExpression = "label:code-review=+1";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(fooInput, barInput);
+
+ gApi.projects().name(allProjects.get()).submitRequirements(input);
+ assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Foo").get()).isNotNull();
+ assertThat(gApi.projects().name(allProjects.get()).submitRequirement("Bar").get()).isNotNull();
+ }
+
+ @Test
+ public void cannotCreateSRWithIncorrectName() throws Exception {
+ SubmitRequirementInput fooInput = new SubmitRequirementInput();
+ fooInput.name = "Foo ";
+ fooInput.allowOverrideInChildProjects = false;
+ fooInput.submittabilityExpression = "label:code-review=+2";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(fooInput, fooInput);
+
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("Name can only consist of alphanumeric characters");
+ }
+
+ @Test
+ public void cannotCreateSRWithoutName() throws Exception {
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.create = ImmutableList.of(new SubmitRequirementInput());
+
+ BadRequestException thrown =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+ assertThat(thrown).hasMessageThat().contains("Empty submit requirement name");
+ }
+
+ @Test
+ public void updateNonExistingSR() throws Exception {
+ SubmitRequirementInput fooInput = new SubmitRequirementInput();
+ fooInput.name = "Foo2";
+ fooInput.allowOverrideInChildProjects = false;
+ fooInput.submittabilityExpression = "label:code-review=+2";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.update = ImmutableMap.of("Foo", fooInput);
+
+ UnprocessableEntityException thrown =
+ assertThrows(
+ UnprocessableEntityException.class,
+ () -> gApi.projects().name(allProjects.get()).submitRequirements(input));
+ assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+ }
+
+ @Test
+ public void updateSR() throws Exception {
+ configSubmitRequirement(project, "Foo");
+ configSubmitRequirement(project, "Bar");
+
+ SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+ fooUpdate.name = "Foo";
+ fooUpdate.description = "new description";
+ fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+ SubmitRequirementInput barUpdate = new SubmitRequirementInput();
+ barUpdate.name = "Baz";
+ barUpdate.submittabilityExpression = "label:code-review=+1";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.update = ImmutableMap.of("Foo", fooUpdate, "Bar", barUpdate);
+
+ gApi.projects().name(project.get()).submitRequirements(input);
+
+ assertThat(gApi.projects().name(project.get()).submitRequirement("Foo").get().description)
+ .isEqualTo(fooUpdate.description);
+ assertThat(
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("Foo")
+ .get()
+ .submittabilityExpression)
+ .isEqualTo(fooUpdate.submittabilityExpression);
+ assertThat(gApi.projects().name(project.get()).submitRequirement("Baz").get()).isNotNull();
+ assertThat(
+ gApi.projects()
+ .name(project.get())
+ .submitRequirement("Baz")
+ .get()
+ .submittabilityExpression)
+ .isEqualTo(barUpdate.submittabilityExpression);
+ assertThrows(
+ ResourceNotFoundException.class,
+ () -> gApi.projects().name(project.get()).submitRequirement("Bar").get());
+ }
+
+ @Test
+ public void deleteAndRecreateSR() throws Exception {
+ configSubmitRequirement(project, "Foo");
+
+ SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+ fooUpdate.name = "Foo";
+ fooUpdate.description = "new description";
+ fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of("Foo");
+ input.create = ImmutableList.of(fooUpdate);
+
+ gApi.projects().name(project.get()).submitRequirements(input);
+
+ SubmitRequirementInfo fooSR =
+ gApi.projects().name(project.get()).submitRequirement("Foo").get();
+ assertThat(fooSR.description).isEqualTo(fooUpdate.description);
+ assertThat(fooSR.submittabilityExpression).isEqualTo(fooUpdate.submittabilityExpression);
+ }
+
+ @Test
+ public void cannotDeleteAndUpdateSR() throws Exception {
+ configSubmitRequirement(project, "Foo");
+
+ SubmitRequirementInput fooUpdate = new SubmitRequirementInput();
+ fooUpdate.name = "Foo";
+ fooUpdate.description = "new description";
+ fooUpdate.submittabilityExpression = "-has:submodule-update";
+
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of("Foo");
+ input.update = ImmutableMap.of("Foo", fooUpdate);
+
+ UnprocessableEntityException thrown =
+ assertThrows(
+ UnprocessableEntityException.class,
+ () -> gApi.projects().name(project.get()).submitRequirements(input));
+ assertThat(thrown).hasMessageThat().contains("Submit requirement Foo not found");
+ }
+
+ @Test
+ public void noOpUpdate() throws Exception {
+ RevCommit refsMetaConfigHead =
+ projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG);
+
+ gApi.projects().name(allProjects.get()).submitRequirements(new BatchSubmitRequirementInput());
+
+ assertThat(projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG))
+ .isEqualTo(refsMetaConfigHead);
+ }
+
+ @Test
+ public void defaultCommitMessage() throws Exception {
+ configSubmitRequirement(allProjects, "Foo");
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.delete = ImmutableList.of("Foo");
+ gApi.projects().name(allProjects.get()).submitRequirements(input);
+ assertThat(
+ projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+ .isEqualTo("Update Submit Requirements");
+ }
+
+ @Test
+ public void withCommitMessage() throws Exception {
+ configSubmitRequirement(allProjects, "Foo");
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.commitMessage = "Batch Update SubmitRequirements";
+ input.delete = ImmutableList.of("Foo");
+ gApi.projects().name(allProjects.get()).submitRequirements(input);
+ assertThat(
+ projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+ .isEqualTo(input.commitMessage);
+ }
+
+ @Test
+ public void commitMessageIsTrimmed() throws Exception {
+ configSubmitRequirement(allProjects, "Foo");
+ BatchSubmitRequirementInput input = new BatchSubmitRequirementInput();
+ input.commitMessage = "Batch Update SubmitRequirements ";
+ input.delete = ImmutableList.of("Foo");
+ gApi.projects().name(allProjects.get()).submitRequirements(input);
+ assertThat(
+ projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
+ .isEqualTo("Batch Update SubmitRequirements");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index b4731db..4760eb5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -23,6 +23,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
+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.entities.LabelFunction;
@@ -33,6 +34,7 @@
import com.google.gerrit.extensions.common.LabelDefinitionInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.inject.Inject;
@@ -671,4 +673,16 @@
projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
.isEqualTo("Set NoOp function");
}
+
+ @Test
+ @GerritConfig(name = "gerrit.requireChangeForConfigUpdate", value = "true")
+ public void requireChangeForConfigUpdate_setLabelRejected() {
+ LabelDefinitionInput input = new LabelDefinitionInput();
+ input.function = LabelFunction.NO_OP.getFunctionName();
+ MethodNotAllowedException e =
+ assertThrows(
+ MethodNotAllowedException.class,
+ () -> gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).update(input));
+ assertThat(e.getMessage()).contains("Updating project config without review is disabled");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java
new file mode 100644
index 0000000..3d762f8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountLimitsIT.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import org.junit.Test;
+
+public class AccountLimitsIT extends AbstractDaemonTest {
+
+ @Inject private AccountLimits.Factory accountLimitsFactory;
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private Provider<CurrentUser> currentUserProvider;
+
+ @Test
+ public void shouldIgnoreQueryLimitForInternalUser() throws Exception {
+ requestScopeOperations.setApiUserInternal();
+ AccountLimits objectUnderTest = accountLimitsFactory.create(currentUserProvider.get());
+
+ assertThat(objectUnderTest.getQueryLimit()).isEqualTo(Integer.MAX_VALUE);
+ }
+
+ @Test
+ public void shouldDefaultValueForReqularUser() {
+ AccountLimits objectUnderTest = accountLimitsFactory.create(currentUserProvider.get());
+
+ assertThat(objectUnderTest.getQueryLimit()).isEqualTo(GlobalCapability.DEFAULT_MAX_QUERY_LIMIT);
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 2476f00..a696354 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -433,7 +433,7 @@
Optional<AccountState> result =
accountsUpdateProvider
.get()
- .update("Force set preferred email", id, (s, u) -> u.setPreferredEmail(email));
+ .update("Force set preferred email", id, u -> u.setPreferredEmail(email));
assertThat(result.map(a -> a.account().preferredEmail())).hasValue(email);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index a6cdfa1..044beba 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.server.change;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
@@ -757,7 +758,7 @@
ReviewInput reviewInput = new ReviewInput();
reviewInput.drafts = DraftHandling.KEEP;
reviewInput.message = "foo";
- CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "comment", false);
+ CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 0, "text", true);
// Replace the existing draft.
comment.id = draftInfo.id;
reviewInput.comments = new HashMap<>();
@@ -767,6 +768,15 @@
// DraftHandling.KEEP is ignored on publishing a comment.
drafts = getDraftComments(changeId, revId);
assertThat(drafts).isEmpty();
+
+ // Verify the comment
+ ImmutableList<CommentInfo> comments =
+ gApi.changes().id(changeId).commentsRequest().get().values().stream()
+ .flatMap(l -> l.stream())
+ .collect(toImmutableList());
+ assertThat(comments).hasSize(1);
+ assertThat(comments.get(0).message).isEqualTo("text");
+ assertThat(comments.get(0).unresolved).isEqualTo(true);
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 7eec5ea..cd1ae5af 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -146,15 +146,11 @@
testRepo.reset(c1_1);
pushHead(testRepo, "refs/for/master", false);
PatchSet.Id ps1_1 = getPatchSetId(c1_1);
- String oldETag = changes.parse(ps1_1.changeId()).getETag();
testRepo.reset(c2_1);
pushHead(testRepo, "refs/for/master", false);
PatchSet.Id ps2_1 = getPatchSetId(c2_1);
- // Push of change 2 should not affect groups (or anything else) of change 1.
- assertThat(changes.parse(ps1_1.changeId()).getETag()).isEqualTo(oldETag);
-
for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
index 5a4f073..4ee5967 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/SubmittedTogetherIT.java
@@ -16,7 +16,10 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.extensions.api.changes.SubmittedTogetherOption.NON_VISIBLE_CHANGES;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GitUtil;
@@ -25,6 +28,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.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
import com.google.gerrit.extensions.client.ChangeStatus;
@@ -32,6 +37,7 @@
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Inject;
import java.util.EnumSet;
@@ -336,6 +342,41 @@
assertSubmittedTogether(id2, id2, id1);
}
+ @Test
+ public void permissionToSubmitForSomeChangesInTopic() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.SUBMIT).ref("refs/heads/testbranch").group(REGISTERED_USERS))
+ .update();
+
+ createBranch(BranchNameKey.create(getProject(), "testbranch"));
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ // Create two independent commits and push.
+ RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
+ String id1 = getChangeId(c1_1);
+ pushHead(testRepo, "refs/for/master%topic=" + name("connectingTopic"), false);
+
+ testRepo.reset(initialHead);
+ RevCommit c2_1 = commitBuilder().add("b.txt", "2").message("subject: 2").create();
+ String id2 = getChangeId(c2_1);
+ pushHead(testRepo, "refs/for/testbranch%topic=" + name("connectingTopic"), false);
+
+ approve(id1);
+ approve(id2);
+ if (isSubmitWholeTopicEnabled()) {
+ ResourceConflictException e =
+ assertThrows(ResourceConflictException.class, () -> submit(id1));
+ assertThat(e.getMessage())
+ .contains(
+ String.format(
+ "Insufficient permission to submit change %d",
+ gApi.changes().id(id2).get()._number));
+ } else {
+ submit(id1);
+ }
+ }
+
private String getChangeId(RevCommit c) throws Exception {
return GitUtil.getChangeId(testRepo, c).get();
}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 2ee5360..d88db52 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -2369,6 +2369,12 @@
@Test
public void revertChangeByOwner() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, sc.owner);
@@ -2394,6 +2400,12 @@
@Test
public void revertChangeByOwnerCcingSelf() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, sc.owner, CC_ON_OWN_COMMENTS);
@@ -2420,6 +2432,12 @@
@Test
public void revertChangeByOther() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, other);
@@ -2446,6 +2464,12 @@
@Test
public void revertChangeByOtherCcingSelf() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REVERT).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
StagedChange sc = stageChange();
revert(sc, other, CC_ON_OWN_COMMENTS);
diff --git a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
index 50fa3b2..3041744 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/OnStoreSubmitRequirementResultModifierIT.java
@@ -33,6 +33,7 @@
import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.change.TestSubmitInput;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.project.OnStoreSubmitRequirementResultModifier;
@@ -64,6 +65,7 @@
@Before
public void setUp() throws Exception {
+ removeDefaultSubmitRequirements();
TEST_ON_STORE_SUBMIT_REQUIREMENT_RESULT_MODIFIER.hide(false);
configSubmitRequirement(
project,
@@ -242,4 +244,8 @@
.count())
.isEqualTo(1);
}
+
+ private void removeDefaultSubmitRequirements() throws RestApiException {
+ gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index 0c24b14..b5a3b66 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -75,6 +75,7 @@
@Before
public void setUp() throws Exception {
+ removeDefaultSubmitRequirements();
PushOneCommit.Result pushResult =
createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
changeData = pushResult.getChange();
@@ -976,6 +977,10 @@
.build();
}
+ private void removeDefaultSubmitRequirements() throws RestApiException {
+ gApi.projects().name(allProjects.get()).submitRequirement("No-Unresolved-Comments").delete();
+ }
+
/** Submit requirement predicate that always throws an error on match. */
static class ThrowingSubmitRequirementPredicate extends SubmitRequirementPredicate
implements ChangeIsOperandFactory {
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
index 809cee9..9e9e1c2 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -34,109 +34,147 @@
public class TaskListenerIT extends AbstractDaemonTest {
/**
* Use a LatchedMethod in a method to allow another thread to await the method's call. Once
- * called, the Latch.call() method will block until another thread calls its LatchedMethods's
- * complete() method.
+ * called, the call() method will block until another thread calls the complete() method or until
+ * a preset timeout is reached.
*/
- private static class LatchedMethod {
- private static final int AWAIT_TIMEOUT = 20;
- private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
-
- /** API class meant be used by the class whose method is being latched */
- private class Latch {
- /** Ensure that the latched method calls this on entry */
- public void call() {
- called.countDown();
- await(complete);
- }
- }
-
- public Latch latch = new Latch();
+ public static class LatchedMethod<T> {
+ private volatile T value;
private final CountDownLatch called = new CountDownLatch(1);
private final CountDownLatch complete = new CountDownLatch(1);
- /** Assert that the Latch's call() method has not yet been called */
+ /** Assert that the call() method has not yet been called */
public void assertUncalled() {
assertThat(called.getCount()).isEqualTo(1);
}
/**
- * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
- * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+ * Assert that a timeout does not occur while awaiting the call() to be called. Fails if the
+ * waiting time elapses before the call() method is called, otherwise passes.
*/
- public void assertAwait() {
+ public void assertCalledEventually() {
assertThat(await(called)).isEqualTo(true);
}
- /** Unblock the Latch's call() method so that it can complete */
+ public T call() {
+ called.countDown();
+ await(complete);
+ return getValue();
+ }
+
+ public T call(T val) {
+ set(val);
+ return call();
+ }
+
+ public T callAndAwaitComplete() throws InterruptedException {
+ called.countDown();
+ complete.await();
+ return getValue();
+ }
+
public void complete() {
complete.countDown();
}
- @CanIgnoreReturnValue
- private static boolean await(CountDownLatch latch) {
- try {
- return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
- } catch (InterruptedException e) {
- return false;
- }
+ public void set(T val) {
+ value = val;
+ }
+
+ public void complete(T val) {
+ set(val);
+ complete();
+ }
+
+ public void assertCalledEventuallyThenComplete(T val) {
+ assertCalledEventually();
+ complete(val);
+ }
+
+ protected T getValue() {
+ return value;
}
}
- private static class LatchedRunnable implements Runnable {
- public LatchedMethod run = new LatchedMethod();
+ public static class LatchedRunnable implements Runnable {
+ public LatchedMethod<?> run = new LatchedMethod<>();
+ public String name = "latched-runnable";
+
+ public LatchedRunnable(String name) {
+ this.name = name;
+ }
+
+ public LatchedRunnable() {}
@Override
public void run() {
- run.latch.call();
+ run.call();
+ }
+
+ @Override
+ public String toString() {
+ return name;
}
}
- private static class ForwardingListener implements TaskListener {
- public volatile TaskListener delegate;
+ public static class ForwardingListener<T extends TaskListener> implements TaskListener {
+ public volatile T delegate;
public volatile Task<?> task;
- public void resetDelegate(TaskListener listener) {
+ public void resetDelegate(T listener) {
delegate = listener;
task = null;
}
@Override
public void onStart(Task<?> task) {
- if (delegate != null) {
- if (this.task == null || this.task == task) {
- this.task = task;
- delegate.onStart(task);
- }
+ if (isDelegatable(task)) {
+ delegate.onStart(task);
}
}
@Override
public void onStop(Task<?> task) {
+ if (isDelegatable(task)) {
+ delegate.onStop(task);
+ }
+ }
+
+ protected boolean isDelegatable(Task<?> task) {
if (delegate != null) {
if (this.task == task) {
- delegate.onStop(task);
+ return true;
+ }
+ if (this.task == null) {
+ this.task = task;
+ return true;
}
}
+ return false;
}
}
- private static class LatchedListener implements TaskListener {
- public LatchedMethod onStart = new LatchedMethod();
- public LatchedMethod onStop = new LatchedMethod();
+ public static class LatchedListener implements TaskListener {
+ public LatchedMethod<?> onStart = new LatchedMethod<>();
+ public LatchedMethod<?> onStop = new LatchedMethod<>();
@Override
public void onStart(Task<?> task) {
- onStart.latch.call();
+ onStart.call();
}
@Override
public void onStop(Task<?> task) {
- onStop.latch.call();
+ onStop.call();
}
}
- private static ForwardingListener forwarder;
+ private static final int AWAIT_TIMEOUT = 20;
+ private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+ private static final long MS_EMPTY_QUEUE =
+ TimeUnit.MILLISECONDS.convert(50, TimeUnit.MILLISECONDS);
+
+ private static ForwardingListener<TaskListener> forwarder;
@Inject private WorkQueue workQueue;
private ScheduledExecutorService executor;
@@ -149,9 +187,9 @@
return new AbstractModule() {
@Override
public void configure() {
- // Forwarder.delegate is empty on start to protect test listener from non test tasks
- // (such as the "Log File Manager") interference
- forwarder = new ForwardingListener(); // Only gets bound once for all tests
+ // Forwarder.delegate is empty on start to protect test listener from non-test tasks (such
+ // as the "Log File Manager") interference
+ forwarder = new ForwardingListener<>(); // Only gets bound once for all tests
bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
}
};
@@ -184,23 +222,23 @@
int size = assertQueueBlockedOnExecution(runnable);
// onStartThenRunThenOnStopAreCalled -> onStart...Called
- listener.onStart.assertAwait();
+ listener.onStart.assertCalledEventually();
assertQueueSize(size);
runnable.run.assertUncalled();
listener.onStop.assertUncalled();
listener.onStart.complete();
// onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
- runnable.run.assertAwait();
+ runnable.run.assertCalledEventually();
listener.onStop.assertUncalled();
runnable.run.complete();
// onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
- listener.onStop.assertAwait();
+ listener.onStop.assertCalledEventually();
assertQueueSize(size);
listener.onStop.complete();
- assertAwaitQueueSize(--size);
+ assertTaskCountIsEventually(--size);
}
@Test
@@ -208,7 +246,7 @@
int size = assertQueueBlockedOnExecution(runnable);
// firstBlocksSecond -> first...
- listener.onStart.assertAwait();
+ listener.onStart.assertCalledEventually();
assertQueueSize(size);
LatchedRunnable runnable2 = new LatchedRunnable();
@@ -219,35 +257,35 @@
assertQueueSize(size); // waiting on first
listener.onStart.complete();
- runnable.run.assertAwait();
+ runnable.run.assertCalledEventually();
assertQueueSize(size); // waiting on first
runnable2.run.assertUncalled();
runnable.run.complete();
- listener.onStop.assertAwait();
+ listener.onStop.assertCalledEventually();
assertQueueSize(size); // waiting on first
runnable2.run.assertUncalled();
listener.onStop.complete();
- runnable2.run.assertAwait();
+ runnable2.run.assertCalledEventually();
assertQueueSize(--size);
runnable2.run.complete();
- assertAwaitQueueSize(--size);
+ assertTaskCountIsEventually(--size);
}
@Test
public void states() throws Exception {
executor.execute(runnable);
- listener.onStart.assertAwait();
+ listener.onStart.assertCalledEventually();
assertStateIs(Task.State.STARTING);
listener.onStart.complete();
- runnable.run.assertAwait();
+ runnable.run.assertCalledEventually();
assertStateIs(Task.State.RUNNING);
runnable.run.complete();
- listener.onStop.assertAwait();
+ listener.onStop.assertCalledEventually();
assertStateIs(Task.State.STOPPING);
listener.onStop.complete();
@@ -255,8 +293,40 @@
assertStateIs(Task.State.DONE);
}
+ /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+ public static void assertTaskCountIsEventually(WorkQueue workQueue, int count)
+ throws InterruptedException {
+ long ms = 0;
+ while (count != workQueue.getTasks().size()) {
+ assertThat(ms++).isLessThan(MS_EMPTY_QUEUE);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+
+ public static void assertQueueSize(WorkQueue workQueue, int size) {
+ assertThat(workQueue.getTasks().size()).isEqualTo(size);
+ }
+
+ @CanIgnoreReturnValue
+ public static boolean await(CountDownLatch latch) {
+ try {
+ return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+
+ public void assertTaskCountIsEventually(int count) throws InterruptedException {
+ TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+ }
+
+ public static void assertStateIs(Task<?> task, Task.State state) {
+ assertThat(task).isNotNull();
+ assertThat(task.getState()).isEqualTo(state);
+ }
+
private void assertStateIs(Task.State state) {
- assertThat(forwarder.task.getState()).isEqualTo(state);
+ assertStateIs(forwarder.task, state);
}
private int assertQueueBlockedOnExecution(Runnable runnable) {
@@ -267,19 +337,10 @@
}
private void assertQueueSize(int size) {
- assertThat(workQueue.getTasks().size()).isEqualTo(size);
+ assertQueueSize(workQueue, size);
}
private void assertAwaitQueueIsEmpty() throws InterruptedException {
- assertAwaitQueueSize(0);
- }
-
- /** Fails if the waiting time elapses before the count is reached, otherwise passes */
- private void assertAwaitQueueSize(int size) throws InterruptedException {
- long i = 0;
- do {
- TimeUnit.NANOSECONDS.sleep(100);
- assertThat(i++).isLessThan(1000);
- } while (size != workQueue.getTasks().size());
+ assertTaskCountIsEventually(0);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
new file mode 100644
index 0000000..3b82ebe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskParkerIT.java
@@ -0,0 +1,548 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedMethod;
+import com.google.gerrit.acceptance.server.util.TaskListenerIT.LatchedRunnable;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.Task.State;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.gerrit.server.git.WorkQueue.TaskParker;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskParkerIT extends AbstractDaemonTest {
+ private static class ForwardingParker extends TaskListenerIT.ForwardingListener<LatchedParker>
+ implements TaskParker {
+ public AtomicInteger isReadyToStartCounter = new AtomicInteger(0);
+ public AtomicInteger onNotReadyToStartCounter = new AtomicInteger(0);
+
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ isReadyToStartCounter.incrementAndGet();
+ if (isDelegatable(task)) {
+ return delegate.isReadyToStart(task);
+ }
+ return true;
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {
+ onNotReadyToStartCounter.incrementAndGet();
+ if (isDelegatable(task)) {
+ delegate.onNotReadyToStart(task);
+ }
+ }
+
+ public void resetCounters() {
+ isReadyToStartCounter.getAndSet(0);
+ onNotReadyToStartCounter.getAndSet(0);
+ }
+ }
+
+ public static class LatchedParker extends TaskListenerIT.LatchedListener implements TaskParker {
+ private static final String EXPENSIVE_TASK = "expensive-task";
+ private final Semaphore expensiveTaskSemaphore = new Semaphore(1, true);
+ public volatile LatchedMethod<Boolean> isReadyToStart = new LatchedMethod<>();
+ public volatile LatchedMethod<?> onNotReadyToStart = new LatchedMethod<>();
+
+ @Override
+ public boolean isReadyToStart(Task<?> task) {
+ Boolean rtn = isReadyToStart.call();
+ if (EXPENSIVE_TASK.equals(task.toString()) && !expensiveTaskSemaphore.tryAcquire()) {
+ return false;
+ }
+ isReadyToStart = new LatchedMethod<>();
+ if (rtn != null) {
+ return rtn;
+ }
+ return true;
+ }
+
+ @Override
+ public void onNotReadyToStart(Task<?> task) {
+ onNotReadyToStart.call();
+ onNotReadyToStart = new LatchedMethod<>();
+ }
+
+ @Override
+ public void onStop(Task<?> task) {
+ if (EXPENSIVE_TASK.equals(task.toString())) {
+ expensiveTaskSemaphore.release();
+ }
+ super.onStop(task);
+ }
+ }
+
+ public static class LatchedForeverRunnable extends LatchedRunnable {
+ public LatchedForeverRunnable(String name) {
+ super(name);
+ }
+
+ @Override
+ public void run() {
+ try {
+ run.callAndAwaitComplete();
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private static ForwardingParker forwarder;
+ private static ForwardingParker forwarder2;
+ public static final long TIMEOUT = TimeUnit.MILLISECONDS.convert(200, TimeUnit.MILLISECONDS);
+
+ private final LatchedParker parker = new LatchedParker();
+
+ @Inject private WorkQueue workQueue;
+ private ScheduledExecutorService executor;
+
+ @Before
+ public void setupExecutorAndForwarder() throws InterruptedException {
+ executor = workQueue.createQueue(1, "TaskParkers");
+ // "Log File Manager"s are likely running and will interfere with tests
+ while (0 != workQueue.getTasks().size()) {
+ for (Task<?> t : workQueue.getTasks()) {
+ t.cancel(true);
+ }
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ forwarder.delegate = parker;
+ forwarder.task = null;
+ forwarder.resetCounters();
+ forwarder2.delegate = null; // load only if test needs it
+ forwarder2.task = null;
+ forwarder2.resetCounters();
+ }
+
+ @After
+ public void shutdownExecutor() throws InterruptedException {
+ executor.shutdownNow();
+ executor.awaitTermination(1, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public Module createModule() {
+ return new AbstractModule() {
+ @Override
+ public void configure() {
+ // Forwarder.delegate is empty on start to protect test parker from non-test tasks (such as
+ // the "Log File Manager") interference
+ forwarder = new ForwardingParker(); // Only gets bound once for all tests
+ bind(TaskListener.class).annotatedWith(Exports.named("parker")).toInstance(forwarder);
+ forwarder2 = new ForwardingParker();
+ bind(TaskListener.class).annotatedWith(Exports.named("parker2")).toInstance(forwarder2);
+ }
+ };
+ }
+
+ @Test
+ public void noParkFlow() throws Exception {
+ LatchedRunnable runnable = new LatchedRunnable();
+
+ assertTaskCountIs(0);
+ assertThat(forwarder.task).isEqualTo(null);
+ parker.isReadyToStart.assertUncalled();
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable.run.assertUncalled();
+ parker.onStop.assertUncalled();
+ assertCorePoolSizeIs(1);
+
+ executor.execute(runnable);
+ assertTaskCountIs(1);
+
+ parker.isReadyToStart.assertCalledEventually();
+ assertTaskCountIs(1);
+ assertStateIs(State.READY);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.isReadyToStart.complete();
+ parker.onStart.assertCalledEventually();
+ assertStateIs(State.STARTING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+ runnable.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.onStart.complete();
+ runnable.run.assertCalledEventually();
+ assertStateIs(State.RUNNING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ runnable.run.complete();
+ parker.onStop.assertCalledEventually();
+ assertStateIs(State.STOPPING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+
+ parker.onStop.complete();
+ assertTaskCountIsEventually(0);
+ assertStateIs(State.DONE);
+ parker.onNotReadyToStart.assertUncalled();
+ assertCorePoolSizeIs(1);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ }
+
+ @Test
+ public void parkFirstSoSecondRuns() throws Exception {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ LatchedRunnable runnable2 = new LatchedRunnable();
+ assertCorePoolSizeIs(1);
+
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventually();
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ executor.execute(runnable2);
+ assertTaskCountIs(2);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ // park runnable1
+ parker.isReadyToStart.complete(false);
+ assertCorePoolSizeIsEventually(2);
+ assertStateIs(task1, State.PARKED);
+
+ runnable2.run.assertCalledEventually();
+ assertTaskCountIs(2);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+ assertStateIs(task1, State.PARKED);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ runnable2.run.complete();
+
+ parker.isReadyToStart.assertCalledEventually();
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.isReadyToStart.complete(true);
+ parker.onStart.assertCalledEventually();
+ assertStateIs(task1, State.STARTING);
+ assertTaskCountIsEventually(1);
+ parker.onNotReadyToStart.assertUncalled();
+ runnable1.run.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ parker.onStart.complete();
+ runnable1.run.assertCalledEventually();
+ assertStateIs(task1, State.RUNNING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+ parker.onStop.assertUncalled();
+
+ runnable1.run.complete();
+ parker.onStop.assertCalledEventually();
+ assertStateIs(task1, State.STOPPING);
+ assertTaskCountIs(1);
+ parker.onNotReadyToStart.assertUncalled();
+
+ parker.onStop.complete();
+ assertCorePoolSizeIsEventually(1);
+ assertTaskCountIsEventually(0);
+ assertStateIs(task1, State.DONE);
+ parker.onNotReadyToStart.assertUncalled();
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ }
+
+ @Test
+ public void unParkPriorityOrder() throws Exception {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ LatchedRunnable runnable2 = new LatchedRunnable();
+ LatchedRunnable runnable3 = new LatchedRunnable();
+
+ // park runnable1
+ assertCorePoolSizeIs(1);
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertStateIsEventually(task1, State.PARKED);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ assertTaskCountIsEventually(1);
+ assertCorePoolSizeIsEventually(2);
+
+ // park runnable2
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable2);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ Task<?> task2 = forwarder.task; // task for runnable2
+ assertStateIsEventually(task2, State.PARKED);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ assertTaskCountIsEventually(2);
+ assertCorePoolSizeIsEventually(3);
+
+ // set parker to ready and execute runnable3
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable3);
+
+ // assert runnable3 finishes executing and runnable1, runnable2 stay parked
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ parker.isReadyToStart.assertCalledEventually();
+ Task<?> task3 = forwarder.task; // task for runnable3
+ assertStateIs(task3, State.READY);
+ parker.isReadyToStart.complete(true);
+ parker.onStart.assertCalledEventually();
+ assertStateIs(task3, State.STARTING);
+ parker.onStart.complete();
+ runnable3.run.assertCalledEventually();
+ assertStateIs(task3, State.RUNNING);
+ runnable1.run.assertUncalled();
+ runnable2.run.assertUncalled();
+ runnable3.run.complete();
+ parker.onStop.assertCalledEventually();
+ assertStateIs(task3, State.STOPPING);
+ parker.onStop.complete();
+ assertTaskCountIsEventually(2);
+ assertStateIs(task3, State.DONE);
+
+ // assert runnable1 finishes executing and runnable2 stays parked
+ runnable1.run.assertCalledEventually();
+ assertStateIs(task1, State.RUNNING);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 4);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ runnable2.run.assertUncalled();
+ assertStateIs(task2, State.PARKED);
+ runnable1.run.complete();
+ assertCorePoolSizeIsEventually(2);
+ assertTaskCountIsEventually(1);
+ assertStateIs(task1, State.DONE);
+
+ // assert runnable2 finishes executing
+ runnable2.run.assertCalledEventually();
+ assertStateIs(task2, State.RUNNING);
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 5);
+ assertCounter(forwarder.onNotReadyToStartCounter, 0);
+ runnable2.run.complete();
+ assertCorePoolSizeIsEventually(1);
+ assertTaskCountIsEventually(0);
+ assertStateIs(task2, State.DONE);
+ }
+
+ @Test
+ public void notReadyToStartIsCalledOnReadyListenerWhenAnotherListenerIsNotReady()
+ throws InterruptedException {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ LatchedRunnable runnable2 = new LatchedRunnable();
+
+ LatchedParker parker2 = new LatchedParker();
+ forwarder2.delegate = parker2;
+
+ // park runnable1 (parker1 is ready and parker2 is not ready)
+ assertCorePoolSizeIs(1);
+ executor.execute(runnable1);
+ parker2.isReadyToStart.complete(false);
+
+ assertTaskCountIsEventually(1);
+ assertCorePoolSizeIsEventually(2);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder2.isReadyToStartCounter, 1);
+ assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertStateIsEventually(task1, State.PARKED);
+
+ // set parker2 to ready and execute runnable-2
+ parker2.isReadyToStart.set(true);
+ forwarder.resetDelegate(parker);
+ forwarder2.resetDelegate(parker2);
+ executor.execute(runnable2);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 2);
+ assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder2.isReadyToStartCounter, 2);
+ assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+ Task<?> task2 = forwarder.task; // task for runnable2
+
+ assertCorePoolSizeIsEventually(1);
+ runnable2.run.assertCalledEventually();
+ runnable2.run.complete();
+ assertTaskCountIsEventually(1);
+ assertStateIs(task2, State.DONE);
+
+ assertCounterIsEventually(forwarder.isReadyToStartCounter, 3);
+ assertCounterIsEventually(forwarder.onNotReadyToStartCounter, 1);
+ assertCounterIsEventually(forwarder2.isReadyToStartCounter, 3);
+ assertCounter(forwarder2.onNotReadyToStartCounter, 0);
+
+ runnable1.run.assertCalledEventually();
+ runnable1.run.complete();
+ assertTaskCountIsEventually(0);
+ assertStateIs(task1, State.DONE);
+ }
+
+ @Test
+ public void runFirstParkSecondUsingTaskName() throws InterruptedException {
+ LatchedForeverRunnable runnable1 = new LatchedForeverRunnable("expensive-task");
+ LatchedRunnable runnable2 = new LatchedRunnable("expensive-task");
+ LatchedParker parker = new LatchedParker();
+ executor = workQueue.createQueue(2, "TaskParkers");
+ assertCorePoolSizeIs(2);
+
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable1);
+ parker.isReadyToStart.complete();
+ parker.onStart.complete();
+ runnable1.run.assertCalledEventually();
+ assertTaskCountIsEventually(1);
+ assertCorePoolSizeIs(2);
+ Task<?> task1 = forwarder.task; // task for runnable1
+ assertStateIs(task1, State.RUNNING);
+
+ forwarder.resetDelegate(parker);
+ executor.execute(runnable2);
+ parker.isReadyToStart.assertCalledEventually();
+ assertCorePoolSizeIsEventually(3);
+ Task<?> task2 = forwarder.task; // task for runnable2
+ assertStateIs(task2, State.PARKED);
+
+ forwarder.resetDelegate(parker);
+ runnable1.run.complete(); // unblock runnable1
+
+ assertCorePoolSizeIsEventually(2);
+ assertTaskCountIsEventually(0); // assert both tasks finish
+ }
+
+ @Test
+ public void interruptingParkedTaskDecrementsCorePoolSize() throws InterruptedException {
+ String taskName = "to-be-parked";
+ LatchedRunnable runnable1 = new LatchedRunnable(taskName);
+ assertCorePoolSizeIs(1);
+
+ // park runnable1
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ assertCorePoolSizeIsEventually(2);
+ assertStateIsEventually(forwarder.task, State.PARKED);
+
+ // interrupt the thread with parked task
+ for (Thread t : Thread.getAllStackTraces().keySet()) {
+ if (t.getName().contains(taskName)) {
+ t.interrupt();
+ break;
+ }
+ }
+
+ assertCorePoolSizeIsEventually(1);
+ }
+
+ @Test
+ public void canCancelParkedTask() throws InterruptedException {
+ LatchedRunnable runnable1 = new LatchedRunnable();
+ assertCorePoolSizeIs(1);
+
+ // park runnable1
+ executor.execute(runnable1);
+ parker.isReadyToStart.assertCalledEventuallyThenComplete(false);
+ assertCorePoolSizeIsEventually(2);
+ Task<?> task = forwarder.task;
+ assertStateIsEventually(task, State.PARKED);
+
+ // cancel parked task
+ task.cancel(true);
+
+ // assert core pool size is reduced and task is cancelled
+ assertCorePoolSizeIsEventually(1);
+ assertTaskCountIsEventually(0);
+ assertStateIs(State.CANCELLED);
+ }
+
+ private void assertTaskCountIs(int size) {
+ TaskListenerIT.assertQueueSize(workQueue, size);
+ }
+
+ private void assertTaskCountIsEventually(int count) throws InterruptedException {
+ TaskListenerIT.assertTaskCountIsEventually(workQueue, count);
+ }
+
+ private void assertCorePoolSizeIs(int count) {
+ assertThat(count).isEqualTo(((ScheduledThreadPoolExecutor) executor).getCorePoolSize());
+ }
+
+ private void assertCorePoolSizeIsEventually(int count) throws InterruptedException {
+ ScheduledThreadPoolExecutor scheduledThreadPoolExecutor =
+ (ScheduledThreadPoolExecutor) executor;
+ long ms = 0;
+ while (count != scheduledThreadPoolExecutor.getCorePoolSize()) {
+ assertThat(ms++).isLessThan(TIMEOUT);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+
+ private void assertCounter(AtomicInteger counter, int desiredCount) {
+ assertThat(counter.get()).isEqualTo(desiredCount);
+ }
+
+ private void assertCounterIsEventually(AtomicInteger counter, int desiredCount)
+ throws InterruptedException {
+ long ms = 0;
+ while (desiredCount != counter.get()) {
+ assertThat(ms++).isLessThan(TIMEOUT);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+
+ private void assertStateIs(Task.State state) {
+ TaskListenerIT.assertStateIs(forwarder.task, state);
+ }
+
+ private void assertStateIs(Task<?> task, Task.State state) {
+ TaskListenerIT.assertStateIs(task, state);
+ }
+
+ private void assertStateIsEventually(Task<?> task, Task.State state) throws InterruptedException {
+ long ms = 0;
+ assertThat(task).isNotNull();
+ while (!task.getState().equals(state)) {
+ assertThat(ms++).isLessThan(TIMEOUT);
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index c4497dc..8153a5d 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -124,7 +124,7 @@
private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
@Override
- public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
+ public void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata) {
logEntries.add(PerformanceLogEntry.create(operation, endTime, metadata));
}
diff --git a/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
index 6c8c85a..47d0d9a 100644
--- a/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ApplyPatchInputProtoConverterTest.java
@@ -36,10 +36,14 @@
public void allValuesConvertedToProto() {
ApplyPatchInput applyPatchInput = new ApplyPatchInput();
applyPatchInput.patch = "test-patch";
+ applyPatchInput.allowConflicts = true;
Entities.ApplyPatchInput proto = applyPatchInputProtoConverter.toProto(applyPatchInput);
Entities.ApplyPatchInput expectedProto =
- Entities.ApplyPatchInput.newBuilder().setPatch("test-patch").build();
+ Entities.ApplyPatchInput.newBuilder()
+ .setPatch("test-patch")
+ .setAllowConflicts(true)
+ .build();
assertThat(proto).isEqualTo(expectedProto);
}
@@ -47,18 +51,24 @@
public void allValuesConvertedToProtoAndBackAgain() {
ApplyPatchInput applyPatchInput = new ApplyPatchInput();
applyPatchInput.patch = "test-patch";
+ applyPatchInput.allowConflicts = true;
ApplyPatchInput convertedApplyPatchInput =
applyPatchInputProtoConverter.fromProto(
applyPatchInputProtoConverter.toProto(applyPatchInput));
assertThat(Objects.equals(applyPatchInput.patch, convertedApplyPatchInput.patch)).isTrue();
+ assertThat(applyPatchInput.allowConflicts).isEqualTo(convertedApplyPatchInput.allowConflicts);
}
/** See {@link SerializedClassSubject} for background and what to do if this test fails. */
@Test
public void methodsExistAsExpected() {
assertThatSerializedClass(ApplyPatchInput.class)
- .hasFields(ImmutableMap.<String, Type>builder().put("patch", String.class).build());
+ .hasFields(
+ ImmutableMap.<String, Type>builder()
+ .put("patch", String.class)
+ .put("allowConflicts", Boolean.class)
+ .build());
}
}
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 0ca9478..804397b 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -4,16 +4,19 @@
name = "proto_converter_tests",
srcs = glob(["*.java"]),
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/proto/testing",
"//java/com/google/gerrit/server",
"//lib:guava",
+ "//lib:guava-testlib",
"//lib:jgit",
"//lib:protobuf",
"//lib/guice",
"//lib/truth",
"//lib/truth:truth-proto-extension",
"//proto:entities_java_proto",
+ "@commons-lang3//jar",
],
)
diff --git a/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
new file mode 100644
index 0000000..eb69d53
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/converter/SafeProtoConverterTest.java
@@ -0,0 +1,286 @@
+package com.google.gerrit.entities.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Primitives;
+import com.google.common.reflect.ClassPath;
+import com.google.common.reflect.ClassPath.ClassInfo;
+import com.google.common.testing.ArbitraryInstances;
+import com.google.gerrit.common.ConvertibleToProto;
+import com.google.gerrit.common.Nullable;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Message;
+import com.google.protobuf.MessageLite;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ SafeProtoConverterTest.ListSafeProtoConverterTest.class, //
+ SafeProtoConverterTest.PerTypeSafeProtoConverterTest.class, //
+})
+public class SafeProtoConverterTest {
+ public static class ListSafeProtoConverterTest {
+ @Test
+ public void areAllConvertersEnums() throws Exception {
+ Stream<? extends Class<?>> safeConverters =
+ ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+ .filter(type -> type.getPackageName().contains("gerrit"))
+ .map(ClassInfo::load)
+ .filter(SafeProtoConverter.class::isAssignableFrom)
+ .filter(clz -> !SafeProtoConverter.class.equals(clz));
+ // Safe converters must be enums. See also `isConverterAValidEnum` test below.
+ assertThat(safeConverters.allMatch(Class::isEnum)).isTrue();
+ }
+ }
+
+ @RunWith(Parameterized.class)
+ public static class PerTypeSafeProtoConverterTest {
+ @Parameter(0)
+ public SafeProtoConverter<Message, Object> converter;
+
+ @Parameter(1)
+ public String converterName;
+
+ @Parameters(name = "PerTypeSafeProtoConverterTest${1}")
+ public static ImmutableList<Object[]> listSafeConverters() throws Exception {
+ return ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+ .filter(type -> type.getPackageName().contains("gerrit"))
+ .map(ClassInfo::load)
+ .filter(SafeProtoConverter.class::isAssignableFrom)
+ .filter(clz -> !SafeProtoConverter.class.equals(clz))
+ .filter(Class::isEnum)
+ .map(clz -> (SafeProtoConverter<Message, Object>) clz.getEnumConstants()[0])
+ .map(clz -> new Object[] {clz, clz.getClass().getSimpleName()})
+ .collect(toImmutableList());
+ }
+
+ /**
+ * For rising visibility, all Java Entity classes which have a {@link SafeProtoConverter}, must
+ * be annotated with {@link ConvertibleToProto}.
+ */
+ @Test
+ public void isJavaClassMarkedAsConvertibleToProto() {
+ assertThat(converter.getEntityClass().getDeclaredAnnotation(ConvertibleToProto.class))
+ .isNotNull();
+ }
+
+ /**
+ * All {@link SafeProtoConverter} implementations must be enums with a single instance. Please
+ * prefer descriptive enum and instance names, such as {@code
+ * MyTypeConverter::MY_TYPE_CONVERTER}.
+ */
+ @Test
+ public void isConverterAValidEnum() {
+ assertThat(converter.getClass().isEnum()).isTrue();
+ assertThat(converter.getClass().getEnumConstants().length).isEqualTo(1);
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to a Java class that has a {@link
+ * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+ * update the corresponding proto accordingly.
+ */
+ @Test
+ public void javaDefaultsKeptOnDoubleConversion() {
+ Object orig;
+ try {
+ orig = buildObjectWithFullFieldsOrThrow(converter.getEntityClass());
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ String.format(
+ "Failed to build object for type %s, this likely means the buildObjectWithFullFieldsOrThrow should be adapted.",
+ converter.getEntityClass().getName()),
+ e);
+ }
+ Object res = converter.fromProto(converter.toProto(converter.getEntityClass().cast(orig)));
+ assertThat(orig).isEqualTo(res);
+ // If this assertion fails, it's likely that you forgot to update the `equals` method to
+ // include your new field.
+ assertThat(EqualsBuilder.reflectionEquals(orig, res)).isTrue();
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to a proto that has a {@link
+ * SafeProtoConverter} set, or that you have changed the default value for such a field. Please
+ * update the corresponding Java class accordingly.
+ */
+ @Test
+ public void protoDefaultsKeptOnDoubleConversion() {
+ Message defaultInstance = getProtoDefaultInstance(converter.getProtoClass());
+ Message preFilled = explicitlyFillProtoDefaults(defaultInstance);
+ Message resFromDefault =
+ converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+ Message resFromPrefilled =
+ converter.toProto(converter.fromProto(converter.getProtoClass().cast(preFilled)));
+ assertThat(resFromDefault).isEqualTo(preFilled);
+ assertThat(resFromPrefilled).isEqualTo(preFilled);
+ }
+
+ @Nullable
+ private static Object buildObjectWithFullFieldsOrThrow(Class<?> clz) throws Exception {
+ if (clz == null) {
+ return null;
+ }
+ Object obj = construct(clz);
+ if (isSimple(clz)) {
+ return obj;
+ }
+ for (Field field : clz.getDeclaredFields()) {
+ if (Modifier.isStatic(field.getModifiers())) {
+ continue;
+ }
+ Class<?> parameterizedType = getParameterizedType(field);
+ if (!field.getType().isArray()
+ && !Map.class.isAssignableFrom(field.getType())
+ && !Collection.class.isAssignableFrom(field.getType())) {
+ if (!field.trySetAccessible()) {
+ return null;
+ }
+ field.set(obj, buildObjectWithFullFieldsOrThrow(field.getType()));
+ } else if (Collection.class.isAssignableFrom(field.getType())
+ && parameterizedType != null) {
+ field.set(obj, ImmutableList.of(buildObjectWithFullFieldsOrThrow(parameterizedType)));
+ }
+ }
+ return obj;
+ }
+
+ /**
+ * AutoValue annotations are not retained on runtime. We can only find out if a class is an
+ * AutoValue, by trying to load the expected AutoValue class.
+ *
+ * <p>For the class {@code package.Clz}, the AutoValue class name is {@code
+ * package.AutoValue_Clz}, for {@code package.Enclosing$Clz}, it is {@code
+ * package.AutoValue_Enclosing_Clz}
+ */
+ static Optional<Class<?>> toRepresentingAutoValueClass(Class<?> clz) {
+ String origClzName = clz.getName();
+ String autoValueClzName =
+ origClzName.substring(0, origClzName.lastIndexOf("."))
+ + ".AutoValue_"
+ + origClzName.substring(origClzName.lastIndexOf(".") + 1);
+ autoValueClzName = autoValueClzName.replace('$', '_');
+ try {
+ return Optional.of(clz.getClassLoader().loadClass(autoValueClzName));
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+ }
+
+ @Nullable
+ private static Class<?> getParameterizedType(Field field) {
+ if (!Collection.class.isAssignableFrom(field.getType())) {
+ return null;
+ }
+ Type genericType = field.getGenericType();
+ if (genericType instanceof ParameterizedType) {
+ return (Class<?>) ((ParameterizedType) genericType).getActualTypeArguments()[0];
+ }
+ return null;
+ }
+
+ @Nonnull
+ static Object construct(@Nonnull Class<?> clz) {
+ try {
+ Object arbitrary = ArbitraryInstances.get(clz);
+ if (arbitrary != null) {
+ return arbitrary;
+ }
+ Optional<Class<?>> optionalAutoValueRepresentation = toRepresentingAutoValueClass(clz);
+ if (optionalAutoValueRepresentation.isPresent()) {
+ return construct(optionalAutoValueRepresentation.get());
+ }
+ Constructor<?> constructor =
+ Arrays.stream(clz.getDeclaredConstructors())
+ // Filter out copy constructors
+ .filter(
+ c ->
+ c.getParameterCount() != 1
+ || !c.getParameterTypes()[0].isAssignableFrom(clz))
+ // Filter out private constructors which cannot be set accessible.
+ .filter(c -> c.canAccess(null) || c.trySetAccessible())
+ .min(Comparator.comparingInt(Constructor::getParameterCount))
+ .get();
+ List<Object> args = new ArrayList<>();
+ for (Class<?> f : constructor.getParameterTypes()) {
+ args.add(construct(f));
+ }
+ return constructor.newInstance(args.toArray());
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to construct class " + clz.getName(), e);
+ }
+ }
+
+ static boolean isSimple(Class<?> c) {
+ return c.isPrimitive()
+ || c.isEnum()
+ || Primitives.isWrapperType(c)
+ || String.class.isAssignableFrom(c)
+ || Timestamp.class.isAssignableFrom(c);
+ }
+
+ /**
+ * Returns the default instance for the given MessageLite class, if it has the {@code
+ * getDefaultInstance} static method.
+ *
+ * @param type the protobuf message class
+ * @throws IllegalArgumentException if the given class doesn't have the static {@code
+ * getDefaultInstance} method
+ */
+ public static <T extends MessageLite> T getProtoDefaultInstance(Class<T> type) {
+ try {
+ return type.cast(type.getMethod("getDefaultInstance").invoke(null));
+ } catch (ReflectiveOperationException | ClassCastException e) {
+ throw new IllegalStateException("Cannot get default instance for " + type, e);
+ }
+ }
+
+ private static Message explicitlyFillProtoDefaults(Message defaultInstance) {
+ Message.Builder res = defaultInstance.toBuilder();
+ for (FieldDescriptor f : defaultInstance.getDescriptorForType().getFields()) {
+ try {
+ if (f.getType().equals(FieldDescriptor.Type.MESSAGE)) {
+ if (f.isRepeated()) {
+ res.addRepeatedField(
+ f,
+ explicitlyFillProtoDefaults(
+ explicitlyFillProtoDefaults(
+ getProtoDefaultInstance(res.newBuilderForField(f).build().getClass()))));
+ } else {
+ res.setField(f, explicitlyFillProtoDefaults((Message) defaultInstance.getField(f)));
+ }
+ } else {
+ res.setField(f, defaultInstance.getField(f));
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Failed to fill default instance for " + f.getName(), e);
+ }
+ }
+ return res.build();
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 1bb39c8..86fd295 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -8,6 +8,7 @@
"//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/extensions/common/testing:common-test-util",
+ "//java/com/google/gerrit/testing:gerrit-test-util",
"//lib:guava",
"//lib/guice",
"//lib/truth",
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
new file mode 100644
index 0000000..828f6c1
--- /dev/null
+++ b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
@@ -0,0 +1,175 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.registration;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.inject.AbstractModule;
+import com.google.inject.Binder;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.ProvisionException;
+import com.google.inject.TypeLiteral;
+import java.util.function.Consumer;
+import org.junit.Test;
+
+public class DynamicItemTest {
+ private static final String PLUGIN_NAME = "plugin-name";
+
+ private static final String UNEXPECTED_PLUGIN_NAME = "unexpected-plugin";
+ private static final String DYNAMIC_ITEM_1 = "item-1";
+ private static final String DYNAMIC_ITEM_2 = "item-2";
+ private static final TypeLiteral<String> STRING_TYPE_LITERAL = new TypeLiteral<>() {};
+ private static final TypeLiteral<FinalItemApi> FINAL_ITEM_API_TYPE_LITERAL =
+ new TypeLiteral<>() {};
+ private static final TypeLiteral<FinalItemApiForPlugin>
+ FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL = new TypeLiteral<>() {};
+
+ @DynamicItem.Final
+ private interface FinalItemApi {}
+
+ private static class FinalItemImpl implements FinalItemApi {
+ private static final FinalItemApi INSTANCE = new FinalItemImpl();
+ }
+
+ @DynamicItem.Final(implementedByPlugin = PLUGIN_NAME)
+ private interface FinalItemApiForPlugin {}
+
+ private static class FinalItemImplByPlugin implements FinalItemApiForPlugin {
+ private static final FinalItemApiForPlugin INSTANCE = new FinalItemImplByPlugin();
+ }
+
+ @Test
+ public void shouldAssignDynamicItemTwice() {
+ ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+ ImmutableMap.of(STRING_TYPE_LITERAL, DynamicItem.itemOf(String.class, null));
+
+ ImmutableList<RegistrationHandle> gerritRegistrations =
+ PrivateInternals_DynamicTypes.attachItems(
+ newInjector(
+ (binder) -> {
+ DynamicItem.itemOf(binder, String.class);
+ DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_1);
+ }),
+ PluginName.GERRIT,
+ bindings);
+ assertThat(gerritRegistrations).hasSize(1);
+ assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_1, PluginName.GERRIT);
+
+ ImmutableList<RegistrationHandle> pluginRegistrations =
+ PrivateInternals_DynamicTypes.attachItems(
+ newInjector(
+ (binder) -> DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_2)),
+ PLUGIN_NAME,
+ bindings);
+ assertThat(pluginRegistrations).hasSize(1);
+ assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_2, PLUGIN_NAME);
+ }
+
+ @Test
+ public void shouldFailToAssignFinalDynamicItemTwice() {
+ ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+ ImmutableMap.of(FINAL_ITEM_API_TYPE_LITERAL, DynamicItem.itemOf(FinalItemApi.class, null));
+
+ ImmutableList<RegistrationHandle> baseInjectorRegistrations =
+ PrivateInternals_DynamicTypes.attachItems(
+ newInjector(
+ (binder) -> {
+ DynamicItem.itemOf(binder, FinalItemApi.class);
+ DynamicItem.bind(binder, FinalItemApi.class).toInstance(FinalItemImpl.INSTANCE);
+ }),
+ PluginName.GERRIT,
+ bindings);
+ assertThat(baseInjectorRegistrations).hasSize(1);
+
+ ProvisionException ignored =
+ assertThrows(
+ ProvisionException.class,
+ () -> {
+ ImmutableList<RegistrationHandle> unused =
+ PrivateInternals_DynamicTypes.attachItems(
+ newInjector(
+ (binder) ->
+ DynamicItem.bind(binder, FinalItemApi.class)
+ .toInstance(FinalItemImpl.INSTANCE)),
+ PluginName.GERRIT,
+ bindings);
+ });
+ }
+
+ @Test
+ public void shouldFailToAssignFinalDynamicItemToDifferentPlugin() {
+ ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+ ImmutableMap.of(
+ FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL,
+ DynamicItem.itemOf(FinalItemApi.class, null));
+
+ assertThrows(
+ ProvisionException.class,
+ () -> {
+ ImmutableList<RegistrationHandle> unused =
+ PrivateInternals_DynamicTypes.attachItems(
+ newInjector(
+ (binder) ->
+ DynamicItem.bind(binder, FinalItemApiForPlugin.class)
+ .toInstance(FinalItemImplByPlugin.INSTANCE)),
+ UNEXPECTED_PLUGIN_NAME,
+ bindings);
+ });
+ }
+
+ @Test
+ public void shouldAssignFinalDynamicItemToExpectedPlugin() {
+ ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings =
+ ImmutableMap.of(
+ FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL,
+ DynamicItem.itemOf(FinalItemApi.class, null));
+
+ ImmutableList<RegistrationHandle> pluginRegistrations =
+ PrivateInternals_DynamicTypes.attachItems(
+ newInjector(
+ (binder) ->
+ DynamicItem.bind(binder, FinalItemApiForPlugin.class)
+ .toInstance(FinalItemImplByPlugin.INSTANCE)),
+ PLUGIN_NAME,
+ bindings);
+ assertThat(pluginRegistrations).hasSize(1);
+ assertDynamicItem(
+ bindings.get(FINAL_TARGET_PLUGIN_ITEM_API_TYPE_LITERAL),
+ FinalItemImplByPlugin.INSTANCE,
+ PLUGIN_NAME);
+ }
+
+ private static Injector newInjector(Consumer<Binder> binding) {
+ return Guice.createInjector(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ binding.accept(binder());
+ }
+ });
+ }
+
+ private static <T> void assertDynamicItem(
+ @Nullable DynamicItem<?> item, T itemVal, String pluginName) {
+ assertThat(item).isNotNull();
+ assertThat(item.get()).isEqualTo(itemVal);
+ assertThat(item.getPluginName()).isEqualTo(pluginName);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 01537e0..0c451ac 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -20,13 +20,33 @@
import static com.google.gerrit.server.cache.testing.CacheSerializerTestUtil.byteString;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.proto.testing.SerializedClassSubject;
import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.change.ChangeKindCacheImpl.NoCache;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
import org.junit.Test;
public class ChangeKindCacheImplTest {
+ private InMemoryRepositoryManager repoManager;
+ private ChangeKindCache changeKindCache;
+
+ @Before
+ public void setUp() {
+ repoManager = new InMemoryRepositoryManager();
+ // For simplicity, we use non-caching version, and as long as we call the method that doesn't
+ // use ChangeData, we can provide null instead of constructing a factory.
+ changeKindCache = new NoCache(new Config(), null, repoManager);
+ }
+
@Test
public void keySerializer() throws Exception {
ChangeKindCacheImpl.Key key =
@@ -57,4 +77,134 @@
ImmutableMap.of(
"prior", ObjectId.class, "next", ObjectId.class, "strategyName", String.class));
}
+
+ @Test
+ public void commitMessageChanged() throws Exception {
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().create();
+ RevCommit firstRev = p.commit().parent(root).message("Commit message").create();
+ RevCommit secondRev = p.commit().parent(root).message("Commit message update").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.NO_CODE_CHANGE);
+ }
+
+ @Test
+ public void sameObject_noChange() throws Exception {
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().create();
+ RevCommit rev =
+ p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, rev, rev))
+ .isEqualTo(ChangeKind.NO_CHANGE);
+ }
+
+ @Test
+ public void sameContent_noChange() throws Exception {
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().create();
+ RevCommit firstRev =
+ p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+ RevCommit secondRev =
+ p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.NO_CHANGE);
+ }
+
+ @Test
+ public void contentChanged_rework() throws Exception {
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().create();
+ RevCommit firstRev =
+ p.commit().parent(root).message("Commit message").add("test.md", "Hello").create();
+ RevCommit secondRev =
+ p.commit().parent(root).message("Commit message").add("test.md", "Goodbye").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.REWORK);
+ }
+
+ @Test
+ public void mergeConflict_rework() throws Exception {
+ // Delete a change in one of the parents
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().add("foo", "foo-text").create();
+ RevCommit firstRev =
+ p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+ // File was deleted, but the commit is still writing new content to it.
+ RevCommit newRoot = p.commit().parent(root).rm("foo").create();
+ RevCommit secondRev =
+ p.commit().parent(newRoot).message("Commit message").add("foo", "bar-text").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.REWORK);
+ }
+
+ @Test
+ public void rebaseThenEdit_rework() throws Exception {
+ // Delete a change in one of the parents
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().add("foo", "foo-text").create();
+ RevCommit firstRev =
+ p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+ // Unrelated file was added.
+ RevCommit newRoot = p.commit().parent(root).add("baz", "baz-text").create();
+ RevCommit secondRev =
+ p.commit().parent(newRoot).message("Commit message").add("foo", "foobar-text").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.REWORK);
+ }
+
+ @Test
+ public void trivialRebase() throws Exception {
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().add("foo", "foo-text").create();
+ RevCommit firstRev =
+ p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+ // Unrelated file was added.
+ RevCommit newRoot = p.commit().parent(root).add("baz", "baz-text").create();
+ RevCommit secondRev =
+ p.commit().parent(newRoot).message("Commit message").add("foo", "bar-text").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.TRIVIAL_REBASE);
+ }
+
+ @Test
+ public void trivialRebaseCommitMessage() throws Exception {
+ TestRepository<Repo> p = newRepo("p");
+ RevCommit root = p.commit().add("foo", "foo-text").create();
+ RevCommit firstRev =
+ p.commit().parent(root).message("Commit message").add("foo", "bar-text").create();
+ // Unrelated file was added.
+ RevCommit newRoot = p.commit().parent(root).add("baz", "baz-text").create();
+ RevCommit secondRev =
+ p.commit().parent(newRoot).message("Commit subject").add("foo", "bar-text").create();
+
+ assertThat(
+ changeKindCache.getChangeKind(
+ p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev))
+ .isEqualTo(ChangeKind.TRIVIAL_REBASE_WITH_MESSAGE_UPDATE);
+ }
+
+ private TestRepository<Repo> newRepo(String name) throws Exception {
+ return new TestRepository<>(repoManager.createRepository(Project.nameKey(name)));
+ }
}
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
index b149d09..131fb23 100644
--- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -151,6 +151,58 @@
}
@Test
+ public void bothPreferencesTypes_getGeneralPreferencesAreEqual() throws Exception {
+ UserPreferences originalProto =
+ UserPreferences.newBuilder()
+ .setGeneralPreferencesInfo(
+ UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(19))
+ .build();
+ Config originalCfg = new Config();
+ originalCfg.fromText("[general]\n\tchangesPerPage = 19");
+
+ CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+ GeneralPreferencesInfo protoGeneral = CachedPreferences.general(Optional.empty(), protoPref);
+ CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+ GeneralPreferencesInfo cfgGeneral = CachedPreferences.general(Optional.empty(), cfgPref);
+
+ assertThat(protoGeneral).isEqualTo(cfgGeneral);
+ }
+
+ @Test
+ public void bothPreferencesTypes_getDiffPreferencesAreEqual() throws Exception {
+ UserPreferences originalProto =
+ UserPreferences.newBuilder()
+ .setDiffPreferencesInfo(UserPreferences.DiffPreferencesInfo.newBuilder().setContext(23))
+ .build();
+ Config originalCfg = new Config();
+ originalCfg.fromText("[diff]\n\tcontext = 23");
+
+ CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+ DiffPreferencesInfo protoDiff = CachedPreferences.diff(Optional.empty(), protoPref);
+ CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+ DiffPreferencesInfo cfgDiff = CachedPreferences.diff(Optional.empty(), cfgPref);
+
+ assertThat(protoDiff).isEqualTo(cfgDiff);
+ }
+
+ @Test
+ public void bothPreferencesTypes_getEditPreferencesAreEqual() throws Exception {
+ UserPreferences originalProto =
+ UserPreferences.newBuilder()
+ .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.newBuilder().setTabSize(27))
+ .build();
+ Config originalCfg = new Config();
+ originalCfg.fromText("[edit]\n\ttabSize = 27");
+
+ CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+ EditPreferencesInfo protoEdit = CachedPreferences.edit(Optional.empty(), protoPref);
+ CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+ EditPreferencesInfo cfgEdit = CachedPreferences.edit(Optional.empty(), cfgPref);
+
+ assertThat(protoEdit).isEqualTo(cfgEdit);
+ }
+
+ @Test
public void defaultPreferences_acceptingGitConfig() throws Exception {
Config cfg = new Config();
cfg.fromText("[general]\n\tchangesPerPage = 19");
diff --git a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
index 8cfcdbd..9888670 100644
--- a/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
+++ b/javatests/com/google/gerrit/server/config/UserPreferencesConverterTest.java
@@ -18,6 +18,9 @@
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter.DIFF_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter.EDIT_PREFERENCES_INFO_CONVERTER;
+import static com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter.GENERAL_PREFERENCES_INFO_CONVERTER;
import static java.util.Arrays.stream;
import com.google.common.collect.ImmutableList;
@@ -35,9 +38,6 @@
import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.MenuItem;
import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.Theme;
import com.google.gerrit.proto.Entities.UserPreferences.GeneralPreferencesInfo.TimeFormat;
-import com.google.gerrit.server.config.UserPreferencesConverter.DiffPreferencesInfoConverter;
-import com.google.gerrit.server.config.UserPreferencesConverter.EditPreferencesInfoConverter;
-import com.google.gerrit.server.config.UserPreferencesConverter.GeneralPreferencesInfoConverter;
import com.google.protobuf.Descriptors.Descriptor;
import com.google.protobuf.Descriptors.EnumDescriptor;
import java.util.EnumSet;
@@ -70,6 +70,35 @@
}
}
+ /**
+ * If this test fails, it's likely that you added a field to {@link GeneralPreferencesInfo}, or
+ * that you have changed the default value for such a field. Please update the {@link
+ * UserPreferences.GeneralPreferencesInfo} proto accordingly.
+ */
+ @Test
+ public void generalPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+ GeneralPreferencesInfo orig = GeneralPreferencesInfo.defaults();
+ GeneralPreferencesInfo res =
+ GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(
+ GENERAL_PREFERENCES_INFO_CONVERTER.toProto(orig));
+ assertThat(res).isEqualTo(orig);
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to {@link
+ * UserPreferences.GeneralPreferencesInfo}, or that you have changed the default value for such a
+ * field. Please update the {@link GeneralPreferencesInfo} class accordingly.
+ */
+ @Test
+ public void generalPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+ UserPreferences.GeneralPreferencesInfo orig =
+ UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
+ UserPreferences.GeneralPreferencesInfo res =
+ GENERAL_PREFERENCES_INFO_CONVERTER.toProto(
+ GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+ assertThat(res).isEqualTo(orig);
+ }
+
@Test
public void generalPreferencesInfo_doubleConversionWithAllFieldsSet() {
UserPreferences.GeneralPreferencesInfo originalProto =
@@ -112,8 +141,8 @@
.setDiffPageSidebar("plugin-insight")
.build();
UserPreferences.GeneralPreferencesInfo resProto =
- GeneralPreferencesInfoConverter.toProto(
- GeneralPreferencesInfoConverter.fromProto(originalProto));
+ GENERAL_PREFERENCES_INFO_CONVERTER.toProto(
+ GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
assertThat(resProto).isEqualTo(originalProto);
}
@@ -125,7 +154,8 @@
new com.google.gerrit.extensions.client.MenuItem(
" name1 ", " url1 ", " target1 ", " id1 "),
new com.google.gerrit.extensions.client.MenuItem(null, " url2 ", null, null));
- UserPreferences.GeneralPreferencesInfo resProto = GeneralPreferencesInfoConverter.toProto(info);
+ UserPreferences.GeneralPreferencesInfo resProto =
+ GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(resProto)
.isEqualTo(
UserPreferences.GeneralPreferencesInfo.newBuilder()
@@ -155,7 +185,7 @@
.build(),
MenuItem.newBuilder().setUrl(" url2 ").build()))
.build();
- GeneralPreferencesInfo info = GeneralPreferencesInfoConverter.fromProto(originalProto);
+ GeneralPreferencesInfo info = GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(originalProto);
assertThat(info.my)
.containsExactly(
new com.google.gerrit.extensions.client.MenuItem("name1", "url1", "target1", "id1"),
@@ -165,14 +195,14 @@
@Test
public void generalPreferencesInfo_emptyJavaToProto() {
GeneralPreferencesInfo info = new GeneralPreferencesInfo();
- UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+ UserPreferences.GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(res).isEqualToDefaultInstance();
}
@Test
public void generalPreferencesInfo_defaultJavaToProto() {
GeneralPreferencesInfo info = GeneralPreferencesInfo.defaults();
- UserPreferences.GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.toProto(info);
+ UserPreferences.GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(res)
.ignoringFieldAbsence()
.isEqualTo(UserPreferences.GeneralPreferencesInfo.getDefaultInstance());
@@ -182,7 +212,7 @@
public void generalPreferencesInfo_emptyProtoToJava() {
UserPreferences.GeneralPreferencesInfo proto =
UserPreferences.GeneralPreferencesInfo.getDefaultInstance();
- GeneralPreferencesInfo res = GeneralPreferencesInfoConverter.fromProto(proto);
+ GeneralPreferencesInfo res = GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(proto);
assertThat(res).isEqualTo(new GeneralPreferencesInfo());
}
@@ -208,6 +238,33 @@
}
}
+ /**
+ * If this test fails, it's likely that you added a field to {@link DiffPreferencesInfo}, or that
+ * you have changed the default value for such a field. Please update the {@link
+ * UserPreferences.DiffPreferencesInfo} proto accordingly.
+ */
+ @Test
+ public void diffPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+ DiffPreferencesInfo orig = DiffPreferencesInfo.defaults();
+ DiffPreferencesInfo res =
+ DIFF_PREFERENCES_INFO_CONVERTER.fromProto(DIFF_PREFERENCES_INFO_CONVERTER.toProto(orig));
+ assertThat(res).isEqualTo(orig);
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to {@link
+ * UserPreferences.DiffPreferencesInfo}, or that you have changed the default value for such a
+ * field. Please update the {@link DiffPreferencesInfo} class accordingly.
+ */
+ @Test
+ public void diffPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+ UserPreferences.DiffPreferencesInfo orig =
+ UserPreferences.DiffPreferencesInfo.getDefaultInstance();
+ UserPreferences.DiffPreferencesInfo res =
+ DIFF_PREFERENCES_INFO_CONVERTER.toProto(DIFF_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+ assertThat(res).isEqualTo(orig);
+ }
+
@Test
public void diffPreferencesInfo_doubleConversionWithAllFieldsSet() {
UserPreferences.DiffPreferencesInfo originalProto =
@@ -238,21 +295,22 @@
.setSkipUncommented(false)
.build();
UserPreferences.DiffPreferencesInfo resProto =
- DiffPreferencesInfoConverter.toProto(DiffPreferencesInfoConverter.fromProto(originalProto));
+ DIFF_PREFERENCES_INFO_CONVERTER.toProto(
+ DIFF_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
assertThat(resProto).isEqualTo(originalProto);
}
@Test
public void diffPreferencesInfo_emptyJavaToProto() {
DiffPreferencesInfo info = new DiffPreferencesInfo();
- UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+ UserPreferences.DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(res).isEqualToDefaultInstance();
}
@Test
public void diffPreferencesInfo_defaultJavaToProto() {
DiffPreferencesInfo info = DiffPreferencesInfo.defaults();
- UserPreferences.DiffPreferencesInfo res = DiffPreferencesInfoConverter.toProto(info);
+ UserPreferences.DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(res)
.ignoringFieldAbsence()
.isEqualTo(UserPreferences.DiffPreferencesInfo.getDefaultInstance());
@@ -262,7 +320,7 @@
public void diffPreferencesInfo_emptyProtoToJava() {
UserPreferences.DiffPreferencesInfo proto =
UserPreferences.DiffPreferencesInfo.getDefaultInstance();
- DiffPreferencesInfo res = DiffPreferencesInfoConverter.fromProto(proto);
+ DiffPreferencesInfo res = DIFF_PREFERENCES_INFO_CONVERTER.fromProto(proto);
assertThat(res).isEqualTo(new DiffPreferencesInfo());
}
@@ -288,6 +346,33 @@
}
}
+ /**
+ * If this test fails, it's likely that you added a field to {@link EditPreferencesInfo}, or that
+ * you have changed the default value for such a field. Please update the {@link
+ * UserPreferences.EditPreferencesInfo} proto accordingly.
+ */
+ @Test
+ public void editPreferencesInfo_javaDefaultsKeptOnDoubleConversion() {
+ EditPreferencesInfo orig = EditPreferencesInfo.defaults();
+ EditPreferencesInfo res =
+ EDIT_PREFERENCES_INFO_CONVERTER.fromProto(EDIT_PREFERENCES_INFO_CONVERTER.toProto(orig));
+ assertThat(res).isEqualTo(orig);
+ }
+
+ /**
+ * If this test fails, it's likely that you added a field to {@link
+ * UserPreferences.EditPreferencesInfo}, or that you have changed the default value for such a
+ * field. Please update the {@link EditPreferencesInfo} class accordingly.
+ */
+ @Test
+ public void editPreferencesInfo_protoDefaultsKeptOnDoubleConversion() {
+ UserPreferences.EditPreferencesInfo orig =
+ UserPreferences.EditPreferencesInfo.getDefaultInstance();
+ UserPreferences.EditPreferencesInfo res =
+ EDIT_PREFERENCES_INFO_CONVERTER.toProto(EDIT_PREFERENCES_INFO_CONVERTER.fromProto(orig));
+ assertThat(res).isEqualTo(orig);
+ }
+
@Test
public void editPreferencesInfo_doubleConversionWithAllFieldsSet() {
UserPreferences.EditPreferencesInfo originalProto =
@@ -308,21 +393,22 @@
.setShowBase(false)
.build();
UserPreferences.EditPreferencesInfo resProto =
- EditPreferencesInfoConverter.toProto(EditPreferencesInfoConverter.fromProto(originalProto));
+ EDIT_PREFERENCES_INFO_CONVERTER.toProto(
+ EDIT_PREFERENCES_INFO_CONVERTER.fromProto(originalProto));
assertThat(resProto).isEqualTo(originalProto);
}
@Test
public void editPreferencesInfo_emptyJavaToProto() {
EditPreferencesInfo info = new EditPreferencesInfo();
- UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+ UserPreferences.EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(res).isEqualToDefaultInstance();
}
@Test
public void editPreferencesInfo_defaultJavaToProto() {
EditPreferencesInfo info = EditPreferencesInfo.defaults();
- UserPreferences.EditPreferencesInfo res = EditPreferencesInfoConverter.toProto(info);
+ UserPreferences.EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.toProto(info);
assertThat(res)
.ignoringFieldAbsence()
.isEqualTo(UserPreferences.EditPreferencesInfo.getDefaultInstance());
@@ -332,7 +418,7 @@
public void editPreferencesInfo_emptyProtoToJava() {
UserPreferences.EditPreferencesInfo proto =
UserPreferences.EditPreferencesInfo.getDefaultInstance();
- EditPreferencesInfo res = EditPreferencesInfoConverter.fromProto(proto);
+ EditPreferencesInfo res = EDIT_PREFERENCES_INFO_CONVERTER.fromProto(proto);
assertThat(res).isEqualTo(new EditPreferencesInfo());
}
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index c1b9f13..8cdf16a 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -52,7 +52,8 @@
testPerformanceLogger =
new PerformanceLogger() {
@Override
- public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
+ public void logNanos(
+ String operation, long durationNanos, Instant endTime, Metadata metadata) {
// do nothing
}
};
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index c93061d..4b3c658 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -365,7 +365,7 @@
private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
@Override
- public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
+ public void logNanos(String operation, long durationNanos, Instant endTime, Metadata metadata) {
logEntries.add(PerformanceLogEntry.create(operation, metadata));
}
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
index bf224f0..01bf941 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractFakeQueryGroupsTest.java
@@ -20,6 +20,7 @@
import com.google.gerrit.extensions.common.GroupInfo;
import com.google.gerrit.index.PaginationType;
import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.index.group.GroupIndexCollection;
import com.google.gerrit.testing.InMemoryModule;
import com.google.inject.Guice;
@@ -52,14 +53,15 @@
public void internalQueriesDoNotPaginateWithNonePaginationType() throws Exception {
assumeTrue(PaginationType.NONE == getCurrentPaginationType());
- final int GROUPS_CREATED_SIZE = 2;
- List<GroupInfo> groupsCreated = new ArrayList<>();
- for (int i = 0; i < GROUPS_CREATED_SIZE; i++) {
- groupsCreated.add(createGroupThatIsVisibleToAll(name("group-" + i)));
+ List<GroupInfo> groupsVisibleToAll = new ArrayList<>();
+ groupsVisibleToAll.add(gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).get());
+
+ for (int i = 0; i < 2; i++) {
+ groupsVisibleToAll.add(createGroupThatIsVisibleToAll(name("group-" + i)));
}
- List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsCreated);
- assertThat(result.size()).isEqualTo(GROUPS_CREATED_SIZE);
+ List<GroupInfo> result = assertQuery(newQuery("is:visibletoall"), groupsVisibleToAll);
+ assertThat(result.size()).isEqualTo(groupsVisibleToAll.size());
assertThat(result.get(result.size() - 1)._moreGroups).isNull();
assertThatSearchQueryWasNotPaginated();
}
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 572e7af..d8339e7 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -49,6 +49,7 @@
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.AuthRequest;
import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.group.db.GroupDelta;
import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -239,13 +240,14 @@
@Test
public void byIsVisibleToAll() throws Exception {
- assertQuery("is:visibletoall");
+ GroupInfo serviceUsersGroupInfo = gApi.groups().id(ServiceUserClassifier.SERVICE_USERS).get();
+ assertQuery("is:visibletoall", serviceUsersGroupInfo);
GroupInfo groupThatIsVisibleToAll =
createGroupThatIsVisibleToAll(name("group-that-is-visible-to-all"));
createGroup(name("group"));
- assertQuery("is:visibletoall", groupThatIsVisibleToAll);
+ assertQuery("is:visibletoall", groupThatIsVisibleToAll, serviceUsersGroupInfo);
}
@Test
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 6c79c43..089ceea 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -18,6 +18,7 @@
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertSectionEquivalent;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.assertTwoConfigsEquivalent;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultAcls;
+import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getAllProjectsWithoutDefaultSubmitRequirements;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.getDefaultAllProjectsWithAllDefaultSections;
import static com.google.gerrit.server.schema.testing.AllProjectsCreatorTestUtil.readAllProjectsConfig;
import static com.google.gerrit.truth.ConfigSubject.assertThat;
@@ -89,11 +90,13 @@
expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
GroupReference adminsGroup = createGroupReference("Administrators");
- GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
AllProjectsInput allProjectsInput =
AllProjectsInput.builder()
.administratorsGroup(adminsGroup)
- .serviceUsersGroup(batchUsersGroup)
+ .serviceUsersGroup(serviceUsersGroup)
+ .blockedUsersGroup(blockedUsersGroup)
.build();
allProjectsCreator.create(allProjectsInput);
@@ -139,6 +142,7 @@
.addBooleanProjectConfig(
BooleanProjectConfig.REJECT_EMPTY_COMMIT, InheritableBoolean.TRUE)
.initDefaultAcls(true)
+ .initDefaultSubmitRequirements(true)
.build();
allProjectsCreator.create(allProjectsInput);
@@ -158,6 +162,26 @@
}
@Test
+ public void createAllProjectsWithoutInitializingDefaultSubmitRequirements() throws Exception {
+ GroupReference adminsGroup = createGroupReference("Administrators");
+ GroupReference serviceUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+ GroupReference blockedUsersGroup = createGroupReference(SchemaCreatorImpl.BLOCKED_USERS);
+ AllProjectsInput allProjectsInput =
+ AllProjectsInput.builder()
+ .administratorsGroup(adminsGroup)
+ .serviceUsersGroup(serviceUsersGroup)
+ .blockedUsersGroup(blockedUsersGroup)
+ .initDefaultSubmitRequirements(false)
+ .build();
+ allProjectsCreator.create(allProjectsInput);
+
+ Config expectedConfig = new Config();
+ expectedConfig.fromText(getAllProjectsWithoutDefaultSubmitRequirements());
+ Config config = readAllProjectsConfig(repoManager, allProjectsName);
+ assertTwoConfigsEquivalent(config, expectedConfig);
+ }
+
+ @Test
public void createAllProjectsOnlyInitializingProjectDescription() throws Exception {
String description = "a project.config with just a project description";
AllProjectsInput allProjectsInput =
@@ -165,6 +189,7 @@
.firstChangeIdForNoteDb(Sequences.FIRST_CHANGE_ID)
.projectDescription(description)
.initDefaultAcls(false)
+ .initDefaultSubmitRequirements(false)
.build();
allProjectsCreator.create(allProjectsInput);
diff --git a/javatests/com/google/gerrit/testing/ConfigSuiteTest.java b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
index 1ec30da..d75bd23 100644
--- a/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
+++ b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
@@ -124,8 +124,8 @@
new ConfigSuite(ConfigBasedTest.class).run(notifier);
verify(configBasedTestListener, Mockito.times(6)).testExecuted(any(), any(), any());
- verify(configBasedTestListener, Mockito.times(1)).testExecuted("test1", "default", null);
- verify(configBasedTestListener, Mockito.times(1)).testExecuted("test2", "default", null);
+ verify(configBasedTestListener, Mockito.times(1)).testExecuted("test1", "default", "default");
+ verify(configBasedTestListener, Mockito.times(1)).testExecuted("test2", "default", "default");
verify(configBasedTestListener, Mockito.times(1))
.testExecuted("test1", "firstValue", "firstConfig");
verify(configBasedTestListener, Mockito.times(1))
diff --git a/lib/BUILD b/lib/BUILD
index f9ece52..a0debc1 100644
--- a/lib/BUILD
+++ b/lib/BUILD
@@ -92,10 +92,7 @@
name = "protobuf",
data = ["//lib:LICENSE-protobuf"],
visibility = ["//visibility:public"],
- exports = [
- "@com_google_protobuf//:protobuf_java",
- "@com_google_protobuf//:protobuf_javalite",
- ],
+ exports = ["@protobuf-java//jar"],
)
java_library(
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD
index 4105d85..d55a273 100644
--- a/lib/highlightjs/BUILD
+++ b/lib/highlightjs/BUILD
@@ -12,6 +12,7 @@
srcs = [
"@ui_npm//highlight.js",
"@ui_npm//highlightjs-closure-templates",
+ "@ui_npm//highlightjs-epp",
"@ui_npm//highlightjs-structured-text",
],
config_file = "rollup.config.js",
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js
index c2d048d..811dec7 100644
--- a/lib/highlightjs/index.js
+++ b/lib/highlightjs/index.js
@@ -17,9 +17,11 @@
import hljs from 'highlight.js';
import soy from 'highlightjs-closure-templates';
+import epp from 'highlightjs-epp';
import iecst from 'highlightjs-structured-text';
hljs.registerLanguage('soy', soy);
+hljs.registerLanguage('epp', epp);
hljs.registerLanguage('iecst', iecst);
export default hljs;
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index 6865340..11be929 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -11,6 +11,11 @@
grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
cat << EOF > $TMP/want
+auto-common
+auto-factory
+auto-service-annotations
+auto-value
+auto-value-annotations
cglib-3_2
commons-io
dropwizard-core
@@ -20,6 +25,7 @@
flogger-google-extensions
flogger-log4j-backend
flogger-system-backend
+gson
guava
guava-testlib
guice-assistedinject
@@ -43,6 +49,7 @@
nekohtml
objenesis
openid-consumer
+protobuf-java
soy
sshd-mina
sshd-osgi
diff --git a/package.json b/package.json
index 1b91658..6aa807b 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,8 @@
"eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
"eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
"litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
- "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app"
+ "lint": "eslint -c polygerrit-ui/app/.eslintrc.js --ignore-path polygerrit-ui/app/.eslintignore polygerrit-ui/app",
+ "gjf": "./tools/run_gjf.sh"
},
"repository": {
"type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index b9c51e3..c4acd92 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -20,7 +20,7 @@
genrule2(
name = "core",
- srcs = ["//plugins/%s:%s.jar" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS],
+ srcs = ["//plugins/%s.jar" % (n if ":" in n else "%s:%s" % (n, n)) for n in CORE_PLUGINS + CUSTOM_PLUGINS],
outs = ["core.zip"],
cmd = "mkdir -p $$TMP/WEB-INF/plugins;" +
"for s in $(SRCS) ; do " +
diff --git a/plugins/hooks b/plugins/hooks
index f975f91..4f43f5d 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit f975f914312b258f84957d19f96014c3edd12644
+Subproject commit 4f43f5db6b8aa7f36381f4f9a4c9ec1fc335d949
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index cdd2d2d..86f7ec6 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit cdd2d2d69666a70a16ac02bacf8e7fbbf4ca9979
+Subproject commit 86f7ec61a9785df246f653a1336520b9607399b1
diff --git a/plugins/replication b/plugins/replication
index 56b8ffb..3982574 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 56b8ffbab5bf619c0b6b5d44f0255fd41b9e1c89
+Subproject commit 3982574d28f254b525b093c2ecae8caa47825910
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 914b099..12fc1b075 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -177,6 +177,13 @@
checkLink?: string;
/**
+ * Indicates that the check run is powered by Aritificial Intelligence. This
+ * allows the UI to add special treatment, e.g. an icon to be added to the
+ * check name. Defaults to `false`.
+ */
+ isAiPowered?: boolean;
+
+ /**
* RUNNABLE: Not run (yet). Mostly useful for runs that the user can trigger
* (see actions) and for indicating that a check was not run at a
* later attempt. Cannot contain results.
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 24110ed..90cf17b 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -552,3 +552,13 @@
intentionalMove?: boolean
): void;
}
+
+/**
+ * Represents a list of ranges in a diff that should be focused.
+ *
+ * This is used to collapse diff chunks that are not in focus.
+ */
+export declare interface DiffRangesToFocus {
+ left: {start: number; end: number}[];
+ right: {start: number; end: number}[];
+}
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index af481fd..d6425be 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -31,3 +31,40 @@
};
}
}
+
+/** <gr-textarea> input event */
+export declare interface InputEventDetail {
+ value: string;
+}
+
+/** <gr-textarea> event for current cursor position */
+export declare interface CursorPositionChangeEventDetail {
+ position: number;
+}
+
+/** <gr-textarea> event when showing a hint */
+export declare interface HintShownEventDetail {
+ hint: string;
+ oldValue: string;
+}
+
+/** <gr-textarea> event when a hint was dismissed */
+export declare interface HintDismissedEventDetail {
+ hint: string;
+}
+
+/** <gr-textarea> event when a hint was applied */
+export declare interface HintAppliedEventDetail {
+ hint: string;
+ oldValue: string;
+}
+
+/** <gr-textarea> interface that external users can rely on */
+export declare interface GrTextarea extends HTMLElement {
+ value?: string;
+ nativeElement?: HTMLElement;
+ placeholder?: string;
+ placeholderHint?: string;
+ hint?: string;
+ setRangeText: (replacement: string, start: number, end: number) => void;
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 8630f75..c595add 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -9,6 +9,7 @@
import {ChangeReplyPluginApi} from './change-reply';
import {ChecksPluginApi} from './checks';
import {EventHelperPluginApi} from './event-helper';
+import {PluginElement} from './hook';
import {PopupPluginApi} from './popup';
import {ReportingPluginApi} from './reporting';
import {ChangeActionsPluginApi} from './change-actions';
@@ -63,7 +64,7 @@
suggestions(): SuggestionsPluginApi;
eventHelper(element: Node): EventHelperPluginApi;
getPluginName(): string;
- hook<T extends HTMLElement>(
+ hook<T extends PluginElement>(
endpointName: string,
opt_options?: RegisterOptions
): HookApi<T>;
@@ -72,12 +73,12 @@
popup(): Promise<PopupPluginApi>;
popup(moduleName: string): Promise<PopupPluginApi>;
popup(moduleName?: string): Promise<PopupPluginApi | null>;
- registerCustomComponent<T extends HTMLElement>(
+ registerCustomComponent<T extends PluginElement>(
endpointName: string,
moduleName?: string,
options?: RegisterOptions
): HookApi<T>;
- registerDynamicCustomComponent<T extends HTMLElement>(
+ registerDynamicCustomComponent<T extends PluginElement>(
endpointName: string,
moduleName?: string,
options?: RegisterOptions
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 997b8fe8..a99771b 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -174,6 +174,7 @@
export enum RevisionKind {
REWORK = 'REWORK',
TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+ TRIVIAL_REBASE_WITH_MESSAGE_UPDATE = 'TRIVIAL_REBASE_WITH_MESSAGE_UPDATE',
MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
NO_CODE_CHANGE = 'NO_CODE_CHANGE',
NO_CHANGE = 'NO_CHANGE',
@@ -395,6 +396,7 @@
pending_reviewers?: AccountInfo[];
reviewer_updates?: ReviewerUpdateInfo[];
messages?: ChangeMessageInfo[];
+ current_revision_number: PatchSetNumber;
current_revision?: CommitId;
revisions?: {[revisionId: string]: RevisionInfo};
tracking_ids?: TrackingIdInfo[];
@@ -706,6 +708,7 @@
doc_search: boolean;
doc_url?: string;
edit_gpg_keys?: boolean;
+ project_state_predicate_enabled: boolean;
report_bug_url?: string;
// The following property is missed in doc
primary_weblink_name?: string;
@@ -1319,4 +1322,5 @@
fix_id: FixId;
description: string;
replacements: FixReplacementInfo[];
+ log_probability?: number;
}
diff --git a/polygerrit-ui/app/api/suggestions.ts b/polygerrit-ui/app/api/suggestions.ts
index b4158be..1aa4ebe 100644
--- a/polygerrit-ui/app/api/suggestions.ts
+++ b/polygerrit-ui/app/api/suggestions.ts
@@ -27,7 +27,21 @@
lineNumber?: number;
}
+export declare interface AutocompleteCommentRequest {
+ id: string;
+ commentText: string;
+ changeInfo: ChangeInfo;
+ patchsetNumber: RevisionPatchSetNum;
+ filePath: string;
+ range?: CommentRange;
+ lineNumber?: number;
+}
+
export declare interface SuggestionsProvider {
+ autocompleteComment?(
+ req: AutocompleteCommentRequest
+ ): Promise<AutocompleteCommentResponse>;
+
/**
* Gerrit calls these methods when ...
* - ... user types a comment draft
@@ -55,6 +69,13 @@
supportedFileExtensions?: string[];
}
+export declare interface AutocompleteCommentResponse {
+ responseCode: ResponseCode;
+ completion?: string;
+ modelVersion?: string;
+ outcome?: number;
+}
+
export declare interface SuggestCodeResponse {
responseCode: ResponseCode;
suggestions: Suggestion[];
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 0fa58f4..b21663a 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -264,6 +264,7 @@
default_base_for_merges: DefaultBase.AUTO_MERGE,
allow_browser_notifications: false,
allow_suggest_code_while_commenting: true,
+ allow_autocompleting_comments: true,
diff_page_sidebar: 'NONE',
};
}
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 37a17ba..643f8aa 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -102,6 +102,8 @@
APPLY_FIX_LOAD = 'ApplyFixLoad',
// Time to copy target to clipboard
COPY_TO_CLIPBOARD = 'CopyToClipboard',
+ // Time to autocomplete a comment
+ COMMENT_COMPLETION = 'CommentCompletion',
}
export enum Interaction {
@@ -156,4 +158,9 @@
// The very first reporting event with `ChangeId` set when visiting a change
// related page. Can be used as a starting point for user journeys.
CHANGE_ID_CHANGED = 'change-id-changed',
+
+ COMMENT_COMPLETION_SUGGESTION_SHOWN = 'comment-completion-suggestion-shown',
+ COMMENT_COMPLETION_SUGGESTION_ACCEPTED = 'comment-completion-suggestion-accepted',
+ COMMENT_COMPLETION_SAVE_DRAFT = 'comment-completion-save-draft',
+ COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched',
}
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index 8ee7669..754a233 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -7,7 +7,7 @@
import '../../shared/gr-button/gr-button';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
import {
AutocompleteSuggestion,
AutocompleteQuery,
@@ -222,7 +222,7 @@
</h3>
<fieldset>
<div>
- <gr-textarea
+ <gr-suggestion-textarea
class="description"
autocomplete="on"
rows="4"
@@ -230,7 +230,7 @@
?disabled=${this.computeGroupDisabled()}
.text=${this.groupConfig?.description ?? ''}
@text-changed=${this.handleDescriptionTextChanged}
- ></gr-textarea>
+ ></gr-suggestion-textarea>
</div>
<span class="value">
<gr-button
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
index 256c6a9..5cf71f8 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -97,14 +97,14 @@
<h3 class="heading-3">Description</h3>
<fieldset>
<div>
- <gr-textarea
+ <gr-suggestion-textarea
autocomplete="on"
class="description monospace"
disabled=""
monospace=""
rows="4"
>
- </gr-textarea>
+ </gr-suggestion-textarea>
</div>
<span class="value">
<gr-button
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
index 615528c..ff87849 100644
--- 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
@@ -74,6 +74,8 @@
// private but used in test
@state() canUpload?: boolean = false; // restAPI can return undefined
+ @state() disableSaveWithoutReview = true;
+
// private but used in test
@state() inheritFromFilter?: RepoName;
@@ -238,7 +240,7 @@
? 'invisible'
: ''}
primary
- ?disabled=${!this.modified}
+ ?disabled=${!this.modified || this.disableSaveWithoutReview}
@click=${this.handleSave}
>Save</gr-button
>
@@ -343,6 +345,7 @@
this.groups = res.groups;
this.weblinks = res.config_web_links || [];
this.canUpload = res.can_upload;
+ this.disableSaveWithoutReview = !!res.require_change_for_config_update;
this.ownerOf = res.owner_of || [];
return toSortedPermissionsArray(this.local);
});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
index 467857d..495de52 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts
@@ -157,12 +157,13 @@
Edit
</gr-button>
<gr-button
- aria-disabled="false"
+ aria-disabled="true"
+ disabled=""
class="invisible"
id="saveBtn"
primary=""
role="button"
- tabindex="0"
+ tabindex="-1"
>
Save
</gr-button>
@@ -1445,9 +1446,18 @@
);
element.repo = 'test-repo' as RepoName;
+ await element.updateComplete;
sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
-
+ assert.equal(
+ queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+ true
+ );
element.modified = true;
+ await element.updateComplete;
+ assert.equal(
+ queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+ false
+ );
queryAndAssert<GrButton>(element, '#saveBtn').click();
await element.updateComplete;
assert.equal(
@@ -1460,6 +1470,58 @@
assert.isTrue(setUrlStub.notCalled);
});
+ test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+ const repoAccessInput = {
+ add: {
+ 'refs/*': {
+ permissions: {
+ owner: {
+ rules: {
+ 123: {action: 'DENY', modified: true},
+ },
+ },
+ },
+ },
+ },
+ remove: {
+ 'refs/*': {
+ permissions: {
+ owner: {
+ rules: {
+ 123: {},
+ },
+ },
+ },
+ },
+ },
+ };
+ stubRestApi('getRepoAccessRights').returns(
+ Promise.resolve(
+ JSON.parse(
+ JSON.stringify({
+ ...accessRes,
+ require_change_for_config_update: true,
+ })
+ )
+ )
+ );
+
+ element.repo = 'test-repo' as RepoName;
+ await element.updateComplete;
+ sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput);
+ assert.equal(
+ queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+ true
+ );
+ element.modified = true;
+ await element.updateComplete;
+ assert.equal(element.disableSaveWithoutReview, true);
+ assert.equal(
+ queryAndAssert<GrButton>(element, '#saveBtn').hasAttribute('disabled'),
+ true
+ );
+ });
+
test('handleSaveForReview', async () => {
const repoAccessInput = {
add: {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 90277534..6e1aba7 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -9,8 +9,9 @@
import '../../shared/gr-button/gr-button';
import '../../shared/gr-download-commands/gr-download-commands';
import '../../shared/gr-select/gr-select';
-import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {
ConfigInfo,
RepoName,
@@ -39,11 +40,14 @@
import {deepClone} from '../../../utils/deep-util';
import {LitElement, PropertyValues, css, html, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
+import {createChangeUrl} from '../../../models/views/change';
import {when} from 'lit/directives/when.js';
import {subscribe} from '../../lit/subscription-controller';
import {createSearchUrl} from '../../../models/views/search';
import {userModelToken} from '../../../models/user/user-model';
import {resolve} from '../../../models/dependency';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {KnownExperimentId} from '../../../services/flags/flags';
const STATES = {
active: {value: RepoState.ACTIVE, label: 'Active'},
@@ -101,6 +105,11 @@
// private but used in test
@state() readOnly = true;
+ @state() showSaveForReviewButton = false;
+
+ // private but used in test
+ @state() disableSaveWithoutReview = true;
+
@state() private states = Object.values(STATES);
@state() private originalConfig?: ConfigInfo;
@@ -114,10 +123,14 @@
@state() private pluginConfigChanged = false;
+ private readonly flagsService = getAppContext().flagsService;
+
private readonly getUserModel = resolve(this, userModelToken);
private readonly restApiService = getAppContext().restApiService;
+ private readonly getNavigation = resolve(this, navigationToken);
+
constructor() {
super();
subscribe(
@@ -195,10 +208,20 @@
${this.renderDescription()} ${this.renderRepoOptions()}
${this.renderPluginConfig()}
<gr-button
- ?disabled=${this.readOnly || !configChanged}
+ id="saveBtn"
+ ?disabled=${this.readOnly ||
+ this.disableSaveWithoutReview ||
+ !configChanged}
@click=${this.handleSaveRepoConfig}
>Save changes</gr-button
>
+ <gr-button
+ id="saveReviewBtn"
+ ?disabled=${this.readOnly || !configChanged}
+ ?hidden=${!this.showSaveForReviewButton}
+ @click=${this.handleSaveRepoConfigForReview}
+ >Save for review</gr-button
+ >
</fieldset>
<gr-endpoint-decorator name="repo-config">
<gr-endpoint-param
@@ -244,7 +267,7 @@
return html`
<h3 id="Description" class="heading-3">Description</h3>
<fieldset>
- <gr-textarea
+ <gr-suggestion-textarea
id="descriptionInput"
class="description"
autocomplete="on"
@@ -254,7 +277,7 @@
?disabled=${this.readOnly}
.text=${this.repoConfig.description ?? ''}
@text-changed=${this.handleDescriptionTextChanged}
- ></gr-textarea>
+ ></gr-suggestion-textarea>
</fieldset>
`;
}
@@ -781,6 +804,11 @@
// If the user is not an owner, is_owner is not a property.
this.readOnly = !access[repo]?.is_owner;
+ this.showSaveForReviewButton = this.flagsService.isEnabled(
+ KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+ );
+ this.disableSaveWithoutReview =
+ !!access[repo]?.require_change_for_config_update;
});
}
})
@@ -815,7 +843,7 @@
config.max_object_size_limit.configured_value = '';
}
this.repoConfig = config;
- this.originalConfig = deepClone(config) as ConfigInfo;
+ this.originalConfig = deepClone<ConfigInfo>(config);
this.loading = false;
};
promises.push(repoConfigHelper());
@@ -920,11 +948,37 @@
this.repo,
this.formatRepoConfigForSave(this.repoConfig)
);
- this.originalConfig = deepClone(this.repoConfig) as ConfigInfo;
+ this.originalConfig = deepClone<ConfigInfo>(this.repoConfig);
this.pluginConfigChanged = false;
return;
}
+ async handleSaveRepoConfigForReview(e: Event) {
+ if (!this.repoConfig || !this.repo)
+ return Promise.reject(new Error('undefined repoConfig or repo'));
+ const button = e && (e.target as GrButton);
+ if (button) {
+ button.loading = true;
+ }
+
+ return this.restApiService
+ .saveRepoConfigForReview(
+ this.repo,
+ this.formatRepoConfigForSave(this.repoConfig)
+ )
+ .then(change => {
+ // Don't navigate on server error.
+ if (change) {
+ this.getNavigation().setUrl(createChangeUrl({change}));
+ }
+ })
+ .finally(() => {
+ if (button) {
+ button.loading = false;
+ }
+ });
+ }
+
private isEdited(
original?: InheritedBooleanInfo | MaxObjectSizeLimitInfo,
repo?: InheritedBooleanInfo | MaxObjectSizeLimitInfo
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index 4deb99a..a4e0f09 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -6,6 +6,8 @@
import '../../../test/common-test-setup';
import './gr-repo';
import {GrRepo} from './gr-repo';
+import {createChange} from '../../../test/test-data-generators';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {mockPromise} from '../../../test/test-utils';
import {
addListenerForTest,
@@ -17,6 +19,7 @@
createInheritedBoolean,
createServerInfo,
} from '../../../test/test-data-generators';
+import {testResolver} from '../../../test/common-test-setup';
import {
ConfigInfo,
GitRef,
@@ -25,6 +28,7 @@
InheritedBooleanInfo,
MaxObjectSizeLimitInfo,
PluginParameterToConfigParameterInfoMap,
+ ProjectAccessInfo,
RepoAccessGroups,
RepoAccessInfoMap,
RepoName,
@@ -32,6 +36,7 @@
import {
ConfigParameterInfoType,
InheritedBooleanInfoConfiguredValue,
+ PermissionAction,
RepoState,
SubmitType,
} from '../../../constants/constants';
@@ -42,9 +47,12 @@
import {PageErrorEvent} from '../../../types/events';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrSelect} from '../../shared/gr-select/gr-select';
-import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../../shared/gr-suggestion-textarea/gr-suggestion-textarea';
import {IronInputElement} from '@polymer/iron-input/iron-input';
import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {ChangeInfo} from '../../../api/rest-api';
suite('gr-repo tests', () => {
let element: GrRepo;
@@ -199,7 +207,7 @@
<fieldset>
<h3 class="heading-3" id="Description">Description</h3>
<fieldset>
- <gr-textarea
+ <gr-suggestion-textarea
autocomplete="on"
class="description monospace"
disabled=""
@@ -208,7 +216,7 @@
placeholder="<Insert repo description here>"
rows="4"
>
- </gr-textarea>
+ </gr-suggestion-textarea>
</fieldset>
<h3 class="heading-3" id="Options">Repository Options</h3>
<fieldset id="options">
@@ -375,6 +383,7 @@
</section>
</fieldset>
<gr-button
+ id="saveBtn"
aria-disabled="true"
disabled=""
role="button"
@@ -382,6 +391,16 @@
>
Save changes
</gr-button>
+ <gr-button
+ id="saveReviewBtn"
+ aria-disabled="true"
+ disabled=""
+ hidden=""
+ role="button"
+ tabindex="-1"
+ >
+ Save for review
+ </gr-button>
</fieldset>
<gr-endpoint-decorator name="repo-config">
<gr-endpoint-param name="repoName"> </gr-endpoint-param>
@@ -633,31 +652,35 @@
});
suite('admin', () => {
+ const testRepoAccess: ProjectAccessInfo = {
+ revision: 'xxxx',
+ local: {
+ 'refs/*': {
+ permissions: {
+ owner: {
+ rules: {xxx: {action: PermissionAction.ALLOW, force: false}},
+ },
+ },
+ },
+ },
+ is_owner: true,
+ owner_of: ['refs/*'] as GitRef[],
+ groups: {
+ xxxx: {
+ id: 'xxxx' as GroupId,
+ url: 'test',
+ name: 'test' as GroupName,
+ },
+ } as RepoAccessGroups,
+ config_web_links: [{name: 'gitiles', url: 'test'}],
+ };
+ let getRepoAccessStub: sinon.SinonStub;
setup(() => {
element.repo = REPO as RepoName;
loggedInStub.returns(Promise.resolve(true));
- stubRestApi('getRepoAccess').callsFake(() =>
+ getRepoAccessStub = stubRestApi('getRepoAccess').callsFake(() =>
Promise.resolve({
- 'test-repo': {
- revision: 'xxxx',
- local: {
- 'refs/*': {
- permissions: {
- owner: {rules: {xxx: {action: 'ALLOW', force: false}}},
- },
- },
- },
- is_owner: true,
- owner_of: ['refs/*'] as GitRef[],
- groups: {
- xxxx: {
- id: 'xxxx' as GroupId,
- url: 'test',
- name: 'test' as GroupName,
- },
- } as RepoAccessGroups,
- config_web_links: [{name: 'gitiles', url: 'test'}],
- },
+ 'test-repo': testRepoAccess,
} as RepoAccessInfoMap)
);
});
@@ -720,7 +743,7 @@
await element.loadRepo();
- const button = queryAll<GrButton>(element, 'gr-button')[2];
+ const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
assert.isTrue(button.hasAttribute('disabled'));
assert.isFalse(
queryAndAssert<HTMLHeadingElement>(
@@ -728,7 +751,7 @@
'#Title'
).classList.contains('edited')
);
- queryAndAssert<GrTextarea>(element, '#descriptionInput').text =
+ queryAndAssert<GrSuggestionTextarea>(element, '#descriptionInput').text =
configInputObj.description;
queryAndAssert<GrSelect>(element, '#stateSelect').bindValue =
configInputObj.state;
@@ -800,5 +823,76 @@
saveStub.lastCall.calledWithExactly(REPO as RepoName, configInputObj)
);
});
+
+ test('saveReviewBtn visible when experiment is enabled', async () => {
+ const flagsService = getAppContext().flagsService;
+ sinon
+ .stub(flagsService, 'isEnabled')
+ .callsFake(
+ id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+ );
+ await element.loadRepo();
+ await element.updateComplete;
+ const button = queryAndAssert<GrButton>(
+ element,
+ 'gr-button#saveReviewBtn'
+ );
+ assert.isFalse(button.hasAttribute('hidden'));
+ });
+
+ test('saveBtn remains disabled when require_change_for_config_update is set', async () => {
+ const flagsService = getAppContext().flagsService;
+ sinon
+ .stub(flagsService, 'isEnabled')
+ .callsFake(
+ id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+ );
+ getRepoAccessStub.callsFake(() =>
+ Promise.resolve({
+ 'test-repo': {
+ ...testRepoAccess,
+ require_change_for_config_update: true,
+ },
+ } as RepoAccessInfoMap)
+ );
+ await element.loadRepo();
+ await element.updateComplete;
+ const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+ assert.isTrue(button.hasAttribute('disabled'));
+ });
+
+ test('saveReviewBtn', async () => {
+ const flagsService = getAppContext().flagsService;
+ sinon
+ .stub(flagsService, 'isEnabled')
+ .callsFake(
+ id => id === KnownExperimentId.SAVE_PROJECT_CONFIG_FOR_REVIEW
+ );
+ let resolver: (value: ChangeInfo | PromiseLike<ChangeInfo>) => void;
+ const saveForReviewStub = stubRestApi('saveRepoConfigForReview').returns(
+ new Promise(r => (resolver = r))
+ );
+ resolver!(createChange());
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+ await element.loadRepo();
+ await element.updateComplete;
+ const input = queryAndAssert<GrSuggestionTextarea>(
+ element,
+ '#descriptionInput'
+ );
+ input.text = 'New description';
+ await input.updateComplete;
+ await element.updateComplete;
+ const button = queryAndAssert<GrButton>(element, 'gr-button#saveBtn');
+ assert.isFalse(button.hasAttribute('disabled'));
+ queryAndAssert<GrButton>(element, 'gr-button#saveReviewBtn').click();
+ await element.updateComplete;
+ assert.isTrue(saveForReviewStub.called);
+ assert.isTrue(setUrlStub.called);
+ assert.isTrue(
+ setUrlStub.lastCall.args?.[0]?.includes(`${createChange()._number}`)
+ );
+ });
});
});
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
index ace2fb5..db1f2f5 100644
--- 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
@@ -377,8 +377,8 @@
*
* private but used in test
*/
- reload() {
- if (!this.viewState) return Promise.resolve();
+ async reload() {
+ if (!this.viewState) return;
// See `firstTimeLoad` comment above.
if (!this.firstTimeLoad) {
@@ -398,27 +398,23 @@
// Otherwise sending a query for 'owner:self' will result in an error.
const isLoggedInUserDashboard =
!project && !!this.loggedInUser && user === 'self';
- return dashboardPromise
- .then(res => {
- if (res && res.title) {
- fireTitleChange(res.title);
- }
- return this.fetchDashboardChanges(res, isLoggedInUserDashboard);
- })
- .then(() => {
- this.maybeShowDraftsBanner();
- // Only report the metric for the default personal dashboard.
- if (type === DashboardType.USER && isLoggedInUserDashboard) {
- this.reporting.dashboardDisplayed();
- }
- })
- .catch(err => {
- fireTitleChange(title || this.computeTitle(user));
- this.reporting.error('Dashboard reload', err);
- })
- .finally(() => {
- this.loading = false;
- });
+ try {
+ const res = await dashboardPromise;
+ if (res && res.title) {
+ fireTitleChange(res.title);
+ }
+ await this.fetchDashboardChanges(res, isLoggedInUserDashboard);
+ this.maybeShowDraftsBanner();
+ // Only report the metric for the default personal dashboard.
+ if (type === DashboardType.USER && isLoggedInUserDashboard) {
+ this.reporting.dashboardDisplayed();
+ }
+ } catch (err) {
+ fireTitleChange(title || this.computeTitle(user));
+ this.reporting.error('Dashboard reload', err as Error);
+ } finally {
+ this.loading = false;
+ }
}
/**
@@ -427,14 +423,11 @@
*
* private but used in test
*/
- fetchDashboardChanges(
+ async fetchDashboardChanges(
res: UserDashboard | undefined,
isLoggedInUserDashboard: boolean
- ): Promise<void> {
- if (!res) {
- return Promise.resolve();
- }
-
+ ) {
+ if (!res) return;
let queries: string[];
if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
@@ -454,36 +447,34 @@
}
}
- return this.restApiService
- .getChangesForMultipleQueries(undefined, queries)
- .then(changes => {
- if (!changes) {
- throw new Error('getChanges returns undefined');
- }
- if (isLoggedInUserDashboard) {
- // Last query ('owner:self limit:1') is only for evaluation if
- // the user is "New" ie. haven't created any changes yet.
- 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),
- emptyStateSlotName: slotNameBySectionName.get(
- res.sections[i].name
- ),
- };
- })
- .filter(
- (section, i) =>
- i < res.sections.length &&
- (!res.sections[i].hideIfEmpty || section.results.length)
- );
- });
+ const changes = await this.restApiService.getChangesForDashboard(
+ undefined,
+ queries
+ );
+ if (!changes) {
+ throw new Error('getChanges returns undefined');
+ }
+ if (isLoggedInUserDashboard) {
+ // Last query ('owner:self limit:1') is only for evaluation if
+ // the user is "New" ie. haven't created any changes yet.
+ 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),
+ emptyStateSlotName: slotNameBySectionName.get(res.sections[i].name),
+ };
+ })
+ .filter(
+ (section, i) =>
+ i < res.sections.length &&
+ (!res.sections[i].hideIfEmpty || section.results.length)
+ );
}
/**
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
index 58a66a6..01ce0b6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts
@@ -42,11 +42,11 @@
let element: GrDashboardView;
let getChangesStub: SinonStubbedMember<
- RestApiService['getChangesForMultipleQueries']
+ RestApiService['getChangesForDashboard']
>;
setup(async () => {
- getChangesStub = stubRestApi('getChangesForMultipleQueries');
+ getChangesStub = stubRestApi('getChangesForDashboard');
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
stubRestApi('getAccountDetails').returns(
Promise.resolve({
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
index 50ec339..836d86a 100644
--- 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
@@ -485,7 +485,7 @@
@state() pluginsLoaded = false;
- @state() threadsWithSuggestions?: CommentThread[];
+ @state() threadsWithUnappliedSuggestions?: CommentThread[];
private readonly restApiService = getAppContext().restApiService;
@@ -579,8 +579,8 @@
);
subscribe(
this,
- () => this.getCommentsModel().threadsWithSuggestions$,
- x => (this.threadsWithSuggestions = x)
+ () => this.getCommentsModel().threadsWithUnappliedSuggestions$,
+ x => (this.threadsWithUnappliedSuggestions = x)
);
}
@@ -820,11 +820,11 @@
<div class="header" slot="header">Publish Change Edit</div>
<div class="main" slot="main">
${when(
- this.numberOfThreadsWithSuggestions() > 0,
+ this.numberOfThreadsWithUnappliedSuggestions() > 0,
() => html`<p class="info">
<gr-icon id="icon" icon="info" small></gr-icon>
- Heads Up! ${this.numberOfThreadsWithSuggestions()} comments have
- suggestions you can apply before publishing
+ Heads Up! ${this.numberOfThreadsWithUnappliedSuggestions()}
+ comments have suggestions you can apply before publishing
</p>`
)}
Do you really want to publish the edit?
@@ -2105,8 +2105,16 @@
}
private handlePublishEditTap() {
- assertIsDefined(this.confirmPublishEditDialog, 'confirmPublishEditDialog');
- this.showActionDialog(this.confirmPublishEditDialog);
+ if (this.numberOfThreadsWithUnappliedSuggestions() > 0) {
+ assertIsDefined(
+ this.confirmPublishEditDialog,
+ 'confirmPublishEditDialog'
+ );
+ this.showActionDialog(this.confirmPublishEditDialog);
+ } else {
+ // Skip confirmation dialog and publish immediately.
+ this.handlePublishEditConfirm();
+ }
}
private handleRebaseEditTap() {
@@ -2264,9 +2272,9 @@
fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
}
- private numberOfThreadsWithSuggestions() {
- if (!this.threadsWithSuggestions) return 0;
- return this.threadsWithSuggestions.length;
+ private numberOfThreadsWithUnappliedSuggestions() {
+ if (!this.threadsWithUnappliedSuggestions) return 0;
+ return this.threadsWithUnappliedSuggestions.length;
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index a926f26..ebcec4b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -2443,6 +2443,7 @@
element.latestPatchNum = 12 as PatchSetNumber;
element.change = {
...createChangeViewChange(),
+ current_revision_number: element.latestPatchNum,
revisions: createRevisions(element.latestPatchNum as number),
messages: createChangeMessages(1),
};
@@ -2458,11 +2459,11 @@
suite('happy path', () => {
let executeChangeActionStub: sinon.SinonStub;
setup(() => {
- stubRestApi('getChangeDetail').returns(
+ stubRestApi('getChange').returns(
Promise.resolve({
...createChangeViewChange(),
// element has latest info
- revisions: createRevisions(element.latestPatchNum as number),
+ current_revision_number: element.latestPatchNum!,
messages: createChangeMessages(1),
})
);
@@ -2535,9 +2536,6 @@
})
);
executeChangeActionStub.resolves(response);
- stubRestApi('getChange').returns(
- Promise.resolve(createChangeViewChange())
- );
await element.send(
HttpMethod.POST,
{message: 'Revert'},
@@ -2625,13 +2623,12 @@
suite('failure modes', () => {
test('non-latest', () => {
- stubRestApi('getChangeDetail').returns(
+ stubRestApi('getChange').returns(
Promise.resolve({
...createChangeViewChange(),
// new patchset was uploaded
- revisions: createRevisions(
- (element.latestPatchNum as number) + 1
- ),
+ current_revision_number: (element.latestPatchNum! +
+ 1) as PatchSetNumber,
messages: createChangeMessages(1),
})
);
@@ -2655,11 +2652,11 @@
});
test('send fails', () => {
- stubRestApi('getChangeDetail').returns(
+ stubRestApi('getChange').returns(
Promise.resolve({
...createChangeViewChange(),
// element has latest info
- revisions: createRevisions(element.latestPatchNum as number),
+ current_revision_number: element.latestPatchNum!,
messages: createChangeMessages(1),
})
);
@@ -2692,14 +2689,29 @@
});
test('revert single change change not reachable', async () => {
- stubRestApi('getChangeDetail').returns(
- Promise.resolve({
- ...createChangeViewChange(),
- // element has latest info
- revisions: createRevisions(element.latestPatchNum as number),
- messages: createChangeMessages(1),
- })
- );
+ let getChangeCall = 0;
+ let errorFired = false;
+ stubRestApi('getChange').callsFake((_, errFn) => {
+ ++getChangeCall;
+ if (getChangeCall === 1) {
+ return Promise.resolve({
+ ...createChangeViewChange(),
+ // element has latest info
+ current_revision_number: element.latestPatchNum!,
+ messages: createChangeMessages(1),
+ });
+ } else {
+ // Mimics the behaviour of gr-rest-api-impl: If errFn is passed
+ // call it and return undefined, otherwise call fireNetworkError
+ // or fireServerError.
+ if (errFn) {
+ errFn.call(undefined);
+ } else {
+ errorFired = true;
+ }
+ return Promise.resolve(undefined);
+ }
+ });
const setUrlStub = sinon.stub(
testResolver(navigationToken),
'setUrl'
@@ -2715,18 +2727,6 @@
_number: 12345,
})
);
- let errorFired = false;
- // Mimics the behaviour of gr-rest-api-impl: If errFn is passed call
- // it and return undefined, otherwise call fireNetworkError or
- // fireServerError.
- stubRestApi('getChange').callsFake((_, errFn) => {
- if (errFn) {
- errFn.call(undefined);
- } else {
- errorFired = true;
- }
- return Promise.resolve(undefined);
- });
await element.send(
HttpMethod.POST,
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
index 0d742de..f1ef362 100644
--- 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
@@ -145,7 +145,6 @@
changeViewModelToken,
ChangeViewState,
createChangeUrl,
- createEditUrl,
} from '../../../models/views/change';
import {rootUrl} from '../../../utils/url-util';
import {userModelToken} from '../../../models/user/user-model';
@@ -2398,13 +2397,10 @@
controls.openDeleteDialog(path);
break;
case GrEditConstants.Actions.OPEN.id:
- assertIsDefined(this.patchNum, 'patchset number');
this.getNavigation().setUrl(
- createEditUrl({
- changeNum: this.change._number,
- repo: this.change.project,
- patchNum: this.patchNum,
+ this.getViewModel().editUrl({
editView: {path},
+ patchNum: this.patchNum,
})
);
break;
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
index 1fd4aa2..7b9c291 100644
--- 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
@@ -73,7 +73,10 @@
import {Modifier} from '../../../utils/dom-util';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView} from '../../../models/views/change';
+import {
+ ChangeChildView,
+ changeViewModelToken,
+} from '../../../models/views/change';
import {rootUrl} from '../../../utils/url-util';
import {testResolver} from '../../../test/common-test-setup';
import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -308,6 +311,12 @@
setup(async () => {
setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+ sinon
+ .stub(testResolver(changeViewModelToken), 'editUrl')
+ .returns('fakeEditUrl');
+ sinon
+ .stub(testResolver(changeViewModelToken), 'diffUrl')
+ .returns('fakeDiffUrl');
stubRestApi('getConfig').returns(
Promise.resolve({
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
index 7a09518..3ecbf74 100644
--- 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
@@ -80,9 +80,8 @@
import {incrementalRepeat} from '../../lit/incremental-repeat';
import {ifDefined} from 'lit/directives/if-defined.js';
import {
- createDiffUrl,
- createEditUrl,
createChangeUrl,
+ changeViewModelToken,
} from '../../../models/views/change';
import {userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@@ -212,7 +211,7 @@
diffViewMode?: DiffViewMode;
@property({type: Boolean})
- editMode?: boolean;
+ editMode = false;
private _filesExpanded = FilesExpandedState.NONE;
@@ -313,6 +312,8 @@
private readonly getNavigation = resolve(this, navigationToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
// private but used in test
fileCursor = new GrCursorManager();
@@ -2167,15 +2168,13 @@
// Private but used in tests.
openCursorFile() {
const diff = this.diffCursor?.getTargetDiffElement();
- if (!this.change || !diff || !this.patchRange || !diff.path) {
- throw new Error('change, diff and patchRange must be all set and valid');
+ if (!this.change || !diff || !this.patchNum || !diff.path) {
+ throw new Error('change, diff and pacthNum must be all set and valid');
}
this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
+ this.getViewModel().diffUrl({
diffView: {path: diff.path},
+ patchNum: this.patchNum,
})
);
}
@@ -2188,15 +2187,13 @@
if (!this.files[this.fileCursor.index]) {
return;
}
- if (!this.change || !this.patchRange) {
+ if (!this.change || !this.patchNum) {
throw new Error('change and patchRange must be set');
}
this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
+ this.getViewModel().diffUrl({
diffView: {path: this.files[this.fileCursor.index].__path},
+ patchNum: this.patchNum,
})
);
}
@@ -2214,30 +2211,21 @@
);
}
+ /** Returns an edit or diff URL depending on `editMode`. */
// Private but used in tests
- computeDiffURL(path?: string) {
- if (
- this.change === undefined ||
- this.patchRange?.patchNum === undefined ||
- path === undefined ||
- this.editMode === undefined
- ) {
- return;
- }
+ computeDiffURL(path?: string): string | undefined {
+ if (path === undefined) return;
+ if (this.patchNum === undefined) return;
+
if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
- return createEditUrl({
- changeNum: this.change._number,
- repo: this.change.project,
- patchNum: this.patchRange.patchNum,
+ return this.getViewModel().editUrl({
+ patchNum: this.patchNum,
editView: {path},
});
}
- return createDiffUrl({
- changeNum: this.change._number,
- repo: this.change.project,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
+ return this.getViewModel().diffUrl({
diffView: {path},
+ patchNum: this.patchNum,
});
}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index eec829a..ca7ab14 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -60,6 +60,11 @@
import {FileMode} from '../../../utils/file-util';
import {SinonStubbedMember} from 'sinon';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
+import {
+ ChangeChildView,
+ changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
suite('gr-diff a11y test', () => {
test('audit', async () => {
@@ -79,6 +84,15 @@
let element: GrFileList;
let saveStub: sinon.SinonStub;
+ setup(async () => {
+ testResolver(changeViewModelToken).setState({
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
+ changeNum: 42 as NumericChangeId,
+ repo: 'gerrit' as RepoName,
+ });
+ });
+
suite('basic tests', async () => {
setup(async () => {
stubRestApi('getDiffComments').returns(Promise.resolve({}));
@@ -179,7 +193,7 @@
<gr-file-status></gr-file-status>
</div>
<span class="path" role="gridcell">
- <a class="pathLink">
+ <a class="pathLink" href="/c/gerrit/+/42/2/path/file0">
<span class="fullFileName" title="path/file0">
<span class="newFilePath"> path/ </span>
<span class="fileName"> file0 </span>
@@ -299,7 +313,7 @@
fileRows[0].querySelector('.path'),
/* HTML */ `
<span class="path" role="gridcell">
- <a class="pathLink">
+ <a class="pathLink" href="/c/gerrit/+/42/2/path/file0">
<span class="fullFileName" title="path/file0">
<span class="newFilePath"> path/ </span>
<span class="fileName"> file0 </span>
@@ -317,7 +331,7 @@
fileRows[1].querySelector('.path'),
/* HTML */ `
<span class="path" role="gridcell">
- <a class="pathLink">
+ <a class="pathLink" href="/c/gerrit/+/42/2/path/file1">
<span class="fullFileName" title="path/file1">
<span class="matchingFilePath"> path/ </span>
<span class="fileName"> file1 </span>
@@ -947,10 +961,10 @@
];
element.changeNum = 42 as NumericChangeId;
element.basePatchNum = PARENT;
- element.patchNum = 2 as RevisionPatchSetNum;
+ element.patchNum = 1 as RevisionPatchSetNum;
element.change = {
_number: 42 as NumericChangeId,
- project: 'test-project',
+ project: 'gerrit',
} as ParsedChangeInfo;
element.fileCursor.setCursorAtIndex(0);
await element.updateComplete;
@@ -1009,7 +1023,7 @@
assert.equal(setUrlStub.callCount, 1);
assert.equal(
setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/2/file_added_in_rev2.txt'
+ '/c/gerrit/+/42/1/file_added_in_rev2.txt'
);
pressKey(element, 'k');
@@ -1705,35 +1719,25 @@
suite('diff url file list', () => {
test('diff url', () => {
- element.change = {
- ...createParsedChange(),
- _number: 1 as NumericChangeId,
- project: 'gerrit' as RepoName,
- };
- element.basePatchNum = PARENT;
- element.patchNum = 1 as RevisionPatchSetNum;
const path = 'index.php';
+ element.patchNum = 1 as RevisionPatchSetNum;
element.editMode = false;
- assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1/index.php');
+ assert.equal(element.computeDiffURL(path), '/c/gerrit/+/42/1/index.php');
});
test('diff url commit msg', () => {
- element.change = {
- ...createParsedChange(),
- _number: 1 as NumericChangeId,
- project: 'gerrit' as RepoName,
- };
- element.basePatchNum = PARENT;
- element.patchNum = 1 as RevisionPatchSetNum;
- element.editMode = false;
const path = '/COMMIT_MSG';
- assert.equal(element.computeDiffURL(path), '/c/gerrit/+/1/1//COMMIT_MSG');
+ element.editMode = false;
+ assert.equal(
+ element.computeDiffURL(path),
+ '/c/gerrit/+/42/1//COMMIT_MSG'
+ );
});
test('edit url', () => {
element.change = {
...createParsedChange(),
- _number: 1 as NumericChangeId,
+ _number: 42 as NumericChangeId,
project: 'gerrit' as RepoName,
};
element.basePatchNum = PARENT;
@@ -1742,14 +1746,14 @@
const path = 'index.php';
assert.equal(
element.computeDiffURL(path),
- '/c/gerrit/+/1/1/index.php,edit'
+ '/c/gerrit/+/42/1/index.php,edit'
);
});
test('edit url commit msg', () => {
element.change = {
...createParsedChange(),
- _number: 1 as NumericChangeId,
+ _number: 42 as NumericChangeId,
project: 'gerrit' as RepoName,
};
element.basePatchNum = PARENT;
@@ -1758,7 +1762,7 @@
const path = '/COMMIT_MSG';
assert.equal(
element.computeDiffURL(path),
- '/c/gerrit/+/1/1//COMMIT_MSG,edit'
+ '/c/gerrit/+/42/1//COMMIT_MSG,edit'
);
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index 65d36ec..77610dc6 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -50,6 +50,9 @@
import {createChangeUrl} from '../../../models/views/change';
import {fire} from '../../../utils/event-util';
import {ChangeMessageDeletedEventDetail} from '../../../types/events';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -97,9 +100,6 @@
return this.message?.author || this.message?.updated_by;
}
- @property({type: Object})
- config?: ServerInfo;
-
@property({type: Boolean})
hideAutomated = false;
@@ -110,11 +110,14 @@
@property({type: Object})
labelExtremes?: LabelExtreme;
- @property({type: Boolean})
+ @state()
loggedIn = false;
@state()
- private isAdmin = false;
+ config?: ServerInfo;
+
+ @state()
+ isAdmin = false;
@state()
private isDeletingChangeMsg = false;
@@ -123,22 +126,28 @@
private readonly getNavigation = resolve(this, navigationToken);
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
constructor() {
super();
this.addEventListener('click', e => this.handleClick(e));
- }
-
- override connectedCallback() {
- super.connectedCallback();
- this.restApiService.getConfig().then(config => {
- this.config = config;
- });
- this.restApiService.getLoggedIn().then(loggedIn => {
- this.loggedIn = loggedIn;
- });
- this.restApiService.getIsAdmin().then(isAdmin => {
- this.isAdmin = !!isAdmin;
- });
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ x => (this.config = x)
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().loggedIn$,
+ x => (this.loggedIn = x)
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().isAdmin$,
+ x => (this.isAdmin = x)
+ );
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 1ed2729..1dbacc1 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -49,8 +49,8 @@
suite('when admin and logged in', () => {
setup(async () => {
- stubRestApi('getIsAdmin').returns(Promise.resolve(true));
element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
+ element.isAdmin = true;
});
test('can see delete button', async () => {
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
index 984391d..e2a1d63 100644
--- 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
@@ -277,7 +277,8 @@
return html`<section id="relatedChanges">
<gr-related-collapse
- title="Relation chain"
+ .name=${'Relation chain'}
+ title="parent changes are ordered after child changes"
class=${classMap({first: isFirst})}
.length=${this.relatedChanges.length}
.numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
@@ -342,7 +343,8 @@
);
return html`<section id="submittedTogether">
<gr-related-collapse
- title="Submitted together"
+ .name=${'Submitted together'}
+ title="parent changes are ordered after child changes"
class=${classMap({first: isFirst})}
.length=${submittedTogetherChanges.length}
.numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
@@ -405,7 +407,7 @@
);
return html`<section id="sameTopic">
<gr-related-collapse
- title="Same topic"
+ .name=${'Same topic'}
class=${classMap({first: isFirst})}
.length=${this.sameTopicChanges.length}
.numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
@@ -445,7 +447,7 @@
);
return html`<section id="mergeConflicts">
<gr-related-collapse
- title="Merge conflicts"
+ .name=${'Merge conflicts'}
class=${classMap({first: isFirst})}
.length=${this.conflictingChanges.length}
.numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
@@ -490,7 +492,7 @@
);
return html`<section id="cherryPicks">
<gr-related-collapse
- title="Cherry picks"
+ .name=${'Cherry picks'}
class=${classMap({first: isFirst})}
.length=${this.cherryPickChanges.length}
.numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 2e33333..e8bf442 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -200,7 +200,10 @@
<gr-endpoint-param name="change"> </gr-endpoint-param>
<gr-endpoint-slot name="top"> </gr-endpoint-slot>
<section id="relatedChanges">
- <gr-related-collapse class="first" title="Relation chain">
+ <gr-related-collapse
+ class="first"
+ title="parent changes are ordered after child changes"
+ >
<div class="relatedChangeLine show-when-collapsed">
<span class="marker space"> </span>
<gr-related-change
@@ -213,7 +216,9 @@
</gr-related-collapse>
</section>
<section id="submittedTogether">
- <gr-related-collapse title="Submitted together">
+ <gr-related-collapse
+ title="parent changes are ordered after child changes"
+ >
<div class="relatedChangeLine selected show-when-collapsed">
<span
aria-label="Arrow marking current change"
@@ -236,7 +241,7 @@
<div class="note" hidden="">(+ )</div>
</section>
<section id="cherryPicks">
- <gr-related-collapse title="Cherry picks">
+ <gr-related-collapse>
<div class="relatedChangeLine show-when-collapsed">
<span class="marker space"> </span>
<gr-related-change show-change-status="">
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
index 30d2282..8c2f459 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
@@ -18,7 +18,7 @@
@customElement('gr-related-collapse')
export class GrRelatedCollapse extends LitElement {
@property()
- override title = '';
+ name = '';
@property({type: Boolean})
showAll = false;
@@ -64,7 +64,7 @@
}
override render() {
- const title = html`<h3 class="title heading-3">${this.title}</h3>`;
+ const title = html`<h3 class="title heading-3">${this.name}</h3>`;
const collapsible = this.length > this.numChangesWhenCollapsed;
this.collapsed = !this.showAll && collapsible;
@@ -88,7 +88,7 @@
e.stopPropagation();
this.showAll = !this.showAll;
this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
- sectionName: this.title,
+ sectionName: this.name,
toState: this.showAll ? 'Show all' : 'Show less',
});
}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 7204035..c76e928 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -88,6 +88,7 @@
fireNoBubble,
fireIronAnnounce,
fireServerError,
+ fireReload,
} from '../../../utils/event-util';
import {ErrorCallback} from '../../../api/rest';
import {DelayedTask} from '../../../utils/async-util';
@@ -122,7 +123,7 @@
import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
import {GrThreadList} from '../gr-thread-list/gr-thread-list';
import {userModelToken} from '../../../models/user/user-model';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {ironAnnouncerRequestAvailability} from '../../polymer-util';
@@ -1459,27 +1460,45 @@
this.getNavigation().blockNavigation('sending review');
return this.saveReview(reviewInput, errFn)
.then(result => {
+ // change-info is not set only if request resulted in error.
+ if (!result?.change_info) {
+ return;
+ }
+
+ // saveReview response don't contain revision information, if the
+ // newer patchset was uploaded in the meantime, we should reload.
+ const reloadRequired =
+ result.change_info.current_revision_number !==
+ this.change?.current_revision_number;
+ // Update the state right away to update comments, even if the full
+ // reload is scheduled right after.
+ const updatedChange = {
+ ...result.change_info,
+ revisions: this.change?.revisions,
+ current_revision: this.change?.current_revision,
+ current_revision_number: this.change?.current_revision_number,
+ };
this.getChangeModel().updateStateChange(
- GrReviewerUpdatesParser.parse(
- result?.change_info as ChangeViewChangeInfo
- )
+ GrReviewerUpdatesParser.parse(updatedChange as ChangeViewChangeInfo)
);
+ if (reloadRequired) {
+ fireReload(this);
+ }
this.patchsetLevelDraftMessage = '';
this.includeComments = true;
fireNoBubble(this, 'send', {});
fireIronAnnounce(this, 'Reply sent');
this.getPluginLoader().jsApiService.handleReplySent();
- return;
})
- .then(result => result)
.finally(() => {
this.getNavigation().releaseNavigation('sending review');
this.disabled = false;
if (this.patchsetLevelGrComment) {
this.patchsetLevelGrComment.disableAutoSaving = false;
}
- // By this point in time the change has loaded, we're only waiting for the comments.
+ // The request finished and reloads if necessary are asynchronously
+ // scheduled.
this.reporting.timeEnd(Timing.SEND_REPLY);
});
}
@@ -1912,7 +1931,7 @@
this.latestPatchNum,
review,
errFn,
- true
+ /* fetchDetail=*/ true
);
}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 500aa63..7c8aece 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -15,6 +15,7 @@
queryAndAssert,
stubReporting,
stubRestApi,
+ waitEventLoop,
waitUntilVisible,
} from '../../../test/test-utils';
import {
@@ -30,6 +31,7 @@
createComment,
createCommentThread,
createDraft,
+ createLabelInfo,
createRevision,
createServiceUserWithId,
} from '../../../test/test-data-generators';
@@ -37,6 +39,7 @@
import {
AccountId,
AccountInfo,
+ ChangeInfo,
CommentThread,
CommitId,
DetailedLabelInfo,
@@ -71,6 +74,8 @@
import {isOwner} from '../../../utils/change-util';
import {createNewPatchsetLevel} from '../../../utils/comment-util';
import {Timing} from '../../../constants/reporting';
+import {ParsedChangeInfo} from '../../../types/types';
+import {changeModelToken} from '../../../models/change/change-model';
function cloneableResponse(status: number, text: string) {
return {
@@ -97,6 +102,8 @@
let changeNum: NumericChangeId;
let latestPatchNum: PatchSetNumber;
let commentsModel: CommentsModel;
+ let change: ParsedChangeInfo;
+ let changeNoRevisions: ChangeInfo;
let lastId = 1;
const makeAccount = function () {
@@ -112,15 +119,13 @@
setup(async () => {
changeNum = 42 as NumericChangeId;
latestPatchNum = 1 as PatchSetNumber;
+ const owner: AccountInfo = {
+ _account_id: 999 as AccountId,
+ display_name: 'Kermit',
+ email: 'abcd' as EmailAddress,
+ };
- stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
- stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
-
- element = await fixture<GrReplyDialog>(html`
- <gr-reply-dialog></gr-reply-dialog>
- `);
-
- element.change = {
+ changeNoRevisions = {
...createChange(),
_number: changeNum,
owner: {
@@ -148,7 +153,23 @@
default_value: 0,
},
},
+
+ current_revision_number: 1 as PatchSetNumber,
};
+ change = {
+ ...changeNoRevisions,
+ revisions: {'commit-id': {...createRevision(), uploader: owner}},
+ current_revision: 'commit-id' as CommitId,
+ };
+
+ stubRestApi('getChange').returns(Promise.resolve(change as ChangeInfo));
+ stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
+
+ element = await fixture<GrReplyDialog>(html`
+ <gr-reply-dialog></gr-reply-dialog>
+ `);
+
+ element.change = change;
element.latestPatchNum = latestPatchNum;
element.permittedLabels = {
'Code-Review': ['-1', ' 0', '+1'],
@@ -186,6 +207,9 @@
});
stubSaveReview((review: ReviewInput) => {
resolver(review);
+ return {
+ change_info: changeNoRevisions,
+ };
});
return promise;
}
@@ -1467,9 +1491,9 @@
...createChange(),
status: ChangeStatus.NEW,
};
+ const restApiPromise = interceptSaveReview();
element.send(false, false);
-
- await waitUntil(() => fireStub.called);
+ await restApiPromise;
const events = fireStub.args.map(arg => arg[0].type || '');
assert.isFalse(events.includes('show-alert'));
@@ -1489,9 +1513,9 @@
status: ChangeStatus.NEW,
work_in_progress: true,
};
+ const restApiPromise = interceptSaveReview();
element.send(false, true);
-
- await waitUntil(() => fireStub.called);
+ await restApiPromise;
const events = fireStub.args.map(arg => arg[0].type || '');
assert.isFalse(events.includes('show-alert'));
@@ -1635,17 +1659,15 @@
test('only send labels that have changed', async () => {
await element.updateComplete;
+ const promise = mockPromise();
stubSaveReview((review: ReviewInput) => {
assert.deepEqual(review?.labels, {
'Code-Review': 0,
Verified: -1,
});
- });
-
- const promise = mockPromise();
- element.addEventListener('send', () => {
promise.resolve();
});
+
// Without wrapping this test in await element.updateComplete, the below two
// calls to tap() cause a race in some situations in shadow DOM. The send
// button can be tapped before the others, causing the test to fail.
@@ -2616,6 +2638,92 @@
);
});
+ test('reload change if patchset updated', async () => {
+ // Async tick is needed because iron-selector content is distributed and
+ // distributed content requires an observer to be set up.
+ await element.updateComplete;
+ const changeModel = testResolver(changeModelToken);
+ const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
+ const responseChange = {
+ ...change,
+ labels: {Verified: createLabelInfo(-1)},
+ revisions: undefined,
+ current_revision: undefined,
+ current_revision_number: (change.current_revision_number +
+ 1) as PatchSetNumber,
+ };
+ stubSaveReview(() => {
+ return {
+ change_info: responseChange as ChangeInfo,
+ };
+ });
+ const reloadPromise = mockPromise();
+ let reloadTriggered = false;
+ document.addEventListener('reload', () => {
+ reloadTriggered = true;
+ reloadPromise.resolve();
+ });
+
+ // Set a different label value
+ const el = queryAndAssert<GrLabelScoreRow>(
+ queryAndAssert(element, 'gr-label-scores'),
+ 'gr-label-score-row[name="Verified"]'
+ );
+ el.setSelectedValue('-1');
+ await element.updateComplete;
+
+ queryAndAssert<GrButton>(element, '.send').click();
+ await element.updateComplete;
+
+ await reloadPromise;
+ assert.isTrue(reloadTriggered);
+
+ // All revision information is old, but all other information is new.
+ const expectedChange = {...change, labels: {Verified: createLabelInfo(-1)}};
+ assert.deepEqual(changeStateUpdateSpy.firstCall.args[0], expectedChange);
+ });
+
+ test('no reload if patchset is the same', async () => {
+ // Async tick is needed because iron-selector content is distributed and
+ // distributed content requires an observer to be set up.
+ await element.updateComplete;
+ const changeModel = testResolver(changeModelToken);
+ const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
+ const responseChange = {
+ ...change,
+ labels: {Verified: createLabelInfo(-1)},
+ revisions: undefined,
+ current_revision: undefined,
+ current_revision_number: change.current_revision_number,
+ };
+ stubSaveReview(() => {
+ return {
+ change_info: responseChange as ChangeInfo,
+ };
+ });
+ let reloadTriggered = false;
+ document.addEventListener('reload', () => {
+ reloadTriggered = true;
+ });
+
+ // Set a different label value
+ const el = queryAndAssert<GrLabelScoreRow>(
+ queryAndAssert(element, 'gr-label-scores'),
+ 'gr-label-score-row[name="Verified"]'
+ );
+ el.setSelectedValue('-1');
+ await element.updateComplete;
+
+ queryAndAssert<GrButton>(element, '.send').click();
+ await element.updateComplete;
+
+ await waitEventLoop();
+ assert.isFalse(reloadTriggered);
+ // All revision information is old, but all other information is new.
+ const expectedChange = {...change, labels: {Verified: createLabelInfo(-1)}};
+ assert.deepEqual(changeStateUpdateSpy.firstCall.args[0], expectedChange);
+ });
+
suite('mention users', () => {
setup(async () => {
element.account = createAccountWithId(1);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index b94aa52..6cff5ab 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -58,6 +58,7 @@
import {
DropdownLink,
LabelNameToInfoMap,
+ PARENT,
PatchSetNumber,
} from '../../types/common';
import {spinnerStyles} from '../../styles/gr-spinner-styles';
@@ -78,7 +79,7 @@
import {when} from 'lit/directives/when.js';
import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
import './gr-checks-attempt';
-import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
+import {changeViewModelToken} from '../../models/views/change';
import {formStyles} from '../../styles/form-styles';
/**
@@ -472,9 +473,11 @@
this.toggleExpanded();
}
- private toggleExpanded() {
+ /** Toggles the expanded state, or if `setExpanded` is provided sets it to the desired state. */
+ toggleExpanded(setExpanded?: boolean) {
if (!this.isExpandable) return;
- this.isExpanded = !this.isExpanded;
+ this.isExpanded =
+ setExpanded === undefined ? !this.isExpanded : setExpanded;
this.reporting.reportInteraction(Interaction.CHECKS_RESULT_ROW_TOGGLE, {
expanded: this.isExpanded,
checkName: this.result?.checkName,
@@ -635,6 +638,8 @@
private getChangeModel = resolve(this, changeModelToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
static override get styles() {
return [
sharedStyles,
@@ -714,14 +719,13 @@
const change = this.getChangeModel().getChange();
assertIsDefined(change);
const path = pointer.path;
- const patchset = this.result?.patchset as PatchSetNumber | undefined;
+ const patchset = this.result?.patchset as PatchSetNumber;
const line = pointer?.range?.start_line;
return {
icon: LinkIcon.CODE,
tooltip: `${path}${rangeText}`,
- url: createDiffUrl({
- changeNum: change._number,
- repo: change.project,
+ url: this.getViewModel().diffUrl({
+ basePatchNum: PARENT,
patchNum: patchset,
checksPatchset: patchset,
diffView: {path, lineNum: line},
@@ -1101,6 +1105,8 @@
// moment before trying to find a child element in it.
setTimeout(() => {
if (el) (el as HTMLElement).focus();
+ // If the target element is a <gr-result-row>, then expand it.
+ (el as GrResultRow)?.toggleExpanded(true);
// <gr-result-row> has display:contents and cannot be scrolled into view
// itself. Thus we are preferring to scroll the first child into view.
el = el?.shadowRoot?.firstElementChild ?? el;
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
index 2916f75..644871a4 100644
--- 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
@@ -7,12 +7,14 @@
import '../../shared/gr-avatar/gr-avatar';
import {getUserName} from '../../../utils/display-name-util';
import {AccountInfo, DropdownLink, ServerInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
import {fire} from '../../../utils/event-util';
import {DropdownContent} from '../../shared/gr-dropdown/gr-dropdown';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
+import {customElement, property, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {subscribe} from '../../lit/subscription-controller';
const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
@@ -30,34 +32,54 @@
@property({type: Object})
account?: AccountInfo;
- @property({type: Object})
+ @property({type: Boolean})
+ showMobile?: boolean;
+
+ // Private but used in test
+ @state()
config?: ServerInfo;
- @property({type: String})
- _path = '/';
+ @state()
+ private path = '/';
- @property({type: Boolean})
- _hasAvatars = false;
+ @state()
+ private hasAvatars = false;
- @property({type: String})
- _switchAccountUrl = '';
+ @state()
+ private switchAccountUrl = '';
- private readonly restApiService = getAppContext().restApiService;
+ // private but used in test
+ @state() feedbackURL = '';
+
+ // Private but used in test
+ readonly getConfigModel = resolve(this, configModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ cfg => {
+ this.config = cfg;
+
+ if (cfg?.gerrit?.report_bug_url) {
+ this.feedbackURL = cfg?.gerrit.report_bug_url;
+ }
+
+ 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 connectedCallback() {
super.connectedCallback();
this.handleLocationChange();
document.addEventListener('location-change', this.handleLocationChange);
- this.restApiService.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 disconnectedCallback() {
@@ -88,15 +110,17 @@
link=""
.items=${this.links}
.topContent=${this.topContent}
- @tap-item-shortcuts=${this._handleShortcutsTap}
+ @tap-item-shortcuts=${this.handleShortcutsTap}
.horizontalAlign=${'right'}
>
- <span ?hidden=${this._hasAvatars}
- >${this._accountName(this.account)}</span
- >
+ ${this.showMobile && !this.hasAvatars
+ ? html`<gr-icon icon="account_circle" filled></gr-icon>`
+ : html`<span ?hidden=${this.hasAvatars}
+ >${this.accountName(this.account)}</span
+ >`}
<gr-avatar
.account=${this.account}
- ?hidden=${!this._hasAvatars}
+ ?hidden=${!this.hasAvatars}
.imageSize=${56}
aria-label="Account avatar"
></gr-avatar>
@@ -104,14 +128,15 @@
}
get links(): DropdownLink[] | undefined {
- return this._getLinks(this._switchAccountUrl, this._path);
+ return this.getLinks(this.switchAccountUrl, this.path);
}
get topContent(): DropdownContent[] | undefined {
- return this._getTopContent(this.account);
+ return this.getTopContent(this.account);
}
- _getLinks(switchAccountUrl?: string, path?: string) {
+ // Private but used in test
+ getLinks(switchAccountUrl?: string, path?: string) {
if (switchAccountUrl === undefined || path === undefined) {
return undefined;
}
@@ -121,37 +146,48 @@
links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
if (switchAccountUrl) {
const replacements = {path};
- const url = this._interpolateUrl(switchAccountUrl, replacements);
+ const url = this.interpolateUrl(switchAccountUrl, replacements);
links.push({name: 'Switch account', url, external: true});
}
+ if (this.showMobile && this.feedbackURL) {
+ links.push({
+ name: 'Feedback',
+ id: 'feedback',
+ url: this.feedbackURL,
+ external: true,
+ target: '_blank',
+ });
+ }
links.push({name: 'Sign out', id: 'signout', url: '/logout'});
return links;
}
- _getTopContent(account?: AccountInfo) {
+ // Private but used in test
+ getTopContent(account?: AccountInfo) {
return [
- {text: this._accountName(account), bold: true},
+ {text: this.accountName(account), bold: true},
{text: account?.email ? account.email : ''},
] as DropdownContent[];
}
- _handleShortcutsTap() {
+ private handleShortcutsTap() {
fire(this, 'show-keyboard-shortcuts', {});
}
private readonly handleLocationChange = () => {
- this._path =
+ this.path =
window.location.pathname + window.location.search + window.location.hash;
};
- _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+ // Private but used in test
+ interpolateUrl(url: string, replacements: {[key: string]: string}) {
return url.replace(
INTERPOLATE_URL_PATTERN,
(_, p1) => replacements[p1] || ''
);
}
- _accountName(account?: AccountInfo) {
+ private accountName(account?: AccountInfo) {
return getUserName(this.config, account);
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
index c224a6b..06dc3f8 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -78,14 +78,14 @@
test('switch account', () => {
// Missing params.
- assert.isUndefined(element._getLinks());
- assert.isUndefined(element._getLinks(undefined));
+ assert.isUndefined(element.getLinks());
+ assert.isUndefined(element.getLinks(undefined));
// No switch account link.
- assert.equal(element._getLinks('', '')!.length, 3);
+ assert.equal(element.getLinks('', '')!.length, 3);
// Unparameterized switch account link.
- let links = element._getLinks('/switch-account', '')!;
+ let links = element.getLinks('/switch-account', '')!;
assert.equal(links.length, 4);
assert.deepEqual(links[2], {
name: 'Switch account',
@@ -94,7 +94,7 @@
});
// Parameterized switch account link.
- links = element._getLinks('/switch-account${path}', '/c/123')!;
+ links = element.getLinks('/switch-account${path}', '/c/123')!;
assert.equal(links.length, 4);
assert.deepEqual(links[2], {
name: 'Switch account',
@@ -103,13 +103,13 @@
});
});
- test('_interpolateUrl', () => {
+ test('interpolateUrl', () => {
const replacements = {
foo: 'bar',
test: 'TEST',
};
const interpolate = (url: string) =>
- element._interpolateUrl(url, replacements);
+ element.interpolateUrl(url, replacements);
assert.equal(interpolate('test'), 'test');
assert.equal(interpolate('${test}'), 'TEST');
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
index ae59fb6..d133bca 100644
--- 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
@@ -22,13 +22,14 @@
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, property, query, state} from 'lit/decorators.js';
import {fire} from '../../../utils/event-util';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {userModelToken} from '../../../models/user/user-model';
import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {subscribe} from '../../lit/subscription-controller';
+import {ifDefined} from 'lit/directives/if-defined.js';
type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
@@ -104,6 +105,9 @@
AuthType.CUSTOM_EXTENSION,
]);
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
declare global {
interface HTMLElementTagNameMap {
'gr-main-header': GrMainHeader;
@@ -149,6 +153,15 @@
// private but used in test
@state() feedbackURL = '';
+ @state() hamburgerClose? = false;
+
+ @query('.nav-sidebar') navSidebar?: HTMLDivElement;
+
+ @query('.modelBackground') modelBackground?: HTMLDivElement;
+
+ @query('.has-collapsible.active')
+ hasCollapsibleActive?: HTMLLIElement;
+
private readonly restApiService = getAppContext().restApiService;
private readonly getPluginLoader = resolve(this, pluginLoaderToken);
@@ -202,10 +215,29 @@
:host {
display: block;
}
- nav {
+ .hideOnDesktop {
+ display: none;
+ }
+
+ nav.hideOnMobile {
align-items: center;
display: flex;
}
+ nav.hideOnMobile ul {
+ list-style: none;
+ padding-left: var(--spacing-l);
+ }
+ nav.hideOnMobile .links > li {
+ cursor: default;
+ display: inline-block;
+ padding: 0;
+ position: relative;
+ }
+
+ .mobileTitle {
+ display: none;
+ }
+
.bigTitle {
color: var(--header-text-color);
font-size: var(--header-title-font-size);
@@ -215,7 +247,8 @@
.bigTitle:hover {
text-decoration: underline;
}
- .titleText {
+ .titleText,
+ .mobileTitleText {
/* Vertical alignment of icons and text with just block/inline display is too troublesome. */
display: flex;
align-items: center;
@@ -239,16 +272,45 @@
content: var(--header-title-content);
white-space: nowrap;
}
- ul {
- list-style: none;
- padding-left: var(--spacing-l);
+
+ .mobileTitleText::before {
+ --icon-width: var(
+ --header-icon-width,
+ var(--header-mobile-icon-size, var(--header-icon-size, 0))
+ );
+ --icon-height: var(
+ --header-icon-height,
+ var(--header-mobile-icon-size, var(--header-icon-size, 0))
+ );
+ background-image: var(--header-mobile-icon, var(--header-icon));
+ background-size: var(--mobile-icon-width, var(--icon-width))
+ var(--mobile-icon-height, var(--icon-height));
+ background-repeat: no-repeat;
+ content: '';
+ /* Any direct child of a flex element implicitly has 'display: block', but let's make that explicit here. */
+ display: block;
+ width: var(--mobile-icon-width, var(--icon-width));
+ height: var(--mobile-icon-height, var(--icon-height));
+ /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+ margin-right: clamp(
+ 0px,
+ var(--mobile-icon-height, var(--icon-height)),
+ var(--spacing-m)
+ );
}
- .links > li {
- cursor: default;
- display: inline-block;
- padding: 0;
- position: relative;
+ .mobileTitleText::after {
+ /* The height will be determined by the line-height of the .bigTitle element. */
+ content: var(
+ --header-mobile-title-content,
+ var(--header-title-content)
+ );
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ flex: 1;
+ overflow: hidden;
+ min-width: 0;
}
+
.linksTitle {
display: inline-block;
font-weight: var(--font-weight-bold);
@@ -264,7 +326,24 @@
flex: 1;
justify-content: flex-end;
}
- .rightItems gr-endpoint-decorator:not(:empty) {
+ .mobileRightItems {
+ align-items: center;
+ justify-content: flex-end;
+
+ display: inline-block;
+ vertical-align: middle;
+ cursor: pointer;
+ position: relative;
+ top: 0px;
+ right: 0px;
+ margin-right: 0;
+ margin-left: auto;
+ min-height: 50px;
+ padding-top: 12px;
+ }
+
+ .rightItems gr-endpoint-decorator:not(:empty),
+ .mobileRightItems gr-endpoint-decorator:not(:empty) {
margin-left: var(--spacing-l);
}
gr-smart-search {
@@ -299,13 +378,17 @@
}
:host([loading]) .accountContainer,
:host([loggedIn]) .loginButton,
- :host([loggedIn]) .registerButton {
+ :host([loggedIn]) .registerButton,
+ :host([loggedIn]) .moreMenu {
display: none;
}
:host([loggedIn]) .settingsButton,
:host([loggedIn]) gr-account-dropdown {
display: inline;
}
+ :host:not([loggedIn]) .moreMenu {
+ display: inline;
+ }
.accountContainer {
flex: 0 0 auto;
align-items: center;
@@ -340,6 +423,10 @@
--gr-button-text-color: var(--header-text-color);
color: var(--header-text-color);
}
+ .hamburger-open {
+ --gr-button-text-color: var(--primary-text-color);
+ color: var(--primary-text-color);
+ }
#mobileSearch {
display: none;
}
@@ -363,7 +450,158 @@
margin-left: var(--spacing-m) !important;
}
gr-dropdown {
- padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-m);
+ padding: 0 var(--spacing-m);
+ }
+ .nav-sidebar {
+ background: var(--table-header-background-color);
+ width: 200px;
+ height: 100%;
+ display: block;
+ position: fixed;
+ left: -200px;
+ top: 0px;
+ transition: left 0.25s ease;
+ margin: 0;
+ border: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ height: 100%;
+ margin-bottom: 15px 0;
+ box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+ border-radius: 3px;
+ z-index: 2;
+ }
+ .nav-sidebar.visible {
+ left: 0px;
+ transition: left 0.25s ease;
+ width: 80%;
+ z-index: 200;
+ }
+ .mobileTitle {
+ position: relative;
+ display: block;
+ top: 10px;
+ font-size: 20px;
+ left: 100px;
+ right: 100px;
+ text-align: center;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ width: 50%;
+ }
+ .nav-header {
+ display: flex;
+ }
+ .hamburger {
+ display: inline-block;
+ vertical-align: middle;
+ height: 50px;
+ cursor: pointer;
+ margin: 0;
+ position: absolute;
+ top: 0;
+ left: 0;
+ padding: 12px;
+ z-index: 200;
+ }
+ .nav-sidebar ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: block;
+ padding-top: 50px;
+ }
+ .nav-sidebar li {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ position: relative;
+ font-size: 14;
+ color: var(--primary-text-color);
+ display: block;
+ }
+ .cover {
+ background: rgba(0, 0, 0, 0.5);
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ overflow: none;
+ z-index: 199;
+ }
+ .hideOnDesktop {
+ display: block;
+ }
+ nav.hideOnMobile {
+ display: none;
+ }
+ .nav-sidebar .menu ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: block;
+ padding-top: 50px;
+ }
+ .nav-sidebar .menu li {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: inline-block;
+ position: relative;
+ font-size: 14;
+ color: var(--primary-text-color);
+ display: block;
+ }
+ .nav-sidebar .menu li a {
+ padding: 15px 20px;
+ font-size: 14;
+ outline: 0;
+ display: block;
+ color: var(--primary-text-color);
+ font-weight: 600;
+ }
+ .nav-sidebar .menu li.active ul.dropdown {
+ display: block;
+ }
+ .nav-sidebar .menu li ul.dropdown {
+ position: absolute;
+ display: none;
+ width: 100%;
+ box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26);
+ padding-top: 0;
+ position: relative;
+ }
+ .nav-sidebar .menu li ul.dropdown li {
+ display: block;
+ list-style-type: none;
+ }
+ .nav-sidebar .menu li ul.dropdown li a {
+ padding: 15px 20px;
+ font-size: 15px;
+ display: block;
+ font-weight: 400;
+ border-bottom: none;
+ padding: 10px 10px 10px 30px;
+ }
+ .nav-sidebar .menu li ul.dropdown li:last-child a {
+ border-bottom: none;
+ }
+ .nav-sidebar .menu a {
+ text-decoration: none;
+ }
+ .nav-sidebar .menu li.active:first-child a {
+ border-radius: 3px 0 0 3px;
+ border-radius: 0;
+ }
+ .nav-sidebar .menu li ul.dropdown li.active:first-child a {
+ border-radius: 0;
+ }
+ .arrow-down {
+ position: absolute;
+ top: 10px;
+ right: 10px;
}
}
`,
@@ -371,35 +609,134 @@
}
override render() {
+ return html` ${this.renderDesktop()} ${this.renderMobile()} `;
+ }
+
+ private renderDesktop() {
return html`
- <nav>
- <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
- <gr-endpoint-decorator name="header-title">
- <div class="titleText"></div>
- </gr-endpoint-decorator>
- </a>
- <ul class="links">
- ${this.computeLinks(this.userLinks, this.adminLinks, this.topMenus).map(
- linkGroup => this.renderLinkGroup(linkGroup)
- )}
- </ul>
- <div class="rightItems">
- <gr-endpoint-decorator
- class="hideOnMobile"
- name="header-small-banner"
- ></gr-endpoint-decorator>
- <gr-smart-search id="search"></gr-smart-search>
- <gr-endpoint-decorator
- class="hideOnMobile"
- name="header-top-right"
- ></gr-endpoint-decorator>
- <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
- ${this.renderFeedback()}
- </gr-endpoint-decorator>
- </div>
- ${this.renderAccount()}
- </div>
- </nav>
+ <nav class="hideOnMobile">
+ <a href=${`//${window.location.host}${getBaseUrl()}/`} class="bigTitle">
+ <gr-endpoint-decorator name="header-title">
+ <div class="titleText"></div>
+ </gr-endpoint-decorator>
+ </a>
+ <ul class="links">
+ ${this.computeLinks(
+ this.userLinks,
+ this.adminLinks,
+ this.topMenus
+ ).map(linkGroup => this.renderLinkGroup(linkGroup))}
+ </ul>
+ <div class="rightItems">
+ <gr-endpoint-decorator
+ class="hideOnMobile"
+ name="header-small-banner"
+ ></gr-endpoint-decorator>
+ <gr-smart-search id="search"></gr-smart-search>
+ <gr-endpoint-decorator
+ class="hideOnMobile"
+ name="header-top-right"
+ ></gr-endpoint-decorator>
+ <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+ ${this.renderFeedback()}
+ </gr-endpoint-decorator>
+ </div>
+ ${this.renderAccount()}
+ </div>
+ </nav>
+ `;
+ }
+
+ private renderMobile() {
+ const moreMenu: MainHeaderLink[] = [
+ {
+ name: this.registerText,
+ url: this.registerURL,
+ },
+ {
+ name: this.loginText,
+ url: this.loginUrl,
+ },
+ ];
+ if (!this.registerURL) {
+ moreMenu.shift();
+ }
+ if (this.feedbackURL) {
+ moreMenu.push({
+ name: 'Feedback',
+ url: this.feedbackURL,
+ external: true,
+ target: '_blank',
+ });
+ }
+
+ return html`
+ <nav class="hideOnDesktop">
+ <div class="nav-sidebar">
+ <ul class="menu">
+ ${this.computeLinks(
+ this.userLinks,
+ this.adminLinks,
+ this.topMenus
+ ).map(linkGroup => this.renderLinkGroupMobile(linkGroup))}
+ </ul>
+ </div>
+ <div class="nav-header">
+ <a
+ class="hamburger"
+ href=""
+ title="Hamburger"
+ aria-label="${!this.hamburgerClose ? 'Open' : 'Close'} hamburger"
+ role="button"
+ @click=${() => {
+ this.handleSidebar();
+ }}
+ >
+ ${!this.hamburgerClose
+ ? html`<gr-icon icon="menu" filled></gr-icon>`
+ : html`<gr-icon
+ class="hamburger-open"
+ icon="menu_open"
+ filled
+ ></gr-icon>`}
+ </a>
+ <a
+ href=${`//${window.location.host}${getBaseUrl()}/`}
+ class="mobileTitle bigTitle"
+ >
+ <gr-endpoint-decorator name="header-mobile-title">
+ <div class="mobileTitleText"></div>
+ </gr-endpoint-decorator>
+ </a>
+ <div class="mobileRightItems">
+ <a
+ class="searchButton"
+ title="Search"
+ @click=${(e: Event) => {
+ this.onMobileSearchTap(e);
+ }}
+ role="button"
+ aria-label=${this.mobileSearchHidden
+ ? 'Show Searchbar'
+ : 'Hide Searchbar'}
+ >
+ <gr-icon icon="search" filled></gr-icon>
+ </a>
+ <gr-dropdown
+ class="moreMenu"
+ link=""
+ .items=${moreMenu}
+ horizontal-align="center"
+ >
+ <span class="linksTitle">
+ <gr-icon icon="more_horiz" filled></gr-icon>
+ </span>
+ </gr-dropdown>
+ ${this.renderAccountDropdown(true)}
+ </div>
+ </div>
+ </nav>
+ <div class="modelBackground" @click=${() => this.handleSidebar()}></div>
`;
}
@@ -420,6 +757,41 @@
`;
}
+ private renderLinkGroupMobile(linkGroup: MainHeaderLinkGroup) {
+ return html`
+ <li class="has-collapsible" @click=${this.handleCollapsible}>
+ <a class="main" href="" data-title=${linkGroup.title}
+ >${linkGroup.title}<gr-icon
+ icon="arrow_drop_down"
+ class="arrow-down"
+ ></gr-icon
+ ></a>
+ <ul class="dropdown">
+ ${linkGroup.links.map(link => this.renderLinkMobile(link))}
+ </ul>
+ </li>
+ `;
+ }
+
+ private renderLinkMobile(link: DropdownLink) {
+ return html`
+ <li tabindex="-1">
+ <span ?hidden=${!!link.url} tabindex="-1">${link.name}</span>
+ <a
+ class="itemAction"
+ href=${this.computeLinkURL(link)}
+ ?download=${!!link.download}
+ rel=${ifDefined(this.computeLinkRel(link) ?? undefined)}
+ target=${ifDefined(link.target ?? undefined)}
+ ?hidden=${!link.url}
+ tabindex="-1"
+ @click=${() => this.handleSidebar()}
+ >${link.name}</a
+ >
+ </li>
+ `;
+ }
+
private renderFeedback() {
if (!this.feedbackURL) return;
@@ -483,11 +855,14 @@
`;
}
- private renderAccountDropdown() {
+ private renderAccountDropdown(showOnMobile?: boolean) {
if (!this.account) return;
return html`
- <gr-account-dropdown .account=${this.account}></gr-account-dropdown>
+ <gr-account-dropdown
+ .account=${this.account}
+ ?showMobile=${showOnMobile}
+ ></gr-account-dropdown>
`;
}
@@ -533,7 +908,6 @@
links.push({
title: 'Documentation',
links: docLinks,
- class: 'hideOnMobile',
});
}
links.push({
@@ -637,4 +1011,96 @@
e.stopPropagation();
fire(this, 'mobile-search', {});
}
+
+ /**
+ * 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.
+ *
+ * TODO: Move to util handler to remove duplication.
+ * @return The scheme-relative URL.
+ */
+ private 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.
+ *
+ * TODO: Move to util handler to remove duplication.
+ * @param path The path for the URL.
+ * @return The scheme-relative URL.
+ */
+ private computeRelativeURL(path: string) {
+ const host = window.location.host;
+ return this.computeURLHelper(host, path);
+ }
+
+ /**
+ * Compute the URL for a link object.
+ *
+ * Private but used in tests.
+ *
+ * TODO: Move to util handler to remove duplication.
+ */
+ private 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.
+ * Private but used in tests.
+ *
+ * TODO: Move to util handler to remove duplication.
+ */
+ private computeLinkRel(link: DropdownLink) {
+ // Note: noopener takes precedence over external.
+ if (link.target) {
+ return REL_NOOPENER;
+ }
+ if (link.external) {
+ return REL_EXTERNAL;
+ }
+ return null;
+ }
+
+ private handleCollapsible(e: MouseEvent) {
+ const target = e.target as HTMLSpanElement;
+ if (target.hasAttribute('data-title')) {
+ if (target.parentElement?.classList.contains('active')) {
+ target.parentElement.classList.remove('active');
+ } else {
+ if (this.hasCollapsibleActive) {
+ this.hasCollapsibleActive.classList.remove('active');
+ }
+ target.parentElement?.classList.toggle('active');
+ }
+ }
+ }
+
+ private handleSidebar() {
+ this.navSidebar?.classList.toggle('visible');
+ if (!this.modelBackground?.classList.contains('cover')) {
+ if (document.getElementsByTagName('html')) {
+ document.getElementsByTagName('html')[0].style.overflow = 'hidden';
+ }
+ } else {
+ if (document.getElementsByTagName('html')) {
+ document.getElementsByTagName('html')[0].style.overflow = '';
+ }
+ }
+ this.modelBackground?.classList.toggle('cover');
+ this.hasCollapsibleActive?.classList.remove('active');
+ this.hamburgerClose = !this.hamburgerClose;
+ }
}
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
index 40430fb..b4a0600 100644
--- 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
@@ -39,7 +39,7 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <nav>
+ <nav class="hideOnMobile">
<a class="bigTitle" href="//localhost:9876/">
<gr-endpoint-decorator name="header-title">
<div class="titleText"></div>
@@ -51,7 +51,7 @@
<span class="linksTitle" id="Changes"> Changes </span>
</gr-dropdown>
</li>
- <li class="hideOnMobile">
+ <li>
<gr-dropdown down-arrow="" horizontal-align="left" link="">
<span class="linksTitle" id="Documentation">Documentation</span>
</gr-dropdown>
@@ -101,6 +101,169 @@
</a>
</div>
</nav>
+ <nav class="hideOnDesktop">
+ <div class="nav-sidebar">
+ <ul class="menu">
+ <li class="has-collapsible">
+ <a class="main" data-title="Changes" href="">
+ Changes
+ <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+ </a>
+ <ul class="dropdown">
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Open </span>
+ <a
+ class="itemAction"
+ href="//localhost:9876/q/status:open+-is:wip"
+ tabindex="-1"
+ >
+ Open
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Merged </span>
+ <a
+ class="itemAction"
+ href="//localhost:9876/q/status:merged"
+ tabindex="-1"
+ >
+ Merged
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Abandoned </span>
+ <a
+ class="itemAction"
+ href="//localhost:9876/q/status:abandoned"
+ tabindex="-1"
+ >
+ Abandoned
+ </a>
+ </li>
+ </ul>
+ </li>
+ <li class="has-collapsible">
+ <a class="main" data-title="Documentation" href="">
+ Documentation
+ <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+ </a>
+ <ul class="dropdown">
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Table of Contents </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/index.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Table of Contents
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Searching </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/user-search.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Searching
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Uploading </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/user-upload.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Uploading
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Access Control </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/access-control.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Access Control
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> REST API </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/rest-api.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ REST API
+ </a>
+ </li>
+ <li tabindex="-1">
+ <span hidden="" tabindex="-1"> Project Owner Guide </span>
+ <a
+ class="itemAction"
+ href="https://gerrit-review.googlesource.com/Documentation/intro-project-owner.html"
+ rel="noopener"
+ tabindex="-1"
+ target="_blank"
+ >
+ Project Owner Guide
+ </a>
+ </li>
+ </ul>
+ </li>
+ <li class="has-collapsible">
+ <a class="main" data-title="Browse" href="">
+ Browse
+ <gr-icon class="arrow-down" icon="arrow_drop_down"> </gr-icon>
+ </a>
+ <ul class="dropdown"></ul>
+ </li>
+ </ul>
+ </div>
+ <div class="nav-header">
+ <a
+ aria-label="Open hamburger"
+ class="hamburger"
+ href=""
+ role="button"
+ title="Hamburger"
+ >
+ <gr-icon filled="" icon="menu"> </gr-icon>
+ </a>
+ <a class="bigTitle mobileTitle" href="//localhost:9876/">
+ <gr-endpoint-decorator name="header-mobile-title">
+ <div class="mobileTitleText"></div>
+ </gr-endpoint-decorator>
+ </a>
+ <div class="mobileRightItems">
+ <a
+ aria-label="Hide Searchbar"
+ class="searchButton"
+ role="button"
+ title="Search"
+ >
+ <gr-icon filled="" icon="search"> </gr-icon>
+ </a>
+ <gr-dropdown class="moreMenu" horizontal-align="center" link="">
+ <span class="linksTitle">
+ <gr-icon filled="" icon="more_horiz"> </gr-icon>
+ </span>
+ </gr-dropdown>
+ </div>
+ </div>
+ </nav>
+ <div class="modelBackground"></div>
`
);
});
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 7d62735..977ac2e 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -41,6 +41,7 @@
import {when} from 'lit/directives/when.js';
import {Timing} from '../../../constants/reporting';
import {changeModelToken} from '../../../models/change/change-model';
+import {getFileExtension} from '../../../utils/file-util';
export interface FilePreview {
filepath: string;
@@ -441,6 +442,9 @@
this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
method: 'apply-fix-dialog',
description: this.fixSuggestions?.[0].description,
+ fileExtension: getFileExtension(
+ this.fixSuggestions?.[0].replacements?.[0].path ?? ''
+ ),
});
}
}
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
index f228fb3..0a3392c 100644
--- 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
@@ -3,7 +3,6 @@
* Copyright 2018 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {Subscription} from 'rxjs';
import '@polymer/iron-a11y-announcer/iron-a11y-announcer';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icon/gr-icon';
@@ -17,6 +16,7 @@
import {sharedStyles} from '../../../styles/shared-styles';
import {userModelToken} from '../../../models/user/user-model';
import {ironAnnouncerRequestAvailability} from '../../polymer-util';
+import {subscribe} from '../../lit/subscription-controller';
@customElement('gr-diff-mode-selector')
export class GrDiffModeSelector extends LitElement {
@@ -36,28 +36,18 @@
private readonly getUserModel = resolve(this, userModelToken);
- private subscriptions: Subscription[] = [];
-
constructor() {
super();
+ subscribe(
+ this,
+ () => this.getBrowserModel().diffViewMode$,
+ x => (this.mode = x)
+ );
}
override connectedCallback() {
super.connectedCallback();
ironAnnouncerRequestAvailability();
- this.subscriptions.push(
- this.getBrowserModel().diffViewMode$.subscribe(
- diffView => (this.mode = diffView)
- )
- );
- }
-
- override disconnectedCallback() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
- super.disconnectedCallback();
}
static override get styles() {
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
index ee2a806..a5c2a99 100644
--- 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
@@ -92,7 +92,6 @@
import {when} from 'lit/directives/when.js';
import {styleMap} from 'lit/directives/style-map.js';
import {
- createDiffUrl,
ChangeChildView,
changeViewModelToken,
} from '../../../models/views/change';
@@ -1457,7 +1456,11 @@
if (!newPath) return;
if (newPath.up) return this.getChangeModel().changeUrl();
if (!newPath.path) return;
- return this.getChangeModel().diffUrl({path: newPath.path});
+ if (!this.patchNum) return;
+ return this.getViewModel().diffUrl({
+ diffView: {path: newPath.path},
+ patchNum: this.patchNum,
+ });
}
private goToEditFile() {
@@ -1504,20 +1507,14 @@
}
private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
- if (!this.change) return;
- if (!this.patchNum) return;
- if (!this.changeNum) return;
- if (!this.path) return;
- const url = createDiffUrl({
- changeNum: this.changeNum,
- repo: this.change.project,
- patchNum: this.patchNum,
- basePatchNum: this.basePatchNum,
+ if (!this.path || !this.patchNum) return;
+ const url = this.getViewModel().diffUrl({
diffView: {
path: this.path,
lineNum,
leftSide,
},
+ patchNum: this.patchNum,
});
history.replaceState(null, '', url);
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index 93424b1..96467b4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -137,9 +137,9 @@
stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
stubRestApi('getPortedComments').returns(Promise.resolve({}));
- element = await fixture(html`<gr-diff-view></gr-diff-view>`);
viewModel = testResolver(changeViewModelToken);
viewModel.setState(createDiffViewState());
+ element = await fixture(html`<gr-diff-view></gr-diff-view>`);
await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
element.path = 'some/path.txt';
element.change = createParsedChange();
@@ -188,16 +188,18 @@
test('renders', async () => {
browserModel.setScreenWidth(0);
- element.patchNum = 10 as RevisionPatchSetNum;
+ const patchNum = 10 as RevisionPatchSetNum;
+ element.patchNum = patchNum;
element.basePatchNum = PARENT;
const change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
revisions: {
- a: createRevision(10),
+ a: createRevision(patchNum),
},
};
changeModel.updateStateChange(change);
+ viewModel.updateState({patchNum});
element.files = getFilesFromFileList([
'chell.go',
'glados.txt',
@@ -1012,14 +1014,16 @@
});
test('prev/up/next links', async () => {
+ const patchNum = 10 as RevisionPatchSetNum;
viewModel.setState({
...createDiffViewState(),
+ patchNum,
});
const change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
revisions: {
- a: createRevision(10),
+ a: createRevision(patchNum),
},
};
changeModel.updateStateChange(change);
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
index 1e871ce..ec8faae 100644
--- 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
@@ -29,7 +29,7 @@
import {customElement, property, query, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
import {resolve} from '../../../models/dependency';
import {modalStyles} from '../../../styles/gr-modal-styles';
import {whenVisible} from '../../../utils/dom-util';
@@ -81,6 +81,8 @@
private readonly getChangeModel = resolve(this, changeModelToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
private readonly getNavigation = resolve(this, navigationToken);
static override get styles() {
@@ -431,13 +433,10 @@
return;
}
assertIsDefined(this.patchNum, 'patchset number');
- const url = createEditUrl({
- changeNum: this.change._number,
- repo: this.change.project,
- patchNum: this.patchNum,
+ const url = this.getViewModel().editUrl({
editView: {path: this.path},
+ patchNum: this.patchNum,
});
-
this.getNavigation().setUrl(url);
this.closeDialog(this.getDialogFromEvent(e));
};
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index 0e6778a..aacbb36 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -32,6 +32,11 @@
changeModelToken,
} from '../../../models/change/change-model';
import {SinonStubbedMember} from 'sinon';
+import {
+ ChangeChildView,
+ changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
suite('gr-edit-controls tests', () => {
let element: GrEditControls;
@@ -45,6 +50,13 @@
>;
setup(async () => {
+ testResolver(changeViewModelToken).setState({
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
+ changeNum: 42 as NumericChangeId,
+ repo: 'gerrit' as RepoName,
+ });
+
element = await fixture<GrEditControls>(html`
<gr-edit-controls></gr-edit-controls>
`);
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 15cfda6..cd19872 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -48,7 +48,7 @@
setup(async () => {
appStartedStub = sinon.stub(getAppContext().reportingService, 'appStarted');
- stubElement('gr-account-dropdown', '_getTopContent');
+ stubElement('gr-account-dropdown', 'getTopContent');
routerStartStub = sinon.stub(GrRouter.prototype, 'start');
stubRestApi('getAccount').returns(Promise.resolve(undefined));
stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
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
index 9c99ae0..2220d2f 100644
--- 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
@@ -3,20 +3,23 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '@polymer/iron-input/iron-input';
import '../../shared/gr-button/gr-button';
import {EmailInfo} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {LitElement, css, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {grFormStyles} from '../../../styles/gr-form-styles';
import {ValueChangedEvent} from '../../../types/events';
import {fire} from '../../../utils/event-util';
+import {deepClone} from '../../../utils/deep-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {subscribe} from '../../lit/subscription-controller';
@customElement('gr-email-editor')
export class GrEmailEditor extends LitElement {
- @property({type: Boolean}) hasUnsavedChanges = false;
+ @state() private originalEmails: EmailInfo[] = [];
/* private but used in test */
@state() emails: EmailInfo[] = [];
@@ -29,6 +32,21 @@
readonly restApiService = getAppContext().restApiService;
+ private readonly getUserModel = resolve(this, userModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().emails$,
+ x => {
+ if (!x) return;
+ this.originalEmails = deepClone<EmailInfo[]>(x);
+ this.emails = deepClone<EmailInfo[]>(x);
+ }
+ );
+ }
+
static override get styles() {
return [
sharedStyles,
@@ -82,24 +100,20 @@
return html`<tr>
<td class="emailColumn">${email.email}</td>
<td class="preferredControl" @click=${this.handlePreferredControlClick}>
- <iron-input
+ <!-- We have to use \`.checked\` rather then \`?checked\` as there
+ appears to be an issue when deleting, checked doesn't work correctly. -->
+ <input
class="preferredRadio"
+ type="radio"
+ name="preferred"
+ .value=${email.email}
+ .checked=${email.preferred}
@change=${this.handlePreferredChange}
- .bindValue=${email.email}
- >
- <input
- class="preferredRadio"
- type="radio"
- @change=${this.handlePreferredChange}
- name="preferred"
- ?checked=${email.preferred}
- />
- </iron-input>
+ />
</td>
<td>
<gr-button
- data-index=${index}
- @click=${this.handleDeleteButton}
+ @click=${() => this.handleDeleteButton(index)}
?disabled=${this.checkPreferred(email.preferred)}
class="remove-button"
>Delete</gr-button
@@ -108,12 +122,6 @@
</tr>`;
}
- loadData() {
- return this.restApiService.getAccountEmails().then(emails => {
- this.emails = emails ?? [];
- });
- }
-
save() {
const promises: Promise<unknown>[] = [];
@@ -127,24 +135,27 @@
);
}
- return Promise.all(promises).then(() => {
+ return Promise.all(promises).then(async () => {
this.emailsToRemove = [];
this.newPreferred = '';
- this.setHasUnsavedChanges(false);
+ await this.getUserModel().loadEmails(true);
+ this.setHasUnsavedChanges();
});
}
- private handleDeleteButton(e: Event) {
- const target = e.target;
- if (!(target instanceof Element)) return;
- const indexStr = target.getAttribute('data-index');
- if (indexStr === null) return;
- const index = Number(indexStr);
+ private handleDeleteButton(index: number) {
const email = this.emails[index];
- this.emailsToRemove = [...this.emailsToRemove, email];
+ // Don't add project to emailsToRemove if it wasn't in
+ // emails.
+ // We have to use JSON.stringify as we cloned the array
+ // so the reference is not the same.
+ const emails = this.emails.some(
+ x => JSON.stringify(email) === JSON.stringify(x)
+ );
+ if (emails) this.emailsToRemove.push(email);
this.emails.splice(index, 1);
this.requestUpdate();
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
private handlePreferredControlClick(e: Event) {
@@ -165,9 +176,10 @@
this.emails[i].preferred = true;
this.requestUpdate();
this.newPreferred = preferred;
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
} else if (this.emails[i].preferred) {
- this.emails[i].preferred = false;
+ delete this.emails[i].preferred;
+ this.setHasUnsavedChanges();
this.requestUpdate();
}
}
@@ -177,9 +189,11 @@
return preferred ?? false;
}
- private setHasUnsavedChanges(value: boolean) {
- this.hasUnsavedChanges = value;
- fire(this, 'has-unsaved-changes-changed', {value});
+ private setHasUnsavedChanges() {
+ const hasUnsavedChanges =
+ JSON.stringify(this.originalEmails) !== JSON.stringify(this.emails) ||
+ this.emailsToRemove.length > 0;
+ fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
}
}
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
index 25c9b97..84ed8dc 100644
--- 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
@@ -11,6 +11,7 @@
suite('gr-email-editor tests', () => {
let element: GrEmailEditor;
+ let accountEmailStub: sinon.SinonStub;
setup(async () => {
const emails = [
@@ -19,13 +20,14 @@
{email: 'email@three.com'},
];
- stubRestApi('getAccountEmails').returns(Promise.resolve(emails));
+ accountEmailStub = stubRestApi('getAccountEmails').returns(
+ Promise.resolve(emails)
+ );
element = await fixture<GrEmailEditor>(
html`<gr-email-editor></gr-email-editor>`
);
- await element.loadData();
await element.updateComplete;
});
@@ -45,20 +47,17 @@
<tr>
<td class="emailColumn">email@one.com</td>
<td class="preferredControl">
- <iron-input class="preferredRadio">
- <input
- class="preferredRadio"
- name="preferred"
- type="radio"
- value="email@one.com"
- />
- </iron-input>
+ <input
+ class="preferredRadio"
+ name="preferred"
+ type="radio"
+ value="email@one.com"
+ />
</td>
<td>
<gr-button
aria-disabled="false"
class="remove-button"
- data-index="0"
role="button"
tabindex="0"
>
@@ -69,21 +68,17 @@
<tr>
<td class="emailColumn">email@two.com</td>
<td class="preferredControl">
- <iron-input class="preferredRadio">
- <input
- checked=""
- class="preferredRadio"
- name="preferred"
- type="radio"
- value="email@two.com"
- />
- </iron-input>
+ <input
+ class="preferredRadio"
+ name="preferred"
+ type="radio"
+ value="email@two.com"
+ />
</td>
<td>
<gr-button
aria-disabled="true"
class="remove-button"
- data-index="1"
disabled=""
role="button"
tabindex="-1"
@@ -95,20 +90,17 @@
<tr>
<td class="emailColumn">email@three.com</td>
<td class="preferredControl">
- <iron-input class="preferredRadio">
- <input
- class="preferredRadio"
- name="preferred"
- type="radio"
- value="email@three.com"
- />
- </iron-input>
+ <input
+ class="preferredRadio"
+ name="preferred"
+ type="radio"
+ value="email@three.com"
+ />
</td>
<td>
<gr-button
aria-disabled="false"
class="remove-button"
- data-index="2"
role="button"
tabindex="0"
>
@@ -123,6 +115,12 @@
});
test('renders', () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const rows = element
.shadowRoot!.querySelector('table')!
.querySelectorAll('tbody tr');
@@ -144,15 +142,21 @@
);
assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
});
test('edit preferred', () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const radios = element
.shadowRoot!.querySelector('table')!
.querySelectorAll<HTMLInputElement>('input[type=radio]');
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
@@ -162,7 +166,7 @@
radios[0].click();
- assert.isTrue(element.hasUnsavedChanges);
+ assert.isTrue(hasUnsavedChangesSpy.called);
assert.isOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
@@ -172,18 +176,24 @@
});
test('delete email', () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const buttons = element
.shadowRoot!.querySelector('table')!
.querySelectorAll('gr-button');
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
buttons[2].click();
- assert.isTrue(element.hasUnsavedChanges);
+ assert.isTrue(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 1);
assert.equal(element.emails.length, 2);
@@ -192,6 +202,12 @@
});
test('save changes', async () => {
+ const hasUnsavedChangesSpy = sinon.spy();
+ element.addEventListener(
+ 'has-unsaved-changes-changed',
+ hasUnsavedChangesSpy
+ );
+
const deleteEmailSpy = spyRestApi('deleteAccountEmail');
const setPreferredSpy = spyRestApi('setPreferredAccountEmail');
@@ -199,7 +215,7 @@
.shadowRoot!.querySelector('table')!
.querySelectorAll('tbody tr');
- assert.isFalse(element.hasUnsavedChanges);
+ assert.isFalse(hasUnsavedChangesSpy.called);
assert.isNotOk(element.newPreferred);
assert.equal(element.emailsToRemove.length, 0);
assert.equal(element.emails.length, 3);
@@ -208,16 +224,24 @@
rows[0].querySelector('gr-button')!.click();
rows[2].querySelector<HTMLInputElement>('input[type=radio]')!.click();
- assert.isTrue(element.hasUnsavedChanges);
+ assert.isTrue(hasUnsavedChangesSpy.called);
+ assert.isTrue(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
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);
+ accountEmailStub.restore();
+
+ accountEmailStub = stubRestApi('getAccountEmails').returns(
+ Promise.resolve(element.emails)
+ );
+
await element.save();
assert.equal(deleteEmailSpy.callCount, 1);
assert.equal(deleteEmailSpy.getCall(0).args[0], 'email@one.com');
assert.isTrue(setPreferredSpy.called);
assert.equal(setPreferredSpy.getCall(0).args[0], 'email@three.com');
+ assert.isFalse(hasUnsavedChangesSpy.lastCall.args[0].detail.value);
});
});
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
index 985f9be..0b5f952 100644
--- 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
@@ -9,8 +9,12 @@
import {grFormStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
import {modalStyles} from '../../../styles/gr-modal-styles';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
declare global {
interface HTMLElementTagNameMap {
@@ -23,47 +27,48 @@
@query('#generatedPasswordModal')
generatedPasswordModal?: HTMLDialogElement;
- @property({type: String})
+ @state()
username?: string;
- @property({type: String})
+ @state()
generatedPassword?: string;
- @property({type: String})
+ @state()
status?: string;
- @property({type: String})
+ @state()
passwordUrl: string | null = null;
private readonly restApiService = getAppContext().restApiService;
- override connectedCallback() {
- super.connectedCallback();
- this.loadData();
- }
+ // Private but used in test
+ readonly getConfigModel = resolve(this, configModelToken);
- loadData() {
- const promises = [];
+ // Private but used in test
+ readonly getUserModel = resolve(this, userModelToken);
- promises.push(
- this.restApiService.getAccount().then(account => {
- if (account) {
- this.username = account.username;
- }
- })
- );
-
- promises.push(
- this.restApiService.getConfig().then(info => {
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ info => {
if (info) {
this.passwordUrl = info.auth.http_password_url || null;
} else {
this.passwordUrl = null;
}
- })
+ }
);
-
- return Promise.all(promises);
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ account => {
+ if (account) {
+ this.username = account.username;
+ }
+ }
+ );
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index 1abf8a7..d94638b 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -14,7 +14,7 @@
import {AccountDetailInfo, ServerInfo} from '../../../types/common';
import {queryAndAssert} from '../../../test/test-utils';
import {GrButton} from '../../shared/gr-button/gr-button';
-import {fixture, html, assert} from '@open-wc/testing';
+import {fixture, html, assert, waitUntil} from '@open-wc/testing';
suite('gr-http-password tests', () => {
let element: GrHttpPassword;
@@ -29,7 +29,12 @@
stubRestApi('getConfig').returns(Promise.resolve(config));
element = await fixture(html`<gr-http-password></gr-http-password>`);
- await element.loadData();
+ await waitUntil(
+ () => element.getUserModel().getState().account === account
+ );
+ await waitUntil(
+ () => element.getConfigModel().getState().serverConfig === config
+ );
await waitEventLoop();
});
@@ -121,7 +126,8 @@
test('with http_password_url', async () => {
config.auth.http_password_url = 'http://example.com/';
- await element.loadData();
+ element.passwordUrl = config.auth.http_password_url;
+ await element.updateComplete;
assert.isNotNull(element.passwordUrl);
assert.equal(element.passwordUrl, config.auth.http_password_url);
});
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
index bb0f1e9..11c8e03 100644
--- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -57,6 +57,9 @@
@query('#allowSuggestCodeWhileCommenting')
allowSuggestCodeWhileCommenting?: HTMLInputElement;
+ @query('#allowAiCommentAutocompletion')
+ allowAiCommentAutocompletion?: HTMLInputElement;
+
@query('#defaultBaseForMergesSelect')
defaultBaseForMergesSelect!: HTMLInputElement;
@@ -121,18 +124,18 @@
() => this.getConfigModel().docsBaseUrl$,
docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
);
- }
-
- override connectedCallback() {
- super.connectedCallback();
- this.getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- const suggestionsPlugins =
- this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
+ if (
+ this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
+ this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
+ ) {
+ subscribe(
+ this,
+ () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
// We currently support results from only 1 provider.
- this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
- });
+ suggestionsPlugins =>
+ (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+ );
+ }
}
static override get styles() {
@@ -284,6 +287,7 @@
</section>
${this.renderBrowserNotifications()}
${this.renderGenerateSuggestionWhenCommenting()}
+ ${this.renderAiCommentAutocompletion()}
${this.renderDefaultBaseForMerges()}
<section>
<label class="title" for="relativeDateInChangeTable"
@@ -480,11 +484,7 @@
// When the experiment is over, move this back to render(),
// removing this function.
private renderGenerateSuggestionWhenCommenting() {
- if (
- !this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
- !this.suggestionsProvider
- )
- return nothing;
+ if (!this.suggestionsProvider) return nothing;
return html`
<section id="allowSuggestCodeWhileCommentingSection">
<div class="title">
@@ -519,6 +519,37 @@
`;
}
+ // When the experiment is over, move this back to render(),
+ // removing this function.
+ private renderAiCommentAutocompletion() {
+ if (
+ !this.flagsService.isEnabled(KnownExperimentId.COMMENT_AUTOCOMPLETION) ||
+ !this.suggestionsProvider
+ )
+ return nothing;
+ return html`
+ <section id="allowAiCommentAutocompletionSection">
+ <div class="title">
+ <label for="allowAiCommentAutocompletion"
+ >AI suggested text completions while commenting</label
+ >
+ </div>
+ <span class="value">
+ <input
+ id="allowAiCommentAutocompletion"
+ type="checkbox"
+ ?checked=${this.prefs?.allow_autocompleting_comments}
+ @change=${() => {
+ this.prefs!.allow_autocompleting_comments =
+ this.allowAiCommentAutocompletion!.checked;
+ this.requestUpdate();
+ }}
+ />
+ </span>
+ </section>
+ `;
+ }
+
// When this is fixed and can be re-enabled, move this back to render()
// and remove function.
private renderDefaultBaseForMerges() {
@@ -565,6 +596,8 @@
Boolean(this.prefs?.allow_browser_notifications) ||
Boolean(this.originalPrefs?.allow_suggest_code_while_commenting) !==
Boolean(this.prefs?.allow_suggest_code_while_commenting) ||
+ Boolean(this.originalPrefs?.allow_autocompleting_comments) !==
+ Boolean(this.prefs?.allow_autocompleting_comments) ||
this.originalPrefs?.default_base_for_merges !==
this.prefs?.default_base_for_merges ||
Boolean(this.originalPrefs?.relative_date_in_change_table) !==
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
deleted file mode 100644
index c7118f7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {LitElement, css, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-import {grFormStyles} from '../../../styles/gr-form-styles';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-settings-item': GrSettingsItem;
- }
-}
-
-@customElement('gr-settings-item')
-export class GrSettingsItem extends LitElement {
- @property({type: String})
- anchor?: string;
-
- @property({type: String})
- override title = '';
-
- static override get styles() {
- return [
- grFormStyles,
- css`
- :host {
- display: block;
- margin-bottom: var(--spacing-xxl);
- }
- `,
- ];
- }
-
- override render() {
- const anchor = this.anchor ?? '';
- return html`<h2 id=${anchor} class="heading-2">${this.title}</h2>`;
- }
-}
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
deleted file mode 100644
index 6c83bea..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
-import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, html} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-settings-menu-item': GrSettingsMenuItem;
- }
-}
-
-@customElement('gr-settings-menu-item')
-export class GrSettingsMenuItem extends LitElement {
- @property({type: String})
- href?: string;
-
- @property({type: String})
- override title = '';
-
- static override get styles() {
- return [sharedStyles, pageNavStyles];
- }
-
- override render() {
- const href = this.href ?? '';
- return html` <div class="navStyles">
- <li><a href=${href}>${this.title}</a></li>
- </div>`;
- }
-}
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
index fd7d67b..7626e14 100644
--- 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
@@ -38,7 +38,7 @@
import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
-import {fireAlert, fireTitleChange} from '../../../utils/event-util';
+import {fire, fireAlert, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {LitElement, css, html} from 'lit';
@@ -190,7 +190,7 @@
const message = await this.restApiService.confirmEmail(this.emailToken);
if (message) fireAlert(this, message);
this.getViewModel().clearToken();
- await this.emailEditor.loadData();
+ await this.getUserModel().loadEmails(true);
}
override connectedCallback() {
@@ -230,8 +230,6 @@
})
);
- promises.push(this.emailEditor.loadData());
-
this._testOnly_loadingPromise = Promise.all(promises).then(() => {
this.loading = false;
@@ -252,6 +250,7 @@
css`
:host {
color: var(--primary-text-color);
+ overflow: auto;
}
h2 {
font-family: var(--header-font-family);
@@ -340,6 +339,9 @@
@unsaved-changes-changed=${(e: ValueChangedEvent<boolean>) => {
this.accountInfoChanged = e.detail.value;
}}
+ @account-detail-update=${() => {
+ fire(this, 'account-detail-update', {});
+ }}
></gr-account-info>
<gr-button
@click=${() => {
@@ -437,7 +439,6 @@
</h2>
<fieldset id="watchedProjects">
<gr-watched-projects-editor
- ?hasUnsavedChanges=${this.watchedProjectsChanged}
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
@@ -463,7 +464,6 @@
<fieldset id="email">
<gr-email-editor
id="emailEditor"
- ?hasUnsavedChanges=${this.emailsChanged}
@has-unsaved-changes-changed=${(
e: ValueChangedEvent<boolean>
) => {
@@ -471,8 +471,8 @@
}}
></gr-email-editor>
<gr-button
- @click=${() => {
- this.emailEditor.save();
+ @click=${async () => {
+ await this.emailEditor.save();
}}
?disabled=${!this.emailsChanged}
>Save changes</gr-button
@@ -605,7 +605,7 @@
};
reloadAccountDetail() {
- Promise.all([this.accountInfo.loadData(), this.emailEditor.loadData()]);
+ Promise.all([this.accountInfo.loadData()]);
}
// private but used in test
@@ -643,7 +643,7 @@
if (!this.isNewEmailValid(this.newEmail)) return;
this.addingEmail = true;
- this.restApiService.addAccountEmail(this.newEmail).then(response => {
+ this.restApiService.addAccountEmail(this.newEmail).then(async response => {
this.addingEmail = false;
// If it was unsuccessful.
@@ -653,6 +653,8 @@
this.lastSentVerificationEmail = this.newEmail;
this.newEmail = '';
+
+ await this.getUserModel().loadEmails(true);
});
}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
index b6690b6..74bcca9 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -331,12 +331,6 @@
assert.isNotOk(element.lastSentVerificationEmail);
});
- test('emails are loaded without emailToken', () => {
- const emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
- element.firstUpdated();
- assert.isTrue(emailEditorLoadDataStub.calledOnce);
- });
-
test('handleSaveChangeTable', () => {
let newColumns = ['Owner', 'Project', 'Branch'];
element.localChangeTableColumns = newColumns.slice(0);
@@ -387,10 +381,8 @@
value: string | PromiseLike<string | null> | null
) => void;
let confirmEmailStub: sinon.SinonStub;
- let emailEditorLoadDataStub: sinon.SinonStub;
setup(() => {
- emailEditorLoadDataStub = sinon.stub(element.emailEditor, 'loadData');
confirmEmailStub = stubRestApi('confirmEmail').returns(
new Promise(resolve => {
resolveConfirm = resolve;
@@ -406,16 +398,6 @@
assert.isTrue(confirmEmailStub.calledWith('foo'));
});
- test('emails are not loaded initially', () => {
- assert.isFalse(emailEditorLoadDataStub.called);
- });
-
- test('user emails are loaded after email confirmed', async () => {
- resolveConfirm('bar');
- await element._testOnly_loadingPromise;
- assert.isTrue(emailEditorLoadDataStub.calledOnce);
- });
-
test('show-alert is fired when email is confirmed', async () => {
const dispatchEventSpy = sinon.spy(element, 'dispatchEvent');
resolveConfirm('bar');
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
index f0f0f2f..583798c 100644
--- 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
@@ -6,7 +6,7 @@
import '@polymer/iron-input/iron-input';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
-import {customElement, property, query} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
import {
AutocompleteQuery,
GrAutocomplete,
@@ -22,6 +22,7 @@
import {fire} from '../../../utils/event-util';
import {PropertiesOfType} from '../../../utils/type-util';
import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {notDeepEqual} from '../../../utils/deep-util';
type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
@@ -43,13 +44,13 @@
@query('#newProject')
newProject?: GrAutocomplete;
- @property({type: Boolean})
- hasUnsavedChanges = false;
+ @state()
+ originalProjects?: ProjectWatchInfo[];
- @property({type: Array})
+ @state()
projects?: ProjectWatchInfo[];
- @property({type: Array})
+ @state()
projectsToRemove: ProjectWatchInfo[] = [];
private readonly query: AutocompleteQuery = input =>
@@ -163,7 +164,8 @@
loadData() {
return this.restApiService.getWatchedProjects().then(projs => {
- this.projects = projs;
+ this.originalProjects = projs;
+ this.projects = projs ? [...projs] : [];
});
}
@@ -186,9 +188,10 @@
}
})
.then(projects => {
- this.projects = projects;
+ this.originalProjects = projects;
+ this.projects = projects ? [...projects] : [];
this.projectsToRemove = [];
- this.setHasUnsavedChanges(false);
+ this.setHasUnsavedChanges();
});
}
@@ -206,13 +209,16 @@
}
private handleRemoveProject(project: ProjectWatchInfo) {
- if (!this.projects) return;
+ if (!this.projects || !this.originalProjects) return;
const index = this.projects.indexOf(project);
if (index < 0) return;
this.projects.splice(index, 1);
- this.projectsToRemove.push(project);
+ // Don't add project to projectsToRemove if it wasn't in
+ // originalProjects.
+ if (this.originalProjects.includes(project))
+ this.projectsToRemove.push(project);
this.requestUpdate();
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
// private but used in tests.
@@ -288,7 +294,7 @@
this.newProject.clear();
this.newFilter.value = '';
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
private handleCheckboxChange(
@@ -300,7 +306,7 @@
const checked = el.checked;
project[key] = !!checked;
this.requestUpdate();
- this.setHasUnsavedChanges(true);
+ this.setHasUnsavedChanges();
}
private handleNotifCellClick(e: Event) {
@@ -311,9 +317,11 @@
}
}
- private setHasUnsavedChanges(value: boolean) {
- this.hasUnsavedChanges = value;
- fire(this, 'has-unsaved-changes-changed', {value});
+ private setHasUnsavedChanges() {
+ const hasUnsavedChanges =
+ notDeepEqual(this.originalProjects, this.projects) ||
+ this.projectsToRemove.length > 0;
+ fire(this, 'has-unsaved-changes-changed', {value: hasUnsavedChanges});
}
isFilterDefined(filter: string | null | undefined) {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index 1db484c..7b2d3b0 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -16,7 +16,7 @@
isServiceUser,
} from '../../../utils/account-util';
import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
-import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
+import {hasOwnProperty} from '../../../utils/common-util';
import {fire} from '../../../utils/event-util';
import {isInvolved} from '../../../utils/change-util';
import {LitElement, css, html, TemplateResult} from 'lit';
@@ -25,17 +25,17 @@
import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
import {ifDefined} from 'lit/directives/if-defined.js';
import {createSearchUrl} from '../../../models/views/search';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
@customElement('gr-account-label')
export class GrAccountLabel extends LitElement {
@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
@@ -71,9 +71,6 @@
@property({type: Boolean})
hideAvatar = false;
- @state()
- _config?: ServerInfo;
-
@property({type: Boolean, reflect: true})
selectionChipStyle = false;
@@ -98,12 +95,24 @@
@property({type: Boolean, reflect: true})
avatarShown = false;
+ // Private but used in tests.
+ @state()
+ selfAccount?: AccountInfo;
+
+ // Private but used in tests.
+ @state()
+ config?: ServerInfo;
+
readonly reporting = getAppContext().reportingService;
private readonly restApiService = getAppContext().restApiService;
private readonly getAccountsModel = resolve(this, accountsModelToken);
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
static override get styles() {
return [
css`
@@ -193,32 +202,40 @@
];
}
- override async updated() {
- assertIsDefined(this.account, 'account');
+ override updated() {
+ this.computeDetailedAccount();
+ }
+
+ private async computeDetailedAccount() {
+ if (!this.account) return;
+ // If this.account is already a detailed object, then there is no need to fill it.
if (isDetailedAccount(this.account)) return;
const account = await this.getAccountsModel().fillDetails(this.account);
- if (!isDetailedAccount(account)) return;
- // AccountInfo returned by fillDetails has the email property set
- // to the primary email of the account. This poses a problem in
- // cases where a secondary email is used as the committer or author
- // email. Therefore, only fill in the *missing* properties.
if (
account &&
+ // If we were not able to get a detailed object, then there is no point in updating the
+ // account.
+ isDetailedAccount(account) &&
account !== this.account &&
- account._account_id === this.account._account_id
+ (!this.account._account_id ||
+ account._account_id === this.account._account_id)
) {
+ // AccountInfo returned by fillDetails has the email property set
+ // to the primary email of the account. This poses a problem in
+ // cases where a secondary email is used as the committer or author
+ // email. Therefore, only fill in the *missing* properties.
this.account = {...account, ...this.account};
}
}
override render() {
- const {account, change, highlightAttention, forceAttention, _config} = this;
+ const {account, change, highlightAttention, forceAttention, config} = this;
if (!account) return;
this.attentionIconShown =
forceAttention ||
this.hasUnforcedAttention(highlightAttention, account, change);
this.deselected = !this.selected;
- const hasAvatars = !!_config?.plugin?.has_avatars;
+ const hasAvatars = !!config?.plugin?.has_avatars;
this.avatarShown = !this.hideAvatar && hasAvatars;
return html`
@@ -238,7 +255,7 @@
account,
change,
false,
- this._selfAccount
+ this.selfAccount
)}
title=${this.computeAttentionIconTitle(
highlightAttention,
@@ -246,7 +263,7 @@
change,
forceAttention,
this.selected,
- this._selfAccount
+ this.selfAccount
)}
>
<gr-button
@@ -259,7 +276,7 @@
account,
change,
this.selected,
- this._selfAccount
+ this.selfAccount
)}
>
<div>
@@ -291,7 +308,7 @@
class="name"
part="gr-account-label-text"
>
- ${this.computeName(account, this.firstName, this._config)}
+ ${this.computeName(account, this.firstName, this.config)}
</span>
${this.renderAccountStatusPlugins()}
</span>
@@ -302,12 +319,16 @@
constructor() {
super();
- this.restApiService.getConfig().then(config => {
- this._config = config;
- });
- this.restApiService.getAccount().then(account => {
- this._selfAccount = account;
- });
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ x => (this.config = x)
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ x => (this.selfAccount = x)
+ );
this.addEventListener('attention-set-updated', () => {
// For re-evaluation of everything that depends on 'change'.
if (this.change) this.change = {...this.change};
@@ -386,7 +407,7 @@
// 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 reason = getRemovedByIconClickReason(this._selfAccount, this._config);
+ const reason = getRemovedByIconClickReason(this.selfAccount, this.config);
if (this.change.attention_set)
delete this.change.attention_set[this.account._account_id];
// For re-evaluation of everything that depends on 'change'.
@@ -412,7 +433,7 @@
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 selfId = this.selfAccount?._account_id || -1;
const reviewers =
this.change && this.change.reviewers && this.change.reviewers.REVIEWER
? [...this.change.reviewers.REVIEWER]
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index 71f8391..8b49cdd 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -154,11 +154,11 @@
suite('attention set', () => {
setup(async () => {
element.highlightAttention = true;
- element._config = {
+ element.config = {
...createServerInfo(),
user: {anonymous_coward_name: 'Anonymous Coward'},
};
- element._selfAccount = kermit;
+ element.selfAccount = kermit;
element.account = {
...createAccountDetailWithIdNameAndEmail(42),
name: 'ernie',
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index 863ee90..b445f75 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -17,7 +17,7 @@
import {configModelToken} from '../../../models/config/config-model';
import {subscribe} from '../../lit/subscription-controller';
import {getDisplayName} from '../../../utils/display-name-util';
-import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {accountsModelToken} from '../../../models/accounts/accounts-model';
import {isDefined} from '../../../types/types';
import {when} from 'lit/directives/when.js';
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
index 0b03581..8787d3c 100644
--- 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
@@ -70,7 +70,11 @@
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {whenRendered} from '../../../utils/dom-util';
-import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {
+ changeViewModelToken,
+ createChangeUrl,
+ createDiffUrl,
+} from '../../../models/views/change';
import {userModelToken} from '../../../models/user/user-model';
import {highlightServiceToken} from '../../../services/highlight/highlight-service';
import {noAwait, waitUntil} from '../../../utils/async-util';
@@ -248,6 +252,8 @@
private readonly getUserModel = resolve(this, userModelToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
private readonly shortcuts = new ShortcutController(this);
private readonly syntaxLayer = new GrSyntaxLayerWorker(
@@ -706,12 +712,15 @@
}
private getDiffUrlForPath() {
- if (!this.changeNum || !this.repoName || !this.thread?.path) {
+ if (
+ !this.changeNum ||
+ !this.repoName ||
+ !this.thread?.path ||
+ !this.thread?.patchNum
+ ) {
return undefined;
}
- return createDiffUrl({
- changeNum: this.changeNum,
- repo: this.repoName,
+ return this.getViewModel().diffUrl({
patchNum: this.thread.patchNum,
diffView: {path: this.thread.path},
});
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 571a322..b56fcfd 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -38,6 +38,11 @@
commentsModelToken,
} from '../../../models/comments/comments-model';
import {testResolver} from '../../../test/common-test-setup';
+import {
+ ChangeChildView,
+ changeViewModelToken,
+} from '../../../models/views/change';
+import {GerritView} from '../../../services/router/router-model';
const c1: CommentInfo = {
author: {name: 'Kermit'},
@@ -80,6 +85,12 @@
let element: GrCommentThread;
setup(async () => {
+ testResolver(changeViewModelToken).setState({
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
+ changeNum: 1 as NumericChangeId,
+ repo: 'test-repo-name' as RepoName,
+ });
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
element = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
element.changeNum = 1 as NumericChangeId;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index 415a6ef..9313739 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -10,7 +10,7 @@
import '../gr-dialog/gr-dialog';
import '../gr-formatted-text/gr-formatted-text';
import '../gr-icon/gr-icon';
-import '../gr-textarea/gr-textarea';
+import '../gr-suggestion-textarea/gr-suggestion-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';
@@ -20,7 +20,7 @@
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {provide, resolve} from '../../../models/dependency';
-import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrSuggestionTextarea} from '../gr-suggestion-textarea/gr-suggestion-textarea';
import {
AccountDetailInfo,
DraftInfo,
@@ -79,8 +79,12 @@
commentModelToken,
} from '../gr-comment-model/gr-comment-model';
import {formStyles} from '../../../styles/form-styles';
-import {Interaction} from '../../../constants/reporting';
-import {Suggestion, SuggestionsProvider} from '../../../api/suggestions';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {
+ AutocompleteCommentResponse,
+ Suggestion,
+ SuggestionsProvider,
+} from '../../../api/suggestions';
import {when} from 'lit/directives/when.js';
import {getDocUrl} from '../../../utils/url-util';
import {configModelToken} from '../../../models/config/config-model';
@@ -89,10 +93,17 @@
import {deepEqual} from '../../../utils/deep-util';
import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview';
import {waitUntil} from '../../../utils/async-util';
+import {
+ AutocompleteCache,
+ AutocompletionContext,
+} from '../../../utils/autocomplete-cache';
+import {HintAppliedEventDetail, HintShownEventDetail} from '../../../api/embed';
+import {levenshteinDistance} from '../../../utils/string-util';
// visible for testing
export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
export const GENERATE_SUGGESTION_DEBOUNCE_DELAY_MS = 500;
+export const AUTOCOMPLETE_DEBOUNCE_DELAY_MS = 200;
export const ENABLE_GENERATE_SUGGESTION_STORAGE_KEY =
'enableGenerateSuggestionStorageKeyForCommentWithId-';
@@ -143,7 +154,7 @@
*/
@query('#editTextarea')
- textarea?: GrTextarea;
+ textarea?: GrSuggestionTextarea;
@query('#container')
container?: HTMLElement;
@@ -219,6 +230,19 @@
@state()
messageText = '';
+ /**
+ * An hint for autocompleting the comment message from plugin suggestion
+ * providers.
+ */
+ @state() autocompleteHint?: AutocompletionContext;
+
+ private autocompleteAcceptedHints: string[] = [];
+
+ /** Based on user preferences. */
+ @state() autocompleteEnabled = true;
+
+ readonly autocompleteCache = new AutocompleteCache();
+
/* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
@state()
unresolved = true;
@@ -300,6 +324,12 @@
private generateSuggestionTrigger$ = new Subject();
/**
+ * This is triggered when the user types into the editing textarea. We then
+ * debounce it and call autocompleteComment().
+ */
+ private autocompleteTrigger$ = new Subject();
+
+ /**
* Set to the content of DraftInfo when entering editing mode.
* Only used for "Cancel".
*/
@@ -392,6 +422,23 @@
() => this.getConfigModel().docsBaseUrl$,
docsBaseUrl => (this.docsBaseUrl = docsBaseUrl)
);
+ subscribe(
+ this,
+ () => this.getPluginLoader().pluginsModel.suggestionsPlugins$,
+ // We currently support results from only 1 provider.
+ suggestionsPlugins =>
+ (this.suggestionsProvider = suggestionsPlugins?.[0]?.provider)
+ );
+ subscribe(
+ this,
+ () =>
+ this.autocompleteTrigger$.pipe(
+ debounceTime(AUTOCOMPLETE_DEBOUNCE_DELAY_MS)
+ ),
+ () => {
+ this.autocompleteComment();
+ }
+ );
if (
this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT) ||
this.flagsService.isEnabled(KnownExperimentId.ML_SUGGESTED_EDIT_V2)
@@ -410,6 +457,7 @@
this,
() => this.getUserModel().preferences$,
prefs => {
+ this.autocompleteEnabled = !!prefs.allow_autocompleting_comments;
if (
this.generateSuggestion !==
!!prefs.allow_suggest_code_while_commenting
@@ -424,15 +472,6 @@
override connectedCallback() {
super.connectedCallback();
- this.getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- const suggestionsPlugins =
- this.getPluginLoader().pluginsModel.getState().suggestionsPlugins;
- // We currently support results from only 1 provider.
- this.suggestionsProvider = suggestionsPlugins?.[0]?.provider;
- });
-
if (this.comment?.id) {
const generateSuggestionStoredContent =
this.getStorage().getEditableContentItem(
@@ -650,6 +689,10 @@
/* Making up for the 2px reduced height above. */
top: 1px;
}
+ gr-suggestion-diff-preview,
+ gr-fix-suggestions {
+ margin-top: var(--spacing-s);
+ }
`,
];
}
@@ -864,7 +907,7 @@
private renderEditingTextarea() {
if (!this.editing || this.collapsed) return;
return html`
- <gr-textarea
+ <gr-suggestion-textarea
id="editTextarea"
class="editMessage"
autocomplete="on"
@@ -872,19 +915,97 @@
rows="4"
.placeholder=${this.messagePlaceholder}
text=${this.messageText}
- @text-changed=${(e: ValueChangedEvent) => {
- // TODO: This is causing a re-render of <gr-comment> on every key
- // press. Try to avoid always setting `this.messageText` or at least
- // debounce it. Most of the code can just inspect the current value
- // of the textare instead of needing a dedicated property.
- this.messageText = e.detail.value;
- this.autoSaveTrigger$.next();
- this.generateSuggestionTrigger$.next();
- }}
- ></gr-textarea>
+ autocompleteHint=${this.autocompleteHint?.commentCompletion ?? ''}
+ @text-changed=${this.handleTextChanged}
+ @hintShown=${this.handleHintShown}
+ @hintApplied=${this.handleHintApplied}
+ ></gr-suggestion-textarea>
`;
}
+ private handleHintShown(e: CustomEvent<HintShownEventDetail>) {
+ const context = this.autocompleteCache.get(e.detail.oldValue);
+ if (context?.commentCompletion !== e.detail.hint) return;
+
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SUGGESTION_SHOWN,
+ context
+ );
+ }
+
+ private handleHintApplied(e: CustomEvent<HintAppliedEventDetail>) {
+ const context = this.autocompleteCache.get(e.detail.oldValue);
+ if (context?.commentCompletion !== e.detail.hint) return;
+
+ this.autocompleteAcceptedHints.push(e.detail.hint);
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SUGGESTION_ACCEPTED,
+ context
+ );
+ }
+
+ private reportHintInteractionSaved() {
+ if (this.autocompleteAcceptedHints.length === 0) return;
+ const content = this.messageText.trimEnd();
+ const acceptedHintsConcatenated = this.autocompleteAcceptedHints.join('');
+ const numExtraCharacters =
+ content.length - acceptedHintsConcatenated.length;
+ let distance = levenshteinDistance(acceptedHintsConcatenated, content);
+ if (numExtraCharacters > 0) {
+ distance -= numExtraCharacters;
+ }
+ const context = {
+ ...this.createAutocompletionBaseContext(),
+ similarCharacters: acceptedHintsConcatenated.length - distance,
+ maxSimilarCharacters: acceptedHintsConcatenated.length,
+ acceptedSuggestionsCount: this.autocompleteAcceptedHints.length,
+ totalAcceptedCharacters: acceptedHintsConcatenated.length,
+ savedDraftLength: content.length,
+ };
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SAVE_DRAFT,
+ context
+ );
+ }
+
+ private reportHintInteraction(
+ interaction: Interaction,
+ context: Partial<AutocompletionContext>
+ ) {
+ context = {
+ ...context,
+ draftContent: '[REDACTED]',
+ commentCompletion: '[REDACTED]',
+ };
+ this.reporting.reportInteraction(interaction, context);
+ }
+
+ private handleTextChanged(e: ValueChangedEvent) {
+ const oldValue = this.messageText;
+ const newValue = e.detail.value;
+ if (oldValue === newValue) return;
+ // TODO: This is causing a re-render of <gr-comment> on every key
+ // press. Try to avoid always setting `this.messageText` or at least
+ // debounce it. Most of the code can just inspect the current value
+ // of the textare instead of needing a dedicated property.
+ this.messageText = newValue;
+
+ this.handleTextChangedForAutocomplete();
+ this.autoSaveTrigger$.next();
+ this.generateSuggestionTrigger$.next();
+ }
+
+ // visible for testing
+ handleTextChangedForAutocomplete() {
+ const cachedHint = this.autocompleteCache.get(this.messageText);
+ if (cachedHint) {
+ this.autocompleteHint = cachedHint;
+ } else {
+ this.autocompleteHint = undefined;
+ this.autocompleteTrigger$.next();
+ }
+ }
+
private renderCommentMessage() {
if (this.collapsed || this.editing) return;
@@ -902,6 +1023,7 @@
private renderCopyLinkIcon() {
// Only show the icon when the thread contains a published comment.
if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
+ if (this.editing) return;
return html`
<gr-icon
icon="link"
@@ -945,6 +1067,7 @@
${this.renderDiscardButton()} ${this.renderEditButton()}
${this.renderCancelButton()} ${this.renderSaveButton()}
${this.renderCopyLinkIcon()}
+ <gr-endpoint-slot name="draft-actions-end"></gr-endpoint-slot>
</div>
`;
}
@@ -1193,6 +1316,7 @@
uuid: this.generatedSuggestionId,
type: 'suggest-code',
commentId: this.comment.id,
+ fileExtension: getFileExtension(this.comment.path ?? ''),
});
this.suggestionLoading = true;
let suggestionResponse;
@@ -1216,9 +1340,11 @@
this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
uuid: this.generatedSuggestionId,
type: 'suggest-code',
+ commentId: this.comment.id,
response: suggestionResponse.responseCode,
numSuggestions: suggestionResponse.suggestions.length,
hasNewRange: suggestionResponse.suggestions?.[0]?.newRange !== undefined,
+ fileExtension: getFileExtension(this.comment.path ?? ''),
});
const suggestion = suggestionResponse.suggestions?.[0];
if (!suggestion?.replacement) return;
@@ -1244,6 +1370,7 @@
uuid: this.generatedSuggestionId,
type: 'suggest-fix',
commentId: this.comment.id,
+ fileExtension: getFileExtension(this.comment.path ?? ''),
});
this.suggestionLoading = true;
let suggestionResponse;
@@ -1267,8 +1394,11 @@
this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_RESPONSE, {
uuid: this.generatedSuggestionId,
type: 'suggest-fix',
+ commentId: this.comment.id,
response: suggestionResponse.responseCode,
numSuggestions: suggestionResponse.fix_suggestions.length,
+ fileExtension: getFileExtension(this.comment.path ?? ''),
+ logProbability: suggestionResponse.fix_suggestions?.[0].log_probability,
});
const suggestion = suggestionResponse.fix_suggestions?.[0];
if (!suggestion?.replacements || suggestion.replacements.length === 0) {
@@ -1284,6 +1414,82 @@
}
}
+ private async autocompleteComment() {
+ const enabled = this.flagsService.isEnabled(
+ KnownExperimentId.COMMENT_AUTOCOMPLETION
+ );
+ const suggestionsProvider = this.suggestionsProvider;
+ const change = this.getChangeModel().getChange();
+ if (
+ !enabled ||
+ !this.autocompleteEnabled ||
+ !suggestionsProvider?.autocompleteComment ||
+ !change ||
+ !this.comment?.patch_set ||
+ !this.comment.path ||
+ this.messageText.length === 0
+ ) {
+ return;
+ }
+ const commentText = this.messageText;
+ this.reporting.time(Timing.COMMENT_COMPLETION);
+ const response = await suggestionsProvider.autocompleteComment({
+ id: id(this.comment),
+ commentText,
+ changeInfo: change as ChangeInfo,
+ patchsetNumber: this.comment?.patch_set,
+ filePath: this.comment.path,
+ range: this.comment.range,
+ lineNumber: this.comment.line,
+ });
+ const elapsed = this.reporting.timeEnd(Timing.COMMENT_COMPLETION);
+ const context = this.createAutocompletionContext(
+ commentText,
+ response,
+ elapsed
+ );
+ this.reportHintInteraction(
+ Interaction.COMMENT_COMPLETION_SUGGESTION_FETCHED,
+ {...context, hasDraftChanged: this.messageText !== commentText}
+ );
+ if (!response?.completion) return;
+ // Note that we are setting the cache value for `commentText` and getting the value
+ // for `this.messageText`.
+ this.autocompleteCache.set(context);
+ this.autocompleteHint = this.autocompleteCache.get(this.messageText);
+ }
+
+ private createAutocompletionBaseContext(): Partial<AutocompletionContext> {
+ return {
+ commentId: id(this.comment!),
+ commentNumber: this.comments?.length ?? 0,
+ filePath: this.comment!.path,
+ fileExtension: getFileExtension(this.comment!.path ?? ''),
+ };
+ }
+
+ private createAutocompletionContext(
+ draftContent: string,
+ response: AutocompleteCommentResponse,
+ requestDurationMs: number
+ ): AutocompletionContext {
+ const commentCompletion = response.completion ?? '';
+ return {
+ ...this.createAutocompletionBaseContext(),
+
+ draftContent,
+ draftContentLength: draftContent.length,
+ commentCompletion,
+ commentCompletionLength: commentCompletion.length,
+
+ isFullCommentPrediction: draftContent.length === 0,
+ draftInSyncWithSuggestionLength: 0,
+ modelVersion: response.modelVersion ?? '',
+ outcome: response.outcome,
+ requestDurationMs,
+ };
+ }
+
private renderRobotActions() {
if (!this.account || !isRobot(this.comment)) return;
const endpoint = html`
@@ -1710,6 +1916,7 @@
if (this.isFixSuggestionChanged()) {
draft.fix_suggestions = this.getFixSuggestions();
}
+ this.reportHintInteractionSaved();
return this.getCommentsModel().saveDraft(draft, options.showToast);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index b849b10..5273439 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -343,6 +343,8 @@
>
Edit
</gr-button>
+ <gr-endpoint-slot name="draft-actions-end">
+ </gr-endpoint-slot>
</div>
</div>
</div>
@@ -407,15 +409,16 @@
</div>
</div>
<div class="body">
- <gr-textarea
+ <gr-suggestion-textarea
autocomplete="on"
+ autocompletehint=""
class="code editMessage"
code=""
id="editTextarea"
rows="4"
text="This is the test comment message."
>
- </gr-textarea>
+ </gr-suggestion-textarea>
<gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
<div class="actions">
<div class="leftActions">
@@ -445,6 +448,8 @@
>
Save
</gr-button>
+ <gr-endpoint-slot name="draft-actions-end">
+ </gr-endpoint-slot>
</div>
</div>
</div>
@@ -593,7 +598,7 @@
element.messageText = 'is that the horse from horsing around??';
element.editing = true;
await element.updateComplete;
- pressKey(element.textarea!.textarea!.textarea, 's', Modifier.CTRL_KEY);
+ pressKey(element.textarea!, 's', Modifier.CTRL_KEY);
assert.isTrue(spy.called);
});
@@ -603,11 +608,7 @@
element.messageText = 'is that the horse from horsing around??';
element.editing = true;
await element.updateComplete;
- pressKey(
- element.textarea!.textarea!.textarea,
- Key.ENTER,
- Modifier.CTRL_KEY
- );
+ pressKey(element.textarea!, Key.ENTER, Modifier.CTRL_KEY);
assert.isTrue(spy.called);
});
test('propagates on patchset comment', async () => {
@@ -896,6 +897,44 @@
});
});
+ suite('handleTextChangedForAutocomplete', () => {
+ test('foo -> foo with asdf', async () => {
+ const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'foo';
+ element.handleTextChangedForAutocomplete();
+ assert.equal(element.autocompleteHint.commentCompletion, 'asdf');
+ });
+
+ test('foo -> bar with asdf', async () => {
+ const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'bar';
+ element.handleTextChangedForAutocomplete();
+ assert.isUndefined(element.autocompleteHint);
+ });
+
+ test('foo -> foofoo with asdf', async () => {
+ const ctx = {draftContent: 'foo', commentCompletion: 'asdf'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'foofoo';
+ element.handleTextChangedForAutocomplete();
+ assert.isUndefined(element.autocompleteHint);
+ });
+
+ test('foo -> foofoo with foomore', async () => {
+ const ctx = {draftContent: 'foo', commentCompletion: 'foomore'};
+ element.autocompleteHint = ctx;
+ element.autocompleteCache.set(ctx);
+ element.messageText = 'foofoo';
+ element.handleTextChangedForAutocomplete();
+ assert.equal(element.autocompleteHint.commentCompletion, 'more');
+ });
+ });
+
suite('suggest edit', () => {
let element: GrComment;
setup(async () => {
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
index babe5e2..2ab3dd5 100644
--- 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
@@ -3,11 +3,9 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {Subscription} from 'rxjs';
import '@polymer/paper-tabs/paper-tab';
import '@polymer/paper-tabs/paper-tabs';
import '../gr-shell-command/gr-shell-command';
-import {getAppContext} from '../../../services/app-context';
import {queryAndAssert} from '../../../utils/common-util';
import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
import {paperStyles} from '../../../styles/gr-paper-styles';
@@ -18,6 +16,7 @@
import {BindValueChangeEvent} from '../../../types/events';
import {resolve} from '../../../models/dependency';
import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
declare global {
interface HTMLElementEventMap {
@@ -55,37 +54,29 @@
@property({type: Boolean, attribute: 'show-keyboard-shortcut-tooltips'})
showKeyboardShortcutTooltips = false;
- private readonly restApiService = getAppContext().restApiService;
-
// Private but used in tests.
readonly getUserModel = resolve(this, userModelToken);
- private subscriptions: Subscription[] = [];
-
- override connectedCallback() {
- super.connectedCallback();
- this.restApiService.getLoggedIn().then(loggedIn => {
- this.loggedIn = loggedIn;
- });
- this.subscriptions.push(
- this.getUserModel().preferences$.subscribe(prefs => {
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().loggedIn$,
+ x => (this.loggedIn = x)
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().preferences$,
+ prefs => {
if (prefs?.download_scheme) {
// Note (issue 5180): normalize the download scheme with lower-case.
this.selectedScheme = prefs.download_scheme.toLowerCase();
fire(this, 'selected-scheme-changed', {value: this.selectedScheme});
}
- })
+ }
);
}
- override disconnectedCallback() {
- for (const s of this.subscriptions) {
- s.unsubscribe();
- }
- this.subscriptions = [];
- super.disconnectedCallback();
- }
-
static override get styles() {
return [
paperStyles,
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
index 3964422..eeccda6 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -43,7 +43,7 @@
import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
import {resolve} from '../../../models/dependency';
import {formStyles} from '../../../styles/form-styles';
-import {createEditUrl} from '../../../models/views/change';
+import {changeViewModelToken} from '../../../models/views/change';
import {SpecialFilePath} from '../../../constants/constants';
const RESTORED_MESSAGE = 'Content restored from a previous edit.';
@@ -128,6 +128,8 @@
private readonly getNavigation = resolve(this, navigationToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
+
// Tests use this so needs to be non private
storeTask?: DelayedTask;
@@ -500,12 +502,11 @@
if (this.editMode) {
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.repoName, 'repoName');
+ assertIsDefined(this.patchNum, 'patchNum');
this.getNavigation().setUrl(
- createEditUrl({
- changeNum: this.changeNum,
- repo: this.repoName,
- patchNum: this.patchNum,
+ this.getViewModel().editUrl({
editView: {path: SpecialFilePath.COMMIT_MESSAGE},
+ patchNum: this.patchNum,
})
);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index e8ccdd1..dbb89db 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -19,6 +19,7 @@
RepoName,
RevisionPatchSetNum,
} from '../../../api/rest-api';
+import {changeViewModelToken} from '../../../models/views/change';
const emails = [
{
@@ -189,6 +190,11 @@
suite('in editMode', () => {
test('click opens edit url', async () => {
+ const editUrlStub = sinon.stub(
+ testResolver(changeViewModelToken),
+ 'editUrl'
+ );
+ editUrlStub.returns('fakeUrl');
const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
element.editMode = true;
element.changeNum = 42 as NumericChangeId;
@@ -201,10 +207,7 @@
);
editButton.click();
assert.isTrue(setUrlStub.called);
- assert.equal(
- setUrlStub.lastCall.args[0],
- '/c/Test+Repo/+/42/1//COMMIT_MSG,edit'
- );
+ assert.equal(setUrlStub.lastCall.args[0], 'fakeUrl');
});
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 7cb1d00..195eeb6 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -87,6 +87,9 @@
static override get styles() {
return [
css`
+ :host {
+ display: block;
+ }
.header {
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
@@ -170,7 +173,11 @@
};
}),
patchNum: this.comment.patch_set,
- onCloseFixPreviewCallbacks: [],
+ onCloseFixPreviewCallbacks: [
+ fixApplied => {
+ if (fixApplied) fire(this, 'apply-user-suggestion', {});
+ },
+ ],
};
fire(this, 'open-fix-preview', eventDetail);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
index 8314912..e1d4f89 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -30,6 +30,7 @@
import {fire} from '../../../utils/event-util';
import {Interaction, Timing} from '../../../constants/reporting';
import {createChangeUrl} from '../../../models/views/change';
+import {getFileExtension} from '../../../utils/file-util';
declare global {
interface HTMLElementEventMap {
@@ -151,9 +152,16 @@
static override get styles() {
return [
css`
+ :host {
+ display: block;
+ }
.buttons {
text-align: right;
}
+ .diff-container {
+ border: 1px solid var(--border-color);
+ border-top: none;
+ }
code {
max-width: var(--gr-formatted-text-prose-max-width, none);
background-color: var(--background-color-secondary);
@@ -220,14 +228,16 @@
if (!anyLineTooLong(diff)) {
this.syntaxLayer.process(diff);
}
- return html`<gr-diff
- .prefs=${this.overridePartialDiffPrefs()}
- .path=${this.preview.filepath}
- .diff=${diff}
- .layers=${this.layers}
- .renderPrefs=${this.renderPrefs}
- .viewMode=${DiffViewMode.UNIFIED}
- ></gr-diff>`;
+ return html`<div class="diff-container">
+ <gr-diff
+ .prefs=${this.overridePartialDiffPrefs()}
+ .path=${this.preview.filepath}
+ .diff=${diff}
+ .layers=${this.layers}
+ .renderPrefs=${this.renderPrefs}
+ .viewMode=${DiffViewMode.UNIFIED}
+ ></gr-diff>
+ </div>`;
}
private async fetchFixPreview() {
@@ -255,6 +265,7 @@
});
this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
uuid: this.uuid,
+ commentId: this.comment?.id ?? '',
});
if (currentPreviews.length > 0) {
this.preview = currentPreviews[0];
@@ -287,6 +298,7 @@
});
this.reporting.timeEnd(Timing.PREVIEW_FIX_LOAD, {
uuid: this.uuid,
+ commentId: this.comment?.id ?? '',
});
if (currentPreviews.length > 0) {
this.preview = currentPreviews[0];
@@ -343,6 +355,10 @@
this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, {
method: '1-click',
description: fixSuggestion.description,
+ fileExtension: getFileExtension(
+ fixSuggestion?.replacements?.[0].path ?? ''
+ ),
+ commentId: this.comment?.id ?? '',
});
if (res?.ok) {
this.getNavigation().setUrl(
@@ -371,6 +387,7 @@
if (!this.suggestion) return;
this.reporting.reportInteraction(Interaction.GENERATE_SUGGESTION_ADDED, {
uuid: this.uuid,
+ commentId: this.comment?.id ?? '',
});
fire(this, 'add-generated-suggestion', {code: this.suggestion});
}
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
index 86be868..2630aad 100644
--- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -103,10 +103,12 @@
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-diff
- class="disable-context-control-buttons hide-line-length-indicator"
- >
- </gr-diff>
+ <div class="diff-container">
+ <gr-diff
+ class="disable-context-control-buttons hide-line-length-indicator"
+ >
+ </gr-diff>
+ </div>
`,
{ignoreAttributes: ['style']}
);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
similarity index 86%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
index 7f70911..d0f6917 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -7,15 +7,14 @@
import '../gr-cursor-manager/gr-cursor-manager';
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
+import '../../../embed/gr-textarea';
import {getAppContext} from '../../../services/app-context';
-import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import {
GrAutocompleteDropdown,
Item,
ItemSelectedEventDetail,
} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {Key} from '../../../utils/dom-util';
-import {ValueChangedEvent} from '../../../types/events';
import {fire} from '../../../utils/event-util';
import {LitElement, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
@@ -31,6 +30,7 @@
import {getAccountDisplayName} from '../../../utils/display-name-util';
import {configModelToken} from '../../../models/config/config-model';
import {formStyles} from '../../../styles/form-styles';
+import {GrTextarea} from '../../../embed/gr-textarea';
const MAX_ITEMS_DROPDOWN = 10;
@@ -72,12 +72,12 @@
}
}
-@customElement('gr-textarea')
-export class GrTextarea extends LitElement {
+@customElement('gr-suggestion-textarea')
+export class GrSuggestionTextarea extends LitElement {
/**
* @event bind-value-changed
*/
- @query('#textarea') textarea?: IronAutogrowTextareaElement;
+ @query('#textarea') textarea?: GrTextarea;
@query('#emojiSuggestions') emojiSuggestions?: GrAutocompleteDropdown;
@@ -108,6 +108,12 @@
standard monospace font. */
@property({type: Boolean}) code = false;
+ /**
+ * An autocompletion hint that is passed to <gr-textarea>, which will allow\
+ * the user to accept it by pressing tab.
+ */
+ @property({type: String}) autocompleteHint = '';
+
@state() suggestions: (Item | EmojiSuggestion)[] = [];
// Accessed in tests.
@@ -170,6 +176,7 @@
override connectedCallback() {
super.connectedCallback();
+
if (this.monospace) {
this.classList.add('monospace');
}
@@ -206,14 +213,27 @@
#textarea {
background-color: var(--view-background-color);
width: 100%;
+ color: var(--primary-text-color);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ padding: 0;
+ box-sizing: border-box;
+ position: relative;
+ --gr-textarea-padding: var(--spacing-s);
+ --gr-textarea-border-width: 0px;
+ --gr-textarea-border-color: var(--border-color);
+ --input-field-bg: var(--view-background-color);
+ --input-field-disabled-bg: var(--view-background-color);
+ --secondary-bg-color: var(--background-color-secondary);
+ --text-default: var(--primary-text-color);
+ --text-disabled: var(--deemphasized-text-color);
+ --text-secondary: var(--deemphasized-text-color);
+ --iron-autogrow-textarea_-_padding: var(--spacing-s);
}
#hiddenText #emojiSuggestions {
visibility: visible;
white-space: normal;
}
- iron-autogrow-textarea {
- position: relative;
- }
#textarea.noBorder {
border: none;
}
@@ -237,22 +257,26 @@
it is set as the positionTarget for the emojiSuggestions dropdown. -->
<span id="caratSpan"></span>
${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
- <iron-autogrow-textarea
- id="textarea"
- class=${classMap({noBorder: this.hideBorder})}
- .autocomplete=${this.autocomplete}
- .placeholder=${this.placeholder}
- ?disabled=${this.disabled}
- .rows=${this.rows}
- .maxRows=${this.maxRows}
- .value=${this.text}
- @value-changed=${(e: ValueChangedEvent) => {
- this.text = e.detail.value;
- }}
- ></iron-autogrow-textarea>
+ ${this.renderTextarea()}
`;
}
+ private renderTextarea() {
+ return html`<gr-textarea
+ id="textarea"
+ putCursorAtEndOnFocus
+ class=${classMap({noBorder: this.hideBorder})}
+ .placeholder=${this.placeholder}
+ ?disabled=${this.disabled}
+ .value=${this.text}
+ .hint=${this.autocompleteHint}
+ @input=${(e: InputEvent) => {
+ const value = (e.target as GrTextarea).value;
+ this.text = value ?? '';
+ }}
+ ></gr-textarea>`;
+ }
+
private renderEmojiDropdown() {
return html`
<gr-autocomplete-dropdown
@@ -282,9 +306,6 @@
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('text')) {
this.fireChangedEvents();
- // Add to updated because we want this.textarea.selectionStart and
- // this.textarea is null in the willUpdate lifecycle
- this.computeIndexAndSearchString();
this.handleTextChanged();
}
}
@@ -295,22 +316,14 @@
this.emojiSuggestions?.close();
}
- getNativeTextarea() {
- return this.textarea!.textarea;
- }
-
+ // Note that this may not work as intended, because the textarea is not
+ // rendered yet.
override focus() {
- // Note that this may not work as intended, because the textarea is not
- // rendered yet.
- this.textarea?.textarea.focus();
+ this.textarea?.focus();
}
putCursorAtEnd() {
- const textarea = this.getNativeTextarea();
- // Put the cursor at the end always.
- textarea.selectionStart = textarea.value.length;
- textarea.selectionEnd = textarea.selectionStart;
- textarea.focus();
+ this.textarea?.putCursorAtEnd();
}
private getVisibleDropdown() {
@@ -433,11 +446,14 @@
// below needs to happen after iron-autogrow-textarea has set the
// incorrect value.
await this.updateComplete;
- this.textarea!.selectionStart = specialCharIndex + text.length + move;
- this.textarea!.selectionEnd = specialCharIndex + text.length + move;
+ this.setCursorPosition(specialCharIndex + text.length + move);
this.resetDropdown();
}
+ setCursorPosition(pos: number) {
+ this.textarea?.setCursorPosition(pos);
+ }
+
private addValueToText(value: string) {
if (!this.text) return '';
const specialCharIndex = this.specialCharIndex ?? 0;
@@ -456,12 +472,11 @@
* private but used in test
*/
updateCaratPosition() {
- if (typeof this.textarea!.value === 'string') {
- this.hiddenText!.textContent = this.textarea!.value.substring(
- 0,
- this.textarea!.selectionStart
- );
+ let position = this.textarea?.getCursorPosition() ?? -1;
+ if (position === -1) {
+ position = this.text.length;
}
+ this.hiddenText!.textContent = this.text.substring(0, position);
const caratSpan = this.caratSpan!;
this.hiddenText!.appendChild(caratSpan);
@@ -474,9 +489,9 @@
// - The search string is an space or new line
// - The colon has been removed
// - There are no suggestions that match the search string
+ const position = this.textarea?.getCursorPosition() ?? -1;
return (
- this.textarea!.selectionStart !==
- (this.currentSearchString ?? '').length + charIndex + 1 ||
+ position !== (this.currentSearchString ?? '').length + charIndex + 1 ||
this.currentSearchString === ' ' ||
this.currentSearchString === '\n' ||
!(text[charIndex] === char)
@@ -522,7 +537,7 @@
)
) {
this.resetDropdown();
- } else if (activeDropdown!.isHidden && this.textarea!.focused) {
+ } else if (activeDropdown!.isHidden && this.isTextareaFocused()) {
// Otherwise open the dropdown and set the position to be just below the
// cursor.
// Do not open dropdown if textarea is not focused
@@ -543,8 +558,11 @@
);
}
- private computeIndexAndSearchString() {
- const currentCarat = this.textarea?.selectionStart ?? this.text.length;
+ public computeIndexAndSearchString() {
+ let currentCarat = this.textarea?.getCursorPosition() ?? -1;
+ if (currentCarat === -1) {
+ currentCarat = this.text.length;
+ }
const m = this.text
.substring(0, currentCarat)
.match(/(?:^|\s)([:@][\S]*)$/);
@@ -561,6 +579,7 @@
// Private but used in tests.
async handleTextChanged() {
+ this.computeIndexAndSearchString();
await this.computeSuggestions();
this.openOrResetDropdown();
this.focus();
@@ -643,10 +662,8 @@
// When nothing is selected, selectionStart is the caret position. We want
// the indentation level of the current line, not the end of the text which
// may be different.
- const currentLine = this.textarea!.textarea.value.substring(
- 0,
- this.textarea!.selectionStart
- )
+ const currentLine = this.text
+ .substring(0, this.textarea?.getCursorPosition() ?? -1)
.split('\n')
.pop();
const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
@@ -665,10 +682,14 @@
// queue.
document.execCommand('insertText', false, '\n' + currentLineIndentation);
}
+
+ isTextareaFocused() {
+ return !!this.textarea?.isFocused;
+ }
}
declare global {
interface HTMLElementTagNameMap {
- 'gr-textarea': GrTextarea;
+ 'gr-suggestion-textarea': GrSuggestionTextarea;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
rename to polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
index d84f5a7..3d36dfa 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea_test.ts
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import './gr-textarea';
-import {GrTextarea} from './gr-textarea';
+import './gr-suggestion-textarea';
+import {GrSuggestionTextarea} from './gr-suggestion-textarea';
import {
Item,
ItemSelectedEventDetail,
@@ -20,11 +20,22 @@
import {createAccountWithEmail} from '../../../test/test-data-generators';
import {Key} from '../../../utils/dom-util';
-suite('gr-textarea tests', () => {
- let element: GrTextarea;
+suite('gr-suggestion-textarea tests with <gr-textarea>', () => {
+ let element: GrSuggestionTextarea;
+
+ const setText = async (text: string) => {
+ element.text = text;
+ await element.updateComplete;
+ await element.textarea!.updateComplete;
+ element.setCursorPosition(text.length);
+ element.handleTextChanged();
+ await element.updateComplete;
+ };
setup(async () => {
- element = await fixture<GrTextarea>(html`<gr-textarea></gr-textarea>`);
+ element = await fixture<GrSuggestionTextarea>(
+ html`<gr-suggestion-textarea></gr-suggestion-textarea>`
+ );
sinon.stub(element.reporting, 'reportInteraction');
await element.updateComplete;
});
@@ -42,8 +53,7 @@
role="listbox"
>
</gr-autocomplete-dropdown>
- <iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
- </iron-autogrow-textarea>`,
+ <gr-textarea putcursoratendonfocus id="textarea"> </gr-textarea>`,
{
// gr-autocomplete-dropdown sizing seems to vary between local & CI
ignoreAttributes: [
@@ -66,17 +76,14 @@
])
);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
-
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '@';
+ await waitUntil(() => element.isTextareaFocused() === true);
+ await setText('@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.equal(listenerStub.lastCall.args[0].detail.value, '@');
- assert.isTrue(element.textarea!.focused);
+ assert.isTrue(element.isTextareaFocused());
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
@@ -85,8 +92,7 @@
assert.isFalse(element.mentionsSuggestions!.isHidden);
assert.equal(element.currentSearchString, '');
- element.text = '@abc@google.com';
- await element.updateComplete;
+ await setText('@abc@google.com');
assert.equal(element.currentSearchString, 'abc@google.com');
assert.equal(element.specialCharIndex, 0);
@@ -104,11 +110,9 @@
])
);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '\n@';
+ await setText('\n@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
@@ -132,14 +136,12 @@
const promise = mockPromise<Item[]>();
stubRestApi('queryAccounts').returns(promise);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
element.suggestions = [
{dataValue: 'prior@google.com', text: 'Prior suggestion'},
];
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '@';
+ await setText('@');
await element.updateComplete;
assert.equal(element.suggestions.length, 0);
@@ -167,16 +169,13 @@
const suggestionStub = stubRestApi('queryAccounts');
suggestionStub.returns(promise1);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '@';
- await element.updateComplete;
+ await setText('@');
assert.equal(element.currentSearchString, '');
suggestionStub.returns(promise2);
- element.text = '@abc@google.com';
+ await setText('@abc@google.com');
// None of suggestions returned yet.
assert.equal(element.suggestions.length, 0);
await element.updateComplete;
@@ -229,11 +228,9 @@
);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '@';
+ await setText('@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
@@ -253,7 +250,6 @@
test('emoji dropdown does not open if mention dropdown is open', async () => {
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
- const resetSpy = sinon.spy(element, 'resetDropdown');
stubRestApi('queryAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
@@ -261,11 +257,9 @@
])
);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '@';
+ await setText('@');
element.suggestions = [
{
name: 'a',
@@ -275,30 +269,28 @@
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
- assert.isFalse(resetSpy.called);
-
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
- element.text = '@h';
+ await setText('@h');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
- element.text = '@h';
+ await setText('@h');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
- element.text = '@h:';
+ await setText('@h:');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
- element.text = '@h:D';
+ await setText('@h:D');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -309,11 +301,9 @@
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = ':';
+ await setText(':');
element.suggestions = [
{
name: 'a',
@@ -325,23 +315,23 @@
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
- element.text = ':D';
+ await setText(':D');
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
- element.text = ':D@';
+ await setText(':D@');
await element.updateComplete;
// emoji dropdown hidden since we have no more suggestions
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
- element.text = ':D@b';
+ await setText(':D@b');
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
- element.text = ':D@b ';
+ await setText(':D@b ');
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isTrue(element.mentionsSuggestions!.isHidden);
@@ -356,11 +346,9 @@
);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
+ await waitUntil(() => element.isTextareaFocused() === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = '@';
+ await setText('@');
await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
@@ -385,8 +373,7 @@
// by default textarea has focus when rendered
// explicitly remove focus from the element for the test
element.blur();
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
+ element.setCursorPosition(1);
element.text = ':';
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -394,9 +381,8 @@
test('emoji selector is not open when a general text is entered', async () => {
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
- element.textarea!.selectionStart = 9;
- element.textarea!.selectionEnd = 9;
+ await waitUntil(() => element.isTextareaFocused() === true);
+ element.setCursorPosition(9);
element.text = 'some text';
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -408,13 +394,13 @@
const listenerStub = sinon.stub();
element.addEventListener('text-changed', listenerStub);
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = ':';
- await element.updateComplete;
+ await waitUntil(() => element.isTextareaFocused() === true);
+ await setText(':');
assert.equal(listenerStub.lastCall.args[0].detail.value, ':');
- assert.isTrue(element.textarea!.focused);
+ assert.isTrue(element.isTextareaFocused());
+ await element.updateComplete;
+ await element.textarea!.updateComplete;
+ await element.emojiSuggestions!.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 0);
assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -423,13 +409,8 @@
test('emoji selector opens when a colon is typed after space', async () => {
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
- // Needed for Safari tests. selectionStart is not updated when text is
- // updated.
- element.textarea!.selectionStart = 2;
- element.textarea!.selectionEnd = 2;
- element.text = ' :';
- await element.updateComplete;
+ await waitUntil(() => element.isTextareaFocused() === true);
+ await setText(' :');
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 1);
assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -438,30 +419,17 @@
test('emoji selector doesn`t open when a colon is typed after character', async () => {
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
- // Needed for Safari tests. selectionStart is not updated when text is
- // updated.
- element.textarea!.selectionStart = 5;
- element.textarea!.selectionEnd = 5;
- element.text = 'test:';
- await element.updateComplete;
+ await waitUntil(() => element.isTextareaFocused() === true);
+ await setText('test:');
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isTrue(element.emojiSuggestions!.isHidden);
});
test('emoji selector opens when a colon is typed and some substring', async () => {
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
- // Needed for Safari tests. selectionStart is not updated when text is
- // updated.
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = ':';
- await element.updateComplete;
- element.textarea!.selectionStart = 2;
- element.textarea!.selectionEnd = 2;
- element.text = ':t';
- await element.updateComplete;
+ await waitUntil(() => element.isTextareaFocused() === true);
+ await setText(':');
+ await setText(':t');
assert.isFalse(element.emojiSuggestions!.isHidden);
assert.equal(element.specialCharIndex, 0);
assert.isTrue(!element.emojiSuggestions!.isHidden);
@@ -472,19 +440,11 @@
element.textarea!.focus();
// Needed for Safari tests. selectionStart is not updated when text is
// updated.
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
+ element.setCursorPosition(1);
// Since selectionStart is on Chrome set always on end of text, we
// stub it to 1
const text = ': hello';
- sinon.stub(element, 'textarea').value({
- selectionStart: 1,
- value: text,
- focused: true,
- textarea: {
- focus: () => {},
- },
- });
+ sinon.stub(element.textarea!, 'getCursorPosition').returns(1);
element.text = text;
await element.updateComplete;
assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -495,25 +455,14 @@
test('emoji selector closes when text changes before the colon', async () => {
element.textarea!.focus();
- await waitUntil(() => element.textarea!.focused === true);
- await element.updateComplete;
- element.textarea!.selectionStart = 10;
- element.textarea!.selectionEnd = 10;
- element.text = 'test test ';
- await element.updateComplete;
- element.textarea!.selectionStart = 12;
- element.textarea!.selectionEnd = 12;
-
- element.text = 'test test :';
- await element.updateComplete;
+ await waitUntil(() => element.isTextareaFocused() === true);
+ await setText('test test ');
+ await setText('test test :');
// typing : opens the selector
assert.isFalse(element.emojiSuggestions!.isHidden);
- element.textarea!.selectionStart = 15;
- element.textarea!.selectionEnd = 15;
- element.text = 'test test :smi';
- await element.updateComplete;
+ await setText('test test :smi');
assert.equal(element.currentSearchString, 'smi');
assert.isFalse(element.emojiSuggestions!.isHidden);
@@ -571,10 +520,12 @@
});
test('handleDropdownItemSelect', async () => {
- element.textarea!.selectionStart = 16;
- element.textarea!.selectionEnd = 16;
element.text = 'test test :tears';
+ await element.updateComplete;
+ await element.textarea!.updateComplete;
+ element.setCursorPosition(16);
element.specialCharIndex = 10;
+ element.handleTextChanged();
await element.updateComplete;
const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
@@ -585,46 +536,37 @@
// wait for reset dropdown to finish
await waitUntil(() => element.specialCharIndex === -1);
- element.textarea!.selectionStart = 16;
- element.textarea!.selectionEnd = 16;
element.text = 'test test :tears';
- element.specialCharIndex = 10;
await element.updateComplete;
+ await element.textarea!.updateComplete;
+ element.setCursorPosition(16);
+ await element.updateComplete;
+ element.specialCharIndex = 10;
+ element.handleTextChanged();
// move the cursor to the left while the suggestion popup is open
- element.textarea!.selectionStart = 0;
+ element.setCursorPosition(0);
element.handleDropdownItemSelect(event);
assert.equal(element.text, 'test test 😂');
// wait for reset dropdown to finish
await waitUntil(() => element.specialCharIndex === -1);
- element.textarea!.selectionStart = 16;
- element.textarea!.selectionEnd = 16;
+ element.setCursorPosition(16);
const text = 'test test :tears happy';
// Since selectionStart is on Chrome set always on end of text, we
// stub it to 16
- const stub = sinon.stub(element, 'textarea').value({
- selectionStart: 16,
- value: text,
- focused: true,
- textarea: {
- focus: () => {},
- },
- });
+ const stub = sinon.stub(element.textarea!, 'getCursorPosition').returns(16);
element.text = text;
element.specialCharIndex = 10;
await element.updateComplete;
stub.restore();
// move the cursor to the right while the suggestion popup is open
- element.textarea!.selectionStart = 22;
+ element.setCursorPosition(22);
element.handleDropdownItemSelect(event);
assert.equal(element.text, 'test test 😂 happy');
});
test('updateCaratPosition', async () => {
- element.textarea!.selectionStart = 4;
- element.textarea!.selectionEnd = 4;
- element.text = 'test';
- await element.updateComplete;
+ await setText('test');
element.updateCaratPosition();
assert.deepEqual(
element.hiddenText!.innerHTML,
@@ -634,7 +576,7 @@
test('newline receives matching indentation', async () => {
const indentCommand = sinon.stub(document, 'execCommand');
- element.textarea!.value = ' a';
+ await setText(' a');
element.handleEnterByKey(new KeyboardEvent('keydown', {key: 'Enter'}));
await element.updateComplete;
assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n ']);
@@ -653,24 +595,11 @@
});
suite('keyboard shortcuts', async () => {
- async function setupDropdown() {
- element.textarea!.focus();
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = ':';
- await element.updateComplete;
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 2;
- element.text = ':1';
- await element.emojiSuggestions!.updateComplete;
- await element.updateComplete;
- }
-
test('escape key', async () => {
const resetSpy = sinon.spy(element, 'resetDropdown');
pressKey(element.textarea! as HTMLElement, Key.ESC);
assert.isFalse(resetSpy.called);
- await setupDropdown();
+ await setText(':1');
pressKey(element.textarea! as HTMLElement, Key.ESC);
assert.isTrue(resetSpy.called);
assert.isTrue(element.emojiSuggestions!.isHidden);
@@ -680,7 +609,7 @@
const upSpy = sinon.spy(element.emojiSuggestions!, 'cursorUp');
pressKey(element.textarea! as HTMLElement, 'ArrowUp');
assert.isFalse(upSpy.called);
- await setupDropdown();
+ await setText(':1');
pressKey(element.textarea! as HTMLElement, 'ArrowUp');
assert.isTrue(upSpy.called);
});
@@ -689,7 +618,7 @@
const downSpy = sinon.spy(element.emojiSuggestions!, 'cursorDown');
pressKey(element.textarea! as HTMLElement, 'ArrowDown');
assert.isFalse(downSpy.called);
- await setupDropdown();
+ await setText(':1');
pressKey(element.textarea! as HTMLElement, 'ArrowDown');
assert.isTrue(downSpy.called);
});
@@ -698,7 +627,7 @@
const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
pressKey(element.textarea! as HTMLElement, Key.ENTER);
assert.isFalse(enterSpy.called);
- await setupDropdown();
+ await setText(':1');
pressKey(element.textarea! as HTMLElement, Key.ENTER);
assert.isTrue(enterSpy.called);
await element.updateComplete;
@@ -706,12 +635,12 @@
});
});
- suite('gr-textarea monospace', () => {
- let element: GrTextarea;
+ suite('gr-suggestion-textarea monospace', () => {
+ let element: GrSuggestionTextarea;
setup(async () => {
- element = await fixture<GrTextarea>(
- html`<gr-textarea monospace></gr-textarea>`
+ element = await fixture<GrSuggestionTextarea>(
+ html`<gr-suggestion-textarea monospace></gr-suggestion-textarea>`
);
await element.updateComplete;
});
@@ -721,12 +650,12 @@
});
});
- suite('gr-textarea hideBorder', () => {
- let element: GrTextarea;
+ suite('gr-suggestion-textarea hideBorder', () => {
+ let element: GrSuggestionTextarea;
setup(async () => {
- element = await fixture<GrTextarea>(
- html`<gr-textarea hide-border></gr-textarea>`
+ element = await fixture<GrSuggestionTextarea>(
+ html`<gr-suggestion-textarea hide-border></gr-suggestion-textarea>`
);
await element.updateComplete;
});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index e1fc2ed..1ec5262e 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -271,6 +271,16 @@
.breadcrumbTooltip {
white-space: nowrap;
}
+ .unrelatedChanges {
+ color: var(--primary-button-text-color);
+ background-color: var(--primary-button-background-color);
+
+ &:hover {
+ // TODO(anuragpathak): Update hover colors as per specification.
+ color: var(--primary-button-text-color);
+ background-color: var(--primary-button-background-color);
+ }
+ }
`,
];
}
@@ -370,8 +380,14 @@
let classes = 'contextControlButton showContext ';
if (type === ContextButtonType.ALL) {
- text = `+${pluralize(linesToExpand, 'common line')}`;
- ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+ if (this.group.hasNonCommonDeltaGroup()) {
+ text = '+ Unrelated changes';
+ ariaLabel = 'Show unrelated changes';
+ classes += ' unrelatedChanges ';
+ } else {
+ text = `+${pluralize(linesToExpand, 'common line')}`;
+ ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+ }
classes += this.showBoth()
? 'centeredButton'
: this.showAbove()
@@ -483,7 +499,7 @@
* Creates a container div with partial (+10) expansion buttons (above and/or below).
*/
private createPartialExpansionButtons() {
- if (!this.showPartialLinks()) {
+ if (!this.showPartialLinks() || this.group?.hasNonCommonDeltaGroup()) {
return undefined;
}
let aboveButton;
@@ -515,7 +531,8 @@
if (
!this.showPartialLinks() ||
!this.renderPreferences?.use_block_expansion ||
- this.group?.hasSkipGroup()
+ this.group?.hasSkipGroup() ||
+ this.group?.hasNonCommonDeltaGroup()
) {
return undefined;
}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
index 20fc9c4..74726bf 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls_test.ts
@@ -10,7 +10,10 @@
import {SyntaxBlock} from '../../../api/diff';
import {fixture, html, assert} from '@open-wc/testing';
import {waitEventLoop} from '../../../test/test-utils';
-import {createContextGroup} from '../../../test/test-data-generators';
+import {
+ createContextGroup,
+ createContextGroupWithDelta,
+} from '../../../test/test-data-generators';
suite('gr-context-control tests', () => {
let element: GrContextControls;
@@ -333,4 +336,16 @@
assert.equal(tooltipAbove.getAttribute('position'), 'top');
assert.equal(tooltipBelow.getAttribute('position'), 'bottom');
});
+
+ test('context control with delta group', async () => {
+ element.group = createContextGroupWithDelta();
+ await waitEventLoop();
+
+ const buttons = element.shadowRoot!.querySelectorAll(
+ 'paper-button.showContext'
+ );
+ assert.equal(buttons.length, 1);
+ assert.equal(buttons[0].textContent!.trim(), '+ Unrelated changes');
+ assert.include([...buttons[0].classList.values()], 'unrelatedChanges');
+ });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index a9e332a..e1728ff 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -233,6 +233,10 @@
// it's a shadow dom.
const {element} = this.findTokenAncestor(e.composedPath()[0]);
if (element) return;
+ this.removeHighlight();
+ }
+
+ private removeHighlight() {
this.hoveredElement = undefined;
this.updateTokenTask?.cancel();
this.updateTokenHighlight(undefined, 0, undefined);
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
index 88451f6..8488bd5 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -10,6 +10,7 @@
DiffInfo,
DiffLayer,
DiffPreferencesInfo,
+ DiffRangesToFocus,
DiffResponsiveMode,
DiffViewMode,
DisplayLine,
@@ -51,6 +52,7 @@
renderPrefs: RenderPreferences;
diffPrefs: DiffPreferencesInfo;
lineOfInterest?: DisplayLine;
+ diffRangesToFocus?: DiffRangesToFocus;
comments: GrDiffCommentThread[];
groups: GrDiffGroup[];
/** how much context to show for large files */
@@ -211,6 +213,9 @@
computeKeyLocations(diffState.lineOfInterest, diffState.comments ?? [])
);
+ readonly diffRangesToFocus$: Observable<DiffRangesToFocus | undefined> =
+ select(this.state$, diffState => diffState.diffRangesToFocus);
+
constructor(
/**
* Normally a reference to the <gr-diff> component. Used for firing events
@@ -232,23 +237,31 @@
}
processDiff() {
- return combineLatest([this.diff$, this.context$, this.renderPrefs$])
+ return combineLatest([
+ this.diff$,
+ this.context$,
+ this.renderPrefs$,
+ this.diffRangesToFocus$,
+ ])
.pipe(
withLatestFrom(this.keyLocations$),
debounceTime(1),
- map(([[diff, context, renderPrefs], keyLocations]) => {
- const options: ProcessingOptions = {
- context,
- keyLocations,
- isBinary: !!(isImageDiff(diff) || diff.binary),
- };
- if (renderPrefs?.num_lines_rendered_at_once) {
- options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
- }
+ map(
+ ([[diff, context, renderPrefs, diffRangesToFocus], keyLocations]) => {
+ const options: ProcessingOptions = {
+ context,
+ keyLocations,
+ isBinary: !!(isImageDiff(diff) || diff.binary),
+ diffRangesToFocus,
+ };
+ if (renderPrefs?.num_lines_rendered_at_once) {
+ options.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+ }
- const processor = new GrDiffProcessor(options);
- return processor.process(diff.content);
- })
+ const processor = new GrDiffProcessor(options);
+ return processor.process(diff.content);
+ }
+ )
)
.subscribe(groups => {
this.updateState({groups});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 6b7b45f..d138414 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -9,7 +9,7 @@
GrDiffGroupType,
hideInContextControl,
} from '../gr-diff/gr-diff-group';
-import {DiffContent} from '../../../types/diff';
+import {DiffContent, DiffRangesToFocus} from '../../../types/diff';
import {Side} from '../../../constants/constants';
import {getStringLength} from '../gr-diff-highlight/gr-annotation';
import {GrDiffLineType, LineNumber} from '../../../api/diff';
@@ -41,6 +41,7 @@
keyLocations?: KeyLocations;
asyncThreshold?: number;
isBinary?: boolean;
+ diffRangesToFocus?: DiffRangesToFocus;
}
/**
@@ -79,10 +80,14 @@
private groups: GrDiffGroup[] = [];
+ // visible for testing
+ diffRangesToFocus?: DiffRangesToFocus;
+
constructor(options: ProcessingOptions) {
this.context = options.context;
this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
this.isBinary = options.isBinary ?? false;
+ this.diffRangesToFocus = options.diffRangesToFocus;
}
/**
@@ -126,7 +131,7 @@
processNext(state: State, chunks: DiffContent[]) {
const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
chunks,
- state.chunkIndex
+ state
);
if (firstUncollapsibleChunkIndex === state.chunkIndex) {
const chunk = chunks[state.chunkIndex];
@@ -161,19 +166,67 @@
return chunk.ab || chunk.b || [];
}
- private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
- let chunkIndex = offset;
+ private firstUncollapsibleChunkIndex(chunks: DiffContent[], state: State) {
+ let chunkIndex = state.chunkIndex;
+ let offsetLeft = state.lineNums.left;
+ let offsetRight = state.lineNums.right;
while (
chunkIndex < chunks.length &&
- this.isCollapsibleChunk(chunks[chunkIndex])
+ this.isCollapsibleChunk(chunks[chunkIndex], offsetLeft, offsetRight)
) {
+ offsetLeft += this.chunkLength(chunks[chunkIndex], Side.LEFT);
+ offsetRight += this.chunkLength(chunks[chunkIndex], Side.RIGHT);
chunkIndex++;
}
return chunkIndex;
}
- private isCollapsibleChunk(chunk: DiffContent) {
- return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+ private isCollapsibleChunk(
+ chunk: DiffContent,
+ offsetLeft: number,
+ offsetRight: number
+ ) {
+ return (
+ (chunk.ab ||
+ chunk.common ||
+ chunk.skip ||
+ this.isChunkOutsideOfFocusRange(chunk, offsetLeft, offsetRight)) &&
+ !chunk.keyLocation
+ );
+ }
+
+ private isChunkOutsideOfFocusRange(
+ chunk: DiffContent,
+ offsetLeft: number,
+ offsetRight: number
+ ) {
+ if (!this.diffRangesToFocus) {
+ return false;
+ }
+ const leftLineCount = this.linesLeft(chunk).length;
+ const rightLineCount = this.linesRight(chunk).length;
+ const hasLeftSideOverlap = this.diffRangesToFocus.left.some(range =>
+ this.hasAnyOverlap(
+ {start: offsetLeft, end: offsetLeft + leftLineCount},
+ range
+ )
+ );
+ const hasRightSideOverlap = this.diffRangesToFocus.right.some(range =>
+ this.hasAnyOverlap(
+ {start: offsetRight, end: offsetRight + rightLineCount},
+ range
+ )
+ );
+ return !hasLeftSideOverlap && !hasRightSideOverlap;
+ }
+
+ private hasAnyOverlap(
+ firstRange: {start: number; end: number},
+ secondRange: {start: number; end: number}
+ ) {
+ const startOverlap = Math.max(firstRange.start, secondRange.start);
+ const endOverlap = Math.min(firstRange.end, secondRange.end);
+ return startOverlap <= endOverlap;
}
/**
@@ -195,8 +248,12 @@
state.chunkIndex,
firstUncollapsibleChunkIndex
);
- const lineCount = collapsibleChunks.reduce(
- (sum, chunk) => sum + this.commonChunkLength(chunk),
+ const leftLineCount = collapsibleChunks.reduce(
+ (sum, chunk) => sum + this.chunkLength(chunk, Side.LEFT),
+ 0
+ );
+ const rightLineCount = collapsibleChunks.reduce(
+ (sum, chunk) => sum + this.chunkLength(chunk, Side.RIGHT),
0
);
@@ -207,25 +264,50 @@
);
const hasSkippedGroup = !!groups.find(g => g.skip);
- if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
+ const hasNonCommonDeltaGroup = !!groups.find(
+ g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly
+ );
+ if (
+ this.context !== FULL_CONTEXT ||
+ hasSkippedGroup ||
+ hasNonCommonDeltaGroup
+ ) {
const contextNumLines = this.context > 0 ? this.context : 0;
const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
- const hiddenEnd =
- lineCount -
+ const hiddenEndLeft =
+ leftLineCount -
(firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
- groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+ const hiddenEndRight =
+ rightLineCount -
+ (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+ groups = hideInContextControl(
+ groups,
+ hiddenStart,
+ hiddenEndLeft,
+ hiddenEndRight
+ );
}
return {
lineDelta: {
- left: lineCount,
- right: lineCount,
+ left: leftLineCount,
+ right: rightLineCount,
},
groups,
newChunkIndex: firstUncollapsibleChunkIndex,
};
}
+ private chunkLength(chunk: DiffContent, side: Side) {
+ if (chunk.skip || chunk.common || chunk.ab) {
+ return this.commonChunkLength(chunk);
+ } else if (side === Side.LEFT) {
+ return this.linesLeft(chunk).length;
+ } else {
+ return this.linesRight(chunk).length;
+ }
+ }
+
private commonChunkLength(chunk: DiffContent) {
if (chunk.skip) {
return chunk.skip;
@@ -247,9 +329,8 @@
): GrDiffGroup[] {
return chunks.map(chunk => {
const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
- const chunkLength = this.commonChunkLength(chunk);
- offsetLeft += chunkLength;
- offsetRight += chunkLength;
+ offsetLeft += this.chunkLength(chunk, Side.LEFT);
+ offsetRight += this.chunkLength(chunk, Side.RIGHT);
return group;
});
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index f6b9737..3f01096 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -933,6 +933,128 @@
);
});
});
+
+ suite('with diffRangesToFocus', () => {
+ let state: State;
+ let chunks: DiffContent[];
+
+ setup(() => {
+ state = {
+ lineNums: {left: 0, right: 0},
+ chunkIndex: 0,
+ };
+ processor.context = 3;
+ processor.diffRangesToFocus = {
+ left: [{start: 6, end: 7}],
+ right: [{start: 6, end: 6}],
+ };
+ chunks = [
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jill a dull boy'
+ ),
+ },
+ {
+ a: ['Old ', ' Change!'],
+ b: ['New Change'],
+ },
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
+ {
+ a: ['Old ', ' Change!', '1'],
+ b: ['New Change', '2'],
+ },
+ ];
+ });
+
+ test('focussed group is not collapsed in context control group', () => {
+ const result = processor.processNext(state, chunks);
+
+ // This should consider second delta group as focussed and not collapse it.
+ // This result is first chunk itself.
+ assert.equal(result.groups.length, 1);
+ assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+ assert.equal(result.groups[0].lines.length, 5);
+ });
+
+ test('collapsing delta group at end in context control group', () => {
+ state = {
+ lineNums: {left: 7, right: 6},
+ chunkIndex: 2,
+ };
+ const result = processor.processNext(state, [
+ ...chunks,
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
+ ]);
+
+ // The first chunk is split into two groups:
+ // 1) A common group which is rendered before contextControl group
+ // 2) Second group is a context control which contains split from 4th chunk
+ // and the delta group and the last unchanged group.
+ assert.equal(result.groups.length, 2);
+ assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+ assert.equal(result.groups[0].lines.length, 3);
+ assert.equal(result.groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(result.groups[1].contextGroups.length, 3);
+ assert.equal(result.groups[1].contextGroups[0].lines.length, 2);
+ assert.equal(
+ result.groups[1].contextGroups[1].type,
+ GrDiffGroupType.DELTA
+ );
+ assert.equal(
+ result.groups[1].contextGroups[2].type,
+ GrDiffGroupType.BOTH
+ );
+ });
+
+ test('collapsing delta group in middle in context control group', () => {
+ state = {
+ lineNums: {left: 7, right: 6},
+ chunkIndex: 2,
+ };
+ const result = processor.processNext(state, chunks);
+
+ // The first chunk is split into two groups:
+ // 1) A common group which is rendered before contextControl group
+ // 2) Second group is a context control which contains split from 4th chunk
+ // and the delta group.
+ assert.equal(result.groups.length, 2);
+ assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+ assert.equal(result.groups[0].lines.length, 3);
+ assert.equal(result.groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(result.groups[1].contextGroups.length, 2);
+ assert.equal(result.groups[1].contextGroups[0].lines.length, 2);
+ assert.equal(
+ result.groups[1].contextGroups[1].type,
+ GrDiffGroupType.DELTA
+ );
+ });
+
+ test('do not collapse if there are not enough context lines', () => {
+ processor.context = 10;
+ state = {
+ lineNums: {left: 7, right: 6},
+ chunkIndex: 2,
+ };
+ const result = processor.processNext(state, chunks);
+
+ // The first chunk is split into two groups:
+ // 1) A common group which is rendered before contextControl group
+ // 2) Second group is a context control which contains split from 4th chunk
+ // and the delta group.
+ assert.equal(result.groups.length, 2);
+ assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+ assert.equal(result.groups[0].lines.length, 5);
+ assert.equal(result.groups[1].type, GrDiffGroupType.DELTA);
+ });
+ });
});
suite('gr-diff-processor helpers', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index c5366e7..e7df002 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -42,40 +42,56 @@
* @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.
+ * @param hiddenEndLeft The first visible element after the hidden range,
+ * as a non-negative line number offset for left side relative to the first
+ * group's start line.
+ * @param hiddenEndRight The first visible element after the hidden range,
+ * as a non-negative line number offset for right side relative to the first
+ * group's start line. If not provided hiddenEndLeft will be used.
*/
export function hideInContextControl(
groups: readonly GrDiffGroup[],
hiddenStart: number,
- hiddenEnd: number
+ hiddenEndLeft: number,
+ hiddenEndRight?: 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);
+ hiddenEndLeft = Math.max(hiddenEndLeft, hiddenStart);
+ hiddenEndRight = Math.max(hiddenEndRight ?? hiddenEndLeft, hiddenStart);
let before: GrDiffGroup[] = [];
let hidden = groups;
let after: readonly GrDiffGroup[] = [];
- const numHidden = hiddenEnd - hiddenStart;
+ const numHiddenLeft = hiddenEndLeft - hiddenStart;
+ const numHiddenRight = hiddenEndRight - hiddenStart;
// Showing a context control row for less than 4 lines does not make much,
// because then that row would consume as much space as the collapsed code.
- if (numHidden > 3) {
+ if (numHiddenLeft > 3 && numHiddenRight > 3) {
if (hiddenStart) {
- [before, hidden] = splitCommonGroups(hidden, hiddenStart);
+ [before, hidden] = splitCommonGroups(hidden, hiddenStart, hiddenStart);
}
- if (hiddenEnd) {
- let beforeLength = 0;
+ if (hiddenEndLeft && hiddenEndRight) {
+ let beforeLengthLeft = 0;
+ let beforeLengthRight = 0;
if (before.length > 0) {
- const beforeStart = before[0].lineRange.left.start_line;
- const beforeEnd = before[before.length - 1].lineRange.left.end_line;
- beforeLength = beforeEnd - beforeStart + 1;
+ const beforeStartLeft = before[0].lineRange.left.start_line;
+ const beforeEndLeft = before[before.length - 1].lineRange.left.end_line;
+ beforeLengthLeft = beforeEndLeft - beforeStartLeft + 1;
+
+ const beforeStartRight = before[0].lineRange.right.start_line;
+ const beforeEndRight =
+ before[before.length - 1].lineRange.right.end_line;
+ beforeLengthRight = beforeEndRight - beforeStartRight + 1;
}
- [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
+ [hidden, after] = splitCommonGroups(
+ hidden,
+ hiddenEndLeft - beforeLengthLeft,
+ hiddenEndRight - beforeLengthRight
+ );
}
} else {
[hidden, after] = [[], hidden];
@@ -165,45 +181,59 @@
* 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.
+ * two groups, which will be put into the first and second list. Groups with
+ * type DELTA which are not common will not be split.
*
- * @param split A line number offset relative to the first group's
- * start line at which the groups should be split.
+ * @param splitLeft A line number offset for left side relative to the first
+ * group's start line at which the groups should be split.
+ * @param splitRight A line number offset for right side 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: readonly GrDiffGroup[],
- split: number
+ splitLeft: number,
+ splitRight: number
): GrDiffGroup[][] {
if (groups.length === 0) return [[], []];
- const leftSplit = groups[0].lineRange.left.start_line + split;
- const rightSplit = groups[0].lineRange.right.start_line + split;
-
+ const leftSplit = groups[0].lineRange.left.start_line + splitLeft;
+ const rightSplit = groups[0].lineRange.right.start_line + splitRight;
+ let isSplitDone = false;
const beforeGroups = [];
const afterGroups = [];
for (const group of groups) {
- const isCompletelyBefore =
- group.lineRange.left.end_line < leftSplit ||
- group.lineRange.right.end_line < rightSplit;
- const isCompletelyAfter =
- leftSplit <= group.lineRange.left.start_line ||
- rightSplit <= group.lineRange.right.start_line;
- if (isCompletelyBefore) {
- beforeGroups.push(group);
- } else if (isCompletelyAfter) {
+ if (isSplitDone) {
afterGroups.push(group);
+ } else if (
+ group.type === GrDiffGroupType.DELTA &&
+ !group.ignoredWhitespaceOnly
+ ) {
+ beforeGroups.push(group);
} else {
- const {beforeSplit, afterSplit} = splitGroupInTwo(
- group,
- leftSplit,
- rightSplit
- );
- if (beforeSplit) {
- beforeGroups.push(beforeSplit);
- }
- if (afterSplit) {
- afterGroups.push(afterSplit);
+ const isCompletelyBefore =
+ group.lineRange.left.end_line < leftSplit ||
+ group.lineRange.right.end_line < rightSplit;
+ const isCompletelyAfter =
+ leftSplit <= group.lineRange.left.start_line ||
+ rightSplit <= group.lineRange.right.start_line;
+ 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);
+ }
+ isSplitDone = true;
}
}
}
@@ -438,6 +468,15 @@
);
}
+ /** Returns true if it contains a DELTA group excluding whitespace only
+ * changes.
+ */
+ hasNonCommonDeltaGroup() {
+ return this.contextGroups?.some(
+ g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly
+ );
+ }
+
containsLine(side: Side, line: LineNumber) {
if (typeof line !== 'number') {
// For FILE and LOST, beforeNumber and afterNumber are the same
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index bbbb4ad..c8446f0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -97,6 +97,8 @@
suite('hideInContextControl', () => {
let groups: GrDiffGroup[];
+ let groupsWithDelta: GrDiffGroup[];
+ let groupsWithWhiteSpaceOnlyChange: GrDiffGroup[];
setup(() => {
groups = [
new GrDiffGroup({
@@ -108,6 +110,46 @@
],
}),
new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [
+ new GrDiffLine(GrDiffLineType.BOTH, 8, 10),
+ new GrDiffLine(GrDiffLineType.BOTH, 9, 11),
+ new GrDiffLine(GrDiffLineType.BOTH, 10, 12),
+ new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
+ ],
+ }),
+ new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines: [
+ new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+ new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
+ new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
+ ],
+ }),
+ ];
+
+ groupsWithWhiteSpaceOnlyChange = [
+ groups[0],
+ new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [
+ 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 GrDiffLine(GrDiffLineType.REMOVE, 11),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+ ],
+ ignoredWhitespaceOnly: true,
+ }),
+ groups[2],
+ ];
+
+ groupsWithDelta = [
+ groups[0],
+ new GrDiffGroup({
type: GrDiffGroupType.DELTA,
lines: [
new GrDiffLine(GrDiffLineType.REMOVE, 8),
@@ -120,14 +162,7 @@
new GrDiffLine(GrDiffLineType.ADD, 0, 13),
],
}),
- new GrDiffGroup({
- type: GrDiffGroupType.BOTH,
- lines: [
- new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
- new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
- new GrDiffLine(GrDiffLineType.BOTH, 14, 16),
- ],
- }),
+ groups[2],
];
});
@@ -144,29 +179,33 @@
assert.equal(collapsedGroups[2], groups[2]);
});
+ test('does not hides when split is at delta group in context control', () => {
+ const collapsedGroups = hideInContextControl(groupsWithDelta, 3, 7);
+ assert.equal(collapsedGroups.length, 3);
+
+ assert.equal(collapsedGroups[0], groupsWithDelta[0]);
+ assert.equal(collapsedGroups[1], groupsWithDelta[1]);
+ assert.equal(collapsedGroups[2], groupsWithDelta[2]);
+ });
+
test('splits partially hidden groups', () => {
const collapsedGroups = hideInContextControl(groups, 4, 8);
assert.equal(collapsedGroups.length, 4);
assert.equal(collapsedGroups[0], groups[0]);
- 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[1].type, GrDiffGroupType.BOTH);
+ assert.deepEqual(collapsedGroups[1].lines, [groups[1].lines[0]]);
assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
assert.equal(collapsedGroups[2].contextGroups.length, 2);
assert.equal(
collapsedGroups[2].contextGroups[0].type,
- GrDiffGroupType.DELTA
+ GrDiffGroupType.BOTH
);
assert.deepEqual(
- collapsedGroups[2].contextGroups[0].adds,
- groups[1].adds.slice(1)
- );
- assert.deepEqual(
- collapsedGroups[2].contextGroups[0].removes,
- groups[1].removes.slice(1)
+ collapsedGroups[2].contextGroups[0].lines,
+ groups[1].lines.slice(1)
);
assert.equal(
@@ -181,6 +220,54 @@
assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
});
+ test('splits partially hidden common delta groups', () => {
+ const collapsedGroups = hideInContextControl(
+ groupsWithWhiteSpaceOnlyChange,
+ 4,
+ 8
+ );
+ assert.equal(collapsedGroups.length, 4);
+ assert.equal(collapsedGroups[0], groupsWithWhiteSpaceOnlyChange[0]);
+
+ assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
+ assert.deepEqual(collapsedGroups[1].adds, [
+ groupsWithWhiteSpaceOnlyChange[1].adds[0],
+ ]);
+ assert.deepEqual(collapsedGroups[1].removes, [
+ groupsWithWhiteSpaceOnlyChange[1].removes[0],
+ ]);
+
+ assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(collapsedGroups[2].contextGroups.length, 2);
+
+ assert.equal(
+ collapsedGroups[2].contextGroups[0].type,
+ GrDiffGroupType.DELTA
+ );
+ assert.deepEqual(
+ collapsedGroups[2].contextGroups[0].adds,
+ groupsWithWhiteSpaceOnlyChange[1].adds.slice(1)
+ );
+ assert.deepEqual(
+ collapsedGroups[2].contextGroups[0].removes,
+ groupsWithWhiteSpaceOnlyChange[1].removes.slice(1)
+ );
+
+ assert.equal(
+ collapsedGroups[2].contextGroups[1].type,
+ GrDiffGroupType.BOTH
+ );
+ assert.deepEqual(collapsedGroups[2].contextGroups[1].lines, [
+ groupsWithWhiteSpaceOnlyChange[2].lines[0],
+ ]);
+
+ assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
+ assert.deepEqual(
+ collapsedGroups[3].lines,
+ groupsWithWhiteSpaceOnlyChange[2].lines.slice(1)
+ );
+ });
+
suite('with skip chunks', () => {
setup(() => {
const skipGroup = new GrDiffGroup({
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
index 1de8298..22a7694 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -670,9 +670,17 @@
// Styles related to the <gr-diff-text> component.
export const grDiffTextStyles = css`
- gr-diff-text .token-highlight {
+ /* The background color for tokens of the "token-highlight-layer". */
+ gr-diff-text hl.token-highlight {
background-color: var(--token-highlighting-color, #fffd54);
}
+ /* We do not want token highlighting to override the "rangeHighlight"
+ color, so let's make sure that there are no "rangeHighlight" element
+ parents that wrap the "token-highlight" element.
+ */
+ gr-diff-text hl.rangeHighlight hl.token-highlight {
+ background-color: transparent;
+ }
/* Describes two states of semantic tokens: whenever a token has a
definition that can be navigated to (navigable) and whenever
the token is actually clickable to perform this navigation. */
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 90b9cfe..f3534c2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -44,6 +44,7 @@
RenderPreferences,
GrDiff as GrDiffApi,
DisplayLine,
+ DiffRangesToFocus,
LineNumber,
ContentLoadNeededEventDetail,
DiffContextExpandedExternalDetail,
@@ -182,6 +183,9 @@
@property({type: Object})
lineOfInterest?: DisplayLine;
+ @property({type: Object})
+ diffRangesToFocus?: DiffRangesToFocus;
+
/**
* True when diff is changed, until the content is done rendering.
* Use getter/setter loading instead of this.
@@ -368,7 +372,8 @@
changedProperties.has('showNewlineWarningLeft') ||
changedProperties.has('showNewlineWarningRight') ||
changedProperties.has('prefs') ||
- changedProperties.has('lineOfInterest')
+ changedProperties.has('lineOfInterest') ||
+ changedProperties.has('diffRangesToFocus')
) {
if (this.diff && this.prefs) {
const renderPrefs = {...(this.renderPrefs ?? {})};
@@ -394,6 +399,7 @@
renderPrefs,
diffPrefs: this.prefs,
lineOfInterest: this.lineOfInterest,
+ diffRangesToFocus: this.diffRangesToFocus,
});
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
index 38a9533..c67157f 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -8,10 +8,10 @@
const $_documentContainer = document.createElement('template');
export const grRangedCommentTheme = css`
- .rangeHighlight {
+ gr-diff-text hl.rangeHighlight {
background-color: var(--diff-highlight-range-color);
}
- .rangeHoverHighlight {
+ gr-diff-text hl.rangeHoverHighlight {
background-color: var(--diff-highlight-range-hover-color);
}
`;
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index 5074290..9337951 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -23,6 +23,7 @@
['application/typescript', 'typescript'],
['application/xml', 'xml'],
['application/xquery', 'xquery'],
+ ['application/x-epp', 'epp'],
['application/x-erb', 'erb'],
['text/css', 'css'],
['text/html', 'html'],
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6de43ed..cbb2d8c 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -13,6 +13,7 @@
import '../api/embed';
import '../scripts/bundled-polymer';
import './diff/gr-diff/gr-diff';
+import './gr-textarea';
import './diff/gr-diff-cursor/gr-diff-cursor';
import {TokenHighlightLayer} from './diff/gr-diff-builder/token-highlight-layer';
import {GrDiffCursor} from './diff/gr-diff-cursor/gr-diff-cursor';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts
new file mode 100644
index 0000000..05bef49
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -0,0 +1,765 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css} from 'lit';
+import {customElement, property, query, queryAsync} from 'lit/decorators.js';
+import {classMap} from 'lit/directives/class-map.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+ GrTextarea as GrTextareaApi,
+ HintAppliedEventDetail,
+ HintShownEventDetail,
+ HintDismissedEventDetail,
+ CursorPositionChangeEventDetail,
+} from '../api/embed';
+
+/**
+ * Waits for the next animation frame.
+ */
+async function animationFrame(): Promise<void> {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ resolve();
+ });
+ });
+}
+
+/**
+ * Whether the current browser supports `plaintext-only` for contenteditable
+ * https://caniuse.com/mdn-html_global_attributes_contenteditable_plaintext-only
+ */
+function supportsPlainTextEditing() {
+ const div = document.createElement('div');
+ try {
+ div.contentEditable = 'PLAINTEXT-ONLY';
+ return div.contentEditable === 'plaintext-only';
+ } catch (e) {
+ return false;
+ }
+}
+
+/** Class for autocomplete hint */
+export const AUTOCOMPLETE_HINT_CLASS = 'autocomplete-hint';
+
+const ACCEPT_PLACEHOLDER_HINT_LABEL =
+ 'Press TAB to accept the placeholder hint.';
+
+/**
+ * A custom textarea component which allows autocomplete functionality.
+ * This component is only supported in Chrome. Other browsers are not supported.
+ *
+ * Example usage:
+ * <gr-textarea></gr-textarea>
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends LitElement implements GrTextareaApi {
+ // editableDivElement is available right away where it may be undefined. This
+ // is used for calls for scrollTop as if it is undefined then we can fallback
+ // to 0. For other usecases use editableDiv.
+ @query('.editableDiv')
+ private readonly editableDivElement?: HTMLDivElement;
+
+ @queryAsync('.editableDiv')
+ private readonly editableDiv?: Promise<HTMLDivElement>;
+
+ @property({type: Boolean, reflect: true}) disabled = false;
+
+ @property({type: String, reflect: true}) placeholder: string | undefined;
+
+ /**
+ * The hint is shown as a autocomplete string which can be added by pressing
+ * TAB.
+ *
+ * The hint is shown
+ * 1. At the cursor position, only when cursor position is at the end of
+ * textarea content.
+ * 2. When textarea has focus.
+ * 3. When selection inside the textarea is collapsed.
+ *
+ * When hint is applied listen for hintApplied event and remove the hint
+ * as component property to avoid showing the hint again.
+ */
+ @property({type: String})
+ set hint(newHint) {
+ if (this.hint !== newHint) {
+ this.innerHint = newHint;
+ this.updateHintInDomIfRendered();
+ }
+ }
+
+ get hint() {
+ return this.innerHint;
+ }
+
+ /**
+ * Show hint is shown as placeholder which people can autocomplete to.
+ *
+ * This takes precedence over hint property.
+ * It is shown even when textarea has no focus.
+ * This is shown only when textarea is blank.
+ */
+ @property({type: String}) placeholderHint: string | undefined;
+
+ /**
+ * Sets the value for textarea and also renders it in dom if it is different
+ * from last rendered value.
+ *
+ * To prevent cursor position from jumping to front of text even when value
+ * remains same, Check existing value before triggering the update and only
+ * update when there is a change.
+ *
+ * Also .innerText binding can't be used for security reasons.
+ */
+ @property({type: String})
+ set value(newValue) {
+ if (this.ignoreValue && this.ignoreValue === newValue) {
+ return;
+ }
+ const oldVal = this.value;
+ if (oldVal !== newValue) {
+ this.innerValue = newValue;
+ this.updateValueInDom();
+ }
+ }
+
+ get value() {
+ return this.innerValue;
+ }
+
+ /**
+ * This value will be ignored by textarea and is not set.
+ */
+ @property({type: String}) ignoreValue: string | undefined;
+
+ /**
+ * Sets cursor at the end of content on focus.
+ */
+ @property({type: Boolean}) putCursorAtEndOnFocus = false;
+
+ /**
+ * Enables save shortcut.
+ *
+ * On S key down with control or meta key enabled is exposed with output event
+ * 'saveShortcut'.
+ */
+ @property({type: Boolean}) enableSaveShortcut = false;
+
+ /*
+ * Is textarea focused. This is a readonly property.
+ */
+ get isFocused(): boolean {
+ return this.focused;
+ }
+
+ /**
+ * Native element for editable div.
+ */
+ get nativeElement() {
+ return this.editableDivElement;
+ }
+
+ /**
+ * Scroll Top for editable div.
+ */
+ override get scrollTop() {
+ return this.editableDivElement?.scrollTop ?? 0;
+ }
+
+ private innerValue: string | undefined;
+
+ private innerHint: string | undefined;
+
+ private focused = false;
+
+ private readonly isPlaintextOnlySupported = supportsPlainTextEditing();
+
+ static override get styles() {
+ return [
+ css`
+ :host {
+ display: inline-block;
+ position: relative;
+ width: 100%;
+ }
+
+ :host([disabled]) {
+ .editableDiv {
+ background-color: var(--input-field-disabled-bg, lightgrey);
+ color: var(--text-disabled, black);
+ cursor: default;
+ }
+ }
+
+ .editableDiv {
+ background-color: var(--input-field-bg, white);
+ border: var(--gr-textarea-border-width, 2px) solid
+ var(--gr-textarea-border-color, white);
+ border-radius: 4px;
+ box-sizing: border-box;
+ color: var(--text-default, black);
+ max-height: var(--gr-textarea-max-height, 16em);
+ min-height: var(--gr-textarea-min-height, 4em);
+ overflow-x: auto;
+ padding: var(--gr-textarea-padding, 12px);
+ white-space: pre-wrap;
+ width: 100%;
+
+ &:focus-visible {
+ border-color: var(--gr-textarea-focus-outline-color, black);
+ outline: none;
+ }
+
+ &:empty::before {
+ content: attr(data-placeholder);
+ color: var(--text-secondary, lightgrey);
+ display: inline;
+ pointer-events: none;
+ }
+
+ &.hintShown:empty::after,
+ .autocomplete-hint:empty::after {
+ background-color: var(--secondary-bg-color, white);
+ border: 1px solid var(--text-secondary, lightgrey);
+ border-radius: 2px;
+ content: 'tab';
+ color: var(--text-secondary, lightgrey);
+ display: inline;
+ pointer-events: none;
+ font-size: 10px;
+ line-height: 10px;
+ margin-left: 4px;
+ padding: 1px 3px;
+ }
+
+ .autocomplete-hint {
+ &:empty::before {
+ content: attr(data-hint);
+ color: var(--text-secondary, lightgrey);
+ }
+ }
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ const isHintShownAsPlaceholder =
+ (!this.disabled && this.placeholderHint) ?? false;
+
+ const placeholder = isHintShownAsPlaceholder
+ ? this.placeholderHint
+ : this.placeholder;
+ const ariaPlaceholder = isHintShownAsPlaceholder
+ ? (this.placeholderHint ?? '') + ACCEPT_PLACEHOLDER_HINT_LABEL
+ : placeholder;
+
+ const classes = classMap({
+ editableDiv: true,
+ hintShown: isHintShownAsPlaceholder,
+ });
+
+ // Chrome supports non-standard "contenteditable=plaintext-only",
+ // which prevents HTML from being inserted into a contenteditable element.
+ // https://github.com/w3c/editing/issues/162
+ return html`<div
+ aria-disabled=${this.disabled}
+ aria-multiline="true"
+ aria-placeholder=${ifDefined(ariaPlaceholder)}
+ data-placeholder=${ifDefined(placeholder)}
+ class=${classes}
+ contenteditable=${this.contentEditableAttributeValue}
+ dir="ltr"
+ role="textbox"
+ @input=${this.onInput}
+ @focus=${this.onFocus}
+ @blur=${this.onBlur}
+ @keydown=${this.handleKeyDown}
+ @keyup=${this.handleKeyUp}
+ @mouseup=${this.handleMouseUp}
+ @scroll=${this.handleScroll}
+ ></div>`;
+ }
+
+ /**
+ * Focuses the textarea.
+ */
+ override async focus() {
+ const editableDivElement = await this.editableDiv;
+ const isFocused = this.isFocused;
+ editableDivElement?.focus?.();
+ // If already focused, do not change the cursor position.
+ if (this.putCursorAtEndOnFocus && !isFocused) {
+ await this.putCursorAtEnd();
+ }
+ }
+
+ /**
+ * Puts the cursor at the end of existing content.
+ * Scrolls the content of textarea towards the end.
+ */
+ async putCursorAtEnd() {
+ const editableDivElement = await this.editableDiv;
+ const selection = this.getSelection();
+
+ if (!editableDivElement || !selection) {
+ return;
+ }
+
+ const range = document.createRange();
+ editableDivElement.focus();
+ range.setStart(editableDivElement, editableDivElement.childNodes.length);
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ this.scrollToCursorPosition(range);
+
+ range.detach();
+
+ this.onCursorPositionChange();
+ }
+
+ public setCursorPosition(position: number) {
+ this.setCursorPositionForDiv(position, this.editableDivElement);
+ }
+
+ public async setCursorPositionAsync(position: number) {
+ const editableDivElement = await this.editableDiv;
+ this.setCursorPositionForDiv(position, editableDivElement);
+ }
+
+ /**
+ * Sets cursor position to given position and scrolls the content to cursor
+ * position.
+ *
+ * If position is out of bounds of value of textarea then cursor is places at
+ * end of content of textarea.
+ */
+ private setCursorPositionForDiv(
+ position: number,
+ editableDivElement?: HTMLDivElement
+ ) {
+ // This will keep track of remaining offset to place the cursor.
+ let remainingOffset = position;
+ let isOnFreshLine = true;
+ let nodeToFocusOn: Node | null = null;
+ const selection = this.getSelection();
+
+ if (!editableDivElement || !selection) {
+ return;
+ }
+ editableDivElement.focus();
+ const findNodeToFocusOn = (childNodes: Node[]) => {
+ for (let i = 0; i < childNodes.length; i++) {
+ const childNode = childNodes[i];
+ let currentNodeLength = 0;
+
+ if (childNode.nodeType === Node.COMMENT_NODE) {
+ continue;
+ }
+
+ if (childNode.nodeName === 'BR') {
+ currentNodeLength++;
+ isOnFreshLine = true;
+ }
+
+ if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+ currentNodeLength++;
+ }
+
+ isOnFreshLine = false;
+
+ if (childNode.nodeType === Node.TEXT_NODE && childNode.textContent) {
+ currentNodeLength += childNode.textContent.length;
+ }
+
+ if (remainingOffset <= currentNodeLength) {
+ nodeToFocusOn = childNode;
+ break;
+ } else {
+ remainingOffset -= currentNodeLength;
+ }
+
+ if (childNode.childNodes?.length > 0) {
+ findNodeToFocusOn(Array.from(childNode.childNodes));
+ }
+ }
+ };
+
+ findNodeToFocusOn(Array.from(editableDivElement.childNodes));
+
+ this.setFocusOnNode(
+ selection,
+ editableDivElement,
+ nodeToFocusOn,
+ remainingOffset
+ );
+ }
+
+ /**
+ * Replaces text from start and end cursor position.
+ */
+ setRangeText(replacement: string, start: number, end: number) {
+ const pre = this.value?.substring(0, start) ?? '';
+ const post = this.value?.substring(end, this.value?.length ?? 0) ?? '';
+
+ this.value = pre + replacement + post;
+ this.setCursorPosition(pre.length + replacement.length);
+ }
+
+ private get contentEditableAttributeValue() {
+ return this.disabled
+ ? 'false'
+ : this.isPlaintextOnlySupported
+ ? ('plaintext-only' as unknown as 'true')
+ : 'true';
+ }
+
+ private setFocusOnNode(
+ selection: Selection,
+ editableDivElement: Node,
+ nodeToFocusOn: Node | null,
+ remainingOffset: number
+ ) {
+ const range = document.createRange();
+ // If node is null or undefined then fallback to focus event which will put
+ // cursor at the end of content.
+ if (nodeToFocusOn === null) {
+ range.setStart(editableDivElement, editableDivElement.childNodes.length);
+ }
+ // If node to focus is BR then focus offset is number of nodes.
+ else if (nodeToFocusOn.nodeName === 'BR') {
+ const nextNode = nodeToFocusOn.nextSibling ?? nodeToFocusOn;
+ range.setEnd(nextNode, 0);
+ } else {
+ range.setStart(nodeToFocusOn, remainingOffset);
+ }
+
+ range.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ // Scroll the content to cursor position.
+ this.scrollToCursorPosition(range);
+
+ range.detach();
+
+ this.onCursorPositionChange();
+ }
+
+ private async onInput(event: Event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+
+ const value = await this.getValue();
+ this.innerValue = value;
+
+ this.fire('input', {value: this.value});
+ }
+
+ private onFocus() {
+ this.focused = true;
+ this.onCursorPositionChange();
+ }
+
+ private onBlur() {
+ this.focused = false;
+ this.removeHintSpanIfShown();
+ this.onCursorPositionChange();
+ }
+
+ private async handleKeyDown(event: KeyboardEvent) {
+ if (
+ event.key === 'Tab' &&
+ !event.shiftKey &&
+ !event.ctrlKey &&
+ !event.metaKey
+ ) {
+ await this.handleTabKeyPress(event);
+ return;
+ }
+ if (
+ this.enableSaveShortcut &&
+ event.key === 's' &&
+ (event.ctrlKey || event.metaKey)
+ ) {
+ event.preventDefault();
+ this.fire('saveShortcut');
+ }
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private handleKeyUp() {
+ this.onCursorPositionChange();
+ }
+
+ private async handleMouseUp() {
+ this.onCursorPositionChange();
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private handleScroll() {
+ this.fire('scroll');
+ }
+
+ private fire<T>(type: string, detail?: T) {
+ this.dispatchEvent(
+ new CustomEvent(type, {detail, bubbles: true, composed: true})
+ );
+ }
+
+ private async handleTabKeyPress(event: KeyboardEvent) {
+ const oldValue = this.value;
+ if (this.placeholderHint && !oldValue) {
+ event.preventDefault();
+ await this.appendHint(this.placeholderHint, event);
+ } else if (this.hasHintSpan()) {
+ event.preventDefault();
+ await this.appendHint(this.hint!, event);
+ }
+ }
+
+ private async appendHint(hint: string, event: Event) {
+ const oldValue = this.value ?? '';
+ const newValue = oldValue + hint;
+
+ this.value = newValue;
+ await this.putCursorAtEnd();
+ await this.onInput(event);
+
+ this.fire('hintApplied', {hint, oldValue});
+ }
+
+ private async toggleHintVisibilityIfAny() {
+ // Wait for the next animation frame so that entered key is processed and
+ // available in dom.
+ await animationFrame();
+
+ const editableDivElement = await this.editableDiv;
+ const currentValue = (await this.getValue()) ?? '';
+ const cursorPosition = await this.getCursorPositionAsync();
+ if (
+ !editableDivElement ||
+ (this.placeholderHint && !currentValue) ||
+ !this.hint ||
+ !this.isFocused ||
+ cursorPosition !== currentValue.length
+ ) {
+ this.removeHintSpanIfShown();
+ return;
+ }
+
+ const hintSpan = this.hintSpan();
+ if (!hintSpan) {
+ this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+ return;
+ }
+
+ const oldHint = (hintSpan as HTMLElement).dataset['hint'];
+ if (oldHint !== this.hint) {
+ this.removeHintSpanIfShown();
+ this.addHintSpanAtEndOfContent(editableDivElement, this.hint || '');
+ }
+ }
+
+ private addHintSpanAtEndOfContent(editableDivElement: Node, hint: string) {
+ const oldValue = this.value ?? '';
+ const hintSpan = document.createElement('span');
+ hintSpan.classList.add(AUTOCOMPLETE_HINT_CLASS);
+ hintSpan.setAttribute('role', 'alert');
+ hintSpan.setAttribute(
+ 'aria-label',
+ 'Suggestion: ' + hint + ' Press TAB to accept it.'
+ );
+ hintSpan.dataset['hint'] = hint;
+ editableDivElement.appendChild(hintSpan);
+ this.fire('hintShown', {hint, oldValue});
+ }
+
+ private removeHintSpanIfShown() {
+ const hintSpan = this.hintSpan();
+ if (hintSpan) {
+ hintSpan.remove();
+ this.fire('hintDismissed', {
+ hint: (hintSpan as HTMLElement).dataset['hint'],
+ });
+ }
+ }
+
+ private hasHintSpan() {
+ return !!this.hintSpan();
+ }
+
+ private hintSpan() {
+ return this.shadowRoot?.querySelector('.' + AUTOCOMPLETE_HINT_CLASS);
+ }
+
+ private onCursorPositionChange() {
+ this.fire('cursorPositionChange', {position: this.getCursorPosition()});
+ }
+
+ private async updateValueInDom() {
+ const editableDivElement =
+ this.editableDivElement ?? (await this.editableDiv);
+ if (editableDivElement) {
+ editableDivElement.innerText = this.value || '';
+ }
+ }
+
+ private async updateHintInDomIfRendered() {
+ // Wait for editable div to render then process the hint.
+ await this.editableDiv;
+ await this.toggleHintVisibilityIfAny();
+ }
+
+ private async getValue() {
+ const editableDivElement = await this.editableDiv;
+ if (editableDivElement) {
+ const [output] = this.parseText(editableDivElement, false, true);
+ return output;
+ }
+ return '';
+ }
+
+ private parseText(
+ node: Node,
+ isLastBr: boolean,
+ isFirst: boolean
+ ): [string, boolean] {
+ let textValue = '';
+ let output = '';
+ if (node.nodeName === 'BR') {
+ return ['\n', true];
+ }
+
+ if (node.nodeType === Node.TEXT_NODE && node.textContent) {
+ return [node.textContent, false];
+ }
+
+ if (node.nodeName === 'DIV' && !isLastBr && !isFirst) {
+ textValue = '\n';
+ }
+
+ isLastBr = false;
+
+ for (let i = 0; i < node.childNodes?.length; i++) {
+ [output, isLastBr] = this.parseText(
+ node.childNodes[i],
+ isLastBr,
+ i === 0
+ );
+ textValue += output;
+ }
+ return [textValue, isLastBr];
+ }
+
+ public getCursorPosition() {
+ return this.getCursorPositionForDiv(this.editableDivElement);
+ }
+
+ public async getCursorPositionAsync() {
+ const editableDivElement = await this.editableDiv;
+ return this.getCursorPositionForDiv(editableDivElement);
+ }
+
+ private getCursorPositionForDiv(editableDivElement?: HTMLDivElement) {
+ const selection = this.getSelection();
+
+ // Cursor position is -1 (not available) if
+ //
+ // If textarea is not rendered.
+ // If textarea is not focused
+ // There is no accessible selection object.
+ // This is not a collapsed selection.
+ if (
+ !editableDivElement ||
+ !this.focused ||
+ !selection ||
+ selection.focusNode === null ||
+ !selection.isCollapsed
+ ) {
+ return -1;
+ }
+
+ let cursorPosition = 0;
+ let isOnFreshLine = true;
+
+ const findCursorPosition = (childNodes: Node[]) => {
+ for (let i = 0; i < childNodes.length; i++) {
+ const childNode = childNodes[i];
+
+ if (childNode.nodeName === 'BR') {
+ cursorPosition++;
+ isOnFreshLine = true;
+ continue;
+ }
+
+ if (childNode.nodeName === 'DIV' && !isOnFreshLine && i !== 0) {
+ cursorPosition++;
+ }
+
+ isOnFreshLine = false;
+
+ if (childNode === selection.focusNode) {
+ cursorPosition += selection.focusOffset;
+ break;
+ } else if (childNode.nodeType === 3 && childNode.textContent) {
+ cursorPosition += childNode.textContent.length;
+ }
+
+ if (childNode.childNodes?.length > 0) {
+ findCursorPosition(Array.from(childNode.childNodes));
+ }
+ }
+ };
+
+ if (editableDivElement === selection.focusNode) {
+ // If focus node is the top textarea then focusOffset is the number of
+ // child nodes before the cursor position.
+ const partOfNodes = Array.from(editableDivElement.childNodes).slice(
+ 0,
+ selection.focusOffset
+ );
+ findCursorPosition(partOfNodes);
+ } else {
+ findCursorPosition(Array.from(editableDivElement.childNodes));
+ }
+
+ return cursorPosition;
+ }
+
+ /** Gets the current selection, preferring the shadow DOM selection. */
+ private getSelection(): Selection | undefined | null {
+ // TODO: Use something similar to gr-diff's getShadowOrDocumentSelection()
+ return this.shadowRoot?.getSelection?.();
+ }
+
+ private scrollToCursorPosition(range: Range) {
+ const tempAnchorEl = document.createElement('br');
+ range.insertNode(tempAnchorEl);
+
+ tempAnchorEl.scrollIntoView({behavior: 'smooth', block: 'nearest'});
+
+ tempAnchorEl.remove();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-textarea': GrTextarea;
+ }
+ interface HTMLElementEventMap {
+ // prettier-ignore
+ 'saveShortcut': CustomEvent<{}>;
+ // prettier-ignore
+ 'hintApplied': CustomEvent<HintAppliedEventDetail>;
+ // prettier-ignore
+ 'hintShown': CustomEvent<HintShownEventDetail>;
+ // prettier-ignore
+ 'hintDismissed': CustomEvent<HintDismissedEventDetail>;
+ // prettier-ignore
+ 'cursorPositionChange': CustomEvent<CursorPositionChangeEventDetail>;
+ }
+}
diff --git a/polygerrit-ui/app/embed/gr-textarea_test.ts b/polygerrit-ui/app/embed/gr-textarea_test.ts
new file mode 100644
index 0000000..b701dcb
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-textarea_test.ts
@@ -0,0 +1,235 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-textarea';
+import {fixture, html, assert} from '@open-wc/testing';
+import {waitForEventOnce} from '../utils/event-util';
+import {AUTOCOMPLETE_HINT_CLASS, GrTextarea} from './gr-textarea';
+import {CursorPositionChangeEventDetail} from '../api/embed';
+
+async function rafPromise() {
+ return new Promise(res => {
+ requestAnimationFrame(res);
+ });
+}
+
+suite('gr-textarea test', () => {
+ let element: GrTextarea;
+
+ setup(async () => {
+ element = await fixture(html` <gr-textarea> </gr-textarea>`);
+ });
+
+ test('text area is registered correctly', () => {
+ assert.instanceOf(element, GrTextarea);
+ });
+
+ test('when disabled textarea have contenteditable set to false', async () => {
+ element.disabled = true;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.getAttribute('contenteditable'), 'false');
+ });
+
+ test('when disabled textarea have aria-disabled set', async () => {
+ element.disabled = true;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.isDefined(editableDiv?.getAttribute('aria-disabled'));
+ });
+
+ test('when textarea has placeholder, set aria-placeholder to placeholder text', async () => {
+ const placeholder = 'A sample placehodler...';
+ element.placeholder = placeholder;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector('.editableDiv');
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.getAttribute('aria-placeholder'), placeholder);
+ });
+
+ test('renders the value', async () => {
+ const value = 'Some value';
+ element.value = value;
+ await element.updateComplete;
+
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.innerText, value);
+ });
+
+ test('streams change event when editable div has input event', async () => {
+ const value = 'Some value \n other value';
+ const INPUT_EVENT = 'input';
+ let changeCalled = false;
+
+ element.addEventListener(INPUT_EVENT, () => {
+ changeCalled = true;
+ });
+
+ const changeEventPromise = waitForEventOnce(element, INPUT_EVENT);
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+
+ editableDiv.innerText = value;
+ editableDiv.dispatchEvent(new Event('input'));
+ await changeEventPromise;
+
+ assert.isTrue(changeCalled);
+ });
+
+ test('does not have focus by default', async () => {
+ assert.isFalse(element.isFocused);
+ });
+
+ test('when focused, isFocused is set to true', async () => {
+ await element.focus();
+ assert.isTrue(element.isFocused);
+ });
+
+ test('when cursor position is set to 0', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.setCursorPosition(0);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 0);
+ });
+
+ test('when cursor position is set to 1', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.value = 'Some value';
+ await element.updateComplete;
+ element.setCursorPosition(1);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 1);
+ });
+
+ test('when cursor position is set to new line', async () => {
+ const CURSOR_POSITION_CHANGE_EVENT = 'cursorPositionChange';
+ let cursorPosition = -1;
+
+ const cursorPositionChangeEventPromise = waitForEventOnce(
+ element,
+ CURSOR_POSITION_CHANGE_EVENT
+ );
+ element.addEventListener(CURSOR_POSITION_CHANGE_EVENT, (event: Event) => {
+ const detail = (event as CustomEvent<CursorPositionChangeEventDetail>)
+ .detail;
+ cursorPosition = detail.position;
+ });
+
+ element.value = 'Some \n\n\n value';
+ await element.updateComplete;
+ element.setCursorPosition(7);
+ await cursorPositionChangeEventPromise;
+
+ assert.equal(cursorPosition, 7);
+ });
+
+ test('when textarea is empty, placeholder hint is shown', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const placeholderHint = 'Some value';
+
+ element.placeholderHint = placeholderHint;
+ await element.updateComplete;
+
+ assert.equal(editableDiv?.dataset['placeholder'], placeholderHint);
+ });
+
+ test('when TAB is pressed, placeholder hint is added as content', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const placeholderHint = 'Some value';
+
+ element.placeholderHint = placeholderHint;
+ await element.updateComplete;
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ await element.updateComplete;
+
+ assert.equal(element.value, placeholderHint);
+ });
+
+ test('when cursor is at end, hint is shown', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const oldValue = 'Hola';
+ const hint = 'amigos';
+
+ element.hint = hint;
+ await element.updateComplete;
+ element.value = oldValue;
+ await element.putCursorAtEnd();
+ await element.updateComplete;
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+ await element.updateComplete;
+ await rafPromise();
+
+ const spanHintElement = editableDiv?.querySelector(
+ '.' + AUTOCOMPLETE_HINT_CLASS
+ ) as HTMLSpanElement;
+ const styles = window.getComputedStyle(spanHintElement, ':before');
+ assert.equal(styles['content'], '"' + hint + '"');
+ });
+
+ test('when TAB is pressed, hint is added as content', async () => {
+ const editableDiv = element.shadowRoot!.querySelector(
+ '.editableDiv'
+ ) as HTMLDivElement;
+ const oldValue = 'Hola';
+ const hint = 'amigos';
+
+ element.hint = hint;
+ element.value = oldValue;
+ await element.updateComplete;
+ await element.putCursorAtEnd();
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'}));
+ await rafPromise();
+ editableDiv.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
+ await element.updateComplete;
+
+ assert.equal(element.value, oldValue + hint);
+ });
+});
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts/accounts-model.ts
similarity index 100%
rename from polygerrit-ui/app/models/accounts-model/accounts-model.ts
rename to polygerrit-ui/app/models/accounts/accounts-model.ts
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
similarity index 100%
rename from polygerrit-ui/app/models/accounts-model/accounts-model_test.ts
rename to polygerrit-ui/app/models/accounts/accounts-model_test.ts
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index bba7e47..04fa0d6 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -46,8 +46,6 @@
ChangeChildView,
ChangeViewModel,
createChangeUrl,
- createDiffUrl,
- createEditUrl,
} from '../views/change';
import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
import {getRevertCreatedChangeIds} from '../../utils/message-util';
@@ -657,27 +655,13 @@
return this.getState().change;
}
- diffUrl(
- diffView: {path: string; lineNum?: number},
- patchNum = this.patchNum,
- basePatchNum = this.basePatchNum
- ) {
- if (!this.change) return;
- if (!this.patchNum) return;
- return createDiffUrl({
- change: this.change,
- patchNum,
- basePatchNum,
- diffView,
- });
- }
-
navigateToDiff(
diffView: {path: string; lineNum?: number},
patchNum = this.patchNum,
basePatchNum = this.basePatchNum
) {
- const url = this.diffUrl(diffView, patchNum, basePatchNum);
+ if (!patchNum) return;
+ const url = this.viewModel.diffUrl({diffView, patchNum, basePatchNum});
if (!url) return;
this.navigation.setUrl(url);
}
@@ -715,18 +699,9 @@
this.navigation.setUrl(url);
}
- editUrl(editView: {path: string; lineNum?: number}) {
- if (!this.change) return;
- return createEditUrl({
- changeNum: this.change._number,
- repo: this.change.project,
- patchNum: this.patchNum,
- editView,
- });
- }
-
navigateToEdit(editView: {path: string; lineNum?: number}) {
- const url = this.editUrl(editView);
+ if (!this.patchNum) return;
+ const url = this.viewModel.editUrl({editView, patchNum: this.patchNum});
if (!url) return;
this.navigation.setUrl(url);
}
@@ -739,27 +714,21 @@
* has been loaded, and false if a newer patch has been uploaded in the
* meantime. The promise is rejected on network error.
*/
- fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
- const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
- return this.restApiService.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
- ? detail.messages![detail.messages!.length - 1]
- : undefined,
- };
- });
+ async fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+ const knownLatest = change.current_revision_number;
+ const detail = await this.restApiService.getChange(change._number);
+ if (!detail) {
+ throw new Error('Change request failed.');
+ }
+ const actualLatest = detail.current_revision_number;
+ return {
+ isLatest: actualLatest <= knownLatest,
+ newStatus: change.status !== detail.status ? detail.status : null,
+ newMessages:
+ (change.messages || []).length < (detail.messages || []).length
+ ? detail.messages![detail.messages!.length - 1]
+ : undefined,
+ };
}
/**
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index ebe6066..bf7b9dc 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -23,6 +23,7 @@
} from '../../test/test-utils';
import {
BasePatchSetNum,
+ ChangeInfo,
CommitId,
EDIT,
NumericChangeId,
@@ -101,6 +102,7 @@
let changeViewModel: ChangeViewModel;
let changeModel: ChangeModel;
let knownChange: ParsedChangeInfo;
+ let knownChangeNoRevision: ChangeInfo;
const testCompleted = new Subject<void>();
async function waitForLoadingStatus(
@@ -123,15 +125,19 @@
testResolver(pluginLoaderToken),
getAppContext().reportingService
);
- knownChange = {
+ knownChangeNoRevision = {
...createChange(),
+ status: ChangeStatus.NEW,
+ current_revision_number: 2 as PatchSetNumber,
+ messages: [],
+ };
+ knownChange = {
+ ...knownChangeNoRevision,
revisions: {
sha1: {...createRevision(1), description: 'patch 1'},
sha2: {...createRevision(2), description: 'patch 2'},
},
- status: ChangeStatus.NEW,
current_revision: 'abc' as CommitId,
- messages: [],
};
});
@@ -377,7 +383,7 @@
});
test('changeModel.fetchChangeUpdates on latest', async () => {
- stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+ stubRestApi('getChange').returns(Promise.resolve(knownChangeNoRevision));
const result = await changeModel.fetchChangeUpdates(knownChange);
assert.isTrue(result.isLatest);
assert.isNotOk(result.newStatus);
@@ -386,17 +392,10 @@
test('changeModel.fetchChangeUpdates not on latest', async () => {
const actualChange = {
- ...knownChange,
- revisions: {
- ...knownChange.revisions,
- sha3: {
- ...createRevision(3),
- description: 'patch 3',
- _number: 3 as PatchSetNumber,
- },
- },
+ ...knownChangeNoRevision,
+ current_revision_number: 3 as PatchSetNumber,
};
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ stubRestApi('getChange').returns(Promise.resolve(actualChange));
const result = await changeModel.fetchChangeUpdates(knownChange);
assert.isFalse(result.isLatest);
assert.isNotOk(result.newStatus);
@@ -405,10 +404,10 @@
test('changeModel.fetchChangeUpdates new status', async () => {
const actualChange = {
- ...knownChange,
+ ...knownChangeNoRevision,
status: ChangeStatus.MERGED,
};
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ stubRestApi('getChange').returns(Promise.resolve(actualChange));
const result = await changeModel.fetchChangeUpdates(knownChange);
assert.isTrue(result.isLatest);
assert.equal(result.newStatus, ChangeStatus.MERGED);
@@ -417,10 +416,10 @@
test('changeModel.fetchChangeUpdates new messages', async () => {
const actualChange = {
- ...knownChange,
+ ...knownChangeNoRevision,
messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
};
- stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+ stubRestApi('getChange').returns(Promise.resolve(actualChange));
const result = await changeModel.fetchChangeUpdates(knownChange);
assert.isTrue(result.isLatest);
assert.isNotOk(result.newStatus);
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index f9a8401..497962f 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -56,7 +56,7 @@
import {Deduping} from '../../api/reporting';
import {extractMentionedUsers, getUserId} from '../../utils/account-util';
import {SpecialFilePath} from '../../constants/constants';
-import {AccountsModel} from '../accounts-model/accounts-model';
+import {AccountsModel} from '../accounts/accounts-model';
import {
distinctUntilChanged,
map,
@@ -432,7 +432,7 @@
threads.filter(t => !isNewThread(t) && isDraftThread(t))
);
- public readonly threadsWithSuggestions$ = select(
+ public readonly threadsWithUnappliedSuggestions$ = select(
combineLatest([this.threads$, this.changeModel.latestPatchNum$]),
([threads, latestPs]) =>
threads.filter(
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 0d3df42..b3dbc08 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -28,7 +28,7 @@
import {changeModelToken} from '../change/change-model';
import {assert} from '@open-wc/testing';
import {testResolver} from '../../test/common-test-setup';
-import {accountsModelToken} from '../accounts-model/accounts-model';
+import {accountsModelToken} from '../accounts/accounts-model';
import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
import {changeViewModelToken} from '../views/change';
import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 8e9bead..880bcd0 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -89,6 +89,11 @@
public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
+ public suggestionsPlugins$ = select(
+ this.state$,
+ state => state.suggestionsPlugins
+ );
+
public pluginsLoaded$ = select(this.state$, state => state.pluginsLoaded);
constructor() {
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index cd6a66a..4973307 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {from, of, Observable} from 'rxjs';
-import {filter, switchMap} from 'rxjs/operators';
+import {filter, switchMap, tap} from 'rxjs/operators';
import {
DiffPreferencesInfo as DiffPreferencesInfoAPI,
DiffViewMode,
@@ -13,6 +13,7 @@
AccountCapabilityInfo,
AccountDetailInfo,
EditPreferencesInfo,
+ EmailInfo,
PreferencesInfo,
TopMenuItemInfo,
} from '../../types/common';
@@ -48,6 +49,7 @@
* `account` is known, then use `accountLoaded` below.
*/
account?: AccountDetailInfo;
+ emails?: EmailInfo[];
/**
* Starts as `false` and switches to `true` after the first `getAccount` call.
* A common use case for this is to wait with loading or doing something until
@@ -82,6 +84,15 @@
userState => userState.account
);
+ readonly emails$: Observable<EmailInfo[] | undefined> = select(
+ this.state$,
+ userState => userState.emails
+ ).pipe(
+ tap(emails => {
+ if (emails === undefined) this.loadEmails();
+ })
+ );
+
/**
* Only emits once we have tried to actually load the account. Note that
* this does not initially emit a value.
@@ -148,12 +159,8 @@
super({
accountLoaded: false,
});
+ this.loadAccount();
this.subscriptions = [
- from(this.restApiService.getAccount()).subscribe(
- (account?: AccountDetailInfo) => {
- this.setAccount(account);
- }
- ),
this.loadedAccount$
.pipe(
switchMap(account => {
@@ -261,4 +268,22 @@
setAccount(account?: AccountDetailInfo) {
this.updateState({account, accountLoaded: true});
}
+
+ private setAccountEmails(emails?: EmailInfo[]) {
+ this.updateState({emails});
+ }
+
+ loadAccount(noCache?: boolean) {
+ if (noCache) this.restApiService.invalidateAccountsDetailCache();
+ return this.restApiService.getAccount().then(account => {
+ this.setAccount(account);
+ });
+ }
+
+ loadEmails(noCache?: boolean) {
+ if (noCache) this.restApiService.invalidateAccountsEmailCache();
+ return this.restApiService.getAccountEmails().then(emails => {
+ this.setAccountEmails(emails);
+ });
+ }
}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 06d981a..661c74f 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -11,11 +11,12 @@
ChangeInfo,
PatchSetNumber,
EDIT,
+ PARENT,
} from '../../api/rest-api';
import {Tab} from '../../constants/constants';
import {GerritView} from '../../services/router/router-model';
import {UrlEncodedCommentId} from '../../types/common';
-import {toggleSet} from '../../utils/common-util';
+import {assertIsDefined, toggleSet} from '../../utils/common-util';
import {select} from '../../utils/observable-util';
import {
encodeURL,
@@ -26,6 +27,7 @@
import {define} from '../dependency';
import {Model} from '../base/model';
import {ViewState} from './base';
+import {isNumber} from '../../utils/patch-set-util';
export enum ChangeChildView {
OVERVIEW = 'OVERVIEW',
@@ -85,7 +87,7 @@
/** These properties apply to the DIFF child view only. */
diffView?: {
- path?: string;
+ path: string;
// TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
lineNum?: number;
leftSide?: boolean;
@@ -93,11 +95,28 @@
/** These properties apply to the EDIT child view only. */
editView?: {
- path?: string;
+ path: string;
lineNum?: number;
};
}
+export type DiffViewState = Partial<ChangeViewState> & {
+ patchNum: RevisionPatchSetNum;
+ diffView: {
+ path: string;
+ lineNum?: number;
+ leftSide?: boolean;
+ };
+};
+
+export type EditViewState = Partial<ChangeViewState> & {
+ patchNum: RevisionPatchSetNum;
+ editView: {
+ path: string;
+ lineNum?: number;
+ };
+};
+
/**
* This is a convenience type such that you can pass a `ChangeInfo` object
* as the `change` property instead of having to set both the `changeNum` and
@@ -145,7 +164,7 @@
export function createChangeUrl(
obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
const state: ChangeViewState = objToState({
...obj,
childView: ChangeChildView.OVERVIEW,
@@ -198,7 +217,7 @@
export function createDiffUrl(
obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
-) {
+): string {
const state: ChangeViewState = objToState({
...obj,
childView: ChangeChildView.DIFF,
@@ -378,6 +397,49 @@
}
}
+ /**
+ * Wrapper around createDiffUrl() that falls back to the current state for all
+ * properties that are not explicitly provided as an override.
+ */
+ diffUrl(override: DiffViewState): string {
+ const current = this.getState();
+ assertIsDefined(current?.changeNum);
+ assertIsDefined(current?.repo);
+
+ const patchNum = override.patchNum ?? current.patchNum;
+ let basePatchNum = override.basePatchNum ?? current.basePatchNum;
+ if (isNumber(basePatchNum) && isNumber(patchNum)) {
+ if ((patchNum as number) <= (basePatchNum as number)) {
+ basePatchNum = PARENT;
+ }
+ }
+ return createDiffUrl({
+ changeNum: override.changeNum ?? current.changeNum,
+ repo: override.repo ?? current.repo,
+ patchNum,
+ basePatchNum,
+ checksPatchset: override.checksPatchset ?? current.checksPatchset,
+ diffView: override.diffView ?? current.diffView,
+ });
+ }
+
+ /**
+ * Wrapper around createEditUrl() that falls back to the current state for all
+ * properties that are not explicitly provided as an override.
+ */
+ editUrl(override: EditViewState): string {
+ const current = this.getState();
+ assertIsDefined(current?.changeNum);
+ assertIsDefined(current?.repo);
+
+ return createEditUrl({
+ changeNum: override.changeNum ?? current.changeNum,
+ repo: override.repo ?? current.repo,
+ patchNum: override.patchNum ?? current.patchNum,
+ editView: override.editView ?? current.editView,
+ });
+ }
+
toggleSelectedCheckRun(checkName: string) {
const current = this.getState()?.checksRunsSelected ?? new Set();
const next = new Set(current);
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 9fa2e89..21fab53 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -454,6 +454,14 @@
},
},
{
+ name: 'highlightjs-epp',
+ license: {
+ name: 'highlightjs-epp',
+ type: LicenseTypes.Bsd3,
+ packageLicenseFile: 'LICENSE',
+ },
+ },
+ {
name: 'highlightjs-structured-text',
license: {
name: 'highlightjs-structured-text',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 73816d2..dea0e8b 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -35,8 +35,9 @@
"@webcomponents/shadycss": "^1.11.2",
"@webcomponents/webcomponentsjs": "^1.3.3",
"highlight.js": "^11.9.0",
- "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
- "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
+ "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba",
+ "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9",
+ "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e",
"immer": "^9.0.21",
"lit": "^3.1.2",
"polymer-bridges": "file:../../polymer-bridges",
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index 40517ac..8536492 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -34,7 +34,7 @@
import {
AccountsModel,
accountsModelToken,
-} from '../models/accounts-model/accounts-model';
+} from '../models/accounts/accounts-model';
import {
DashboardViewModel,
dashboardViewModelToken,
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index b59d7a8b3..9431cf8 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -22,4 +22,7 @@
ML_SUGGESTED_EDIT = 'UiFeature__ml_suggested_edit',
ML_SUGGESTED_EDIT_V2 = 'UiFeature__ml_suggested_edit_v2',
REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data',
+ COMMENT_AUTOCOMPLETION = 'UiFeature__comment_autocompletion_enabled',
+ SAVE_PROJECT_CONFIG_FOR_REVIEW = 'UiFeature__save_project_config_for_review',
+ PARALLEL_DASHBOARD_REQUESTS = 'UiFeature__parallel_dashboard_requests',
}
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 6df2c67..e175228 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -55,7 +55,7 @@
/**
* Finish named timer and report it to server.
*/
- timeEnd(name: Timing, eventDetails?: EventDetails): void;
+ timeEnd(name: Timing, eventDetails?: EventDetails): number;
/**
* Get a timer object for reporting a user timing. The start time will be
* the time that the object has been created, and the end time will be the
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 1eb3bc2..585b714 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -3,7 +3,7 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {FlagsService} from '../flags/flags';
+import {FlagsService, KnownExperimentId} from '../flags/flags';
import {EventValue, ReportingService, Timer} from './gr-reporting';
import {hasOwnProperty} from '../../utils/common-util';
import {NumericChangeId} from '../../types/common';
@@ -360,6 +360,7 @@
screenSize?: {width: number; height: number};
viewport?: {width: number; height: number};
usedJSHeapSizeMb?: number;
+ parallelRequestsEnabled?: boolean;
}
interface SlowRpcCall {
@@ -687,6 +688,9 @@
const details: PageLoadDetails = {
rpcList: this.slowRpcSnapshot,
hiddenDurationMs: this.hiddenDurationTimer.accHiddenDurationMs,
+ parallelRequestsEnabled: this._flagsService.isEnabled(
+ KnownExperimentId.PARALLEL_DASHBOARD_REQUESTS
+ ),
};
if (window.screen) {
@@ -770,23 +774,26 @@
/**
* Finish named timer and report it to server.
*/
- timeEnd(name: Timing, eventDetails?: EventDetails) {
+ timeEnd(name: Timing, eventDetails?: EventDetails): number {
if (!hasOwnProperty(this._baselines, name)) {
- return;
+ return 0;
}
- const baseTime = this._baselines[name];
+ const begin = this._baselines[name];
delete this._baselines[name];
- this._reportTiming(name, now() - baseTime, eventDetails);
+ const end = now();
+ const elapsed = end - begin;
+ this._reportTiming(name, elapsed, eventDetails);
// Finalize the interval. Either from a registered start mark or
// the navigation start time (if baseTime is 0).
- if (baseTime !== 0) {
+ if (begin !== 0) {
window.performance.measure(name, `${name}-start`);
} else {
// Microsoft Edge does not handle the 2nd param correctly
// (if undefined).
window.performance.measure(name);
}
+ return elapsed;
}
/**
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index fb1f0c3..7cb777a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -71,5 +71,5 @@
setRepoName: () => {},
setChangeId: () => {},
time: () => {},
- timeEnd: () => {},
+ timeEnd: () => 0,
};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
index 2d3dfe2..91d742a 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -174,6 +174,7 @@
},
usedJSHeapSizeMb: 1,
hiddenDurationMs: 0,
+ parallelRequestsEnabled: false,
})
);
});
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
index a03680a..f4964e8 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts
@@ -347,6 +347,22 @@
});
}
+ saveRepoConfigForReview(
+ repo: RepoName,
+ config: ConfigInput
+ ): Promise<ChangeInfo | undefined> {
+ const url = `/projects/${encodeURIComponent(repo)}/config:review`;
+ return this._restApiHelper.fetchJSON({
+ fetchOptions: getFetchOptions({
+ method: HttpMethod.PUT,
+ body: config,
+ }),
+ url,
+ anonymizedUrl: '/projects/*/config',
+ reportServerError: true,
+ }) as unknown as Promise<ChangeInfo | undefined>;
+ }
+
runRepoGC(repo: RepoName): Promise<Response> {
const encodeName = encodeURIComponent(repo);
return this._restApiHelper.fetch({
@@ -1109,6 +1125,42 @@
}
/**
+ * Depending on an experiment this will either use `getChangesForMultipleQueries()`, which
+ * makes just one request to the REST API. Or it will fan out into multiple parallel
+ * requests and call `getChanges()` for each query.
+ */
+ async getChangesForDashboard(
+ changesPerPage?: number,
+ queries?: string[],
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[][] | undefined> {
+ // CAUTION: Before actually enabling this experiment for everyone we will have to also change
+ // the prefetched query in the backend. As is the experiment may help improving the
+ // DashboardDisplayed metric, but it will definitely make the *Startup*DashboardDisplayed
+ // latency worse.
+ const parallelRequests = this.flagService.isEnabled(
+ KnownExperimentId.PARALLEL_DASHBOARD_REQUESTS
+ );
+ if (parallelRequests && queries && queries.length > 1) {
+ const requestPromises = queries.map(query =>
+ this.getChanges(changesPerPage, query, offset, options)
+ );
+ return Promise.all(requestPromises).then(results => {
+ if (results.includes(undefined)) return undefined;
+ return results as ChangeInfo[][];
+ });
+ } else {
+ return this.getChangesForMultipleQueries(
+ changesPerPage,
+ queries,
+ offset,
+ options
+ );
+ }
+ }
+
+ /**
* For every query fetches the matching changes.
*
* If options is undefined then default options (see getListChangesOptionsHex) is
@@ -1276,7 +1328,6 @@
// This list MUST be kept in sync with
// ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
- // This list MUST be kept in sync with getResponseFormatOptions
const options = [
ListChangesOption.ALL_COMMITS,
ListChangesOption.ALL_REVISIONS,
@@ -1300,35 +1351,6 @@
return options;
}
- async getResponseFormatOptions(): Promise<string[]> {
- const config = await this.getConfig(false);
-
- // This list MUST be kept in sync with
- // ChangeIT#changeDetailsDoesNotRequireIndex and IndexPreloadingUtil#CHANGE_DETAIL_OPTIONS
- // This list MUST be kept in sync with getChangeOptions
- const options = [
- 'ALL_COMMITS',
- 'ALL_REVISIONS',
- 'CHANGE_ACTIONS',
- 'DETAILED_LABELS',
- 'DETAILED_ACCOUNTS',
- 'DOWNLOAD_COMMANDS',
- 'MESSAGES',
- 'REVIEWER_UPDATES',
- 'SUBMITTABLE',
- 'WEB_LINKS',
- 'SKIP_DIFFSTAT',
- 'SUBMIT_REQUIREMENTS',
- ];
- if (this.flagService.isEnabled(KnownExperimentId.REVISION_PARENTS_DATA)) {
- options.push('PARENTS');
- }
- if (config?.receive?.enable_signed_push) {
- options.push('PUSH_CERTIFICATES');
- }
- return options;
- }
-
/**
* @param optionsHex list changes options in hex
*/
@@ -1594,6 +1616,10 @@
this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/detail');
}
+ invalidateAccountsEmailCache() {
+ this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/self/emails');
+ }
+
getGroups(filter: string, groupsPerPage: number, offset?: number) {
const url = this._getGroupsUrl(filter, groupsPerPage, offset);
@@ -2042,6 +2068,20 @@
});
}
+ getSaveReviewChangeOptions(): string[] {
+ const options = [
+ 'CHANGE_ACTIONS',
+ 'DETAILED_LABELS',
+ 'DETAILED_ACCOUNTS',
+ 'MESSAGES',
+ 'REVIEWER_UPDATES',
+ 'SUBMITTABLE',
+ 'SKIP_DIFFSTAT',
+ 'SUBMIT_REQUIREMENTS',
+ ];
+ return options;
+ }
+
async saveChangeReview(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
@@ -2050,7 +2090,7 @@
fetchDetail?: boolean
): Promise<ReviewResult | undefined> {
if (fetchDetail) {
- review.response_format_options = await this.getResponseFormatOptions();
+ review.response_format_options = this.getSaveReviewChangeOptions();
}
const promises: [Promise<void>, Promise<string>] = [
this.awaitPendingDiffDrafts(),
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 947952c..4c188fb 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -493,6 +493,12 @@
options?: string,
errFn?: ErrorCallback
): Promise<ChangeInfo[] | undefined>;
+ getChangesForDashboard(
+ changesPerPage?: number,
+ query?: string[],
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[][] | undefined>;
getChangesForMultipleQueries(
changesPerPage?: number,
query?: string[],
@@ -542,6 +548,7 @@
invalidateReposCache(): void;
invalidateAccountsCache(): void;
invalidateAccountsDetailCache(): void;
+ invalidateAccountsEmailCache(): void;
removeFromAttentionSet(
changeNum: NumericChangeId,
user: AccountId,
@@ -644,6 +651,10 @@
deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+ saveRepoConfigForReview(
+ repo: RepoName,
+ config: ConfigInput
+ ): Promise<ChangeInfo | undefined>;
getRelatedChanges(
changeNum: NumericChangeId,
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 7742b1f..a85b5d0 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -417,8 +417,8 @@
--diff-context-control-background-color: #fff7d4;
--diff-context-control-border-color: #f6e6a5;
--diff-context-control-color: var(--default-button-text-color);
- --diff-highlight-range-color: rgba(255, 213, 0, 0.5);
- --diff-highlight-range-hover-color: rgba(255, 255, 0, 0.5);
+ --diff-highlight-range-color: rgba(255, 220, 0, 0.5);
+ --diff-highlight-range-hover-color: rgba(255, 190, 0, 0.5);
--diff-selection-background-color: #c7dbf9;
--diff-tab-indicator-color: var(--deemphasized-text-color);
--diff-trailing-whitespace-indicator: #ff9ad2;
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 77f2498..284087e 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -256,6 +256,9 @@
getChanges() {
return Promise.resolve([]);
},
+ getChangesForDashboard() {
+ return Promise.resolve([]);
+ },
getChangesForMultipleQueries() {
return Promise.resolve([]);
},
@@ -442,6 +445,7 @@
invalidateGroupsCache(): void {},
invalidateReposCache(): void {},
invalidateAccountsDetailCache(): void {},
+ invalidateAccountsEmailCache(): void {},
probePath(): Promise<boolean> {
return Promise.resolve(true);
},
@@ -518,6 +522,9 @@
saveRepoConfig(): Promise<Response> {
return Promise.resolve(new Response());
},
+ saveRepoConfigForReview(): Promise<ChangeInfo | undefined> {
+ throw new Error('saveRepoConfigForReview() not implemented by mock.');
+ },
saveWatchedProjects(): Promise<ProjectWatchInfo[] | undefined> {
return Promise.resolve([]);
},
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index cba3a05..2309977 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -98,6 +98,7 @@
import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
import {
DetailedLabelInfo,
+ PatchSetNumber,
QuickLabelInfo,
SubmitRequirementExpressionInfo,
SubmitRequirementResultInfo,
@@ -428,6 +429,7 @@
owner: createAccountWithId(),
// This is documented as optional, but actually always set.
reviewers: createReviewers(),
+ current_revision_number: 1 as PatchSetNumber,
...partial,
};
}
@@ -488,6 +490,7 @@
all_projects: 'All-Projects',
all_users: 'All-Users',
doc_search: false,
+ project_state_predicate_enabled: true,
};
}
@@ -685,6 +688,27 @@
});
}
+export function createContextGroupWithDelta() {
+ return new GrDiffGroup({
+ type: GrDiffGroupType.CONTEXT_CONTROL,
+ contextGroups: [
+ new GrDiffGroup({
+ type: GrDiffGroupType.DELTA,
+ lines: [
+ 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 GrDiffLine(GrDiffLineType.REMOVE, 11),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 13),
+ ],
+ }),
+ ],
+ });
+}
+
export function createBlame(): BlameInfo {
return {
author: 'test-author',
@@ -710,6 +734,7 @@
email_strategy: EmailStrategy.ENABLED,
allow_browser_notifications: true,
allow_suggest_code_while_commenting: true,
+ allow_autocompleting_comments: true,
};
}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 4dd965c..720899f 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -986,6 +986,7 @@
can_add?: boolean;
can_add_tags?: boolean;
config_visible?: boolean;
+ require_change_for_config_update?: boolean;
groups: RepoAccessGroups;
config_web_links: WebLinkInfo[];
}
@@ -1344,6 +1345,7 @@
email_format?: EmailFormat;
allow_browser_notifications?: boolean;
allow_suggest_code_while_commenting?: boolean;
+ allow_autocompleting_comments?: boolean;
diff_page_sidebar?: DiffPageSidebar;
}
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index 2a8c7e5..0d1592e 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -16,6 +16,7 @@
DiffFileMetaInfo as DiffFileMetaInfoApi,
DiffInfo as DiffInfoApi,
DiffIntralineInfo,
+ DiffRangesToFocus,
DiffResponsiveMode,
DiffPreferencesInfo as DiffPreferenceInfoApi,
IgnoreWhitespaceType,
@@ -27,6 +28,7 @@
export type {
ChangeType,
DiffIntralineInfo,
+ DiffRangesToFocus,
DiffResponsiveMode,
IgnoreWhitespaceType,
MarkLength,
diff --git a/polygerrit-ui/app/utils/autocomplete-cache.ts b/polygerrit-ui/app/utils/autocomplete-cache.ts
new file mode 100644
index 0000000..b880ccd
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+export interface AutocompletionContext {
+ draftContent: string;
+ draftContentLength?: number;
+ commentCompletion: string;
+ commentCompletionLength?: number;
+
+ isFullCommentPrediction?: boolean;
+ draftInSyncWithSuggestionLength?: number;
+ modelVersion?: string;
+ outcome?: number;
+ requestDurationMs?: number;
+
+ commentId?: string;
+ commentNumber?: number;
+ filePath?: string;
+ fileExtension?: string;
+
+ similarCharacters?: number;
+ maxSimilarCharacters?: number;
+ acceptedSuggestionsCount?: number;
+ totalAcceptedCharacters?: number;
+ savedDraftLength?: number;
+
+ hasDraftChanged?: boolean;
+}
+
+/**
+ * Caching for autocompleting text, e.g. comments.
+ *
+ * If the user continues typing text that matches the completion hint, then keep the hint.
+ *
+ * If the user backspaces, then continue using previous hint.
+ */
+export class AutocompleteCache {
+ /**
+ * We are using an ordered list instead of a map here, because we want to evict the oldest
+ * entries, if the capacity is exceeded. And we want to prefer newer entries over older
+ * entries, if both match the criteria for being reused.
+ */
+ private cache: AutocompletionContext[] = [];
+
+ constructor(private readonly capacity = 10) {}
+
+ get(content: string): AutocompletionContext | undefined {
+ if (content === '') return undefined;
+ for (let i = this.cache.length - 1; i >= 0; i--) {
+ const cachedContext = this.cache[i];
+ const completionContent = cachedContext.draftContent;
+ const completionHint = cachedContext.commentCompletion;
+ const completionFull = completionContent + completionHint;
+ if (completionContent.length > content.length) continue;
+ if (!completionFull.startsWith(content)) continue;
+ if (completionFull === content) continue;
+ const hint = completionFull.substring(content.length);
+ return {
+ ...cachedContext,
+ draftContent: content,
+ commentCompletion: hint,
+ draftInSyncWithSuggestionLength:
+ content.length - completionContent.length,
+ };
+ }
+ return undefined;
+ }
+
+ set(context: AutocompletionContext) {
+ const index = this.cache.findIndex(
+ c => c.draftContent === context.draftContent
+ );
+ if (index !== -1) {
+ this.cache.splice(index, 1);
+ } else if (this.cache.length >= this.capacity) {
+ this.cache.shift();
+ }
+ this.cache.push(context);
+ }
+}
diff --git a/polygerrit-ui/app/utils/autocomplete-cache_test.ts b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
new file mode 100644
index 0000000..970436b
--- /dev/null
+++ b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
@@ -0,0 +1,89 @@
+/**
+ * @license
+ * Copyright 2024 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {AutocompleteCache} from './autocomplete-cache';
+import {assert} from '@open-wc/testing';
+
+suite('AutocompleteCache', () => {
+ let cache: AutocompleteCache;
+
+ setup(() => {
+ cache = new AutocompleteCache();
+ });
+
+ const cacheSet = (draftContent: string, commentCompletion: string) => {
+ cache.set({draftContent, commentCompletion});
+ };
+
+ const assertCacheEqual = (
+ draftContent: string,
+ expectedCommentCompletion?: string
+ ) => {
+ assert.equal(
+ cache.get(draftContent)?.commentCompletion,
+ expectedCommentCompletion
+ );
+ };
+
+ test('should get and set values', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foo', 'bar');
+ });
+
+ test('should return undefined for empty content string', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('', undefined);
+ });
+
+ test('should return a value, if completion content+hint start with content', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foo', 'bar');
+ assertCacheEqual('foob', 'ar');
+ assertCacheEqual('fooba', 'r');
+ assertCacheEqual('foobar', undefined);
+ });
+
+ test('should not return a value, if content is shorter than completion content', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('f', undefined);
+ assertCacheEqual('fo', undefined);
+ });
+
+ test('should not get values that are not set', () => {
+ assertCacheEqual('foo', undefined);
+ });
+
+ test('should not return an empty completion, if content equals completion content+hint', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foobar', undefined);
+ });
+
+ test('skips over the first entry, but returns the second entry', () => {
+ cacheSet('foobar', 'bang');
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foobar', 'bang');
+ });
+
+ test('replaces entries', () => {
+ cacheSet('foo', 'bar');
+ cacheSet('foo', 'baz');
+ assertCacheEqual('foo', 'baz');
+ });
+
+ test('prefers newer entries, but also returns older entries', () => {
+ cacheSet('foo', 'bar');
+ assertCacheEqual('foob', 'ar');
+ cacheSet('foob', 'arg');
+ assertCacheEqual('foob', 'arg');
+ assertCacheEqual('foo', 'bar');
+ });
+
+ test('capacity', () => {
+ cache = new AutocompleteCache(1);
+ cacheSet('foo', 'bar');
+ cacheSet('boom', 'bang');
+ assertCacheEqual('foo', undefined);
+ });
+});
diff --git a/polygerrit-ui/app/utils/deep-util.ts b/polygerrit-ui/app/utils/deep-util.ts
index eca528e..3e41e61 100644
--- a/polygerrit-ui/app/utils/deep-util.ts
+++ b/polygerrit-ui/app/utils/deep-util.ts
@@ -82,7 +82,7 @@
/**
* @param obj Object
*/
-export function deepClone(obj?: object) {
- if (!obj) return undefined;
- return JSON.parse(JSON.stringify(obj));
+export function deepClone<T>(obj: T): T {
+ if (!obj) throw new Error('undefined object for deepClone');
+ return JSON.parse(JSON.stringify(obj)) as T;
}
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 03728a0..04e8c19 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -437,13 +437,16 @@
if (!isElementTarget(rootTarget)) return false;
const tagName = rootTarget.tagName;
const type = rootTarget.getAttribute('type');
+ const editable = !!(rootTarget as HTMLElement).isContentEditable;
if (
+ editable ||
// 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' ||
+ tagName === 'GR-TEXTAREA' ||
(e.key === 'Enter' &&
(tagName === 'A' ||
tagName === 'BUTTON' ||
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index fe185be..7deae3d 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -309,6 +309,14 @@
});
});
+ test('suppress shortcut event from <div contenteditable>', async () => {
+ const el = document.createElement('div');
+ el.setAttribute('contenteditable', '');
+ await keyEventOn(el, e => {
+ assert.isTrue(shouldSuppress(e));
+ });
+ });
+
test('suppress shortcut event from <input>', async () => {
await keyEventOn(document.createElement('input'), e => {
assert.isTrue(shouldSuppress(e));
@@ -321,6 +329,12 @@
});
});
+ test('suppress shortcut event from <gr-textarea>', async () => {
+ await keyEventOn(document.createElement('gr-textarea'), e => {
+ assert.isTrue(shouldSuppress(e));
+ });
+ });
+
test('do not suppress shortcut event from checkbox <input>', async () => {
const inputEl = document.createElement('input');
inputEl.setAttribute('type', 'checkbox');
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 7a5cd45..dc7adc1 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -70,7 +70,7 @@
return patchset as PatchSetNum;
}
-export function isNumber(psn: PatchSetNum): psn is PatchSetNumber {
+export function isNumber(psn?: PatchSetNum): psn is PatchSetNumber {
return typeof psn === 'number';
}
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 81dcde1..abc5529 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -115,3 +115,42 @@
fileName: fileNameSection,
};
}
+
+/**
+ * Computes the Levenshtein edit distance between two strings.
+ */
+export function levenshteinDistance(str1: string, str2: string): number {
+ const m = str1.length;
+ const n = str2.length;
+
+ // Create a matrix to store edit distances
+ const dp: number[][] = Array.from({length: m + 1}, () =>
+ Array(n + 1).fill(0)
+ );
+
+ // Initialize first row and column with base cases
+ for (let i = 0; i <= m; i++) {
+ dp[i][0] = i;
+ }
+ for (let j = 0; j <= n; j++) {
+ dp[0][j] = j;
+ }
+
+ // Calculate edit distances for all substrings
+ for (let i = 1; i <= m; i++) {
+ for (let j = 1; j <= n; j++) {
+ if (str1[i - 1] === str2[j - 1]) {
+ dp[i][j] = dp[i - 1][j - 1];
+ } else {
+ dp[i][j] = Math.min(
+ dp[i - 1][j] + 1, // Deletion
+ dp[i][j - 1] + 1, // Insertion
+ dp[i - 1][j - 1] + 1 // Substitution
+ );
+ }
+ }
+ }
+
+ // Return the final edit distance
+ return dp[m][n];
+}
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index b2f722f..a8f10e2 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -610,18 +610,24 @@
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
-"highlight.js@^11.5.0 || ^10.4.1", highlight.js@^11.9.0:
+highlight.js@^11.9.0, "highlight.js@^11.9.0 || ^10.4.1":
version "11.9.0"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0"
integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==
-"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates":
+"highlightjs-closure-templates@https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba":
version "0.0.1"
- resolved "https://github.com/highlightjs/highlightjs-closure-templates#7922b1e68def8b10199e186bb679600de3ebb711"
+ resolved "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba"
dependencies:
- highlight.js "^11.5.0 || ^10.4.1"
+ highlight.js "^11.9.0 || ^10.4.1"
-"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text":
+"highlightjs-epp@https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9":
+ version "0.0.1"
+ resolved "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9"
+ dependencies:
+ highlight.js "^11.9.0"
+
+"highlightjs-structured-text@https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e":
version "1.4.9"
resolved "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e"
dependencies:
diff --git a/proto/BUILD b/proto/BUILD
index 7aa761d..624568b 100644
--- a/proto/BUILD
+++ b/proto/BUILD
@@ -23,3 +23,9 @@
visibility = ["//visibility:public"],
deps = [":entities_proto"],
)
+
+cc_proto_library(
+ name = "entities_cc_proto",
+ visibility = ["//visibility:public"],
+ deps = [":entities_proto"],
+)
diff --git a/proto/entities.proto b/proto/entities.proto
index be04e97..3a59f09 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -125,9 +125,10 @@
}
// Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput.
-// Next ID: 2
+// Next ID: 3
message ApplyPatchInput {
optional string patch = 1;
+ optional bool allow_conflicts = 2;
}
// Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput.
@@ -277,7 +278,7 @@
// Next ID: 2
message ObjectId {
// Hex string representation of the ID.
- optional string name = 1;
+ optional string name = 1 [default="0000000000000000000000000000000000000000"];
}
// Serialized form of a continuation token used for pagination.
@@ -289,7 +290,7 @@
// Proto representation of the User preferences classes
// Next ID: 4
message UserPreferences {
- // Next ID: 24
+ // Next ID: 26
message GeneralPreferencesInfo {
// Number of changes to show in a screen.
optional int32 changes_per_page = 1 [default = 25];
@@ -368,6 +369,8 @@
repeated string change_table = 18;
optional bool allow_browser_notifications = 19 [default = true];
+ optional bool allow_suggest_code_while_commenting = 24 [default = true];
+ optional bool allow_autocompleting_comments = 25 [default = true];
optional string diff_page_sidebar = 23 [default = "NONE"];
}
optional GeneralPreferencesInfo general_preferences_info = 1;
@@ -453,11 +456,11 @@
optional Side side = 2 [default = REVISION];
message Range {
// 1-based
- optional int32 start_line = 1;
+ optional int32 start_line = 1 [default = 1];
// 0-based
optional int32 start_char = 2;
// 1-based
- optional int32 end_line = 3;
+ optional int32 end_line = 3 [default = 1];
// 0-based
optional int32 end_char = 4;
}
@@ -466,7 +469,7 @@
// number is identical to the range's end line.
optional Range position_range = 3;
// 1-based
- optional int32 line_number = 4;
+ optional int32 line_number = 4 [default = 1];
}
// If not set, the comment is on the patchset level.
@@ -488,4 +491,4 @@
optional fixed64 written_on_millis = 11;
// Required.
optional string server_id = 12;
-}
\ 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 642ef474..432d088 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -63,6 +63,7 @@
el = text/x-common-lisp
elm = text/x-elm
ejs = application/x-ejs
+epp = application/x-epp
erb = application/x-erb
erl = text/x-erlang
es6 = text/jsx
diff --git a/tools/BUILD b/tools/BUILD
index ed55a4f..dc40427 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -4,9 +4,18 @@
"default_java_toolchain",
)
load("@rules_java//java:defs.bzl", "java_package_configuration")
+load("@rules_proto//proto:defs.bzl", "proto_lang_toolchain")
exports_files(["nongoogle.bzl"])
+proto_lang_toolchain(
+ name = "protoc_java_toolchain",
+ command_line = "--java_out=%s",
+ progress_message = "Generating Java proto_library %{label}",
+ runtime = "@protobuf-java//jar",
+ toolchain_type = "@rules_java//java/proto:toolchain_type",
+)
+
[default_java_toolchain(
name = "error_prone_warnings_toolchain_java" + VERSION,
configuration = dict(),
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index 4792de2..52fa1dd 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -14,7 +14,8 @@
# War packaging.
-load("//tools:deps.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_GSON_VERSION", "AUTO_VALUE_VERSION")
+load("//tools:deps.bzl", "AUTO_VALUE_GSON_VERSION")
+load("//tools:nongoogle.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_VERSION")
jar_filetype = [".jar"]
diff --git a/tools/bzl/plugins.bzl b/tools/bzl/plugins.bzl
index adde59e..9c67c8a 100644
--- a/tools/bzl/plugins.bzl
+++ b/tools/bzl/plugins.bzl
@@ -7,6 +7,7 @@
"hooks",
"plugin-manager",
"replication",
+ "replication:replication-api",
"reviewnotes",
"singleusergroup",
"webhooks",
diff --git a/tools/defs.bzl b/tools/defs.bzl
index 672c6f9..3ebc7dc 100644
--- a/tools/defs.bzl
+++ b/tools/defs.bzl
@@ -1,11 +1,25 @@
-load("@com_google_protobuf//:protobuf_deps.bzl", "protobuf_deps")
+"""
+Bazel definitions for tools.
+"""
+
+load("@bazel_features//:deps.bzl", "bazel_features_deps")
+load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies")
+load("@toolchains_protoc//protoc:repositories.bzl", "rules_protoc_dependencies")
+load("@toolchains_protoc//protoc:toolchain.bzl", "protoc_toolchains")
def gerrit_init():
"""
Initialize the WORKSPACE for gerrit targets
"""
- protobuf_deps()
+ rules_protoc_dependencies()
- native.register_toolchains("//tools:error_prone_warnings_toolchain_java17_definition")
+ rules_proto_dependencies()
- native.register_toolchains("//tools:error_prone_warnings_toolchain_java21_definition")
+ bazel_features_deps()
+
+ protoc_toolchains(
+ name = "toolchains_protoc_hub",
+ version = "v25.3",
+ )
+
+ native.register_toolchains("//tools:all")
diff --git a/tools/deps.bzl b/tools/deps.bzl
index d056483..bc37010 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -11,9 +11,6 @@
MAIL_VERS = "1.6.0"
MIME4J_VERS = "0.8.1"
OW2_VERS = "9.2"
-AUTO_COMMON_VERSION = "1.2.1"
-AUTO_FACTORY_VERSION = "1.0.1"
-AUTO_VALUE_VERSION = "1.10.4"
AUTO_VALUE_GSON_VERSION = "1.3.1"
PROLOG_VERS = "1.4.4"
PROLOG_REPO = GERRIT
@@ -90,12 +87,6 @@
)
maven_jar(
- name = "gson",
- artifact = "com.google.code.gson:gson:2.9.0",
- sha1 = "8a1167e089096758b49f9b34066ef98b2f4b37aa",
- )
-
- maven_jar(
name = "caffeine",
artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS,
sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842",
@@ -283,36 +274,6 @@
)
maven_jar(
- name = "auto-common",
- artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
- sha1 = "f6da26895f759010f5f170c8044e84c1b17ef83e",
- )
-
- maven_jar(
- name = "auto-factory",
- artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
- sha1 = "f81ece06b6525085da217cd900116f44caafe877",
- )
-
- maven_jar(
- name = "auto-service-annotations",
- artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
- sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
- )
-
- maven_jar(
- name = "auto-value",
- artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
- sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
- )
-
- maven_jar(
- name = "auto-value-annotations",
- artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
- sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
- )
-
- maven_jar(
name = "auto-value-gson-runtime",
artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index c1d8095..2c70939 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -36,7 +36,7 @@
name = "main_classpath_collect",
testonly = True,
deps = LIBS + PGMLIBS + DEPS + TEST_DEPS + TEST_DEPS_GENERATED +
- ["//plugins/%s:%s__plugin" % (n, n) for n in CORE_PLUGINS + CUSTOM_PLUGINS] +
+ ["//plugins/%s__plugin" % (n if ":" in n else "%s:%s" % (n, n)) for n in CORE_PLUGINS + CUSTOM_PLUGINS] +
["//plugins/%s:%s__plugin_test_deps" % (n, n) for n in CUSTOM_PLUGINS_TEST_DEPS],
)
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index a1f383c..603e6c8 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.10.1-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Acceptance Test Framework</name>
<description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index edc39c7..241d59b 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.10.1-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Extension API</name>
<description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index d1a098e..00132bd 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.10.1-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Plugin API</name>
<description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index 8b46ccd..5a21ab7 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.10.1-SNAPSHOT</version>
+ <version>3.11.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Gerrit Code Review - WAR</name>
<description>Gerrit WAR</description>
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index ac3f668..d9b90d8 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -7,6 +7,12 @@
load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe")
load("//tools/bzl:maven_jar.bzl", "maven_jar")
+AUTO_COMMON_VERSION = "1.2.2"
+
+AUTO_FACTORY_VERSION = "1.0.1"
+
+AUTO_VALUE_VERSION = "1.10.4"
+
GUAVA_VERSION = "33.0.0-jre"
GUAVA_BIN_SHA1 = "161ba27964a62f241533807a46b8711b13c1d94b"
@@ -18,27 +24,43 @@
def archive_dependencies():
return [
{
- "name": "com_google_protobuf",
- "sha256": "9bd87b8280ef720d3240514f884e56a712f2218f0d693b48050c836028940a42",
- "strip_prefix": "protobuf-25.1",
- "urls": [
- "https://github.com/protocolbuffers/protobuf/archive/v25.1.tar.gz",
- ],
- },
- {
"name": "platforms",
"urls": [
- "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
- "https://github.com/bazelbuild/platforms/releases/download/0.0.7/platforms-0.0.7.tar.gz",
+ "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz",
+ "https://github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz",
],
- "sha256": "3a561c99e7bdbe9173aa653fd579fe849f1d8d67395780ab4770b1f381431d51",
+ "sha256": "218efe8ee736d26a3572663b374a253c012b716d8af0c07e842e82f238a0a7ee",
+ },
+ {
+ "name": "bazel_features",
+ "strip_prefix": "bazel_features-1.11.0",
+ "urls": [
+ "https://github.com/bazel-contrib/bazel_features/releases/download/v1.11.0/bazel_features-v1.11.0.tar.gz",
+ ],
+ "sha256": "2cd9e57d4c38675d321731d65c15258f3a66438ad531ae09cb8bb14217dc8572",
},
{
"name": "rules_java",
"urls": [
- "https://github.com/bazelbuild/rules_java/releases/download/7.3.1/rules_java-7.3.1.tar.gz",
+ "https://github.com/bazelbuild/rules_java/releases/download/7.6.1/rules_java-7.6.1.tar.gz",
],
- "sha256": "4018e97c93f97680f1650ffd2a7530245b864ac543fd24fae8c02ba447cb2864",
+ "sha256": "f8ae9ed3887df02f40de9f4f7ac3873e6dd7a471f9cddf63952538b94b59aeb3",
+ },
+ {
+ "name": "rules_proto",
+ "strip_prefix": "rules_proto-6.0.0",
+ "urls": [
+ "https://github.com/bazelbuild/rules_proto/releases/download/6.0.0/rules_proto-6.0.0.tar.gz",
+ ],
+ "sha256": "303e86e722a520f6f326a50b41cfc16b98fe6d1955ce46642a5b7a67c11c0f5d",
+ },
+ {
+ "name": "toolchains_protoc",
+ "strip_prefix": "toolchains_protoc-0.3.0",
+ "urls": [
+ "https://github.com/aspect-build/toolchains_protoc/releases/download/v0.3.0/toolchains_protoc-v0.3.0.tar.gz",
+ ],
+ "sha256": "117af61ee2f1b9b014dcac7c9146f374875551abb8a30e51d1b3c5946d25b142",
},
{
"name": "ubuntu2204_jdk17",
@@ -115,18 +137,18 @@
sha1 = "cb2f351bf4463751201f43bb99865235d5ba07ca",
)
- SSHD_VERS = "2.12.0"
+ SSHD_VERS = "2.13.1"
maven_jar(
name = "sshd-osgi",
artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS,
- sha1 = "32b8de1cbb722ba75bdf9898e0c41d42af00ce57",
+ sha1 = "50958cc44076749e790d7332021cff546707624c",
)
maven_jar(
name = "sshd-sftp",
artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS,
- sha1 = "0f96f00a07b186ea62838a6a4122e8f4cad44df6",
+ sha1 = "e1b6da4ef604718e32cad59ef32618610da7a170",
)
maven_jar(
@@ -144,7 +166,7 @@
maven_jar(
name = "sshd-mina",
artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS,
- sha1 = "8b202f7d4c0d7b714fd0c93a1352af52aa031149",
+ sha1 = "ff4a9fac41a111d806f6a058d23278b0819da7ce",
)
maven_jar(
@@ -182,6 +204,36 @@
# no concern about version skew.
maven_jar(
+ name = "auto-common",
+ artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION,
+ sha1 = "9d38f10e22411681cf1d1ee3727e002af19f2c9e",
+ )
+
+ maven_jar(
+ name = "auto-factory",
+ artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION,
+ sha1 = "f81ece06b6525085da217cd900116f44caafe877",
+ )
+
+ maven_jar(
+ name = "auto-service-annotations",
+ artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION,
+ sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432",
+ )
+
+ maven_jar(
+ name = "auto-value",
+ artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION,
+ sha1 = "90f9629eaa123f88551cc26a64bc386967ee24cc",
+ )
+
+ maven_jar(
+ name = "auto-value-annotations",
+ artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION,
+ sha1 = "9679de8286eb0a151db6538ba297a8951c4a1224",
+ )
+
+ maven_jar(
name = "error-prone-annotations",
artifact = "com.google.errorprone:error_prone_annotations:2.22.0",
sha1 = "bfb9e4281a4cea34f0ec85b3acd47621cfab35b4",
@@ -252,6 +304,18 @@
sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e",
)
+ maven_jar(
+ name = "gson",
+ artifact = "com.google.code.gson:gson:2.10.1",
+ sha1 = "b3add478d4382b78ea20b1671390a858002feb6c",
+ )
+
+ maven_jar(
+ name = "protobuf-java",
+ artifact = "com.google.protobuf:protobuf-java:3.25.3",
+ sha1 = "d3200261955f3298e0d85c9892201e70492ce8eb",
+ )
+
# Test-only dependencies below.
maven_jar(
name = "cglib-3_2",
diff --git a/tools/rules_nodejs-5.8.4-node_versions.bzl.patch b/tools/rules_nodejs-5.8.4-node_versions.bzl.patch
deleted file mode 100644
index 7df62d6..0000000
--- a/tools/rules_nodejs-5.8.4-node_versions.bzl.patch
+++ /dev/null
@@ -1,17 +0,0 @@
-diff --git a/nodejs/private/node_versions.bzl b/nodejs/private/node_versions.bzl
-index bbb45b26..8758b3cc 100644
---- a/nodejs/private/node_versions.bzl
-+++ b/nodejs/private/node_versions.bzl
-@@ -2311,4 +2311,12 @@ NODE_VERSIONS = {
- "18.17.0-linux_s390x": ("node-v18.17.0-linux-s390x.tar.xz", "node-v18.17.0-linux-s390x", "876ca54c246d24e346d0c740fbb72c9fb7353369127f20492bc923ee6d0121db"),
- "18.17.0-linux_amd64": ("node-v18.17.0-linux-x64.tar.xz", "node-v18.17.0-linux-x64", "f36facda28c4d5ce76b3a1b4344e688d29d9254943a47f2f1909b1a10acb1959"),
- "18.17.0-windows_amd64": ("node-v18.17.0-win-x64.zip", "node-v18.17.0-win-x64", "06e30b4e70b18d794651ef132c39080e5eaaa1187f938721d57edae2824f4e96"),
-+ # 20.9.0
-+ "20.9.0-darwin_arm64": ("node-v20.9.0-darwin-arm64.tar.gz", "node-v20.9.0-darwin-arm64", "31d2d46ae8d8a3982f54e2ff1e60c2e4a8e80bf78a3e8b46dcaac95ac5d7ce6a"),
-+ "20.9.0-darwin_amd64": ("node-v20.9.0-darwin-x64.tar.gz", "node-v20.9.0-darwin-x64", "fc5b73f2a78c17bbe926cdb1447d652f9f094c79582f1be6471b4b38a2e1ccc8"),
-+ "20.9.0-linux_arm64": ("node-v20.9.0-linux-arm64.tar.xz", "node-v20.9.0-linux-arm64", "ced3ecece4b7c3a664bca3d9e34a0e3b9a31078525283a6fdb7ea2de8ca5683b"),
-+ "20.9.0-linux_ppc64le": ("node-v20.9.0-linux-ppc64le.tar.xz", "node-v20.9.0-linux-ppc64le", "3c6cea5d614cfbb95d92de43fbc2f8ecd66e431502fe5efc4f3c02637897bd45"),
-+ "20.9.0-linux_s390x": ("node-v20.9.0-linux-s390x.tar.xz", "node-v20.9.0-linux-s390x", "af1f4e63756ff685d452166c4d5ba93a308e816ee7c46015b5e086163d9f011b"),
-+ "20.9.0-linux_amd64": ("node-v20.9.0-linux-x64.tar.xz", "node-v20.9.0-linux-x64", "9033989810bf86220ae46b1381bdcdc6c83a0294869ba2ad39e1061f1e69217a"),
-+ "20.9.0-windows_amd64": ("node-v20.9.0-win-x64.zip", "node-v20.9.0-win-x64", "70d87dad2378c63216ff83d5a754c61d2886fc39d32ce0d2ea6de763a22d3780"),
- }
diff --git a/tools/run_gjf.sh b/tools/run_gjf.sh
new file mode 100755
index 0000000..c6eea0f
--- /dev/null
+++ b/tools/run_gjf.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+#
+# Copyright (C) 2024 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -eu
+
+GJF_VERSION=$(grep -o "^VERSION=.*$" tools/setup_gjf.sh | grep -o "[0-9][0-9]*\.[0-9][0-9]*")
+GJF=$(find 'tools/format' -regex '.*/google-java-format-[0-9][0-9]*\.[0-9][0-9]*')
+if [ ! -f "$GJF" ]; then
+ ./setup_gjf.sh
+ GJF=$(find 'tools/format' -regex '.*/google-java-format-[0-9][0-9]*\.[0-9][0-9]*')
+fi
+echo 'Running google-java-format check...'
+git show --diff-filter=AM --name-only --pretty="" HEAD | grep java$ | xargs $GJF -r
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh
index 8e2b57b..303aa0c 100755
--- a/tools/setup_gjf.sh
+++ b/tools/setup_gjf.sh
@@ -20,20 +20,13 @@
VERSION=${1:-1.7}
case "$VERSION" in
-1.3)
- SHA1="a73cfe6f9af01bd6ff150c0b50c9d620400f784c"
- ;;
-1.5)
- SHA1="b1f79e4d39a3c501f07c0ce7e8b03ac6964ed1f1"
- ;;
-1.6)
- SHA1="02b3e84e52d2473e2c4868189709905a51647d03"
- ;;
1.7)
SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702"
+ TAG_PREFIX="google-java-format-"
;;
1.22.0)
SHA1="693d8fd04656886a2287cfe1d7a118c4697c3a57"
+ TAG_PREFIX="v"
;;
*)
echo "unknown google-java-format version: $VERSION"
@@ -51,7 +44,7 @@
mkdir -p "$dir"
name="google-java-format-$VERSION-all-deps.jar"
-url="https://github.com/google/google-java-format/releases/download/v$VERSION/$name"
+url="https://github.com/google/google-java-format/releases/download/$TAG_PREFIX$VERSION/$name"
"$root/tools/download_file.py" -o "$dir/$name" -u "$url" -v "$SHA1"
launcher="$dir/google-java-format-$VERSION"
diff --git a/version.bzl b/version.bzl
index 0c63937..181159e 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.10.1-SNAPSHOT"
+GERRIT_VERSION = "3.11.0-SNAPSHOT"