Merge branch 'stable-3.11' * stable-3.11: Point to correct JGit API docs version Audit: fetch current user and sessionId at the beginning of the REST API Release-Notes: skip Change-Id: I6d70a0f3bb837330b944dd6143ecbed0e1041ff9
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt index 52b2b18..5b78353 100644 --- a/Documentation/access-control.txt +++ b/Documentation/access-control.txt
@@ -428,6 +428,10 @@ Further documentation on how to push can be found on the link:user-upload.html#push_create[Upload changes] page. +**NOTE**: All change related permissions should normally set using +"refs/heads/<ref>" branch and not using "refs/for/<ref>". Unless specifically +stated otherwise. + [[access_categories]] == Access Categories @@ -1566,11 +1570,6 @@ using the link:rest-api-projects.html#check-access[check.access] endpoint. -In addition, when a request fails due to permission errors and the caller has -this capability, ACL info is returned that contains information about the -permissions rules that have been checked. This allows the user to understand -which permissions rule caused request to be rejected. - [[capability_viewAllAccounts]] === View All Accounts
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt index 2ffc7bc..28aba16 100644 --- a/Documentation/config-gerrit.txt +++ b/Documentation/config-gerrit.txt
@@ -104,6 +104,19 @@ [[accounts]] === Section accounts +[[accounts.caseInsensitiveLocalPart]]accounts.caseInsensitiveLocalPart:: ++ +When querying by email, the case sensitivity of the email local part depends on the domains +specified in the this list. ++ +Users can register emails with mixed case, and if the email’s domain matches one in the configured +list, the local part is treated as case-insensitive. +This ensures that emails with different cases, such as User@mail.com and user@mail.com, are +associated with the same user. +For domains not listed in the configuration, email matching remains case-sensitive. ++ +Default is unset. + [[accounts.visibility]]accounts.visibility:: + Controls visibility of other users' dashboard pages and @@ -2205,6 +2218,23 @@ + Default is `true`. +[[core.useGitattributesForMerge]]core.useGitattributesForMerge:: ++ +Use JGit's support for reading gitattributes files to control behavior during +link:https://git-scm.com/docs/gitattributes.html#_performing_a_three_way_merge[ +three-way content merges,role=external,window=_blank]. This only affects +projects that allow content merges. ++ +Enabling this support does add overhead to all content merge operations since +the presence and content of in-tree `.gitattributes` files is always considered +(even when the trees have no `.gitattributes` files or those files have no +merge driver configuration). See the +link:https://git-scm.com/docs/gitattributes.html[gitattributes man page] for +more information on configuration and behavior. Also note that JGit only +implements a subset of the documented configuration. ++ +Default is `false`. + [[core.repositoryCacheCleanupDelay]]core.repositoryCacheCleanupDelay:: + Delay between each periodic cleanup of expired repositories. @@ -2600,8 +2630,10 @@ [[gerrit.listProjectsFromIndex]]gerrit.listProjectsFromIndex:: + -Enable rendering of project list from the secondary index instead -of purely relying on the in-memory cache. +Enable rendering of project list from the secondary index when the project +filter is empty, instead of purely relying on the in-memory cache. +When listing the projects with a filter, the list is always rendered +from the project in-memory cache. + By default `false`. + @@ -2615,7 +2647,7 @@ link:access-control.html#capability_queryLimit[queryLimit] which is defaulted to 500 entries. -[[gerrit.projectStatePredicateEnabled]] +[[gerrit.projectStatePredicateEnabled]]gerrit.projectStatePredicateEnabled:: + Indicates whether the link:rest-api-projects.html[/projects/] REST API endpoint supports filtering projects by state. The value is exposed in @@ -2946,6 +2978,15 @@ + Setting it to `true` may lead to some unexpected results in audit log and must be set carefully. +[[groups.enableDeleteGroup]]groups.enableDeleteGroup:: ++ +Controls whether to activate the delete groups functionality. +If `true`, then users with permission can delete groups. +This setting provides administrators the ability to activate or to deactivate delete groups functionality. ++ +By default, `false`. ++ + [[groups.includeExternalUsersInRegisteredUsersGroup]]groups.includeExternalUsersInRegisteredUsersGroup:: + Controls whether external users (these are users we have sufficient @@ -3697,6 +3738,36 @@ + Defaults to `false`. +[[label]] +=== Section label + +[[label.name.labelCopyEnforcement]]label.<name>.labelCopyEnforcement:: ++ +The votes that satisfy this condition are copied regardless of label's +link:config-labels.html#label_copyCondition[copyCondition]. ++ +Uses the same syntax as label's +link:config-labels.html#label_copyCondition[copyCondition] ++ +The final copyCondition is equivalent to +---- +(<labelCondition> AND NOT <copyRestriction>) OR <copyEnforcement> +---- + +[[label.name.labelCopyRestriction]]label.<name>.labelCopyRestriction:: ++ +The votes that satisfy this condition are not copied regardless of label's +link:config-labels.html#label_copyCondition[copyCondition] unless they also +satisfy "change.codeReviewLabelCopyEnforcement". ++ +Uses the same syntax as label's +link:config-labels.html#label_copyCondition[copyCondition] ++ +The final copyCondition is equivalent to +---- +(<labelCondition> AND NOT <copyRestriction>) OR <copyEnforcement> +---- + [[scheduledIndexer]] === Section scheduledIndexer @@ -5745,9 +5816,8 @@ [[sshd.waitTimeout]]sshd.waitTimeout:: + -Time in seconds after which the server automatically terminates -connections waiting for a server operation to complete, like for instance -cloning a very large repo with lots of refs. +Maximum time the server will wait for available space in +the output stream's buffer when writing data. Values should use common unit suffixes to express their setting: + * s, sec, second, seconds
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt index b7493a3..ca80db92 100644 --- a/Documentation/config-labels.txt +++ b/Documentation/config-labels.txt
@@ -438,9 +438,9 @@ `changekind:REWORK` is equivalent to setting `is:ANY`. [[is_magic]] -==== is:{MIN,MAX,ANY} +==== is:{MIN,MAX,POSITIVE,NEGATIVE,ANY} -Matches approvals that have a minimal, maximal or any score: +Matches approvals that have a minimal, maximal, positive, negative or any score: * [[is_min]]`MIN`: + @@ -452,6 +452,14 @@ Matches approvals that a maximal score, i.e. the highest possible (positive) value for this label. +* [[is_positive]]`POSITIVE`: ++ +Matches approvals that have score larger than 0. + +* [[is_negative]]`NEGATIVE`: ++ +Matches approvals that have score smaller than 0. + * [[is_any]]`ANY`: + Matches any approval when a new patch set is uploaded. @@ -469,12 +477,20 @@ Matches votes granted by a user who is a member of link:#group-id[\{group-id\}]. +Plugins can install custom operands for "uploaderin" that are checked before +group membership is checked and have format of +"uploaderin:<operand>_<pluginName>" + [[uploaderin]] ==== uploaderin:link:#group-id[\{group-id\}] Matches all votes if the new patch set was uploaded by a member of link:#group-id[\{group-id\}]. +Plugins can install custom operands for "uploaderin" that are checked before +group membership is checked and have format of +"uploaderin:<operand>_<pluginName>" + [[has_unchanged_files]] ==== has:unchanged-files @@ -495,6 +511,13 @@ Note, "unchanged-files" is the only value that is supported for the "has" operator. +[[changeis]] +==== changeis:{Change Query is: predicate} + +Any "is:{something}" predicate that is available as part of +link:user-search.html#search-operators[Change Query] can be used in copy +condition with "changeis:" prefix. + [[group-id]] ==== Group ID
diff --git a/Documentation/cross-repository-changes.txt b/Documentation/cross-repository-changes.txt index 53fd5cd..16067d5 100644 --- a/Documentation/cross-repository-changes.txt +++ b/Documentation/cross-repository-changes.txt
@@ -32,7 +32,8 @@ * A topic is a string that can be associated with a change. * Multiple changes can use that topic to be submitted at the same time (assuming approvals, etc.). -* Submitting a change with a topic causes all of the changes in the topic *to be +* When link:config-gerrit.html#change.submitWholeTopic[config.submitWholeTopic] is enabled, + submitting a change within a topic causes all of the changes in the topic *to be submitted together* ** Topics that span only a single repository are guaranteed to be submitted together
diff --git a/Documentation/dev-ci.txt b/Documentation/dev-ci.txt new file mode 100644 index 0000000..c5a36a2 --- /dev/null +++ b/Documentation/dev-ci.txt
@@ -0,0 +1,61 @@ +:linkattrs: += Gerrit Code Review - Continuous Integration + +[[summary]] +== TL;DR + +All the Gerrit incoming changes and stable branches are built on the +link:https://gerrit-ci.gerritforge.com[Gerrit CI]. + +The link:https://gerrit.googlesource.com/gerrit-ci-scripts[gerrit-ci-scripts] +project contains all the YAML files definitions associated with the +link:https://docs.openstack.org/infra/jenkins-job-builder/attic/[Jenkins Job Builder] +definition of the continuous integration Jobs. + +Gerrit maintainers are responsible for making sure that the CI jobs are +up-to-date by triggering the +link:https://gerrit-ci.gerritforge.com/job/gerrit-ci-scripts/[Gerrit-CI scripts job] +upon new commits to the master branch of the gerrit-ci-scripts project. + +[[sign-up]] +== Signing up as maintainer on Gerrit-CI + +The link:https://gerrit-ci.gerritforge.com/job/gerrit-ci-scripts/[Gerrit-CI] +controller allows the Gerrit maintainers to sign-in using their GitHub +accounts and have their username defined in the list of Users. + +***** +NOTE: Because of recent link:https://docs.google.com/document/d/1vDjunjDrLYYpVoVON-B_c83f56Nhm-lMDMjXmYmFYk4[security issues] + found on Jenkins and future potential risks, only the Gerrit + maintainers and contributors are allowed to access the Jenkins UI and + sign-up for creating an account. +***** + +Once the sign-up phase is complete, the maintainer needs to grant +himself permissions on Jenkins by creating a change to add their names into +the Jenkins +link:https://gerrit.googlesource.com/gerrit-ci-scripts/+/refs/heads/master/jenkins-docker/server/config-external.xml#11[config.xml] +in the permissions XML Section. + +== Applying changes to Jenkins on Gerrit-CI + +The Jenkins setup link:https://gerrit-ci.gerritforge.com[Gerrit-CI] adopts +a link:https://www.ncsc.gov.uk/collection/zero-trust-architecture[Zero-Trust-Architecture] +and therefore assumes that any access could be potentially malicious. + +- To limit the impact of future attacks or zero-days vulnerabilities the controller + must not have any meaningful secret or key which could be stolen. +- It must not be possible for anyone to change anything on the Gerrit-CI + infrastructure without authenticating with their credentials. +- No credentials should be stored anywhere on the Jenkins controller. +- Everything should be coming from the link:https://gerrit.googlesource.com/gerrit-ci-scripts[gerrit-ci-scripts] project + and the infrastructure must be immutable and ephemeral. + +Gerrit maintainers can apply the latest changes on the Jenkins controller on Gerrit-CI by performing the following +actions: + +- Generate a personal API account token by authenticating to + link:https://gerrit-ci.gerritforge.com/user/lucamilanesio/configure[Gerrit CI user's settings] + and generating a new API token. +- Trigger the link:https://gerrit-ci.gerritforge.com/job/gerrit-ci-scripts/build?delay=0sec[gerrit-ci-scripts] job + entering their GitHub username and their API account token
diff --git a/Documentation/dev-community.txt b/Documentation/dev-community.txt index 07e3a11..f26ca0d 100644 --- a/Documentation/dev-community.txt +++ b/Documentation/dev-community.txt
@@ -58,6 +58,7 @@ [[maintainer]] == Maintainer +* link:dev-ci.html[Gerrit CI] * link:dev-release.html[Making a Gerrit Release] * link:dev-release-subproject.html[Making a Release of a Gerrit Subproject] * link:https://www.gerritcodereview.com/publishing.html[Publish Gerrit Homepage]
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt index 07aff36..9a97aad 100644 --- a/Documentation/dev-crafting-changes.txt +++ b/Documentation/dev-crafting-changes.txt
@@ -158,7 +158,7 @@ and contentious discussions about trivial issues like whitespace. You may download and run `google-java-format` on your own, or you may -run `./tools/setup_gjf.sh` to download a local copy and set up a +run `./tools/gjf.sh setup` to download a local copy and set up a wrapper script. If you run your own copy, please use the same version, as there may be slight differences between versions.
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt index 364dc9b..eef8b0d 100644 --- a/Documentation/dev-plugins.txt +++ b/Documentation/dev-plugins.txt
@@ -1020,7 +1020,7 @@ @Override protected void configure() { bind(ChangeHasOperandFactory.class) - .annotatedWith(Exports.named("sample") + .annotatedWith(Exports.named("sample")) .to(SampleHasOperand.class); } } @@ -1033,6 +1033,43 @@ } ---- +[[copy_condition_operands]] +== Label Copy-Condition Operands + +Plugins can define operands to extend what votes are copied as part of label's +link:config-labels.html#label_copyCondition[copyCondition]. +Plugin defines a Predicate<ApprovalContext> implementation and a factory that is +bound to the `DynamicSet` from a module's `configure()` method in the plugin. + +Following operators supported: +* "uploaderin:" and "approverin" by implementing UserInOperandFactory. + +The new operand, when used in a CopyCondition would appear as: + `:operandName_pluginName` + +A sample `UserInOperandFactory` class implementing, and registering, a +new `uploaderin:sample_pluginName` operand is shown below: + +[source, java] +---- +public class SampleUserInOperand implements UserInOperandFactory { + public static class Module extends AbstractModule { + @Override + protected void configure() { + bind(UserInOperandFactory.class) + .annotatedWith(Exports.named("sample")) + .to(SampleUserInOperand.class); + } + } + + @Override + public Predicate<ApprovalContext> create() + throws QueryParseException { + return new UserInSamplePredicate(); + } +} +---- + [[command_options]] == Command Options @@ -3330,6 +3367,24 @@ `com.google.gerrit.server.RequestListener` is an extension point that is invoked each time the server executes a request from a user. +[[validation-options-listener]] +== ValidationOptionsListener + +`com.google.gerrit.server.ValidationOptionsListener` is an extension point that +is invoked when a patch set is created. The extension point gets the validation +options that were specified by the user. For example, this extension point can +be used to log validation options for auditing purposes. + +[[commit-validation-info-listener]] +== CommitValidationInfoListener + +`com.google.gerrit.server.git.validators.ValidationOptionsListener` is an +extension point that is invoked after a commit has passed the validations that +are done by `CommitValidationListener`'s. + +If any `CommitValidationListener` rejects the commit (by throwing a +`CommitValidationException`) this extension point is not invoked. + [[custom-keyed-values]] == Custom Keyed values
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt index a510e25..903c635 100644 --- a/Documentation/metrics.txt +++ b/Documentation/metrics.txt
@@ -321,6 +321,17 @@ ** `view`: view implementation class +=== SSH + +* `ssh/success_count`: Rate of successful SSH requests +** `command_name`: + Name of the SSH command +* `ssh/error_count`: Rate of SSH error responses +** `command_name`: + Name of the SSH command +** `exception`: + Name of the exception which has caused the request to fail. + === Query * `query/query_latency`: Successful query latency, accumulated over the life
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt index 7c93cc0..25eedf2 100644 --- a/Documentation/pg-plugin-dev.txt +++ b/Documentation/pg-plugin-dev.txt
@@ -207,11 +207,6 @@ Supported events: -* `history`: Invoked when the view is changed to a new screen within - the Gerrit web application. The token after "#" is passed as the - argument to the callback function, for example "/c/42/" while - showing change 42. - * `showchange`: Invoked when a change is made visible. A link:rest-api-changes.html#change-info[ChangeInfo] and link:rest-api-changes.html#revision-info[RevisionInfo] @@ -229,19 +224,6 @@ shown, and called again when the submit is confirmed to check whether the actual submission action can proceed. -* `comment`: Invoked when a DOM element that represents a comment is - created. This DOM element is passed as argument. This DOM element - contains nested elements that Gerrit uses to format the comment. The - DOM structure may differ between comment types such as inline - comments, file-level comments and summary comments, and it may change - with new Gerrit versions. - -* `highlightjs-loaded`: Invoked when the highlight.js library has - finished loading. The global `hljs` object (also now accessible via - `window.hljs`) is passed as an argument to the callback function. - This event can be used to register a new language highlighter with - the highlight.js library before syntax highlighting begins. - [[high-level-api]] == High-level API
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt index 58f2a5c..6b4ec94 100644 --- a/Documentation/rest-api-accounts.txt +++ b/Documentation/rest-api-accounts.txt
@@ -19,6 +19,11 @@ provided by the `q` parameter. The `n` parameter can be used to limit the returned results. +[NOTE] +Searching for accounts by email address can have different case sensitivity +behavior depending on site configuration. See +link:user-search-accounts.html#email[email operator] for details. + As result a list of link:#account-info[AccountInfo] entities is returned.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt index c199d82..50055b9 100644 --- a/Documentation/rest-api-changes.txt +++ b/Documentation/rest-api-changes.txt
@@ -5039,6 +5039,9 @@ can expand the ZIP to obtain the plain text patch, avoiding the need for a base64 decoding step. This option implies `download`. +Adding query parameter `raw` (for example `/changes/.../patch?raw`) will return +the patch as a plain-text patch file. + Query parameter `download` (e.g. `/changes/.../patch?download`) will suggest the browser save the patch as `commitsha1.diff.base64`, for later processing by command line tools. @@ -7803,6 +7806,57 @@ link:#notify-info[NotifyInfo] entity. |============================= +[[conflicts-info]] +=== ConflictsInfo +The `ConflictsInfo` entity contains information about conflicts in a revision. + +[options="header",cols="1,^1,5"] +|================================== +|Field Name ||Description +|`ours` |optional| +The SHA1 of the commit that was used as "ours" for the Git merge that created the revision. + +- For merge commits that are created by the link:#create-change[Create Change] REST endpoint +"ours" is the SHA1 of the change's target branch (the branch that is specified as `branch` in the +link:#change-input[ChangeInput]). + +- For merge commits that are created by the link:#create-merge-patch-set-for-change[Create Merge +Patch Set For Change] REST endpoint "ours" is the SHA1 of the change's target branch (if in the +link:#merge-patch-set-input[MergePatchSetInput] `inherit_parent` is `false` and `base_change` is +not set), the current parent of the change (if in the +link:#merge-patch-set-input[MergePatchSetInput] `inherit_parent` is `true`) or the current +revision of the base change (if in the link:#merge-patch-set-input[MergePatchSetInput] +`inherit_parent` is `false` and `base_change` is set). + +- For the link:#cherry-pick[Cherry Pick Revision] and the +link:rest-api-projects.html#cherry-pick-commit[Cherry Pick Commit] REST endpoints "ours" is the +SHA1 of the commit onto which the revision/commit is being cherry-picked (e.g. the head of the +target branch or the revision of the base change). + +- For the link:#rebase-change[Rebase Change] and the link:#rebase-chain[Rebase Chain] REST +endpoints "ours" is the SHA1 of the patch set that is being rebased. + +Guaranteed to be set if `contains_conflicts` is `true`. If `contains_conflicts` is `false`, only +set if the revision was created by Gerrit as a result of performing a Git merge. +|`theirs` |optional| +The SHA1 of the commit that was used as "theirs" for the Git merge that created the revision. + +- For merge commits that are created by the the link:#create-change[Create Change] and the +link:#create-merge-patch-set-for-change[Create Merge Patch Set For Change] REST endpoints "theirs" +is the SHA1 of the source branch (the branch that is specified as `source` in the +link:#merge-input[MergeInput]). + +- For the link:#cherry-pick[Cherry Pick Revision] and the +link:rest-api-projects.html#cherry-pick-commit[Cherry Pick Commit] REST endpoints "theirs" is the +SHA1 of the revision/commit that is being cherry-picked. + +- For the link:#rebase-change[Rebase Change] and the link:#rebase-chain[Rebase Chain] REST endpoints +"theirs" is the SHA1 of the new base onto which the patch set is being rebased. + +Guaranteed to be set if `contains_conflicts` is `true`. If `contains_conflicts` is `false`, only +set if the revision was created by Gerrit as a result of performing a Git merge. +|`contains_conflicts` || +Whether any of the files in the revision has a conflict due to merging "ours" and "theirs". + +If "true" at least one of the files in the revision has a conflict and contains Git conflict +markers. The conflicts occurred while performing a merge between "ours" and "theirs". + +If "false", and "ours" and "theirs" are present, merging "ours" and "theirs" didn't have any +conflict. In this case the files in the revision may only contain Git conflict markers if they +were already present in "ours" or "theirs". + +If "false", and "ours" and "theirs" are not present, the revision was not created as a result of +performing a Git merge and hence doesn't contain conflicts. +|================================= + [[delete-change-message-input]] === DeleteChangeMessageInput The `DeleteChangeMessageInput` entity contains the options for deleting a change message. @@ -9025,6 +9079,22 @@ |`description` |optional| The description of this patchset, as displayed in the patchset selector menu. May be null if no description is set. +|`conflicts` |optional| +Information about conflicts in this revision as a +link:#conflicts-info[ConflictsInfo] entity. + +Only set for revisions that were created by Gerrit as a result of +performing a Git merge (merge commits that were created by the +link:#create-change[Create Change] and the +link:#create-merge-patch-set-for-change[Create Merge Patch Set For +Change] REST endpoints and revisions created by the +link:#cherry-pick[Cherry Pick Revision], +link:rest-api-projects.html#cherry-pick-commit[Cherry Pick Commit], +link:#rebase-change[Rebase Change] or link:#rebase-chain[Rebase Chain] +REST endpoints). + +Absence of this field, doesn't guarantee absence of conflicts. It can +also be missing for revisions with conflicts that were created before +the field was introduced or where the merge was performed locally (not +by Gerrit operation). |=========================== [[robot-comment-info]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt index 52c505a..03ccd36 100644 --- a/Documentation/rest-api-groups.txt +++ b/Documentation/rest-api-groups.txt
@@ -1479,6 +1479,47 @@ HTTP/1.1 204 No Content ---- +[[delete-group]] +=== Delete Group +-- +'DELETE /groups/link:#group-id[\{group-id\}]' +-- + +Delete group. +The group to delete must be internal group. + +This endpoint is only allowed for Gerrit's internal groups; attempting to call on a +non-internal group will return 405 Method Not Allowed. + +.Request +---- + DELETE /groups/MyProject-Committers HTTP/1.0 +---- + +.Response +---- + HTTP/1.1 204 No Content +---- + +[[delete-group]] +=== Delete Group +-- +'POST /groups/link:#group-id[\{group-id\}].delete' +-- + +Delete group. +The deleted group must be internal group. + +.Request +---- + POST /groups/MyProject-Committers.delete HTTP/1.0 +---- + +.Response +---- + HTTP/1.1 204 No Content +---- + [[ids]] == IDs
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt index 469fcdd..a618f1f 100644 --- a/Documentation/user-attention-set.txt +++ b/Documentation/user-attention-set.txt
@@ -26,7 +26,7 @@ On the plus side you can strictly ignore everyone else's changes, if you are not in the attention set. :-) -=== Rules +== Rules To help with the back and forth, Gerrit applies some basic automated rules for changing the attention set: @@ -66,7 +66,7 @@ Note that just uploading a new patchset is not a relevant event for the attention set to change. -=== Interaction +== Interaction There are three ways to interact with the attention set: The attention icon, the hovercard of owner and reviewer chips and the "Reply" dialog. @@ -95,15 +95,15 @@ image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"] -=== Bots [[bots]] +== Bots [[bots]] The attention set is meant for human reviews only. Triggering bots and reacting -to their results is a different workflow and not in scope of the attenion set. -Thus members of the "Service Users" group will never be added to the +to their results is a different workflow and not in scope of the attention set. +Thus, members of the "Service Users" group will never be added to the attention set. And replies by such users will only add the change owner (and uploader) to the attention set, if it comes along with a negative vote. -=== Dashboard +== Dashboard The default *dashboard* contains a new section at the top called "Your turn". It lists all changes where the logged-in user is in the attention set. When you are @@ -121,7 +121,14 @@ Note that you can also navigate to other users' dashboards to check their "Your turn" section. -=== Emails +=== Bold Changes / Mark Reviewed + +Before the attention set feature, changes were bolded in the dashboard when +*something* happened and you could explicitly "mark a change reviewed" on the +change page. This former way of keeping track of what you should look at has +been replaced by the attention set. + +== Emails Every email begins with `Attention is currently required from: ...`, so you can identify at a glance whether you are expected to act. @@ -139,7 +146,7 @@ Gerrit-Attention: Marian Harbach <mharbach@google.com> ---- -=== Browser notifications +== Browser notifications You'll automatically get notifications when you are in the attention set. You must enable desktop notifications on your browser to see them. @@ -160,18 +167,11 @@ - Make sure browser notifications are turned on in your operating system - Your host can have browser notifications disabled for some user groups -=== Bold Changes / Mark Reviewed - -Before the attention set feature, changes were bolded in the dashboard when -*something* happened and you could explicitly "mark a change reviewed" on the -change page. This former way of keeping track of what you should look at has -been replaced by the attention set. - -=== For Gerrit Admins +== For Gerrit Admins The Attention Set has been available since the 3.3 release (late 2020). -=== Important note for all host owners, project owners, and bot owners +== Important note for all host owners, project owners, and bot owners If you are a host/project owner, please make sure all bots that run against your host/project are part of the link:access-control.html#service_users[Service Users] group. @@ -190,20 +190,13 @@ To add members or subgroups, use the Gerrit UI; BROWSE -> Groups -> search for "Service Users" -> Members. -GERRIT ------- -Part of link:index.html[Gerrit Code Review] +=== Auto re-add owner [[auto-readd-owner]] -SEARCHBOX ---------- +This job automatically re-adds the change owner to the attention-set for open non-WIP/private +changes that have been inactive for a defined time. Gerrit administrators may +link:config-gerrit.html#auto-readd[configure] this. -=== Auto readd owner [[auto-readd-owner]] - -This job automatically readds the change owner to the attention-set for open non-WIP/private -changes that have been inactive for a defined time. Gerrit administrators may configure -link:config-gerrit.html#auto-readd[this] - -Readding the owner to the attention-set of an inactive change has the advantages: +Re-adding the owner to the attention-set of an inactive change has the advantages: * It signals the change owner that the review is not progressing and that the owner may need to adjust the attention-set or indicate a need for a priority review. @@ -211,3 +204,9 @@ * It makes people set changes in WIP or private for changes that should not be actively reviewed. +GERRIT +------ +Part of link:index.html[Gerrit Code Review] + +SEARCHBOX +---------
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt index d5318c9..07185c3 100644 --- a/Documentation/user-search-accounts.txt +++ b/Documentation/user-search-accounts.txt
@@ -36,6 +36,13 @@ + Matches accounts that have the email address 'EMAIL' or an email address that starts with 'EMAIL'. ++ +If 'EMAIL' contains a domain and that domain matches one in the configured +link:config-gerrit.html#accounts.caseInsensitiveLocalPart[accounts.caseInsensitiveLocalPart] +list, the local part of the email will be treated as case-insensitive. +For example, if `example.com` is configured as case-insensitive, then `User@example.com` +and `user@example.com` will be treated as equivalent. +For domains not listed, the matching will remain case-sensitive. [[is]] [[is-active]]
diff --git a/contrib/maintenance/.flake8 b/contrib/maintenance/.flake8 new file mode 100644 index 0000000..577b437 --- /dev/null +++ b/contrib/maintenance/.flake8
@@ -0,0 +1,4 @@ +[flake8] +max-line-length = 80 +extend-select = B950 +extend-ignore = E203,E402,E501,E701
diff --git a/contrib/maintenance/Pipfile b/contrib/maintenance/Pipfile new file mode 100644 index 0000000..dd5279c --- /dev/null +++ b/contrib/maintenance/Pipfile
@@ -0,0 +1,16 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[dev-packages] +black = "*" +pytest = "*" +flake8 = "*" +flake8-bugbear = "*" + +[requires] +python_version = "3.12" + +[pipenv] +allow_prereleases = true
diff --git a/contrib/maintenance/Pipfile.lock b/contrib/maintenance/Pipfile.lock new file mode 100644 index 0000000..3982b7d --- /dev/null +++ b/contrib/maintenance/Pipfile.lock
@@ -0,0 +1,165 @@ +{ + "_meta": { + "hash": { + "sha256": "acb9add5d8f9c6fbe267f6ce332593ce25e13f68abd85d82095b17869127f80e" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.12" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": {}, + "develop": { + "attrs": { + "hashes": [ + "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", + "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2" + ], + "markers": "python_version >= '3.7'", + "version": "==24.2.0" + }, + "black": { + "hashes": [ + "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6", + "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e", + "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f", + "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018", + "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e", + "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd", + "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4", + "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed", + "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2", + "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42", + "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af", + "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb", + "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368", + "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb", + "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af", + "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed", + "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47", + "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2", + "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a", + "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c", + "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920", + "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==24.8.0" + }, + "click": { + "hashes": [ + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.7" + }, + "flake8": { + "hashes": [ + "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38", + "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==7.1.1" + }, + "flake8-bugbear": { + "hashes": [ + "sha256:25bc3867f7338ee3b3e0916bf8b8a0b743f53a9a5175782ddc4325ed4f386b89", + "sha256:9b77627eceda28c51c27af94560a72b5b2c97c016651bdce45d8f56c180d2d32" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==24.8.19" + }, + "iniconfig": { + "hashes": [ + "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", + "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "mccabe": { + "hashes": [ + "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", + "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" + ], + "markers": "python_version >= '3.6'", + "version": "==0.7.0" + }, + "mypy-extensions": { + "hashes": [ + "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", + "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.0" + }, + "packaging": { + "hashes": [ + "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", + "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124" + ], + "markers": "python_version >= '3.8'", + "version": "==24.1" + }, + "pathspec": { + "hashes": [ + "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", + "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" + ], + "markers": "python_version >= '3.8'", + "version": "==0.12.1" + }, + "platformdirs": { + "hashes": [ + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" + ], + "markers": "python_version >= '3.8'", + "version": "==4.3.6" + }, + "pluggy": { + "hashes": [ + "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", + "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669" + ], + "markers": "python_version >= '3.8'", + "version": "==1.5.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3", + "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521" + ], + "markers": "python_version >= '3.8'", + "version": "==2.12.1" + }, + "pyflakes": { + "hashes": [ + "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", + "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" + ], + "markers": "python_version >= '3.8'", + "version": "==3.2.0" + }, + "pytest": { + "hashes": [ + "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", + "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==8.3.3" + } + } +}
diff --git a/contrib/maintenance/README.md b/contrib/maintenance/README.md new file mode 100644 index 0000000..e1cd5b3 --- /dev/null +++ b/contrib/maintenance/README.md
@@ -0,0 +1,277 @@ +# Gerrit Maintenance + +This package provides a set of tools that can be used to maintain a Gerrit site. +Some tools will also work with git repositories in general. + +The following tools are available: + +- [Extended Git GarbageCollection](#extended-git-garbagecollection) + +## Dependencies + +- Python > 3.12 + +For development, some additional python libraries are required. These are managed +with pipenv. To install them, run: + +```sh +pipenv sync --dev +``` + +## Development + +### Code Style + +This package is formatted using `black`. To automatically format all python files, +run: + +```sh +pipenv run black . +``` + +`flake8` is being used to identify code style issues. To run it, use: + +```sh +pipenv run flake8 . +``` + +### Tests + +To execute tests, run: + +```sh +pipenv run pytest +``` + +## Usage + +The gerrit-maintenance CLI provides a toolbox to run scripts for performing +maintenance tasks on a Gerrit site. The CLI uses a nested command structure. The +available commands will be described in the following sections. + +To start the CLI, run: + +```sh +pipenv run python ./gerrit-maintenance.py -d $SITE -h +``` + +At this level, the path to the Gerrit site has to be provided. + +The next layer deals with the different aspects of a Gerrit site: + +### Projects + +This set of subcommands deals with maintaining the projects/repositories in +the Gerrit site. To get an overview of available commands, run: + +```sh +pipenv run python ./gerrit-maintenance.py -d $SITE projects -h +``` + +By default the selected subcommand will run on all projects in the site, but the +list can be filtered by either selecting projects specifically + +```sh +pipenv run python ./gerrit-maintenance.py \ + -d $SITE \ + projects \ + --project All-Users \ + --project All-Projects \ + $CMD +``` + +or by skipping some projects + +```sh +pipenv run python ./gerrit-maintenance.py \ + -d $SITE \ + projects \ + --skip All-Users \ + --skip All-Projects \ + $CMD +``` + +The maintenance scripts available for projects are: + +#### Git Garbage Collection + +To run Git GC as part of the gerrit-maintenance CLI, run: + +```sh +pipenv run python ./gerrit-maintenance.py \ + -d $SITE \ + projects \ + gc +``` + +You may run it as well as a [standalone git extension](#extended-git-garbagecollection). + +You can provide git configuration options to git gc using the `-c` option: + +```sh +pipenv run python ./gerrit-maintenance.py \ + -d $SITE \ + projects \ + gc \ + -c repack.writebitmaps=false +``` + +As with the standalone git extension, all arguments provided in addition to the +ones known by the CLI will be forwarded to the `git gc` command, e.g. the following +command will suppress all progress reports logged by git: + +```sh +pipenv run python ./gerrit-maintenance.py \ + -d $SITE \ + projects \ + gc \ + --quiet +``` + +The CLI also includes all extended features mentioned in [this section](#extended-features). + +## Extended Git GarbageCollection + +Git provides a GarbageCollection command (`git gc`) to clean up repositories. +Unfortunately, this command misses some cleanup steps that help improving +the performance of a repository. + +The python script provided here wraps `git gc` and adds additional options and +cleanup steps. + +### Dependencies + +Refer to [general dependencies](#dependencies) + +No non-standard libraries are being used to keep running this tool simple. + +### Installation + +Put this directory somewhere convenient and ensure that the `git-gcplus` +executable is present in the `PATH` environment variable, e.g. by symlinking it +to `/usr/local/bin`. + +### Usage + +The extended git gc can be called like any other git-command: + +```sh +git gcplus +``` + +This will run the extended gc in the current working directory (if it is a +repository). + +A specific repository can be set as usual using `-C`: + +```sh +git -C "/var/gerrit/git/All-Users.git" gcplus +``` + +The repository configuration can also be overridden as usual: + +```sh +git -c repack.writebitmaps=false gcplus +``` + +The script will further forward all [options](https://git-scm.com/docs/git-gc#_options) +provided by the `git gc` command to the included `git gc` run, e.g. the following +command will suppress all progress reports written by git: + +```sh +git gcplus --quiet +``` + +The extended git gc script also adds a few more options: + +- `--pack-all-refs` / `-r` + +### Extended features + +#### Packing all refs + +Enabled by: `--pack-all-refs` / `-r` + +Git gc by default only packs refs that are already packed. That potentially +leaves a lot of loose refs in large projects, some of which are not actively +being used anymore. + +Enabling this feature conveniently runs `git pack-refs --all`, if there are more +than 10 loose refs after the `git-gc` run. + +#### Preserving packs + +Enabled by configuring `gc.preserveoldpacks = true` + +As part of git gc packs are rewritten, which includes the change of the pack names. +If a long running request accesses a pack that is being recreated in this way +while the request is running, the request can fail, because the server tries +and fails to access the now deleted old pack. This can lead to a significant +amount of failing requests on large repositories and greatly inconvenience users. + +Jgit provides a feature to prevent the above described scenario by allowing to +preserve packs. This is done by hardlinking them before the gc and falling back +to the preserved pack in case a request fails to find a pack. Unfortunately, this +is not supported by native git. + +This extended gc script adds support for the following options added by jgit: + +- `gc.preserveoldpacks`: Whether to preserve packs before running `git gc`. +- `gc.prunepreserved`: Whether to prune preserved packs created by previous runs. + +Setting those options will prevent failures as described above, if the server uses +jgit (e.g. Gerrit), at a cost of using more storage. + +#### Lock handling + +Enabled: Always + +Git guards gc by locking a lock file "gc.pid" before starting execution. +The lock file contains the pid and hostname of the process holding the +lock. Git tries to kill the process holding that lock if the lock file +wasn't modified in the last 12 hours and was started from the same host. + +This does not work in a scenario where git gc is running in an ephemeral +environment like Kubernetes, where the host might actually always be different, +e.g. if git gc is running in a Kubernetes CronJob on a repository in a shared +filesystem. + +The extended git gc will always delete the lock, if it hasn't been modified for +at least 12 h. This matches the behavior of jgit. + +#### Deletion of empty ref directories + +Enabled: Always + +Git gc might leave empty directories after packing refs. This happens if all refs +in a namespace have been packed. This potentially leaves thousands of empty +directories, especially with Gerrit's NoteDB. This can cause significant performance +issues on slow filesystems like NFS. + +The extended gc will delete empty ref directories older than 1h. + +#### Deletion of stale incoming packs + +Enabled: Always + +If a git server crashes while still serving push requests the temporary incoming +pack file will never be cleaned up, unnecessarily cluttering the repository. + +The extended gc will consider incoming packs not modified for 1 day to be stale +and delete them. + +#### Using a marker file to enable aggressive gc + +Enabled by creating a file named `gc-aggressive` or `gc-aggressive-once` in the +repository's `.git` directory. + +In some use cases an aggressive GC should be run for a while as part of a scheduled +git gc. In that case it is not always convenient to change the calling script. + +The extended gc will check for the existence of the following files: + +- `gc-aggressive` +- `gc-aggressive-once` + +In the latter case, the file will be deleted, effectively causing an aggressive +gc just once.
diff --git a/contrib/maintenance/cli/__init__.py b/contrib/maintenance/cli/__init__.py new file mode 100644 index 0000000..e61f878 --- /dev/null +++ b/contrib/maintenance/cli/__init__.py
@@ -0,0 +1,13 @@ +# 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.
diff --git a/contrib/maintenance/cli/gc.py b/contrib/maintenance/cli/gc.py new file mode 100644 index 0000000..08456f9 --- /dev/null +++ b/contrib/maintenance/cli/gc.py
@@ -0,0 +1,57 @@ +# 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. + +import sys + +sys.path.append("..") + +from git.gc import MAX_LOOSE_REF_COUNT + + +PROG = "Execute Git Garbage Collection." +DESCRIPTION = """ +Run Git GC with additional cleanup steps on repository. + +To specify a one-time --aggressive git gc for a repository X, simply +create an empty file called 'gc-aggressive-once' in the `/path/to/X.git` +folder: + + $ cd /path/to/X.git + $ touch gc-aggressive-once + +On the next run, gc.sh will use --aggressive option for gc-ing this +repository *and* will remove this file. Next time, gc.sh again runs +normal gc for this repository. + +To specify a permanent --aggressive git gc for a repository, create +an empty file named "gc-aggressive" in the same folder: + + $ cd /path/to/X.git + $ touch gc-aggressive + +Every next git gc on this repository will use --aggressive option. +""" + + +def add_arguments(parser): + parser.add_argument( + "-r", + "--pack-all-refs", + help=( + "Whether to pack all refs, " + f"if more than {MAX_LOOSE_REF_COUNT} loose refs exist." + ), + dest="pack_refs", + action="store_true", + )
diff --git a/contrib/maintenance/gerrit-maintenance.py b/contrib/maintenance/gerrit-maintenance.py new file mode 100755 index 0000000..5e3b7fa --- /dev/null +++ b/contrib/maintenance/gerrit-maintenance.py
@@ -0,0 +1,110 @@ +#!/usr/bin/python3 + +# 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. + +import argparse +import logging +import sys + +import cli.gc + +from gerrit.site import Site +from gerrit.tasks.gc import BatchGitGarbageGollection +from git.gc import GitGarbageCollectionProvider + +logging.basicConfig( + level=logging.INFO, + stream=sys.stdout, + format="%(asctime)s [%(levelname)s] %(message)s", +) + + +def _run_projects_gc(args): + site = Site(args[0].site) + projects = ( + args[0].projects + if args[0].projects + else site.get_projects(args[0].skip_projects) + ) + BatchGitGarbageGollection( + site, + projects, + GitGarbageCollectionProvider.get(args[0].pack_refs, args[0].config), + ).run(args[1]) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-d", + "--site", + help="Path to Gerrit site", + dest="site", + action="store", + default="/var/gerrit", + ) + parser.set_defaults(func=lambda x: parser.print_usage()) + + subparsers = parser.add_subparsers() + + parser_projects = subparsers.add_parser( + "projects", + help="Tools for working with Gerrit projects.", + ) + parser_projects.add_argument( + "-p", + "--project", + help=( + "Which project to gc. Can be used multiple times. If not given, all " + "attrs=projects (except for `--skipped` ones) will be gc'ed." + ), + dest="projects", + action="append", + default=[], + ) + parser_projects.add_argument( + "-s", + "--skip", + help="Which project to skip. Can be used multiple times.", + dest="skip_projects", + action="append", + default=[], + ) + parser_projects.set_defaults(func=lambda x: parser_projects.print_usage()) + + subparsers_projects = parser_projects.add_subparsers() + parser_projects_gc = subparsers_projects.add_parser( + "gc", + prog=cli.gc.PROG, + description=cli.gc.DESCRIPTION, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + cli.gc.add_arguments(parser_projects_gc) + parser_projects_gc.add_argument( + "-c", + "--config", + help="Git config options to apply.", + dest="config", + action="append", + default=[], + ) + parser_projects_gc.set_defaults(func=_run_projects_gc) + + args = parser.parse_known_args() + args[0].func(args) + + +if __name__ == "__main__": + main()
diff --git a/contrib/maintenance/gerrit/__init__.py b/contrib/maintenance/gerrit/__init__.py new file mode 100644 index 0000000..e61f878 --- /dev/null +++ b/contrib/maintenance/gerrit/__init__.py
@@ -0,0 +1,13 @@ +# 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.
diff --git a/contrib/maintenance/gerrit/site.py b/contrib/maintenance/gerrit/site.py new file mode 100644 index 0000000..51d567f --- /dev/null +++ b/contrib/maintenance/gerrit/site.py
@@ -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. + +import sys + +sys.path.append("..") + +import os + +from git.config import GitConfigReader +from git.repo import GIT_SUFFIX + + +class Site: + def __init__(self, path): + self.path = path + self.base_path = None + + def get_etc_path(self): + return os.path.join(self.path, "etc") + + def get_base_path(self): + if not self.base_path: + with GitConfigReader( + os.path.join(self.get_etc_path(), "gerrit.config") + ) as cfg: + config_base_path = cfg.get("gerrit", None, "basePath", "git") + if os.path.isabs(config_base_path): + self.basePath = config_base_path + else: + self.basePath = os.path.join(self.path, config_base_path) + + return self.basePath + + def get_projects(self, excludes=None): + for current, dirs, _ in os.walk(self.get_base_path(), topdown=True): + if os.path.splitext(current)[1] != GIT_SUFFIX: + continue + + dirs.clear() + project = f"{current[len(self.get_base_path()) + 1:-len(GIT_SUFFIX)]}" + if excludes and project in excludes: + continue + + yield project
diff --git a/contrib/maintenance/gerrit/tasks/__init__.py b/contrib/maintenance/gerrit/tasks/__init__.py new file mode 100644 index 0000000..e61f878 --- /dev/null +++ b/contrib/maintenance/gerrit/tasks/__init__.py
@@ -0,0 +1,13 @@ +# 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.
diff --git a/contrib/maintenance/gerrit/tasks/gc.py b/contrib/maintenance/gerrit/tasks/gc.py new file mode 100644 index 0000000..164a87b --- /dev/null +++ b/contrib/maintenance/gerrit/tasks/gc.py
@@ -0,0 +1,32 @@ +# 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. + +import os.path +import sys + +sys.path.append("../..") + +from git.repo import GIT_SUFFIX + + +class BatchGitGarbageGollection: + def __init__(self, site, projects, gc_runner): + self.site = site + self.projects = projects + self.gc_runner = gc_runner + + def run(self, gc_args): + base_path = self.site.get_base_path() + for project in self.projects: + self.gc_runner.run(os.path.join(base_path, project + GIT_SUFFIX), gc_args)
diff --git a/contrib/maintenance/git-gcplus b/contrib/maintenance/git-gcplus new file mode 100755 index 0000000..d9f6fcf --- /dev/null +++ b/contrib/maintenance/git-gcplus
@@ -0,0 +1,50 @@ +#!/usr/bin/env python + +# 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. + +import argparse +import logging +import sys + +import cli.gc + +from git.gc import GitGarbageCollectionProvider + +logging.basicConfig( + level=logging.INFO, + stream=sys.stderr, + format="%(asctime)s [%(levelname)s] %(message)s", +) + + +def _run_gc(args): + GitGarbageCollectionProvider.get(args[0].pack_refs).run(args=args[1]) + + +def main(): + parser = argparse.ArgumentParser( + prog=cli.gc.PROG, + description=cli.gc.DESCRIPTION, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + cli.gc.add_arguments(parser) + + args = parser.parse_known_args() + _run_gc(args) + + +if __name__ == "__main__": + main()
diff --git a/contrib/maintenance/git/__init__.py b/contrib/maintenance/git/__init__.py new file mode 100644 index 0000000..e61f878 --- /dev/null +++ b/contrib/maintenance/git/__init__.py
@@ -0,0 +1,13 @@ +# 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.
diff --git a/contrib/maintenance/git/config.py b/contrib/maintenance/git/config.py new file mode 100644 index 0000000..cdcb2b0 --- /dev/null +++ b/contrib/maintenance/git/config.py
@@ -0,0 +1,217 @@ +# 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. + +import logging +import re + +DEFAULT_SUBSECTION = "default" +SECTION_HEADER_PATTERN = re.compile( + r"^\[(?P<section>[^\s\"]+)\s?\"?(?P<subsection>[^\s\"]+)?\"?\]$" +) +LOG = logging.getLogger(__name__) + + +class GitConfigException(Exception): + """Exception thrown when git config could not be parsed.""" + + +class GitConfigReader: + def __init__(self, config_path): + self.path = config_path + self.contents = {} + + def __enter__(self): + LOG.debug("reader") + self.file = open(self.path, "r", encoding="utf-8") + self._parse() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.file.close() + self.contents = {} + + def get(self, section, subsection, key, default=None, all=False): + if not subsection: + subsection = DEFAULT_SUBSECTION + if ( + section not in self.contents + or subsection not in self.contents[section] + or key not in self.contents[section][subsection] + ): + return default + value = self.contents[section][subsection][key] + if isinstance(value, list) and not all: + return value[-1] + return value + + def list(self): + return self.contents + + def _parse(self): + current_section = None + current_subsection = None + for line in self.file.readlines(): + LOG.debug("Read config line: %s", line) + line = line.split("#", 1)[0].strip() + if not line: + continue + LOG.debug("Not a comment: |%s|", line) + section_match = SECTION_HEADER_PATTERN.match(line) + if section_match: + LOG.debug(section_match.groupdict()) + current_section = section_match.group("section").lower() + if not current_section: + raise GitConfigException("Section has to be set with subsection.") + LOG.debug("Parsed section %s", current_section) + current_subsection = section_match.group("subsection") + if not current_subsection: + current_subsection = DEFAULT_SUBSECTION + current_subsection = current_subsection.lower() + LOG.debug("Parsed subsection %s", current_subsection) + else: + LOG.debug("Parsing key-value pair: |%s|", line) + key, value = line.split("=", 1) + key = key.strip().lower() + value = value.strip() + if value.lower() == "true": + value = True + elif value.lower() == "false": + value = False + if not current_section: + raise GitConfigException( + "All key-value pairs have to be part of a section." + ) + self._ensure_full_section(current_section, current_subsection) + if key not in self.contents[current_section][current_subsection]: + self.contents[current_section][current_subsection][key] = value + else: + if isinstance( + self.contents[current_section][current_subsection][key], list + ): + self.contents[current_section][current_subsection][key].append( + value + ) + else: + self.contents[current_section][current_subsection][key] = [ + self.contents[current_section][current_subsection][key], + value, + ] + LOG.debug("Parsed config: %s", self.contents) + + def _ensure_full_section(self, section, subsection): + if section not in self.contents: + self.contents[section] = {} + if subsection not in self.contents[section]: + self.contents[section][subsection] = {} + + +class GitConfigWriter(GitConfigReader): + def __init__(self, config_path): + super().__init__(config_path) + + def __enter__(self): + self.file = open(self.path, "r+", encoding="utf-8") + self._parse() + return self + + def set(self, section, subsection, key, value): + section, subsection, key = self._ensure_full_key_format( + section, subsection, key + ) + self._ensure_full_section(section, subsection) + self.contents[section][subsection][key] = value + + def add(self, section, subsection, key, value): + section, subsection, key = self._ensure_full_key_format( + section, subsection, key + ) + self._ensure_full_section(section, subsection) + if key not in self.contents[section][subsection]: + self.contents[section][subsection][key] = value + if isinstance(self.contents[section][subsection][key], list): + self.contents[section][subsection][key].append(value) + else: + self.contents[section][subsection][key] = [ + self.contents[section][subsection][key], + value, + ] + + def unset(self, section, subsection, key): + section, subsection, key = self._ensure_full_key_format( + section, subsection, key + ) + if section not in self.contents or subsection not in self.contents[section]: + return + self.contents[section][subsection].pop(key, None) + if not self.contents[section][subsection]: + self.contents[section].pop(subsection, None) + if not self.contents[section]: + self.contents.pop(section, None) + + def remove(self, section, subsection, key, value): + section, subsection, key = self._ensure_full_key_format( + section, subsection, key + ) + if ( + section not in self.contents + or subsection not in self.contents[section] + or key not in self.contents[section][subsection] + ): + return + try: + self.contents[section][subsection][key].remove(value) + except ValueError: + return + + def write(self): + formatted = "" + for section in self.contents: + for subsection in self.contents[section]: + if subsection == DEFAULT_SUBSECTION: + formatted += self._format_section( + section, None, self.contents[section][subsection] + ) + else: + formatted += self._format_section( + section, subsection, self.contents[section][subsection] + ) + LOG.debug("Writing config:\n %s \n\n to %s", formatted, self.file) + self.file.truncate(0) + self.file.seek(0) + self.file.write(formatted) + + def _format_section(self, section, subsection, entries): + if subsection: + formatted = f'[{section} "{subsection}"]\n' + else: + formatted = f"[{section}]\n" + + for key, value in entries.items(): + if isinstance(value, list): + for v in value: + formatted += f" {key} = {v}\n" + else: + formatted += f" {key} = {value}\n" + + return formatted + + def _ensure_full_key_format(self, section, subsection, key): + if not subsection: + subsection = DEFAULT_SUBSECTION + + section = section.lower() + subsection = subsection.lower() + key = key.lower() + + return section, subsection, key
diff --git a/contrib/maintenance/git/gc.py b/contrib/maintenance/git/gc.py new file mode 100644 index 0000000..7a4c087 --- /dev/null +++ b/contrib/maintenance/git/gc.py
@@ -0,0 +1,220 @@ +# 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. + +import abc +import logging +import os + +from datetime import datetime, timedelta +from glob import glob + +from .config import GitConfigReader +from . import repo + +LOG = logging.getLogger(__name__) + +AGGRESSIVE_FLAG = "--aggressive" +MAX_AGE_GC_LOCK = timedelta(hours=12) +MAX_AGE_EMPTY_REF_DIRS = timedelta(hours=1) +MAX_AGE_INCOMING_PACKS = timedelta(days=1) +MAX_LOOSE_REF_COUNT = 10 +PACK_PATH = "objects/pack" +PRESERVED_PACK_PATH = f"{PACK_PATH}/preserved" + + +class Util: + @staticmethod + def is_file_stale(file, max_age): + return datetime.fromtimestamp(os.stat(file).st_mtime) + max_age < datetime.now() + + +class GCStep(abc.ABC): + @abc.abstractmethod + def run(self, repo_dir): + pass + + +class GCLockHandlingInitStep(GCStep): + def run(self, repo_dir): + gc_lock_path = os.path.join(repo_dir, "gc.pid") + if os.path.exists(gc_lock_path) and Util.is_file_stale( + gc_lock_path, MAX_AGE_GC_LOCK + ): + LOG.warning( + "Pruning stale 'gc.pid' lock file older than %s min: %s", + MAX_AGE_GC_LOCK.min, + gc_lock_path, + ) + os.remove(gc_lock_path) + + +class PreservePacksInitStep(GCStep): + def run(self, repo_dir): + with GitConfigReader(os.path.join(repo_dir, "config")) as config_reader: + is_prune_preserved = config_reader.get("gc", None, "prunepreserved", False) + is_preserve_old_packs = config_reader.get( + "gc", None, "preserveoldpacks", False + ) + + if is_prune_preserved: + self._prune_preserved(repo_dir) + + if is_preserve_old_packs: + self._preserve_packs(repo_dir) + + def _prune_preserved(self, repo_dir): + full_preserved_pack_path = os.path.join(repo_dir, PRESERVED_PACK_PATH) + if os.path.exists(full_preserved_pack_path): + LOG.info("Pruning old preserved packs.") + count = 0 + for file in os.listdir(full_preserved_pack_path): + if file.endswith(".old-pack") or file.endswith(".old-idx"): + count += 1 + full_old_pack_path = os.path.join(full_preserved_pack_path, file) + LOG.debug("Deleting %s", full_old_pack_path) + os.remove(full_old_pack_path) + LOG.info("Done pruning %d old preserved packs.", count) + + def _preserve_packs(self, repo_dir): + full_pack_path = os.path.join(repo_dir, PACK_PATH) + full_preserved_pack_path = os.path.join(repo_dir, PRESERVED_PACK_PATH) + if not os.path.exists(full_preserved_pack_path): + os.makedirs(full_preserved_pack_path) + LOG.info("Preserving packs.") + count = 0 + for file in os.listdir(full_pack_path): + full_file_path = os.path.join(full_pack_path, file) + filename, ext = os.path.splitext(file) + if ( + os.path.isfile(full_file_path) + and filename.startswith("pack-") + and ext in [".pack", ".idx"] + ): + LOG.debug("Preserving pack %s", file) + os.link( + os.path.join(full_pack_path, file), + os.path.join( + full_preserved_pack_path, + self._get_preserved_packfile_name(file), + ), + ) + if ext == ".pack": + count += 1 + LOG.info("Preserved %d packs", count) + + def _get_preserved_packfile_name(self, file): + filename, ext = os.path.splitext(file) + return f"{filename}.old-{ext[1:]}" + + +DEFAULT_INIT_STEPS = [GCLockHandlingInitStep(), PreservePacksInitStep()] + + +class DeleteEmptyRefDirsCleanupStep(GCStep): + def run(self, repo_dir): + refs_path = os.path.join(repo_dir, "refs") + for dir in glob(os.path.join(refs_path, "*/*")): + if ( + os.path.isdir(dir) + and len(os.listdir(dir)) == 0 + and Util.is_file_stale(dir, MAX_AGE_EMPTY_REF_DIRS) + ): + os.removedirs(dir) + + +class DeleteStaleIncomingPacksCleanupStep(GCStep): + def run(self, repo_dir): + objects_path = os.path.join(repo_dir, "objects") + for file in glob(os.path.join(objects_path, "incoming_*.pack")): + if Util.is_file_stale(file, MAX_AGE_INCOMING_PACKS): + LOG.warning( + "Pruning stale incoming pack/index file older than %d days: %s", + MAX_AGE_INCOMING_PACKS.days, + file, + ) + os.remove(file) + + +class PackAllRefsAfterStep(GCStep): + def run(self, repo_dir): + loose_ref_count = 0 + for _, _, files in os.walk(os.path.join(repo_dir, "refs"), topdown=True): + loose_ref_count += len([file for file in files]) + if loose_ref_count > MAX_LOOSE_REF_COUNT: + repo.pack_refs(repo_dir, all=True) + LOG.info("Found %d loose refs -> pack all refs", loose_ref_count) + else: + LOG.info( + "Found less than %d refs -> skipping pack all refs" + % MAX_LOOSE_REF_COUNT + ) + + +DEFAULT_AFTER_STEPS = [ + DeleteEmptyRefDirsCleanupStep(), + DeleteStaleIncomingPacksCleanupStep(), +] + + +class GitGarbageCollectionProvider: + @staticmethod + def get(pack_refs=True, git_config=None): + init_steps = DEFAULT_INIT_STEPS.copy() + after_steps = DEFAULT_AFTER_STEPS.copy() + + if pack_refs: + after_steps.append(PackAllRefsAfterStep()) + + return GitGarbageCollection(init_steps, after_steps, git_config) + + +class GitGarbageCollection: + def __init__(self, init_steps, after_steps, git_config=None): + self.init_steps = init_steps + self.after_steps = after_steps + self.git_config = git_config + + def run(self, repo_dir=None, args=None): + LOG.info("Started gc in %s", repo_dir) + if not repo_dir: + repo_dir = repo.git_dir() + if not os.path.exists(repo_dir) or not os.path.isdir(repo_dir): + LOG.error("Failed: Directory does not exist: %s", repo_dir) + return + + for init_step in self.init_steps: + init_step.run(repo_dir) + + if self._is_aggressive(repo_dir) and AGGRESSIVE_FLAG not in args: + args.append(AGGRESSIVE_FLAG) + + try: + repo.gc(repo_dir, self.git_config, args) + except repo.GitCommandException: + LOG.error("Failed to run gc in %s", repo_dir) + + for after_step in self.after_steps: + after_step.run(repo_dir) + + LOG.info("Finished gc in %s", repo_dir) + + def _is_aggressive(self, project_dir): + if os.path.exists(os.path.join(project_dir, "gc-aggressive")): + LOG.info("Running aggressive gc in %s", project_dir) + return True + elif os.path.exists(os.path.join(project_dir, "gc-aggressive-once")): + LOG.info("Running aggressive gc once in %s", project_dir) + os.remove(os.path.join(project_dir, "gc-aggressive-once")) + return True + return False
diff --git a/contrib/maintenance/git/repo.py b/contrib/maintenance/git/repo.py new file mode 100644 index 0000000..12fb1da --- /dev/null +++ b/contrib/maintenance/git/repo.py
@@ -0,0 +1,135 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import subprocess + +LOG = logging.getLogger(__name__) + +GIT_SUFFIX = ".git" + + +class GitCommandException(Exception): + """Exception thrown by failed git commands.""" + + +def git_dir(): + try: + return ( + subprocess.run( + ["git", "rev-parse", "--git-dir"], capture_output=True, check=True + ) + .stdout.decode() + .strip() + ) + except subprocess.CalledProcessError: + raise GitCommandException("Unable to find .git directory.") + + +def commit_id(repo_dir, ref="HEAD"): + try: + cmd = ["git", "rev-parse", "--short", ref] + return ( + subprocess.run(cmd, cwd=repo_dir, capture_output=True, check=True) + .stdout.decode() + .strip() + ) + except subprocess.CalledProcessError: + raise GitCommandException("Unable to parse current commit ID.") + + +def add(repo_dir, files=None): + if not files: + files = ["."] + try: + cmd = ["git", "add"] + cmd.extend(files) + subprocess.run(cmd, cwd=repo_dir, capture_output=True, check=True) + except subprocess.CalledProcessError: + raise GitCommandException("Unable to add files to index.") + + +def commit(repo_dir, message): + try: + cmd = ["git", "commit", "-m", message] + subprocess.run(cmd, cwd=repo_dir, capture_output=True, check=True) + except subprocess.CalledProcessError: + raise GitCommandException("Unable to commit.") + + +def push(repo_dir, remote, refspec): + try: + cmd = ["git", "push", remote, refspec] + subprocess.run(cmd, cwd=repo_dir, capture_output=True, check=True) + except subprocess.CalledProcessError: + raise GitCommandException("Unable to push.") + + +def clone(url, target_dir=""): + try: + cmd = ["git", "clone", url, target_dir] + subprocess.run(cmd, capture_output=True, check=True) + if target_dir: + return target_dir + + repo_name = url.split("/")[-1] + if repo_name.endswith(GIT_SUFFIX): + repo_name = repo_name[: -len(GIT_SUFFIX)] + return repo_name + except subprocess.CalledProcessError: + raise GitCommandException(f"Unable to clone repo {url}.") + + +def init(base_dir, repo_name, bare=False): + try: + cmd = ["git", "init"] + if bare: + cmd.append("--bare") + cmd.append(os.path.join(base_dir, repo_name)) + subprocess.run(cmd, cwd=base_dir, capture_output=True, check=True) + except subprocess.CalledProcessError: + raise GitCommandException(f"Unable to initialize git repo {repo_name}.") + + +def pack_refs(repo_dir, all=False): + command = "git pack-refs" + if all: + command += " --all" + try: + subprocess.run(command, cwd=repo_dir, shell=True, check=True) + except subprocess.CalledProcessError as e: + if e.stdout: + LOG.info(e.stdout) + if e.stderr: + LOG.error(e.stderr) + raise GitCommandException(f"Failed to pack refs in {repo_dir}") + + +def gc(repo_dir, git_config=None, args=None): + cmd = "git " + if git_config: + cmd = cmd + "-c " + " -c ".join(git_config) + cmd += " gc" + if args: + cmd = cmd + " " + " ".join(args) + try: + # Git gc requires a shell to output logs, i.e. `shell` has to be `True` + subprocess.run(cmd, cwd=repo_dir, shell=True, check=True) + except subprocess.CalledProcessError as e: + if e.stdout: + LOG.info(e.stdout) + if e.stderr: + LOG.error(e.stderr) + raise GitCommandException(f"Failed to run gc in {repo_dir}")
diff --git a/contrib/maintenance/pyproject.toml b/contrib/maintenance/pyproject.toml new file mode 100644 index 0000000..9cde8b2 --- /dev/null +++ b/contrib/maintenance/pyproject.toml
@@ -0,0 +1,12 @@ +[project] +name = "gerrit-maintenance" +description = "Suite of tools to maintain a Gerrit site" + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] +log_cli = "True" +log_cli_level = "DEBUG" +pythonpath = "." +testpaths = ["tests"]
diff --git a/contrib/maintenance/tests/gerrit/test_site.py b/contrib/maintenance/tests/gerrit/test_site.py new file mode 100644 index 0000000..4381983 --- /dev/null +++ b/contrib/maintenance/tests/gerrit/test_site.py
@@ -0,0 +1,46 @@ +# 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. + +import os +import pytest + +from gerrit.site import Site +from git.repo import init, GIT_SUFFIX + +REPOSITORIES = ["All-Projects", "All-Users", "test", "nested/repo"] + + +@pytest.fixture(scope="function") +def site(tmp_path_factory): + site = tmp_path_factory.mktemp("site") + base_path = os.path.join(site, "git") + os.makedirs(base_path) + for repo in REPOSITORIES: + init(base_path, repo + GIT_SUFFIX, bare=True) + etc_path = os.path.join(site, "etc") + os.makedirs(etc_path) + with open(os.path.join(etc_path, "gerrit.config"), "w") as f: + f.write( + """ + [gerrit] + basePath = git + """ + ) + return site + + +def test_get_projects(site): + site = Site(site) + assert REPOSITORIES.sort() == list(site.get_projects()).sort() + assert "test" not in list(site.get_projects(excludes=["test"]))
diff --git a/contrib/maintenance/tests/git/conftest.py b/contrib/maintenance/tests/git/conftest.py new file mode 100644 index 0000000..474546b --- /dev/null +++ b/contrib/maintenance/tests/git/conftest.py
@@ -0,0 +1,33 @@ +# 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. + +import os.path +import pytest + +import git.repo + + +@pytest.fixture(scope="function") +def repo(tmp_path_factory): + dir = tmp_path_factory.mktemp("repos") + repo_name = "test.git" + git.repo.init(dir, repo_name, bare=True) + return os.path.join(dir, repo_name) + + +@pytest.fixture(scope="function") +def local_repo(tmp_path_factory, repo): + dir = tmp_path_factory.mktemp("local.git") + git.repo.clone(repo, dir) + return dir
diff --git a/contrib/maintenance/tests/git/test_config.py b/contrib/maintenance/tests/git/test_config.py new file mode 100644 index 0000000..f8039d6 --- /dev/null +++ b/contrib/maintenance/tests/git/test_config.py
@@ -0,0 +1,122 @@ +# 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. + +import os.path +import pytest + +from git.config import DEFAULT_SUBSECTION, GitConfigReader, GitConfigWriter + +CONFIG = """ +[section] + key = value +[section "subsection"] + other_key = test + other_key = another_value + another_key = test +[another_section] + # some comment + gerrit = awesome # of course + boolean = true + another_boolean = false +""" + +CONFIG_DICT = { + "section": { + "default": {"key": "value"}, + "subsection": {"other_key": ["test", "another_value"], "another_key": "test"}, + }, + "another_section": { + "default": {"gerrit": "awesome", "boolean": True, "another_boolean": False} + }, +} + + +@pytest.fixture(scope="function") +def repo_with_config(repo): + with open(os.path.join(repo, "config"), "w") as f: + f.write(CONFIG) + return repo + + +def test_list_config(repo_with_config): + with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + assert reader.list() == CONFIG_DICT + + +def test_get_config(repo_with_config): + with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + assert ( + reader.get("section", None, "key") + == CONFIG_DICT["section"]["default"]["key"] + ) + assert ( + reader.get("section", "subsection", "another_key") + == CONFIG_DICT["section"]["subsection"]["another_key"] + ) + assert ( + reader.get("section", "subsection", "other_key") + == CONFIG_DICT["section"]["subsection"]["other_key"][-1] + ) + assert ( + reader.get("section", "subsection", "other_key", all=True) + == CONFIG_DICT["section"]["subsection"]["other_key"] + ) + assert reader.get("another_section", "default", "boolean") + assert not reader.get("another_section", "default", "another_boolean") + + +def test_set_config(repo_with_config): + with GitConfigWriter(os.path.join(repo_with_config, "config")) as writer: + writer.set("new", None, "key", "value") + writer.set("new", "new_sub", "key", "val") + writer.write() + + with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + assert reader.get("new", None, "key") == "value" + assert reader.get("new", "new_sub", "key") == "val" + + +def test_add_to_config(repo_with_config): + with GitConfigWriter(os.path.join(repo_with_config, "config")) as writer: + writer.add("new", None, "key", "value") + writer.add("section", None, "key", "value2") + writer.add("section", "subsection", "other_key", "val") + writer.write() + + with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + assert reader.get("new", None, "key") == "value" + assert reader.get("section", None, "key") == "value2" + assert reader.get("section", "subsection", "other_key") == "val" + + +def test_unset_config(repo_with_config): + with GitConfigWriter(os.path.join(repo_with_config, "config")) as writer: + writer.unset("section", None, "key") + writer.unset("section", "subsection", "another_key") + writer.write() + + with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + config = reader.list() + assert DEFAULT_SUBSECTION not in config["section"] + assert "another_key" not in config["section"]["subsection"] + + +def test_remove_config(repo_with_config): + with GitConfigWriter(os.path.join(repo_with_config, "config")) as writer: + writer.remove("section", "subsection", "other_key", "test") + writer.write() + + with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + config = reader.list() + assert "test" not in config["section"]["subsection"]["other_key"]
diff --git a/contrib/maintenance/tests/git/test_gc.py b/contrib/maintenance/tests/git/test_gc.py new file mode 100644 index 0000000..a7d831f --- /dev/null +++ b/contrib/maintenance/tests/git/test_gc.py
@@ -0,0 +1,193 @@ +# 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. + +import os +import unittest.mock as mock + +import git.repo + +from datetime import datetime, timedelta +from pathlib import Path +from git.gc import ( + DeleteStaleIncomingPacksCleanupStep, + DeleteEmptyRefDirsCleanupStep, + GCLockHandlingInitStep, + GitGarbageCollection, + PackAllRefsAfterStep, + PreservePacksInitStep, +) +from git.config import GitConfigWriter + + +def test_GCLockHandlingInitStep(repo): + lock_file = os.path.join(repo, "gc.pid") + with open(lock_file, "w") as f: + f.write("1234") + + task = GCLockHandlingInitStep() + + task.run(repo) + assert os.path.exists(lock_file) + + _mofify_last_modified(lock_file, timedelta(hours=13)) + + task.run(repo) + assert not os.path.exists(lock_file) + + +def test_PreservePacksInitStep(repo): + task = PreservePacksInitStep() + + pack_path = os.path.join(repo, "objects", "pack") + preserved_pack_path = os.path.join(pack_path, "preserved") + + fake_pack = os.path.join(pack_path, "pack-fake.pack") + fake_preserved_pack = os.path.join(preserved_pack_path, "pack-fake.old-pack") + fake_idx = os.path.join(pack_path, "pack-fake.idx") + fake_preserved_idx = os.path.join(preserved_pack_path, "pack-fake.old-idx") + fake_rev = os.path.join(pack_path, "pack-fake.rev") + fake_preserved_rev = os.path.join(preserved_pack_path, "pack-fake.old-rev") + + Path(fake_pack).touch() + Path(fake_idx).touch() + Path(fake_rev).touch() + + with GitConfigWriter(os.path.join(repo, "config")) as writer: + writer.set("gc", None, "preserveoldpacks", False) + writer.write() + + task.run(repo) + + assert not os.path.exists(fake_preserved_pack) + assert not os.path.exists(fake_preserved_idx) + assert not os.path.exists(fake_preserved_rev) + + with GitConfigWriter(os.path.join(repo, "config")) as writer: + writer.set("gc", None, "preserveoldpacks", True) + writer.write() + + task.run(repo) + + assert os.path.exists(fake_preserved_pack) + assert os.path.exists(fake_preserved_idx) + assert not os.path.exists(fake_preserved_rev) + + with GitConfigWriter(os.path.join(repo, "config")) as writer: + writer.set("gc", None, "preserveoldpacks", False) + writer.set("gc", None, "prunepreserved", True) + writer.write() + + task.run(repo) + + assert not os.path.exists(fake_preserved_pack) + assert not os.path.exists(fake_preserved_idx) + + +def test_DeleteEmptyRefDirsCleanupStep(repo): + delete_path = os.path.join(repo, "refs", "heads", "delete") + os.makedirs(delete_path) + keep_path = os.path.join(repo, "refs", "heads", "keep") + os.makedirs(keep_path) + Path(os.path.join(keep_path, "abcd1234")).touch() + + task = DeleteEmptyRefDirsCleanupStep() + + task.run(repo) + assert os.path.exists(delete_path) + assert os.path.exists(keep_path) + + _mofify_last_modified(delete_path, timedelta(hours=2)) + task.run(repo) + assert not os.path.exists(delete_path) + + +def test_DeleteStaleIncomingPacksCleanupStep(repo): + task = DeleteStaleIncomingPacksCleanupStep() + + objects_path = os.path.join(repo, "objects") + pack_path = os.path.join(objects_path, "pack") + pack_file = os.path.join(pack_path, "pack-1234.pack") + Path(pack_file).touch() + object_shard = os.path.join(objects_path, "f8") + os.makedirs(object_shard) + object_file = os.path.join(objects_path, "f8", "abcd") + Path(object_file).touch() + incoming_pack_file = os.path.join(objects_path, "incoming_1234.pack") + Path(incoming_pack_file).touch() + + task.run(repo) + + assert os.path.exists(pack_file) + assert os.path.exists(object_file) + assert os.path.exists(incoming_pack_file) + + _mofify_last_modified(pack_file, timedelta(days=2)) + _mofify_last_modified(object_file, timedelta(days=2)) + _mofify_last_modified(incoming_pack_file, timedelta(days=2)) + + task.run(repo) + + assert os.path.exists(pack_file) + assert os.path.exists(object_file) + assert not os.path.exists(incoming_pack_file) + + +def test_PackAllRefsAfterStep(repo, local_repo): + test_file = Path(os.path.join(local_repo, "test.txt")) + test_file.touch() + git.repo.add(local_repo, [test_file]) + git.repo.commit(local_repo, "test commit") + + target_loose_ref_count = 15 + loose_ref_count = 0 + while loose_ref_count < target_loose_ref_count: + loose_ref_count += 1 + git.repo.push(local_repo, "origin", f"HEAD:refs/heads/test{loose_ref_count}") + + task = PackAllRefsAfterStep() + task.run(repo) + + assert len(os.listdir(os.path.join(repo, "refs", "heads"))) == 0 + packed_refs_file = os.path.join(repo, "packed-refs") + assert os.path.exists(packed_refs_file) + with open(packed_refs_file, "r") as f: + assert ( + len(f.readlines()) == target_loose_ref_count + 1 + ) # First line is a comment + + git.repo.push( + local_repo, "origin", f"HEAD:refs/heads/test{target_loose_ref_count + 1}" + ) + task.run(repo) + assert len(os.listdir(os.path.join(repo, "refs", "heads"))) == 1 + with open(packed_refs_file, "r") as f: + assert ( + len(f.readlines()) == target_loose_ref_count + 1 + ) # First line is a comment + + +@mock.patch("subprocess.run") +def test_gc_executed(mock_subproc_run, repo): + gc = GitGarbageCollection([], []) + gc.run(repo) + mock_subproc_run.assert_called() + assert mock_subproc_run.call_count == 1 + + +def _mofify_last_modified(file, time_delta): + file_stat = os.stat(file) + new_mod_timestamp = datetime.timestamp( + datetime.fromtimestamp(file_stat.st_mtime) - time_delta + ) + os.utime(file, (file_stat.st_atime, new_mod_timestamp))
diff --git a/contrib/maintenance/tests/git/test_repo.py b/contrib/maintenance/tests/git/test_repo.py new file mode 100644 index 0000000..cb2e8b7 --- /dev/null +++ b/contrib/maintenance/tests/git/test_repo.py
@@ -0,0 +1,61 @@ +# 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. + +import os.path +import pytest + +import git.repo + + +@pytest.fixture(scope="function") +def tmp_dir(tmp_path_factory): + return tmp_path_factory.mktemp("ltmp_dir") + + +def test_git_init_commit(tmp_dir): + repo_name = "repo" + repo_path = os.path.join(tmp_dir, repo_name) + repo_git_path = os.path.join(repo_path, ".git") + + git.repo.init(tmp_dir, repo_name) + assert os.path.exists(repo_path) + assert os.path.exists(repo_git_path) + + new_commit = _create_new_commit(repo_path) + assert new_commit + + +def test_git_clone_push(tmp_dir, repo): + repo_name = "repo" + repo_path = os.path.join(tmp_dir, repo_name) + repo_git_path = os.path.join(repo_path, ".git") + + git.repo.clone(repo, repo_path) + assert os.path.exists(repo_path) + assert os.path.exists(repo_git_path) + + new_commit = _create_new_commit(repo_path) + assert new_commit + + git.repo.push(repo_path, "origin", "HEAD:refs/heads/master") + assert git.repo.commit_id(repo, "refs/heads/master") == new_commit + + +def _create_new_commit(repo_path): + with open(os.path.join(repo_path, "test.txt"), "w") as f: + f.write("test content") + + git.repo.add(repo_path) + git.repo.commit(repo_path, "Test commit") + return git.repo.commit_id(repo_path)
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java index 4748e31..a5757ef 100644 --- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java +++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -15,6 +15,7 @@ package com.google.gerrit.acceptance; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.Project; import com.google.gerrit.index.QueryOptions; import com.google.gerrit.index.Schema; import com.google.gerrit.index.query.DataSource; @@ -72,6 +73,11 @@ } @Override + public void deleteAllForProject(Project.NameKey project) { + throw new UnsupportedOperationException("ChangeIndex is disabled"); + } + + @Override public void deleteAll() { throw new UnsupportedOperationException("ChangeIndex is disabled"); }
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java index 5a1de63..7ef8fed 100644 --- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java +++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -45,6 +45,7 @@ import com.google.gerrit.extensions.webui.ResolveConflictsWebLink; import com.google.gerrit.server.ExceptionHook; import com.google.gerrit.server.ServerStateProvider; +import com.google.gerrit.server.ValidationOptionsListener; import com.google.gerrit.server.account.AccountStateProvider; import com.google.gerrit.server.account.GroupBackend; import com.google.gerrit.server.change.FilterIncludedIn; @@ -52,10 +53,12 @@ import com.google.gerrit.server.config.ProjectConfigEntry; import com.google.gerrit.server.git.ChangeMessageModifier; import com.google.gerrit.server.git.receive.PluginPushOption; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.CommitValidationListener; import com.google.gerrit.server.git.validators.OnSubmitValidationListener; import com.google.gerrit.server.git.validators.RefOperationValidationListener; import com.google.gerrit.server.logging.PerformanceLogger; +import com.google.gerrit.server.query.approval.ApprovalQueryBuilder.UserInOperandFactory; import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeHasOperandFactory; import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeIsOperandFactory; import com.google.gerrit.server.restapi.change.OnPostReview; @@ -111,9 +114,12 @@ private final DynamicSet<ServerStateProvider> serverStateProviders; private final DynamicSet<AccountStateProvider> accountStateProviders; private final DynamicSet<AttentionSetListener> attentionSetListeners; + private final DynamicSet<ValidationOptionsListener> validationOptionsListeners; + private final DynamicSet<CommitValidationInfoListener> commitValidationInfoListeners; private final DynamicMap<ChangeHasOperandFactory> hasOperands; private final DynamicMap<ChangeIsOperandFactory> isOperands; + private final DynamicMap<UserInOperandFactory> userInOperands; private final DynamicMap<ReviewerSuggestion> reviewerSuggestions; @Inject @@ -157,9 +163,12 @@ DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners, DynamicMap<ChangeHasOperandFactory> hasOperands, DynamicMap<ChangeIsOperandFactory> isOperands, + DynamicMap<UserInOperandFactory> userInOperands, DynamicSet<ServerStateProvider> serverStateProviders, DynamicSet<AccountStateProvider> accountStateProviders, DynamicSet<AttentionSetListener> attentionSetListeners, + DynamicSet<ValidationOptionsListener> validationOptionsListeners, + DynamicSet<CommitValidationInfoListener> commitValidationInfoListeners, DynamicMap<ReviewerSuggestion> reviewerSuggestions) { this.accountIndexedListeners = accountIndexedListeners; this.changeIndexedListeners = changeIndexedListeners; @@ -200,9 +209,12 @@ this.reviewerDeletedListeners = reviewerDeletedListeners; this.hasOperands = hasOperands; this.isOperands = isOperands; + this.userInOperands = userInOperands; this.serverStateProviders = serverStateProviders; this.accountStateProviders = accountStateProviders; this.attentionSetListeners = attentionSetListeners; + this.validationOptionsListeners = validationOptionsListeners; + this.commitValidationInfoListeners = commitValidationInfoListeners; this.reviewerSuggestions = reviewerSuggestions; } @@ -280,6 +292,11 @@ } @CanIgnoreReturnValue + public Registration add(UserInOperandFactory userInOperand, String exportName) { + return add(userInOperands, userInOperand, exportName); + } + + @CanIgnoreReturnValue public Registration add(ChangeMessageModifier changeMessageModifier) { return add(changeMessageModifiers, changeMessageModifier); } @@ -396,6 +413,16 @@ } @CanIgnoreReturnValue + public Registration add(ValidationOptionsListener validationOptionsListener) { + return add(validationOptionsListeners, validationOptionsListener); + } + + @CanIgnoreReturnValue + public Registration add(CommitValidationInfoListener commitValidationInfoListener) { + return add(commitValidationInfoListeners, commitValidationInfoListener); + } + + @CanIgnoreReturnValue public Registration add(CapabilityDefinition capabilityDefinition, String exportName) { return add(capabilityDefinitions, capabilityDefinition, exportName); }
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java index 76c0f04..5f5d3b2 100644 --- a/java/com/google/gerrit/acceptance/HttpResponse.java +++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -78,9 +78,13 @@ } public String getEntityContent() throws IOException { + var buf = getRawContent(); + return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim(); + } + + public ByteBuffer getRawContent() throws IOException { requireNonNull(response, "Response is not initialized."); requireNonNull(response.getEntity(), "Response.Entity is not initialized."); - ByteBuffer buf = IO.readWholeStream(response.getEntity().getContent(), 1024); - return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()).trim(); + return IO.readWholeStream(response.getEntity().getContent(), 1024); } }
diff --git a/java/com/google/gerrit/acceptance/InProcessProtocol.java b/java/com/google/gerrit/acceptance/InProcessProtocol.java index abcc108..8907fae 100644 --- a/java/com/google/gerrit/acceptance/InProcessProtocol.java +++ b/java/com/google/gerrit/acceptance/InProcessProtocol.java
@@ -324,7 +324,7 @@ .orElseThrow( () -> new RuntimeException(String.format("project %s not found", req.project))); - AsyncReceiveCommits arc = factory.create(projectState, identifiedUser, db, null); + AsyncReceiveCommits arc = factory.create(projectState, identifiedUser, db, null, null); if (arc.canUpload() != Capable.OK) { throw new ServiceNotAuthorizedException(); }
diff --git a/java/com/google/gerrit/acceptance/AssertUtil.java b/java/com/google/gerrit/acceptance/PreferencesAssertionUtil.java similarity index 74% rename from java/com/google/gerrit/acceptance/AssertUtil.java rename to java/com/google/gerrit/acceptance/PreferencesAssertionUtil.java index f72c6d3..8893e31 100644 --- a/java/com/google/gerrit/acceptance/AssertUtil.java +++ b/java/com/google/gerrit/acceptance/PreferencesAssertionUtil.java
@@ -15,6 +15,7 @@ package com.google.gerrit.acceptance; import static com.google.common.truth.Truth.assertWithMessage; +import static com.google.gerrit.extensions.client.NullableBooleanPreferencesFieldComparator.equalBooleanPreferencesFields; import static com.google.gerrit.server.config.ConfigUtil.skipField; import java.lang.reflect.Field; @@ -22,7 +23,9 @@ import java.util.HashSet; import java.util.Set; -public class AssertUtil { +/** Utility class for preferences assertion. */ +public class PreferencesAssertionUtil { + /** Asserts preferences classes equality, ignoring the specified fields. */ public static <T> void assertPrefs(T actual, T expected, String... fieldsToExclude) throws IllegalArgumentException, IllegalAccessException { Set<String> excludedFields = new HashSet<>(Arrays.asList(fieldsToExclude)); @@ -33,12 +36,10 @@ Object actualVal = field.get(actual); Object expectedVal = field.get(expected); if (field.getType().isAssignableFrom(Boolean.class)) { - if (actualVal == null) { - actualVal = false; - } - if (expectedVal == null) { - expectedVal = false; - } + assertWithMessage("%s [actual: %s, expected: %s]", field.getName(), actualVal, expectedVal) + .that(equalBooleanPreferencesFields((Boolean) expectedVal, (Boolean) actualVal)) + .isTrue(); + continue; } assertWithMessage(field.getName()).that(actualVal).isEqualTo(expectedVal); }
diff --git a/java/com/google/gerrit/acceptance/TestExtensions.java b/java/com/google/gerrit/acceptance/TestExtensions.java new file mode 100644 index 0000000..85cd3cb --- /dev/null +++ b/java/com/google/gerrit/acceptance/TestExtensions.java
@@ -0,0 +1,96 @@ +// 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.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION; +import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.DIRECT_PUSH; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.entities.BranchNameKey; +import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.server.ValidationOptionsListener; +import com.google.gerrit.server.events.CommitReceivedEvent; +import com.google.gerrit.server.git.validators.CommitValidationException; +import com.google.gerrit.server.git.validators.CommitValidationInfo; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; +import com.google.gerrit.server.git.validators.CommitValidationListener; +import com.google.gerrit.server.git.validators.CommitValidationMessage; +import com.google.gerrit.server.update.context.RefUpdateContext; +import java.util.List; + +/** + * Class to host common test extension implementations. + * + * <p>To test the invocation of an extension point tests usually register a test implementation for + * the extension that records the parameters with which it has been called. + * + * <p>If the same extension point is triggered by different actions, these test extension + * implementations may be needed in different test classes. To avoid duplicating them in the test + * classes, they can be added to this class and then be reused from the different tests. + */ +public class TestExtensions { + public static class TestCommitValidationListener implements CommitValidationListener { + public CommitReceivedEvent receiveEvent; + + @Override + public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) + throws CommitValidationException { + this.receiveEvent = receiveEvent; + return ImmutableList.of(); + } + } + + public static class TestValidationOptionsListener implements ValidationOptionsListener { + public ImmutableListMultimap<String, String> validationOptions; + + @Override + public void onPatchSetCreation( + BranchNameKey projectAndBranch, + PatchSet.Id patchSetId, + ImmutableListMultimap<String, String> validationOptions) { + this.validationOptions = validationOptions; + } + } + + public static class TestCommitValidationInfoListener implements CommitValidationInfoListener { + public ImmutableMap<String, CommitValidationInfo> validationInfoByValidator; + public CommitReceivedEvent receiveEvent; + @Nullable public PatchSet.Id patchSetId; + public boolean hasChangeModificationRefContext; + public boolean hasDirectPushRefContext; + + @Override + public void commitValidated( + ImmutableMap<String, CommitValidationInfo> validationInfoByValidator, + CommitReceivedEvent receiveEvent, + PatchSet.Id patchSetId) { + this.validationInfoByValidator = validationInfoByValidator; + this.receiveEvent = receiveEvent; + this.patchSetId = patchSetId; + this.hasChangeModificationRefContext = RefUpdateContext.hasOpen(CHANGE_MODIFICATION); + this.hasDirectPushRefContext = RefUpdateContext.hasOpen(DIRECT_PUSH); + } + } + + /** + * Private constructor to prevent instantiation of this class. + * + * <p>This class contains only static classes and hence never needs to be instantiated. + */ + private TestExtensions() {} +}
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java index 85233f2..f7e0667 100644 --- a/java/com/google/gerrit/acceptance/TestMetricMaker.java +++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -15,8 +15,11 @@ package com.google.gerrit.acceptance; import com.google.auto.value.AutoValue; +import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.UsedAt; +import com.google.gerrit.extensions.registration.RegistrationHandle; import com.google.gerrit.metrics.Counter0; import com.google.gerrit.metrics.Counter1; import com.google.gerrit.metrics.Counter2; @@ -59,6 +62,7 @@ public class TestMetricMaker extends DisabledMetricMaker { private final ConcurrentHashMap<CounterKey, MutableLong> counts = new ConcurrentHashMap<>(); private final ConcurrentHashMap<CounterKey, MutableLong> timers = new ConcurrentHashMap<>(); + private final ConcurrentHashMap<String, Supplier<?>> callbackMetrics = new ConcurrentHashMap<>(); public long getCount(String counterName, Object... fieldValues) { return getCounterValue(CounterKey.create(counterName, fieldValues)).longValue(); @@ -68,9 +72,14 @@ return getTimerValue(CounterKey.create(timerName)).longValue(); } + public Object getCallbackMetricValue(String name) { + return callbackMetrics.get(name).get(); + } + public void reset() { counts.clear(); timers.clear(); + callbackMetrics.clear(); } private MutableLong getCounterValue(CounterKey counterKey) { @@ -149,6 +158,17 @@ }; } + @Override + @CanIgnoreReturnValue + public <V> RegistrationHandle newCallbackMetric( + String name, Class<V> valueClass, Description desc, Supplier<V> trigger) { + callbackMetrics.put(name, trigger); + return new RegistrationHandle() { + @Override + public void remove() {} + }; + } + @AutoValue abstract static class CounterKey { abstract String name();
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java index 971347c..daf7d4a 100644 --- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java +++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -152,6 +152,7 @@ inserter.setGroups(getGroups(changeCreation)); changeCreation.topic().ifPresent(t -> inserter.setTopic(t)); inserter.setApprovals(changeCreation.approvals()); + inserter.setValidationOptions(changeCreation.validationOptions()); try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) { batchUpdate.setRepository(repository, revWalk, objectInserter);
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java index eb714d45..9f51510 100644 --- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java +++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -18,6 +18,7 @@ import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.acceptance.testsuite.ThrowingFunction; @@ -55,6 +56,8 @@ public abstract ImmutableMap<String, Short> approvals(); + public abstract ImmutableListMultimap<String, String> validationOptions(); + public abstract String commitMessage(); public abstract ImmutableList<TreeModification> treeModifications(); @@ -72,7 +75,8 @@ .commitMessage("A test change") // Which value we choose here doesn't matter. All relevant code paths set the desired value. .mergeStrategy(MergeStrategy.OURS) - .approvals(ImmutableMap.of()); + .approvals(ImmutableMap.of()) + .validationOptions(ImmutableListMultimap.of()); } @AutoValue.Builder @@ -157,6 +161,10 @@ */ public abstract Builder approvals(ImmutableMap<String, Short> approvals); + /** The validation options that should be used for creating this change. */ + public abstract Builder validationOptions( + ImmutableListMultimap<String, String> validationOptions); + /** * The commit message. The message may contain a {@code Change-Id} footer but does not need to. * If the footer is absent, it will be generated.
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java index c957986..001021f 100644 --- a/java/com/google/gerrit/common/data/GlobalCapability.java +++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -59,6 +59,9 @@ /** Can create any group on the server. */ public static final String CREATE_GROUP = "createGroup"; + /** Can delete internal group on the server. */ + public static final String DELETE_GROUP = "deleteGroup"; + /** Can create any project on the server. */ public static final String CREATE_PROJECT = "createProject"; @@ -145,6 +148,7 @@ NAMES_ALL.add(BATCH_CHANGES_LIMIT); NAMES_ALL.add(CREATE_ACCOUNT); NAMES_ALL.add(CREATE_GROUP); + NAMES_ALL.add(DELETE_GROUP); NAMES_ALL.add(CREATE_PROJECT); NAMES_ALL.add(EMAIL_REVIEWERS); NAMES_ALL.add(FLUSH_CACHES);
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java index 6f71874..b3f685d 100644 --- a/java/com/google/gerrit/entities/PatchSet.java +++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -195,6 +195,8 @@ public abstract Optional<String> description(); + public abstract Builder conflicts(Optional<Conflicts> conflicts); + public abstract PatchSet build(); } @@ -268,6 +270,16 @@ */ public abstract Optional<String> description(); + /** + * Information about conflicts in this patch set. + * + * <p>Only set for patch sets that are created by Gerrit as a result of performing a Git merge. + * + * <p>If this field is not set it's unknown whether this patch set contains any file with + * conflicts. + */ + public abstract Optional<Conflicts> conflicts(); + /** Patch set number. */ public int number() { return id().get(); @@ -277,4 +289,46 @@ public String refName() { return id().toRefName(); } + + @AutoValue + @ConvertibleToProto + public abstract static class Conflicts { + /** + * The SHA1 of the commit that was used as {@code ours} for the Git merge that created the + * revision. + * + * <p>Guaranteed to be set if {@link #containsConflicts()} is {@code true}. If {@link + * #containsConflicts()} is {@code false}, only set if the revision was created by Gerrit as a + * result of performing a Git merge. + */ + public abstract Optional<ObjectId> ours(); + + /** + * The SHA1 of the commit that was used as {@code theirs} for the Git merge that created the + * revision. + * + * <p>Guaranteed to be set if {@link #containsConflicts()} is {@code true}. If {@link + * #containsConflicts()} is {@code false}, only set if the revision was created by Gerrit as a + * result of performing a Git merge. + */ + public abstract Optional<ObjectId> theirs(); + + /** + * Whether any of the files in the revision has a conflict due to merging {@link #ours} and + * {@link #theirs}. + * + * <p>If {@code true} at least one of the files in the revision has a conflict and contains Git + * conflict markers. + * + * <p>If {@code false} merging {@link #ours} and {@link #theirs} didn't have any conflict. In + * this case the files in the revision may only contain Git conflict marker if they were already + * present in {@link #ours} or {@link #theirs}. + */ + public abstract boolean containsConflicts(); + + public static Conflicts create( + Optional<ObjectId> ours, Optional<ObjectId> theirs, boolean containsConflicts) { + return new AutoValue_PatchSet_Conflicts(ours, theirs, containsConflicts); + } + } }
diff --git a/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java b/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java new file mode 100644 index 0000000..c7dd9e2 --- /dev/null +++ b/java/com/google/gerrit/entities/converter/ConflictsProtoConverter.java
@@ -0,0 +1,66 @@ +// 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.entities.converter; + +import com.google.errorprone.annotations.Immutable; +import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.proto.Entities; +import com.google.protobuf.Parser; +import java.util.Optional; +import org.eclipse.jgit.lib.ObjectId; + +@Immutable +public enum ConflictsProtoConverter + implements SafeProtoConverter<Entities.Conflicts, PatchSet.Conflicts> { + INSTANCE; + + private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter = + ObjectIdProtoConverter.INSTANCE; + + @Override + public Entities.Conflicts toProto(PatchSet.Conflicts conflicts) { + Entities.Conflicts.Builder builder = Entities.Conflicts.newBuilder(); + conflicts.ours().ifPresent(ours -> builder.setOurs(objectIdConverter.toProto(ours))); + conflicts.theirs().ifPresent(theirs -> builder.setTheirs(objectIdConverter.toProto(theirs))); + return builder.setContainsConflicts(conflicts.containsConflicts()).build(); + } + + @Override + public PatchSet.Conflicts fromProto(Entities.Conflicts proto) { + return PatchSet.Conflicts.create( + proto.hasOurs() + ? Optional.of(objectIdConverter.fromProto(proto.getOurs())) + : Optional.empty(), + proto.hasTheirs() + ? Optional.of(objectIdConverter.fromProto(proto.getTheirs())) + : Optional.empty(), + proto.hasContainsConflicts() ? proto.getContainsConflicts() : false); + } + + @Override + public Parser<Entities.Conflicts> getParser() { + return Entities.Conflicts.parser(); + } + + @Override + public Class<Entities.Conflicts> getProtoClass() { + return Entities.Conflicts.class; + } + + @Override + public Class<PatchSet.Conflicts> getEntityClass() { + return PatchSet.Conflicts.class; + } +}
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java index 22985d9..6412137 100644 --- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java +++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -21,6 +21,7 @@ import com.google.gerrit.proto.Entities; import com.google.protobuf.Parser; import java.time.Instant; +import java.util.Optional; import org.eclipse.jgit.lib.ObjectId; @Immutable @@ -29,6 +30,8 @@ private final ProtoConverter<Entities.PatchSet_Id, PatchSet.Id> patchSetIdConverter = PatchSetIdProtoConverter.INSTANCE; + private final ProtoConverter<Entities.Conflicts, PatchSet.Conflicts> conflictsConverter = + ConflictsProtoConverter.INSTANCE; private final ProtoConverter<Entities.ObjectId, ObjectId> objectIdConverter = ObjectIdProtoConverter.INSTANCE; private final ProtoConverter<Entities.Account_Id, Account.Id> accountIdConverter = @@ -50,6 +53,9 @@ } patchSet.pushCertificate().ifPresent(builder::setPushCertificate); patchSet.description().ifPresent(builder::setDescription); + patchSet + .conflicts() + .ifPresent(conflicts -> builder.setConflicts(conflictsConverter.toProto(conflicts))); return builder.build(); } @@ -69,6 +75,9 @@ if (proto.hasBranch()) { builder.branch(proto.getBranch()); } + if (proto.hasConflicts()) { + builder.conflicts(Optional.of(conflictsConverter.fromProto(proto.getConflicts()))); + } // The following fields used to theoretically be nullable in PatchSet, but in practice no // production codepath should have ever serialized an instance that was missing one of these
diff --git a/java/com/google/gerrit/extensions/api/GerritApi.java b/java/com/google/gerrit/extensions/api/GerritApi.java index eebb555..9c2f30e 100644 --- a/java/com/google/gerrit/extensions/api/GerritApi.java +++ b/java/com/google/gerrit/extensions/api/GerritApi.java
@@ -20,7 +20,6 @@ import com.google.gerrit.extensions.api.groups.Groups; import com.google.gerrit.extensions.api.plugins.Plugins; import com.google.gerrit.extensions.api.projects.Projects; -import com.google.gerrit.extensions.restapi.NotImplementedException; public interface GerritApi { Accounts accounts(); @@ -34,40 +33,4 @@ Projects projects(); Plugins plugins(); - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements GerritApi { - @Override - public Accounts accounts() { - throw new NotImplementedException(); - } - - @Override - public Changes changes() { - throw new NotImplementedException(); - } - - @Override - public Config config() { - throw new NotImplementedException(); - } - - @Override - public Groups groups() { - throw new NotImplementedException(); - } - - @Override - public Projects projects() { - throw new NotImplementedException(); - } - - @Override - public Plugins plugins() { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java index 251bb5b..a68307a 100644 --- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java +++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -28,7 +28,6 @@ import com.google.gerrit.extensions.common.GpgKeyInfo; import com.google.gerrit.extensions.common.GroupInfo; import com.google.gerrit.extensions.common.SshKeyInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.List; import java.util.Map; @@ -138,219 +137,4 @@ String setHttpPassword(String httpPassword) throws RestApiException; void delete() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements AccountApi { - @Override - public AccountInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountDetailInfo detail() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountStateInfo state() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public boolean getActive() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setActive(boolean active) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String getAvatarUrl(int size) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GeneralPreferencesInfo getPreferences() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffPreferencesInfo getDiffPreferences() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditPreferencesInfo getEditPreferences() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ProjectWatchInfo> setWatchedProjects(List<ProjectWatchInfo> in) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void starChange(String changeId) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void unstarChange(String changeId) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<GroupInfo> getGroups() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<EmailInfo> getEmails() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void addEmail(EmailInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteEmail(String email) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EmailApi createEmail(EmailInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EmailApi email(String email) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setStatus(String status) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setDisplayName(String displayName) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<SshKeyInfo> listSshKeys() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SshKeyInfo addSshKey(String key) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteSshKey(int seq) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, GpgKeyInfo> putGpgKeys(List<String> add, List<String> remove) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GpgKeyApi gpgKey(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<AgreementInfo> listAgreements() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void signAgreement(String agreementName) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void index() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<AccountExternalIdInfo> getExternalIds() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteExternalIds(List<String> externalIds) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<DeletedDraftCommentInfo> deleteDraftComments(DeleteDraftCommentsInput input) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setName(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String generateHttpPassword() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String setHttpPassword(String httpPassword) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/java/com/google/gerrit/extensions/api/accounts/Accounts.java index ad0d385..34c8eec 100644 --- a/java/com/google/gerrit/extensions/api/accounts/Accounts.java +++ b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -17,7 +17,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.client.ListAccountsOption; import com.google.gerrit.extensions.common.AccountInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Arrays; import java.util.EnumSet; @@ -218,55 +217,4 @@ return options; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Accounts { - @Override - public AccountApi id(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountApi id(int id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountApi self() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountApi create(String username) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountApi create(AccountInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SuggestAccountsRequest suggestAccounts() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SuggestAccountsRequest suggestAccounts(String query) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query(String query) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/accounts/EmailApi.java b/java/com/google/gerrit/extensions/api/accounts/EmailApi.java index da038c3..7a50ea8 100644 --- a/java/com/google/gerrit/extensions/api/accounts/EmailApi.java +++ b/java/com/google/gerrit/extensions/api/accounts/EmailApi.java
@@ -15,7 +15,6 @@ package com.google.gerrit.extensions.api.accounts; import com.google.gerrit.extensions.common.EmailInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface EmailApi { @@ -24,25 +23,4 @@ void delete() throws RestApiException; void setPreferred() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements EmailApi { - @Override - public EmailInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setPreferred() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java b/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java index 6757a05..4ecba2a 100644 --- a/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java +++ b/java/com/google/gerrit/extensions/api/accounts/GpgKeyApi.java
@@ -15,27 +15,10 @@ package com.google.gerrit.extensions.api.accounts; import com.google.gerrit.extensions.common.GpgKeyInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface GpgKeyApi { GpgKeyInfo get() throws RestApiException; void delete() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements GpgKeyApi { - @Override - public GpgKeyInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java index da9a8c7..587f2d2 100644 --- a/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java +++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetApi.java
@@ -14,22 +14,10 @@ package com.google.gerrit.extensions.api.changes; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; /** API for managing the attention set of a change. */ public interface AttentionSetApi { void remove(AttentionSetInput input) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements AttentionSetApi { - @Override - public void remove(AttentionSetInput input) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java index dec3125..5f7ed4d 100644 --- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java +++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -36,7 +36,6 @@ import com.google.gerrit.extensions.common.SubmitRequirementInput; import com.google.gerrit.extensions.common.SubmitRequirementResultInfo; import com.google.gerrit.extensions.common.SuggestedReviewerInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Arrays; @@ -599,287 +598,4 @@ return reviewerState; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements ChangeApi { - @Override - public String id() { - throw new NotImplementedException(); - } - - @Override - public ReviewerApi reviewer(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public RevisionApi revision(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void abandon(AbandonInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void restore(RestoreInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void move(MoveInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setPrivate(boolean value, @Nullable String message) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setWorkInProgress(String message) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setReadyForReview(String message) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi revert(RevertInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public RevertSubmissionInfo revertSubmission(RevertInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void rebase(RebaseInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String topic() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void topic(String topic) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public IncludedInInfo includedIn() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SuggestedReviewersRequest suggestReviewers() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ReviewerInfo> reviewers() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo get( - EnumSet<ListChangesOption> options, ImmutableListMultimap<String, String> pluginOptions) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfoDifference metaDiff( - @Nullable String oldMetaRevId, - @Nullable String newMetaRevId, - EnumSet<ListChangesOption> options, - ImmutableListMultimap<String, String> pluginOptions) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CommitMessageInfo getMessage() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setMessage(CommitMessageInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeEditApi edit() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setHashtags(HashtagsInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Set<String> getHashtags() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setCustomKeyedValues(CustomKeyedValuesInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ImmutableMap<String, String> getCustomKeyedValues() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AttentionSetApi attention(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - @Deprecated - public Map<String, List<CommentInfo>> comments() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - @Deprecated - public List<CommentInfo> commentsAsList() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CommentsRequest commentsRequest() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<CommentInfo>> drafts() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<CommentInfo> draftsAsList() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DraftsRequest draftsRequest() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo check() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo check(FixInput fix) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CheckSubmitRequirementRequest checkSubmitRequirementRequest() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void index() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ChangeInfo> submittedTogether() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmittedTogetherInfo submittedTogether(EnumSet<SubmittedTogetherOption> options) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmittedTogetherInfo submittedTogether( - EnumSet<ListChangesOption> a, EnumSet<SubmittedTogetherOption> b) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public PureRevertInfo pureRevert() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public PureRevertInfo pureRevert(String claimedOriginal) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ChangeMessageInfo> messages() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeMessageApi message(String id) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java index 6d99ded..eaf398d 100644 --- a/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java +++ b/java/com/google/gerrit/extensions/api/changes/ChangeEditApi.java
@@ -17,7 +17,6 @@ import com.google.gerrit.extensions.client.ChangeEditDetailOption; import com.google.gerrit.extensions.common.EditInfo; import com.google.gerrit.extensions.restapi.BinaryResult; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RawInput; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.EnumSet; @@ -214,91 +213,4 @@ */ void modifyIdentity(String name, String email, ChangeEditIdentityType type) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements ChangeEditApi { - @Override - public ChangeEditDetailRequest detail() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Optional<EditInfo> get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void create() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void rebase() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditInfo rebase(RebaseChangeEditInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void publish() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Optional<BinaryResult> getFile(String filePath) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void renameFile(String oldFilePath, String newFilePath) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void restoreFile(String filePath) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void modifyFile(String filePath, FileContentInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteFile(String filePath) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String getCommitMessage() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void modifyCommitMessage(String newCommitMessage) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void modifyIdentity(String name, String email, ChangeEditIdentityType type) - throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java index 66356f1..2f7541c 100644 --- a/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java +++ b/java/com/google/gerrit/extensions/api/changes/ChangeMessageApi.java
@@ -15,7 +15,6 @@ package com.google.gerrit.extensions.api.changes; import com.google.gerrit.extensions.common.ChangeMessageInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; /** Interface for change message APIs. */ @@ -30,20 +29,4 @@ * @return the change message with its message updated. */ ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements ChangeMessageApi { - @Override - public ChangeMessageInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeMessageInfo delete(DeleteChangeMessageInput input) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/Changes.java b/java/com/google/gerrit/extensions/api/changes/Changes.java index 605a92e..bd838fd 100644 --- a/java/com/google/gerrit/extensions/api/changes/Changes.java +++ b/java/com/google/gerrit/extensions/api/changes/Changes.java
@@ -21,7 +21,6 @@ import com.google.gerrit.extensions.client.ListChangesOption; import com.google.gerrit.extensions.common.ChangeInfo; import com.google.gerrit.extensions.common.ChangeInput; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Arrays; import java.util.EnumSet; @@ -201,50 +200,4 @@ return sb.toString(); } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Changes { - @Override - public ChangeApi id(int id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi id(String triplet) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi id(String project, String branch, String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi id(String project, int id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi create(ChangeInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo createAsInfo(ChangeInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query() { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query(String query) { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentApi.java b/java/com/google/gerrit/extensions/api/changes/CommentApi.java index 9b5e1da..0135f62 100644 --- a/java/com/google/gerrit/extensions/api/changes/CommentApi.java +++ b/java/com/google/gerrit/extensions/api/changes/CommentApi.java
@@ -16,7 +16,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.common.CommentInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface CommentApi { @@ -33,20 +32,4 @@ */ @CanIgnoreReturnValue CommentInfo delete(DeleteCommentInput input) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements CommentApi { - @Override - public CommentInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CommentInfo delete(DeleteCommentInput input) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/DraftApi.java b/java/com/google/gerrit/extensions/api/changes/DraftApi.java index 50816b7..0767b01 100644 --- a/java/com/google/gerrit/extensions/api/changes/DraftApi.java +++ b/java/com/google/gerrit/extensions/api/changes/DraftApi.java
@@ -16,7 +16,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.common.CommentInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface DraftApi extends CommentApi { @@ -24,20 +23,4 @@ CommentInfo update(DraftInput in) throws RestApiException; void delete() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented extends CommentApi.NotImplemented implements DraftApi { - @Override - public CommentInfo update(DraftInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileApi.java b/java/com/google/gerrit/extensions/api/changes/FileApi.java index e20ac56..b4a5ec5 100644 --- a/java/com/google/gerrit/extensions/api/changes/FileApi.java +++ b/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -18,7 +18,6 @@ import com.google.gerrit.extensions.common.BlameInfo; import com.google.gerrit.extensions.common.DiffInfo; import com.google.gerrit.extensions.restapi.BinaryResult; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.List; import java.util.OptionalInt; @@ -117,45 +116,4 @@ return forBase; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements FileApi { - @Override - public BinaryResult content() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffInfo diff() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffInfo diff(String base) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffInfo diff(int parent) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffRequest diffRequest() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setReviewed(boolean reviewed) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BlameRequest blameRequest() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java b/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java index 70e456d..485b04b 100644 --- a/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java +++ b/java/com/google/gerrit/extensions/api/changes/ReviewerApi.java
@@ -14,7 +14,6 @@ package com.google.gerrit.extensions.api.changes; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Map; @@ -29,35 +28,4 @@ void remove() throws RestApiException; void remove(DeleteReviewerInput input) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements ReviewerApi { - @Override - public Map<String, Short> votes() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteVote(String label) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteVote(DeleteVoteInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void remove() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void remove(DeleteReviewerInput input) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java index 69cf25d..a1a5444 100644 --- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java +++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -33,7 +33,6 @@ import com.google.gerrit.extensions.common.TestSubmitRuleInfo; import com.google.gerrit.extensions.common.TestSubmitRuleInput; import com.google.gerrit.extensions.restapi.BinaryResult; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.EnumSet; import java.util.List; @@ -207,246 +206,4 @@ return uninterestingParent; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements RevisionApi { - @Override - public ReviewResult review(ReviewInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo submit(SubmitInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi cherryPick(CherryPickInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo cherryPickAsInfo(CherryPickInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi rebase(RebaseInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo rebaseAsInfo(RebaseInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public boolean canRebase() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public RevisionReviewerApi reviewer(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setReviewed(String path, boolean reviewed) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Set<String> reviewed() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public MergeableInfo mergeable() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public MergeableInfo mergeableOtherBranches() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, FileInfo> files(String base) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, FileInfo> files(int parentNum) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<String> queryFiles(String query) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public FileApi file(String path) { - throw new NotImplementedException(); - } - - @Override - public CommitInfo commit(boolean addLinks) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<CommentInfo>> comments() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<CommentInfo> commentsAsList() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<CommentInfo> draftsAsList() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<CommentInfo>> portedComments() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<CommentInfo>> portedDrafts() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditInfo applyFix(String fixId) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditInfo applyFix(ApplyProvidedFixInput applyProvidedFixInput) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, DiffInfo> getFixPreview(String fixId) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, DiffInfo> getFixPreview(ApplyProvidedFixInput applyProvidedFixInput) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, List<CommentInfo>> drafts() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DraftApi createDraft(DraftInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DraftApi draft(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CommentApi comment(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public RobotCommentApi robotComment(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BinaryResult patch() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BinaryResult patch(String path) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, ActionInfo> actions() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmitType submitType() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public TestSubmitRuleInfo testSubmitRule(TestSubmitRuleInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public MergeListRequest getMergeList() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public RelatedChangesInfo related() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public RelatedChangesInfo related(EnumSet<GetRelatedOption> options) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListMultimap<String, ApprovalInfo> votes() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void description(String description) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String description() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String etag() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BinaryResult getArchive(ArchiveFormat format) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java index ec2d5d6..07eb7e0 100644 --- a/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java +++ b/java/com/google/gerrit/extensions/api/changes/RevisionReviewerApi.java
@@ -14,7 +14,6 @@ package com.google.gerrit.extensions.api.changes; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Map; @@ -24,25 +23,4 @@ void deleteVote(String label) throws RestApiException; void deleteVote(DeleteVoteInput input) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements RevisionReviewerApi { - @Override - public Map<String, Short> votes() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteVote(String label) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteVote(DeleteVoteInput input) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java b/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java index e44f21f..8ff4e95 100644 --- a/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java +++ b/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
@@ -15,20 +15,8 @@ package com.google.gerrit.extensions.api.changes; import com.google.gerrit.extensions.common.RobotCommentInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface RobotCommentApi { RobotCommentInfo get() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements RobotCommentApi { - @Override - public RobotCommentInfo get() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/config/Config.java b/java/com/google/gerrit/extensions/api/config/Config.java index 041e1dd..b649da2 100644 --- a/java/com/google/gerrit/extensions/api/config/Config.java +++ b/java/com/google/gerrit/extensions/api/config/Config.java
@@ -14,20 +14,7 @@ package com.google.gerrit.extensions.api.config; -import com.google.gerrit.extensions.restapi.NotImplementedException; - public interface Config { /** Returns an API for getting server related configurations. */ Server server(); - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Config { - @Override - public Server server() { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/config/ExperimentApi.java b/java/com/google/gerrit/extensions/api/config/ExperimentApi.java index fb30884..ad17b75 100644 --- a/java/com/google/gerrit/extensions/api/config/ExperimentApi.java +++ b/java/com/google/gerrit/extensions/api/config/ExperimentApi.java
@@ -15,16 +15,8 @@ package com.google.gerrit.extensions.api.config; import com.google.gerrit.extensions.common.ExperimentInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface ExperimentApi { ExperimentInfo get() throws RestApiException; - - class NotImplemented implements ExperimentApi { - @Override - public ExperimentInfo get() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/config/Server.java b/java/com/google/gerrit/extensions/api/config/Server.java index 26806d1..f3bd3ed 100644 --- a/java/com/google/gerrit/extensions/api/config/Server.java +++ b/java/com/google/gerrit/extensions/api/config/Server.java
@@ -21,7 +21,6 @@ import com.google.gerrit.extensions.client.GeneralPreferencesInfo; import com.google.gerrit.extensions.common.ExperimentInfo; import com.google.gerrit.extensions.common.ServerInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.webui.TopMenu; import java.util.List; @@ -69,73 +68,4 @@ return enabledOnly; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Server { - @Override - public String getVersion() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ServerInfo getInfo() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GeneralPreferencesInfo setDefaultPreferences(GeneralPreferencesInfo in) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DiffPreferencesInfo setDefaultDiffPreferences(DiffPreferencesInfo in) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditPreferencesInfo getDefaultEditPreferences() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public EditPreferencesInfo setDefaultEditPreferences(EditPreferencesInfo in) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<TopMenu.MenuEntry> topMenus() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ExperimentApi experiment(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListExperimentsRequest listExperiments() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/java/com/google/gerrit/extensions/api/groups/GroupApi.java index e1b3a9f..671a19c 100644 --- a/java/com/google/gerrit/extensions/api/groups/GroupApi.java +++ b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
@@ -18,7 +18,6 @@ import com.google.gerrit.extensions.common.GroupAuditEventInfo; import com.google.gerrit.extensions.common.GroupInfo; import com.google.gerrit.extensions.common.GroupOptionsInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Arrays; import java.util.List; @@ -40,6 +39,9 @@ */ void name(String name) throws RestApiException; + /** Delete group. */ + void delete() throws RestApiException; + /** Returns owning group info. */ GroupInfo owner() throws RestApiException; @@ -173,105 +175,4 @@ * <p>Only supported for internal groups. */ void index() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements GroupApi { - @Override - public GroupInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GroupInfo detail() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String name() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void name(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GroupInfo owner() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void owner(String owner) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String description() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void description(String description) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GroupOptionsInfo options() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void options(GroupOptionsInfo options) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<AccountInfo> members() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<AccountInfo> members(boolean recursive) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void addMembers(List<String> members) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void removeMembers(List<String> members) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<GroupInfo> includedGroups() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void addGroups(List<String> groups) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void removeGroups(List<String> groups) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void index() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/groups/Groups.java b/java/com/google/gerrit/extensions/api/groups/Groups.java index 8d53af0..0a9a927 100644 --- a/java/com/google/gerrit/extensions/api/groups/Groups.java +++ b/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -17,7 +17,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.client.ListGroupsOption; import com.google.gerrit.extensions.common.GroupInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.ArrayList; import java.util.Arrays; @@ -291,40 +290,4 @@ return options; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Groups { - @Override - public GroupApi id(String id) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GroupApi create(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public GroupApi create(GroupInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListRequest list() { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query() { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query(String query) { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/plugins/PluginApi.java b/java/com/google/gerrit/extensions/api/plugins/PluginApi.java index b6d78a3..b0d5bdc 100644 --- a/java/com/google/gerrit/extensions/api/plugins/PluginApi.java +++ b/java/com/google/gerrit/extensions/api/plugins/PluginApi.java
@@ -15,7 +15,6 @@ package com.google.gerrit.extensions.api.plugins; import com.google.gerrit.extensions.common.PluginInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface PluginApi { @@ -26,30 +25,4 @@ void disable() throws RestApiException; void reload() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements PluginApi { - @Override - public PluginInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void enable() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void disable() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void reload() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/plugins/Plugins.java b/java/com/google/gerrit/extensions/api/plugins/Plugins.java index fed8507..9ab67fa 100644 --- a/java/com/google/gerrit/extensions/api/plugins/Plugins.java +++ b/java/com/google/gerrit/extensions/api/plugins/Plugins.java
@@ -16,7 +16,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.common.PluginInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.ArrayList; import java.util.List; @@ -110,33 +109,4 @@ return regex; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Plugins { - @Override - public ListRequest list() { - throw new NotImplementedException(); - } - - @Override - public PluginApi name(String name) { - throw new NotImplementedException(); - } - - @Override - @Deprecated - public PluginApi install( - String name, com.google.gerrit.extensions.common.InstallPluginInput input) - throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public PluginApi install(String name, InstallPluginInput input) { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/BranchApi.java b/java/com/google/gerrit/extensions/api/projects/BranchApi.java index a410205..6d30c1b 100644 --- a/java/com/google/gerrit/extensions/api/projects/BranchApi.java +++ b/java/com/google/gerrit/extensions/api/projects/BranchApi.java
@@ -17,7 +17,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.api.changes.ChangeApi.SuggestedReviewersRequest; import com.google.gerrit.extensions.restapi.BinaryResult; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.List; @@ -43,45 +42,4 @@ default SuggestedReviewersRequest suggestCcs(String query) throws RestApiException { return suggestReviewers().forCc().withQuery(query); } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements BranchApi { - @Override - public BranchApi create(BranchInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SuggestedReviewersRequest suggestReviewers() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SuggestedReviewersRequest suggestReviewers(String query) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BranchInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BinaryResult file(String path) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ReflogEntryInfo> reflog() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java index 146ef27..c471ef7 100644 --- a/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java +++ b/java/com/google/gerrit/extensions/api/projects/ChildProjectApi.java
@@ -15,27 +15,10 @@ package com.google.gerrit.extensions.api.projects; import com.google.gerrit.extensions.common.ProjectInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface ChildProjectApi { ProjectInfo get() throws RestApiException; ProjectInfo get(boolean recursive) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements ChildProjectApi { - @Override - public ProjectInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectInfo get(boolean recursive) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/CommitApi.java b/java/com/google/gerrit/extensions/api/projects/CommitApi.java index b0cc9da..18ba0c0 100644 --- a/java/com/google/gerrit/extensions/api/projects/CommitApi.java +++ b/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -19,7 +19,6 @@ import com.google.gerrit.extensions.api.changes.IncludedInInfo; import com.google.gerrit.extensions.common.CommitInfo; import com.google.gerrit.extensions.common.FileInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Map; @@ -32,27 +31,4 @@ /** List files in a specific commit against the parent commit. */ Map<String, FileInfo> files(int parentNum) throws RestApiException; - - /** A default implementation for source compatibility when adding new methods to the interface. */ - class NotImplemented implements CommitApi { - @Override - public CommitInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeApi cherryPick(CherryPickInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public IncludedInInfo includedIn() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public Map<String, FileInfo> files(int parentNum) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/DashboardApi.java b/java/com/google/gerrit/extensions/api/projects/DashboardApi.java index 3cde570..8c32b72 100644 --- a/java/com/google/gerrit/extensions/api/projects/DashboardApi.java +++ b/java/com/google/gerrit/extensions/api/projects/DashboardApi.java
@@ -14,7 +14,6 @@ package com.google.gerrit.extensions.api.projects; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface DashboardApi { @@ -24,25 +23,4 @@ DashboardInfo get(boolean inherited) throws RestApiException; void setDefault() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements DashboardApi { - @Override - public DashboardInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DashboardInfo get(boolean inherited) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void setDefault() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/LabelApi.java b/java/com/google/gerrit/extensions/api/projects/LabelApi.java index a5e23f2..009ac40 100644 --- a/java/com/google/gerrit/extensions/api/projects/LabelApi.java +++ b/java/com/google/gerrit/extensions/api/projects/LabelApi.java
@@ -18,7 +18,6 @@ import com.google.gerrit.common.Nullable; import com.google.gerrit.extensions.common.LabelDefinitionInfo; import com.google.gerrit.extensions.common.LabelDefinitionInput; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface LabelApi { @@ -35,30 +34,4 @@ } void delete(@Nullable String commitMessage) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements LabelApi { - @Override - public LabelApi create(LabelDefinitionInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public LabelDefinitionInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public LabelDefinitionInfo update(LabelDefinitionInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete(@Nullable String commitMessage) throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java index 58fd93a..86d597d 100644 --- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java +++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -26,7 +26,6 @@ import com.google.gerrit.extensions.common.ListTagSortOption; import com.google.gerrit.extensions.common.ProjectInfo; import com.google.gerrit.extensions.common.SubmitRequirementInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.Collection; import java.util.List; @@ -306,232 +305,4 @@ */ @CanIgnoreReturnValue ChangeInfo submitRequirementsReview(BatchSubmitRequirementInput input) throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements ProjectApi { - @Override - public ProjectApi create() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectApi create(ProjectInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String description() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectAccessInfo access() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChangeInfo accessChange(ProjectAccessInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CheckProjectResultInfo check(CheckProjectInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ConfigInfo config() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ConfigInfo config(ConfigInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @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(); - } - - @Override - public void description(DescriptionInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListRefsRequest<BranchInfo> branches() { - throw new NotImplementedException(); - } - - @Override - public ListRefsRequest<TagInfo> tags() { - throw new NotImplementedException(); - } - - @Override - public List<ProjectInfo> children() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ProjectInfo> children(boolean recursive) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public List<ProjectInfo> children(int limit) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ChildProjectApi child(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public BranchApi branch(String ref) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public TagApi tag(String ref) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteBranches(DeleteBranchesInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void deleteTags(DeleteTagsInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public CommitApi commit(String commit) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DashboardApi dashboard(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public DashboardApi defaultDashboard() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListDashboardsRequest dashboards() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void defaultDashboard(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void removeDefaultDashboard() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String head() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void head(String head) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public String parent() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void parent(String parent) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void index(boolean indexChildren) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void indexChanges() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListLabelsRequest labels() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListSubmitRequirementsRequest submitRequirements() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public LabelApi label(String labelName) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmitRequirementApi submitRequirement(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - 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/api/projects/Projects.java b/java/com/google/gerrit/extensions/api/projects/Projects.java index 7c8ecca..1f4f141 100644 --- a/java/com/google/gerrit/extensions/api/projects/Projects.java +++ b/java/com/google/gerrit/extensions/api/projects/Projects.java
@@ -17,7 +17,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.client.ProjectState; import com.google.gerrit.extensions.common.ProjectInfo; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import java.util.ArrayList; import java.util.Collections; @@ -261,40 +260,4 @@ return start; } } - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements Projects { - @Override - public ProjectApi name(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectApi create(ProjectInput in) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ProjectApi create(String name) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public ListRequest list() { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query() { - throw new NotImplementedException(); - } - - @Override - public QueryRequest query(String query) { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java index 29765c0..3556613 100644 --- a/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java +++ b/java/com/google/gerrit/extensions/api/projects/SubmitRequirementApi.java
@@ -17,7 +17,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.common.SubmitRequirementInfo; import com.google.gerrit.extensions.common.SubmitRequirementInput; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface SubmitRequirementApi { @@ -34,30 +33,4 @@ /** Delete existing submit requirement. */ void delete() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements SubmitRequirementApi { - @Override - public SubmitRequirementApi create(SubmitRequirementInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmitRequirementInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public SubmitRequirementInfo update(SubmitRequirementInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/api/projects/TagApi.java b/java/com/google/gerrit/extensions/api/projects/TagApi.java index 69c29df..94cddaf 100644 --- a/java/com/google/gerrit/extensions/api/projects/TagApi.java +++ b/java/com/google/gerrit/extensions/api/projects/TagApi.java
@@ -15,7 +15,6 @@ package com.google.gerrit.extensions.api.projects; import com.google.errorprone.annotations.CanIgnoreReturnValue; -import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; public interface TagApi { @@ -25,25 +24,4 @@ TagInfo get() throws RestApiException; void delete() throws RestApiException; - - /** - * A default implementation which allows source compatibility when adding new methods to the - * interface. - */ - class NotImplemented implements TagApi { - @Override - public TagApi create(TagInput input) throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public TagInfo get() throws RestApiException { - throw new NotImplementedException(); - } - - @Override - public void delete() throws RestApiException { - throw new NotImplementedException(); - } - } }
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java index ad494cb..8de9826 100644 --- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java +++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -14,6 +14,8 @@ package com.google.gerrit.extensions.client; +import static com.google.gerrit.extensions.client.NullableBooleanPreferencesFieldComparator.equalBooleanPreferencesFields; + import com.google.common.base.MoreObjects; import com.google.gerrit.common.ConvertibleToProto; import java.util.Objects; @@ -76,25 +78,26 @@ && Objects.equals(this.fontSize, other.fontSize) && Objects.equals(this.lineLength, other.lineLength) && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate) - && Objects.equals(this.expandAllComments, other.expandAllComments) - && Objects.equals(this.intralineDifference, other.intralineDifference) - && Objects.equals(this.manualReview, other.manualReview) - && Objects.equals(this.showLineEndings, other.showLineEndings) - && Objects.equals(this.showTabs, other.showTabs) - && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors) - && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting) - && Objects.equals(this.hideTopMenu, other.hideTopMenu) - && Objects.equals(this.autoHideDiffTableHeader, other.autoHideDiffTableHeader) - && Objects.equals(this.hideLineNumbers, other.hideLineNumbers) - && Objects.equals(this.renderEntireFile, other.renderEntireFile) - && Objects.equals(this.hideEmptyPane, other.hideEmptyPane) - && Objects.equals(this.matchBrackets, other.matchBrackets) - && Objects.equals(this.lineWrapping, other.lineWrapping) + && equalBooleanPreferencesFields(this.expandAllComments, other.expandAllComments) + && equalBooleanPreferencesFields(this.intralineDifference, other.intralineDifference) + && equalBooleanPreferencesFields(this.manualReview, other.manualReview) + && equalBooleanPreferencesFields(this.showLineEndings, other.showLineEndings) + && equalBooleanPreferencesFields(this.showTabs, other.showTabs) + && equalBooleanPreferencesFields(this.showWhitespaceErrors, other.showWhitespaceErrors) + && equalBooleanPreferencesFields(this.syntaxHighlighting, other.syntaxHighlighting) + && equalBooleanPreferencesFields(this.hideTopMenu, other.hideTopMenu) + && equalBooleanPreferencesFields( + this.autoHideDiffTableHeader, other.autoHideDiffTableHeader) + && equalBooleanPreferencesFields(this.hideLineNumbers, other.hideLineNumbers) + && equalBooleanPreferencesFields(this.renderEntireFile, other.renderEntireFile) + && equalBooleanPreferencesFields(this.hideEmptyPane, other.hideEmptyPane) + && equalBooleanPreferencesFields(this.matchBrackets, other.matchBrackets) + && equalBooleanPreferencesFields(this.lineWrapping, other.lineWrapping) && Objects.equals(this.ignoreWhitespace, other.ignoreWhitespace) - && Objects.equals(this.retainHeader, other.retainHeader) - && Objects.equals(this.skipDeleted, other.skipDeleted) - && Objects.equals(this.skipUnchanged, other.skipUnchanged) - && Objects.equals(this.skipUncommented, other.skipUncommented); + && equalBooleanPreferencesFields(this.retainHeader, other.retainHeader) + && equalBooleanPreferencesFields(this.skipDeleted, other.skipDeleted) + && equalBooleanPreferencesFields(this.skipUnchanged, other.skipUnchanged) + && equalBooleanPreferencesFields(this.skipUncommented, other.skipUncommented); } @Override
diff --git a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java index 5da211e..6e3d097 100644 --- a/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java +++ b/java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
@@ -14,6 +14,8 @@ package com.google.gerrit.extensions.client; +import static com.google.gerrit.extensions.client.NullableBooleanPreferencesFieldComparator.equalBooleanPreferencesFields; + import com.google.common.base.MoreObjects; import com.google.gerrit.common.ConvertibleToProto; import java.util.Objects; @@ -46,16 +48,16 @@ && Objects.equals(this.lineLength, other.lineLength) && Objects.equals(this.indentUnit, other.indentUnit) && Objects.equals(this.cursorBlinkRate, other.cursorBlinkRate) - && Objects.equals(this.hideTopMenu, other.hideTopMenu) - && Objects.equals(this.showTabs, other.showTabs) - && Objects.equals(this.showWhitespaceErrors, other.showWhitespaceErrors) - && Objects.equals(this.syntaxHighlighting, other.syntaxHighlighting) - && Objects.equals(this.hideLineNumbers, other.hideLineNumbers) - && Objects.equals(this.matchBrackets, other.matchBrackets) - && Objects.equals(this.lineWrapping, other.lineWrapping) - && Objects.equals(this.indentWithTabs, other.indentWithTabs) - && Objects.equals(this.autoCloseBrackets, other.autoCloseBrackets) - && Objects.equals(this.showBase, other.showBase); + && equalBooleanPreferencesFields(this.hideTopMenu, other.hideTopMenu) + && equalBooleanPreferencesFields(this.showTabs, other.showTabs) + && equalBooleanPreferencesFields(this.showWhitespaceErrors, other.showWhitespaceErrors) + && equalBooleanPreferencesFields(this.syntaxHighlighting, other.syntaxHighlighting) + && equalBooleanPreferencesFields(this.hideLineNumbers, other.hideLineNumbers) + && equalBooleanPreferencesFields(this.matchBrackets, other.matchBrackets) + && equalBooleanPreferencesFields(this.lineWrapping, other.lineWrapping) + && equalBooleanPreferencesFields(this.indentWithTabs, other.indentWithTabs) + && equalBooleanPreferencesFields(this.autoCloseBrackets, other.autoCloseBrackets) + && equalBooleanPreferencesFields(this.showBase, other.showBase); } @Override
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java index 3cc374a..ffdc276 100644 --- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java +++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -14,6 +14,8 @@ package com.google.gerrit.extensions.client; +import static com.google.gerrit.extensions.client.NullableBooleanPreferencesFieldComparator.equalBooleanPreferencesFields; + import com.google.common.base.MoreObjects; import com.google.gerrit.common.ConvertibleToProto; import java.util.List; @@ -202,26 +204,32 @@ && Objects.equals(this.theme, other.theme) && Objects.equals(this.dateFormat, other.dateFormat) && Objects.equals(this.timeFormat, other.timeFormat) - && Objects.equals(this.expandInlineDiffs, other.expandInlineDiffs) - && Objects.equals(this.relativeDateInChangeTable, other.relativeDateInChangeTable) + && equalBooleanPreferencesFields(this.expandInlineDiffs, other.expandInlineDiffs) + && equalBooleanPreferencesFields( + this.relativeDateInChangeTable, other.relativeDateInChangeTable) && Objects.equals(this.diffView, other.diffView) - && Objects.equals(this.sizeBarInChangeTable, other.sizeBarInChangeTable) - && Objects.equals(this.legacycidInChangeTable, other.legacycidInChangeTable) - && Objects.equals(this.muteCommonPathPrefixes, other.muteCommonPathPrefixes) - && Objects.equals(this.signedOffBy, other.signedOffBy) + && equalBooleanPreferencesFields(this.sizeBarInChangeTable, other.sizeBarInChangeTable) + && equalBooleanPreferencesFields(this.legacycidInChangeTable, other.legacycidInChangeTable) + && equalBooleanPreferencesFields(this.muteCommonPathPrefixes, other.muteCommonPathPrefixes) + && equalBooleanPreferencesFields(this.signedOffBy, other.signedOffBy) && Objects.equals(this.emailStrategy, other.emailStrategy) && Objects.equals(this.emailFormat, other.emailFormat) && Objects.equals(this.defaultBaseForMerges, other.defaultBaseForMerges) - && Objects.equals(this.publishCommentsOnPush, other.publishCommentsOnPush) - && Objects.equals(this.disableKeyboardShortcuts, other.disableKeyboardShortcuts) - && Objects.equals(this.disableTokenHighlighting, other.disableTokenHighlighting) - && Objects.equals(this.workInProgressByDefault, other.workInProgressByDefault) + && equalBooleanPreferencesFields(this.publishCommentsOnPush, other.publishCommentsOnPush) + && equalBooleanPreferencesFields( + this.disableKeyboardShortcuts, other.disableKeyboardShortcuts) + && equalBooleanPreferencesFields( + this.disableTokenHighlighting, other.disableTokenHighlighting) + && equalBooleanPreferencesFields( + this.workInProgressByDefault, other.workInProgressByDefault) && Objects.equals(this.my, other.my) && Objects.equals(this.changeTable, other.changeTable) - && Objects.equals(this.allowBrowserNotifications, other.allowBrowserNotifications) - && Objects.equals( + && equalBooleanPreferencesFields( + this.allowBrowserNotifications, other.allowBrowserNotifications) + && equalBooleanPreferencesFields( this.allowSuggestCodeWhileCommenting, other.allowSuggestCodeWhileCommenting) - && Objects.equals(this.allowAutocompletingComments, other.allowAutocompletingComments) + && equalBooleanPreferencesFields( + this.allowAutocompletingComments, other.allowAutocompletingComments) && Objects.equals(this.diffPageSidebar, other.diffPageSidebar); }
diff --git a/java/com/google/gerrit/extensions/client/NullableBooleanPreferencesFieldComparator.java b/java/com/google/gerrit/extensions/client/NullableBooleanPreferencesFieldComparator.java new file mode 100644 index 0000000..ccccceb --- /dev/null +++ b/java/com/google/gerrit/extensions/client/NullableBooleanPreferencesFieldComparator.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.extensions.client; + +import com.google.gerrit.common.Nullable; +import java.util.Objects; + +/** + * Utility class to compare nullable {@link Boolean} preferences fields. + * + * <p>This class only meant to be used for comparing preferences fields that are potentially loaded + * using {@link com.google.gerrit.server.config.ConfigUtil} (such as {@link GeneralPreferencesInfo}, + * {@link DiffPreferencesInfo} and {@link EditPreferencesInfo}). + */ +public class NullableBooleanPreferencesFieldComparator { + + /** + * Compare 2 nullable {@link Boolean} preferences fields, regard to {@code null} as {@code false}. + * + * <p>{@link com.google.gerrit.server.config.ConfigUtil#loadSection} sets the following values for + * Boolean fields, relating to {@code null} as {@code false} the same way: + * + * <table> + * <tr><th> user-def </th> <th> default </th> <th> result </th></tr> + * <tr><td> true </td> <td> true </td> <td> true </td></tr> + * <tr><td> true </td> <td> false </td> <td> true </td></tr> + * <tr><td> true </td> <td> null </td> <td> true </td></tr> + * <tr><td> false </td> <td> true </td> <td> false </td></tr> + * <tr><td> false </td> <td> false </td> <td> null </td></tr> + * <tr><td> false </td> <td> null </td> <td> null </td></tr> + * <tr><td> null </td> <td> true </td> <td> true </td></tr> + * <tr><td> null </td> <td> false </td> <td> null </td></tr> + * <tr><td> null </td> <td> null </td> <td> null </td></tr> + * </table> + * + * When reading the values, the readers always check whether the value is {@code true}, + * practically referring to {@code null} values as {@code false} anyway. Preferences equality + * methods should reflect this state. + */ + public static boolean equalBooleanPreferencesFields(@Nullable Boolean a, @Nullable Boolean b) { + return Objects.equals( + Objects.requireNonNullElse(a, false), Objects.requireNonNullElse(b, false)); + } +}
diff --git a/java/com/google/gerrit/extensions/common/ConflictsInfo.java b/java/com/google/gerrit/extensions/common/ConflictsInfo.java new file mode 100644 index 0000000..ba9f1be --- /dev/null +++ b/java/com/google/gerrit/extensions/common/ConflictsInfo.java
@@ -0,0 +1,55 @@ +// 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; + +/** Information about conflicts in a revision. */ +public class ConflictsInfo { + /** + * The SHA1 of the commit that was used as {@code ours} for the Git merge that created the + * revision. + * + * <p>Guaranteed to be set if {@link #containsConflicts} is {@code true}. If {@link + * #containsConflicts} is {@code false}, only set if the revision was created by Gerrit as a + * result of performing a Git merge. + */ + public String ours; + + /** + * The SHA1 of the commit that was used as {@code theirs} for the Git merge that created the + * revision. + * + * <p>Guaranteed to be set if {@link #containsConflicts} is {@code true}. If {@link + * #containsConflicts} is {@code false}, only set if the revision was created by Gerrit as a + * result of performing a Git merge. + */ + public String theirs; + + /** + * Whether any of the files in the revision has a conflict due to merging {@link #ours} and {@link + * #theirs}. + * + * <p>If {@code true} at least one of the files in the revision has a conflict and contains Git + * conflict markers. The conflicts occurred while performing a merge between {@link #ours} and + * {@link #theirs}. + * + * <p>If {@code false}, and {@link #ours} and {@link #theirs} are present, merging {@link #ours} + * and {@link #theirs} didn't have any conflict. In this case the files in the revision may only + * contain Git conflict markers if they were already present in {@link #ours} or {@link #theirs}. + * + * <p>If {@code false}, and {@link #ours} and {@link #theirs} are not present, the revision was + * not created as a result of performing a Git merge and hence doesn't contain conflicts. + */ + public Boolean containsConflicts; +}
diff --git a/java/com/google/gerrit/extensions/common/DeleteGroupInput.java b/java/com/google/gerrit/extensions/common/DeleteGroupInput.java new file mode 100644 index 0000000..302a4a8 --- /dev/null +++ b/java/com/google/gerrit/extensions/common/DeleteGroupInput.java
@@ -0,0 +1,16 @@ +// 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; + +public class DeleteGroupInput {}
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java index 7b74a06..dc134fb 100644 --- a/java/com/google/gerrit/extensions/common/RevisionInfo.java +++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -45,6 +45,15 @@ public PushCertificateInfo pushCertificate; public String description; + /** + * Information about conflicts in this revision. + * + * <p>Only set for revisions that were created by Gerrit as a result of performing a Git merge. + * + * <p>If this field is not set it's unknown whether the revision contains any file with conflicts. + */ + public ConflictsInfo conflicts; + public RevisionInfo() {} public RevisionInfo(String ref) {
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java index dc1758e..d052aa8 100644 --- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java +++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -536,7 +536,7 @@ } AsyncReceiveCommits arc = - factory.create(state, userProvider.get().asIdentifiedUser(), db, null); + factory.create(state, userProvider.get().asIdentifiedUser(), db, null, null); ReceivePack rp = arc.getReceivePack(); req.setAttribute(ATT_ARC, arc); return rp;
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java index f2dbbc2..d7ec2ad 100644 --- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java +++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -313,8 +313,13 @@ throws ServletException, IOException { final long startNanos = System.nanoTime(); long auditStartTs = TimeUtil.nowMs(); - res.setHeader("Content-Disposition", "attachment"); res.setHeader("X-Content-Type-Options", "nosniff"); + // Nobody should be loading HTML from our API server, but if for some reason that happens, stop + // it having any capabilities + res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox"); + res.setHeader("Referrer-Policy", "no-referrer"); + // Nobody should be iframing our API server. + res.setHeader("X-Frame-Options", "deny"); int statusCode = SC_OK; long responseBytes = -1; Optional<Exception> cause = Optional.empty();
diff --git a/java/com/google/gerrit/index/IndexCollection.java b/java/com/google/gerrit/index/IndexCollection.java index 66a9fba..8698908 100644 --- a/java/com/google/gerrit/index/IndexCollection.java +++ b/java/com/google/gerrit/index/IndexCollection.java
@@ -18,6 +18,8 @@ import com.google.common.collect.Lists; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.extensions.events.LifecycleListener; +import com.google.gerrit.metrics.Description; +import com.google.gerrit.metrics.MetricMaker; import java.util.Collection; import java.util.Collections; import java.util.concurrent.CopyOnWriteArrayList; @@ -25,12 +27,21 @@ /** Dynamic pointers to the index versions used for searching and writing. */ public abstract class IndexCollection<K, V, I extends Index<K, V>> implements LifecycleListener { + protected enum IndexType { + ACCOUNTS, + CHANGES, + GROUPS, + PROJECTS + } + private final CopyOnWriteArrayList<I> writeIndexes; private final AtomicReference<I> searchIndex; + private final MetricMaker metrics; - protected IndexCollection() { + protected IndexCollection(MetricMaker metrics) { this.writeIndexes = Lists.newCopyOnWriteArrayList(); this.searchIndex = new AtomicReference<>(); + this.metrics = metrics; } /** Returns the current search index version. */ @@ -93,7 +104,9 @@ } @Override - public void start() {} + public void start() { + registerMetric(); + } @Override public void stop() { @@ -107,4 +120,22 @@ } } } + + protected abstract IndexType getIndexName(); + + private void registerMetric() { + String indexName = getIndexName().name().toLowerCase(); + metrics.newCallbackMetric( + String.format("indexes/%s", indexName), + Integer.class, + new Description(String.format("%s Index documents", indexName)) + .setGauge() + .setUnit("documents"), + () -> { + if (getSearchIndex() == null) { + return -1; + } + return getSearchIndex().numDocs(); + }); + } }
diff --git a/java/com/google/gerrit/index/project/BUILD b/java/com/google/gerrit/index/project/BUILD index b029513..28392f0 100644 --- a/java/com/google/gerrit/index/project/BUILD +++ b/java/com/google/gerrit/index/project/BUILD
@@ -8,6 +8,7 @@ "//java/com/google/gerrit/entities", "//java/com/google/gerrit/index", "//java/com/google/gerrit/index:query_exception", + "//java/com/google/gerrit/metrics", "//lib:guava", "//lib/errorprone:annotations", "//lib/guice",
diff --git a/java/com/google/gerrit/index/project/ProjectIndexCollection.java b/java/com/google/gerrit/index/project/ProjectIndexCollection.java index 7ff23c5..cfc745e 100644 --- a/java/com/google/gerrit/index/project/ProjectIndexCollection.java +++ b/java/com/google/gerrit/index/project/ProjectIndexCollection.java
@@ -17,13 +17,22 @@ import com.google.common.annotations.VisibleForTesting; import com.google.gerrit.entities.Project; import com.google.gerrit.index.IndexCollection; +import com.google.gerrit.metrics.MetricMaker; +import com.google.inject.Inject; import com.google.inject.Singleton; /** Collection of active project indices. See {@link IndexCollection} for details on collections. */ @Singleton public class ProjectIndexCollection extends IndexCollection<Project.NameKey, ProjectData, ProjectIndex> { - + @Inject @VisibleForTesting - public ProjectIndexCollection() {} + public ProjectIndexCollection(MetricMaker metrics) { + super(metrics); + } + + @Override + protected IndexType getIndexName() { + return IndexType.PROJECTS; + } }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java index 0950a4c..b7b3835 100644 --- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java +++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -26,7 +26,9 @@ import com.google.gerrit.entities.Change; import com.google.gerrit.entities.InternalGroup; import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.Project.NameKey; import com.google.gerrit.index.Index; +import com.google.gerrit.index.IndexConfig; import com.google.gerrit.index.QueryOptions; import com.google.gerrit.index.Schema; import com.google.gerrit.index.SchemaFieldDefs; @@ -48,6 +50,7 @@ import com.google.gerrit.server.index.change.ChangeIndex; import com.google.gerrit.server.index.group.GroupIndex; import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.query.change.ChangePredicates; import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; import java.time.Instant; @@ -56,6 +59,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.IntStream; import java.util.stream.Stream; import org.eclipse.jgit.annotations.Nullable; @@ -248,6 +252,7 @@ extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex { private final ChangeData.Factory changeDataFactory; private final boolean skipMergable; + private final IndexConfig indexConfig; @Inject @VisibleForTesting @@ -255,10 +260,12 @@ SitePaths sitePaths, ChangeData.Factory changeDataFactory, @Assisted Schema<ChangeData> schema, - @GerritServerConfig Config cfg) { + @GerritServerConfig Config cfg, + IndexConfig indexConfig) { super(schema, sitePaths, "changes"); this.changeDataFactory = changeDataFactory; this.skipMergable = !MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex(); + this.indexConfig = indexConfig; } @Override @@ -315,6 +322,16 @@ public void deleteByValue(ChangeData value) { delete(ChangeIndex.ENTITY_TO_KEY.apply(value)); } + + @Override + public void deleteAllForProject(NameKey project) { + QueryOptions opts = QueryOptions.create(indexConfig, 0, Integer.MAX_VALUE, Set.of()); + DataSource<ChangeData> result = getSource(ChangePredicates.project(project), opts); + for (FieldBundle f : result.readRaw().toList()) { + int changeNum = f.<Integer>getValue(ChangeField.CHANGENUM_SPEC).intValue(); + delete(Change.id(changeNum)); + } + } } /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java index 024b102..2bc29d9 100644 --- a/java/com/google/gerrit/lucene/ChangeSubIndex.java +++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableSet; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.Project; import com.google.gerrit.index.QueryOptions; import com.google.gerrit.index.Schema; import com.google.gerrit.index.Schema.Values; @@ -105,6 +106,11 @@ } @Override + public void deleteAllForProject(Project.NameKey project) { + throw new UnsupportedOperationException("don't use ChangeSubIndex directly"); + } + + @Override public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts) throws QueryParseException { throw new UnsupportedOperationException("don't use ChangeSubIndex directly");
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java index e5f7787..7b88f45 100644 --- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java +++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -259,6 +259,16 @@ } @Override + public void deleteAllForProject(Project.NameKey project) { + Term allForProject = new Term(ChangeField.PROJECT_SPEC.getName(), project.get()); + try { + Futures.allAsList(openIndex.delete(allForProject), closedIndex.delete(allForProject)).get(); + } catch (ExecutionException | InterruptedException e) { + throw new StorageException(e); + } + } + + @Override public void deleteAll() { openIndex.deleteAll(); closedIndex.deleteAll();
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java index f30efd4..7e4ce3c 100644 --- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java +++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -86,6 +86,7 @@ import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl; import com.google.gerrit.server.project.SubmitRuleEvaluator; import com.google.gerrit.server.query.approval.ApprovalModule; +import com.google.gerrit.server.query.approval.ApprovalQueryBuilder; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate; import com.google.gerrit.server.query.change.ChangeQueryBuilder; @@ -209,6 +210,7 @@ DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class); DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class); DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class); + DynamicMap.mapOf(binder(), ApprovalQueryBuilder.UserInOperandFactory.class); // Submit rules DynamicSet.setOf(binder(), SubmitRule.class);
diff --git a/java/com/google/gerrit/server/AclInfoController.java b/java/com/google/gerrit/server/AclInfoController.java index 1563ba3..9f6e15a 100644 --- a/java/com/google/gerrit/server/AclInfoController.java +++ b/java/com/google/gerrit/server/AclInfoController.java
@@ -14,31 +14,25 @@ package com.google.gerrit.server; -import com.google.common.collect.ImmutableList; -import com.google.gerrit.server.logging.LoggingContext; import com.google.gerrit.server.logging.TraceContext; -import com.google.gerrit.server.permissions.GlobalPermission; -import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.permissions.PermissionBackendException; -import com.google.inject.Inject; import com.google.inject.Singleton; import java.util.Optional; /** Class to control when ACL infos should be collected and be returned to the user. */ +// TODO: re-enable this class if it's found safe, otherwise remove it @Singleton public class AclInfoController { - private final PermissionBackend permissionBackend; - - @Inject - AclInfoController(PermissionBackend permissionBackend) { - this.permissionBackend = permissionBackend; - } - + /** + * Enable ACL logging if the user has the "View Access" capability. + * + * @param traceContext the trace context on which ACL logging enabled if the user has the "View + * Access" capability. + * @throws PermissionBackendException thrown if there is a failure while checking permissions + */ public void enableAclLoggingIfUserCanViewAccess(TraceContext traceContext) throws PermissionBackendException { - if (canViewAclInfos()) { - traceContext.enableAclLogging(); - } + // intentionally disabled } /** @@ -46,25 +40,7 @@ * Optional#empty()} if ACL logging hasn't been turned on */ public Optional<String> getAclInfoMessage() { - // ACL logging is only enabled if the user can view ACL infos. This is checked when ACL logging - // is turned on in enableAclLoggingIfUserCanViewAccess. Hence we can return ACL infos if ACL - // logging is on and do not need to check the permission again. We want to avoid re-checking the - // permission so that we do not need to handle PermissionBackendException. - if (!LoggingContext.getInstance().isAclLogging()) { - return Optional.empty(); - } - - ImmutableList<String> aclLogRecords = TraceContext.getAclLogRecords(); - if (aclLogRecords.isEmpty()) { - aclLogRecords = ImmutableList.of("Found no rules that apply, so defaulting to no permission"); - } - - StringBuilder msgBuilder = new StringBuilder("ACL info:"); - aclLogRecords.forEach(aclLogRecord -> msgBuilder.append("\n* ").append(aclLogRecord)); - return Optional.of(msgBuilder.toString()); - } - - private boolean canViewAclInfos() throws PermissionBackendException { - return permissionBackend.currentUser().test(GlobalPermission.VIEW_ACCESS); + // intentionally disabled + return Optional.empty(); } }
diff --git a/java/com/google/gerrit/server/RequestCounter.java b/java/com/google/gerrit/server/RequestCounter.java new file mode 100644 index 0000000..444521a --- /dev/null +++ b/java/com/google/gerrit/server/RequestCounter.java
@@ -0,0 +1,28 @@ +// 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; + +import com.google.gerrit.common.Nullable; + +public interface RequestCounter { + /** + * Count a request. + * + * @param requestInfo information about the request + * @param error The exception which caused the request to fail, or null if the request was + * successful. + */ + void countRequest(RequestInfo requestInfo, @Nullable Throwable error); +}
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java index 927985d8..6457b38 100644 --- a/java/com/google/gerrit/server/RequestInfo.java +++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -85,6 +85,13 @@ return requestUri().map(RequestInfo::redactRequestUri); } + /** + * The command name of the SSH command. + * + * <p>Only set if request type is {@link RequestType#SSH}. + */ + public abstract Optional<String> commandName(); + /** The user that has sent the request. */ public abstract CurrentUser callingUser(); @@ -164,6 +171,18 @@ return builder().requestType(requestType).callingUser(callingUser).traceContext(traceContext); } + public static RequestInfo.Builder builder( + RequestType requestType, + String commandName, + CurrentUser callingUser, + TraceContext traceContext) { + return builder() + .requestType(requestType) + .commandName(commandName) + .callingUser(callingUser) + .traceContext(traceContext); + } + @UsedAt(UsedAt.Project.GOOGLE) public static RequestInfo.Builder builder() { return new AutoValue_RequestInfo.Builder(); @@ -191,6 +210,8 @@ return this; } + public abstract Builder commandName(String commandName); + public abstract Builder callingUser(CurrentUser callingUser); public abstract Builder traceContext(TraceContext traceContext);
diff --git a/java/com/google/gerrit/server/ValidationOptionsListener.java b/java/com/google/gerrit/server/ValidationOptionsListener.java new file mode 100644 index 0000000..41a408f --- /dev/null +++ b/java/com/google/gerrit/server/ValidationOptionsListener.java
@@ -0,0 +1,33 @@ +// 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; + +import com.google.common.collect.ImmutableListMultimap; +import com.google.gerrit.entities.BranchNameKey; +import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.extensions.annotations.ExtensionPoint; + +/** + * Hook to get to know about validation options that have been specified by the user. + * + * <p>For example, this extension point can be used to log validation options for auditing purposes. + */ +@ExtensionPoint +public interface ValidationOptionsListener { + void onPatchSetCreation( + BranchNameKey projectAndBranch, + PatchSet.Id patchSetId, + ImmutableListMultimap<String, String> validationOptions); +}
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java index 42687987..17201a0 100644 --- a/java/com/google/gerrit/server/account/AccountLoader.java +++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import com.google.common.collect.Iterables; +import com.google.common.collect.Sets; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; import com.google.gerrit.extensions.common.AccountInfo; @@ -37,7 +38,7 @@ import java.util.Set; public class AccountLoader { - public static final Set<FillOptions> DETAILED_OPTIONS = + public static final Set<FillOptions> DETAILED_OPTIONS_WITHOUT_AVATAR = Collections.unmodifiableSet( EnumSet.of( FillOptions.ID, @@ -47,9 +48,17 @@ FillOptions.DISPLAY_NAME, FillOptions.STATUS, FillOptions.STATE, - FillOptions.AVATARS, FillOptions.TAGS)); + /** + * NOTE: loading avatars might be a time consuming operation. Callers which don't display the + * avatar should use {@link #DETAILED_OPTIONS_WITHOUT_AVATAR}. + */ + public static final Set<FillOptions> DETAILED_OPTIONS = + Collections.unmodifiableSet( + Sets.union( + AccountLoader.DETAILED_OPTIONS_WITHOUT_AVATAR, EnumSet.of(FillOptions.AVATARS))); + public interface Factory { AccountLoader create(boolean detailed);
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java index 7621929..24c148c 100644 --- a/java/com/google/gerrit/server/account/CapabilityCollection.java +++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -51,6 +51,7 @@ public final ImmutableList<PermissionRule> readAs; public final ImmutableList<PermissionRule> queryLimit; public final ImmutableList<PermissionRule> createGroup; + public final ImmutableList<PermissionRule> deleteGroup; @Inject CapabilityCollection( @@ -100,6 +101,7 @@ readAs = getPermission(GlobalCapability.READ_AS); queryLimit = getPermission(GlobalCapability.QUERY_LIMIT); createGroup = getPermission(GlobalCapability.CREATE_GROUP); + deleteGroup = getPermission(GlobalCapability.DELETE_GROUP); } private static List<PermissionRule> mergeAdmin(
diff --git a/java/com/google/gerrit/server/account/GroupControl.java b/java/com/google/gerrit/server/account/GroupControl.java index fd18d3e..828cfb6 100644 --- a/java/com/google/gerrit/server/account/GroupControl.java +++ b/java/com/google/gerrit/server/account/GroupControl.java
@@ -230,4 +230,19 @@ } return canAdministrateServer(); } + + public boolean canDeleteGroup() { + return canAdministrateServer() || hasDeleteGroupCapability(); + } + + private boolean hasDeleteGroupCapability() { + try { + return perm.test(GlobalPermission.DELETE_GROUP); + } catch (PermissionBackendException e) { + logger.atFine().log( + "Failed to check %s global capability for user %s", + GlobalPermission.DELETE_GROUP, user.getLoggableName()); + return false; + } + } }
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java index 64b8ec0..c6ffce5 100644 --- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java +++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -213,7 +213,7 @@ AvatarProvider ap = avatar.get(); if (ap != null) { info.avatars = new ArrayList<>(); - IdentifiedUser user = userFactory.create(account.id()); + IdentifiedUser user = userFactory.create(accountState); // PolyGerrit UI uses the following sizes for avatars: // - 32px for avatars next to names e.g. on the dashboard. This is also Gerrit's default.
diff --git a/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java index aa09278..503c596 100644 --- a/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java +++ b/java/com/google/gerrit/server/account/externalids/DuplicateExternalIdKeyException.java
@@ -30,6 +30,11 @@ this.duplicateKey = duplicateKey; } + public DuplicateExternalIdKeyException(ExternalId.Key duplicateKey, Throwable why) { + super("Duplicate external ID key: " + duplicateKey.get(), why); + this.duplicateKey = duplicateKey; + } + public ExternalId.Key getDuplicateKey() { return duplicateKey; }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java index 0755a6d..892a222 100644 --- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java +++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -19,6 +19,7 @@ import com.google.gerrit.entities.Account; import java.io.IOException; import java.util.Optional; +import java.util.Set; import org.eclipse.jgit.errors.ConfigInvalidException; public interface ExternalIds { @@ -28,6 +29,9 @@ /** Returns the specified external ID. */ Optional<ExternalId> get(ExternalId.Key key) throws IOException; + /** Returns the specified external IDs. */ + ImmutableSet<ExternalId> get(Set<ExternalId.Key> keys) throws IOException; + /** Returns the external IDs of the specified account. */ ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsSameAccountChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsSameAccountChecker.java new file mode 100644 index 0000000..787583c5 --- /dev/null +++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsSameAccountChecker.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.account.externalids; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.entities.Account; + +/** Static utilities for checking that all specified external IDs belong to the same account. */ +public final class ExternalIdsSameAccountChecker { + private ExternalIdsSameAccountChecker() {} + + /** + * Checks that all specified external IDs belong to the same account. + * + * @return the ID of the account to which all specified external IDs belong. + */ + public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) { + return checkSameAccount(extIds, null); + } + + /** + * Checks that all specified external IDs belong to specified account. If no account is specified + * it is checked that all specified external IDs belong to the same account. + * + * @return the ID of the account to which all specified external IDs belong. + */ + @CanIgnoreReturnValue + public static Account.Id checkSameAccount( + Iterable<ExternalId> extIds, @Nullable Account.Id accountId) { + for (ExternalId extId : extIds) { + if (accountId == null) { + accountId = extId.accountId(); + continue; + } + checkState( + accountId.equals(extId.accountId()), + "external id %s belongs to account %s, but expected account %s", + extId.key().get(), + extId.accountId().get(), + accountId.get()); + } + return accountId; + } +}
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java index 6137884..5f708d2 100644 --- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java +++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
@@ -41,6 +41,7 @@ import com.google.gerrit.server.account.externalids.ExternalId; import com.google.gerrit.server.account.externalids.ExternalIdCache; import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor; +import com.google.gerrit.server.account.externalids.ExternalIdsSameAccountChecker; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.AuthConfig; import com.google.gerrit.server.git.meta.MetaDataUpdate; @@ -691,12 +692,6 @@ cacheUpdates.add(cu -> cu.remove(removedExtIds)); } - public void replace( - Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd) - throws IOException, DuplicateExternalIdKeyException { - replace(accountId, toDelete, toAdd, defaultNoteIdResolver); - } - /** * Replaces external IDs for an account by external ID keys. * @@ -708,15 +703,23 @@ * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to * the specified account. */ - public void replace( + private void replace( Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd, - Function<ExternalId, ObjectId> noteIdResolver) + Function<ExternalId, ObjectId> noteIdResolver, + Collection<ExternalId.Key> externalIdsDeletedInTransaction) throws IOException, DuplicateExternalIdKeyException { checkLoaded(); - checkSameAccount(toAdd, accountId); - checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), toDelete); + Account.Id inferredAccountId = ExternalIdsSameAccountChecker.checkSameAccount(toAdd, accountId); + if (inferredAccountId != null && !accountId.equals(inferredAccountId)) { + throw new IllegalArgumentException( + String.format( + "ExternalIdNotes#replace called for account %s, but with external IDs correlated to" + + " account %s", + accountId, inferredAccountId)); + } + checkExternalIdKeysDontExist(ExternalId.Key.from(toAdd), externalIdsDeletedInTransaction); Set<ExternalId> removedExtIds = new HashSet<>(); Set<ExternalId> updatedExtIds = new HashSet<>(); @@ -797,13 +800,55 @@ */ public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd) throws IOException, DuplicateExternalIdKeyException { - Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd)); + Account.Id accountId = + ExternalIdsSameAccountChecker.checkSameAccount(Iterables.concat(toDelete, toAdd)); if (accountId == null) { // toDelete and toAdd are empty -> nothing to do return; } - replace(accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd); + Set<ExternalId.Key> toDeleteKeys = toDelete.stream().map(ExternalId::key).collect(toSet()); + replace(accountId, toDelete, toAdd, /* externalIdsDeletedInTransaction= */ toDeleteKeys); + } + + /** + * Replaces external IDs. + * + * <p>Deletion of external IDs is done before adding the new external IDs. This means if an + * external ID is specified for deletion and an external ID with the same key is specified to be + * added, the old external ID with that key is deleted first and then the new external ID is added + * (so the external ID for that key is replaced). + * + * <p>This method also gets a collection of all external IDs that should be deleted in this + * transaction - from the target account or other accounts. This collection is taken into account + * when calculating duplications. + */ + public void replace( + Account.Id accountId, + Collection<ExternalId> toDelete, + Collection<ExternalId> toAdd, + Collection<ExternalId.Key> externalIdsDeletedInTransaction) + throws IOException, DuplicateExternalIdKeyException { + Account.Id inferredAccountId = + ExternalIdsSameAccountChecker.checkSameAccount(Iterables.concat(toDelete, toAdd)); + if (inferredAccountId != null && !accountId.equals(inferredAccountId)) { + throw new IllegalArgumentException( + String.format( + "ExternalIdNotes#replace called for account %s, but with external IDs correlated to" + + " account %s", + accountId, inferredAccountId)); + } + if (toDelete.isEmpty() && toAdd.isEmpty()) { + // nothing to do + return; + } + + replace( + accountId, + toDelete.stream().map(ExternalId::key).collect(toSet()), + toAdd, + defaultNoteIdResolver, + externalIdsDeletedInTransaction); } /** @@ -822,14 +867,20 @@ Collection<ExternalId> toAdd, Function<ExternalId, ObjectId> noteIdResolver) throws IOException, DuplicateExternalIdKeyException { - Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd)); + Account.Id accountId = + ExternalIdsSameAccountChecker.checkSameAccount(Iterables.concat(toDelete, toAdd)); if (accountId == null) { // toDelete and toAdd are empty -> nothing to do return; } + Set<ExternalId.Key> toDeleteKeys = toDelete.stream().map(ExternalId::key).collect(toSet()); replace( - accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd, noteIdResolver); + accountId, + toDeleteKeys, + toAdd, + noteIdResolver, + /* externalIdsDeletedInTransaction= */ toDeleteKeys); } @Override @@ -889,39 +940,6 @@ } } - /** - * Checks that all specified external IDs belong to the same account. - * - * @return the ID of the account to which all specified external IDs belong. - */ - private static Account.Id checkSameAccount(Iterable<ExternalId> extIds) { - return checkSameAccount(extIds, null); - } - - /** - * Checks that all specified external IDs belong to specified account. If no account is specified - * it is checked that all specified external IDs belong to the same account. - * - * @return the ID of the account to which all specified external IDs belong. - */ - @CanIgnoreReturnValue - public static Account.Id checkSameAccount( - Iterable<ExternalId> extIds, @Nullable Account.Id accountId) { - for (ExternalId extId : extIds) { - if (accountId == null) { - accountId = extId.accountId(); - continue; - } - checkState( - accountId.equals(extId.accountId()), - "external id %s belongs to account %s, but expected account %s", - extId.key().get(), - extId.accountId().get(), - accountId.get()); - } - return accountId; - } - private void incrementalDuplicateDetection(Collection<ExternalId> externalIds) { externalIds.stream() .map(ExternalId::key)
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java index 4c26442..46d52e1 100644 --- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java +++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdsNoteDbImpl.java
@@ -14,7 +14,9 @@ package com.google.gerrit.server.account.externalids.storage.notedb; +import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.common.collect.MoreCollectors.toOptional; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSetMultimap; @@ -28,6 +30,7 @@ import com.google.inject.Singleton; import java.io.IOException; import java.util.Optional; +import java.util.Set; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.ObjectId; @@ -71,15 +74,28 @@ @Override public Optional<ExternalId> get(ExternalId.Key key) throws IOException { - Optional<ExternalId> externalId = Optional.empty(); - if (authConfig.isUserNameCaseInsensitiveMigrationMode()) { - externalId = - externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false)); + ImmutableSet<ExternalId> res = get(ImmutableSet.of(key)); + checkState(res.size() <= 1, "Got multiple matches for external ID [%s]", key); + return !res.isEmpty() ? res.stream().collect(toOptional()) : Optional.empty(); + } + + @Override + public ImmutableSet<ExternalId> get(Set<ExternalId.Key> keys) throws IOException { + ImmutableSet.Builder<ExternalId> res = ImmutableSet.builder(); + for (ExternalId.Key key : keys) { + Optional<ExternalId> externalId = Optional.empty(); + if (authConfig.isUserNameCaseInsensitiveMigrationMode()) { + externalId = + externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false)); + } + if (externalId.isEmpty()) { + externalId = externalIdCache.byKey(key); + } + if (externalId.isPresent()) { + res.add(externalId.get()); + } } - if (!externalId.isPresent()) { - externalId = externalIdCache.byKey(key); - } - return externalId; + return res.build(); } @Override
diff --git a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java index 6987de5..083f993 100644 --- a/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java +++ b/java/com/google/gerrit/server/account/storage/notedb/AccountNoteDbWriteStorageModule.java
@@ -18,6 +18,11 @@ import com.google.gerrit.extensions.registration.DynamicSet; import com.google.gerrit.server.account.AccountsUpdate; import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdNoteDbWriteStorageModule; +import com.google.gerrit.server.account.storage.notedb.validators.AccountCommitValidator; +import com.google.gerrit.server.account.storage.notedb.validators.AccountMergeValidator; +import com.google.gerrit.server.account.storage.notedb.validators.ExternalIdUpdateValidator; +import com.google.gerrit.server.git.validators.CommitValidationListener; +import com.google.gerrit.server.git.validators.MergeValidationListener; import com.google.gerrit.server.index.account.ReindexAccountsAfterRefUpdate; import com.google.inject.AbstractModule; @@ -34,5 +39,10 @@ .to(AccountsUpdateNoteDbImpl.FactoryNoReindex.class); DynamicSet.bind(binder(), GitBatchRefUpdateListener.class) .to(ReindexAccountsAfterRefUpdate.class); + + // Validators + DynamicSet.bind(binder(), CommitValidationListener.class).to(AccountCommitValidator.class); + DynamicSet.bind(binder(), CommitValidationListener.class).to(ExternalIdUpdateValidator.class); + DynamicSet.bind(binder(), MergeValidationListener.class).to(AccountMergeValidator.class); } }
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 edc8707..663eddd 100644 --- a/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java +++ b/java/com/google/gerrit/server/account/storage/notedb/AccountsUpdateNoteDbImpl.java
@@ -16,6 +16,9 @@ import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.collect.ImmutableMap.toImmutableMap; +import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.gerrit.server.account.externalids.ExternalIdsSameAccountChecker.checkSameAccount; import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toList; @@ -25,6 +28,7 @@ import com.google.common.base.Strings; import com.google.common.base.Throwables; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.gerrit.common.Nullable; @@ -58,9 +62,11 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import javax.inject.Inject; import javax.inject.Singleton; import org.eclipse.jgit.errors.ConfigInvalidException; @@ -291,22 +297,50 @@ throws IOException, ConfigInvalidException { return execute( ImmutableList.of( - repo -> { - AccountConfig accountConfig = read(repo, accountId); - Account account = accountConfig.getNewAccount(committerIdent.getWhenAsInstant()); - AccountState accountState = AccountState.forAccount(account); - AccountDelta.Builder deltaBuilder = AccountDelta.builder(); - configureDelta(init, accountState, deltaBuilder); + new ExecutableUpdate() { + @Override + public AccountConfig getAccountConfig(Repository repo) + throws ConfigInvalidException, IOException { + return read(repo, accountId); + } - AccountDelta accountDelta = deltaBuilder.build(); - accountConfig.setAccountDelta(accountDelta); - updateExternalIdNotes( - repo, accountConfig.getExternalIdsRev(), accountId, accountDelta); - CachedPreferences defaultPreferences = - CachedPreferences.fromLegacyConfig( - VersionedDefaultPreferences.get(repo, allUsersName)); + @Override + public Optional<AccountDelta> computeAccountDelta( + Repository allUsersRepo, AccountConfig accountConfig) throws IOException { + Account account = + accountConfig.getNewAccount(committerIdent.getWhenAsInstant()); + AccountState accountState = AccountState.forAccount(account); + AccountDelta.Builder deltaBuilder = AccountDelta.builder(); + configureDelta(init, accountState, deltaBuilder); - return new UpdatedAccount(message, accountConfig, defaultPreferences, true); + return Optional.of(deltaBuilder.build()); + } + + @Override + public UpdatedAccount execute( + Repository repo, + Set<ExternalId.Key> externalIdsDeletedInTransaction, + AccountConfig accountConfig, + Optional<AccountDelta> delta) + throws IOException, ConfigInvalidException { + accountConfig.setAccountDelta(delta.get()); + updateExternalIdNotes( + repo, + accountConfig.getExternalIdsRev(), + accountId, + delta.get(), + ImmutableSet.of()); + CachedPreferences defaultPreferences = + CachedPreferences.fromLegacyConfig( + VersionedDefaultPreferences.get(repo, allUsersName)); + + return new UpdatedAccount(message, accountConfig, defaultPreferences, true); + } + + @Override + public Account.Id getAccountId() { + return accountId; + } })) .get(0) .get(); @@ -321,44 +355,79 @@ } private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) { - return repo -> { - AccountConfig accountConfig = read(repo, updateArguments.accountId); - CachedPreferences defaultPreferences = - CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName)); - Optional<AccountState> accountState = - AccountsNoteDbImpl.getFromAccountConfig(externalIds, accountConfig, defaultPreferences); - if (!accountState.isPresent()) { - return null; + return new ExecutableUpdate() { + @Override + public AccountConfig getAccountConfig(Repository repo) + throws ConfigInvalidException, IOException { + return read(repo, updateArguments.accountId); } - AccountDelta.Builder deltaBuilder = AccountDelta.builder(); - configureDelta(updateArguments.configureDelta, accountState.get(), deltaBuilder); + @Override + public Optional<AccountDelta> computeAccountDelta( + Repository repo, AccountConfig accountConfig) throws IOException, ConfigInvalidException { + CachedPreferences defaultPreferences = + CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName)); + Optional<AccountState> accountState = + AccountsNoteDbImpl.getFromAccountConfig(externalIds, accountConfig, defaultPreferences); + if (!accountState.isPresent()) { + return Optional.empty(); + } - AccountDelta delta = deltaBuilder.build(); - updateExternalIdNotes( - repo, accountConfig.getExternalIdsRev(), updateArguments.accountId, delta); + AccountDelta.Builder deltaBuilder = AccountDelta.builder(); + configureDelta(updateArguments.configureDelta, accountState.get(), deltaBuilder); - if (delta.getShouldDeleteAccount().orElse(false)) { - return new DeletedAccount(updateArguments.message, accountConfig.getRefName()); + return Optional.of(deltaBuilder.build()); } - accountConfig.setAccountDelta(delta); - CachedPreferences cachedDefaultPreferences = - CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName)); - return new UpdatedAccount( - updateArguments.message, accountConfig, cachedDefaultPreferences, false); + @Override + @Nullable + public UpdatedAccount execute( + Repository repo, + Set<ExternalId.Key> externalIdsDeletedInTransaction, + AccountConfig accountConfig, + Optional<AccountDelta> delta) + throws IOException, ConfigInvalidException { + if (delta.isEmpty()) { + return null; + } + updateExternalIdNotes( + repo, + accountConfig.getExternalIdsRev(), + updateArguments.accountId, + delta.get(), + externalIdsDeletedInTransaction); + + if (delta.get().getShouldDeleteAccount().orElse(false)) { + return new DeletedAccount(updateArguments.message, accountConfig.getRefName()); + } + + accountConfig.setAccountDelta(delta.get()); + CachedPreferences cachedDefaultPreferences = + CachedPreferences.fromLegacyConfig(VersionedDefaultPreferences.get(repo, allUsersName)); + return new UpdatedAccount( + updateArguments.message, accountConfig, cachedDefaultPreferences, false); + } + + @Override + public Account.Id getAccountId() { + return updateArguments.accountId; + } }; } private void updateExternalIdNotes( - Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update) + Repository allUsersRepo, + Optional<ObjectId> rev, + Account.Id accountId, + AccountDelta update, + Set<ExternalId.Key> externalIdsDeletedInTransaction) throws IOException, ConfigInvalidException { if (update.hasExternalIdUpdates()) { // Only load the externalIds if they are going to be updated // This makes e.g. preferences updates faster. - ExternalIdNotes.checkSameAccount( + checkSameAccount( Iterables.concat( update.getCreatedExternalIds(), update.getUpdatedExternalIds(), @@ -367,7 +436,11 @@ if (externalIdNotes == null) { externalIdNotes = extIdNotesFactory.load(allUsersRepo, rev.orElse(ObjectId.zeroId())); } - externalIdNotes.replace(update.getDeletedExternalIds(), update.getCreatedExternalIds()); + externalIdNotes.replace( + accountId, + update.getDeletedExternalIds(), + update.getCreatedExternalIds(), + externalIdsDeletedInTransaction); externalIdNotes.upsert(update.getUpdatedExternalIds()); } } @@ -399,9 +472,8 @@ accountState.clear(); updatedAccounts.clear(); try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) { - for (ExecutableUpdate executableUpdate : executableUpdates) { - updatedAccounts.add(executableUpdate.execute(allUsersRepo)); - } + ExecutableBatchUpdate batch = new ExecutableBatchUpdate(executableUpdates); + updatedAccounts.addAll(batch.executeAll(allUsersRepo)); commit( allUsersRepo, updatedAccounts.stream().filter(Objects::nonNull).collect(toList())); @@ -535,9 +607,77 @@ private static void doNothing() {} - @FunctionalInterface private interface ExecutableUpdate { - UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException; + AccountConfig getAccountConfig(Repository allUsersRepo) + throws ConfigInvalidException, IOException; + + Optional<AccountDelta> computeAccountDelta(Repository allUsersRepo, AccountConfig accountConfig) + throws IOException, ConfigInvalidException; + + @Nullable + UpdatedAccount execute( + Repository allUsersRepo, + Set<ExternalId.Key> externalIdsDeletedInTransaction, + AccountConfig accountConfig, + Optional<AccountDelta> delta) + throws IOException, ConfigInvalidException; + + Account.Id getAccountId(); + } + + private static class ExecutableBatchUpdate { + + // Note: ImmutableMap is guaranteed to preserve insersion order. + private final ImmutableMap<Account.Id, ExecutableUpdate> updatesPerAccount; + + ExecutableBatchUpdate(List<ExecutableUpdate> updates) { + updatesPerAccount = + updates.stream().collect(toImmutableMap(u -> u.getAccountId(), Function.identity())); + } + + List<UpdatedAccount> executeAll(Repository allUsersRepo) + throws IOException, ConfigInvalidException { + ImmutableMap.Builder<Account.Id, AccountConfig> configPerAccountBuilder = + ImmutableMap.builder(); + for (Map.Entry<Account.Id, ExecutableUpdate> e : updatesPerAccount.entrySet()) { + configPerAccountBuilder.put(e.getKey(), e.getValue().getAccountConfig(allUsersRepo)); + } + ImmutableMap<Account.Id, AccountConfig> configPerAccount = + configPerAccountBuilder.buildOrThrow(); + + ImmutableMap.Builder<Account.Id, Optional<AccountDelta>> deltaPerAccountBuilder = + ImmutableMap.builder(); + for (Map.Entry<Account.Id, ExecutableUpdate> e : updatesPerAccount.entrySet()) { + deltaPerAccountBuilder.put( + e.getKey(), + e.getValue().computeAccountDelta(allUsersRepo, configPerAccount.get(e.getKey()))); + } + ImmutableMap<Account.Id, Optional<AccountDelta>> deltaPerAccount = + deltaPerAccountBuilder.buildOrThrow(); + + HashSet<ExternalId.Key> externalIdsDeletedInTransaction = new HashSet<>(); + for (Optional<AccountDelta> delta : deltaPerAccount.values()) { + if (delta.isPresent()) { + externalIdsDeletedInTransaction.addAll( + delta.get().getDeletedExternalIds().stream() + .map(ExternalId::key) + .collect(toImmutableSet())); + } + } + + ArrayList<UpdatedAccount> res = new ArrayList<>(); + for (Map.Entry<Account.Id, ExecutableUpdate> e : updatesPerAccount.entrySet()) { + Account.Id accountId = e.getKey(); + res.add( + e.getValue() + .execute( + allUsersRepo, + externalIdsDeletedInTransaction, + configPerAccount.get(accountId), + deltaPerAccount.get(accountId))); + } + return res; + } } private class UpdatedAccount {
diff --git a/java/com/google/gerrit/server/account/storage/notedb/validators/AccountCommitValidator.java b/java/com/google/gerrit/server/account/storage/notedb/validators/AccountCommitValidator.java new file mode 100644 index 0000000..fe2f83b --- /dev/null +++ b/java/com/google/gerrit/server/account/storage/notedb/validators/AccountCommitValidator.java
@@ -0,0 +1,91 @@ +// 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.account.storage.notedb.validators; + +import static java.util.stream.Collectors.toList; + +import com.google.gerrit.entities.Account; +import com.google.gerrit.server.config.AllUsersName; +import com.google.gerrit.server.events.CommitReceivedEvent; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.git.validators.AccountValidator; +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.git.validators.ValidationMessage; +import com.google.gerrit.server.util.MagicBranch; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.eclipse.jgit.lib.Repository; + +/** + * Validates that pending account updates in NoteDb are valid according to {@link AccountValidator}. + */ +public class AccountCommitValidator implements CommitValidationListener { + + private final GitRepositoryManager repoManager; + private final AllUsersName allUsers; + private final AccountValidator accountValidator; + + @Inject + AccountCommitValidator( + GitRepositoryManager repoManager, AllUsersName allUsers, AccountValidator accountValidator) { + this.repoManager = repoManager; + this.allUsers = allUsers; + this.accountValidator = accountValidator; + } + + @Override + public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) + throws CommitValidationException { + if (!allUsers.equals(receiveEvent.project.getNameKey())) { + return Collections.emptyList(); + } + + if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) { + // no validation on push for review, will be checked on submit by + // MergeValidators.AccountMergeValidator + return Collections.emptyList(); + } + + Account.Id accountId = Account.Id.fromRef(receiveEvent.refName); + if (accountId == null) { + return Collections.emptyList(); + } + + try (Repository repo = repoManager.openRepository(allUsers)) { + List<String> errorMessages = + accountValidator.validate( + accountId, + repo, + receiveEvent.revWalk, + receiveEvent.command.getOldId(), + receiveEvent.commit); + if (!errorMessages.isEmpty()) { + throw new CommitValidationException( + "invalid account configuration", + errorMessages.stream() + .map(m -> new CommitValidationMessage(m, ValidationMessage.Type.ERROR)) + .collect(toList())); + } + } catch (IOException e) { + throw new CommitValidationException( + String.format("Validating update for account %s failed", accountId.get()), e); + } + return Collections.emptyList(); + } +}
diff --git a/java/com/google/gerrit/server/account/storage/notedb/validators/AccountMergeValidator.java b/java/com/google/gerrit/server/account/storage/notedb/validators/AccountMergeValidator.java new file mode 100644 index 0000000..f2883e6 --- /dev/null +++ b/java/com/google/gerrit/server/account/storage/notedb/validators/AccountMergeValidator.java
@@ -0,0 +1,92 @@ +// 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.account.storage.notedb.validators; + +import com.google.common.base.Joiner; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.BranchNameKey; +import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.account.AccountProperties; +import com.google.gerrit.server.config.AllUsersName; +import com.google.gerrit.server.git.CodeReviewCommit; +import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk; +import com.google.gerrit.server.git.validators.AccountValidator; +import com.google.gerrit.server.git.validators.MergeValidationException; +import com.google.gerrit.server.git.validators.MergeValidationListener; +import com.google.gerrit.server.project.ProjectState; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.List; +import org.eclipse.jgit.lib.Repository; + +public class AccountMergeValidator implements MergeValidationListener { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final AllUsersName allUsersName; + private final ChangeData.Factory changeDataFactory; + private final AccountValidator accountValidator; + + @Inject + public AccountMergeValidator( + AllUsersName allUsersName, + ChangeData.Factory changeDataFactory, + AccountValidator accountValidator) { + this.allUsersName = allUsersName; + this.changeDataFactory = changeDataFactory; + this.accountValidator = accountValidator; + } + + @Override + public void onPreMerge( + Repository repo, + CodeReviewRevWalk revWalk, + CodeReviewCommit commit, + ProjectState destProject, + BranchNameKey destBranch, + PatchSet.Id patchSetId, + IdentifiedUser caller) + throws MergeValidationException { + Account.Id accountId = Account.Id.fromRef(destBranch.branch()); + if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) { + return; + } + + ChangeData cd = + changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId()); + try { + if (!cd.currentFilePaths().contains(AccountProperties.ACCOUNT_CONFIG)) { + return; + } + } catch (StorageException e) { + logger.atSevere().withCause(e).log("Cannot validate account update"); + throw new MergeValidationException("account validation unavailable", e); + } + + try { + List<String> errorMessages = + accountValidator.validate(accountId, repo, revWalk, null, commit); + if (!errorMessages.isEmpty()) { + throw new MergeValidationException( + "invalid account configuration: " + Joiner.on("; ").join(errorMessages)); + } + } catch (IOException e) { + logger.atSevere().withCause(e).log("Cannot validate account update"); + throw new MergeValidationException("account validation unavailable", e); + } + } +}
diff --git a/java/com/google/gerrit/server/account/storage/notedb/validators/ExternalIdUpdateValidator.java b/java/com/google/gerrit/server/account/storage/notedb/validators/ExternalIdUpdateValidator.java new file mode 100644 index 0000000..f4d14b6 --- /dev/null +++ b/java/com/google/gerrit/server/account/storage/notedb/validators/ExternalIdUpdateValidator.java
@@ -0,0 +1,79 @@ +// 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.account.storage.notedb.validators; + +import static java.util.stream.Collectors.toList; + +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo; +import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker; +import com.google.gerrit.server.config.AllUsersName; +import com.google.gerrit.server.events.CommitReceivedEvent; +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.git.validators.ValidationMessage; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.eclipse.jgit.errors.ConfigInvalidException; + +/** Validates updates to refs/meta/external-ids. */ +public class ExternalIdUpdateValidator implements CommitValidationListener { + private final AllUsersName allUsers; + private final AccountCache accountCache; + private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; + + @Inject + ExternalIdUpdateValidator( + AllUsersName allUsers, + ExternalIdsConsistencyChecker externalIdsConsistencyChecker, + AccountCache accountCache) { + this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; + this.allUsers = allUsers; + this.accountCache = accountCache; + } + + @Override + public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) + throws CommitValidationException { + if (allUsers.equals(receiveEvent.project.getNameKey()) + && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) { + try { + List<ConsistencyProblemInfo> problems = + externalIdsConsistencyChecker.check(accountCache, receiveEvent.commit); + List<CommitValidationMessage> msgs = + problems.stream() + .map( + p -> + new CommitValidationMessage( + p.message, + p.status == ConsistencyProblemInfo.Status.ERROR + ? ValidationMessage.Type.ERROR + : ValidationMessage.Type.OTHER)) + .collect(toList()); + if (msgs.stream().anyMatch(ValidationMessage::isError)) { + throw new CommitValidationException("invalid external IDs", msgs); + } + return msgs; + } catch (IOException | ConfigInvalidException e) { + throw new CommitValidationException("error validating external IDs", e); + } + } + return Collections.emptyList(); + } +}
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java index 38510e3..c84bde2 100644 --- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java +++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -57,6 +57,7 @@ import com.google.gerrit.extensions.common.TestSubmitRuleInput; import com.google.gerrit.extensions.restapi.BinaryResult; import com.google.gerrit.extensions.restapi.IdString; +import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.RestModifyView; import com.google.gerrit.server.account.AccountDirectory.FillOptions; @@ -108,7 +109,7 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevWalk; -class RevisionApiImpl extends RevisionApi.NotImplemented { +class RevisionApiImpl implements RevisionApi { interface Factory { RevisionApiImpl create(RevisionResource r); } @@ -719,4 +720,9 @@ throw asRestApiException("Cannot get archive", e); } } + + @Override + public String etag() throws RestApiException { + throw new NotImplementedException(); + } }
diff --git a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java index db906a9..c7a2c69 100644 --- a/java/com/google/gerrit/server/api/groups/GroupApiImpl.java +++ b/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -19,6 +19,7 @@ import com.google.gerrit.extensions.api.groups.GroupApi; import com.google.gerrit.extensions.api.groups.OwnerInput; import com.google.gerrit.extensions.common.AccountInfo; +import com.google.gerrit.extensions.common.DeleteGroupInput; import com.google.gerrit.extensions.common.DescriptionInput; import com.google.gerrit.extensions.common.GroupAuditEventInfo; import com.google.gerrit.extensions.common.GroupInfo; @@ -29,6 +30,7 @@ import com.google.gerrit.server.group.GroupResource; import com.google.gerrit.server.restapi.group.AddMembers; import com.google.gerrit.server.restapi.group.AddSubgroups; +import com.google.gerrit.server.restapi.group.DeleteGroup; import com.google.gerrit.server.restapi.group.DeleteMembers; import com.google.gerrit.server.restapi.group.DeleteSubgroups; import com.google.gerrit.server.restapi.group.GetAuditLog; @@ -58,6 +60,7 @@ private final GetDetail getDetail; private final GetName getName; private final PutName putName; + private final DeleteGroup deleteGroup; private final GetOwner getOwner; private final PutOwner putOwner; private final GetDescription getDescription; @@ -80,6 +83,7 @@ GetDetail getDetail, GetName getName, PutName putName, + DeleteGroup deleteGroup, GetOwner getOwner, PutOwner putOwner, GetDescription getDescription, @@ -99,6 +103,7 @@ this.getDetail = getDetail; this.getName = getName; this.putName = putName; + this.deleteGroup = deleteGroup; this.getOwner = getOwner; this.putOwner = putOwner; this.getDescription = getDescription; @@ -156,6 +161,17 @@ } @Override + public void delete() throws RestApiException { + DeleteGroupInput in = new DeleteGroupInput(); + try { + @SuppressWarnings("unused") + var unused = deleteGroup.apply(rsrc, in); + } catch (Exception e) { + throw asRestApiException("Cannot delete group", e); + } + } + + @Override public GroupInfo owner() throws RestApiException { try { return getOwner.apply(rsrc).value();
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java index 9bb3bc9..ba1733e 100644 --- a/java/com/google/gerrit/server/approval/ApprovalCopier.java +++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -19,11 +19,13 @@ import com.google.auto.value.AutoValue; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; import com.google.common.collect.HashBasedTable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Table; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.LabelType; import com.google.gerrit.entities.LabelTypes; @@ -37,6 +39,9 @@ import com.google.gerrit.server.PatchSetUtil; import com.google.gerrit.server.change.ChangeKindCache; import com.google.gerrit.server.change.LabelNormalizer; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.experiments.ExperimentFeatures; +import com.google.gerrit.server.experiments.ExperimentFeaturesConstants; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.TraceContext; @@ -46,6 +51,7 @@ import com.google.gerrit.server.project.ProjectState; import com.google.gerrit.server.query.approval.ApprovalContext; import com.google.gerrit.server.query.approval.ApprovalQueryBuilder; +import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.update.RepoView; import com.google.gerrit.server.util.LabelVote; import com.google.gerrit.server.util.ManualRequestContext; @@ -53,8 +59,12 @@ import com.google.inject.Inject; import com.google.inject.Singleton; import java.io.IOException; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Optional; +import java.util.Set; +import org.eclipse.jgit.attributes.AttributesNodeProvider; +import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectInserter; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.Repository; @@ -123,56 +133,111 @@ /** The approval. */ public abstract PatchSetApproval patchSetApproval(); - /** - * Lists the leaf predicates of the copy condition that are fulfilled. - * - * <p>Example: The expression - * - * <pre> - * changekind:TRIVIAL_REBASE OR is:MIN - * </pre> - * - * has two leaf predicates: - * - * <ul> - * <li>changekind:TRIVIAL_REBASE - * <li>is:MIN - * </ul> - * - * This method will return the leaf predicates that are fulfilled, for example if only the - * first predicate is fulfilled, the returned list will be equal to - * ["changekind:TRIVIAL_REBASE"]. - * - * <p>Empty if the label type is missing, if there is no copy condition or if the copy - * condition is not parseable. - */ - public abstract ImmutableSet<String> passingAtoms(); - - /** - * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link - * #passingAtoms()} for more details. - * - * <p>Empty if the label type is missing, if there is no copy condition or if the copy - * condition is not parseable. - */ - public abstract ImmutableSet<String> failingAtoms(); + /** Details about the evaluation of approval copy condition. */ + public abstract ApprovalCopyResult approvalCopyResult(); @VisibleForTesting public static PatchSetApprovalData create( - PatchSetApproval approval, - ImmutableSet<String> passingAtoms, - ImmutableSet<String> failingAtoms) { - return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData( - approval, passingAtoms, failingAtoms); + PatchSetApproval approval, ApprovalCopyResult copyResult) { + return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(approval, copyResult); } private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) { return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData( - approval, ImmutableSet.of(), ImmutableSet.of()); + approval, ApprovalCopyResult.createEvaluationSkipped()); } } } + /** Result for checking if an approval can be copied to the next patch set. */ + @AutoValue + public abstract static class ApprovalCopyResult { + /** Whether the approval can be copied to the next patch set. */ + public abstract boolean canCopy(); + + /** Label's copyCondition */ + public abstract @Nullable String labelCopyCondition(); + + /** Whether the approval can be copied to the next patch set based on label's copyCondition. */ + public abstract boolean labelCopy(); + + /** Condition that forces copy based on server configuration */ + public abstract @Nullable String copyEnforcement(); + + /** + * Whether the approval must be copied to the next patch set based on servers copyEnforcement. + */ + public abstract boolean forcedCopy(); + + /** Condition that forces copy not to be made based on server configuration */ + public abstract @Nullable String copyRestriction(); + + /** + * Whether the approval must be not be copied to the next patch set based on servers + * copyRestriction. + */ + public abstract boolean forcedNonCopy(); + + /** + * Lists the leaf predicates of the copy condition that are fulfilled. + * + * <p>Example: The expression + * + * <pre> + * changekind:TRIVIAL_REBASE OR is:MIN + * </pre> + * + * has two leaf predicates: + * + * <ul> + * <li>changekind:TRIVIAL_REBASE + * <li>is:MIN + * </ul> + * + * This method will return the leaf predicates that are fulfilled, for example if only the first + * predicate is fulfilled, the returned list will be equal to ["changekind:TRIVIAL_REBASE"]. + * + * <p>Empty if the label type is missing, if there is no copy condition or if the copy condition + * is not parseable. + */ + public abstract ImmutableSet<String> passingAtoms(); + + /** + * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link + * #passingAtoms()} for more details. + * + * <p>Empty if the label type is missing, if there is no copy condition or if the copy condition + * is not parseable. + */ + public abstract ImmutableSet<String> failingAtoms(); + + public static ApprovalCopyResult create( + @Nullable String labelCopyCondition, + boolean labelCopy, + @Nullable String copyEnforcement, + boolean forcedCopy, + @Nullable String copyRestriction, + boolean forcedNonCopy, + Set<String> passingAtoms, + Set<String> failingAtoms) { + return new AutoValue_ApprovalCopier_ApprovalCopyResult( + forcedCopy || (labelCopy && !forcedNonCopy), + labelCopyCondition, + labelCopy, + copyEnforcement, + forcedCopy, + copyRestriction, + forcedNonCopy, + ImmutableSet.copyOf(passingAtoms), + ImmutableSet.copyOf(failingAtoms)); + } + + public static ApprovalCopyResult createEvaluationSkipped() { + return new AutoValue_ApprovalCopier_ApprovalCopyResult( + false, null, false, null, false, null, false, ImmutableSet.of(), ImmutableSet.of()); + } + } + private final GitRepositoryManager repoManager; private final ProjectCache projectCache; private final ChangeKindCache changeKindCache; @@ -180,6 +245,9 @@ private final LabelNormalizer labelNormalizer; private final ApprovalQueryBuilder approvalQueryBuilder; private final OneOffRequestContext requestContext; + private final ChangeData.Factory changeDataFactory; + private final Config cfg; + private final ExperimentFeatures experimentFeatures; @Inject ApprovalCopier( @@ -189,7 +257,10 @@ PatchSetUtil psUtil, LabelNormalizer labelNormalizer, ApprovalQueryBuilder approvalQueryBuilder, - OneOffRequestContext requestContext) { + OneOffRequestContext requestContext, + ChangeData.Factory changeDataFactory, + @GerritServerConfig Config cfg, + ExperimentFeatures experimentFeatures) { this.repoManager = repoManager; this.projectCache = projectCache; this.changeKindCache = changeKindCache; @@ -197,6 +268,9 @@ this.labelNormalizer = labelNormalizer; this.approvalQueryBuilder = approvalQueryBuilder; this.requestContext = requestContext; + this.changeDataFactory = changeDataFactory; + this.cfg = cfg; + this.experimentFeatures = experimentFeatures; } /** @@ -280,6 +354,7 @@ // Iterate over the follow-up patch sets in order to copy the approval from their prior patch // set if possible (copy from PS N-1 to PS N). + AttributesNodeProvider attributesNodeProvider = repo.createAttributesNodeProvider(); for (PatchSet.Id followUpPatchSetId : followUpPatchSets) { PatchSet followUpPatchSet = psUtil.get(changeNotes, followUpPatchSetId); ChangeKind changeKind = @@ -287,6 +362,7 @@ changeNotes.getProjectName(), revWalk, repo.getConfig(), + attributesNodeProvider, priorPatchSet.commitId(), followUpPatchSet.commitId()); boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet); @@ -332,12 +408,23 @@ ChangeKind changeKind, boolean isMerge, RepoView repoView) { - if (!labelType.getCopyCondition().isPresent()) { - return ApprovalCopyResult.createForMissingCopyCondition(); + String forcedCopyCondition = null; + String forcedNonCopyCondition = null; + if (experimentFeatures.isFeatureEnabled( + ExperimentFeaturesConstants.ENABLE_CENTRAL_OVERRIDE_FOR_CODE_REVIEW_COPY_CONDITION, + changeNotes.getProjectName())) { + forcedCopyCondition = cfg.getString("label", labelType.getName(), "labelCopyEnforcement"); + forcedNonCopyCondition = cfg.getString("label", labelType.getName(), "labelCopyRestriction"); + } + String labelCopyCondition = labelType.getCopyCondition().orElse(null); + if (Strings.isNullOrEmpty(forcedCopyCondition) + && Strings.isNullOrEmpty(forcedNonCopyCondition) + && Strings.isNullOrEmpty(labelCopyCondition)) { + return ApprovalCopyResult.createEvaluationSkipped(); } ApprovalContext ctx = ApprovalContext.create( - changeNotes, + changeDataFactory.create(changeNotes), sourcePatchSetId, approverId, labelType, @@ -346,39 +433,69 @@ changeKind, isMerge, repoView); + // Use a request context to run checks as an internal user with expanded visibility. This is + // so that the output of the copy condition does not depend on who is running the current + // request (e.g. a group used in this query might not be visible to the person sending this + // request). + try (ManualRequestContext ignored = requestContext.open()) { + LinkedHashSet<String> passingAtoms = new LinkedHashSet<>(); + LinkedHashSet<String> failingAtoms = new LinkedHashSet<>(); + boolean labelCopy = evaluateCondition(labelCopyCondition, ctx, passingAtoms, failingAtoms); + boolean forcedCopy = evaluateCondition(forcedCopyCondition, ctx, passingAtoms, failingAtoms); + boolean forcedNonCopy = + evaluateCondition(forcedNonCopyCondition, ctx, passingAtoms, failingAtoms); + ApprovalCopyResult result = + ApprovalCopyResult.create( + labelCopyCondition, + labelCopy, + forcedCopyCondition, + forcedCopy, + forcedNonCopyCondition, + forcedNonCopy, + passingAtoms, + failingAtoms); + logger.atFine().log( + "%s copy %s of account %d on change %d from patch set %d to patch set %d" + + " (%s%s%spassingAtoms = %s, failingAtoms = %s, changeKind = %s)", + result.canCopy() ? "Can" : "Cannot", + LabelVote.create(labelType.getName(), approvalValue).format(), + approverId.get(), + changeNotes.getChangeId().get(), + sourcePatchSetId.get(), + targetPatchSet.id().get(), + !Strings.isNullOrEmpty(labelCopyCondition) + ? String.format("copyCondition = %s, ", labelCopyCondition) + : "", + !Strings.isNullOrEmpty(forcedCopyCondition) + ? String.format("copyEnforcement = %s, ", forcedCopyCondition) + : "", + !Strings.isNullOrEmpty(forcedNonCopyCondition) + ? String.format("copyRestriction = %s, ", forcedNonCopyCondition) + : "", + passingAtoms, + failingAtoms, + changeKind.name()); + return result; + } + } + + private boolean evaluateCondition( + @Nullable String copyCondition, + ApprovalContext ctx, + LinkedHashSet<String> passingAtoms, + LinkedHashSet<String> failingAtoms) { + if (Strings.isNullOrEmpty(copyCondition)) { + return false; + } try { - // Use a request context to run checks as an internal user with expanded visibility. This is - // so that the output of the copy condition does not depend on who is running the current - // request (e.g. a group used in this query might not be visible to the person sending this - // request). - try (ManualRequestContext ignored = requestContext.open()) { - Predicate<ApprovalContext> copyConditionPredicate = - approvalQueryBuilder.parse(labelType.getCopyCondition().get()); - boolean canCopy = copyConditionPredicate.asMatchable().match(ctx); - ImmutableSet.Builder<String> passingAtomsBuilder = ImmutableSet.builder(); - ImmutableSet.Builder<String> failingAtomsBuilder = ImmutableSet.builder(); - evaluateAtoms(copyConditionPredicate, ctx, passingAtomsBuilder, failingAtomsBuilder); - ImmutableSet<String> passingAtoms = passingAtomsBuilder.build(); - ImmutableSet<String> failingAtoms = failingAtomsBuilder.build(); - logger.atFine().log( - "%s copy %s of account %d on change %d from patch set %d to patch set %d" - + " (copyCondition = %s, passingAtoms = %s, failingAtoms = %s, changeKind = %s)", - canCopy ? "Can" : "Cannot", - LabelVote.create(labelType.getName(), approvalValue).format(), - approverId.get(), - changeNotes.getChangeId().get(), - sourcePatchSetId.get(), - targetPatchSet.id().get(), - labelType.getCopyCondition().get(), - passingAtoms, - failingAtoms, - changeKind.name()); - return ApprovalCopyResult.create(canCopy, passingAtoms, failingAtoms); - } + Predicate<ApprovalContext> copyConditionPredicate = approvalQueryBuilder.parse(copyCondition); + boolean result = copyConditionPredicate.asMatchable().match(ctx); + evaluateAtoms(copyConditionPredicate, ctx, passingAtoms, failingAtoms); + return result; } catch (QueryParseException e) { logger.atWarning().withCause(e).log( "Unable to copy label because config is invalid. This should have been caught before."); - return ApprovalCopyResult.createForNonParseableCopyCondition(); + return false; } } @@ -419,6 +536,7 @@ projectName, repoView.getRevWalk(), repoView.getConfig(), + repoView.getAttributesNodeProvider(), priorPatchSet.getValue().commitId(), targetPatchSet.commitId()); boolean isMerge = isMerge(projectName, repoView.getRevWalk(), targetPatchSet); @@ -481,14 +599,11 @@ priorPsa.label(), priorPsa.accountId(), Result.PatchSetApprovalData.create( - copiedApprovalNormalized.get(), - approvalCopyResult.passingAtoms(), - approvalCopyResult.failingAtoms())); + copiedApprovalNormalized.get(), approvalCopyResult)); } } else { outdatedApprovalsBuilder.add( - Result.PatchSetApprovalData.create( - priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms())); + Result.PatchSetApprovalData.create(priorPsa, approvalCopyResult)); continue; } } @@ -521,11 +636,15 @@ private static void evaluateAtoms( Predicate<ApprovalContext> predicate, ApprovalContext approvalContext, - ImmutableSet.Builder<String> passingAtoms, - ImmutableSet.Builder<String> failingAtoms) { + LinkedHashSet<String> passingAtoms, + LinkedHashSet<String> failingAtoms) { if (predicate.isLeaf()) { + String predicateString = predicate.getPredicateString(); + if (passingAtoms.contains(predicateString) || failingAtoms.contains(predicateString)) { + return; + } boolean isPassing = predicate.asMatchable().match(approvalContext); - (isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString()); + (isPassing ? passingAtoms : failingAtoms).add(predicateString); return; } predicate @@ -534,46 +653,4 @@ childPredicate -> evaluateAtoms(childPredicate, approvalContext, passingAtoms, failingAtoms)); } - - /** Result for checking if an approval can be copied to the next patch set. */ - @AutoValue - abstract static class ApprovalCopyResult { - /** Whether the approval can be copied to the next patch set. */ - abstract boolean canCopy(); - - /** - * Lists the leaf predicates of the copy condition that are fulfilled. See {@link - * Result.PatchSetApprovalData#passingAtoms()} for more details. - * - * <p>Empty if there is no copy condition or if the copy condition is not parseable. - */ - abstract ImmutableSet<String> passingAtoms(); - - /** - * Lists the leaf predicates of the copy condition that are not fulfilled. See {@link - * Result.PatchSetApprovalData#passingAtoms()} for more details. - * - * <p>Empty if there is no copy condition or if the copy condition is not parseable. - */ - abstract ImmutableSet<String> failingAtoms(); - - private static ApprovalCopyResult create( - boolean canCopy, ImmutableSet<String> passingAtoms, ImmutableSet<String> failingAtoms) { - return new AutoValue_ApprovalCopier_ApprovalCopyResult(canCopy, passingAtoms, failingAtoms); - } - - private static ApprovalCopyResult createForMissingCopyCondition() { - return new AutoValue_ApprovalCopier_ApprovalCopyResult( - /* canCopy= */ false, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()); - } - - private static ApprovalCopyResult createForNonParseableCopyCondition() { - return new AutoValue_ApprovalCopier_ApprovalCopyResult( - /* canCopy= */ false, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()); - } - } }
diff --git a/java/com/google/gerrit/server/approval/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java index 04683e8..59959ed 100644 --- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java +++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -28,6 +28,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Function; +import com.google.common.base.Strings; import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; @@ -37,6 +38,7 @@ import com.google.common.collect.Lists; import com.google.common.collect.Multimaps; import com.google.common.collect.Sets; +import com.google.common.collect.Streams; import com.google.common.flogger.FluentLogger; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.Nullable; @@ -58,6 +60,7 @@ import com.google.gerrit.server.ReviewerSet; import com.google.gerrit.server.ReviewerStatusUpdate; import com.google.gerrit.server.account.AccountCache; +import com.google.gerrit.server.approval.ApprovalCopier.ApprovalCopyResult; import com.google.gerrit.server.change.LabelNormalizer; import com.google.gerrit.server.config.AnonymousCowardName; import com.google.gerrit.server.notedb.ChangeNotes; @@ -458,10 +461,8 @@ changeUpdate.addToPlannedAttentionSetUpdates(updates); } - public Optional<String> formatApprovalCopierResult( - ApprovalCopier.Result approvalCopierResult, LabelTypes labelTypes) { + public Optional<String> formatApprovalCopierResult(ApprovalCopier.Result approvalCopierResult) { requireNonNull(approvalCopierResult, "approvalCopierResult"); - requireNonNull(labelTypes, "labelTypes"); if (approvalCopierResult.copiedApprovals().isEmpty() && approvalCopierResult.outdatedApprovals().isEmpty()) { @@ -472,17 +473,27 @@ if (!approvalCopierResult.copiedApprovals().isEmpty()) { message.append("Copied Votes:\n"); - message.append( - formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals(), labelTypes)); + message.append(formatApprovalListWithCopyCondition(approvalCopierResult.copiedApprovals())); } if (!approvalCopierResult.outdatedApprovals().isEmpty()) { if (!approvalCopierResult.copiedApprovals().isEmpty()) { message.append("\n"); } message.append("Outdated Votes:\n"); + message.append(formatApprovalListWithCopyCondition(approvalCopierResult.outdatedApprovals())); + } + + if (Streams.concat( + approvalCopierResult.copiedApprovals().stream(), + approvalCopierResult.outdatedApprovals().stream()) + .anyMatch( + a -> + !Strings.isNullOrEmpty(a.approvalCopyResult().copyEnforcement()) + || !Strings.isNullOrEmpty(a.approvalCopyResult().copyRestriction()))) { message.append( - formatApprovalListWithCopyCondition( - approvalCopierResult.outdatedApprovals(), labelTypes)); + "\n" + + "\\* The label has `labelCopyEnforcement` or `labelCopyRestriction` configured." + + " Only the most relevant condition that determined the outcome is shown.\n"); } return Optional.of(message.toString()); @@ -511,9 +522,6 @@ * (copy condition: "approverin:7d9e2d5b561e75230e4463ae757ac5d6ff715d85")} * <li>{@code <comma-separated-list-of-approval-for-the-same-label>} (if no copy condition is * present), e.g.: {@code Code-Review+1, Code-Review+2} - * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (label type is missing)} (if - * the label type is missing), e.g.: {@code Code-Review+1, Code-Review+2 (label type is - * missing)} * <li>{@code <comma-separated-list-of-approval-for-the-same-label> (non-parseable copy * condition: "<non-parseable copy-condition>")} (if a non-parseable copy condition is * present), e.g.: {@code Code-Review+1, Code-Review+2 (non-parseable copy condition: @@ -521,12 +529,10 @@ * </ul> * * @param approvalDatas the approvals that should be formatted, with approval meta data - * @param labelTypes the label types * @return bullet list with the formatted approvals */ private String formatApprovalListWithCopyCondition( - ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas, - LabelTypes labelTypes) { + ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas) { StringBuilder message = new StringBuilder(); // sort approvals by label vote so that we list them in a deterministic order @@ -547,35 +553,16 @@ for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>> approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) { - String label = approvalsByLabelEntry.getKey(); Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel = approvalsByLabelEntry.getValue(); - if (!labelTypes.byLabel(label).isPresent()) { - message - .append("* ") - .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)) - .append(" (label type is missing)\n"); - continue; - } - - LabelType labelType = labelTypes.byLabel(label).get(); - if (!labelType.getCopyCondition().isPresent()) { - message - .append("* ") - .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)) - .append("\n"); - continue; - } - // Group the approvals that have the same label by the passing atoms. If approvals have the // same label, but have different passing atoms, we need to list them in separate lines // (because in each line we will highlight different passing atoms that matched). Approvals // with the same label and the same passing atoms are formatted as a single line. ImmutableListMultimap<ImmutableSet<String>, ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabelByPassingAndFailingAtoms = - Multimaps.index( - approvalsForSameLabel, ApprovalCopier.Result.PatchSetApprovalData::passingAtoms); + Multimaps.index(approvalsForSameLabel, a -> a.approvalCopyResult().passingAtoms()); // Approvals with the same label that have the same passing atoms should have the same failing // atoms (since the label is the same they have the same copy condition). @@ -587,7 +574,7 @@ checkThatPropertyIsTheSameForAllApprovals( approvalsForSameLabelAndSamePassingAtoms, "failing atoms", - approvalData -> approvalData.failingAtoms())); + approvalData -> approvalData.approvalCopyResult().failingAtoms())); // The order in which we add lines for approvals with the same label but different passing // atoms needs to be deterministic for tests. Just sort them by the string representation of @@ -607,8 +594,7 @@ .append("* ") .append( formatApprovalsWithCopyCondition( - approvalsForSameLabelWithSamePassingAndFailingAtoms, - labelType.getCopyCondition().get())) + approvalsForSameLabelWithSamePassingAndFailingAtoms)) .append("\n"); } } @@ -641,13 +627,11 @@ * * @param approvalsWithSameLabelAndSamePassingAndFailingAtoms the approvals that should be * formatted, must be for the same label - * @param copyCondition the copy condition of the label * @return the formatted approvals */ private String formatApprovalsWithCopyCondition( Collection<ApprovalCopier.Result.PatchSetApprovalData> - approvalsWithSameLabelAndSamePassingAndFailingAtoms, - String copyCondition) { + approvalsWithSameLabelAndSamePassingAndFailingAtoms) { // Check that all given approvals have the same label and the same passing and failing atoms. checkThatPropertyIsTheSameForAllApprovals( approvalsWithSameLabelAndSamePassingAndFailingAtoms, @@ -656,11 +640,36 @@ checkThatPropertyIsTheSameForAllApprovals( approvalsWithSameLabelAndSamePassingAndFailingAtoms, "passing atoms", - approvalData -> approvalData.passingAtoms()); + approvalData -> approvalData.approvalCopyResult().passingAtoms()); checkThatPropertyIsTheSameForAllApprovals( approvalsWithSameLabelAndSamePassingAndFailingAtoms, "failing atoms", - approvalData -> approvalData.failingAtoms()); + approvalData -> approvalData.approvalCopyResult().failingAtoms()); + + ApprovalCopyResult copyResult = + !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty() + ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream() + .findFirst() + .get() + .approvalCopyResult() + : ApprovalCopyResult.createEvaluationSkipped(); + // In order to keep the message concise and understandable only show most relevant + // copy condition and add an asterisk to show if forced conditions are present. + String copyConditionName = ""; + String copyCondition = ""; + boolean showAsterisk = + !Strings.isNullOrEmpty(copyResult.copyEnforcement()) + || !Strings.isNullOrEmpty(copyResult.copyRestriction()); + if (copyResult.canCopy() == copyResult.labelCopy()) { + copyCondition = copyResult.labelCopyCondition(); + copyConditionName = "copy condition"; + } else if (!Strings.isNullOrEmpty(copyResult.copyEnforcement()) && copyResult.forcedCopy()) { + copyCondition = copyResult.copyEnforcement(); + copyConditionName = "forced copy condition"; + } else if (!Strings.isNullOrEmpty(copyResult.copyRestriction()) && copyResult.forcedNonCopy()) { + copyCondition = copyResult.copyRestriction(); + copyConditionName = "forced copy restriction"; + } StringBuilder message = new StringBuilder(); @@ -671,7 +680,10 @@ logger.atWarning().withCause(e).log("Non-parsable query condition"); message.append( formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms)); - message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition)); + message.append( + String.format( + " (non-parseable %s%s: \"%s\")", + copyConditionName, showAsterisk ? "\\*" : "", copyCondition)); return message.toString(); } @@ -739,14 +751,8 @@ message.append( formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms)); } - ImmutableSet<String> passingAtoms = - !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty() - ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms() - : ImmutableSet.of(); message.append( - String.format( - " (copy condition: \"%s\")", - formatCopyConditionAsMarkdown(copyCondition, passingAtoms))); + formatApprovalCopyResult(copyResult, copyConditionName, copyCondition, showAsterisk)); return message.toString(); } @@ -781,6 +787,9 @@ */ private String formatCopyConditionAsMarkdown( String copyCondition, ImmutableSet<String> passingAtoms) { + if (Strings.isNullOrEmpty(copyCondition)) { + return "NEVER"; + } StringBuilder formattedCopyCondition = new StringBuilder(); StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true); while (tokenizer.hasMoreTokens()) { @@ -794,7 +803,33 @@ return formattedCopyCondition.toString(); } + /** + * Formats the given copy condition as a Markdown string. + * + * <p>Passing atoms are formatted as bold. + * + * @param approvalCopyResult evaluation information for the copyCondition + * @param copyConditionName name by which to refer to the copy condition string + * @param copyCondition expression that was evaluated + * @param showAsterisk if asterisk referring to forced copy conditions should be shown + * @return the formatted copy condition as a Markdown string + */ + private String formatApprovalCopyResult( + ApprovalCopyResult approvalCopyResult, + String copyConditionName, + String copyCondition, + boolean showAsterisk) { + return String.format( + " (%s%s: \"%s\")", + copyConditionName, + showAsterisk ? "\\*" : "", + formatCopyConditionAsMarkdown(copyCondition, approvalCopyResult.passingAtoms())); + } + private boolean containsUserInPredicate(String copyCondition) throws QueryParseException { + if (Strings.isNullOrEmpty(copyCondition)) { + return false; + } // Use a request context to run checks as an internal user with expanded visibility. This is // so that the output of the copy condition does not depend on who is running the current // request (e.g. a group used in this query might not be visible to the person sending this
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java index 728830c..e3a8b06 100644 --- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java +++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -21,6 +21,7 @@ import com.google.gerrit.lifecycle.LifecycleModule; import com.google.gerrit.server.config.ChangeCleanupConfig; import com.google.gerrit.server.git.WorkQueue; +import com.google.gerrit.server.project.LockManager; import com.google.gerrit.server.update.RetryHelper; import com.google.gerrit.server.update.UpdateException; import com.google.gerrit.server.util.ManualRequestContext; @@ -28,6 +29,7 @@ import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; +import java.util.concurrent.locks.Lock; /** Runnable to enable scheduling change cleanups to run periodically */ public class ChangeCleanupRunner implements Runnable { @@ -73,6 +75,7 @@ private final OneOffRequestContext oneOffRequestContext; private final AbandonUtil abandonUtil; private final RetryHelper retryHelper; + private final LockManager lockManager; private final long abandonAfterMillis; private final boolean abandonIfMergeable; @Nullable private final String message; @@ -82,12 +85,14 @@ OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper, + LockManager lockManager, @Assisted long abandonAfterMillis, @Assisted boolean abandonIfMergeable, @Assisted @Nullable String message) { this.oneOffRequestContext = oneOffRequestContext; this.abandonUtil = abandonUtil; this.retryHelper = retryHelper; + this.lockManager = lockManager; this.abandonAfterMillis = abandonAfterMillis; this.abandonIfMergeable = abandonIfMergeable; this.message = message; @@ -98,10 +103,12 @@ OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper, + LockManager lockManager, ChangeCleanupConfig cfg) { this.oneOffRequestContext = oneOffRequestContext; this.abandonUtil = abandonUtil; this.retryHelper = retryHelper; + this.lockManager = lockManager; this.abandonAfterMillis = cfg.getAbandonAfter(); this.abandonIfMergeable = cfg.getAbandonIfMergeable(); this.message = cfg.getAbandonMessage(); @@ -109,6 +116,14 @@ @Override public void run() { + Lock lock = lockManager.getLock("change-cleanup"); + if (!lock.tryLock()) { + logger.atInfo().log( + "Couldn't acquire change-cleanup lock. Assuming another server is running" + + " change-cleanup"); + return; + } + logger.atInfo().log("Running change cleanups."); try (ManualRequestContext ctx = oneOffRequestContext.open()) { // abandonInactiveOpenChanges skips failures instead of throwing, so retrying will never @@ -127,6 +142,8 @@ .call(); } catch (RestApiException | UpdateException e) { logger.atSevere().withCause(e).log("Failed to cleanup changes."); + } finally { + lock.unlock(); } }
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java index ef36bd4..366bf1e 100644 --- a/java/com/google/gerrit/server/change/ChangeInserter.java +++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -53,6 +53,7 @@ import com.google.gerrit.server.ChangeMessagesUtil; import com.google.gerrit.server.ChangeUtil; import com.google.gerrit.server.PatchSetUtil; +import com.google.gerrit.server.ValidationOptionsListener; import com.google.gerrit.server.approval.ApprovalsUtil; import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput; import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification; @@ -63,6 +64,8 @@ import com.google.gerrit.server.extensions.events.RevisionCreated; import com.google.gerrit.server.git.GroupCollector; import com.google.gerrit.server.git.validators.CommitValidationException; +import com.google.gerrit.server.git.validators.CommitValidationInfo; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.CommitValidators; import com.google.gerrit.server.git.validators.TopicValidator; import com.google.gerrit.server.mail.EmailFactories; @@ -76,6 +79,7 @@ import com.google.gerrit.server.patch.PatchSetInfoFactory; import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.permissions.PermissionBackendException; +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.ssh.NoSshInfo; @@ -127,6 +131,8 @@ private final AutoMerger autoMerger; private final ChangeUtil changeUtil; private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory; + private final PluginSetContext<ValidationOptionsListener> validationOptionsListeners; + private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners; private final Change.Id changeId; private final PatchSet.Id psId; @@ -137,6 +143,7 @@ private PatchSet.Id cherryPickOf; private Change.Status status; private String topic; + private PatchSet.Conflicts conflicts; private String message; private String patchSetDescription; private boolean isPrivate; @@ -145,6 +152,7 @@ private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of(); private ImmutableMap<String, String> customKeyedValues = ImmutableMap.of(); private boolean validate = true; + private ImmutableMap<String, CommitValidationInfo> validationInfos; private Map<String, Short> approvals; private RequestScopePropagator requestScopePropagator; private boolean fireRevisionCreated; @@ -182,6 +190,8 @@ AutoMerger autoMerger, ChangeUtil changeUtil, DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory, + PluginSetContext<ValidationOptionsListener> validationOptionsListeners, + PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners, @Assisted Change.Id changeId, @Assisted ObjectId commitId, @Assisted String refName) { @@ -202,6 +212,8 @@ this.autoMerger = autoMerger; this.changeUtil = changeUtil; this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory; + this.validationOptionsListeners = validationOptionsListeners; + this.commitValidationInfoListeners = commitValidationInfoListeners; this.changeId = changeId; this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID); @@ -266,6 +278,12 @@ } @CanIgnoreReturnValue + public ChangeInserter setConflicts(PatchSet.Conflicts conflicts) { + this.conflicts = conflicts; + return this; + } + + @CanIgnoreReturnValue public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) { this.cherryPickOf = cherryPickOf; return this; @@ -283,9 +301,30 @@ return this; } + /** + * Disables the commit validation because validation is not needed. + * + * @return the {@link ChangeInserter} instance to allow chaining calls + */ @CanIgnoreReturnValue - public ChangeInserter setValidate(boolean validate) { - this.validate = validate; + public ChangeInserter disableValidation() { + return disableValidation(null); + } + + /** + * Disables the commit validation because the validation has already been done. + * + * <p>The result from the validation that has already been done needs to be provided to this + * method and is being used to invoke the {@link CommitValidationInfoListener}'s. + * + * @param validationInfos result of validating {@link #commitId} + * @return the {@link ChangeInserter} instance to allow chaining calls + */ + @CanIgnoreReturnValue + public ChangeInserter disableValidation( + @Nullable ImmutableMap<String, CommitValidationInfo> validationInfos) { + this.validate = false; + this.validationInfos = validationInfos; return this; } @@ -542,6 +581,11 @@ if (!approvals.isEmpty()) { update.putReviewer(ctx.getAccountId(), REVIEWER); } + + if (conflicts != null) { + update.setConflicts(conflicts); + } + if (message != null) { changeMessage = cmUtil.setChangeMessage( @@ -639,23 +683,32 @@ } private void validate(RepoContext ctx) throws IOException, ResourceConflictException { - if (!validate) { - return; - } + try (CommitReceivedEvent event = + new CommitReceivedEvent( + cmd, + projectState.getProject(), + change.getDest().branch(), + validationOptions, + ctx.getRepoView().getConfig(), + ctx.getRevWalk().getObjectReader(), + commitId, + ctx.getIdentifiedUser(), + diffOperationsForCommitValidationFactory.create( + ctx.getRepoView(), ctx.getInserter()))) { + if (!validate) { + if (validationInfos != null) { + commitValidationInfoListeners.runEach( + commitValidationInfoListener -> + commitValidationInfoListener.commitValidated(validationInfos, event, psId)); + } + return; + } - try { - try (CommitReceivedEvent event = - new CommitReceivedEvent( - cmd, - projectState.getProject(), - change.getDest().branch(), - validationOptions, - ctx.getRepoView().getConfig(), - ctx.getRevWalk().getObjectReader(), - commitId, - ctx.getIdentifiedUser(), - diffOperationsForCommitValidationFactory.create( - ctx.getRepoView(), ctx.getInserter()))) { + try { + validationOptionsListeners.runEach( + validationOptionsListener -> + validationOptionsListener.onPatchSetCreation( + change.getDest(), psId, validationOptions)); commitValidatorsFactory .forGerritCommits( permissionBackend.user(ctx.getUser()).project(ctx.getProject()), @@ -664,10 +717,11 @@ new NoSshInfo(), ctx.getRevWalk(), change) + .patchSet(psId) .validate(event); + } catch (CommitValidationException e) { + throw new ResourceConflictException(e.getFullMessage()); } - } catch (CommitValidationException e) { - throw new ResourceConflictException(e.getFullMessage()); } }
diff --git a/java/com/google/gerrit/server/change/ChangeKindCache.java b/java/com/google/gerrit/server/change/ChangeKindCache.java index 9bd7ad7..3dfed22 100644 --- a/java/com/google/gerrit/server/change/ChangeKindCache.java +++ b/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -20,6 +20,7 @@ import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.client.ChangeKind; import com.google.gerrit.server.query.change.ChangeData; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.revwalk.RevWalk; @@ -35,11 +36,16 @@ Project.NameKey project, @Nullable RevWalk rw, @Nullable Config repoConfig, + @Nullable AttributesNodeProvider attributesNodeProvider, ObjectId prior, ObjectId next); ChangeKind getChangeKind(Change change, PatchSet patch); ChangeKind getChangeKind( - @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch); + @Nullable RevWalk rw, + @Nullable Config repoConfig, + @Nullable AttributesNodeProvider attributesNodeProvider, + ChangeData cd, + PatchSet patch); }
diff --git a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java index 90752c0..b02da0b 100644 --- a/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java +++ b/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -49,6 +49,7 @@ import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.errors.LargeObjectException; import org.eclipse.jgit.lib.AnyObjectId; import org.eclipse.jgit.lib.Config; @@ -81,6 +82,7 @@ public static class NoCache implements ChangeKindCache { private final boolean useRecursiveMerge; + private final boolean useGitattributesForMerge; private final ChangeData.Factory changeDataFactory; private final GitRepositoryManager repoManager; @@ -90,6 +92,7 @@ ChangeData.Factory changeDataFactory, GitRepositoryManager repoManager) { this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig); + this.useGitattributesForMerge = MergeUtil.useGitattributesForMerge(serverConfig); this.changeDataFactory = changeDataFactory; this.repoManager = repoManager; } @@ -99,11 +102,20 @@ Project.NameKey project, @Nullable RevWalk rw, @Nullable Config repoConfig, + AttributesNodeProvider attributesNodeProvider, ObjectId prior, ObjectId next) { try { Key key = Key.create(prior, next, useRecursiveMerge); - return new Loader(key, repoManager, project, rw, repoConfig).call(); + return new Loader( + key, + repoManager, + project, + rw, + repoConfig, + attributesNodeProvider, + useGitattributesForMerge) + .call(); } catch (IOException e) { logger.atWarning().withCause(e).log( "Cannot check trivial rebase of new patch set %s in %s", next.name(), project); @@ -118,8 +130,12 @@ @Override public ChangeKind getChangeKind( - @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) { - return getChangeKindInternal(this, rw, repoConfig, cd, patch); + @Nullable RevWalk rw, + @Nullable Config repoConfig, + AttributesNodeProvider attributesNodeProvider, + ChangeData cd, + PatchSet patch) { + return getChangeKindInternal(this, rw, repoConfig, attributesNodeProvider, cd, patch); } } @@ -170,23 +186,31 @@ private final Project.NameKey projectName; private final RevWalk alreadyOpenRw; private final Config repoConfig; + private final AttributesNodeProvider repoAttributesNodeProvider; + private final boolean useGitattributesForMerge; private Loader( Key key, GitRepositoryManager repoManager, Project.NameKey projectName, @Nullable RevWalk rw, - @Nullable Config repoConfig) { + @Nullable Config repoConfig, + @Nullable AttributesNodeProvider attributesNodeProvider, + boolean useGitattributesForMerge) { checkArgument( - (rw == null && repoConfig == null) || (rw != null && repoConfig != null), - "must either provide both revwalk/config, or neither; got %s/%s", + (rw == null && repoConfig == null && attributesNodeProvider == null) + || (rw != null && repoConfig != null && attributesNodeProvider != null), + "must either provide revwalk/config/attributesNodeProvider, or none; got %s/%s/%s", rw, - repoConfig); + repoConfig, + attributesNodeProvider); this.key = key; this.repoManager = repoManager; this.projectName = projectName; this.alreadyOpenRw = rw; this.repoConfig = repoConfig; + this.repoAttributesNodeProvider = attributesNodeProvider; + this.useGitattributesForMerge = useGitattributesForMerge; } @SuppressWarnings("resource") // Resources are manually managed. @@ -198,11 +222,13 @@ RevWalk rw = alreadyOpenRw; Config config = repoConfig; + AttributesNodeProvider attributesNodeProvider = repoAttributesNodeProvider; Repository repo = null; if (alreadyOpenRw == null) { repo = repoManager.openRepository(projectName); rw = new RevWalk(repo); config = repo.getConfig(); + attributesNodeProvider = repo.createAttributesNodeProvider(); } try { RevCommit prior = rw.parseCommit(key.prior()); @@ -233,7 +259,13 @@ // having the same tree as would exist when the prior commit is // cherry-picked onto the next commit's new first parent. try (ObjectInserter ins = new InMemoryInserter(rw.getObjectReader())) { - ThreeWayMerger merger = MergeUtil.newThreeWayMerger(ins, config, key.strategyName()); + ThreeWayMerger merger = + MergeUtil.newThreeWayMerger( + ins, + config, + attributesNodeProvider, + key.strategyName(), + useGitattributesForMerge); merger.setBase(prior.getParent(0)); if (merger.merge(next.getParent(0), prior) && merger.getResultTreeId().equals(next.getTree())) { @@ -317,6 +349,7 @@ private final Cache<Key, ChangeKind> cache; private final boolean useRecursiveMerge; + private final boolean useGitattributesForMerge; private final ChangeData.Factory changeDataFactory; private final GitRepositoryManager repoManager; @@ -328,6 +361,7 @@ GitRepositoryManager repoManager) { this.cache = cache; this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig); + this.useGitattributesForMerge = MergeUtil.useGitattributesForMerge(serverConfig); this.changeDataFactory = changeDataFactory; this.repoManager = repoManager; } @@ -337,11 +371,22 @@ Project.NameKey project, @Nullable RevWalk rw, @Nullable Config repoConfig, + @Nullable AttributesNodeProvider attributesNodeProvider, ObjectId prior, ObjectId next) { try { Key key = Key.create(prior, next, useRecursiveMerge); - ChangeKind kind = cache.get(key, new Loader(key, repoManager, project, rw, repoConfig)); + ChangeKind kind = + cache.get( + key, + new Loader( + key, + repoManager, + project, + rw, + repoConfig, + attributesNodeProvider, + useGitattributesForMerge)); logger.atFine().log("Change kind of new patch set %s in %s: %s", next.name(), project, kind); return kind; } catch (ExecutionException e) { @@ -358,14 +403,19 @@ @Override public ChangeKind getChangeKind( - @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) { - return getChangeKindInternal(this, rw, repoConfig, cd, patch); + @Nullable RevWalk rw, + @Nullable Config repoConfig, + @Nullable AttributesNodeProvider attributesNodeProvider, + ChangeData cd, + PatchSet patch) { + return getChangeKindInternal(this, rw, repoConfig, attributesNodeProvider, cd, patch); } private static ChangeKind getChangeKindInternal( ChangeKindCache cache, @Nullable RevWalk rw, @Nullable Config repoConfig, + AttributesNodeProvider attributesNodeProvider, ChangeData change, PatchSet patch) { ChangeKind kind = ChangeKind.REWORK; @@ -390,7 +440,12 @@ if (priorPs != patch) { kind = cache.getChangeKind( - change.project(), rw, repoConfig, priorPs.commitId(), patch.commitId()); + change.project(), + rw, + repoConfig, + attributesNodeProvider, + priorPs.commitId(), + patch.commitId()); } } catch (StorageException e) { // Do nothing; assume we have a complex change @@ -419,7 +474,12 @@ RevWalk rw = new RevWalk(repo)) { kind = getChangeKindInternal( - cache, rw, repo.getConfig(), changeDataFactory.create(change), patch); + cache, + rw, + repo.getConfig(), + repo.createAttributesNodeProvider(), + changeDataFactory.create(change), + patch); } catch (IOException e) { // Do nothing; assume we have a complex change logger.atWarning().withCause(e).log(
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java index 9f7a7fc..b8926d0 100644 --- a/java/com/google/gerrit/server/change/ConsistencyChecker.java +++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -575,7 +575,7 @@ bu.addOp( notes.getChangeId(), inserter - .setValidate(false) + .disableValidation() .setFireRevisionCreated(false) .setAllowClosed(true) .setMessage("Patch set for merged commit inserted by consistency checker"));
diff --git a/java/com/google/gerrit/server/change/MergeabilityCache.java b/java/com/google/gerrit/server/change/MergeabilityCache.java index b432bc9..f3f718b 100644 --- a/java/com/google/gerrit/server/change/MergeabilityCache.java +++ b/java/com/google/gerrit/server/change/MergeabilityCache.java
@@ -22,24 +22,6 @@ /** Cache for mergeability of commits into destination branches. */ public interface MergeabilityCache { - class NotImplemented implements MergeabilityCache { - @Override - public boolean get( - ObjectId commit, - Ref intoRef, - SubmitType submitType, - String mergeStrategy, - BranchNameKey dest, - Repository repo) { - throw new UnsupportedOperationException("Mergeability checking disabled"); - } - - @Override - public Boolean getIfPresent( - ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy) { - throw new UnsupportedOperationException("Mergeability checking disabled"); - } - } boolean get( ObjectId commit,
diff --git a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java index 44af1e4..7097eb8 100644 --- a/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java +++ b/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -221,6 +221,9 @@ @Override public Boolean getIfPresent( ObjectId commit, Ref intoRef, SubmitType submitType, String mergeStrategy) { - return cache.getIfPresent(new EntryKey(commit, toId(intoRef), submitType, mergeStrategy)); + EntryKey entryKey = new EntryKey(commit, toId(intoRef), submitType, mergeStrategy); + Boolean mergeable = cache.getIfPresent(entryKey); + logger.atFine().log("got mergeable=%s (entryKey=%s)", mergeable, entryKey); + return mergeable; } }
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java index 3b0f6fb..797b84a 100644 --- a/java/com/google/gerrit/server/change/PatchSetInserter.java +++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -22,6 +22,7 @@ import static java.util.Objects.requireNonNull; import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.Nullable; @@ -37,12 +38,15 @@ import com.google.gerrit.server.ChangeUtil; import com.google.gerrit.server.PatchSetUtil; import com.google.gerrit.server.ReviewerSet; +import com.google.gerrit.server.ValidationOptionsListener; import com.google.gerrit.server.approval.ApprovalCopier; import com.google.gerrit.server.approval.ApprovalsUtil; import com.google.gerrit.server.events.CommitReceivedEvent; import com.google.gerrit.server.extensions.events.RevisionCreated; import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged; import com.google.gerrit.server.git.validators.CommitValidationException; +import com.google.gerrit.server.git.validators.CommitValidationInfo; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.CommitValidators; import com.google.gerrit.server.git.validators.TopicValidator; import com.google.gerrit.server.notedb.ChangeNotes; @@ -53,6 +57,7 @@ import com.google.gerrit.server.permissions.ChangePermission; import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.server.plugincontext.PluginSetContext; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.ssh.NoSshInfo; import com.google.gerrit.server.update.BatchUpdateOp; @@ -89,6 +94,8 @@ private final AutoMerger autoMerger; private final TopicValidator topicValidator; private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory; + private final PluginSetContext<ValidationOptionsListener> validationOptionsListeners; + private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners; // Assisted-injected fields. private final PatchSet.Id psId; @@ -103,6 +110,7 @@ private String description; private Boolean workInProgress; private boolean validate = true; + private ImmutableMap<String, CommitValidationInfo> validationInfos; private boolean checkAddPatchSetPermission = true; private List<String> groups = Collections.emptyList(); private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of(); @@ -110,6 +118,7 @@ private boolean allowClosed; private boolean sendEmail = true; private String topic; + private PatchSet.Conflicts conflicts; private boolean storeCopiedVotes = true; // Fields set during some phase of BatchUpdate.Op. @@ -139,6 +148,8 @@ AutoMerger autoMerger, TopicValidator topicValidator, DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory, + PluginSetContext<ValidationOptionsListener> validationOptionsListeners, + PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners, @Assisted ChangeNotes notes, @Assisted PatchSet.Id psId, @Assisted ObjectId commitId) { @@ -156,6 +167,8 @@ this.autoMerger = autoMerger; this.topicValidator = topicValidator; this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory; + this.validationOptionsListeners = validationOptionsListeners; + this.commitValidationInfoListeners = commitValidationInfoListeners; this.origNotes = notes; this.psId = psId; @@ -184,9 +197,30 @@ return this; } + /** + * Disables the commit validation because validation is not needed. + * + * @return the {@link PatchSetInserter} instance to allow chaining calls + */ @CanIgnoreReturnValue - public PatchSetInserter setValidate(boolean validate) { - this.validate = validate; + public PatchSetInserter disableValidation() { + return disableValidation(null); + } + + /** + * Disables the commit validation because the validation has already been done. + * + * <p>The result from the validation that has already been done needs to be provided to this + * method and is being used to invoke the {@link CommitValidationInfoListener}'s. + * + * @param validationInfos result of validating {@link #commitId} + * @return the {@link PatchSetInserter} instance to allow chaining calls + */ + @CanIgnoreReturnValue + public PatchSetInserter disableValidation( + @Nullable ImmutableMap<String, CommitValidationInfo> validationInfos) { + this.validate = false; + this.validationInfos = validationInfos; return this; } @@ -235,6 +269,12 @@ return this; } + @CanIgnoreReturnValue + public PatchSetInserter setConflicts(PatchSet.Conflicts conflicts) { + this.conflicts = conflicts; + return this; + } + /** * We always want to store copied votes except when the change is getting submitted and a new * patch-set is created on submit (using submit strategies such as "REBASE_ALWAYS"). In such @@ -268,6 +308,7 @@ ctx.getProject(), ctx.getRevWalk(), ctx.getRepoView().getConfig(), + ctx.getRepoView().getAttributesNodeProvider(), psUtil.current(origNotes).commitId(), commitId); @@ -337,13 +378,17 @@ ctx.getNotes(), patchSet, ctx.getRepoView(), update); } - mailMessage = insertChangeMessage(update, ctx); + if (conflicts != null) { + update.setConflicts(conflicts); + } + + mailMessage = insertChangeMessage(update); return true; } @Nullable - private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx) { + private String insertChangeMessage(ChangeUpdate update) { StringBuilder messageBuilder = new StringBuilder(); if (message != null) { messageBuilder.append(message); @@ -351,12 +396,7 @@ if (approvalCopierResult != null) { approvalsUtil - .formatApprovalCopierResult( - approvalCopierResult, - projectCache - .get(ctx.getProject()) - .orElseThrow(illegalState(ctx.getProject())) - .getLabelTypes()) + .formatApprovalCopierResult(approvalCopierResult) .ifPresent( msg -> { if (message != null && !message.endsWith("\n")) { @@ -418,9 +458,6 @@ .get(ctx.getProject()) .orElseThrow(illegalState(ctx.getProject())) .checkStatePermitsWrite(); - if (!validate) { - return; - } String refName = getPatchSetId().toRefName(); try (CommitReceivedEvent event = @@ -441,17 +478,33 @@ ctx.getIdentifiedUser(), diffOperationsForCommitValidationFactory.create( ctx.getRepoView(), ctx.getInserter()))) { - commitValidatorsFactory - .forGerritCommits( - permissionBackend.user(ctx.getUser()).project(ctx.getProject()), - origNotes.getChange().getDest(), - ctx.getIdentifiedUser(), - new NoSshInfo(), - ctx.getRevWalk(), - origNotes.getChange()) - .validate(event); - } catch (CommitValidationException e) { - throw new ResourceConflictException(e.getFullMessage()); + if (!validate) { + if (validationInfos != null) { + commitValidationInfoListeners.runEach( + commitValidationInfoListener -> + commitValidationInfoListener.commitValidated(validationInfos, event, psId)); + } + return; + } + + validationOptionsListeners.runEach( + validationOptionsListener -> + validationOptionsListener.onPatchSetCreation( + change.getDest(), psId, validationOptions)); + try { + commitValidatorsFactory + .forGerritCommits( + permissionBackend.user(ctx.getUser()).project(ctx.getProject()), + origNotes.getChange().getDest(), + ctx.getIdentifiedUser(), + new NoSshInfo(), + ctx.getRevWalk(), + origNotes.getChange()) + .patchSet(psId) + .validate(event); + } catch (CommitValidationException e) { + throw new ResourceConflictException(e.getFullMessage()); + } } } }
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java index eeaa161..d6a067f 100644 --- a/java/com/google/gerrit/server/change/RebaseChangeOp.java +++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -124,6 +124,7 @@ private String mergeStrategy; private boolean verifyNeedsRebase = true; private final boolean useDiff3; + private final boolean useGitattributesForMerge; private CodeReviewCommit rebasedCommit; private PatchSet.Id rebasedPatchSetId; @@ -208,6 +209,7 @@ this.projectName = notes.getProjectName(); this.originalPatchSet = originalPatchSet; this.useDiff3 = cfg.getBoolean("change", null, "diff3ConflictView", false); + this.useGitattributesForMerge = MergeUtil.useGitattributesForMerge(cfg); } @CanIgnoreReturnValue @@ -362,11 +364,15 @@ .setDescription("Rebase") .setFireRevisionCreated(fireRevisionCreated) .setCheckAddPatchSetPermission(checkAddPatchSetPermission) - .setValidate(validate) .setSendEmail(sendEmail) // The votes are automatically copied and they don't count as copied votes. See // method's javadoc. .setStoreCopiedVotes(storeCopiedVotes); + rebasedCommit.getConflicts().ifPresent(patchSetInserter::setConflicts); + + if (!validate) { + patchSetInserter.disableValidation(); + } if (!rebasedCommit.getFilesWithGitConflicts().isEmpty() && !notes.getChange().isWorkInProgress()) { @@ -496,10 +502,19 @@ } DirCache dc = DirCache.newInCore(); - if (allowConflicts && merger instanceof ResolveMerger) { - // The DirCache must be set on ResolveMerger before calling - // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get populated. - ((ResolveMerger) merger).setDirCache(dc); + if (merger instanceof ResolveMerger) { + if (useGitattributesForMerge) { + // We need to set the attributes provider before attempting the merge in order to read and + // honor gitattributes merge settings correctly + ((ResolveMerger) merger) + .setAttributesNodeProvider(ctx.getRepoView().getAttributesNodeProvider()); + } + if (allowConflicts) { + // The DirCache must be set on ResolveMerger before calling + // ResolveMerger#merge(AnyObjectId...) otherwise the entries in DirCache don't get + // populated. + ((ResolveMerger) merger).setDirCache(dc); + } } boolean success = merger.merge(original, base); @@ -605,7 +620,7 @@ } ObjectId objectId = ctx.getInserter().insert(cb); CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId); - commit.setFilesWithGitConflicts(filesWithGitConflicts); + commit.setConflicts(original, base, filesWithGitConflicts); logger.atFine().log("rebased commit=%s", commit.name()); return commit; }
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java index 5b63fac..d6cbeda 100644 --- a/java/com/google/gerrit/server/change/RevisionJson.java +++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -42,6 +42,7 @@ import com.google.gerrit.extensions.client.ListChangesOption; import com.google.gerrit.extensions.common.ChangeInfo; import com.google.gerrit.extensions.common.CommitInfo; +import com.google.gerrit.extensions.common.ConflictsInfo; import com.google.gerrit.extensions.common.FetchInfo; import com.google.gerrit.extensions.common.PushCertificateInfo; import com.google.gerrit.extensions.common.RevisionInfo; @@ -85,6 +86,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; @@ -187,7 +189,16 @@ AccountLoader accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS)); try (Repository repo = openRepoIfNecessary(cd.project()); RevWalk rw = newRevWalk(repo)) { - RevisionInfo rev = toRevisionInfo(accountLoader, cd, in, repo, rw, true, null); + RevisionInfo rev = + toRevisionInfo( + accountLoader, + cd, + in, + repo, + rw, + true, + null, + repo != null ? repo.createAttributesNodeProvider() : null); accountLoader.fill(); return rev; } @@ -267,6 +278,8 @@ Map<String, RevisionInfo> res = new LinkedHashMap<>(); try (Repository repo = openRepoIfNecessary(cd.project()); RevWalk rw = newRevWalk(repo)) { + AttributesNodeProvider attributesNodeProvider = + repo != null ? repo.createAttributesNodeProvider() : null; for (PatchSet in : map.values()) { PatchSet.Id id = in.id(); boolean want; @@ -280,7 +293,8 @@ if (want) { res.put( in.commitId().name(), - toRevisionInfo(accountLoader, cd, in, repo, rw, false, changeInfo)); + toRevisionInfo( + accountLoader, cd, in, repo, rw, false, changeInfo, attributesNodeProvider)); } } return res; @@ -325,7 +339,8 @@ @Nullable Repository repo, @Nullable RevWalk rw, boolean fillCommit, - @Nullable ChangeInfo changeInfo) + @Nullable ChangeInfo changeInfo, + @Nullable AttributesNodeProvider attributesNodeProvider) throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException { Change c = cd.change(); RevisionInfo out = new RevisionInfo(); @@ -345,8 +360,21 @@ out.realUploader = accountLoader.get(in.realUploader()); } out.fetch = makeFetchMap(cd, in); - out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in); + out.kind = + changeKindCache.getChangeKind( + rw, repo != null ? repo.getConfig() : null, attributesNodeProvider, cd, in); out.description = in.description().orElse(null); + out.conflicts = + in.conflicts() + .map( + conflicts -> { + ConflictsInfo conflictsInfo = new ConflictsInfo(); + conflictsInfo.containsConflicts = conflicts.containsConflicts(); + conflictsInfo.ours = conflicts.ours().map(ObjectId::getName).orElse(null); + conflictsInfo.theirs = conflicts.theirs().map(ObjectId::getName).orElse(null); + return conflictsInfo; + }) + .orElse(null); boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT)); boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
diff --git a/java/com/google/gerrit/server/config/AccountConfig.java b/java/com/google/gerrit/server/config/AccountConfig.java new file mode 100644 index 0000000..4e852bc --- /dev/null +++ b/java/com/google/gerrit/server/config/AccountConfig.java
@@ -0,0 +1,34 @@ +// 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.config; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import org.eclipse.jgit.lib.Config; + +/** Account related settings from {@code gerrit.config}. */ +@Singleton +public class AccountConfig { + private final String[] caseInsensitiveLocalParts; + + @Inject + AccountConfig(@GerritServerConfig Config cfg) { + caseInsensitiveLocalParts = cfg.getStringList("accounts", null, "caseInsensitiveLocalPart"); + } + + public String[] getCaseInsensitiveLocalParts() { + return caseInsensitiveLocalParts; + } +}
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java index 1f799c6..43c4933 100644 --- a/java/com/google/gerrit/server/config/CapabilityConstants.java +++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -27,6 +27,7 @@ public String batchChangesLimit; public String createAccount; public String createGroup; + public String deleteGroup; public String createProject; public String emailReviewers; public String flushCaches;
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java index e76207c..121c62e 100644 --- a/java/com/google/gerrit/server/config/ConfigUtil.java +++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -371,8 +371,12 @@ } else if (isLong(t)) { f.set(s, cfg.getLong(section, sub, n, (Long) d)); } else if (isBoolean(t)) { + // Sets the field if: + // - 'cfg' value is 'true'. + // - the default value is 'true'. + // - i is set. boolean b = cfg.getBoolean(section, sub, n, (Boolean) d); - if (b || i != null) { + if (b || (Boolean) d || i != null) { f.set(s, b); } } else if (t.isEnum()) { @@ -427,10 +431,13 @@ requireNonNull(val, "Default cannot be null for: " + n); } } - if (!isBoolean(t) || (boolean) val) { + if (!isBoolean(t) || (boolean) val || (Boolean) f.get(defaults)) { // To reproduce the same behavior as in the loadSection method above, values are // explicitly set for all types, except the boolean type. For the boolean type, the value - // is set only if it is 'true' (so, the false value is omitted in the result object). + // is set only in the following cases: + // - 'cfg' value is 'true'. + // - the default value is 'true'. + // Otherwise, false values are omitted in the result object. f.set(s, val); } }
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java index 1f0bd6e..e1b3317 100644 --- a/java/com/google/gerrit/server/config/GerritGlobalModule.java +++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -93,6 +93,7 @@ import com.google.gerrit.server.RequestListener; import com.google.gerrit.server.ServerStateProvider; import com.google.gerrit.server.TraceRequestListener; +import com.google.gerrit.server.ValidationOptionsListener; import com.google.gerrit.server.account.AccountControl; import com.google.gerrit.server.account.AccountDeactivator; import com.google.gerrit.server.account.AccountExternalIdCreator; @@ -148,10 +149,10 @@ import com.google.gerrit.server.git.validators.CommentCountValidator; import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator; import com.google.gerrit.server.git.validators.CommentSizeValidator; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.CommitValidationListener; import com.google.gerrit.server.git.validators.MergeValidationListener; import com.google.gerrit.server.git.validators.MergeValidators; -import com.google.gerrit.server.git.validators.MergeValidators.AccountMergeValidator; import com.google.gerrit.server.git.validators.MergeValidators.GroupMergeValidator; import com.google.gerrit.server.git.validators.MergeValidators.ProjectConfigValidator; import com.google.gerrit.server.git.validators.OnSubmitValidationListener; @@ -196,6 +197,7 @@ import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl; import com.google.gerrit.server.project.SubmitRuleEvaluator; import com.google.gerrit.server.query.approval.ApprovalModule; +import com.google.gerrit.server.query.approval.ApprovalQueryBuilder; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate; import com.google.gerrit.server.query.change.ChangeQueryBuilder; @@ -410,6 +412,7 @@ DynamicSet.bind(binder(), CommitValidationListener.class) .to(SubmitRequirementConfigValidator.class); DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesWarningValidator.class); + DynamicSet.setOf(binder(), CommitValidationInfoListener.class); DynamicSet.setOf(binder(), CommentValidator.class); DynamicSet.setOf(binder(), DiffValidator.class); DynamicSet.bind(binder(), DiffValidator.class).to(DiffFileSizeValidator.class); @@ -469,6 +472,7 @@ DynamicSet.setOf(binder(), AccountStateProvider.class); DynamicMap.mapOf(binder(), AccountTagProvider.class); DynamicSet.setOf(binder(), AttentionSetListener.class); + DynamicSet.setOf(binder(), ValidationOptionsListener.class); DynamicMap.mapOf(binder(), MailFilter.class); bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class); @@ -493,6 +497,7 @@ DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class); DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class); DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class); + DynamicMap.mapOf(binder(), ApprovalQueryBuilder.UserInOperandFactory.class); DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class); install(new GitwebConfig.LegacyModule(cfg)); @@ -500,7 +505,6 @@ bind(AnonymousUser.class); factory(AbandonOp.Factory.class); - factory(AccountMergeValidator.Factory.class); factory(GroupMergeValidator.Factory.class); factory(RefOperationValidators.Factory.class); factory(OnSubmitValidators.Factory.class);
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java index 1b8e1623..be55ec1 100644 --- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java +++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -685,7 +685,7 @@ new PersonIdent(currentEditCommit.getCommitterIdent(), timestamp)); CodeReviewCommit newEditCommit = revWalk.parseCommit(newEditCommitId); - newEditCommit.setFilesWithGitConflicts(filesWithGitConflicts); + newEditCommit.setConflicts(basePatchSetCommit, editCommitId, filesWithGitConflicts); return newEditCommit; } }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java index 0189306..f962bb8 100644 --- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java +++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -183,7 +183,13 @@ // Previously checked that the base patch set is the current patch set. ObjectId prior = basePatchSet.commitId(); ChangeKind kind = - changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), prior, squashed); + changeKindCache.getChangeKind( + change.getProject(), + rw, + repo.getConfig(), + repo.createAttributesNodeProvider(), + prior, + squashed); if (kind == ChangeKind.NO_CODE_CHANGE) { message.append("Commit message was updated."); inserter.setDescription("Edit commit message");
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java index d6562a6..6380db3 100644 --- a/java/com/google/gerrit/server/events/EventFactory.java +++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -76,6 +76,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; @@ -361,6 +362,7 @@ public void addPatchSets( RevWalk revWalk, Config repoConfig, + AttributesNodeProvider attributesNodeProvider, ChangeAttribute ca, Map<PatchSet.Id, Collection<PatchSetApproval>> approvals, boolean includeFiles, @@ -370,7 +372,8 @@ ca.patchSets = new ArrayList<>(changeData.patchSets().size()); for (PatchSet p : changeData.patchSets()) { PatchSetAttribute psa = - asPatchSetAttribute(revWalk, repoConfig, changeData, p, accountLoader); + asPatchSetAttribute( + revWalk, repoConfig, attributesNodeProvider, changeData, p, accountLoader); if (approvals != null) { addApprovals(psa, p.id(), approvals, changeData.getLabelTypes(), accountLoader); } @@ -434,14 +437,20 @@ } public PatchSetAttribute asPatchSetAttribute( - RevWalk revWalk, Config repoConfig, ChangeData changeData, PatchSet patchSet) { - return asPatchSetAttribute(revWalk, repoConfig, changeData, patchSet, null); + RevWalk revWalk, + Config repoConfig, + AttributesNodeProvider attributesNodeProvider, + ChangeData changeData, + PatchSet patchSet) { + return asPatchSetAttribute( + revWalk, repoConfig, attributesNodeProvider, changeData, patchSet, null); } /** Create a PatchSetAttribute for the given patchset suitable for serialization to JSON. */ public PatchSetAttribute asPatchSetAttribute( RevWalk revWalk, Config repoConfig, + AttributesNodeProvider attributesNodeProvider, ChangeData changeData, PatchSet patchSet, AccountAttributeLoader accountLoader) { @@ -476,7 +485,9 @@ p.sizeDeletions += fileDiff.deletions(); p.sizeInsertions += fileDiff.insertions(); } - p.kind = changeKindCache.getChangeKind(revWalk, repoConfig, changeData, patchSet); + p.kind = + changeKindCache.getChangeKind( + revWalk, repoConfig, attributesNodeProvider, changeData, patchSet); } catch (IOException | StorageException e) { logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id()); } catch (DiffNotAvailableException e) {
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java index 66e894c..125c47f 100644 --- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java +++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -216,7 +216,11 @@ try (Repository repo = repoManager.openRepository(changeData.change().getProject()); RevWalk revWalk = new RevWalk(repo)) { return eventFactory.asPatchSetAttribute( - revWalk, repo.getConfig(), changeData, patchSet); + revWalk, + repo.getConfig(), + repo.createAttributesNodeProvider(), + changeData, + patchSet); } catch (IOException e) { throw new RuntimeException(e); }
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java index cd91745..7ad9f85 100644 --- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java +++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -60,4 +60,8 @@ /** Whether we allow fix suggestions in HumanComments. */ public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS = "GerritBackendFeature__allow_fix_suggestions_in_comments"; + + /** Whether we allow fix suggestions in HumanComments. */ + public static final String ENABLE_CENTRAL_OVERRIDE_FOR_CODE_REVIEW_COPY_CONDITION = + "GerritBackendFeature__enable_central_override_for_code_review_copy_condition"; }
diff --git a/java/com/google/gerrit/server/git/CodeReviewCommit.java b/java/com/google/gerrit/server/git/CodeReviewCommit.java index 79df21a..2990aa8 100644 --- a/java/com/google/gerrit/server/git/CodeReviewCommit.java +++ b/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -32,6 +32,7 @@ import org.eclipse.jgit.errors.IncorrectObjectTypeException; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.AnyObjectId; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectReader; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; @@ -134,6 +135,16 @@ */ private transient Optional<String> statusMessage = Optional.empty(); + /** + * Information about conflicts in this commit. + * + * <p>Only set for patch sets that are created by Gerrit as a result of performing a Git merge. + * + * <p>If this field is not set it's unknown whether this patch set contains any file with + * conflicts. + */ + @Nullable private PatchSet.Conflicts conflicts; + /** List of files in this commit that contain Git conflict markers. */ private ImmutableSet<String> filesWithGitConflicts; @@ -161,15 +172,32 @@ this.statusMessage = Optional.ofNullable(statusMessage); } - public ImmutableSet<String> getFilesWithGitConflicts() { - return filesWithGitConflicts != null ? filesWithGitConflicts : ImmutableSet.of(); + public void setNoConflicts() { + this.conflicts = + PatchSet.Conflicts.create( + Optional.empty(), Optional.empty(), /* containsConflicts= */ false); } - public void setFilesWithGitConflicts(@Nullable Set<String> filesWithGitConflicts) { - this.filesWithGitConflicts = - filesWithGitConflicts != null && !filesWithGitConflicts.isEmpty() - ? ImmutableSet.copyOf(filesWithGitConflicts) - : null; + public void setConflicts( + ObjectId ours, ObjectId theirs, @Nullable Set<String> filesWithGitConflicts) { + if (filesWithGitConflicts != null && !filesWithGitConflicts.isEmpty()) { + this.conflicts = + PatchSet.Conflicts.create( + Optional.of(ours), Optional.of(theirs), /* containsConflicts= */ true); + this.filesWithGitConflicts = ImmutableSet.copyOf(filesWithGitConflicts); + } else { + this.conflicts = + PatchSet.Conflicts.create( + Optional.of(ours), Optional.of(theirs), /* containsConflicts= */ false); + } + } + + public Optional<PatchSet.Conflicts> getConflicts() { + return Optional.ofNullable(conflicts); + } + + public ImmutableSet<String> getFilesWithGitConflicts() { + return filesWithGitConflicts != null ? filesWithGitConflicts : ImmutableSet.of(); } public PatchSet.Id getPatchsetId() {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java index 4a5e1b0..30f2ee8 100644 --- a/java/com/google/gerrit/server/git/CommitUtil.java +++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -46,6 +46,7 @@ import com.google.gerrit.server.change.NotifyResolver; import com.google.gerrit.server.change.ValidationOptionsUtil; import com.google.gerrit.server.extensions.events.ChangeReverted; +import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; import com.google.gerrit.server.mail.send.MessageIdGenerator; @@ -173,12 +174,12 @@ try (Repository git = repoManager.openRepository(notes.getProjectName()); ObjectInserter oi = git.newObjectInserter(); ObjectReader reader = oi.newReader(); - RevWalk revWalk = new RevWalk(reader)) { + CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) { ObjectId generatedChangeId = CommitMessageUtil.generateChangeId(); - ObjectId revCommit = + CodeReviewCommit revertCommit = createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId); return createRevertChangeFromCommit( - revCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git); + revertCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git); } catch (RepositoryNotFoundException e) { throw new ResourceNotFoundException(notes.getChangeId().toString(), e); } @@ -192,16 +193,16 @@ * @param notes ChangeNotes of the change being reverted. * @param user Current User performing the revert. * @param ts Timestamp of creation for the commit. - * @return ObjectId that represents the newly created commit. + * @return that newly created revert commit. */ - public ObjectId createRevertCommit( + public CodeReviewCommit createRevertCommit( String message, ChangeNotes notes, CurrentUser user, Instant ts) throws RestApiException, IOException { try (Repository git = repoManager.openRepository(notes.getProjectName()); ObjectInserter oi = git.newObjectInserter(); ObjectReader reader = oi.newReader(); - RevWalk revWalk = new RevWalk(reader)) { + CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) { return createRevertCommit(message, notes, user, ts, oi, revWalk, null); } catch (RepositoryNotFoundException e) { throw new ResourceNotFoundException(notes.getProjectName().toString(), e); @@ -256,13 +257,13 @@ * @throws ResourceConflictException Can't revert the initial commit. * @throws IOException Thrown in case of I/O errors. */ - private ObjectId createRevertCommit( + private CodeReviewCommit createRevertCommit( String message, ChangeNotes notes, CurrentUser user, Instant ts, ObjectInserter oi, - RevWalk revWalk, + CodeReviewRevWalk revWalk, @Nullable ObjectId generatedChangeId) throws ResourceConflictException, IOException { @@ -293,17 +294,26 @@ message = ChangeIdUtil.insertId(message, generatedChangeId, true); } - return createCommitWithTree( - oi, - authorIdent, - committerIdent, - ImmutableList.of(commitToRevert), - message, - parentToCommitToRevert.getTree()); + CodeReviewCommit revertCommit = + revWalk.parseCommit( + createCommitWithTree( + oi, + authorIdent, + committerIdent, + ImmutableList.of(commitToRevert), + message, + parentToCommitToRevert.getTree())); + + // The revert commit is based on the commit that is being reverted and has the same tree as the + // parent of the commit that is being reverted. This means revert commit never contains any + // conflicts. + revertCommit.setNoConflicts(); + + return revertCommit; } private Change.Id createRevertChangeFromCommit( - ObjectId revertCommitId, + CodeReviewCommit revertCommit, RevertInput input, ChangeNotes notes, CurrentUser user, @@ -313,7 +323,6 @@ RevWalk revWalk, Repository git) throws IOException, RestApiException, UpdateException, ConfigInvalidException { - RevCommit revertCommit = revWalk.parseCommit(revertCommitId); Change.Id changeId = Change.id(seq.nextChangeId()); if (input.getWorkInProgress()) { input.notify = firstNonNull(input.notify, NotifyHandling.NONE); @@ -341,6 +350,7 @@ ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs); ins.setRevertOf(notes.getChangeId()); ins.setWorkInProgress(input.getWorkInProgress()); + revertCommit.getConflicts().ifPresent(ins::setConflicts); try (BatchUpdate bu = updateFactory.create(notes.getProjectName(), user, ts)) { bu.setRepository(git, revWalk, oi);
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java index b38d46e..f4553a8 100644 --- a/java/com/google/gerrit/server/git/MergeUtil.java +++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -75,6 +75,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.diff.Sequence; import org.eclipse.jgit.dircache.DirCache; import org.eclipse.jgit.dircache.DirCacheBuilder; @@ -133,6 +134,10 @@ return cfg.getBoolean("core", null, "useRecursiveMerge", true); } + public static boolean useGitattributesForMerge(Config cfg) { + return cfg.getBoolean("core", null, "useGitattributesForMerge", false); + } + public static ThreeWayMergeStrategy getMergeStrategy(Config cfg) { return useRecursiveMerge(cfg) ? MergeStrategy.RECURSIVE : MergeStrategy.RESOLVE; } @@ -143,6 +148,7 @@ private final ProjectState project; private final boolean useContentMerge; private final boolean useRecursiveMerge; + private final boolean useGitattributesForMerge; private final PluggableCommitMessageGenerator commitMessageGenerator; private final ChangeUtil changeUtil; @@ -182,6 +188,7 @@ this.project = project; this.useContentMerge = useContentMerge; this.useRecursiveMerge = useRecursiveMerge(serverConfig); + this.useGitattributesForMerge = useGitattributesForMerge(serverConfig); } public CodeReviewCommit getFirstFastForward( @@ -222,45 +229,16 @@ CodeReviewRevWalk rw, int parentIndex, boolean ignoreIdenticalTree, - boolean allowConflicts) - throws IOException, - MergeIdenticalTreeException, - MergeConflictException, - MethodNotAllowedException, - InvalidMergeStrategyException { - return createCherryPickFromCommit( - inserter, - repoConfig, - mergeTip, - originalCommit, - cherryPickCommitterIdent, - commitMsg, - rw, - parentIndex, - ignoreIdenticalTree, - allowConflicts, - false); - } - - public CodeReviewCommit createCherryPickFromCommit( - ObjectInserter inserter, - Config repoConfig, - RevCommit mergeTip, - RevCommit originalCommit, - PersonIdent cherryPickCommitterIdent, - String commitMsg, - CodeReviewRevWalk rw, - int parentIndex, - boolean ignoreIdenticalTree, boolean allowConflicts, - boolean diff3Format) + boolean diff3Format, + AttributesNodeProvider attributesNodeProvider) throws IOException, MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException, InvalidMergeStrategyException { - ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig); + ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig, attributesNodeProvider); m.setBase(originalCommit.getParent(parentIndex)); DirCache dc = DirCache.newInCore(); @@ -355,24 +333,11 @@ cherryPickCommit.setMessage(commitMsg); matchAuthorToCommitterDate(project, cherryPickCommit); CodeReviewCommit commit = rw.parseCommit(inserter.insert(cherryPickCommit)); - commit.setFilesWithGitConflicts(filesWithGitConflicts); + commit.setConflicts(mergeTip, originalCommit, filesWithGitConflicts); logger.atFine().log("CherryPick commitId=%s", commit.name()); return commit; } - public static ObjectId mergeWithConflicts( - RevWalk rw, - ObjectInserter ins, - DirCache dc, - String oursName, - RevCommit ours, - String theirsName, - RevCommit theirs, - Map<String, MergeResult<? extends Sequence>> mergeResults) - throws IOException { - return mergeWithConflicts(rw, ins, dc, oursName, ours, theirsName, theirs, mergeResults, false); - } - @SuppressWarnings("resource") // TemporaryBuffer requires calling close before reading. public static ObjectId mergeWithConflicts( RevWalk rw, @@ -482,37 +447,11 @@ RevCommit originalCommit, String mergeStrategy, boolean allowConflicts, - PersonIdent committerIdent, - String commitMsg, - CodeReviewRevWalk rw) - throws IOException, - MergeIdenticalTreeException, - MergeConflictException, - InvalidMergeStrategyException { - return createMergeCommit( - inserter, - repoConfig, - mergeTip, - originalCommit, - mergeStrategy, - allowConflicts, - committerIdent, - committerIdent, - commitMsg, - rw); - } - - public static CodeReviewCommit createMergeCommit( - ObjectInserter inserter, - Config repoConfig, - RevCommit mergeTip, - RevCommit originalCommit, - String mergeStrategy, - boolean allowConflicts, PersonIdent authorIdent, PersonIdent committerIdent, String commitMsg, - CodeReviewRevWalk rw) + CodeReviewRevWalk rw, + boolean diff3Format) throws IOException, MergeIdenticalTreeException, MergeConflictException, @@ -594,7 +533,8 @@ mergeTip, "SOURCE BRANCH", originalCommit, - mergeResults); + mergeResults, + diff3Format); } CommitBuilder mergeCommit = new CommitBuilder(); @@ -604,7 +544,7 @@ mergeCommit.setCommitter(committerIdent); mergeCommit.setMessage(commitMsg); CodeReviewCommit commit = rw.parseCommit(inserter.insert(mergeCommit)); - commit.setFilesWithGitConflicts(filesWithGitConflicts); + commit.setConflicts(mergeTip, originalCommit, filesWithGitConflicts); return commit; } @@ -795,6 +735,7 @@ CodeReviewCommit mergeTip, CodeReviewCommit toMerge) { if (hasMissingDependencies(mergeSorter, toMerge)) { + logger.atFine().log("%s cannot be merged due to missing dependencies", toMerge.name()); return false; } @@ -803,11 +744,13 @@ private boolean canMerge(CodeReviewCommit mergeTip, Repository repo, CodeReviewCommit toMerge) { try (ObjectInserter ins = new InMemoryInserter(repo)) { - return newThreeWayMerger(ins, repo.getConfig()).merge(mergeTip, toMerge); + return newThreeWayMerger(ins, repo).merge(mergeTip, toMerge); } catch (LargeObjectException e) { - logger.atWarning().log("Cannot merge due to LargeObjectException: %s", toMerge.name()); + logger.atWarning().log("%s cannot be merged due to LargeObjectException", toMerge.name()); return false; } catch (NoMergeBaseException e) { + logger.atFine().log( + "%s cannot be merged because no merge base could be found", toMerge.name()); return false; } catch (IOException e) { throw new StorageException("Cannot merge " + toMerge.name(), e); @@ -875,7 +818,7 @@ // that on the current merge tip. // try (ObjectInserter ins = new InMemoryInserter(repo)) { - ThreeWayMerger m = newThreeWayMerger(ins, repo.getConfig()); + ThreeWayMerger m = newThreeWayMerger(ins, repo); m.setBase(toMerge.getParent(0)); return m.merge(mergeTip, toMerge); } catch (IOException e) { @@ -911,9 +854,10 @@ Config repoConfig, BranchNameKey destBranch, CodeReviewCommit mergeTip, - CodeReviewCommit n) + CodeReviewCommit n, + AttributesNodeProvider attributesNodeProvider) throws InvalidMergeStrategyException { - ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig); + ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig, attributesNodeProvider); try { if (m.merge(mergeTip, n)) { return writeMergeCommit( @@ -1039,9 +983,20 @@ .collect(joining(",", "Merge changes ", merged.size() > 5 ? ", ..." : "")); } - public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) + public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Repository repo) throws InvalidMergeStrategyException { - return newThreeWayMerger(inserter, repoConfig, mergeStrategyName()); + return newThreeWayMerger(inserter, repo.getConfig(), repo.createAttributesNodeProvider()); + } + + public ThreeWayMerger newThreeWayMerger( + ObjectInserter inserter, Config repoConfig, AttributesNodeProvider attributesNodeProvider) + throws InvalidMergeStrategyException { + return newThreeWayMerger( + inserter, + repoConfig, + attributesNodeProvider, + mergeStrategyName(), + useGitattributesForMerge); } public String mergeStrategyName() { @@ -1073,13 +1028,20 @@ } public static ThreeWayMerger newThreeWayMerger( - ObjectInserter inserter, Config repoConfig, String strategyName) + ObjectInserter inserter, + Config repoConfig, + AttributesNodeProvider attributesNodeProvider, + String strategyName, + boolean useGitattributesForMerge) throws InvalidMergeStrategyException { Merger m = newMerger(inserter, repoConfig, strategyName); checkArgument( m instanceof ThreeWayMerger, "merge strategy %s does not support three-way merging", strategyName); + if (m instanceof ResolveMerger && useGitattributesForMerge) { + ((ResolveMerger) m).setAttributesNodeProvider(attributesNodeProvider); + } return (ThreeWayMerger) m; }
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java index 21da863..8a40618 100644 --- a/java/com/google/gerrit/server/git/PureRevertCache.java +++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -185,7 +185,7 @@ ThreeWayMerger merger = mergeUtilFactory .create(projectCache.get(project).orElseThrow(illegalState(project))) - .newThreeWayMerger(oi, repo.getConfig()); + .newThreeWayMerger(oi, repo); merger.setBase(claimedRevertCommit.getParent(0)); boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit); if (!success || merger.getResultTreeId() == null) {
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java index cb1af07..1d8aa05 100644 --- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java +++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -36,6 +36,7 @@ import com.google.gerrit.metrics.Timer1; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.PublishCommentsOp; +import com.google.gerrit.server.RequestCounter; import com.google.gerrit.server.cache.PerThreadCache; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.ConfigUtil; @@ -102,7 +103,8 @@ ProjectState projectState, IdentifiedUser user, Repository repository, - @Nullable MessageSender messageSender); + @Nullable MessageSender messageSender, + @Nullable RequestCounter requestCounter); } public static class AsyncReceiveCommitsModule extends PrivateModule { @@ -259,7 +261,8 @@ @Assisted ProjectState projectState, @Assisted IdentifiedUser user, @Assisted Repository repo, - @Assisted @Nullable MessageSender messageSender) + @Assisted @Nullable MessageSender messageSender, + @Assisted @Nullable RequestCounter requestCounter) throws PermissionBackendException { this.multiProgressMonitorFactory = multiProgressMonitorFactory; this.executor = executor; @@ -305,7 +308,8 @@ projectName, user.getAccountId())); receiveCommits = - factory.create(projectState, user, receivePack, repo, allRefsWatcher, messageSender); + factory.create( + projectState, user, receivePack, repo, allRefsWatcher, messageSender, requestCounter); receiveCommits.init(); QuotaResponse.Aggregated availableTokens = quotaBackend.user(user).project(projectName).availableTokens(REPOSITORY_SIZE_GROUP);
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java index 6a43719..5138db9 100644 --- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java +++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -20,6 +20,7 @@ import com.google.auto.value.AutoValue; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.BranchNameKey; @@ -28,6 +29,8 @@ import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.events.CommitReceivedEvent; import com.google.gerrit.server.git.validators.CommitValidationException; +import com.google.gerrit.server.git.validators.CommitValidationInfo; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.CommitValidators; import com.google.gerrit.server.logging.TraceContext; @@ -65,14 +68,23 @@ /** A boolean validation status and a list of additional messages. */ @AutoValue abstract static class Result { - static Result create(boolean isValid, ImmutableList<CommitValidationMessage> messages) { - return new AutoValue_BranchCommitValidator_Result(isValid, messages); + static Result create( + boolean isValid, + ImmutableMap<String, CommitValidationInfo> validationInfos, + ImmutableList<CommitValidationMessage> messages) { + return new AutoValue_BranchCommitValidator_Result(isValid, validationInfos, messages); } /** Whether the commit is valid. */ abstract boolean isValid(); /** + * Map that maps a validator name to a {@link CommitValidationInfo} (result of running the + * validator). + */ + abstract ImmutableMap<String, CommitValidationInfo> validationInfos(); + + /** * A list of messages related to the validation. Messages may be present regardless of the * {@link #isValid()} status. */ @@ -103,6 +115,8 @@ * @param cmd the ReceiveCommand executing the push. * @param commit the commit being validated. * @param isMerged whether this is a merge commit created by magicBranch --merge option + * @param invokeCommitValidationInfoListeners whether the {@link CommitValidationInfoListener}'s + * should be invoked when the validation is done * @param change the change for which this is a new patchset. * @return The validation {@link Result}. */ @@ -115,6 +129,7 @@ ImmutableListMultimap<String, String> pushOptions, boolean isMerged, NoteMap rejectCommits, + boolean invokeCommitValidationInfoListeners, @Nullable Change change) throws IOException { return validateCommit( @@ -126,6 +141,7 @@ pushOptions, isMerged, rejectCommits, + invokeCommitValidationInfoListeners, change, false); } @@ -138,6 +154,8 @@ * @param cmd the ReceiveCommand executing the push. * @param commit the commit being validated. * @param isMerged whether this is a merge commit created by magicBranch --merge option + * @param invokeCommitValidationInfoListeners whether the {@link CommitValidationInfoListener}'s + * should be invoked when the validation is done * @param change the change for which this is a new patchset. * @param skipValidation whether 'skip-validation' was requested. * @return The validation {@link Result}. @@ -151,10 +169,12 @@ ImmutableListMultimap<String, String> pushOptions, boolean isMerged, NoteMap rejectCommits, + boolean invokeCommitValidationInfoListeners, @Nullable Change change, boolean skipValidation) throws IOException { try (TraceTimer traceTimer = TraceContext.newTimer("BranchCommitValidator#validateCommit")) { + ImmutableMap<String, CommitValidationInfo> validationInfos = ImmutableMap.of(); ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>(); try (CommitReceivedEvent receiveEvent = new CommitReceivedEvent( @@ -185,10 +205,16 @@ skipValidation); } - for (CommitValidationMessage m : validators.validate(receiveEvent)) { - messages.add( - new CommitValidationMessage( - messageForCommit(commit, m.getMessage(), objectReader), m.getType())); + validationInfos = + validators + .invokeCommitValidationInfoListeners(invokeCommitValidationInfoListeners) + .validate(receiveEvent); + for (CommitValidationInfo validatioInfo : validationInfos.values()) { + for (CommitValidationMessage m : validatioInfo.validationMessages()) { + messages.add( + new CommitValidationMessage( + messageForCommit(commit, m.getMessage(), objectReader), m.getType())); + } } } catch (CommitValidationException e) { logger.atFine().log("Commit validation failed on %s", commit.name()); @@ -201,9 +227,9 @@ } cmd.setResult( REJECTED_OTHER_REASON, messageForCommit(commit, e.getMessage(), objectReader)); - return Result.create(false, messages.build()); + return Result.create(false, ImmutableMap.of(), messages.build()); } - return Result.create(true, messages.build()); + return Result.create(true, validationInfos, messages.build()); } }
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java index 8dcfcc4..8e7b4d5 100644 --- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java +++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -119,6 +119,7 @@ import com.google.gerrit.server.PatchSetUtil; import com.google.gerrit.server.PublishCommentUtil; import com.google.gerrit.server.PublishCommentsOp; +import com.google.gerrit.server.RequestCounter; import com.google.gerrit.server.RequestInfo; import com.google.gerrit.server.RequestListener; import com.google.gerrit.server.Sequences; @@ -152,6 +153,7 @@ 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.CommitValidationInfo; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.git.validators.RefOperationValidationException; import com.google.gerrit.server.git.validators.RefOperationValidators; @@ -288,7 +290,8 @@ ReceivePack receivePack, Repository repository, AllRefsWatcher allRefsWatcher, - MessageSender messageSender); + @Nullable MessageSender messageSender, + @Nullable RequestCounter requestCounter); } private class ReceivePackMessageSender implements MessageSender { @@ -430,6 +433,7 @@ private final SetHashtagsOp.Factory hashtagsFactory; private final SetTopicOp.Factory setTopicFactory; private final ServiceUserClassifier serviceUserClassifier; + private final RequestCounter requestCounter; private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners; private final TagCache tagCache; private final ProjectConfig.Factory projectConfigFactory; @@ -453,6 +457,10 @@ // Collections populated during processing. private final Queue<ValidationMessage> messages; + // Map that maps a commit SHA1 to its validation results (map that maps a validator name to its + // result). + private final Map<String, ImmutableMap<String, CommitValidationInfo>> validationInfosByCommit; + /** Multimap of error text to refnames that produced that error. */ private final ListMultimap<String, String> errors; @@ -536,7 +544,8 @@ @Assisted ReceivePack rp, @Assisted Repository repository, @Assisted AllRefsWatcher allRefsWatcher, - @Nullable @Assisted MessageSender messageSender) + @Assisted @Nullable MessageSender messageSender, + @Assisted @Nullable RequestCounter requestCounter) throws IOException { // Injected fields. this.accountResolver = accountResolver; @@ -580,6 +589,7 @@ this.receiveConfig = receiveConfig; this.refValidatorsFactory = refValidatorsFactory; this.replaceOpFactory = replaceOpFactory; + this.requestCounter = requestCounter; this.requestListeners = requestListeners; this.retryHelper = retryHelper; this.requestScopePropagator = requestScopePropagator; @@ -606,6 +616,8 @@ rejectCommits = BanCommit.loadRejectCommitsMap(repo, rp.getRevWalk()); // Collections populated during processing. + validationInfosByCommit = new LinkedHashMap<>(); + errors = MultimapBuilder.linkedHashKeys().arrayListValues().build(); rejectionReasons = new LinkedHashMap<>(); messages = new ConcurrentLinkedQueue<>(); @@ -706,7 +718,8 @@ TraceTimer traceTimer = newTimer("processCommands", Metadata.builder().resourceCount(commandCount))) { RequestInfo requestInfo = - RequestInfo.builder(RequestInfo.RequestType.GIT_RECEIVE, user, traceContext) + RequestInfo.builder( + RequestInfo.RequestType.GIT_RECEIVE, "git-receive-pack", user, traceContext) .project(project.getNameKey()) .build(); requestListeners.runEach(l -> l.onRequest(requestInfo)); @@ -722,6 +735,7 @@ commands = commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList()); + Throwable error = null; try (RequestStateContext requestStateContext = RequestStateContext.open() .addRequestStateProvider(progress) @@ -732,9 +746,11 @@ commands, RejectionReason.create(MetricBucket.INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR)); } catch (InvalidDeadlineException e) { + error = e; rejectRemaining( commands, RejectionReason.create(MetricBucket.INVALID_DEADLINE, e.getMessage())); } catch (RuntimeException e) { + error = e; Optional<RequestCancelledException> requestCancelledException = RequestCancelledException.getFromCausalChain(e); if (!requestCancelledException.isPresent()) { @@ -764,6 +780,10 @@ } rejectRemaining(commands, RejectionReason.create(metricBucket, msg.toString())); + } finally { + if (requestCounter != null) { + requestCounter.countRequest(requestInfo, error); + } } // This sends error messages before the 'done' string of the progress monitor is sent. @@ -2657,6 +2677,9 @@ "Creating new change for %s even though it is already tracked", name); } + // Validate the received commits. Do not invoke the CommitValidationInfoListener's yet + // because we create changes/patch-sets for the commits only later and we need to provide + // the patch set ID, that we don't know yet, to CommitValidationInfoListener's. BranchCommitValidator.Result validationResult = validator.validateCommit( repo, @@ -2668,7 +2691,9 @@ ImmutableListMultimap.copyOf(pushOptions), magicBranch.merged, rejectCommits, - null); + /* invokeCommitValidationInfoListeners= */ false, + /* change= */ null); + validationInfosByCommit.put(c.name(), validationResult.validationInfos()); messages.addAll(validationResult.messages()); if (!validationResult.isValid()) { // Not a change the user can propose? Abort as early as possible. @@ -3021,8 +3046,10 @@ .setTopic(magicBranch.topic) .setPrivate(setChangeAsPrivate) .setWorkInProgress(magicBranch.shouldSetWorkInProgressOnNewChanges()) - // Changes already validated in validateNewCommits. - .setValidate(false); + // The commit has already been validated in + // selectNewAndReplacedChangesFromMagicBranch. + .setValidationOptions(ImmutableListMultimap.copyOf(pushOptions)) + .disableValidation(validationInfosByCommit.get(commit.name())); if (magicBranch.merged) { ins.setStatus(Change.Status.MERGED); @@ -3537,6 +3564,10 @@ priorCommit, psId, newCommit, + // The commit has already been validated in + // selectNewAndReplacedChangesFromMagicBranch. + ImmutableListMultimap.copyOf(pushOptions), + validationInfosByCommit.get(newCommit.name()), info, groups, magicBranch, @@ -3751,9 +3782,10 @@ cmd, c, ImmutableListMultimap.copyOf(pushOptions), - false, + /* isMerged= */ false, rejectCommits, - null, + /* invokeCommitValidationInfoListeners= */ true, + /* change= */ null, skipValidation); messages.addAll(validationResult.messages()); if (!validationResult.isValid()) {
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java index 60e1a09..87ed6e7 100644 --- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java +++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -26,6 +26,8 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Streams; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; @@ -60,16 +62,21 @@ import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList; import com.google.gerrit.server.change.ReviewerOp; import com.google.gerrit.server.config.AnonymousCowardName; +import com.google.gerrit.server.events.CommitReceivedEvent; import com.google.gerrit.server.extensions.events.CommentAdded; 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.CommitValidationInfo; +import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.TopicValidator; import com.google.gerrit.server.mail.MailUtil.MailRecipients; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.notedb.ChangeUpdate; +import com.google.gerrit.server.patch.DiffOperationsForCommitValidation; import com.google.gerrit.server.permissions.PermissionBackendException; +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; @@ -111,6 +118,8 @@ @Assisted("priorCommitId") ObjectId priorCommit, @Assisted("patchSetId") PatchSet.Id patchSetId, @Assisted("commitId") ObjectId commitId, + ImmutableListMultimap<String, String> pushOptions, + ImmutableMap<String, CommitValidationInfo> validationInfos, PatchSetInfo info, List<String> groups, @Nullable MagicBranchInput magicBranch, @@ -136,6 +145,8 @@ private final ReviewerModifier reviewerModifier; private final ChangeUtil changeUtil; private final TopicValidator topicValidator; + private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory; + private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners; private final ProjectState projectState; private final Change change; @@ -145,6 +156,8 @@ private final ObjectId priorCommitId; private final PatchSet.Id patchSetId; private final ObjectId commitId; + private final ImmutableListMultimap<String, String> pushOptions; + private final ImmutableMap<String, CommitValidationInfo> validationInfos; private final PatchSetInfo info; private final MagicBranchInput magicBranch; private final PushCertificate pushCertificate; @@ -182,6 +195,8 @@ ReviewerModifier reviewerModifier, ChangeUtil changeUtil, TopicValidator topicValidator, + DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory, + PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners, @Assisted ProjectState projectState, @Assisted Change change, @Assisted boolean checkMergedInto, @@ -190,6 +205,8 @@ @Assisted("priorCommitId") ObjectId priorCommitId, @Assisted("patchSetId") PatchSet.Id patchSetId, @Assisted("commitId") ObjectId commitId, + @Assisted ImmutableListMultimap<String, String> pushOptions, + @Assisted @Nullable ImmutableMap<String, CommitValidationInfo> validationInfos, @Assisted PatchSetInfo info, @Assisted List<String> groups, @Assisted @Nullable MagicBranchInput magicBranch, @@ -211,6 +228,8 @@ this.reviewerModifier = reviewerModifier; this.changeUtil = changeUtil; this.topicValidator = topicValidator; + this.diffOperationsForCommitValidationFactory = diffOperationsForCommitValidationFactory; + this.commitValidationInfoListeners = commitValidationInfoListeners; this.projectState = projectState; this.change = change; @@ -220,6 +239,8 @@ this.priorCommitId = priorCommitId.copy(); this.patchSetId = patchSetId; this.commitId = commitId.copy(); + this.pushOptions = pushOptions; + this.validationInfos = validationInfos; this.info = info; this.groups = groups; this.magicBranch = magicBranch; @@ -236,6 +257,7 @@ projectState.getNameKey(), ctx.getRevWalk(), ctx.getRepoView().getConfig(), + ctx.getRepoView().getAttributesNodeProvider(), priorCommitId, commitId); @@ -254,6 +276,25 @@ cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, patchSetId.toRefName()); ctx.addRefUpdate(cmd); + + if (validationInfos != null) { + try (CommitReceivedEvent event = + new CommitReceivedEvent( + cmd, + projectState.getProject(), + change.getDest().branch(), + pushOptions, + ctx.getRepoView().getConfig(), + ctx.getRevWalk().getObjectReader(), + commitId, + ctx.getIdentifiedUser(), + diffOperationsForCommitValidationFactory.create( + ctx.getRepoView(), ctx.getInserter()))) { + commitValidationInfoListeners.runEach( + commitValidationInfoListener -> + commitValidationInfoListener.commitValidated(validationInfos, event, patchSetId)); + } + } } @Override @@ -432,7 +473,7 @@ message.append("\n\n").append(reviewMessage); } approvalsUtil - .formatApprovalCopierResult(approvalCopierResult, projectState.getLabelTypes()) + .formatApprovalCopierResult(approvalCopierResult) .ifPresent( msg -> { if (Strings.isNullOrEmpty(reviewMessage) || !reviewMessage.endsWith("\n")) {
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationInfo.java b/java/com/google/gerrit/server/git/validators/CommitValidationInfo.java new file mode 100644 index 0000000..85bbded --- /dev/null +++ b/java/com/google/gerrit/server/git/validators/CommitValidationInfo.java
@@ -0,0 +1,73 @@ +// 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.validators; + +import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * Result of invoking a {@link CommitValidationListener} if the commit passed the validator. + * + * <p>Note, if a commit is rejected by a {@link CommitValidationListener} it throws a {@link + * CommitValidationException} and no {@code CommitValidationInfo} is returned. Hence {@code + * CommitValidationInfo} doesn't cover rejections. + */ +@AutoValue +public abstract class CommitValidationInfo { + /** Empty metadata map. */ + public static final ImmutableMap<String, String> NO_METADATA = ImmutableMap.of(); + + public enum Status { + /** The validation has been performed and the commit passed the validation. */ + PASSED, + + /** + * The validation was not done because it was not applicable, for example the validator + * configuration didn't match the commit that was uploaded/created. + */ + NOT_APPLICABLE, + + /** The validation has been skipped by the user. */ + SKIPPED_BY_USER, + } + + /** Status of the commit validation run. */ + public abstract Status status(); + + /** + * Metadata about the commit validation that has been performed, for example the version ID of the + * configuration that was used for the commit validation or the SHA1 from which the configuration + * that was used for the commit validation was read. + */ + public abstract ImmutableMap<String, String> metadata(); + + /** Validation messages collected during the commit validation run. */ + public abstract ImmutableList<CommitValidationMessage> validationMessages(); + + public static CommitValidationInfo passed( + ImmutableMap<String, String> metadata, + ImmutableList<CommitValidationMessage> validationMessages) { + return new AutoValue_CommitValidationInfo(Status.PASSED, metadata, validationMessages); + } + + public static CommitValidationInfo notApplicable(ImmutableMap<String, String> metadata) { + return new AutoValue_CommitValidationInfo(Status.NOT_APPLICABLE, metadata, ImmutableList.of()); + } + + public static CommitValidationInfo skippedByUser(ImmutableMap<String, String> metadata) { + return new AutoValue_CommitValidationInfo(Status.SKIPPED_BY_USER, metadata, ImmutableList.of()); + } +}
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationInfoListener.java b/java/com/google/gerrit/server/git/validators/CommitValidationInfoListener.java new file mode 100644 index 0000000..27c0059 --- /dev/null +++ b/java/com/google/gerrit/server/git/validators/CommitValidationInfoListener.java
@@ -0,0 +1,50 @@ +// 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.validators; + +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.extensions.annotations.ExtensionPoint; +import com.google.gerrit.server.events.CommitReceivedEvent; + +/** + * Extension point that is invoked after a commit has passed the validations that are done by {@code + * CommitValidationListener}'s. + * + * <p>If any {@code CommitValidationListener} rejects the commit (by throwing a {@code + * CommitValidationException}) this extension point is not invoked. + */ +@ExtensionPoint +public interface CommitValidationInfoListener { + /** + * Invoked after a commit has passed the validation that is done by {@code + * CommitValidationListener}'s + * + * <p>Not invoked if any {@code CommitValidationListener} rejects the commit (by throwing a {@code + * CommitValidationException}). + * + * @param validationInfoByValidator Map that maps a validator name to a {@link + * CommitValidationInfo} (result of running the validator). + * @param receiveEvent The receive event for which the validation was done. Contains data about + * which commit was validated and what is the updated ref. + * @param patchSetId if the validation was done for a patch set, the ID of the patch set, + * otherwise {@code null} + */ + void commitValidated( + ImmutableMap<String, CommitValidationInfo> validationInfoByValidator, + CommitReceivedEvent receiveEvent, + @Nullable PatchSet.Id patchSetId); +}
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidationListener.java b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java index 9f68c0d..d1c8e3d 100644 --- a/java/com/google/gerrit/server/git/validators/CommitValidationListener.java +++ b/java/com/google/gerrit/server/git/validators/CommitValidationListener.java
@@ -14,6 +14,9 @@ package com.google.gerrit.server.git.validators; +import static com.google.gerrit.server.git.validators.CommitValidationInfo.NO_METADATA; + +import com.google.common.collect.ImmutableList; import com.google.gerrit.extensions.annotations.ExtensionPoint; import com.google.gerrit.server.events.CommitReceivedEvent; import java.util.List; @@ -32,14 +35,45 @@ @ExtensionPoint public interface CommitValidationListener { /** - * Commit validation. + * Name of the validator. + * + * <p>Must return a unique name (i.e. a name that is not used by any other validator). + */ + default String getValidatorName() { + return getClass().getName(); + } + + /** + * Runs a commit validation. + * + * <p>This method only exist for backwards-compatibility and doesn't need to be implemented when + * {@link #validateCommit(CommitReceivedEvent)} is implemented. * * @param receiveEvent commit event details - * @return list of validation messages - * @throws CommitValidationException if validation fails + * @return list of validation messages if the commit passes the validation + * @throws CommitValidationException if validation fails and the commit is rejected + * @deprecated use {@link #validateCommit(CommitReceivedEvent)} instead */ - List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) - throws CommitValidationException; + @Deprecated + default List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) + throws CommitValidationException { + throw new IllegalStateException("not implemented"); + } + + /** + * Runs a commit validation. + * + * <p>Implement this method instead of {@link #onCommitReceived(CommitReceivedEvent)}. + * + * @param receiveEvent commit event details + * @return result of the commit validation if the commit passes the validation + * @throws CommitValidationException if validation fails and the commit is rejected + */ + default CommitValidationInfo validateCommit(CommitReceivedEvent receiveEvent) + throws CommitValidationException { + return CommitValidationInfo.passed( + NO_METADATA, ImmutableList.copyOf(onCommitReceived(receiveEvent))); + } /** * Whether this validator should validate all commits.
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java index 2311240..9696e12 100644 --- a/java/com/google/gerrit/server/git/validators/CommitValidators.java +++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -15,26 +15,28 @@ package com.google.gerrit.server.git.validators; import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN; import static com.google.gerrit.entities.RefNames.REFS_CHANGES; import static com.google.gerrit.entities.RefNames.REFS_CONFIG; +import static com.google.gerrit.server.git.validators.CommitValidationInfo.NO_METADATA; import static com.google.gerrit.server.project.ProjectCache.illegalState; -import static java.util.stream.Collectors.toList; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Streams; import com.google.common.flogger.FluentLogger; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.FooterConstants; import com.google.gerrit.common.Nullable; -import com.google.gerrit.entities.Account; import com.google.gerrit.entities.BooleanProjectConfig; import com.google.gerrit.entities.BranchNameKey; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Patch; +import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.RefNames; -import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo; import com.google.gerrit.extensions.registration.DynamicItem; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.metrics.Counter2; @@ -44,14 +46,11 @@ import com.google.gerrit.server.ChangeUtil; import com.google.gerrit.server.GerritPersonIdent; import com.google.gerrit.server.IdentifiedUser; -import com.google.gerrit.server.account.AccountCache; -import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker; import com.google.gerrit.server.config.AllProjectsName; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.UrlFormatter; import com.google.gerrit.server.events.CommitReceivedEvent; -import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.git.ValidationError; import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.TraceContext; @@ -62,6 +61,7 @@ import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.server.permissions.RefPermission; import com.google.gerrit.server.plugincontext.PluginSetContext; +import com.google.gerrit.server.plugincontext.PluginSetEntryContext; import com.google.gerrit.server.project.LabelConfigValidator; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.project.ProjectConfig; @@ -77,6 +77,7 @@ import java.net.URL; import java.util.ArrayList; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -85,7 +86,6 @@ import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.PersonIdent; -import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.notes.NoteMap; import org.eclipse.jgit.revwalk.FooterKey; import org.eclipse.jgit.revwalk.FooterLine; @@ -108,18 +108,15 @@ private final PersonIdent gerritIdent; private final DynamicItem<UrlFormatter> urlFormatter; private final PluginSetContext<CommitValidationListener> pluginValidators; - private final GitRepositoryManager repoManager; private final AllUsersName allUsers; private final AllProjectsName allProjects; - private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; - private final AccountValidator accountValidator; - private final AccountCache accountCache; private final ProjectCache projectCache; private final ProjectConfig.Factory projectConfigFactory; private final Config config; private final ChangeUtil changeUtil; private final MetricMaker metricMaker; private final ApprovalQueryBuilder approvalQueryBuilder; + private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners; @Inject Factory( @@ -127,32 +124,26 @@ DynamicItem<UrlFormatter> urlFormatter, @GerritServerConfig Config config, PluginSetContext<CommitValidationListener> pluginValidators, - GitRepositoryManager repoManager, AllUsersName allUsers, AllProjectsName allProjects, - ExternalIdsConsistencyChecker externalIdsConsistencyChecker, - AccountValidator accountValidator, - AccountCache accountCache, ProjectCache projectCache, ProjectConfig.Factory projectConfigFactory, ChangeUtil changeUtil, MetricMaker metricMaker, - ApprovalQueryBuilder approvalQueryBuilder) { + ApprovalQueryBuilder approvalQueryBuilder, + PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners) { this.gerritIdent = gerritIdent; this.urlFormatter = urlFormatter; this.config = config; this.pluginValidators = pluginValidators; - this.repoManager = repoManager; this.allUsers = allUsers; this.allProjects = allProjects; - this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; - this.accountValidator = accountValidator; - this.accountCache = accountCache; this.projectCache = projectCache; this.projectConfigFactory = projectConfigFactory; this.changeUtil = changeUtil; this.metricMaker = metricMaker; this.approvalQueryBuilder = approvalQueryBuilder; + this.commitValidationInfoListeners = commitValidationInfoListeners; } public CommitValidators forReceiveCommits( @@ -180,13 +171,19 @@ new ChangeIdValidator( changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change)) .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)) - .add(new BannedCommitsValidator(rejectCommits)) - .add(new PluginCommitValidationListener(pluginValidators, skipValidation)) - .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache)) - .add(new AccountCommitValidator(repoManager, allUsers, accountValidator)) + .add(new BannedCommitsValidator(rejectCommits)); + + Iterator<PluginSetEntryContext<CommitValidationListener>> pluginValidatorsIt = + pluginValidators.iterator(); + while (pluginValidatorsIt.hasNext()) { + validators.add(skippablePluginValidator(pluginValidatorsIt.next().get(), skipValidation)); + } + + validators .add(new GroupCommitValidator(allUsers)) .add(new LabelConfigValidator(approvalQueryBuilder)); - return new CommitValidators(validators.build()); + + return new CommitValidators(commitValidationInfoListeners, validators.build()); } public CommitValidators forGerritCommits( @@ -210,13 +207,19 @@ .add( new ChangeIdValidator( changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change)) - .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)) - .add(new PluginCommitValidationListener(pluginValidators)) - .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache)) - .add(new AccountCommitValidator(repoManager, allUsers, accountValidator)) + .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)); + + Iterator<PluginSetEntryContext<CommitValidationListener>> pluginValidatorsIt = + pluginValidators.iterator(); + while (pluginValidatorsIt.hasNext()) { + validators.add(pluginValidatorsIt.next().get()); + } + + validators .add(new GroupCommitValidator(allUsers)) .add(new LabelConfigValidator(approvalQueryBuilder)); - return new CommitValidators(validators.build()); + + return new CommitValidators(commitValidationInfoListeners, validators.build()); } public CommitValidators forMergedCommits( @@ -243,22 +246,84 @@ .add(new ProjectStateValidationListener(projectState)) .add(new AuthorUploaderValidator(user, perm, urlFormatter.get())) .add(new CommitterUploaderValidator(user, perm, urlFormatter.get())); - return new CommitValidators(validators.build()); + return new CommitValidators(commitValidationInfoListeners, validators.build()); + } + + CommitValidationListener skippablePluginValidator( + CommitValidationListener pluginValidator, boolean skipValidation) { + return new CommitValidationListener() { + @Override + public String getValidatorName() { + return pluginValidator.getValidatorName(); + } + + @Override + public CommitValidationInfo validateCommit(CommitReceivedEvent receiveEvent) + throws CommitValidationException { + if (skipValidation && !pluginValidator.shouldValidateAllCommits()) { + return CommitValidationInfo.skippedByUser(NO_METADATA); + } + return pluginValidator.validateCommit(receiveEvent); + } + }; } } + private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners; private final List<CommitValidationListener> validators; - CommitValidators(List<CommitValidationListener> validators) { + @Nullable private PatchSet.Id patchSetId; + private boolean invokeCommitValidationInfoListeners = true; + + CommitValidators( + PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners, + List<CommitValidationListener> validators) { + this.commitValidationInfoListeners = commitValidationInfoListeners; this.validators = validators; } + /** + * Sets the patch set for which the validation is done. + * + * <p>If the validation is done for a commit that is not associated with a patch set (e.g. when + * commits are pushed directly, bypassing code-review) this method doesn't need to be called. + * + * @param patchSetId the patch set for which the validation is done + * @return the {@link CommitValidators} instance to allow chaining calls + */ @CanIgnoreReturnValue - public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent) + public CommitValidators patchSet(PatchSet.Id patchSetId) { + this.patchSetId = patchSetId; + return this; + } + + /** + * Whether the {@link CommitValidationInfoListener}s should be invoked after the validation is + * done. + * + * <p>If invoking the {@link CommitValidationInfoListener}s is skipped, it's the responsibility of + * the caller to invoke them later. For example, this makes sense when commits are validated and + * the information about the change/patch-set (see {@link + * #patchSet(com.google.gerrit.entities.PatchSet.Id)} is not available yet. + * + * @param invokeCommitValidationInfoListeners Whether the {@link CommitValidationInfoListener}s + * should be invoked after the validation is done. + * @return the {@link CommitValidators} instance to allow chaining calls + */ + @CanIgnoreReturnValue + public CommitValidators invokeCommitValidationInfoListeners( + boolean invokeCommitValidationInfoListeners) { + this.invokeCommitValidationInfoListeners = invokeCommitValidationInfoListeners; + return this; + } + + @CanIgnoreReturnValue + public ImmutableMap<String, CommitValidationInfo> validate(CommitReceivedEvent receiveEvent) throws CommitValidationException { - List<CommitValidationMessage> messages = new ArrayList<>(); - try { - for (CommitValidationListener commitValidator : validators) { + ImmutableMap.Builder<String, CommitValidationInfo> validationInfosBuilder = + ImmutableMap.builder(); + for (CommitValidationListener commitValidator : validators) { + try { try (TraceTimer ignored = TraceContext.newTimer( "Running CommitValidationListener", @@ -268,17 +333,37 @@ .branchName(receiveEvent.getBranchNameKey().branch()) .commit(receiveEvent.commit.name()) .build())) { - messages.addAll(commitValidator.onCommitReceived(receiveEvent)); + CommitValidationInfo commitValidationInfo = commitValidator.validateCommit(receiveEvent); + logger.atFine().log( + "commit %s has passed validator %s: %s", + receiveEvent.commit.name(), commitValidator.getValidatorName(), commitValidationInfo); + validationInfosBuilder.put(commitValidator.getValidatorName(), commitValidationInfo); } + } catch (CommitValidationException e) { + // Keep the old messages (and their order) in case of an exception + ImmutableList<CommitValidationMessage> messages = + Streams.concat( + validationInfosBuilder.build().values().stream() + .flatMap(validationInfo -> validationInfo.validationMessages().stream()), + e.getMessages().stream()) + .collect(toImmutableList()); + logger.atFine().withCause(e).log( + "commit %s was rejected by validator %s: %s", + receiveEvent.commit.name(), commitValidator.getValidatorName(), messages); + throw new CommitValidationException(e.getMessage(), messages); } - } catch (CommitValidationException e) { - logger.atFine().withCause(e).log( - "CommitValidationException occurred: %s", e.getFullMessage()); - // Keep the old messages (and their order) in case of an exception - messages.addAll(e.getMessages()); - throw new CommitValidationException(e.getMessage(), messages); } - return messages; + + ImmutableMap<String, CommitValidationInfo> validationInfos = validationInfosBuilder.build(); + + if (invokeCommitValidationInfoListeners) { + commitValidationInfoListeners.runEach( + commitValidationInfoListener -> + commitValidationInfoListener.commitValidated( + validationInfos, receiveEvent, patchSetId)); + } + + return validationInfos; } public static class ChangeIdValidator implements CommitValidationListener { @@ -641,55 +726,6 @@ } } - /** Execute commit validation plug-ins */ - public static class PluginCommitValidationListener implements CommitValidationListener { - private final boolean skipValidation; - private final PluginSetContext<CommitValidationListener> commitValidationListeners; - - public PluginCommitValidationListener( - final PluginSetContext<CommitValidationListener> commitValidationListeners) { - this(commitValidationListeners, false); - } - - public PluginCommitValidationListener( - final PluginSetContext<CommitValidationListener> commitValidationListeners, - boolean skipValidation) { - this.skipValidation = skipValidation; - this.commitValidationListeners = commitValidationListeners; - } - - private void runValidator( - CommitValidationListener validator, - List<CommitValidationMessage> messages, - CommitReceivedEvent receiveEvent) - throws CommitValidationException { - if (skipValidation && !validator.shouldValidateAllCommits()) { - return; - } - messages.addAll(validator.onCommitReceived(receiveEvent)); - } - - @Override - public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) - throws CommitValidationException { - List<CommitValidationMessage> messages = new ArrayList<>(); - try { - commitValidationListeners.runEach( - l -> runValidator(l, messages, receiveEvent), CommitValidationException.class); - } catch (CommitValidationException e) { - messages.addAll(e.getMessages()); - throw new CommitValidationException(e.getMessage(), messages); - } - return messages; - } - - @Override - public boolean shouldValidateAllCommits() { - return commitValidationListeners.stream() - .anyMatch(CommitValidationListener::shouldValidateAllCommits); - } - } - public static class SignedOffByValidator implements CommitValidationListener { private final IdentifiedUser user; private final PermissionBackend.ForRef perm; @@ -872,106 +908,6 @@ } } - /** Validates updates to refs/meta/external-ids. */ - public static class ExternalIdUpdateListener implements CommitValidationListener { - private final AllUsersName allUsers; - private final AccountCache accountCache; - private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; - - public ExternalIdUpdateListener( - AllUsersName allUsers, - ExternalIdsConsistencyChecker externalIdsConsistencyChecker, - AccountCache accountCache) { - this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; - this.allUsers = allUsers; - this.accountCache = accountCache; - } - - @Override - public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) - throws CommitValidationException { - if (allUsers.equals(receiveEvent.project.getNameKey()) - && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) { - try { - List<ConsistencyProblemInfo> problems = - externalIdsConsistencyChecker.check(accountCache, receiveEvent.commit); - List<CommitValidationMessage> msgs = - problems.stream() - .map( - p -> - new CommitValidationMessage( - p.message, - p.status == ConsistencyProblemInfo.Status.ERROR - ? ValidationMessage.Type.ERROR - : ValidationMessage.Type.OTHER)) - .collect(toList()); - if (msgs.stream().anyMatch(ValidationMessage::isError)) { - throw new CommitValidationException("invalid external IDs", msgs); - } - return msgs; - } catch (IOException | ConfigInvalidException e) { - throw new CommitValidationException("error validating external IDs", e); - } - } - return Collections.emptyList(); - } - } - - public static class AccountCommitValidator implements CommitValidationListener { - private final GitRepositoryManager repoManager; - private final AllUsersName allUsers; - private final AccountValidator accountValidator; - - public AccountCommitValidator( - GitRepositoryManager repoManager, - AllUsersName allUsers, - AccountValidator accountValidator) { - this.repoManager = repoManager; - this.allUsers = allUsers; - this.accountValidator = accountValidator; - } - - @Override - public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) - throws CommitValidationException { - if (!allUsers.equals(receiveEvent.project.getNameKey())) { - return Collections.emptyList(); - } - - if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) { - // no validation on push for review, will be checked on submit by - // MergeValidators.AccountMergeValidator - return Collections.emptyList(); - } - - Account.Id accountId = Account.Id.fromRef(receiveEvent.refName); - if (accountId == null) { - return Collections.emptyList(); - } - - try (Repository repo = repoManager.openRepository(allUsers)) { - List<String> errorMessages = - accountValidator.validate( - accountId, - repo, - receiveEvent.revWalk, - receiveEvent.command.getOldId(), - receiveEvent.commit); - if (!errorMessages.isEmpty()) { - throw new CommitValidationException( - "invalid account configuration", - errorMessages.stream() - .map(m -> new CommitValidationMessage(m, ValidationMessage.Type.ERROR)) - .collect(toList())); - } - } catch (IOException e) { - throw new CommitValidationException( - String.format("Validating update for account %s failed", accountId.get()), e); - } - return Collections.emptyList(); - } - } - /** Rejects updates to group branches. */ public static class GroupCommitValidator implements CommitValidationListener { private final AllUsersName allUsers;
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java index c8a3d1e..654b5d0 100644 --- a/java/com/google/gerrit/server/git/validators/MergeValidators.java +++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -14,10 +14,8 @@ package com.google.gerrit.server.git.validators; -import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; -import com.google.gerrit.entities.Account; import com.google.gerrit.entities.BranchNameKey; import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.Project; @@ -28,7 +26,6 @@ import com.google.gerrit.extensions.registration.Extension; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.server.IdentifiedUser; -import com.google.gerrit.server.account.AccountProperties; import com.google.gerrit.server.config.AllProjectsName; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.GerritServerConfig; @@ -48,7 +45,6 @@ import com.google.gerrit.server.query.change.ChangeData; import com.google.inject.Inject; import java.io.IOException; -import java.util.List; import java.util.Objects; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.Config; @@ -69,7 +65,6 @@ private final PluginSetContext<MergeValidationListener> mergeValidationListeners; private final ProjectConfigValidator.Factory projectConfigValidatorFactory; - private final AccountMergeValidator.Factory accountValidatorFactory; private final GroupMergeValidator.Factory groupValidatorFactory; public interface Factory { @@ -80,11 +75,9 @@ MergeValidators( PluginSetContext<MergeValidationListener> mergeValidationListeners, ProjectConfigValidator.Factory projectConfigValidatorFactory, - AccountMergeValidator.Factory accountValidatorFactory, GroupMergeValidator.Factory groupValidatorFactory) { this.mergeValidationListeners = mergeValidationListeners; this.projectConfigValidatorFactory = projectConfigValidatorFactory; - this.accountValidatorFactory = accountValidatorFactory; this.groupValidatorFactory = groupValidatorFactory; } @@ -105,7 +98,6 @@ ImmutableList.of( new PluginMergeValidationListener(mergeValidationListeners), projectConfigValidatorFactory.create(), - accountValidatorFactory.create(), groupValidatorFactory.create(), new DestBranchRefValidator()); @@ -280,65 +272,6 @@ } } - public static class AccountMergeValidator implements MergeValidationListener { - public interface Factory { - AccountMergeValidator create(); - } - - private final AllUsersName allUsersName; - private final ChangeData.Factory changeDataFactory; - private final AccountValidator accountValidator; - - @Inject - public AccountMergeValidator( - AllUsersName allUsersName, - ChangeData.Factory changeDataFactory, - AccountValidator accountValidator) { - this.allUsersName = allUsersName; - this.changeDataFactory = changeDataFactory; - this.accountValidator = accountValidator; - } - - @Override - public void onPreMerge( - Repository repo, - CodeReviewRevWalk revWalk, - CodeReviewCommit commit, - ProjectState destProject, - BranchNameKey destBranch, - PatchSet.Id patchSetId, - IdentifiedUser caller) - throws MergeValidationException { - Account.Id accountId = Account.Id.fromRef(destBranch.branch()); - if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) { - return; - } - - ChangeData cd = - changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId()); - try { - if (!cd.currentFilePaths().contains(AccountProperties.ACCOUNT_CONFIG)) { - return; - } - } catch (StorageException e) { - logger.atSevere().withCause(e).log("Cannot validate account update"); - throw new MergeValidationException("account validation unavailable", e); - } - - try { - List<String> errorMessages = - accountValidator.validate(accountId, repo, revWalk, null, commit); - if (!errorMessages.isEmpty()) { - throw new MergeValidationException( - "invalid account configuration: " + Joiner.on("; ").join(errorMessages)); - } - } catch (IOException e) { - logger.atSevere().withCause(e).log("Cannot validate account update"); - throw new MergeValidationException("account validation unavailable", e); - } - } - } - /** Validator to ensure that group refs are not mutated. */ public static class GroupMergeValidator implements MergeValidationListener { public interface Factory {
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java index d466041..e6bd019 100644 --- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java +++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -15,18 +15,38 @@ package com.google.gerrit.server.group; import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Sets; import com.google.common.flogger.FluentLogger; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; import com.google.gerrit.entities.AccountGroup; import com.google.gerrit.entities.GroupReference; +import com.google.gerrit.entities.InternalGroup; +import com.google.gerrit.index.IndexConfig; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.query.DataSource; +import com.google.gerrit.index.query.FieldBundle; +import com.google.gerrit.index.query.Predicate; +import com.google.gerrit.index.query.QueryParseException; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.group.db.GroupNameNotes; +import com.google.gerrit.server.index.IndexExecutor; +import com.google.gerrit.server.index.group.GroupField; +import com.google.gerrit.server.index.group.GroupIndexCollection; import com.google.gerrit.server.index.group.GroupIndexer; import com.google.inject.Inject; import com.google.inject.Provider; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.eclipse.jgit.lib.Repository; /** @@ -41,58 +61,87 @@ * information until the next indexing happens. The interval on which group indexing is done is * configurable by setting {@code index.scheduledIndexer.interval} in {@code gerrit.config}. By * default group indexing is done every 5 minutes. - * - * <p>This class is not able to detect group deletions that were replicated while the slave was - * offline. This means if group refs are deleted while the slave is offline these groups are not - * removed from the group index when the slave is started. However since group deletion is not - * supported this should never happen and one can always do an offline reindex before starting the - * slave. */ public class PeriodicGroupIndexer implements Runnable { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private final AllUsersName allUsersName; private final GitRepositoryManager repoManager; + private final ListeningExecutorService executor; + private final GroupIndexCollection indexes; private final Provider<GroupIndexer> groupIndexerProvider; - - private ImmutableSet<AccountGroup.UUID> groupUuids; + private final IndexConfig indexConfig; @Inject PeriodicGroupIndexer( AllUsersName allUsersName, GitRepositoryManager repoManager, + @IndexExecutor(BATCH) ListeningExecutorService executor, + GroupIndexCollection indexes, + IndexConfig indexConfig, Provider<GroupIndexer> groupIndexerProvider) { this.allUsersName = allUsersName; this.repoManager = repoManager; + this.executor = executor; + this.indexes = indexes; + this.indexConfig = indexConfig; this.groupIndexerProvider = groupIndexerProvider; } @Override public synchronized void run() { try (Repository allUsers = repoManager.openRepository(allUsersName)) { - ImmutableSet<AccountGroup.UUID> newGroupUuids = + ImmutableSet<AccountGroup.UUID> allGroups = GroupNameNotes.loadAllGroups(allUsers).stream() .map(GroupReference::getUUID) .collect(toImmutableSet()); GroupIndexer groupIndexer = groupIndexerProvider.get(); - int reindexCounter = 0; - for (AccountGroup.UUID groupUuid : newGroupUuids) { - if (groupIndexer.reindexIfStale(groupUuid)) { - reindexCounter++; - } + AtomicInteger reindexCounter = new AtomicInteger(); + List<ListenableFuture<?>> indexingTasks = new ArrayList<>(); + for (AccountGroup.UUID groupUuid : allGroups) { + indexingTasks.add( + executor.submit( + () -> { + if (groupIndexer.reindexIfStale(groupUuid)) { + reindexCounter.incrementAndGet(); + } + })); } - if (groupUuids != null) { - // Check if any group was deleted since the last run and if yes remove these groups from the - // index. - for (AccountGroup.UUID groupUuid : Sets.difference(groupUuids, newGroupUuids)) { - groupIndexer.index(groupUuid); - reindexCounter++; - } + + Set<AccountGroup.UUID> groupsInIndex = queryAllGroupsFromIndex(); + for (AccountGroup.UUID groupUuid : Sets.difference(groupsInIndex, allGroups)) { + indexingTasks.add( + executor.submit( + () -> { + groupIndexer.index(groupUuid); + reindexCounter.incrementAndGet(); + })); } - groupUuids = newGroupUuids; + Futures.successfulAsList(indexingTasks).get(); logger.atInfo().log("Run group indexer, %s groups reindexed", reindexCounter); } catch (Exception t) { logger.atSevere().withCause(t).log("Failed to reindex groups"); } } + + private Set<AccountGroup.UUID> queryAllGroupsFromIndex() { + try { + DataSource<InternalGroup> result = + indexes + .getSearchIndex() + .getSource( + Predicate.any(), + QueryOptions.create( + indexConfig, 0, Integer.MAX_VALUE, Set.of(GroupField.UUID_FIELD.name()))); + return StreamSupport.stream(result.readRaw().spliterator(), false) + .map(f -> fromUUIDField(f)) + .collect(Collectors.toUnmodifiableSet()); + } catch (QueryParseException e) { + throw new RuntimeException(e); + } + } + + private static AccountGroup.UUID fromUUIDField(FieldBundle f) { + return AccountGroup.uuid(f.<String>getValue(GroupField.UUID_FIELD_SPEC)); + } }
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java index 7c4fb16f9..3c7e012 100644 --- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java +++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -167,6 +167,37 @@ } /** + * Creates an instance of {@code GroupNameNotes} for use when deleting a new group. + * + * <p><strong>Note: </strong>The returned instance of {@code GroupNameNotes} has to be committed + * via {@link #commit(com.google.gerrit.server.git.meta.MetaDataUpdate) commit(MetaDataUpdate)} in + * order to delete the group. + * + * @param projectName the name of the project which holds the commits of the notes + * @param repository the repository which holds the commits of the notes + * @param groupUuid the UUID of the group to delete. + * @param groupName the name of the group to delete. + * @return an instance of {@code GroupNameNotes} configured for a specific group deletion + * @throws IOException if the repository can't be accessed for some reason + * @throws ConfigInvalidException in no case so far + */ + public static GroupNameNotes forDeletingGroup( + Project.NameKey projectName, + Repository repository, + AccountGroup.UUID groupUuid, + AccountGroup.NameKey groupName) + throws ConfigInvalidException, IOException { + requireNonNull(groupName); + Optional<GroupReference> groupToDelete = loadGroup(repository, groupName); + if (groupToDelete.isEmpty()) { + throw new IOException("Could not load groups for deletion"); + } + GroupNameNotes groupNameNotes = new GroupNameNotes(groupUuid, groupName, null); + groupNameNotes.load(projectName, repository); + return groupNameNotes; + } + + /** * Loads the {@code GroupReference} (name/UUID pair) for the group with the specified name. * * @param repository the repository which holds the commits of the notes
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java index 31538d3..bcdb496 100644 --- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java +++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -15,6 +15,7 @@ package com.google.gerrit.server.group.db; import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE; +import static org.eclipse.jgit.lib.Constants.R_REFS; import com.google.auto.value.AutoValue; import com.google.common.annotations.VisibleForTesting; @@ -28,8 +29,11 @@ import com.google.gerrit.entities.AccountGroup; import com.google.gerrit.entities.InternalGroup; import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.RefNames; import com.google.gerrit.exceptions.DuplicateKeyException; import com.google.gerrit.exceptions.NoSuchGroupException; +import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.git.LockFailureException; import com.google.gerrit.git.RefUpdateUtil; import com.google.gerrit.server.GerritPersonIdent; import com.google.gerrit.server.IdentifiedUser; @@ -62,8 +66,11 @@ import java.util.concurrent.TimeUnit; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.lib.BatchRefUpdate; +import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.transport.ReceiveCommand; /** * A database accessor for write calls related to groups. @@ -307,6 +314,22 @@ } } + /** + * Delete a specific group + * + * @param groupUuid the UUID of the group to delete + * @throws ConfigInvalidException if removing group note failed + * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb + */ + public void deleteGroup(AccountGroup.UUID groupUuid) throws ConfigInvalidException, IOException { + try (TraceTimer ignored = + TraceContext.newTimer( + "Deleting group", Metadata.builder().groupUuid(groupUuid.get()).build())) { + DeleteResult result = deleteGroupInNoteDbWithRetry(groupUuid); + evictCacheOnGroupDeletion(result); + } + } + private InternalGroup createGroupInNoteDbWithRetry( InternalGroupCreation groupCreation, GroupDelta groupDelta) throws IOException, ConfigInvalidException, DuplicateKeyException { @@ -398,6 +421,76 @@ } } + private DeleteResult deleteGroupInNoteDbWithRetry(AccountGroup.UUID groupUuid) + throws IOException, ConfigInvalidException { + try { + return retryHelper.groupUpdate("deleteGroup", () -> deleteGroupInNoteDb(groupUuid)).call(); + } catch (Exception e) { + Throwables.throwIfUnchecked(e); + Throwables.throwIfInstanceOf(e, IOException.class); + Throwables.throwIfInstanceOf(e, ConfigInvalidException.class); + Throwables.throwIfInstanceOf(e, DuplicateKeyException.class); + throw new IOException(e); + } + } + + private DeleteResult deleteGroupInNoteDb(AccountGroup.UUID groupUuid) + throws NoSuchGroupException, ConfigInvalidException, IOException { + try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) { + try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) { + GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid); + if (groupConfig.getLoadedGroup().isEmpty()) { + throw new NoSuchGroupException(groupUuid); + } + InternalGroup group = groupConfig.getLoadedGroup().get(); + GroupNameNotes groupNameNotes = + GroupNameNotes.forDeletingGroup( + allUsersName, allUsersRepo, groupUuid, group.getNameKey()); + commit(allUsersRepo, null, groupNameNotes); + deleteSingleRefNote(group.getGroupUUID().get()); + return buildDeleteResult(group); + } + } + } + + private void deleteSingleRefNote(String ref) { + if (!ref.startsWith(R_REFS)) { + ref = RefNames.REFS_GROUPS + ref.substring(0, 2) + "/" + ref; + } + try (Repository repository = repoManager.openRepository(allUsersName)) { + RefUpdate u = repository.updateRef(ref); + u.setExpectedOldObjectId(repository.exactRef(ref).getObjectId()); + u.setNewObjectId(ObjectId.zeroId()); + u.setForceUpdate(true); + RefUpdate.Result result = u.delete(); + + switch (result) { + case NEW: + case NO_CHANGE: + case FAST_FORWARD: + case FORCED: + gitRefUpdated.fire( + allUsersName, + u, + ReceiveCommand.Type.DELETE, + currentUser.map(IdentifiedUser::state).orElse(null)); + break; + case LOCK_FAILURE: + throw new LockFailureException(String.format("Cannot delete %s", ref), u); + case IO_FAILURE: + case NOT_ATTEMPTED: + case REJECTED: + case RENAMED: + case REJECTED_MISSING_OBJECT: + case REJECTED_OTHER_REASON: + default: + throw new StorageException(String.format("Cannot delete %s: %s", ref, result.name())); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + private static UpdateResult getUpdateResult( InternalGroup originalGroup, InternalGroup updatedGroup) { Set<Account.Id> addedMembers = @@ -424,13 +517,31 @@ return resultBuilder.build(); } + private static DeleteResult buildDeleteResult(InternalGroup deletedGroup) { + ImmutableSet<Account.Id> deletedMembers = deletedGroup.getMembers(); + ImmutableSet<AccountGroup.UUID> deletedSubgroups = deletedGroup.getSubgroups(); + + DeleteResult.Builder resultBuilder = + DeleteResult.builder() + .setDeletedGroupUuid(deletedGroup.getGroupUUID()) + .setDeletedGroupId(deletedGroup.getId()) + .setDeletedGroupName(deletedGroup.getNameKey()) + .setDeletedGroupMembers(deletedMembers) + .setDeletedGroupSubgroups(deletedSubgroups); + return resultBuilder.build(); + } + private void commit( - Repository allUsersRepo, GroupConfig groupConfig, @Nullable GroupNameNotes groupNameNotes) + Repository allUsersRepo, + @Nullable GroupConfig groupConfig, + @Nullable GroupNameNotes groupNameNotes) throws IOException { BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate(); - try (MetaDataUpdate metaDataUpdate = - metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) { - groupConfig.commit(metaDataUpdate); + if (groupConfig != null) { + try (MetaDataUpdate metaDataUpdate = + metaDataUpdateFactory.create(allUsersName, allUsersRepo, batchRefUpdate)) { + groupConfig.commit(metaDataUpdate); + } } if (groupNameNotes != null) { // MetaDataUpdates unfortunately can't be reused. -> Create a new one. @@ -475,6 +586,16 @@ result.getDeletedSubgroups().forEach(groupIncludeCache::evictParentGroupsOf); } + private void evictCacheOnGroupDeletion(DeleteResult result) { + logger.atFine().log("evict caches on deletion of group %s", result.getDeletedGroupUuid()); + groupCache.evict(result.getDeletedGroupUuid()); + indexer.get().index(result.getDeletedGroupUuid()); + groupCache.evict(result.getDeletedGroupId()); + groupCache.evict(AccountGroup.nameKey(result.getDeletedGroupName().get())); + result.getDeletedGroupMembers().forEach(groupIncludeCache::evictGroupsWithMember); + result.getDeletedGroupSubgroups().forEach(groupIncludeCache::evictParentGroupsOf); + } + private void updateNameInProjectConfigsIfNecessary(UpdateResult result) { if (result.getPreviousGroupName().isPresent()) { AccountGroup.NameKey previousName = result.getPreviousGroupName().get(); @@ -597,4 +718,36 @@ abstract UpdateResult build(); } } + + @AutoValue + abstract static class DeleteResult { + abstract AccountGroup.UUID getDeletedGroupUuid(); + + abstract AccountGroup.Id getDeletedGroupId(); + + abstract AccountGroup.NameKey getDeletedGroupName(); + + abstract ImmutableSet<Account.Id> getDeletedGroupMembers(); + + abstract ImmutableSet<AccountGroup.UUID> getDeletedGroupSubgroups(); + + static Builder builder() { + return new AutoValue_GroupsUpdate_DeleteResult.Builder(); + } + + @AutoValue.Builder + abstract static class Builder { + abstract Builder setDeletedGroupUuid(AccountGroup.UUID groupUuid); + + abstract Builder setDeletedGroupId(AccountGroup.Id groupId); + + abstract Builder setDeletedGroupName(AccountGroup.NameKey name); + + abstract Builder setDeletedGroupMembers(Set<Account.Id> deletedMembers); + + abstract Builder setDeletedGroupSubgroups(Set<AccountGroup.UUID> deletedSubgroups); + + abstract DeleteResult build(); + } + } }
diff --git a/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java index 9d03e3b..8768fb6 100644 --- a/java/com/google/gerrit/server/index/account/AccountIndexCollection.java +++ b/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -17,13 +17,23 @@ import com.google.common.annotations.VisibleForTesting; import com.google.gerrit.entities.Account; import com.google.gerrit.index.IndexCollection; +import com.google.gerrit.metrics.MetricMaker; import com.google.gerrit.server.account.AccountState; +import com.google.inject.Inject; import com.google.inject.Singleton; /** Collection of active account indices. See {@link IndexCollection} for details on collections. */ @Singleton public class AccountIndexCollection extends IndexCollection<Account.Id, AccountState, AccountIndex> { + @Inject @VisibleForTesting - public AccountIndexCollection() {} + public AccountIndexCollection(MetricMaker metrics) { + super(metrics); + } + + @Override + protected IndexType getIndexName() { + return IndexType.ACCOUNTS; + } }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java index 74e9af1..cb44a3b 100644 --- a/java/com/google/gerrit/server/index/change/ChangeIndex.java +++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -15,6 +15,7 @@ package com.google.gerrit.server.index.change; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.Project; import com.google.gerrit.index.Index; import com.google.gerrit.index.IndexDefinition; import com.google.gerrit.index.query.Predicate; @@ -35,4 +36,6 @@ } Function<ChangeData, Change.Id> ENTITY_TO_KEY = ChangeData::getId; + + public void deleteAllForProject(Project.NameKey project); }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java index 817ec0e..75db96c 100644 --- a/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java +++ b/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -17,12 +17,22 @@ import com.google.common.annotations.VisibleForTesting; import com.google.gerrit.entities.Change; import com.google.gerrit.index.IndexCollection; +import com.google.gerrit.metrics.MetricMaker; import com.google.gerrit.server.query.change.ChangeData; +import com.google.inject.Inject; import com.google.inject.Singleton; /** Collection of active change indices. See {@link IndexCollection} for details on collections. */ @Singleton public class ChangeIndexCollection extends IndexCollection<Change.Id, ChangeData, ChangeIndex> { + @Inject @VisibleForTesting - public ChangeIndexCollection() {} + public ChangeIndexCollection(MetricMaker metrics) { + super(metrics); + } + + @Override + protected IndexType getIndexName() { + return IndexType.CHANGES; + } }
diff --git a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java index e1941ab..71f719d 100644 --- a/java/com/google/gerrit/server/index/group/GroupIndexCollection.java +++ b/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -18,12 +18,22 @@ import com.google.gerrit.entities.AccountGroup; import com.google.gerrit.entities.InternalGroup; import com.google.gerrit.index.IndexCollection; +import com.google.gerrit.metrics.MetricMaker; +import com.google.inject.Inject; import com.google.inject.Singleton; /** Collection of active group indices. See {@link IndexCollection} for details on collections. */ @Singleton public class GroupIndexCollection extends IndexCollection<AccountGroup.UUID, InternalGroup, GroupIndex> { + @Inject @VisibleForTesting - public GroupIndexCollection() {} + public GroupIndexCollection(MetricMaker metrics) { + super(metrics); + } + + @Override + protected IndexType getIndexName() { + return IndexType.GROUPS; + } }
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java index 33a49ae..60464e5 100644 --- a/java/com/google/gerrit/server/logging/Metadata.java +++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -76,6 +76,9 @@ /** The cause of an error. */ public abstract Optional<String> cause(); + /** The command name of an SSH request. */ + public abstract Optional<String> commandName(); + /** Side where the comment is written: <= 0 for parent, 1 for revision. */ public abstract Optional<Integer> commentSide(); @@ -88,6 +91,9 @@ /** The type of an event. */ public abstract Optional<String> eventType(); + /** The name of an exception which failed an SSH request. */ + public abstract Optional<String> exception(); + /** The value of the @Export annotation which was used to register a plugin extension. */ public abstract Optional<String> exportValue(); @@ -192,8 +198,8 @@ * authDomainName=Optional.empty, branchName=Optional.empty, cacheKey=Optional.empty, * cacheName=Optional.empty, caller=Optional.empty, className=Optional.empty, * cancellationReason=Optional.empty, changeId=Optional[9212550], changeIdType=Optional.empty, - * cause=Optional.empty, diffAlgorithm=Optional.empty, eventType=Optional.empty, - * exportValue=Optional.empty, filePath=Optional.empty, garbageCollectorName=Optional.empty, + * cause=Optional.empty, commandName=Optional.empty, diffAlgorithm=Optional.empty, eventType=Optional.empty, + * exception=Optional.empty, exportValue=Optional.empty, filePath=Optional.empty, garbageCollectorName=Optional.empty, * gitOperation=Optional.empty, groupId=Optional.empty, groupName=Optional.empty, * groupUuid=Optional.empty, httpStatus=Optional.empty, indexName=Optional.empty, * indexVersion=Optional[0], methodName=Optional.empty, multiple=Optional.empty, @@ -235,10 +241,12 @@ .add("changeId", changeId().orElse(null)) .add("changeIdType", changeIdType().orElse(null)) .add("cause", cause().orElse(null)) + .add("commandName", commandName().orElse(null)) .add("commentSide", commentSide().orElse(null)) .add("commit", commit().orElse(null)) .add("diffAlgorithm", diffAlgorithm().orElse(null)) .add("eventType", eventType().orElse(null)) + .add("exception", exception().orElse(null)) .add("exportValue", exportValue().orElse(null)) .add("filePath", filePath().orElse(null)) .add("garbageCollectorName", garbageCollectorName().orElse(null)) @@ -314,6 +322,8 @@ public abstract Builder cause(@Nullable String cause); + public abstract Builder commandName(@Nullable String commandName); + public abstract Builder commentSide(int side); public abstract Builder commit(@Nullable String commit); @@ -322,6 +332,8 @@ public abstract Builder eventType(@Nullable String eventType); + public abstract Builder exception(@Nullable String exception); + public abstract Builder exportValue(@Nullable String exportValue); public abstract Builder filePath(@Nullable String filePath);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java index eb1f692..ba10648 100644 --- a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java +++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -22,12 +22,14 @@ public static final FooterKey FOOTER_BRANCH = new FooterKey("Branch"); public static final FooterKey FOOTER_CHANGE_ID = new FooterKey("Change-id"); public static final FooterKey FOOTER_COMMIT = new FooterKey("Commit"); + public static final FooterKey FOOTER_CONTAINS_CONFLICTS = new FooterKey("Contains-Conflicts"); public static final FooterKey FOOTER_CURRENT = new FooterKey("Current"); public static final FooterKey FOOTER_CUSTOM_KEYED_VALUE = new FooterKey("Custom-Keyed-Value"); public static final FooterKey FOOTER_GROUPS = new FooterKey("Groups"); public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags"); public static final FooterKey FOOTER_LABEL = new FooterKey("Label"); public static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label"); + public static final FooterKey FOOTER_OURS = new FooterKey("Ours"); public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set"); public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description"); @@ -39,6 +41,7 @@ public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with"); public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic"); public static final FooterKey FOOTER_TAG = new FooterKey("Tag"); + public static final FooterKey FOOTER_THEIRS = new FooterKey("Theirs"); public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress"); public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of"); public static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java index f3ae867..b402d91 100644 --- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java +++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -21,12 +21,14 @@ import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT; +import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CONTAINS_CONFLICTS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL; +import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_OURS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE; @@ -37,6 +39,7 @@ import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG; +import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_THEIRS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS; import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange; @@ -559,15 +562,15 @@ return; } - // Parse mutable patch set fields first so they can be recorded in the PendingPatchSetFields. + // Parse mutable patch set fields. parseDescription(psId, commit); parseGroups(psId, commit); - ObjectId currRev = parseRevision(commit); - if (currRev != null) { - parsePatchSet(psId, currRev, accountId, realAccountId, commitTimestamp); + Optional<ObjectId> currRev = parseRevision(commit); + if (currRev.isPresent()) { + parsePatchSet(commit, psId, currRev.get(), accountId, realAccountId, commitTimestamp); } - parseCurrentPatchSet(psId, commit); + parseCurrentPatchSet(commit, psId); if (status == null) { status = parseStatus(commit); @@ -683,23 +686,77 @@ return line; } - @Nullable - private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException { - String sha = parseOneFooter(commit, FOOTER_COMMIT); + private Optional<ObjectId> parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException { + return parseSha1(commit, FOOTER_COMMIT); + } + + private Optional<ObjectId> parseOurs(ChangeNotesCommit commit) throws ConfigInvalidException { + return parseSha1(commit, FOOTER_OURS); + } + + private Optional<ObjectId> parseTheirs(ChangeNotesCommit commit) throws ConfigInvalidException { + return parseSha1(commit, FOOTER_THEIRS); + } + + private Optional<ObjectId> parseSha1(ChangeNotesCommit commit, FooterKey footerKey) + throws ConfigInvalidException { + String sha = parseOneFooter(commit, footerKey); if (sha == null) { - return null; + return Optional.empty(); } try { - return ObjectId.fromString(sha); + return Optional.of(ObjectId.fromString(sha)); } catch (InvalidObjectIdException e) { - ConfigInvalidException cie = invalidFooter(FOOTER_COMMIT, sha); + ConfigInvalidException cie = invalidFooter(footerKey, sha); cie.initCause(e); throw cie; } } + private Optional<PatchSet.Conflicts> parseConflicts(ChangeNotesCommit commit) + throws ConfigInvalidException { + Optional<Boolean> containsConflicts = parseContainsConflicts(commit); + if (containsConflicts.isEmpty()) { + return Optional.empty(); + } + + Optional<ObjectId> ours = parseOurs(commit); + if (containsConflicts.get() && ours.isEmpty()) { + throw parseException( + "Missing footer: %s (if footer %s is set to true, footer %s must be set)", + FOOTER_OURS, FOOTER_CONTAINS_CONFLICTS.getName(), FOOTER_OURS); + } + + Optional<ObjectId> theirs = parseTheirs(commit); + if (containsConflicts.get() && theirs.isEmpty()) { + throw parseException( + "Missing footer: %s (if footer %s is set to true, footer %s must be set)", + FOOTER_THEIRS, FOOTER_CONTAINS_CONFLICTS.getName(), FOOTER_THEIRS); + } + + return Optional.of(PatchSet.Conflicts.create(ours, theirs, containsConflicts.get())); + } + + private Optional<Boolean> parseContainsConflicts(ChangeNotesCommit commit) + throws ConfigInvalidException { + String containsConflictsStr = parseOneFooter(commit, FOOTER_CONTAINS_CONFLICTS); + if (containsConflictsStr == null) { + return Optional.empty(); + } else if (Boolean.TRUE.toString().equalsIgnoreCase(containsConflictsStr)) { + return Optional.of(Boolean.TRUE); + } else if (Boolean.FALSE.toString().equalsIgnoreCase(containsConflictsStr)) { + return Optional.of(Boolean.FALSE); + } + throw invalidFooter(FOOTER_CONTAINS_CONFLICTS, containsConflictsStr); + } + private void parsePatchSet( - PatchSet.Id psId, ObjectId rev, Account.Id accountId, Account.Id realAccountId, Instant ts) + ChangeNotesCommit commit, + PatchSet.Id psId, + ObjectId rev, + Account.Id accountId, + Account.Id realAccountId, + Instant ts) throws ConfigInvalidException { if (accountId == null) { throw parseException("patch set %s requires an identified user as uploader", psId.get()); @@ -717,11 +774,12 @@ .commitId(rev) .uploader(accountId) .realUploader(realAccountId) - .createdOn(ts); + .createdOn(ts) + .conflicts(parseConflicts(commit)); // Fields not set here: - // * Groups, parsed earlier in parseGroups. - // * Description, parsed earlier in parseDescription. - // * Push certificate, parsed later in parseNotes. + // * Groups: parsed earlier in parseGroups. + // * Description: parsed earlier in parseDescription. + // * Push certificate: parsed later in parseNotes. } private void parseGroups(PatchSet.Id psId, ChangeNotesCommit commit) @@ -737,7 +795,7 @@ } } - private void parseCurrentPatchSet(PatchSet.Id psId, ChangeNotesCommit commit) + private void parseCurrentPatchSet(ChangeNotesCommit commit, PatchSet.Id psId) throws ConfigInvalidException { // This commit implies a new current patch set if either it creates a new // patch set, or sets the current field explicitly.
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java index c97065b..ae99363 100644 --- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java +++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -24,12 +24,14 @@ import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHANGE_ID; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CHERRY_PICK_OF; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COMMIT; +import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CONTAINS_CONFLICTS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CUSTOM_KEYED_VALUE; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_GROUPS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_HASHTAGS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL; +import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_OURS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE; @@ -40,6 +42,7 @@ import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMISSION_ID; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBMITTED_WITH; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TAG; +import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_THEIRS; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_TOPIC; import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS; import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter; @@ -191,6 +194,7 @@ private boolean currentPatchSet; private Boolean isPrivate; private Boolean workInProgress; + private PatchSet.Conflicts conflicts; private Integer revertOf; // If null, the update does not modify the field. Otherwise, it updates the field with the // new value or resets if cherryPickOf == Optional.empty(). @@ -891,6 +895,15 @@ } } + if (conflicts != null) { + conflicts.ours().map(ObjectId::getName).ifPresent(ours -> addFooter(msg, FOOTER_OURS, ours)); + conflicts + .theirs() + .map(ObjectId::getName) + .ifPresent(theirs -> addFooter(msg, FOOTER_THEIRS, theirs)); + addFooter(msg, FOOTER_CONTAINS_CONFLICTS, conflicts.containsConflicts()); + } + if (revertOf != null) { addFooter(msg, FOOTER_REVERT_OF, revertOf); } @@ -1263,6 +1276,10 @@ this.workInProgress = workInProgress; } + public void setConflicts(PatchSet.Conflicts conflicts) { + this.conflicts = conflicts; + } + @CanIgnoreReturnValue private static StringBuilder addFooter(StringBuilder sb, FooterKey footer) { return sb.append(footer.getName()).append(": ");
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java index 677ee18..fe7d348 100644 --- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java +++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -190,6 +190,7 @@ case CREATE_ACCOUNT: case CREATE_GROUP: + case DELETE_GROUP: case CREATE_PROJECT: case MAINTAIN_SERVER: case MODIFY_ACCOUNT:
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java index 1b87446..ae6cb9d 100644 --- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java +++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -45,6 +45,7 @@ .put(GlobalPermission.ADMINISTRATE_SERVER, GlobalCapability.ADMINISTRATE_SERVER) .put(GlobalPermission.CREATE_ACCOUNT, GlobalCapability.CREATE_ACCOUNT) .put(GlobalPermission.CREATE_GROUP, GlobalCapability.CREATE_GROUP) + .put(GlobalPermission.DELETE_GROUP, GlobalCapability.DELETE_GROUP) .put(GlobalPermission.CREATE_PROJECT, GlobalCapability.CREATE_PROJECT) .put(GlobalPermission.EMAIL_REVIEWERS, GlobalCapability.EMAIL_REVIEWERS) .put(GlobalPermission.FLUSH_CACHES, GlobalCapability.FLUSH_CACHES)
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java index d83353c..ed1842d 100644 --- a/java/com/google/gerrit/server/permissions/GlobalPermission.java +++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -43,6 +43,7 @@ ADMINISTRATE_SERVER, CREATE_ACCOUNT, CREATE_GROUP, + DELETE_GROUP, CREATE_PROJECT, EMAIL_REVIEWERS, FLUSH_CACHES,
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java index e586477..3cf55c5 100644 --- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java +++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -34,10 +34,13 @@ import com.google.gerrit.server.account.AccountState; import com.google.gerrit.server.account.externalids.ExternalId; import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory; +import com.google.gerrit.server.config.AccountConfig; import com.google.gerrit.server.index.account.AccountField; import com.google.gerrit.server.index.account.AccountIndexCollection; import com.google.inject.Inject; +import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Set; /** @@ -49,6 +52,7 @@ public class InternalAccountQuery extends InternalQuery<AccountState, InternalAccountQuery> { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private final AccountConfig accountConfig; private final ExternalIdKeyFactory externalIdKeyFactory; @Inject @@ -56,8 +60,11 @@ AccountQueryProcessor queryProcessor, AccountIndexCollection indexes, IndexConfig indexConfig, - ExternalIdKeyFactory externalIdKeyFactory) { + ExternalIdKeyFactory externalIdKeyFactory, + AccountConfig accountConfig) { + super(queryProcessor, indexes, indexConfig); + this.accountConfig = accountConfig; this.externalIdKeyFactory = externalIdKeyFactory; } @@ -95,27 +102,36 @@ } /** - * Queries for accounts that have a preferred email that exactly matches the given email. + * Queries for accounts that have a preferred email that matches the given email. + * + * <p>The local part of the email is compared either in a case-insensitive or case-sensitive + * manner, depending on the configuration parameter {@code accounts.caseInsensitiveLocalPart}. + * Check the configuration documentation for more details. * * @param email preferred email by which accounts should be found * @return list of accounts that have a preferred email that exactly matches the given email */ public List<AccountState> byPreferredEmail(String email) { + String normalizedEmail = normalizeEmailCase(email); if (hasPreferredEmailExact()) { - return query(AccountPredicates.preferredEmailExact(email)); + return query(AccountPredicates.preferredEmailExact(normalizedEmail)); } if (!hasPreferredEmail()) { return ImmutableList.of(); } - return query(AccountPredicates.preferredEmail(email)).stream() - .filter(a -> a.account().preferredEmail().equals(email)) + return query(AccountPredicates.preferredEmail(normalizedEmail)).stream() + .filter(a -> a.account().preferredEmail().equals(normalizedEmail)) .collect(toList()); } /** - * Makes multiple queries for accounts by preferred email (exact match). + * Makes multiple queries for accounts by preferred email. + * + * <p>The local part of the email is compared either in a case-insensitive or case-sensitive + * manner, depending on the configuration parameter {@code accounts.caseInsensitiveLocalPart}. + * Check the configuration documentation for more details. * * @param emails preferred emails by which accounts should be found * @return multimap of the given emails to accounts that have a preferred email that exactly @@ -124,7 +140,10 @@ public Multimap<String, AccountState> byPreferredEmail(List<String> emails) { if (hasPreferredEmailExact()) { List<List<AccountState>> r = - query(emails.stream().map(AccountPredicates::preferredEmailExact).collect(toList())); + query( + emails.stream() + .map(email -> AccountPredicates.preferredEmailExact(normalizeEmailCase(email))) + .collect(toList())); ListMultimap<String, AccountState> accountsByEmail = ArrayListMultimap.create(); for (int i = 0; i < emails.size(); i++) { accountsByEmail.putAll(emails.get(i), r.get(i)); @@ -166,4 +185,21 @@ private boolean hasPreferredEmailExact() { return hasField(AccountField.PREFERRED_EMAIL_EXACT_SPEC); } + + private String normalizeEmailCase(String email) { + return Arrays.asList(accountConfig.getCaseInsensitiveLocalParts()) + .contains(getLowerCaseEmailDomain(email)) + ? email.toLowerCase(Locale.US) + : email; + } + + private String getLowerCaseEmailDomain(String email) { + String[] parts = email.split("@", 2); + // The caller method byPreferredEmail can be invoked with the local part + // of the email only. Handle this case by just returning it. + if (parts.length != 2) { + return email; + } + return parts[1].toLowerCase(Locale.US); + } }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java index 971996d..5a26e62 100644 --- a/java/com/google/gerrit/server/query/approval/ApprovalContext.java +++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -22,6 +22,7 @@ import com.google.gerrit.entities.PatchSet; import com.google.gerrit.extensions.client.ChangeKind; import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.update.RepoView; /** Entity representing all required information to match predicates for copying approvals. */ @@ -46,6 +47,9 @@ /** {@link ChangeNotes} of the change in question. */ public abstract ChangeNotes changeNotes(); + /** {@link ChangeData } of the change in question. */ + public abstract ChangeData changeData(); + /** {@link ChangeKind} of the delta between the origin and target patch set. */ public abstract ChangeKind changeKind(); @@ -55,7 +59,7 @@ public abstract RepoView repoView(); public static ApprovalContext create( - ChangeNotes changeNotes, + ChangeData changeData, PatchSet.Id sourcePatchSetId, Account.Id approverId, LabelType labelType, @@ -81,7 +85,8 @@ labelType, approvalValue, targetPatchSet, - changeNotes, + changeData.notes(), + changeData, changeKind, isMerge, repoView);
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java index 6ae47ad..b86dcb7 100644 --- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java +++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -17,18 +17,25 @@ import static java.util.stream.Collectors.joining; import com.google.common.base.Enums; +import com.google.common.base.Splitter; import com.google.common.primitives.Ints; import com.google.gerrit.entities.AccountGroup; import com.google.gerrit.entities.GroupDescription; import com.google.gerrit.extensions.client.ChangeKind; +import com.google.gerrit.extensions.registration.DynamicMap; +import com.google.gerrit.index.query.Matchable; +import com.google.gerrit.index.query.OperatorPredicate; import com.google.gerrit.index.query.Predicate; import com.google.gerrit.index.query.QueryBuilder; import com.google.gerrit.index.query.QueryParseException; import com.google.gerrit.server.account.GroupControl; import com.google.gerrit.server.group.GroupResolver; +import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.query.change.ChangeQueryBuilder; import com.google.inject.Inject; import com.google.inject.Singleton; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.Optional; @@ -37,11 +44,39 @@ private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef = new QueryBuilder.Definition<>(ApprovalQueryBuilder.class); + private static final Splitter PLUGIN_SPLITTER = Splitter.on("_"); + + public static class ChangeIsPredicate extends OperatorPredicate<ApprovalContext> + implements Matchable<ApprovalContext> { + private final Predicate<ChangeData> delegate; + + public ChangeIsPredicate(Predicate<ChangeData> delegate, String value) { + super("changeis", value); + this.delegate = delegate; + } + + @Override + public boolean match(ApprovalContext approvalContext) { + return delegate.asMatchable().match(approvalContext.changeData()); + } + + @Override + public int getCost() { + return delegate.asMatchable().getCost(); + } + } + + public interface UserInOperandFactory { + Predicate<ApprovalContext> create(UserInPredicate.Field field) throws QueryParseException; + } + private final MagicValuePredicate.Factory magicValuePredicate; private final UserInPredicate.Factory userInPredicate; private final GroupResolver groupResolver; private final GroupControl.Factory groupControl; private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate; + private final ChangeQueryBuilder changeQueryBuilder; + private final DynamicMap<UserInOperandFactory> userInOperands; @Inject protected ApprovalQueryBuilder( @@ -49,13 +84,17 @@ UserInPredicate.Factory userInPredicate, GroupResolver groupResolver, GroupControl.Factory groupControl, - ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) { + ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate, + ChangeQueryBuilder changeQueryBuilder, + DynamicMap<UserInOperandFactory> userInOperands) { super(mydef, null); this.magicValuePredicate = magicValuePredicate; this.userInPredicate = userInPredicate; this.groupResolver = groupResolver; this.groupControl = groupControl; this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate; + this.changeQueryBuilder = changeQueryBuilder; + this.userInOperands = userInOperands; } @Operator @@ -93,13 +132,15 @@ } @Operator - public Predicate<ApprovalContext> approverin(String group) throws QueryParseException { - return userInPredicate.create(UserInPredicate.Field.APPROVER, parseGroupOrThrow(group)); + public Predicate<ApprovalContext> approverin(String groupOrPluginOperand) + throws QueryParseException { + return userin(UserInPredicate.Field.APPROVER, groupOrPluginOperand); } @Operator - public Predicate<ApprovalContext> uploaderin(String group) throws QueryParseException { - return userInPredicate.create(UserInPredicate.Field.UPLOADER, parseGroupOrThrow(group)); + public Predicate<ApprovalContext> uploaderin(String groupOrPluginOperand) + throws QueryParseException { + return userin(UserInPredicate.Field.UPLOADER, groupOrPluginOperand); } @Operator @@ -114,6 +155,26 @@ value)); } + @Operator + public Predicate<ApprovalContext> changeis(String value) throws QueryParseException { + Predicate<ChangeData> changePredicate = changeQueryBuilder.is(value); + return new ChangeIsPredicate(changePredicate, value); + } + + private Predicate<ApprovalContext> userin( + UserInPredicate.Field field, String groupOrPluginOperand) throws QueryParseException { + // For plugins the value will be operandName_pluginName + List<String> names = PLUGIN_SPLITTER.splitToList(groupOrPluginOperand); + if (names.size() == 2) { + UserInOperandFactory op = userInOperands.get(names.get(1), names.get(0)); + if (op != null) { + return op.create(field); + } + } + + return userInPredicate.create(field, parseGroupOrThrow(groupOrPluginOperand)); + } + private static <T extends Enum<T>> Optional<T> parseEnumValue(Class<T> clazz, String value) { return Optional.ofNullable( Enums.getIfPresent(clazz, value.toUpperCase(Locale.US).replace('-', '_')).orNull());
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java index 98471da..dc9a6d9 100644 --- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java +++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -26,7 +26,9 @@ enum MagicValue { MIN, MAX, - ANY + ANY, + POSITIVE, + NEGATIVE } public interface Factory { @@ -44,20 +46,20 @@ @Override public boolean match(ApprovalContext ctx) { - short pValue; switch (value) { case ANY: return true; case MIN: - pValue = ctx.labelType().getMaxNegative(); - break; + return ctx.approvalValue() == ctx.labelType().getMaxNegative(); case MAX: - pValue = ctx.labelType().getMaxPositive(); - break; + return ctx.approvalValue() == ctx.labelType().getMaxPositive(); + case POSITIVE: + return ctx.approvalValue() > 0; + case NEGATIVE: + return ctx.approvalValue() < 0; default: throw new IllegalArgumentException("unrecognized label value: " + value); } - return pValue == ctx.approvalValue(); } @Override
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java index fda2014..10c072f 100644 --- a/java/com/google/gerrit/server/query/approval/UserInPredicate.java +++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -30,7 +30,7 @@ UserInPredicate create(Field field, AccountGroup.UUID group); } - enum Field { + public enum Field { UPLOADER, APPROVER }
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java index 0fd9c0e..3ce7e38 100644 --- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java +++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -57,6 +57,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.util.io.DisabledOutputStream; @@ -216,13 +217,15 @@ Map<Project.NameKey, Repository> repos = new HashMap<>(); Map<Project.NameKey, RevWalk> revWalks = new HashMap<>(); + Map<Project.NameKey, AttributesNodeProvider> attributesNodeProviders = new HashMap<>(); QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString)); pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities()); try { AccountAttributeLoader accountLoader = accountAttributeLoaderFactory.create(); List<ChangeAttribute> changeAttributes = new ArrayList<>(); for (ChangeData d : results.entities()) { - changeAttributes.add(buildChangeAttribute(d, repos, revWalks, accountLoader)); + changeAttributes.add( + buildChangeAttribute(d, repos, revWalks, accountLoader, attributesNodeProviders)); } accountLoader.fill(); changeAttributes.forEach(c -> show(c)); @@ -259,7 +262,8 @@ ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks, - AccountAttributeLoader accountLoader) + AccountAttributeLoader accountLoader, + Map<Project.NameKey, AttributesNodeProvider> attributesNodeProviders) throws IOException { ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), accountLoader); c.hashtags = Lists.newArrayList(d.hashtags()); @@ -287,21 +291,26 @@ if (includePatchSets || includeCurrentPatchSet || includeDependencies) { Project.NameKey p = d.change().getProject(); Repository repo; + AttributesNodeProvider attributesNodeProvider; RevWalk rw = revWalks.get(p); - // Cache and reuse repos and revwalks. + // Cache and reuse repos, revWalks, and attributesNodeProviders. if (rw == null) { repo = repoManager.openRepository(p); checkState(repos.put(p, repo) == null); rw = new RevWalk(repo); revWalks.put(p, rw); + attributesNodeProvider = repo.createAttributesNodeProvider(); + attributesNodeProviders.put(p, attributesNodeProvider); } else { repo = repos.get(p); + attributesNodeProvider = attributesNodeProviders.get(p); } if (includePatchSets) { eventFactory.addPatchSets( rw, repo.getConfig(), + attributesNodeProvider, c, includeApprovals ? d.conditionallyLoadApprovalsWithCopied().asMap() : null, includeFiles, @@ -328,7 +337,13 @@ } } else { c.currentPatchSet = - eventFactory.asPatchSetAttribute(rw, repo.getConfig(), d, current, accountLoader); + eventFactory.asPatchSetAttribute( + rw, + repo.getConfig(), + repo.createAttributesNodeProvider(), + d, + current, + accountLoader); if (includeFiles) { eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet()); }
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java index 359393e..3bc5bb8 100644 --- a/java/com/google/gerrit/server/restapi/RestApiModule.java +++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -32,6 +32,7 @@ * bound in {@link RestModule}. */ public class RestApiModule extends AbstractModule { + @Override protected void configure() { install(new AccessRestApiModule());
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java index 5402dcc..13c41cf 100644 --- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java +++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -394,7 +394,8 @@ input.parent - 1, input.allowEmpty, input.allowConflicts, - useDiff3); + useDiff3, + git.createAttributesNodeProvider()); logger.atFine().log("flushing inserter %s", oi); oi.flush(); } catch (MergeIdenticalTreeException | MergeConflictException e) { @@ -470,6 +471,7 @@ inserter.setMessage( messageForDestinationChange( inserter.getPatchSetId(), sourceBranch, sourceCommit, cherryPickCommit)); + cherryPickCommit.getConflicts().ifPresent(inserter::setConflicts); inserter.setTopic(topic); if (workInProgress != null) { inserter.setWorkInProgress(workInProgress); @@ -511,6 +513,7 @@ throws IOException, InvalidChangeOperationException { Change.Id changeId = idForNewChange != null ? idForNewChange : Change.id(seq.nextChangeId()); ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName); + cherryPickCommit.getConflicts().ifPresent(ins::setConflicts); ins.setRevertOf(revertOf); if (workInProgress != null) { ins.setWorkInProgress(workInProgress);
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java index 2c32586..57be320 100644 --- a/java/com/google/gerrit/server/restapi/change/CreateChange.java +++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -146,6 +146,7 @@ private final NotifyResolver notifyResolver; private final ContributorAgreementsChecker contributorAgreements; private final boolean disablePrivateChanges; + private final boolean useDiff3; @Inject CreateChange( @@ -186,6 +187,9 @@ this.mergeUtilFactory = mergeUtilFactory; this.notifyResolver = notifyResolver; this.contributorAgreements = contributorAgreements; + this.useDiff3 = + config.getBoolean( + "change", /* subsection= */ null, "diff3ConflictView", /* defaultValue= */ false); } @Override @@ -533,6 +537,7 @@ ins.setPrivate(input.isPrivate); ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty()); ins.setGroups(groups); + c.getConflicts().ifPresent(ins::setConflicts); if (input.validationOptions != null) { ImmutableListMultimap.Builder<String, String> validationOptions = @@ -702,9 +707,12 @@ ObjectId treeId = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId(); logger.atFine().log("Tree ID of empty commit: %s", treeId.name()); List<RevCommit> parents = mergeTip == null ? ImmutableList.of() : ImmutableList.of(mergeTip); - return rw.parseCommit( - CommitUtil.createCommitWithTree( - oi, authorIdent, committerIdent, parents, commitMessage, treeId)); + CodeReviewCommit commit = + rw.parseCommit( + CommitUtil.createCommitWithTree( + oi, authorIdent, committerIdent, parents, commitMessage, treeId)); + commit.setNoConflicts(); + return commit; } private static CodeReviewCommit createCommitWithSuppliedTree( @@ -780,7 +788,8 @@ authorIdent, committerIdent, commitMessage, - rw); + rw, + this.useDiff3); logger.atFine().log("tree ID of merge commit: %s", mergeCommit.getTree().getId().name()); return mergeCommit; } catch (NoMergeBaseException e) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java index 87d983e..19ba6d1 100644 --- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java +++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -50,6 +50,7 @@ import com.google.gerrit.server.change.ChangeResource; import com.google.gerrit.server.change.NotifyResolver; import com.google.gerrit.server.change.PatchSetInserter; +import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.git.CodeReviewCommit; import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk; import com.google.gerrit.server.git.GitRepositoryManager; @@ -76,6 +77,7 @@ import java.time.ZoneId; import java.util.List; import java.util.Optional; +import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectInserter; import org.eclipse.jgit.lib.ObjectReader; @@ -99,9 +101,11 @@ private final ProjectCache projectCache; private final ChangeFinder changeFinder; private final PermissionBackend permissionBackend; + private final boolean useDiff3; @Inject CreateMergePatchSet( + @GerritServerConfig Config cfg, BatchUpdate.Factory updateFactory, GitRepositoryManager gitManager, CommitsCollection commits, @@ -126,6 +130,9 @@ this.projectCache = projectCache; this.changeFinder = changeFinder; this.permissionBackend = permissionBackend; + this.useDiff3 = + cfg.getBoolean( + "change", /* subsection= */ null, "diff3ConflictView", /* defaultValue= */ false); } @Override @@ -222,6 +229,7 @@ .setMessage(messageForChange(nextPsId, newCommit)) .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty()) .setCheckAddPatchSetPermission(false); + newCommit.getConflicts().ifPresent(psInserter::setConflicts); if (in.validationOptions != null) { ImmutableListMultimap.Builder<String, String> validationOptions = @@ -322,7 +330,8 @@ author, committer, commitMsg, - rw); + rw, + this.useDiff3); } private static String messageForChange(PatchSet.Id patchSetId, CodeReviewCommit commit) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetContent.java b/java/com/google/gerrit/server/restapi/change/GetContent.java index c536946..5ce10b4 100644 --- a/java/com/google/gerrit/server/restapi/change/GetContent.java +++ b/java/com/google/gerrit/server/restapi/change/GetContent.java
@@ -66,8 +66,9 @@ public Response<BinaryResult> apply(FileResource rsrc) throws ResourceNotFoundException, IOException, BadRequestException { String path = rsrc.getPatchKey().fileName(); + PatchSet.Id patchSetId = rsrc.getRevision().getPatchSet().id(); if (Patch.COMMIT_MSG.equals(path)) { - String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes()); + String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes(), patchSetId); return Response.ok( BinaryResult.create(msg) .setContentType(FileContentUtil.TEXT_X_GERRIT_COMMIT_MESSAGE) @@ -89,9 +90,9 @@ parent)); } - private String getMessage(ChangeNotes notes) throws IOException { + private String getMessage(ChangeNotes notes, PatchSet.Id patchSetId) throws IOException { Change.Id changeId = notes.getChangeId(); - PatchSet ps = psUtil.current(notes); + PatchSet ps = psUtil.get(notes, patchSetId); if (ps == null) { throw new NoSuchChangeException(changeId); }
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java index 238712e..05f061d 100644 --- a/java/com/google/gerrit/server/restapi/change/GetPatch.java +++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -43,10 +43,29 @@ public class GetPatch implements RestReadView<RevisionResource> { private final GitRepositoryManager repoManager; - @Option(name = "--zip") + /** + * What is this base64 and zip business doing here? Just give me a patch file! + * + * <p>The reason these legacy types are here is to force paleolithic browsers like IE6 to not do + * cross site scripting. We have since invented X-Content-Type-Options: nosniff, which every + * browser released since IE8 supports, making this madness unnecessary in the modern era, thus + * the raw mode being available. + * + * <p>The only reason raw is not default is to not break old scripts. + */ + private enum OutputType { + ZIP, + BASE64, + RAW, + } + + @Option(name = "--zip", usage = "retrieve a zip file with one patch file inside it") private boolean zip; - @Option(name = "--download") + @Option(name = "--raw", usage = "retrieve a plain-text patch file rather than base64") + private boolean raw; + + @Option(name = "--download", usage = "send the file with a download hint") private boolean download; @Option(name = "--path") @@ -67,6 +86,18 @@ ResourceConflictException, IOException, ResourceNotFoundException { + if (raw && zip) { + throw new BadRequestException("raw and zip options are mutually exclusive"); + } + final OutputType outputType; + if (raw) { + outputType = OutputType.RAW; + } else if (zip) { + outputType = OutputType.ZIP; + } else { + outputType = OutputType.BASE64; + } + final Repository repo = repoManager.openRepository(rsrc.getProject()); boolean close = true; try { @@ -91,16 +122,20 @@ new BinaryResult() { @Override public void writeTo(OutputStream out) throws IOException { - if (zip) { - ZipOutputStream zos = new ZipOutputStream(out); - ZipEntry e = new ZipEntry(fileName(rw, commit)); - e.setTime(commit.getCommitTime() * 1000L); - zos.putNextEntry(e); - format(zos); - zos.closeEntry(); - zos.finish(); - } else { - format(out); + switch (outputType) { + case ZIP: + ZipOutputStream zos = new ZipOutputStream(out); + ZipEntry e = new ZipEntry(fileName(rw, commit)); + e.setTime(commit.getCommitTime() * 1000L); + zos.putNextEntry(e); + format(zos); + zos.closeEntry(); + zos.finish(); + break; + case RAW: + case BASE64: + format(out); + break; } } @@ -123,14 +158,21 @@ throw new ResourceNotFoundException(String.format("File not found: %s.", path)); } - if (zip) { - bin.disableGzip() - .setContentType("application/zip") - .setAttachmentName(fileName(rw, commit) + ".zip"); - } else { - bin.base64() - .setContentType("application/mbox") - .setAttachmentName(download ? fileName(rw, commit) + ".base64" : null); + switch (outputType) { + case ZIP: + bin.disableGzip() + .setContentType("application/zip") + .setAttachmentName(fileName(rw, commit) + ".zip"); + break; + case BASE64: + bin.base64() + .setContentType("application/mbox") + .setAttachmentName(download ? fileName(rw, commit) + ".base64" : null); + break; + case RAW: + bin.setContentType("text/plain") + .setAttachmentName(download ? fileName(rw, commit) : null); + break; } close = false;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java index 819ae72..1482144 100644 --- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java +++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -14,10 +14,12 @@ package com.google.gerrit.server.restapi.change; +import static com.google.common.collect.ImmutableSet.toImmutableSet; import static java.util.stream.Collectors.toList; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; @@ -48,7 +50,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; @@ -203,7 +204,7 @@ int numberOfRelevantChanges = config.getInt("suggest", "relevantChanges", 50); // Get the user's last numberOfRelevantChanges changes, check reviewers try { - ImmutableList<ChangeData> result = + ImmutableList<ChangeData> changes = queryProvider .get() .setLimit(numberOfRelevantChanges) @@ -213,9 +214,13 @@ // Put those candidates at the bottom of the list candidateList.stream().forEach(id -> suggestions.put(id, new MutableDouble(0))); - for (ChangeData cd : result) { + ImmutableSet<Account.Id> candidateIds = + changes.stream().flatMap(cd -> cd.reviewers().all().stream()).collect(toImmutableSet()); + Map<Account.Id, AccountState> candidateStates = accountCache.get(candidateIds); + + for (ChangeData cd : changes) { for (Account.Id reviewer : cd.reviewers().all()) { - if (accountMatchesQuery(reviewer, query)) { + if (accountMatchesQuery(candidateStates.get(reviewer), query)) { suggestions .computeIfAbsent(reviewer, (ignored) -> new MutableDouble(0)) .add(baseWeight); @@ -230,13 +235,15 @@ } } - private boolean accountMatchesQuery(Account.Id id, String query) { - Optional<Account> account = accountCache.get(id).map(AccountState::account); - if (account.isPresent() && account.get().isActive()) { + private boolean accountMatchesQuery(AccountState accountState, String query) { + if (accountState == null) { + return false; + } + Account account = accountState.account(); + if (account.isActive()) { if (Strings.isNullOrEmpty(query) - || (account.get().fullName() != null && account.get().fullName().startsWith(query)) - || (account.get().preferredEmail() != null - && account.get().preferredEmail().startsWith(query))) { + || (account.fullName() != null && account.fullName().startsWith(query)) + || (account.preferredEmail() != null && account.preferredEmail().startsWith(query))) { return true; } }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java index 93173128b..29f76ee 100644 --- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java +++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -350,7 +350,9 @@ private List<SuggestedReviewerInfo> loadAccounts(List<Account.Id> accountIds) throws PermissionBackendException { Set<FillOptions> fillOptions = - Sets.union(AccountLoader.DETAILED_OPTIONS, EnumSet.of(FillOptions.SECONDARY_EMAILS)); + Sets.union( + AccountLoader.DETAILED_OPTIONS_WITHOUT_AVATAR, + EnumSet.of(FillOptions.SECONDARY_EMAILS)); AccountLoader accountLoader = accountLoaderFactory.create(fillOptions); try (Timer0.Context ctx = metrics.loadAccountsLatency.start()) {
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteGroup.java b/java/com/google/gerrit/server/restapi/group/DeleteGroup.java new file mode 100644 index 0000000..6954dd0 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/group/DeleteGroup.java
@@ -0,0 +1,200 @@ +// 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.group; + +import com.google.gerrit.entities.AccountGroup; +import com.google.gerrit.entities.CachedProjectConfig; +import com.google.gerrit.entities.GroupDescription; +import com.google.gerrit.entities.InternalGroup; +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.client.ListGroupsOption; +import com.google.gerrit.extensions.common.DeleteGroupInput; +import com.google.gerrit.extensions.common.GroupInfo; +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.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.server.UserInitiated; +import com.google.gerrit.server.account.GroupCache; +import com.google.gerrit.server.account.GroupControl; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.group.GroupResource; +import com.google.gerrit.server.group.db.Groups; +import com.google.gerrit.server.group.db.GroupsUpdate; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.server.project.ProjectCache; +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 java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.eclipse.jgit.errors.ConfigInvalidException; +import org.eclipse.jgit.lib.Config; + +@Singleton +public class DeleteGroup implements RestModifyView<GroupResource, DeleteGroupInput> { + private final Provider<ListGroups> listGroupProvider; + private final GroupCache groupCache; + private final ProjectCache projectCache; + private final Provider<GroupsUpdate> groupsUpdateProvider; + private final GroupJson json; + private final Groups groups; + private final boolean deleteGroupEnabled; + + @Inject + DeleteGroup( + Provider<ListGroups> listGroupProvider, + GroupCache groupCache, + ProjectCache projectCache, + @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider, + @GerritServerConfig Config cfg, + GroupJson json, + Groups groups) { + this.listGroupProvider = listGroupProvider; + this.groupCache = groupCache; + this.projectCache = projectCache; + this.groupsUpdateProvider = groupsUpdateProvider; + this.json = json; + this.groups = groups; + this.deleteGroupEnabled = cfg.getBoolean("groups", "enableDeleteGroup", false); + } + + public DeleteGroup addOption(ListGroupsOption o) { + json.addOption(o); + return this; + } + + @Override + public Response<String> apply(GroupResource resource, DeleteGroupInput input) + throws AuthException, + BadRequestException, + UnprocessableEntityException, + ResourceConflictException, + IOException, + ConfigInvalidException, + ResourceNotFoundException, + PermissionBackendException, + NotInternalGroupException { + if (deleteGroupEnabled) { + GroupDescription.Internal internalGroup = + resource.asInternalGroup().orElseThrow(NotInternalGroupException::new); + final GroupControl control = resource.getControl(); + if (!control.canDeleteGroup()) { + throw new AuthException("Cannot delete group " + internalGroup.getName()); + } + groupDeletionPrecondition(internalGroup); + deleteGroup(internalGroup); + return Response.ok(); + } + throw new ResourceNotFoundException("Deletion of Group is not enabled"); + } + + private void groupDeletionPrecondition(GroupDescription.Internal internalGroup) + throws ResourceConflictException, ConfigInvalidException, IOException { + AccountGroup.UUID uuid = internalGroup.getGroupUUID(); + if (groupCache.get(internalGroup.getGroupUUID()).isEmpty()) { + throw new ResourceConflictException( + String.format("group %s does not exist", internalGroup.getGroupUUID())); + } + List<InternalGroup> ownedGroup = getOwnedGroup(uuid, internalGroup.getName()); + if (!ownedGroup.isEmpty()) { + String msg = + "Cannot delete group that is owner of other groups: \n" + + ownedGroup.stream() + .map(InternalGroup::getName) + .collect(Collectors.joining(", ", "[", "]")); + throw new ResourceConflictException(msg); + } + List<String> inProjects = getProjectsWithGroupRefs(uuid); + if (!inProjects.isEmpty()) { + String msg = + "Cannot delete group that is referenced in access permissions for project: \n" + + inProjects; + throw new ResourceConflictException(msg); + } + List<String> subgroupsInGroups = getSubgroupsInGroups(uuid, internalGroup.getName()); + if (!subgroupsInGroups.isEmpty()) { + String msg = "Cannot delete group that is subgroup of another group: \n" + subgroupsInGroups; + throw new ResourceConflictException(msg); + } + } + + private List<InternalGroup> getOwnedGroup(AccountGroup.UUID uuid, String groupOwnerName) + throws IOException { + try { + ListGroups listOwner = listGroupProvider.get(); + listOwner.setOwnedBy(uuid.get()); + List<GroupInfo> groups = listOwner.get(); + return groups.stream() + .filter(group -> !group.name.equals(groupOwnerName)) + .map(group -> groupCache.get(AccountGroup.UUID.parse(group.id))) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new IOException("Failed to check owned groups for group " + uuid.get(), e); + } + } + + private List<String> getProjectsWithGroupRefs(AccountGroup.UUID uuid) { + List<String> projects = new ArrayList<>(); + for (Project.NameKey projectName : projectCache.all()) { + Optional<ProjectState> projectState = projectCache.get(projectName); + if (projectState.isPresent()) { + CachedProjectConfig config = projectState.get().getConfig(); + if (config.getGroup(uuid).isPresent()) { + projects.add(projectName.toString()); + } + } + } + return projects; + } + + private List<String> getSubgroupsInGroups(AccountGroup.UUID uuid, String groupName) + throws ConfigInvalidException, IOException { + List<String> allGroupsWithSubGroups = new ArrayList<>(); + groups + .getAllGroupReferences() + .forEach( + entry -> { + try { + if (groups.getGroup(entry.getUUID()).isEmpty()) { + throw new ResourceNotFoundException( + String.format( + "Could not check if group %s is subgroup of %s", + groupName, entry.getName())); + } + if (groups.getGroup(entry.getUUID()).get().getSubgroups().contains(uuid)) { + allGroupsWithSubGroups.add(entry.getName()); + } + } catch (IOException | ConfigInvalidException | ResourceNotFoundException e) { + throw new RuntimeException(e); + } + }); + return allGroupsWithSubGroups; + } + + private void deleteGroup(GroupDescription.Internal internalGroup) + throws IOException, ConfigInvalidException { + AccountGroup.UUID uuid = internalGroup.getGroupUUID(); + groupsUpdateProvider.get().deleteGroup(uuid); + } +}
diff --git a/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java index f115374..e9f726c 100644 --- a/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java +++ b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
@@ -38,6 +38,7 @@ DynamicMap.mapOf(binder(), SUBGROUP_KIND); create(GROUP_KIND).to(CreateGroup.class); + delete(GROUP_KIND).to(DeleteGroup.class); get(GROUP_KIND).to(GetGroup.class); put(GROUP_KIND).to(PutGroup.class); get(GROUP_KIND, "description").to(GetDescription.class);
diff --git a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java index dc4e6df..6b46164 100644 --- a/java/com/google/gerrit/server/restapi/project/CheckMergeability.java +++ b/java/com/google/gerrit/server/restapi/project/CheckMergeability.java
@@ -45,6 +45,7 @@ private String source; private String strategy; private SubmitType submitType; + private final boolean useGitattributesForMerge; @Option( name = "--source", @@ -76,6 +77,7 @@ this.commits = commits; this.strategy = MergeUtil.getMergeStrategy(cfg).getName(); this.submitType = cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY); + this.useGitattributesForMerge = MergeUtil.useGitattributesForMerge(cfg); } @Override @@ -95,8 +97,6 @@ try (Repository git = gitManager.openRepository(resource.getNameKey()); RevWalk rw = new RevWalk(git); ObjectInserter inserter = new InMemoryInserter(git)) { - Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy); - Ref destRef = git.getRefDatabase().exactRef(resource.getRef()); if (destRef == null) { throw new ResourceNotFoundException(resource.getRef()); @@ -126,6 +126,12 @@ return Response.ok(result); } + Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy); + if (m instanceof ResolveMerger && useGitattributesForMerge) { + // We need to set the attributes provider before attempting the merge in order to read and + // honor gitattributes merge settings correctly + ((ResolveMerger) m).setAttributesNodeProvider(git.createAttributesNodeProvider()); + } if (m.merge(false, targetCommit, sourceCommit)) { result.mergeable = true; result.commitMerged = false;
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java index 61e3c3c..7cc4d75 100644 --- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java +++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -343,7 +343,7 @@ .setMessage( // Same message as in ReceiveCommits.CreateRequest. ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of())) - .setValidate(false) + .disableValidation() .setUpdateRef(false); } }
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java index a213f28..002d0a1 100644 --- a/java/com/google/gerrit/server/submit/CherryPick.java +++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -122,7 +122,8 @@ 0, false, false, - useDiff3); + useDiff3, + ctx.getRepoView().getAttributesNodeProvider()); } catch (MergeConflictException mce) { // Keep going in the case of a single merge failure; the goal is to // cherry-pick as many commits as possible. @@ -213,7 +214,8 @@ ctx.getRepoView().getConfig(), args.destBranch, mergeTip.getCurrentTip(), - toMerge); + toMerge, + ctx.getRepoView().getAttributesNodeProvider()); result = amendGitlink(result); mergeTip.moveTipTo(result, toMerge); args.mergeUtil.markCleanMerges(
diff --git a/java/com/google/gerrit/server/submit/MergeOneOp.java b/java/com/google/gerrit/server/submit/MergeOneOp.java index 1840479..d2b6ea1 100644 --- a/java/com/google/gerrit/server/submit/MergeOneOp.java +++ b/java/com/google/gerrit/server/submit/MergeOneOp.java
@@ -46,7 +46,8 @@ ctx.getRepoView().getConfig(), args.destBranch, args.mergeTip.getCurrentTip(), - toMerge); + toMerge, + ctx.getRepoView().getAttributesNodeProvider()); if (args.project.is(BooleanProjectConfig.REJECT_EMPTY_COMMIT) && merged.getTree().equals(merged.getParent(0).getTree())) { toMerge.setStatusCode(EMPTY_COMMIT);
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java index 1f7288d..74ea315 100644 --- a/java/com/google/gerrit/server/submit/MergeOp.java +++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -474,22 +474,37 @@ // 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", - triggeringChangeId.get(), caller.getRealUser().getLoggableName(), cd.getId().get()); - problems.add( - ChangeProblem.create( - cd.getId(), - String.format( - "Change %d depends on other hidden changes", triggeringChangeId.get()))); + if (triggeringChangeId.get() != cd.getId().get()) { + logger.atFine().log( + "Change %d cannot be submitted by user %s because it depends on change %d which the" + + "user cannot read", + triggeringChangeId.get(), caller.getRealUser().getLoggableName(), cd.getId().get()); + problems.add( + ChangeProblem.create( + cd.getId(), + String.format( + "Change %d depends on other hidden changes", triggeringChangeId.get()))); + } else { + logger.atFine().log( + "Change %d cannot be submitted by user %s because they don't have READ permission", + triggeringChangeId.get(), caller.getRealUser().getLoggableName()); + problems.add( + ChangeProblem.create( + cd.getId(), String.format("Change %d is not visible", triggeringChangeId.get()))); + } return; } 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", - triggeringChangeId.get(), caller.getRealUser().getLoggableName(), cd.getId().get()); + if (triggeringChangeId.get() != cd.getId().get()) { + logger.atFine().log( + "Change %d cannot be submitted by user %s because it depends on change %d which the" + + "user cannot submit", + triggeringChangeId.get(), caller.getRealUser().getLoggableName(), cd.getId().get()); + } else { + logger.atFine().log( + "Change %d cannot be submitted by user %s because they don't have SUBMIT permission", + triggeringChangeId.get(), caller.getRealUser().getLoggableName()); + } problems.add( ChangeProblem.create( cd.getId(), @@ -498,13 +513,22 @@ } if (caller.isImpersonating()) { if (!permissionBackend.user(caller).change(cd).test(ChangePermission.READ)) { - logger.atFine().log( - "Change %d cannot be submitted by user %s on behalf of user %s because it depends on" - + " change %d which the on-behalf-of user does not have READ permission for", - triggeringChangeId.get(), - caller.getRealUser().getLoggableName(), - caller.getLoggableName(), - cd.getId().get()); + if (triggeringChangeId.get() != cd.getId().get()) { + logger.atFine().log( + "Change %d cannot be submitted by user %s on behalf of user %s because it depends" + + " on change %d which the on-behalf-of user does not have READ permission for", + triggeringChangeId.get(), + caller.getRealUser().getLoggableName(), + caller.getLoggableName(), + cd.getId().get()); + } else { + logger.atFine().log( + "Change %d cannot be submitted by user %s on behalf of user %s because the" + + " on-behalf-of user does not have READ permission", + triggeringChangeId.get(), + caller.getRealUser().getLoggableName(), + caller.getLoggableName()); + } problems.add( ChangeProblem.create( cd.getId(), @@ -514,13 +538,22 @@ return; } if (!can.contains(ChangePermission.SUBMIT_AS)) { - logger.atFine().log( - "Change %d cannot be submitted by user %s on behalf of user %s because it depends on" - + " change %d which the user does not have SUBMIT_AS permission for", - triggeringChangeId.get(), - caller.getRealUser().getLoggableName(), - caller.getLoggableName(), - cd.getId().get()); + if (triggeringChangeId.get() != cd.getId().get()) { + logger.atFine().log( + "Change %d cannot be submitted by user %s on behalf of user %s because it depends" + + " on change %d which the user does not have SUBMIT_AS permission for", + triggeringChangeId.get(), + caller.getRealUser().getLoggableName(), + caller.getLoggableName(), + cd.getId().get()); + } else { + logger.atFine().log( + "Change %d cannot be submitted by user %s on behalf of user %s because they do not" + + " have SUBMIT_AS permission", + triggeringChangeId.get(), + caller.getRealUser().getLoggableName(), + caller.getLoggableName()); + } problems.add( ChangeProblem.create( cd.getId(),
diff --git a/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java index 7e861b6..907acfc 100644 --- a/java/com/google/gerrit/server/update/RepoView.java +++ b/java/com/google/gerrit/server/update/RepoView.java
@@ -23,6 +23,7 @@ import java.io.IOException; import java.util.Map; import java.util.Optional; +import org.eclipse.jgit.attributes.AttributesNodeProvider; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectInserter; @@ -49,6 +50,7 @@ private final ObjectInserter inserterWrapper; private final ChainedReceiveCommands commands; private final boolean closeRepo; + private AttributesNodeProvider attributesNodeProvider; RepoView(GitRepositoryManager repoManager, Project.NameKey project) throws IOException { repo = repoManager.openRepository(project); @@ -163,6 +165,20 @@ return result; } + /** + * Get the existing or create a new {@link org.eclipse.jgit.attributes.AttributesNodeProvider}. + * + * @return a new or existing {@link org.eclipse.jgit.attributes.AttributesNodeProvider}. This + * {@link org.eclipse.jgit.attributes.AttributesNodeProvider} is lazy loaded only once for the + * life of this RepoView. + */ + public AttributesNodeProvider getAttributesNodeProvider() { + if (attributesNodeProvider == null) { + attributesNodeProvider = repo.createAttributesNodeProvider(); + } + return attributesNodeProvider; + } + private static Optional<ObjectId> toOptional(ObjectId id) { return id.equals(ObjectId.zeroId()) ? Optional.empty() : Optional.of(id); }
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java index c5621ed..58886f1 100644 --- a/java/com/google/gerrit/server/update/RetryHelper.java +++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -120,7 +120,8 @@ final Counter3<String, String, String> failuresOnAutoRetryCount; @Inject - Metrics(MetricMaker metricMaker) { + @VisibleForTesting + public Metrics(MetricMaker metricMaker) { Field<String> actionTypeField = Field.ofString("action_type", Metadata.Builder::actionType) .description("The type of the action that was retried.")
diff --git a/java/com/google/gerrit/server/update/context/RefUpdateContext.java b/java/com/google/gerrit/server/update/context/RefUpdateContext.java index a8c11df..13d5366 100644 --- a/java/com/google/gerrit/server/update/context/RefUpdateContext.java +++ b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
@@ -16,10 +16,14 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; +import static com.google.common.collect.ImmutableList.toImmutableList; import com.google.common.collect.ImmutableList; +import com.google.gerrit.common.UsedAt; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; +import java.util.List; import java.util.Optional; /** @@ -151,6 +155,13 @@ private final RefUpdateType updateType; private final Optional<String> justification; + /** + * Custom data, e.g. Google-specific data. + * + * <p>This data is only stored on the top-level context. + */ + private List<Object> customData; + private RefUpdateContext(RefUpdateType updateType, Optional<String> justification) { this.updateType = updateType; this.justification = justification; @@ -169,6 +180,39 @@ return result; } + @UsedAt(UsedAt.Project.GOOGLE) + public void addCustomData(Object data) { + // Store the data in the top-level context only. + RefUpdateContext currentContext = current.get().getFirst(); + if (this != currentContext) { + currentContext.addCustomData(data); + return; + } + + if (customData == null) { + customData = new ArrayList<>(); + } + customData.add(data); + } + + @SuppressWarnings("unchecked") + public <T> ImmutableList<T> getCustomData(Class<T> clazz) { + // The data is available in the top-level context only. + RefUpdateContext currentContext = current.get().getFirst(); + if (this != currentContext) { + return currentContext.getCustomData(clazz); + } + + if (customData == null) { + return ImmutableList.of(); + } + + return customData.stream() + .filter(data -> clazz.isAssignableFrom(data.getClass())) + .map(data -> (T) data) + .collect(toImmutableList()); + } + /** * Returns the type of {@link RefUpdateContext}. *
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java index a4e427d..7df7f3f 100644 --- a/java/com/google/gerrit/sshd/SshCommand.java +++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -46,6 +46,7 @@ @Inject @GerritServerConfig private Config config; @Inject private DeadlineChecker.Factory deadlineCheckerFactory; @Inject private CancellationMetrics cancellationMetrics; + @Inject private SshMetrics sshMetrics; @Option(name = "--trace", usage = "enable request tracing") private boolean trace; @@ -53,7 +54,7 @@ @Option(name = "--trace-id", usage = "trace ID (can only be set if --trace was set too)") private String traceId; - @Option(name = "--deadline", usage = "deadline after which the request should be aborted)") + @Option(name = "--deadline", usage = "deadline after which the request should be aborted") private String deadline; protected PrintWriter stdout; @@ -72,7 +73,9 @@ PerformanceLogContext performanceLogContext = new PerformanceLogContext(config, performanceLoggers)) { RequestInfo requestInfo = - RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build(); + RequestInfo.builder(RequestInfo.RequestType.SSH, getName(), user, traceContext) + .build(); + Throwable error = null; try (RequestStateContext requestStateContext = RequestStateContext.open() .addRequestStateProvider( @@ -80,8 +83,10 @@ requestListeners.runEach(l -> l.onRequest(requestInfo)); SshCommand.this.run(); } catch (InvalidDeadlineException e) { + error = e; stderr.println(e.getMessage()); } catch (RuntimeException e) { + error = e; Optional<RequestCancelledException> requestCancelledException = RequestCancelledException.getFromCausalChain(e); if (!requestCancelledException.isPresent()) { @@ -97,6 +102,8 @@ " (%s)", requestCancelledException.get().getCancellationMessage().get())); } stderr.println(msg.toString()); + } finally { + sshMetrics.countRequest(requestInfo, error); } } finally { stdout.flush();
diff --git a/java/com/google/gerrit/sshd/SshMetrics.java b/java/com/google/gerrit/sshd/SshMetrics.java new file mode 100644 index 0000000..2e1ade3 --- /dev/null +++ b/java/com/google/gerrit/sshd/SshMetrics.java
@@ -0,0 +1,64 @@ +// 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.sshd; + +import com.google.gerrit.common.Nullable; +import com.google.gerrit.metrics.Counter1; +import com.google.gerrit.metrics.Counter2; +import com.google.gerrit.metrics.Description; +import com.google.gerrit.metrics.Field; +import com.google.gerrit.metrics.MetricMaker; +import com.google.gerrit.server.RequestCounter; +import com.google.gerrit.server.RequestInfo; +import com.google.gerrit.server.logging.Metadata; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +@Singleton +public class SshMetrics implements RequestCounter { + private final Counter1<String> count; + private final Counter2<String, String> errorCount; + + @Inject + SshMetrics(MetricMaker metrics) { + this.count = + metrics.newCounter( + "ssh/success_count", + new Description("Count of successful ssh requests").setRate(), + Field.ofString("command_name", Metadata.Builder::commandName) + .description("The command name of the request.") + .build()); + + this.errorCount = + metrics.newCounter( + "ssh/error_count", + new Description("Number of failed requests").setRate(), + Field.ofString("command_name", Metadata.Builder::commandName) + .description("The command name of the request.") + .build(), + Field.ofString("exception", Metadata.Builder::exception) + .description("Exception that failed the request.") + .build()); + } + + @Override + public void countRequest(RequestInfo requestInfo, @Nullable Throwable error) { + if (error == null) { + count.increment(requestInfo.commandName().get()); + } else { + errorCount.increment(requestInfo.commandName().get(), error.getClass().getSimpleName()); + } + } +}
diff --git a/java/com/google/gerrit/sshd/commands/Receive.java b/java/com/google/gerrit/sshd/commands/Receive.java index 3f2e2ad..f34b08b 100644 --- a/java/com/google/gerrit/sshd/commands/Receive.java +++ b/java/com/google/gerrit/sshd/commands/Receive.java
@@ -28,6 +28,7 @@ import com.google.gerrit.server.permissions.ProjectPermission; import com.google.gerrit.sshd.AbstractGitCommand; import com.google.gerrit.sshd.CommandMetaData; +import com.google.gerrit.sshd.SshMetrics; import com.google.inject.Inject; import java.io.IOException; import org.eclipse.jgit.errors.TooLargeObjectInPackException; @@ -45,6 +46,7 @@ @Inject private AsyncReceiveCommits.Factory factory; @Inject private PermissionBackend permissionBackend; + @Inject private SshMetrics sshMetrics; private final SetMultimap<ReviewerStateInternal, Account.Id> reviewers = MultimapBuilder.hashKeys(2).hashSetValues().build(); @@ -82,7 +84,7 @@ } AsyncReceiveCommits arc = - factory.create(projectState, currentUser.asIdentifiedUser(), repo, null); + factory.create(projectState, currentUser.asIdentifiedUser(), repo, null, sshMetrics); try { Capable r = arc.canUpload();
diff --git a/java/com/google/gerrit/sshd/commands/Upload.java b/java/com/google/gerrit/sshd/commands/Upload.java index b80b879..7977895 100644 --- a/java/com/google/gerrit/sshd/commands/Upload.java +++ b/java/com/google/gerrit/sshd/commands/Upload.java
@@ -33,6 +33,7 @@ import com.google.gerrit.server.permissions.ProjectPermission; import com.google.gerrit.server.plugincontext.PluginSetContext; import com.google.gerrit.sshd.AbstractGitCommand; +import com.google.gerrit.sshd.SshMetrics; import com.google.inject.Inject; import java.io.IOException; import java.util.List; @@ -54,6 +55,7 @@ @Inject private UploadValidators.Factory uploadValidatorsFactory; @Inject private PermissionBackend permissionBackend; @Inject private UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook; + @Inject private SshMetrics sshMetrics; private PackStatistics stats; @@ -93,21 +95,30 @@ try (TraceContext traceContext = TraceContext.open(); TracingHook tracingHook = new TracingHook()) { RequestInfo requestInfo = - RequestInfo.builder(RequestInfo.RequestType.GIT_UPLOAD, user, traceContext) + RequestInfo.builder(RequestInfo.RequestType.GIT_UPLOAD, getName(), user, traceContext) .project(projectState.getNameKey()) .build(); - requestListeners.runEach(l -> l.onRequest(requestInfo)); - up.setProtocolV2Hook(tracingHook); - up.upload(in, out, err); - session.setPeerAgent(up.getPeerUserAgent()); - stats = up.getStatistics(); - } catch (UploadValidationException e) { - // UploadValidationException is used by the UploadValidators to - // stop the uploadPack. We do not want this exception to go beyond this - // point otherwise it would print a stacktrace in the logs and return an - // internal server error to the client. - if (!e.isOutput()) { - up.sendMessage(e.getMessage()); + Throwable error = null; + try { + requestListeners.runEach(l -> l.onRequest(requestInfo)); + up.setProtocolV2Hook(tracingHook); + up.upload(in, out, err); + session.setPeerAgent(up.getPeerUserAgent()); + stats = up.getStatistics(); + } catch (UploadValidationException e) { + error = e; + // UploadValidationException is used by the UploadValidators to + // stop the uploadPack. We do not want this exception to go beyond this + // point otherwise it would print a stacktrace in the logs and return an + // internal server error to the client. + if (!e.isOutput()) { + up.sendMessage(e.getMessage()); + } + } catch (RuntimeException e) { + error = e; + throw e; + } finally { + sshMetrics.countRequest(requestInfo, error); } } }
diff --git a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java index 3464d21..a940644 100644 --- a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java +++ b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
@@ -199,4 +199,14 @@ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0); assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0); } + + @Test + public void callbackMetric() { + String name = "some_name"; + Integer metricValue = 3; + testMetricMaker.newCallbackMetric( + name, Integer.class, new Description("some_description"), () -> metricValue); + + assertThat(testMetricMaker.getCallbackMetricValue(name)).isEqualTo(metricValue); + } }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java index 55a2023..f77dbb2 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -71,6 +71,7 @@ import com.google.gerrit.acceptance.ExtensionRegistry; import com.google.gerrit.acceptance.ExtensionRegistry.Registration; import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.PushOneCommit.Result; import com.google.gerrit.acceptance.Sandboxed; import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.UseClockStep; @@ -585,9 +586,101 @@ AccountIndexedCounter accountIndexedCounter = getAccountIndexedCounter(); try (Registration registration = extensionRegistry.newRegistration().add(accountIndexedCounter)) { - AccountInfo info = gApi.accounts().id(admin.id().get()).get(); - AccountInfo infoByIntId = gApi.accounts().id(info._accountId).get(); - assertThat(info.name).isEqualTo(infoByIntId.name); + assertAccountFound("admin"); + accountIndexedCounter.assertNoReindex(); + } + } + + @Test + @GerritConfig(name = "accounts.caseInsensitiveLocalPart", value = "example.com") + public void getByEmailIdCaseInsensitive() throws Exception { + AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter(); + try (Registration registration = + extensionRegistry.newRegistration().add(accountIndexedCounter)) { + + String email = "admin@example.com"; + assertAccountFound(email); + assertAccountFound(email.toUpperCase(Locale.US)); + + accountIndexedCounter.assertNoReindex(); + } + } + + @Test + public void getByEmailIdCaseSensitive() throws Exception { + AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter(); + try (Registration registration = + extensionRegistry.newRegistration().add(accountIndexedCounter)) { + + String email = "admin@example.com"; + assertAccountFound(email); + assertAccountNotFound(email.toUpperCase(Locale.US)); + + accountIndexedCounter.assertNoReindex(); + } + } + + @Test + @GerritConfig(name = "accounts.caseInsensitiveLocalPart", value = "example.com") + public void lookUpByEmailCaseInsensitive() throws Exception { + assertThat(emails.getAccountFor(admin.email().toUpperCase(Locale.US))).isNotEmpty(); + } + + @Test + public void lookUpByEmailCaseSensitive() throws Exception { + assertThat(emails.getAccountFor(admin.email().toUpperCase(Locale.US))).isEmpty(); + } + + @Test + @GerritConfig(name = "accounts.caseInsensitiveLocalPart", value = "example.com") + public void lookUpByEmailsCaseInsensitive() throws Exception { + assertThat(emails.getAccountsFor(admin.email().toUpperCase(Locale.US))).isNotEmpty(); + } + + @Test + public void lookUpByEmailsCaseSensitive() throws Exception { + assertThat(emails.getAccountsFor(admin.email().toUpperCase(Locale.US))).isEmpty(); + } + + private void assertAccountNotFound(String mail) { + ResourceNotFoundException thrown = + assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id(mail)); + assertThat(thrown).hasMessageThat().isEqualTo("Account '" + mail + "' not found"); + } + + @Test + @GerritConfig(name = "accounts.caseInsensitiveLocalPart", value = "example.com") + public void getByNameAndEmailIdCaseInsensitive() throws Exception { + AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter(); + try (Registration registration = + extensionRegistry.newRegistration().add(accountIndexedCounter)) { + + String nameAndEmail = "Admin <admin@example.com>"; + assertAccountFound(nameAndEmail); + assertAccountFound(nameAndEmail.toUpperCase(Locale.US)); + + accountIndexedCounter.assertNoReindex(); + } + } + + @CanIgnoreReturnValue + private AccountInfo assertAccountFound(String id) throws RestApiException { + AccountInfo infoByEmailLowerCase = gApi.accounts().id(id).get(); + AccountInfo infoByIntId = gApi.accounts().id(infoByEmailLowerCase._accountId).get(); + assertThat(infoByEmailLowerCase.name).isEqualTo(infoByIntId.name); + return infoByIntId; + } + + @Test + public void getByNameAndEmailIdCaseSensitive() throws Exception { + AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter(); + try (Registration registration = + extensionRegistry.newRegistration().add(accountIndexedCounter)) { + + String nameAndEmail = "Admin <admin@example.com>"; + assertAccountFound(nameAndEmail); + assertAccountNotFound(nameAndEmail.toUpperCase(Locale.US)); + accountIndexedCounter.assertNoReindex(); } } @@ -2672,6 +2765,23 @@ } @Test + @UseClockStep + public void updateDrafts() throws Exception { + try { + PushOneCommit.Result r1 = createChange(); + + requestScopeOperations.setApiUser(user.id()); + CommentInfo draft = createDraft(r1, PushOneCommit.FILE_NAME, "draft creation"); + updateDraft(r1, draft, "draft update"); + assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList()).hasSize(1); + assertThat(gApi.changes().id(r1.getChangeId()).current().draftsAsList().get(0).message) + .isEqualTo("draft update"); + } finally { + cleanUpDrafts(); + } + } + + @Test public void userCanGenerateNewHttpPassword() throws Exception { sender.clear(); String newPassword = gApi.accounts().self().generateHttpPassword(); @@ -2937,12 +3047,22 @@ .isEqualTo(postUpdateStatus); } - private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception { + @CanIgnoreReturnValue + private CommentInfo createDraft(Result r, String path, String message) throws Exception { DraftInput in = new DraftInput(); in.path = path; in.line = 1; in.message = message; - gApi.changes().id(r.getChangeId()).current().createDraft(in); + return gApi.changes().id(r.getChangeId()).current().createDraft(in).get(); + } + + @CanIgnoreReturnValue + private CommentInfo updateDraft(Result r, CommentInfo orig, String newMessage) throws Exception { + DraftInput in = new DraftInput(); + in.path = orig.path; + in.line = orig.line; + in.message = newMessage; + return gApi.changes().id(r.getChangeId()).current().draft(orig.id).update(in); } private void cleanUpDrafts() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java index e3380c0..7c6e5ed 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/DiffPreferencesIT.java
@@ -15,7 +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 static com.google.gerrit.acceptance.PreferencesAssertionUtil.assertPrefs; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.NoHttpd; @@ -64,6 +64,9 @@ DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).setDiffPreferences(i); assertPrefs(o, i); + // Re-getting the preferences should yield the same fields + o = gApi.accounts().id(admin.id().get()).getDiffPreferences(); + assertPrefs(o, i); // Partially fill input record i = new DiffPreferencesInfo(); @@ -74,6 +77,38 @@ } @Test + public void setDiffPreferences_booleanHandling() throws Exception { + DiffPreferencesInfo update = new DiffPreferencesInfo(); + update.showLineEndings = true; // Default is 'true' + update.lineWrapping = true; // Default is 'false' + update.showTabs = false; // Default is 'true' + update.hideTopMenu = false; // Default is 'false' + update.intralineDifference = null; // Default is 'true' + update.retainHeader = null; // Default is 'false' + + DiffPreferencesInfo o = gApi.accounts().id(admin.id().get()).setDiffPreferences(update); + + // Explicitly assert configured values + assertThat(o.showLineEndings).isTrue(); + assertThat(o.lineWrapping).isTrue(); + assertThat(o.showTabs).isFalse(); + assertThat(o.hideTopMenu).isNull(); // Both new value and default are false, omitted + assertThat(o.intralineDifference).isTrue(); + assertThat(o.retainHeader).isNull(); // new value is 'null' and default is false, omitted + + // assert unaffected fields + assertPrefs( + o, + DiffPreferencesInfo.defaults(), + "showLineEndings", + "lineWrapping", + "showTabs", + "hideTopMenu", + "intralineDifference", + "retainHeader"); + } + + @Test public void getDiffPreferencesWithConfiguredDefaults() throws Exception { DiffPreferencesInfo d = DiffPreferencesInfo.defaults(); int newLineLength = d.lineLength + 10;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java index 74829a3..d38c09a 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -15,7 +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 static com.google.gerrit.acceptance.PreferencesAssertionUtil.assertPrefs; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.NoHttpd;
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java index afa9bca..db038d1 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -15,7 +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 static com.google.gerrit.acceptance.PreferencesAssertionUtil.assertPrefs; import static com.google.gerrit.testing.GerritJUnit.assertThrows; import com.google.gerrit.acceptance.AbstractDaemonTest;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java index 46422e2..c1f186f 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -22,6 +22,7 @@ import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT; import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME; import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel; @@ -169,11 +170,7 @@ 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.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; @@ -2995,6 +2992,33 @@ } @Test + public void deleteTopicWithoutPermissionNotAllowed() throws Exception { + PushOneCommit.Result r = createChange(); + assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo(""); + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.EDIT_TOPIC_NAME).ref("refs/heads/master").group(REGISTERED_USERS)) + .update(); + requestScopeOperations.setApiUser(user.id()); + gApi.changes().id(r.getChangeId()).topic("mytopic"); + assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo("mytopic"); + requestScopeOperations.setApiUser(admin.id()); + projectOperations + .project(project) + .forUpdate() + .remove( + permissionKey(Permission.EDIT_TOPIC_NAME) + .ref("refs/heads/master") + .group(REGISTERED_USERS)) + .update(); + requestScopeOperations.setApiUser(user.id()); + AuthException thrown = + assertThrows(AuthException.class, () -> gApi.changes().id(r.getChangeId()).topic("")); + assertThat(thrown).hasMessageThat().contains("edit topic name not permitted"); + } + + @Test public void editTopicWithPermissionAllowed() throws Exception { PushOneCommit.Result r = createChange(); assertThat(gApi.changes().id(r.getChangeId()).topic()).isEqualTo(""); @@ -4906,15 +4930,4 @@ 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/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java index bc210f0..9c2cbee 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -21,7 +21,6 @@ import static com.google.gerrit.server.project.testing.TestLabels.value; import static com.google.gerrit.testing.GerritJUnit.assertThrows; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.gerrit.acceptance.AbstractDaemonTest; @@ -29,20 +28,19 @@ import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; -import com.google.gerrit.common.Nullable; import com.google.gerrit.common.RawInputUtil; import com.google.gerrit.entities.AccountGroup; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.LabelId; import com.google.gerrit.entities.LabelType; -import com.google.gerrit.entities.LabelTypes; -import com.google.gerrit.entities.LabelValue; import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.entities.RefNames; import com.google.gerrit.extensions.api.changes.ReviewInput; import com.google.gerrit.extensions.common.ChangeInfo; import com.google.gerrit.server.approval.ApprovalCopier; +import com.google.gerrit.server.approval.ApprovalCopier.ApprovalCopyResult; +import com.google.gerrit.server.approval.ApprovalCopier.Result.PatchSetApprovalData; import com.google.gerrit.server.approval.ApprovalsUtil; import com.google.gerrit.server.group.SystemGroupBackend; import com.google.gerrit.server.project.testing.TestLabels; @@ -58,14 +56,14 @@ * * <p>Some of the tests only verify the correct formatting of the copied/outdated approvals in the * change message that is done by {@link - * ApprovalsUtil#formatApprovalCopierResult(com.google.gerrit.server.approval.ApprovalCopier.Result, - * LabelTypes)}. This method does the formatting based on the inputs that it gets, but it doesn't do - * any verification of these inputs. This means it's possible to provide inputs that are - * inconsistent with the approval copying logic in {@link ApprovalCopier}. E.g. it's possible to - * provide "is:MAX" as a passing atom for a "Code-Review-1" vote and have "is:MAX" highlighted as - * passing in the message although the "Code-Review-1" vote doesn't match with "is:MAX". For easier - * readability the formatting tests avoid using such inconsistent input data, but it's not - * impossible that in some cases we made a mistake and the input data is inconsistent. + * ApprovalsUtil#formatApprovalCopierResult(com.google.gerrit.server.approval.ApprovalCopier.Result) + * }. This method does the formatting based on the inputs that it gets, but it doesn't do any + * verification of these inputs. This means it's possible to provide inputs that are inconsistent + * with the approval copying logic in {@link ApprovalCopier}. E.g. it's possible to provide "is:MAX" + * as a passing atom for a "Code-Review-1" vote and have "is:MAX" highlighted as passing in the + * message although the "Code-Review-1" vote doesn't match with "is:MAX". For easier readability the + * formatting tests avoid using such inconsistent input data, but it's not impossible that in some + * cases we made a mistake and the input data is inconsistent. */ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest { @Inject private ApprovalsUtil approvalsUtil; @@ -74,193 +72,77 @@ @Test public void cannotFormatWithNullApprovalCopierResult() throws Exception { - LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes(); NullPointerException exception = assertThrows( NullPointerException.class, - () -> - approvalsUtil.formatApprovalCopierResult( - /* approvalCopierResult= */ null, labelTypes)); + () -> approvalsUtil.formatApprovalCopierResult(/* approvalCopierResult= */ null)); assertThat(exception).hasMessageThat().isEqualTo("approvalCopierResult"); } @Test - public void cannotFormatWithNullLabelTypes() throws Exception { - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of()); - NullPointerException exception = - assertThrows( - NullPointerException.class, - () -> - approvalsUtil.formatApprovalCopierResult( - approvalCopierResult, /* labelTypes= */ null)); - assertThat(exception).hasMessageThat().isEqualTo("labelTypes"); - } - - @Test public void format_noCopiedApprovals_noOutdatedApprovals() throws Exception { - LabelTypes labelTypes = projectCache.get(project).get().getLabelTypes(); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .isEmpty(); - } - - @Test - public void formatCopiedApproval_missingLabelType() throws Exception { - LabelTypes labelTypes = new LabelTypes(ImmutableList.of()); - PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n"); - } - - @Test - public void formatOutdatedApproval_missingLabelType() throws Exception { - LabelTypes labelTypes = new LabelTypes(ImmutableList.of()); - PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of(), - /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()))); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Outdated Votes:\n* Code-Review+1 (label type is missing)\n"); - } - - @Test - public void formatCopiedApproval_noCopyCondition() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null))); - PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1\n"); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)).isEmpty(); } @Test public void formatOutdatedApproval_noCopyCondition() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), - /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()))); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Outdated Votes:\n* Code-Review+1\n"); + /* outdatedApprovals= */ ImmutableSet.of(skippedEval(patchSetApproval))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"NEVER\")\n"); } @Test public void formatCopiedApproval_withCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", -2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval, + /* copied= */ true, + /* copyCondition= */ "is:MIN OR is:MAX", /* passingAtoms= */ ImmutableSet.of("is:MIN"), /* failingAtoms= */ ImmutableSet.of("is:MAX"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue("Copied Votes:\n* Code-Review-2 (copy condition: \"**is:MIN** OR is:MAX\")\n"); } @Test public void formatOutdatedApproval_withCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ "changekind:TRIVIAL_REBASE is:MAX"))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval, + /* copied= */ false, + /* copyCondition= */ "changekind:TRIVIAL_REBASE is:MAX", /* passingAtoms= */ ImmutableSet.of("is:MAX"), /* failingAtoms= */ ImmutableSet.of("changekind:TRIVIAL_REBASE")))); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( "Outdated Votes:\n* Code-Review+2 (copy condition:" + " \"changekind:TRIVIAL_REBASE **is:MAX**\")\n"); } @Test - public void formatCopiedApproval_withNonParseableCopyCondition_noUserInPredicate() - throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"))); - PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue( - "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"); - } - - @Test public void formatOutdatedApproval_withNonParseableCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), - /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()))); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + /* outdatedApprovals= */ ImmutableSet.of(errorEval(patchSetApproval, "foo bar baz"))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"); } @@ -269,24 +151,20 @@ public void formatCopiedApproval_withCopyCondition_withUserInPredicate() throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -296,26 +174,22 @@ } @Test - public void formatOutdatedpproval_withCopyCondition_withUserInPredicate() throws Exception { + public void formatOutdatedApproval_withCopyCondition_withUserInPredicate() throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval, + /* copied= */ false, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX")))); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Outdated Votes:\n" @@ -329,19 +203,15 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), @@ -353,7 +223,7 @@ // user that can see everything when parsing the copy condition. requestScopeOperations.setApiUser(user.id()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -368,20 +238,16 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX")))); @@ -391,7 +257,7 @@ // user that can see everything when parsing the copy condition. requestScopeOperations.setApiUser(user.id()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Outdated Votes:\n" @@ -401,57 +267,19 @@ } @Test - public void formatCopiedApproval_withNonParseableCopyCondition_withUserInPredicate() - throws Exception { - String groupUuid = - groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); - PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue( - String.format( - "Copied Votes:\n" - + "* Code-Review+1 (non-parseable copy condition: \"is:MIN" - + " OR (is:MAX approverin:%s) OR foo bar baz\")\n", - groupUuid)); - } - - @Test public void formatOutdatedApproval_withNonParseableCopyCondition_withUserInPredicate() throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of(), /* outdatedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + errorEval( patchSetApproval, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()))); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Outdated Votes:\n" @@ -461,201 +289,94 @@ } @Test - public void formatMultipleApprovals_sameVote_missingLabelType() throws Exception { - LabelTypes labelTypes = new LabelTypes(ImmutableList.of()); - PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); - PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1 (label type is missing)\n"); - } - - @Test - public void formatMultipleApprovals_differentLabels_missingLabelType() throws Exception { - LabelTypes labelTypes = new LabelTypes(ImmutableList.of()); - PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); - PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue( - "Copied Votes:\n" - + "* Code-Review+1 (label type is missing)\n" - + "* Verified+1 (label type is missing)\n"); - } - - @Test - public void formatMultipleApprovals_differentValues_missingLabelType() throws Exception { - LabelTypes labelTypes = new LabelTypes(ImmutableList.of()); - PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); - PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); - ApprovalCopier.Result approvalCopierResult = - ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2 (label type is missing)\n"); - } - - @Test public void formatMultipleApprovals_sameVote_noCopyCondition() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1\n"); + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + skippedEval(patchSetApproval1), skippedEval(patchSetApproval2))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"NEVER\")\n"); } @Test public void formatMultipleApprovals_differentLabel_noCopyCondition() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null), - createLabelType(/* labelName= */ "Verified", /* copyCondition= */ null))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1\n* Verified+1\n"); + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + skippedEval(patchSetApproval1), skippedEval(patchSetApproval2))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue( + "Outdated Votes:\n" + + "* Code-Review+1 (copy condition: \"NEVER\")\n" + + "* Verified+1 (copy condition: \"NEVER\")\n"); } @Test public void formatMultipleApprovals_differentValue_noCopyCondition() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ null))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) - .hasValue("Copied Votes:\n* Code-Review+1, Code-Review+2\n"); + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + skippedEval(patchSetApproval1), skippedEval(patchSetApproval2))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue("Outdated Votes:\n* Code-Review+1, Code-Review+2 (copy condition: \"NEVER\")\n"); } @Test public void formatMultipleApprovals_sameVote_withCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ "is:MIN OR is:MAX", /* passingAtoms= */ ImmutableSet.of("is:MAX"), /* failingAtoms= */ ImmutableSet.of("is:MIN")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ "is:MIN OR is:MAX", /* passingAtoms= */ ImmutableSet.of("is:MAX"), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue("Copied Votes:\n* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n"); } @Test public void formatMultipleApprovals_differentLabel_withCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"), - LabelType.builder( - "Verified", - ImmutableList.of( - LabelValue.create((short) -1, "Fails"), - LabelValue.create((short) 0, "No Vote"), - LabelValue.create((short) 1, "Succeeds"))) - .setCopyCondition("is:MIN OR is:MAX") - .build())); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ "is:MIN OR is:MAX", /* passingAtoms= */ ImmutableSet.of("is:MAX"), /* failingAtoms= */ ImmutableSet.of("is:MIN")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ "is:MIN OR is:MAX", /* passingAtoms= */ ImmutableSet.of("is:MAX"), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( "Copied Votes:\n" + "* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n" @@ -666,26 +387,25 @@ public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_samePassingAtoms() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "changekind:REWORK"))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ "changekind:REWORK", /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"), /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ "changekind:REWORK", /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"), /* failingAtoms= */ ImmutableSet.of())), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( "Copied Votes:\n" + "* Code-Review+1, Code-Review+2 (copy condition: \"**changekind:REWORK**\")\n"); @@ -695,26 +415,25 @@ public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_differentPassingAtoms() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "is:1 OR is:2"))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ "is:1 OR is:2", /* passingAtoms= */ ImmutableSet.of("is:2"), /* failingAtoms= */ ImmutableSet.of("is:1")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ "is:1 OR is:2", /* passingAtoms= */ ImmutableSet.of("is:1"), /* failingAtoms= */ ImmutableSet.of("is:2"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( "Copied Votes:\n" + "* Code-Review+1 (copy condition: \"**is:1** OR is:2\")\n" @@ -724,56 +443,34 @@ @Test public void formatMultipleApprovals_sameVote_withNonParseableCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + errorEval(patchSetApproval1, "foo bar baz"), + errorEval(patchSetApproval2, "foo bar baz"))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( - "Copied Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"); + "Outdated Votes:\n* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n"); } @Test public void formatMultipleApprovals_differentLabel_withNonParseableCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType(/* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"), - createLabelType(/* labelName= */ "Verified", /* copyCondition= */ "foo bar baz"))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + errorEval(patchSetApproval1, "foo bar baz"), + errorEval(patchSetApproval2, "foo bar baz"))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( - "Copied Votes:\n" + "Outdated Votes:\n" + "* Code-Review+1 (non-parseable copy condition: \"foo bar baz\")\n" + "* Verified+1 (non-parseable copy condition: \"foo bar baz\")\n"); } @@ -782,28 +479,17 @@ public void formatMultipleApprovals_differentValue_withNonParseableCopyCondition_noUserInPredicate() throws Exception { - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", /* copyCondition= */ "foo bar baz"))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( - patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + errorEval(patchSetApproval1, "foo bar baz"), + errorEval(patchSetApproval2, "foo bar baz"))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( - "Copied Votes:\n" + "Outdated Votes:\n" + "* Code-Review+1, Code-Review+2" + " (non-parseable copy condition: \"foo bar baz\")\n"); } @@ -814,30 +500,29 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -855,34 +540,34 @@ String administratorsGroupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); String registeredUsersGroupUuid = SystemGroupBackend.REGISTERED_USERS.get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR (is:MAX approverin:%s)", - administratorsGroupUuid, registeredUsersGroupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s) OR (is:MAX approverin:%s)", + administratorsGroupUuid, registeredUsersGroupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", registeredUsersGroupUuid)), /* failingAtoms= */ ImmutableSet.of( "is:MIN", String.format("approverin:%s", administratorsGroupUuid))), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s) OR (is:MAX approverin:%s)", + administratorsGroupUuid, registeredUsersGroupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", administratorsGroupUuid), String.format("approverin:%s", registeredUsersGroupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -905,34 +590,29 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)), - createLabelType( - /* labelName= */ "Verified", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", -2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of("is:MIN"), /* failingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", groupUuid))), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:MAX approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:MAX", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -952,30 +632,29 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:ANY approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:ANY approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:ANY", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:ANY approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:ANY", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -992,31 +671,31 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:1 approverin:%s) OR (is:2 approverin:%s)", - groupUuid, groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:1 approverin:%s) OR (is:2 approverin:%s)", + groupUuid, groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:2", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:1")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:1 approverin:%s) OR (is:2 approverin:%s)", + groupUuid, groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:1", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:2"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -1039,36 +718,38 @@ throws Exception { TestAccount user2 = accountCreator.user2(); String groupUuid = SystemGroupBackend.REGISTERED_USERS.get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:ANY approverin:%s)", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user2, "Code-Review", 1); PatchSetApproval patchSetApproval3 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval1, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:ANY approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:ANY", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval2, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:ANY approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:ANY", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN")), - ApprovalCopier.Result.PatchSetApprovalData.create( + createApprovalData( patchSetApproval3, + /* copied= */ true, + /* copyCondition= */ String.format( + "is:MIN OR (is:ANY approverin:%s)", groupUuid), /* passingAtoms= */ ImmutableSet.of( "is:ANY", String.format("approverin:%s", groupUuid)), /* failingAtoms= */ ImmutableSet.of("is:MIN"))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -1085,28 +766,19 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + errorEval( patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)), + errorEval( patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid))), /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( "Copied Votes:\n" @@ -1121,35 +793,22 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)), - createLabelType( - /* labelName= */ "Verified", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + errorEval( patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)), + errorEval( patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( - "Copied Votes:\n" + "Outdated Votes:\n" + "* Code-Review+1 (non-parseable copy condition: \"is:MIN" + " OR (is:MAX approverin:%s) OR foo bar baz\")\n" + "* Verified+1 (non-parseable copy condition: \"is:MIN" @@ -1163,31 +822,22 @@ throws Exception { String groupUuid = groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get(); - LabelTypes labelTypes = - new LabelTypes( - ImmutableList.of( - createLabelType( - /* labelName= */ "Code-Review", - /* copyCondition= */ String.format( - "is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2); PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1); ApprovalCopier.Result approvalCopierResult = ApprovalCopier.Result.create( - /* copiedApprovals= */ ImmutableSet.of( - ApprovalCopier.Result.PatchSetApprovalData.create( + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + errorEval( patchSetApproval1, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of()), - ApprovalCopier.Result.PatchSetApprovalData.create( + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)), + errorEval( patchSetApproval2, - /* passingAtoms= */ ImmutableSet.of(), - /* failingAtoms= */ ImmutableSet.of())), - /* outdatedApprovals= */ ImmutableSet.of()); - assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes)) + String.format("is:MIN OR (is:MAX approverin:%s) OR foo bar baz", groupUuid)))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) .hasValue( String.format( - "Copied Votes:\n" + "Outdated Votes:\n" + "* Code-Review+1, Code-Review+2 (non-parseable copy condition: \"is:MIN" + " OR (is:MAX approverin:%s) OR foo bar baz\")\n", groupUuid)); @@ -1235,7 +885,7 @@ + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n" + "\n" + "Outdated Votes:\n" - + "* Verified+1\n"); + + "* Verified+1 (copy condition: \"NEVER\")\n"); } @Test @@ -1286,7 +936,7 @@ + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n" + "\n" + "Outdated Votes:\n" - + "* Verified+1\n"); + + "* Verified+1 (copy condition: \"NEVER\")\n"); } @Test @@ -1332,7 +982,85 @@ + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n" + "\n" + "Outdated Votes:\n" - + "* Verified+1\n"); + + "* Verified+1 (copy condition: \"NEVER\")\n"); + } + + @Test + public void forcedCopyIncludedInChangeMessage() { + PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", -1); + ApprovalCopier.Result approvalCopierResult = + ApprovalCopier.Result.create( + /* copiedApprovals= */ ImmutableSet.of( + PatchSetApprovalData.create( + patchSetApproval, + ApprovalCopyResult.create( + /* labelCopyCondition= */ "is:MIN OR is:MAX", + /* labelCopy= */ false, + /* copyEnforcement= */ "is:negative", + /* forcedCopy= */ true, + /* copyRestriction= */ "changekind:REWORK", + /* forcedNonCopy= */ true, + ImmutableSet.of("is:negative", "changekind:REWORK"), + ImmutableSet.of("is:MIN", "is:MAX")))), + /* outdatedApprovals= */ ImmutableSet.of()); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue( + "Copied Votes:\n" + + "* Code-Review-1 (forced copy condition\\*: \"**is:negative**\")\n\n" + + "\\* The label has `labelCopyEnforcement` or `labelCopyRestriction` configured." + + " Only the most relevant condition that determined the outcome is shown.\n"); + } + + @Test + public void forcedNonCopyIncludedInChangeMessage() { + PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2); + ApprovalCopier.Result approvalCopierResult = + ApprovalCopier.Result.create( + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + PatchSetApprovalData.create( + patchSetApproval, + ApprovalCopyResult.create( + /* labelCopyCondition= */ "is:MIN OR is:MAX", + /* labelCopy= */ true, + /* copyEnforcement= */ "is:negative", + /* forcedCopy= */ false, + /* copyRestriction= */ "changekind:REWORK", + /* forcedNonCopy= */ true, + ImmutableSet.of("is:MAX", "changekind:REWORK"), + ImmutableSet.of("is:MIN", "is:negative"))))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue( + "Outdated Votes:\n" + + "* Code-Review+2 (forced copy restriction\\*: \"**changekind:REWORK**\")\n\n" + + "\\* The label has `labelCopyEnforcement` or `labelCopyRestriction` configured." + + " Only the most relevant condition that determined the outcome is shown.\n"); + } + + @Test + public void forcedRulesPresent_asteriskIncludedInChangeMessage() { + PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1); + ApprovalCopier.Result approvalCopierResult = + ApprovalCopier.Result.create( + /* copiedApprovals= */ ImmutableSet.of(), + /* outdatedApprovals= */ ImmutableSet.of( + PatchSetApprovalData.create( + patchSetApproval, + ApprovalCopyResult.create( + /* labelCopyCondition= */ "is:MIN OR is:MAX", + /* labelCopy= */ false, + /* copyEnforcement= */ "is:negative", + /* forcedCopy= */ false, + /* copyRestriction= */ "changekind:REWORK", + /* forcedNonCopy= */ true, + ImmutableSet.of("changekind:REWORK"), + ImmutableSet.of("is:MIN", "is:MAX", "is:negative"))))); + assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult)) + .hasValue( + "Outdated Votes:\n" + + "* Code-Review+1 (copy condition\\*: \"is:MIN OR is:MAX\")\n\n" + + "\\* The label has `labelCopyEnforcement` or `labelCopyRestriction` configured." + + " Only the most relevant condition that determined the outcome is shown.\n"); } private PatchSetApproval createPatchSetApproval( @@ -1346,19 +1074,40 @@ .build(); } - private LabelType createLabelType(String labelName, @Nullable String copyCondition) { - LabelType.Builder labelTypeBuilder = - LabelType.builder( - labelName, - ImmutableList.of( - LabelValue.create((short) -2, "Vetoed"), - LabelValue.create((short) -1, "Disliked"), - LabelValue.create((short) 0, "No Vote"), - LabelValue.create((short) 1, "Liked"), - LabelValue.create((short) 2, "Approved"))); - if (copyCondition != null) { - labelTypeBuilder.setCopyCondition(copyCondition); - } - return labelTypeBuilder.build(); + private PatchSetApprovalData skippedEval(PatchSetApproval psa) { + return PatchSetApprovalData.create(psa, ApprovalCopyResult.createEvaluationSkipped()); + } + + private PatchSetApprovalData errorEval(PatchSetApproval psa, String copyCondition) { + return PatchSetApprovalData.create( + psa, + ApprovalCopyResult.create( + copyCondition, + /* labelCopy= */ false, + /* copyEnforcement= */ null, + /* forcedCopy= */ false, + /* copyRestriction= */ null, + /* forcedNonCopy= */ false, + ImmutableSet.of(), + ImmutableSet.of())); + } + + private PatchSetApprovalData createApprovalData( + PatchSetApproval psa, + boolean copied, + String copyCondition, + ImmutableSet<String> passingAtoms, + ImmutableSet<String> failingAtoms) { + return PatchSetApprovalData.create( + psa, + ApprovalCopyResult.create( + copyCondition, + /* labelCopy= */ copied, + /* copyEnforcement= */ null, + /* forcedCopy= */ false, + /* copyRestriction= */ null, + /* forcedNonCopy= */ false, + passingAtoms, + failingAtoms)); } }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java index 502f286..710ae54 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.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.TestExtensions.TestCommitValidationListener; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block; import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS; @@ -25,7 +26,6 @@ import static com.google.gerrit.testing.GerritJUnit.assertThrows; import static java.nio.charset.StandardCharsets.UTF_8; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import com.google.gerrit.acceptance.AbstractDaemonTest; @@ -47,16 +47,13 @@ import com.google.gerrit.extensions.common.CommitInfo; import com.google.gerrit.extensions.common.MergeInput; import com.google.gerrit.extensions.common.MergePatchSetInput; +import com.google.gerrit.extensions.common.RevisionInfo; import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.BinaryResult; import com.google.gerrit.extensions.restapi.ResourceConflictException; import com.google.gerrit.extensions.restapi.UnprocessableEntityException; -import com.google.gerrit.server.events.CommitReceivedEvent; -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.inject.Inject; import java.io.ByteArrayOutputStream; import java.util.List; @@ -126,6 +123,17 @@ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit) .isEqualTo(parent); + // Verify the conflicts information + RevCommit sourceBranch = projectOperations.project(project).getHead(mergeInput.source); + RevCommit targetBranch = projectOperations.project(project).getHead(changeInfo.branch); + RevisionInfo currentRevision = changeInfo.getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(currentMaster.getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(targetBranch.name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); + // Verify the message that has been posted on the change. List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages(); assertThat(messages).hasSize(2); @@ -280,6 +288,17 @@ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit) .isEqualTo(parent); + // Verify the conflicts information + RevCommit sourceBranch = projectOperations.project(project).getHead(mergeInput.source); + RevCommit targetBranch = projectOperations.project(project).getHead(changeInfo.branch); + RevisionInfo currentRevision = changeInfo.getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(currentMaster.getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(targetBranch.name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name()); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); + // Verify that the file content in the created patch set is correct. // We expect that it has conflict markers to indicate the conflict. BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content(); @@ -400,6 +419,17 @@ .isEqualTo(parent); assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit) .isNotEqualTo(currentMaster.getCommit().getName()); + + // Verify the conflicts information + RevCommit sourceBranch = projectOperations.project(project).getHead(mergeInput.source); + RevisionInfo currentRevision = changeInfo.getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(parent); + assertThat(currentRevision.commit.parents.get(0).commit) + .isNotEqualTo(currentMaster.getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(parent); + assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); } @Test @@ -481,20 +511,29 @@ testRepo.reset(initialHead); PushOneCommit.Result result = createChange("refs/for/bar"); String baseChange = result.getChangeId(); - String expectedParent = result.getCommit().getName(); + String baseChangeCommit = result.getCommit().getName(); // Create the destination change on 'master' branch. testRepo.reset(initialHead); String changeId = createChange().getChangeId(); - gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange)); + MergePatchSetInput mergePatchSetInput = createMergePatchSetInput(baseChange); + gApi.changes().id(changeId).createMergePatchSet(mergePatchSetInput); ChangeInfo changeInfo = gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION); assertThat(changeInfo.revisions).hasSize(2); assertThat(changeInfo.subject).isEqualTo("create ps2"); - assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit) - .isEqualTo(expectedParent); + + // Verify the conflicts information + RevCommit sourceBranch = + projectOperations.project(project).getHead(mergePatchSetInput.merge.source); + RevisionInfo currentRevision = changeInfo.getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(baseChangeCommit); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(baseChangeCommit); + assertThat(currentRevision.conflicts.theirs).isEqualTo(sourceBranch.name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); } @Test @@ -732,15 +771,4 @@ event.getChange().workInProgress != null ? event.getChange().workInProgress : false; } } - - 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/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java index 843de33..e1a1f7c 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -16,6 +16,7 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block; import static com.google.gerrit.extensions.client.ChangeKind.MERGE_FIRST_PARENT_UPDATE; @@ -39,6 +40,7 @@ import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.TestMetricMaker; +import com.google.gerrit.acceptance.config.GerritConfig; import com.google.gerrit.acceptance.testsuite.account.AccountOperations; import com.google.gerrit.acceptance.testsuite.change.ChangeOperations; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; @@ -75,10 +77,6 @@ import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.UnprocessableEntityException; -import com.google.gerrit.server.events.CommitReceivedEvent; -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.inject.Inject; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -133,6 +131,7 @@ PushOneCommit.Result r = createChange(); testRepo.reset("HEAD~1"); PushOneCommit.Result r2 = createChange(); + RevCommit commitThatIsBeingRebased = r2.getCommit(); // Approve and submit the first change RevisionApi revision = gApi.changes().id(r.getChangeId()).current(); @@ -145,7 +144,8 @@ // Rebase the second change rebaseCall.call(r2.getChangeId()); - verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2); + verifyRebaseForChange( + r2.getChange().getId(), commitThatIsBeingRebased.name(), r.getCommit().name(), true, 2); // Rebasing the second change again should fail verifyChangeIsUpToDate(r2); @@ -234,6 +234,7 @@ .file(file2) .content("content") .create(); + String commitThatIsBeingRebased = getCurrentRevision(mergeChangeId); // Create a change in master onto which the merge change can be rebased. This change touches // an unrelated file (file3) so that there is no conflict on rebase. @@ -255,9 +256,11 @@ verifyRebaseForChange( mergeChangeId, + commitThatIsBeingRebased, ImmutableList.of( getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)), /* shouldHaveApproval= */ true, + /* shouldHaveConflicts,= */ false, /* expectedNumRevisions= */ 2); // Verify the file contents. @@ -439,7 +442,8 @@ .file(file) .content(mergeContent) .create(); - String mergeSha1 = abbreviateName(ObjectId.fromString(getCurrentRevision(mergeChangeId)), 6); + String commitThatIsBeingRebased = getCurrentRevision(mergeChangeId); + String mergeSha1 = abbreviateName(ObjectId.fromString(commitThatIsBeingRebased), 6); // Create a change in master onto which the merge change can be rebased. This change touches // the file again so that there is a conflict on rebase. @@ -475,8 +479,10 @@ String baseCommit = getCurrentRevision(newBaseChangeInMaster); verifyRebaseForChange( mergeChangeId, + commitThatIsBeingRebased, ImmutableList.of(baseCommit, getCurrentRevision(changeInOtherBranch)), /* shouldHaveApproval= */ false, + /* shouldHaveConflicts,= */ true, /* expectedNumRevisions= */ 2); // Verify the file contents. @@ -585,6 +591,7 @@ .file(file) .content(mergeContent) .create(); + String commitThatIsBeingRebased = getCurrentRevision(mergeChangeId); // Create a change in master onto which the merge change can be rebased. This change touches // the file again so that there is a conflict on rebase. @@ -606,9 +613,11 @@ verifyRebaseForChange( mergeChangeId, + commitThatIsBeingRebased, ImmutableList.of( getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)), /* shouldHaveApproval= */ false, + /* shouldHaveConflicts,= */ false, /* expectedNumRevisions= */ 2); // Verify the file contents. @@ -1193,6 +1202,7 @@ verifyRebaseForChange( r2.getChange().getId(), + r2.getCommit().name(), r.getCommit().name(), /* shouldHaveApproval= */ false, /* expectedNumRevisions= */ 2); @@ -1216,35 +1226,53 @@ } protected void verifyRebaseForChange( - Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval) + Change.Id changeId, + String commitThatIsBeingRebased, + Change.Id baseChangeId, + boolean shouldHaveApproval) throws RestApiException { - verifyRebaseForChange(changeId, baseChangeId, shouldHaveApproval, 2); + verifyRebaseForChange( + changeId, commitThatIsBeingRebased, baseChangeId, shouldHaveApproval, 2); } protected void verifyRebaseForChange( Change.Id changeId, + String commitThatIsBeingRebased, Change.Id baseChangeId, boolean shouldHaveApproval, int expectedNumRevisions) throws RestApiException { verifyRebaseForChange( changeId, + commitThatIsBeingRebased, ImmutableList.of(getCurrentRevision(baseChangeId)), shouldHaveApproval, + /* shouldHaveConflicts,= */ false, expectedNumRevisions); } protected void verifyRebaseForChange( - Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions) + Change.Id changeId, + String commitThatIsBeingRebased, + String parentCommit, + boolean shouldHaveApproval, + int expectedNumRevisions) throws RestApiException { verifyRebaseForChange( - changeId, ImmutableList.of(baseCommit), shouldHaveApproval, expectedNumRevisions); + changeId, + commitThatIsBeingRebased, + ImmutableList.of(parentCommit), + shouldHaveApproval, /* shouldHaveConflicts,= */ + false, + expectedNumRevisions); } protected void verifyRebaseForChange( Change.Id changeId, - List<String> baseCommits, + String commitThatIsBeingRebased, + List<String> parentCommits, boolean shouldHaveApproval, + boolean shouldHaveConflicts, int expectedNumRevisions) throws RestApiException { ChangeInfo info = @@ -1254,12 +1282,18 @@ assertThat(r._number).isEqualTo(expectedNumRevisions); assertThat(r.realUploader).isNull(); - // ...and the base should be correct - assertThat(r.commit.parents).hasSize(baseCommits.size()); - for (int baseNum = 0; baseNum < baseCommits.size(); baseNum++) { - assertWithMessage("base commit " + baseNum + " for change " + changeId) - .that(r.commit.parents.get(baseNum).commit) - .isEqualTo(baseCommits.get(baseNum)); + // check conflicts info + assertThat(r.conflicts).isNotNull(); + assertThat(r.conflicts.ours).isEqualTo(commitThatIsBeingRebased); + assertThat(r.conflicts.theirs).isEqualTo(parentCommits.get(0)); + assertThat(r.conflicts.containsConflicts).isEqualTo(shouldHaveConflicts); + + // ...and the parent should be correct + assertThat(r.commit.parents).hasSize(parentCommits.size()); + for (int parentNum = 0; parentNum < parentCommits.size(); parentNum++) { + assertWithMessage("parent commit " + parentNum + " for change " + changeId) + .that(r.commit.parents.get(parentNum).commit) + .isEqualTo(parentCommits.get(parentNum)); } // ...and the committer and description should be correct @@ -1289,17 +1323,6 @@ assertThat(thrown).hasMessageThat().contains("Change is already up to date"); } - protected static class TestCommitValidationListener implements CommitValidationListener { - public CommitReceivedEvent receiveEvent; - - @Override - public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) - throws CommitValidationException { - this.receiveEvent = receiveEvent; - return ImmutableList.of(); - } - } - protected static class TestWorkInProgressStateChangedListener implements WorkInProgressStateChangedListener { boolean invoked; @@ -1399,8 +1422,12 @@ ChangeInfo changeInfo = gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION); assertThat(changeInfo.revisions).hasSize(2); - assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit) - .isEqualTo(base.name()); + RevisionInfo currentRevision = changeInfo.getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(base.name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(patchSet.name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(base.name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); // Verify that the file content in the created patch set is correct. BinaryResult bin = @@ -1479,8 +1506,13 @@ ChangeInfo changeInfo = gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION); assertThat(changeInfo.revisions).hasSize(2); - assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit) - .isEqualTo(base.name()); + + RevisionInfo currentRevision = changeInfo.getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(base.name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(patchSet.name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(base.name()); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); // Verify that the file content in the created patch set is correct. // We expect that it has conflict markers to indicate the conflict. @@ -1552,6 +1584,62 @@ } @Test + @GerritConfig(name = "core.useGitattributesForMerge", value = "true") + public void rebaseWithAttributes_UnionContentMerge() throws Exception { + PushOneCommit pushAttributes = + pushFactory.create( + admin.newIdent(), + testRepo, + "add merge=union to gitattributes", + ".gitattributes", + "*.txt merge=union"); + PushOneCommit.Result unusedResult = pushAttributes.to("refs/heads/master"); + + PushOneCommit.Result r1 = createChange(); + gApi.changes() + .id(r1.getChangeId()) + .revision(r1.getCommit().name()) + .review(ReviewInput.approve()); + gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit(); + + PushOneCommit push = + pushFactory.create( + admin.newIdent(), + testRepo, + PushOneCommit.SUBJECT, + PushOneCommit.FILE_NAME, + "other content", + "I3bf2c82554e83abc759154e85db94c7ebb079c70"); + PushOneCommit.Result r2 = push.to("refs/for/master"); + r2.assertOkStatus(); + String changeId = r2.getChangeId(); + RevCommit patchSet = r2.getCommit(); + RevCommit base = r1.getCommit(); + RebaseInput rebaseInput = new RebaseInput(); + rebaseInput.strategy = "recursive"; + ChangeInfo changeInfo = + gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput); + assertThat(changeInfo.containsGitConflicts).isNull(); + assertThat(changeInfo.workInProgress).isNull(); + + RevisionInfo currentRevision = + gApi.changes().id(changeId).get(CURRENT_REVISION).getCurrentRevision(); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(patchSet.name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(base.name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); + + // Verify that the file content in the created patch set is correct. + // We expect that it has no conflict markers and the content of both changes. + BinaryResult bin = + gApi.changes().id(changeId).current().file(PushOneCommit.FILE_NAME).content(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + bin.writeTo(os); + String fileContent = new String(os.toByteArray(), UTF_8); + assertThat(fileContent).isEqualTo("other content" + "\n" + PushOneCommit.FILE_CONTENT); + } + + @Test public void rebaseFromRelationChainToClosedChange() throws Exception { PushOneCommit.Result r1 = createChange(); testRepo.reset("HEAD~1"); @@ -1842,9 +1930,12 @@ gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4); // Only r2, r3 and r4 are rebased. - verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2); - verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true); - verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false); + verifyRebaseForChange( + r2.getChange().getId(), r2.getCommit().name(), r.getCommit().name(), true, 2); + verifyRebaseForChange( + r3.getChange().getId(), r3.getCommit().name(), r2.getChange().getId(), true); + verifyRebaseForChange( + r4.getChange().getId(), r4.getCommit().name(), r3.getChange().getId(), false); verifyChangeIsUpToDate(r2); verifyChangeIsUpToDate(r3); @@ -1863,7 +1954,8 @@ verifyRebaseChainResponse( gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5); - verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false); + verifyRebaseForChange( + r5.getChange().getId(), r5.getCommit().name(), r4.getChange().getId(), false); } @Test @@ -1924,6 +2016,7 @@ .file(file1) .content("merged content") .create(); + String mergeCommitThatIsBeingRebased = getCurrentRevision(mergeChangeId); // Create a follow up change. Change.Id followUpChangeId = @@ -1936,6 +2029,7 @@ .file(file1) .content("modified content") .create(); + String followUpCommitThatIsBeingRebased = getCurrentRevision(followUpChangeId); // Create another change in the other branch so that we can create another merge Change.Id anotherChangeInOtherBranch = @@ -1963,6 +2057,7 @@ .file(file1) .content("another merged content") .create(); + String followUpMergeCommitThatIsBeingRebased = getCurrentRevision(followUpMergeChangeId); // Create a change in master onto which the chain can be rebased. This change touches an // unrelated file (file2) so that there is no conflict on rebase. @@ -1984,20 +2079,26 @@ verifyRebaseForChange( mergeChangeId, + mergeCommitThatIsBeingRebased, ImmutableList.of( getCurrentRevision(newBaseChangeInMaster), getCurrentRevision(changeInOtherBranch)), /* shouldHaveApproval= */ false, + /* shouldHaveConflicts,= */ false, /* expectedNumRevisions= */ 2); verifyRebaseForChange( followUpChangeId, + followUpCommitThatIsBeingRebased, ImmutableList.of(getCurrentRevision(mergeChangeId)), /* shouldHaveApproval= */ false, + /* shouldHaveConflicts,= */ false, /* expectedNumRevisions= */ 2); verifyRebaseForChange( followUpMergeChangeId, + followUpMergeCommitThatIsBeingRebased, ImmutableList.of( getCurrentRevision(followUpChangeId), getCurrentRevision(anotherChangeInOtherBranch)), /* shouldHaveApproval= */ false, + /* shouldHaveConflicts,= */ false, /* expectedNumRevisions= */ 2); // Verify the file contents. @@ -2033,6 +2134,7 @@ .edit() .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8))); gApi.changes().id(r3.getChangeId()).edit().publish(); + String r3PatchSet2 = getCurrentRevision(r3.getChange().getId()); // Approve and submit the first change RevisionApi revision = gApi.changes().id(r.getChangeId()).current(); @@ -2042,9 +2144,11 @@ // Rebase the chain through r4. rebaseCall.call(r4.getChangeId()); - verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), false, 2); - verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), false, 3); - verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false); + verifyRebaseForChange( + r2.getChange().getId(), r2.getCommit().name(), r.getCommit().name(), false, 2); + verifyRebaseForChange(r3.getChange().getId(), r3PatchSet2, r2.getChange().getId(), false, 3); + verifyRebaseForChange( + r4.getChange().getId(), r4.getCommit().name(), r3.getChange().getId(), false); assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString()) .isEqualTo(newContent); @@ -2096,8 +2200,10 @@ verifyRebaseChainResponse(gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r3, r4); // Only r3 and r4 are rebased. - verifyRebaseForChange(r3.getChange().getId(), r2.getChange().getId(), true); - verifyRebaseForChange(r4.getChange().getId(), r3.getChange().getId(), false); + verifyRebaseForChange( + r3.getChange().getId(), r3.getCommit().name(), r2.getChange().getId(), true); + verifyRebaseForChange( + r4.getChange().getId(), r4.getCommit().name(), r3.getChange().getId(), false); verifyChangeIsUpToDate(r2); verifyChangeIsUpToDate(r3); @@ -2116,7 +2222,8 @@ verifyRebaseChainResponse( gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r3, r4, r5); - verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false); + verifyRebaseForChange( + r5.getChange().getId(), r5.getCommit().name(), r4.getChange().getId(), false); } @Test @@ -2179,10 +2286,11 @@ r2.assertOkStatus(); String changeWithConflictId = r2.getChangeId(); - RevCommit patchSet = r2.getCommit(); + RevCommit parentPatchSet = r2.getCommit(); RevCommit base = r1.getCommit(); PushOneCommit.Result r3 = createChange("refs/for/master"); r3.assertOkStatus(); + RevCommit childPatchSet = r3.getCommit(); TestWorkInProgressStateChangedListener wipStateChangedListener = new TestWorkInProgressStateChangedListener(); @@ -2194,14 +2302,32 @@ gApi.changes().id(r3.getChangeId()).rebaseChain(rebaseInput); verifyRebaseChainResponse(res, true, r2, r3); RebaseChainInfo rebaseChainInfo = res.value(); - ChangeInfo changeWithConflictInfo = rebaseChainInfo.rebasedChanges.get(0); - assertThat(changeWithConflictInfo.changeId).isEqualTo(r2.getChangeId()); - assertThat(changeWithConflictInfo.containsGitConflicts).isTrue(); - assertThat(changeWithConflictInfo.workInProgress).isTrue(); + + ChangeInfo parentChangeInfo = rebaseChainInfo.rebasedChanges.get(0); + assertThat(parentChangeInfo.changeId).isEqualTo(r2.getChangeId()); + assertThat(parentChangeInfo.containsGitConflicts).isTrue(); + assertThat(parentChangeInfo.workInProgress).isTrue(); + + RevisionInfo parentChangeCurrentRevision = parentChangeInfo.getCurrentRevision(); + assertThat(parentChangeCurrentRevision.commit.parents.get(0).commit).isEqualTo(base.name()); + assertThat(parentChangeCurrentRevision.conflicts).isNotNull(); + assertThat(parentChangeCurrentRevision.conflicts.ours).isEqualTo(parentPatchSet.name()); + assertThat(parentChangeCurrentRevision.conflicts.theirs).isEqualTo(base.name()); + assertThat(parentChangeCurrentRevision.conflicts.containsConflicts).isTrue(); + ChangeInfo childChangeInfo = rebaseChainInfo.rebasedChanges.get(1); assertThat(childChangeInfo.changeId).isEqualTo(r3.getChangeId()); assertThat(childChangeInfo.containsGitConflicts).isTrue(); assertThat(childChangeInfo.workInProgress).isTrue(); + + RevisionInfo childChangeCurrentRevision = childChangeInfo.getCurrentRevision(); + assertThat(childChangeCurrentRevision.commit.parents.get(0).commit) + .isEqualTo(parentChangeCurrentRevision.commit.commit); + assertThat(childChangeCurrentRevision.conflicts).isNotNull(); + assertThat(childChangeCurrentRevision.conflicts.ours).isEqualTo(childPatchSet.name()); + assertThat(childChangeCurrentRevision.conflicts.theirs) + .isEqualTo(parentChangeCurrentRevision.commit.commit); + assertThat(childChangeCurrentRevision.conflicts.containsConflicts).isTrue(); } assertThat(wipStateChangedListener.invoked).isTrue(); assertThat(wipStateChangedListener.wip).isTrue(); @@ -2222,7 +2348,7 @@ ByteArrayOutputStream os = new ByteArrayOutputStream(); bin.writeTo(os); String fileContent = new String(os.toByteArray(), UTF_8); - String patchSetSha1 = abbreviateName(patchSet, 6); + String patchSetSha1 = abbreviateName(parentPatchSet, 6); String baseSha1 = abbreviateName(base, 6); assertThat(fileContent) .isEqualTo(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java index 1f3a6eb..f6bd27a 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.TestExtensions.TestCommitValidationListener; 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; @@ -53,10 +54,6 @@ import com.google.gerrit.extensions.restapi.ResourceConflictException; import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.server.ChangeUtil; -import com.google.gerrit.server.events.CommitReceivedEvent; -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.permissions.PermissionDeniedException; import com.google.gerrit.testing.FakeEmailSender.Message; import com.google.inject.Inject; @@ -208,6 +205,11 @@ assertThat(revertChange.messages).hasSize(1); assertThat(revertChange.messages.iterator().next().message).isEqualTo("Uploaded patch set 1."); assertThat(revertChange.revertOf).isEqualTo(gApi.changes().id(r.getChangeId()).get()._number); + + assertThat(revertChange.getCurrentRevision().conflicts).isNotNull(); + assertThat(revertChange.getCurrentRevision().conflicts.containsConflicts).isFalse(); + assertThat(revertChange.getCurrentRevision().conflicts.ours).isNull(); + assertThat(revertChange.getCurrentRevision().conflicts.theirs).isNull(); } @Test @@ -950,6 +952,12 @@ .isEqualTo(result.getChange().change().getChangeId()); assertThat(revertChanges.get(0).get().topic) .startsWith("revert-" + result.getChange().change().getSubmissionId() + "-"); + + assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts).isNotNull(); + assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.containsConflicts) + .isFalse(); + assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.ours).isNull(); + assertThat(revertChanges.get(0).get().getCurrentRevision().conflicts.theirs).isNull(); } @Test @@ -1194,6 +1202,16 @@ assertThat(revertChanges).hasSize(2); assertThat(gApi.changes().id(revertChanges.get(0).id()).current().related().changes).hasSize(2); + + // None of the revert changes has conflicts. + for (int i = 0; i < revertChanges.size(); i++) { + // Internally RevertSubmission either uses Revert or Cherry-Pick to do the reverts. + // Depending on which operation is used ours/theirs is set (if Cherry-Pick is used) or unset + // (if Revert is used). Hence we do not validate ours/theirs here. + assertThat(revertChanges.get(i).get().getCurrentRevision().conflicts).isNotNull(); + assertThat(revertChanges.get(i).get().getCurrentRevision().conflicts.containsConflicts) + .isFalse(); + } } @Test @@ -1563,15 +1581,4 @@ input.workInProgress = true; return input; } - - 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/config/DiffPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java index 70aa557..35166e9 100644 --- a/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/config/DiffPreferencesIT.java
@@ -15,7 +15,7 @@ package com.google.gerrit.acceptance.api.config; import static com.google.common.truth.Truth.assertWithMessage; -import static com.google.gerrit.acceptance.AssertUtil.assertPrefs; +import static com.google.gerrit.acceptance.PreferencesAssertionUtil.assertPrefs; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.NoHttpd;
diff --git a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java index 02f1ec3..04059a6 100644 --- a/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/config/EditPreferencesIT.java
@@ -15,7 +15,7 @@ package com.google.gerrit.acceptance.api.config; import static com.google.common.truth.Truth.assertWithMessage; -import static com.google.gerrit.acceptance.AssertUtil.assertPrefs; +import static com.google.gerrit.acceptance.PreferencesAssertionUtil.assertPrefs; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.NoHttpd;
diff --git a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java index 221e171..1baf119 100644 --- a/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/config/GeneralPreferencesIT.java
@@ -15,7 +15,7 @@ package com.google.gerrit.acceptance.api.config; import static com.google.common.truth.Truth.assertWithMessage; -import static com.google.gerrit.acceptance.AssertUtil.assertPrefs; +import static com.google.gerrit.acceptance.PreferencesAssertionUtil.assertPrefs; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.NoHttpd;
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java index fe3cb00..e53da53 100644 --- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java +++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -52,6 +52,7 @@ .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, + ExperimentFeaturesConstants.ENABLE_CENTRAL_OVERRIDE_FOR_CODE_REVIEW_COPY_CONDITION, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE) .inOrder();
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java index 289e642..b9bbd96 100644 --- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java +++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -156,6 +156,7 @@ @Inject private GroupsSnapshotReader groupsSnapshotReader; @Inject private ProjectResetter.Builder.Factory projectResetterFactory; + @Inject private GroupOperations groupsOperations; @Override public Module createModule() { @@ -733,6 +734,104 @@ } @Test + @GerritConfig(name = "groups.enableDeleteGroup", value = "true") + public void deleteGroup() throws Exception { + String name = groupOperations.newGroup().create().get(); + gApi.groups().id(name).delete(); + assertGroupDoesNotExist(name); + } + + @Test + @GerritConfig(name = "groups.enableDeleteGroup", value = "false") + public void notEnabledDeleteGroups() throws Exception { + String name = groupOperations.newGroup().create().get(); + Throwable exception = + assertThrows(ResourceNotFoundException.class, () -> gApi.groups().id(name).delete()); + assertThat(exception.getMessage()).isEqualTo("Deletion of Group is not enabled"); + } + + @Test + @IgnoreGroupInconsistencies + @GerritConfig(name = "groups.enableDeleteGroup", value = "true") + public void deleteGroupOwnsOtherGroup() { + AccountGroup.UUID ownerUuid = groupOperations.newGroup().create(); + AccountGroup.UUID memberUuid = groupOperations.newGroup().ownerGroupUuid(ownerUuid).create(); + groupOperations.group(ownerUuid).forUpdate().addSubgroup(memberUuid).update(); + + Throwable exception = + assertThrows( + ResourceConflictException.class, () -> gApi.groups().id(ownerUuid.get()).delete()); + assertThat(exception.getMessage()) + .isEqualTo( + "Cannot delete group that is owner of other groups: \n" + + "[" + + groupOperations.group(memberUuid).get().name() + + "]"); + } + + @Test + @GerritConfig(name = "groups.enableDeleteGroup", value = "true") + public void deleteGroupIsReferenced() { + AccountGroup.UUID uuid = groupsOperations.newGroup().create(); + projectOperations + .project(allProjects) + .forUpdate() + .add(allow(Permission.PUSH).ref("refs/heads/*").group(uuid)) + .update(); + + Throwable exception = + assertThrows(ResourceConflictException.class, () -> gApi.groups().id(uuid.get()).delete()); + assertThat(exception.getMessage()) + .isEqualTo( + "Cannot delete group that is referenced in access permissions for project: \n" + + "[" + + allProjects.get() + + "]"); + } + + @Test + @GerritConfig(name = "groups.enableDeleteGroup", value = "true") + public void deleteGroupIsSubgroup() throws Exception { + AccountGroup.UUID ownerUuid = groupOperations.newGroup().create(); + AccountGroup.UUID memberUuid = groupOperations.newGroup().ownerGroupUuid(ownerUuid).create(); + groupOperations.group(ownerUuid).forUpdate().addSubgroup(memberUuid).update(); + + Throwable exception = + assertThrows( + ResourceConflictException.class, () -> gApi.groups().id(memberUuid.get()).delete()); + assertThat(exception.getMessage()) + .isEqualTo( + "Cannot delete group that is subgroup of another group: \n" + + "[" + + groupOperations.group(ownerUuid).get().name() + + "]"); + } + + @Test + @GerritConfig(name = "groups.enableDeleteGroup", value = "true") + public void nonAdminAreNotAllowedToDeleteGroup() throws Exception { + AccountGroup.UUID groupUuid = groupOperations.newGroup().visibleToAll(true).create(); + requestScopeOperations.setApiUser(user.id()); + Throwable exception = + assertThrows(AuthException.class, () -> gApi.groups().id(groupUuid.get()).delete()); + assertThat(exception.getMessage()) + .isEqualTo("Cannot delete group " + groupOperations.group(groupUuid).get().name()); + } + + @Test + @GerritConfig(name = "groups.enableDeleteGroup", value = "true") + public void nonAdminGrantedCapabilityToDeleteGroup() throws Exception { + String name = groupOperations.newGroup().visibleToAll(true).create().get(); + projectOperations + .allProjectsForUpdate() + .add(allowCapability(GlobalCapability.DELETE_GROUP).group(REGISTERED_USERS)) + .update(); + requestScopeOperations.setApiUser(user.id()); + gApi.groups().id(name).delete(); + assertGroupDoesNotExist(name); + } + + @Test public void groupDescription() throws Exception { String name = name("group"); gApi.groups().create(name);
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java index e0c4b08..fb6259b 100644 --- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java +++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -16,18 +16,26 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.gerrit.acceptance.GitUtil.pushHead; +import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME; +import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION; +import static com.google.gerrit.git.ObjectIds.abbreviateName; import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; import static com.google.gerrit.testing.GerritJUnit.assertThrows; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.stream.Collectors.toList; +import static org.eclipse.jgit.lib.Constants.HEAD; import static org.eclipse.jgit.lib.Constants.R_TAGS; +import com.google.common.collect.ImmutableMap; import com.google.common.truth.Correspondence; import com.google.gerrit.acceptance.AbstractDaemonTest; -import com.google.gerrit.acceptance.NoHttpd; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.PushOneCommit.Result; +import com.google.gerrit.acceptance.RestResponse; import com.google.gerrit.acceptance.TestAccount; import com.google.gerrit.acceptance.testsuite.account.AccountOperations; import com.google.gerrit.acceptance.testsuite.change.ChangeOperations; @@ -51,8 +59,11 @@ import com.google.gerrit.extensions.common.GitPerson; import com.google.gerrit.extensions.common.RevisionInfo; 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.truth.NullAwareCorrespondence; import com.google.inject.Inject; +import java.io.ByteArrayOutputStream; import java.util.Iterator; import java.util.List; import org.eclipse.jgit.junit.TestRepository; @@ -61,7 +72,6 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; -@NoHttpd public class CommitIT extends AbstractDaemonTest { @Inject private ProjectOperations projectOperations; @Inject private AccountOperations accountOperations; @@ -167,6 +177,39 @@ } @Test + public void cherryPickWithoutConflicts() throws Exception { + String destBranch = "foo"; + createBranch(BranchNameKey.create(project, destBranch)); + + // Create change to cherry-pick + PushOneCommit.Result r = createChange(); + RevCommit commitToCherryPick = r.getCommit(); + + // Cherry-pick to foo branch + CherryPickInput input = new CherryPickInput(); + input.destination = destBranch; + ChangeInfo cherryPickResult = + gApi.projects() + .name(project.get()) + .commit(commitToCherryPick.name()) + .cherryPick(input) + .get(); + + // Verify the conflicts information + RevCommit head = projectOperations.project(project).getHead(cherryPickResult.branch); + RevisionInfo currentRevision = + gApi.changes() + .id(cherryPickResult.id) + .get(CURRENT_REVISION, CURRENT_COMMIT) + .getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); + } + + @Test public void cherryPickWithoutMessageSameBranch() throws Exception { String destBranch = "master"; @@ -272,6 +315,197 @@ } @Test + public void cherryPickWithAllowConflicts() throws Exception { + ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId(); + + // Create a branch and push a commit to it (by-passing review) + String destBranch = "foo"; + createBranch(BranchNameKey.create(project, destBranch)); + String destContent = "some content"; + PushOneCommit push = + pushFactory.create( + admin.newIdent(), + testRepo, + PushOneCommit.SUBJECT, + ImmutableMap.of(PushOneCommit.FILE_NAME, destContent, "foo.txt", "foo")); + push.to("refs/heads/" + destBranch); + + // Create a change on master with a commit that conflicts with the commit on the other branch. + testRepo.reset(initial); + String changeContent = "another content"; + push = + pushFactory.create( + admin.newIdent(), + testRepo, + PushOneCommit.SUBJECT, + ImmutableMap.of(PushOneCommit.FILE_NAME, changeContent, "bar.txt", "bar")); + PushOneCommit.Result r = push.to("refs/for/master"); + RevCommit commitToCherryPick = r.getCommit(); + + // Cherry-pick the commit to the other branch, that should fail with a conflict. + CherryPickInput input = new CherryPickInput(); + input.destination = destBranch; + ResourceConflictException thrown = + assertThrows( + ResourceConflictException.class, + () -> + gApi.projects() + .name(project.get()) + .commit(commitToCherryPick.name()) + .cherryPick(input)); + assertThat(thrown).hasMessageThat().startsWith("Cherry pick failed: merge conflict"); + + // Cherry-pick with auto merge should succeed. + // We make a REST call here, rather than using CommitApi.cherryPick(CherryPickInput) because we + // want to validate transient fields of the returned ChangeInfo and + // CommitApi.cherryPick(CherryPickInput) doesn't return the ChangeInfo, but a ChangeApi. + // ChangeApi allows us to retrieve the ChangeInfo, but this is a new ChangeInfo instance that + // doesn't have transient fields like 'containsGitConflicts' set. Hence since we do want to + // verify the transient fields, we do a REST call instead, where we can get the returned + // ChangeInfo and verify the transient fields in it. + input.allowConflicts = true; + RestResponse response = + adminRestSession.post( + "/projects/" + project.get() + "/commits/" + commitToCherryPick.name() + "/cherrypick", + input); + response.assertOK(); + ChangeInfo cherryPickChange = newGson().fromJson(response.getReader(), ChangeInfo.class); + assertThat(cherryPickChange.containsGitConflicts).isTrue(); + assertThat(cherryPickChange.workInProgress).isTrue(); + + // Verify the conflicts information + RevCommit head = projectOperations.project(project).getHead(cherryPickChange.branch); + RevisionInfo currentRevision = + gApi.changes() + .id(cherryPickChange.id) + .get(CURRENT_REVISION, CURRENT_COMMIT) + .getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name()); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); + + // Verify that the file content in the cherry-pick change is correct. + // We expect that it has conflict markers to indicate the conflict. + BinaryResult bin = + gApi.changes() + .id(project.get(), cherryPickChange._number) + .current() + .file(PushOneCommit.FILE_NAME) + .content(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + bin.writeTo(os); + String fileContent = new String(os.toByteArray(), UTF_8); + String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6); + String changeSha1 = abbreviateName(r.getCommit(), 6); + assertThat(fileContent) + .isEqualTo( + "<<<<<<< HEAD (" + + destSha1 + + " test commit)\n" + + destContent + + "\n" + + "=======\n" + + changeContent + + "\n" + + ">>>>>>> CHANGE (" + + changeSha1 + + " test commit)\n"); + } + + @Test + public void cherryPickToExistingChangeWithAllowConflicts() throws Exception { + String tip = testRepo.getRepository().exactRef("HEAD").getObjectId().name(); + + String destBranch = "foo"; + createBranch(BranchNameKey.create(project, destBranch)); + String destContent = "some content"; + PushOneCommit.Result existingChange = + createChange(testRepo, destBranch, SUBJECT, FILE_NAME, destContent, null); + + testRepo.reset(tip); + String changeContent = "another content"; + PushOneCommit.Result srcChange = + createChange(testRepo, "master", SUBJECT, FILE_NAME, changeContent, null); + RevCommit commitToCherryPick = srcChange.getCommit(); + + // Cherry-pick the commit to the other branch, that should fail with a conflict. + CherryPickInput input = new CherryPickInput(); + input.destination = destBranch; + input.base = existingChange.getCommit().name(); + input.message = "cherry-pick to foo" + "\n\nChange-Id: " + existingChange.getChangeId(); + input.destination = destBranch; + ResourceConflictException thrown = + assertThrows( + ResourceConflictException.class, + () -> + gApi.projects() + .name(project.get()) + .commit(commitToCherryPick.name()) + .cherryPick(input)); + assertThat(thrown).hasMessageThat().startsWith("Cherry pick failed: merge conflict"); + + // Cherry-pick with auto merge should succeed. + // We make a REST call here, rather than using CommitApi.cherryPick(CherryPickInput) because we + // want to validate transient fields of the returned ChangeInfo and + // CommitApi.cherryPick(CherryPickInput) doesn't return the ChangeInfo, but a ChangeApi. + // ChangeApi allows us to retrieve the ChangeInfo, but this is a new ChangeInfo instance that + // doesn't have transient fields like 'containsGitConflicts' set. Hence since we do want to + // verify the transient fields, we do a REST call instead, where we can get the returned + // ChangeInfo and verify the transient fields in it. + input.allowConflicts = true; + RestResponse response = + adminRestSession.post( + "/projects/" + project.get() + "/commits/" + commitToCherryPick.name() + "/cherrypick", + input); + response.assertOK(); + ChangeInfo cherryPickChange = newGson().fromJson(response.getReader(), ChangeInfo.class); + assertThat(cherryPickChange.containsGitConflicts).isTrue(); + assertThat(cherryPickChange.workInProgress).isTrue(); + + // Verify the conflicts information + RevisionInfo currentRevision = + gApi.changes() + .id(cherryPickChange.id) + .get(CURRENT_REVISION, CURRENT_COMMIT) + .getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(existingChange.getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours).isEqualTo(existingChange.getCommit().name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(srcChange.getCommit().name()); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); + + // Verify that the file content in the cherry-pick change is correct. + // We expect that it has conflict markers to indicate the conflict. + BinaryResult bin = + gApi.changes() + .id(project.get(), cherryPickChange._number) + .current() + .file(PushOneCommit.FILE_NAME) + .content(); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + bin.writeTo(os); + String fileContent = new String(os.toByteArray(), UTF_8); + String destSha1 = abbreviateName(existingChange.getCommit(), 6); + String changeSha1 = abbreviateName(srcChange.getCommit(), 6); + assertThat(fileContent) + .isEqualTo( + "<<<<<<< HEAD (" + + destSha1 + + " test commit)\n" + + destContent + + "\n" + + "=======\n" + + changeContent + + "\n" + + ">>>>>>> CHANGE (" + + changeSha1 + + " test commit)\n"); + } + + @Test public void cherryPickCommitWithoutChangeIdCreateNewChange() throws Exception { String destBranch = "foo"; createBranch(BranchNameKey.create(project, destBranch)); @@ -366,7 +600,6 @@ + "Change-Id: Ideadbeefdeadbeefdeadbeefdeadbeefdeadbeef\n\n" + "Change-Id: %s\n", existingDestChange.changeId); - input.allowConflicts = true; input.allowEmpty = true; ChangeInfo cherryPickResult = @@ -407,7 +640,6 @@ input.destination = destBranch; input.message = String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId); - input.allowConflicts = true; input.allowEmpty = true; // Use RevisionAPI to submit initial cherryPick. ChangeInfo cherryPickResult = @@ -459,7 +691,6 @@ input.destination = destBranch; input.message = String.format("it goes to foo branch\n\nChange-Id: %s\n", existingDestChange.changeId); - input.allowConflicts = true; input.allowEmpty = true; BadRequestException thrown =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java index 4973e41..083910f 100644 --- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java +++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -20,11 +20,16 @@ import static com.google.gerrit.acceptance.PushOneCommit.PATCH; import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY; import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationInfoListener; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; +import static com.google.gerrit.acceptance.TestExtensions.TestValidationOptionsListener; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.entities.Patch.COMMIT_MSG; import static com.google.gerrit.entities.Patch.MERGE_LIST; import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL; import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION; import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS; import static com.google.gerrit.git.ObjectIds.abbreviateName; import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; @@ -57,6 +62,7 @@ import com.google.gerrit.entities.BranchOrderSection; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.LabelId; +import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.entities.Permission; import com.google.gerrit.entities.Project; @@ -100,10 +106,7 @@ import com.google.gerrit.extensions.restapi.UnprocessableEntityException; import com.google.gerrit.extensions.webui.PatchSetWebLink; import com.google.gerrit.extensions.webui.ResolveConflictsWebLink; -import com.google.gerrit.server.events.CommitReceivedEvent; -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.git.validators.CommitValidationInfo; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.util.AccountTemplateUtil; import com.google.gerrit.testing.FakeEmailSender; @@ -344,6 +347,16 @@ assertThat(changeInfo.workInProgress).isNull(); ChangeApi cherry = gApi.changes().id(changeInfo._number); + // Verify the conflicts information + RevCommit head = projectOperations.project(project).getHead(changeInfo.branch); + RevisionInfo currentRevision = + gApi.changes().id(changeInfo.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); + assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name()); + ChangeInfo cherryPickChangeInfoWithDetails = cherry.get(); assertThat(cherryPickChangeInfoWithDetails.workInProgress).isNull(); assertThat(cherryPickChangeInfoWithDetails.messages).hasSize(1); @@ -745,6 +758,19 @@ assertThat(cherryPickChange.containsGitConflicts).isTrue(); assertThat(cherryPickChange.workInProgress).isTrue(); + // Verify the conflicts information + RevCommit head = projectOperations.project(project).getHead(cherryPickChange.branch); + RevisionInfo currentRevision = + gApi.changes() + .id(cherryPickChange.id) + .get(CURRENT_REVISION, CURRENT_COMMIT) + .getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit).isEqualTo(head.name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); + assertThat(currentRevision.conflicts.ours).isEqualTo(head.getName()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(r.getCommit().name()); + // Verify that subject and topic on the cherry-pick change have been correctly populated. assertThat(cherryPickChange.subject).contains(in.message); assertThat(cherryPickChange.topic).isEqualTo("someTopic-" + destBranch); @@ -814,6 +840,16 @@ assertThat(changeInfo.containsGitConflicts).isTrue(); assertThat(changeInfo.workInProgress).isTrue(); + + // Verify the conflicts information + RevisionInfo currentRevision = + gApi.changes().id(changeInfo.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(existingChange.getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); + assertThat(currentRevision.conflicts.ours).isEqualTo(existingChange.getCommit().name()); + assertThat(currentRevision.conflicts.theirs).isEqualTo(srcChange.getCommit().name()); } @Test @@ -828,11 +864,17 @@ gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput()); TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + TestValidationOptionsListener testValidationOptionsListener = + new TestValidationOptionsListener(); try (Registration registration = - extensionRegistry.newRegistration().add(testCommitValidationListener)) { + extensionRegistry + .newRegistration() + .add(testCommitValidationListener) + .add(testValidationOptionsListener)) { gApi.changes().id(r.getChangeId()).current().cherryPick(in); assertThat(testCommitValidationListener.receiveEvent.pushOptions) .containsExactly("key", "value"); + assertThat(testValidationOptionsListener.validationOptions).containsExactly("key", "value"); } } @@ -874,6 +916,68 @@ } @Test + public void commitValidationInfoListenerIsInvokedOnPatchSetCreation() throws Exception { + // Do a cherry-pick to an existing change to invoke PatchSetInserter to create a new patch set. + + // Create a change on the master branch that we can cherry-pick + PushOneCommit.Result r = + pushFactory + .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a") + .to("refs/for/master"); + Change.Id origChangeId = r.getChange().getId(); + + // Create a branch foo to which we can cherry-pick. + BranchInput branchInput = new BranchInput(); + branchInput.revision = r.getCommit().getParent(0).name(); + gApi.projects().name(project.get()).branch("foo").create(branchInput); + + // Create a change on branch foo that we will update by cherry pick. + pushFactory + .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r.getChangeId()) + .to("refs/for/foo") + .assertOkStatus(); + + // Cherry-pick the change from the master branch to the foo branch so that it updates the + // existing change on the foo branch. + TestCommitValidationInfoListener testCommitValidationInfoListener = + new TestCommitValidationInfoListener(); + TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + try (Registration registration = + extensionRegistry + .newRegistration() + .add(testCommitValidationInfoListener) + .add(testCommitValidationListener)) { + CherryPickInput in = new CherryPickInput(); + in.destination = "foo"; + in.message = r.getCommit().getFullMessage(); + in.validationOptions = ImmutableMap.of("key", "value"); + ChangeApi cherry = + gApi.changes().id(project.get(), origChangeId.get()).current().cherryPick(in); + ChangeInfo changeInfo = cherry.get(); + assertThat(changeInfo.currentRevisionNumber).isEqualTo(2); + assertThat(changeInfo.cherryPickOfChange).isEqualTo(origChangeId.get()); + assertThat(changeInfo.cherryPickOfPatchSet).isEqualTo(1); + + assertThat(testCommitValidationInfoListener.validationInfoByValidator) + .containsKey(TestCommitValidationListener.class.getName()); + assertThat( + testCommitValidationInfoListener + .validationInfoByValidator + .get(TestCommitValidationListener.class.getName()) + .status()) + .isEqualTo(CommitValidationInfo.Status.PASSED); + assertThat(testCommitValidationInfoListener.receiveEvent.commit.name()) + .isEqualTo(changeInfo.currentRevision); + assertThat(testCommitValidationInfoListener.receiveEvent.pushOptions) + .containsExactly("key", "value"); + assertThat(testCommitValidationInfoListener.patchSetId) + .isEqualTo(PatchSet.id(Change.id(changeInfo._number), changeInfo.currentRevisionNumber)); + assertThat(testCommitValidationInfoListener.hasChangeModificationRefContext).isTrue(); + assertThat(testCommitValidationInfoListener.hasDirectPushRefContext).isFalse(); + } + } + + @Test public void cherryPickToAbandonedChange() throws Exception { PushOneCommit.Result r1 = pushFactory @@ -2622,14 +2726,33 @@ return Iterables.transform(r, a -> Account.id(a._accountId)); } - private static class TestCommitValidationListener implements CommitValidationListener { - public CommitReceivedEvent receiveEvent; + @Test + public void getCommitMessageFromOlderPatchSet() throws Exception { + PushOneCommit.Result result = + createChange( + testRepo, "master", "Initial commit message", FILE_NAME, "other content", null); + Change.Id changeId = result.getChange().getId(); + PatchSet.Id initialPatchSetId = result.getPatchSetId(); - @Override - public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) - throws CommitValidationException { - this.receiveEvent = receiveEvent; - return ImmutableList.of(); - } + pushFactory + .create( + admin.newIdent(), + testRepo, + "another commit message", + FILE_NAME, + "new content", + result.getChangeId()) + .to("refs/for/master"); + + // Fetch the commit message for the initial patch set using the /content endpoint + String commitMessage = + gApi.changes() + .id(changeId.get()) + .revision(initialPatchSetId.get()) + .file(COMMIT_MSG) + .content() + .asString(); + + assertThat(commitMessage).startsWith("Initial commit message"); } }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java index 34b858e..21c4c9e 100644 --- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java +++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -24,6 +24,8 @@ import static com.google.gerrit.acceptance.GitUtil.pushHead; import static com.google.gerrit.acceptance.GitUtil.pushOne; import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationInfoListener; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel; @@ -106,6 +108,7 @@ import com.google.gerrit.server.git.receive.NoteDbPushOption; import com.google.gerrit.server.git.receive.PluginPushOption; import com.google.gerrit.server.git.receive.ReceiveConstants; +import com.google.gerrit.server.git.validators.CommitValidationInfo; import com.google.gerrit.server.git.validators.CommitValidationListener; import com.google.gerrit.server.git.validators.CommitValidationMessage; import com.google.gerrit.server.group.SystemGroupBackend; @@ -2673,70 +2676,25 @@ .isEqualTo(Iterables.getLast(commits).name()); } - @Test - public void aclInfoIsReturnedIfPushFailsDueToAPermissionError() throws Exception { - String master = "refs/heads/master"; - - projectOperations - .project(project) - .forUpdate() - .add(block(Permission.PUSH).ref(master).group(REGISTERED_USERS)) - .update(); - - // without VIEW_ACCESS capability no ACL info is returned - TestRepository<?> userRepo = cloneProject(project, user); - userRepo - .branch("HEAD") - .commit() - .message("New Commit 1") - .author(user.newIdent()) - .committer(user.newIdent()) - .insertChangeId() - .create(); - PushResult pushResult = pushHead(userRepo, master); - assertPushRejected(pushResult, master, "prohibited by Gerrit: not permitted: update"); - assertThat(pushResult.getMessages()).doesNotContain("ACL info"); - - // with VIEW_ACCESS capability an ACL info is returned when the request fails due to a - // permission error - projectOperations - .allProjectsForUpdate() - .add(allowCapability(GlobalCapability.VIEW_ACCESS).group(REGISTERED_USERS)) - .update(); - pushResult = pushHead(userRepo, master); - assertPushRejected(pushResult, master, "prohibited by Gerrit: not permitted: update"); - assertThat(pushResult.getMessages()) - .contains( - String.format( - "ACL info:\n" - + "* '%s' cannot perform 'push' with force=false on project '%s'" - + " for ref '%s' because this permission is blocked", - user.username(), project, master)); - - // with VIEW_ACCESS capability no ACL info is returned when the request doesn't fail due to a - // permission error - projectOperations - .project(project) - .forUpdate() - .add(allow(Permission.PUSH).ref(master).group(REGISTERED_USERS)) - .update(); - pushResult = pushHead(userRepo, master); - assertPushOk(pushResult, master); - assertThat(pushResult.getMessages()).doesNotContain("ACL info"); - } - private static class TestValidator implements CommitValidationListener { private final AtomicInteger count = new AtomicInteger(); + private final String validatorName; private final boolean validateAll; @Nullable private CommitReceivedEvent receivedEvent; - TestValidator(boolean validateAll) { + TestValidator(String validatorName, boolean validateAll) { + this.validatorName = validatorName; this.validateAll = validateAll; } TestValidator() { - this(false); + this(TestValidator.class.getName(), false); + } + + @Override + public String getValidatorName() { + return validatorName; } @Override @@ -2797,7 +2755,7 @@ @Test public void skipValidation() throws Exception { String master = "refs/heads/master"; - TestValidator validator = new TestValidator(); + TestValidator validator = new TestValidator("validator1", /* validateAll= */ false); try (Registration registration = extensionRegistry.newRegistration().add(validator)) { // Validation listener is called on normal push PushOneCommit push = @@ -2826,7 +2784,7 @@ // Validation listener that needs to validate all commits gets called even // when the skip option is used. - TestValidator validator2 = new TestValidator(true); + TestValidator validator2 = new TestValidator("validator2", /* validateAll= */ true); try (Registration registration2 = extensionRegistry.newRegistration().add(validator2)) { PushOneCommit push4 = pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content"); @@ -3248,6 +3206,98 @@ r.assertErrorStatus("expected SHA1 for option --base: invalid"); } + @Test + public void commitValidationInfoListenerIsInvokedOnChangeCreation() throws Exception { + TestCommitValidationInfoListener testCommitValidationInfoListener = + new TestCommitValidationInfoListener(); + TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + try (Registration registration = + extensionRegistry + .newRegistration() + .add(testCommitValidationInfoListener) + .add(testCommitValidationListener)) { + PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo); + + // Plugins can define push options that skip plugin-specific validators. To verify that push + // options are passed to the CommitValidationInfoListener's (in receiveEvent.pushOptions) we + // set an arbitrary push option here. Note this must be a push option that is known to Gerrit, + // either because Gerrit defines it are because the plugin did register it via the + // PluginPushOption extension point. To avoid the overhead of registering a PluginPushOption, + // we use the 'topic' push option for testing this. + push.setPushOptions(ImmutableList.of("topic=myTopic")); + + PushOneCommit.Result r = push.to("refs/for/master"); + r.assertOkStatus(); + + assertThat(testCommitValidationInfoListener.validationInfoByValidator) + .containsKey(TestCommitValidationListener.class.getName()); + assertThat( + testCommitValidationInfoListener + .validationInfoByValidator + .get(TestCommitValidationListener.class.getName()) + .status()) + .isEqualTo(CommitValidationInfo.Status.PASSED); + assertThat(testCommitValidationInfoListener.receiveEvent.commit.name()) + .isEqualTo(r.getCommit().name()); + assertThat(testCommitValidationInfoListener.receiveEvent.pushOptions) + .containsExactly("topic", "myTopic"); + assertThat(testCommitValidationInfoListener.patchSetId).isEqualTo(r.getPatchSetId()); + assertThat(testCommitValidationInfoListener.hasChangeModificationRefContext).isTrue(); + assertThat(testCommitValidationInfoListener.hasDirectPushRefContext).isFalse(); + } + } + + @Test + public void commitValidationInfoListenerIsInvokedOnPatchSetCreation() throws Exception { + PushOneCommit.Result r = pushTo("refs/for/master"); + r.assertOkStatus(); + + TestCommitValidationInfoListener testCommitValidationInfoListener = + new TestCommitValidationInfoListener(); + TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + try (Registration registration = + extensionRegistry + .newRegistration() + .add(testCommitValidationInfoListener) + .add(testCommitValidationListener)) { + PushOneCommit push = + pushFactory.create( + admin.newIdent(), + testRepo, + PushOneCommit.SUBJECT, + "b.txt", + "anotherContent", + r.getChangeId()); + + // Plugins can define push options that skip plugin-specific validators. To verify that push + // options are passed to the CommitValidationInfoListener's (in receiveEvent.pushOptions) we + // set an arbitrary push option here. Note this must be a push option that is known to Gerrit, + // either because Gerrit defines it are because the plugin did register it via the + // PluginPushOption extension point. To avoid the overhead of registering a PluginPushOption, + // we use the 'topic' push option for testing this. + push.setPushOptions(ImmutableList.of("topic=myTopic")); + + r = push.to("refs/for/master"); + r.assertOkStatus(); + + assertThat(testCommitValidationInfoListener.validationInfoByValidator) + .containsKey(TestCommitValidationListener.class.getName()); + assertThat( + testCommitValidationInfoListener + .validationInfoByValidator + .get(TestCommitValidationListener.class.getName()) + .status()) + .isEqualTo(CommitValidationInfo.Status.PASSED); + assertThat(testCommitValidationInfoListener.receiveEvent.commit.name()) + .isEqualTo(r.getCommit().name()); + assertThat(testCommitValidationInfoListener.receiveEvent.pushOptions) + .containsExactly("topic", "myTopic"); + assertThat(testCommitValidationInfoListener.patchSetId).isEqualTo(r.getPatchSetId()); + assertThat(testCommitValidationInfoListener.hasChangeModificationRefContext).isTrue(); + assertThat(testCommitValidationInfoListener.hasDirectPushRefContext).isFalse(); + } + } + private DraftInput newDraft(String path, int line, String message) { DraftInput d = new DraftInput(); d.path = path;
diff --git a/javatests/com/google/gerrit/acceptance/git/DirectPushIT.java b/javatests/com/google/gerrit/acceptance/git/DirectPushIT.java new file mode 100644 index 0000000..605df2d --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/git/DirectPushIT.java
@@ -0,0 +1,113 @@ +// 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.git; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationInfoListener; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.ExtensionRegistry; +import com.google.gerrit.acceptance.ExtensionRegistry.Registration; +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.entities.Permission; +import com.google.gerrit.server.git.validators.CommitValidationInfo; +import com.google.inject.Inject; +import org.junit.Test; + +public class DirectPushIT extends AbstractDaemonTest { + @Inject private ExtensionRegistry extensionRegistry; + @Inject private ProjectOperations projectOperations; + + @Test + public void commitValidationInfoListenerIsInvokedOnDirectPush() throws Exception { + TestCommitValidationInfoListener testCommitValidationInfoListener = + new TestCommitValidationInfoListener(); + TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + try (Registration registration = + extensionRegistry + .newRegistration() + .add(testCommitValidationInfoListener) + .add(testCommitValidationListener)) { + PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo); + push.setPushOptions(ImmutableList.of("skip-foo=true")); + PushOneCommit.Result r = push.to("refs/heads/master"); + r.assertOkStatus(); + + assertThat(testCommitValidationInfoListener.validationInfoByValidator) + .containsKey(TestCommitValidationListener.class.getName()); + assertThat( + testCommitValidationInfoListener + .validationInfoByValidator + .get(TestCommitValidationListener.class.getName()) + .status()) + .isEqualTo(CommitValidationInfo.Status.PASSED); + assertThat(testCommitValidationInfoListener.receiveEvent.commit.name()) + .isEqualTo(r.getCommit().name()); + assertThat(testCommitValidationInfoListener.receiveEvent.pushOptions) + .containsExactly("skip-foo", "true"); + assertThat(testCommitValidationInfoListener.patchSetId).isNull(); + assertThat(testCommitValidationInfoListener.hasChangeModificationRefContext).isFalse(); + assertThat(testCommitValidationInfoListener.hasDirectPushRefContext).isTrue(); + } + } + + @Test + public void commitValidationInfoListenerIsInvokedOnDirectPush_skipValidation() throws Exception { + // Using "o=skip-validation" requires the user to have 'Forge Author', 'Forge Committer', 'Forge + // Server' and 'Push Merge' permissions. + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(adminGroupUuid())) + .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(adminGroupUuid())) + .add(allow(Permission.FORGE_SERVER).ref("refs/*").group(adminGroupUuid())) + .add(allow(Permission.PUSH_MERGE).ref("refs/*").group(adminGroupUuid())) + .update(); + + TestCommitValidationInfoListener testCommitValidationInfoListener = + new TestCommitValidationInfoListener(); + TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + try (Registration registration = + extensionRegistry + .newRegistration() + .add(testCommitValidationInfoListener) + .add(testCommitValidationListener)) { + PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo); + push.setPushOptions(ImmutableList.of("skip-validation")); + PushOneCommit.Result r = push.to("refs/heads/master"); + r.assertOkStatus(); + + assertThat(testCommitValidationInfoListener.validationInfoByValidator) + .containsKey(TestCommitValidationListener.class.getName()); + assertThat( + testCommitValidationInfoListener + .validationInfoByValidator + .get(TestCommitValidationListener.class.getName()) + .status()) + .isEqualTo(CommitValidationInfo.Status.SKIPPED_BY_USER); + assertThat(testCommitValidationInfoListener.receiveEvent.commit.name()) + .isEqualTo(r.getCommit().name()); + assertThat(testCommitValidationInfoListener.receiveEvent.pushOptions) + .containsExactly("skip-validation", ""); + assertThat(testCommitValidationInfoListener.patchSetId).isNull(); + assertThat(testCommitValidationInfoListener.hasChangeModificationRefContext).isFalse(); + assertThat(testCommitValidationInfoListener.hasDirectPushRefContext).isTrue(); + } + } +}
diff --git a/javatests/com/google/gerrit/acceptance/index/IndexMetricIT.java b/javatests/com/google/gerrit/acceptance/index/IndexMetricIT.java new file mode 100644 index 0000000..5e9f1ad --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/index/IndexMetricIT.java
@@ -0,0 +1,57 @@ +// 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.index; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.TestMetricMaker; +import com.google.inject.Inject; +import org.junit.Test; + +public class IndexMetricIT extends AbstractDaemonTest { + @Inject protected TestMetricMaker testMetricMaker; + + @Test + public void checkProjectsIndexMetric() throws Exception { + int numberProjects = intMetricValueOf("indexes/projects"); + gApi.projects().create("some_project"); + assertThat(intMetricValueOf("indexes/projects")).isEqualTo(numberProjects + 1); + } + + @Test + public void checkChangesIndexMetric() throws Exception { + int numberChanges = intMetricValueOf("indexes/changes"); + createChange(); + assertThat(intMetricValueOf("indexes/changes")).isEqualTo(numberChanges + 1); + } + + @Test + public void checkAccountsIndexMetric() throws Exception { + int numberAccounts = intMetricValueOf("indexes/accounts"); + gApi.accounts().create("some_account"); + assertThat(intMetricValueOf("indexes/accounts")).isEqualTo(numberAccounts + 1); + } + + @Test + public void checkGroupsIndexMetric() throws Exception { + int numberGroups = intMetricValueOf("indexes/groups"); + gApi.groups().create("some_group"); + assertThat(intMetricValueOf("indexes/groups")).isEqualTo(numberGroups + 1); + } + + private int intMetricValueOf(String metric) { + return (int) testMetricMaker.getCallbackMetricValue(metric); + } +}
diff --git a/javatests/com/google/gerrit/acceptance/rest/AclInfoRestIT.java b/javatests/com/google/gerrit/acceptance/rest/AclInfoRestIT.java deleted file mode 100644 index 6ae65fe..0000000 --- a/javatests/com/google/gerrit/acceptance/rest/AclInfoRestIT.java +++ /dev/null
@@ -1,120 +0,0 @@ -// 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; - -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.allowCapability; -import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block; -import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS; -import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; - -import com.google.gerrit.acceptance.AbstractDaemonTest; -import com.google.gerrit.acceptance.RestResponse; -import com.google.gerrit.acceptance.testsuite.change.ChangeOperations; -import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; -import com.google.gerrit.common.data.GlobalCapability; -import com.google.gerrit.entities.Change; -import com.google.gerrit.entities.Permission; -import com.google.gerrit.extensions.client.Comment; -import com.google.gerrit.extensions.common.ApplyProvidedFixInput; -import com.google.gerrit.extensions.common.FixReplacementInfo; -import com.google.inject.Inject; -import java.util.Arrays; -import java.util.List; -import org.junit.Test; - -public class AclInfoRestIT extends AbstractDaemonTest { - @Inject private ProjectOperations projectOperations; - @Inject private ChangeOperations changeOperations; - - @Test - public void cannotApplyProvidedFixlWithoutAddPatchSetPermission() throws Exception { - projectOperations - .project(project) - .forUpdate() - .add(block(Permission.ADD_PATCH_SET).ref("refs/*").group(ANONYMOUS_USERS)) - .update(); - - Change.Id changeId = - changeOperations - .newChange() - .project(project) - .branch("master") - .file("foo.txt") - .content("some content") - .create(); - - Comment.Range range = new Comment.Range(); - range.startLine = 1; - range.startCharacter = 0; - range.endLine = 1; - range.endCharacter = 3; - - FixReplacementInfo fixReplacementInfo = new FixReplacementInfo(); - fixReplacementInfo.path = "foo.txt"; - fixReplacementInfo.replacement = "other"; - fixReplacementInfo.range = range; - - List<FixReplacementInfo> fixReplacementInfoList = Arrays.asList(fixReplacementInfo); - ApplyProvidedFixInput applyProvidedFixInput = new ApplyProvidedFixInput(); - applyProvidedFixInput.fixReplacementInfos = fixReplacementInfoList; - - // without VIEW_ACCESS capability no ACL info is returned - RestResponse resp = - userRestSession.post( - "/changes/" + changeId + "/revisions/current/fix:apply", applyProvidedFixInput); - resp.assertStatus(403); - assertThat(resp.getEntityContent()).isEqualTo("edit not permitted"); - - // with VIEW_ACCESS capability an ACL info is returned when the request fails due to a - // permission error - projectOperations - .allProjectsForUpdate() - .add(allowCapability(GlobalCapability.VIEW_ACCESS).group(REGISTERED_USERS)) - .update(); - resp = - userRestSession.post( - "/changes/" + changeId + "/revisions/current/fix:apply", applyProvidedFixInput); - resp.assertStatus(403); - assertThat(resp.getEntityContent()) - .isEqualTo( - String.format( - "edit not permitted\n\n" - + "ACL info:\n" - + "* '%s' can perform 'read' with force=false on project '%s' for ref" - + " 'refs/heads/master' (allowed for group 'global:Anonymous-Users' by rule" - + " 'group Anonymous Users')\n" - + "* '%s' can perform 'push' with force=false on project '%s' for ref" - + " 'refs/for/refs/heads/master' (allowed for group 'global:Registered-Users'" - + " by rule 'group Registered Users')\n" - + "* '%s' cannot perform 'addPatchSet' with force=false on project '%s' for ref" - + " 'refs/for/refs/heads/master' because this permission is blocked", - user.username(), project, user.username(), project, user.username(), project)); - - // with VIEW_ACCESS capability no ACL info is returned when the request doesn't fail due to a - // permission error - projectOperations - .project(project) - .forUpdate() - .add(allow(Permission.ADD_PATCH_SET).ref("refs/*").group(ANONYMOUS_USERS)) - .update(); - resp = - userRestSession.post( - "/changes/" + changeId + "/revisions/current/fix:apply", applyProvidedFixInput); - resp.assertOK(); - assertThat(resp.getEntityContent()).doesNotContain("ACL info"); - } -}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java index 3ce6d8d..1903784 100644 --- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java +++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -20,6 +20,7 @@ public BatchChangesLimit batchChangesLimit; public boolean createAccount; public boolean createGroup; + public boolean deleteGroup; public boolean createProject; public boolean emailReviewers; public boolean flushCaches;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java index c47b3a4..2035aa4 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -16,14 +16,18 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.TruthJUnit.assume; +import static java.nio.charset.StandardCharsets.UTF_8; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.TestProjectInput; +import com.google.gerrit.acceptance.config.GerritConfig; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; import com.google.gerrit.entities.PatchSet; import com.google.gerrit.extensions.client.InheritableBoolean; +import com.google.gerrit.extensions.restapi.BinaryResult; import com.google.gerrit.server.change.MergeabilityComputationBehavior; import com.google.inject.Inject; +import java.io.ByteArrayOutputStream; import org.eclipse.jgit.revwalk.RevCommit; import org.junit.Test; @@ -87,6 +91,39 @@ } @Test + @TestProjectInput(useContentMerge = InheritableBoolean.TRUE) + @GerritConfig(name = "core.useGitattributesForMerge", value = "true") + public void submitWithUnionContentMerge() throws Throwable { + PushOneCommit pushAttributes = + pushFactory.create( + admin.newIdent(), + testRepo, + "add merge=union to gitattributes", + ".gitattributes", + "*.txt merge=union"); + PushOneCommit.Result unusedResult = pushAttributes.to("refs/heads/master"); + RevCommit initialHead = projectOperations.project(project).getHead("master"); + PushOneCommit.Result change = createChange("Change 1", "a.txt", "content"); + submit(change.getChangeId()); + + RevCommit oldHead = projectOperations.project(project).getHead("master"); + testRepo.reset(initialHead); + PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content"); + submit(change2.getChangeId()); + RevCommit head = projectOperations.project(project).getHead("master"); + assertThat(head.getParentCount()).isEqualTo(2); + assertThat(head.getParent(0)).isEqualTo(oldHead); + assertThat(head.getParent(1)).isEqualTo(change2.getCommit()); + + // We expect that it has no conflict markers and the content of both changes. + BinaryResult bin = gApi.projects().name(project.get()).branch("master").file("a.txt"); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + bin.writeTo(os); + String fileContent = new String(os.toByteArray(), UTF_8); + assertThat(fileContent).isEqualTo("content" + "\n" + "other content"); + } + + @Test @TestProjectInput(createEmptyCommit = false) public void submitMultipleCommitsToEmptyRepoAsFastForward() throws Throwable { PushOneCommit.Result change1 = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java index 04093a5..7704b1f 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -15,6 +15,9 @@ package com.google.gerrit.acceptance.rest.change; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationInfoListener; +import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; +import static com.google.gerrit.acceptance.TestExtensions.TestValidationOptionsListener; 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.entities.Permission.CREATE; @@ -23,6 +26,7 @@ import static com.google.gerrit.entities.RefNames.changeMetaRef; import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS; import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT; +import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION; import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat; import static com.google.gerrit.git.ObjectIds.abbreviateName; import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; @@ -49,6 +53,7 @@ import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; import com.google.gerrit.entities.BranchNameKey; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.Permission; import com.google.gerrit.entities.RefNames; import com.google.gerrit.entities.converter.ChangeInputProtoConverter; @@ -72,6 +77,7 @@ import com.google.gerrit.extensions.common.DiffInfo; import com.google.gerrit.extensions.common.GitPerson; import com.google.gerrit.extensions.common.MergeInput; +import com.google.gerrit.extensions.common.RevisionInfo; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.BinaryResult; @@ -79,10 +85,7 @@ import com.google.gerrit.extensions.restapi.ResourceNotFoundException; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.UnprocessableEntityException; -import com.google.gerrit.server.events.CommitReceivedEvent; -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.git.validators.CommitValidationInfo; import com.google.gerrit.server.patch.ApplyPatchUtil; import com.google.gerrit.server.restapi.change.CreateChange; import com.google.gerrit.server.restapi.change.CreateChange.CommitTreeSupplier; @@ -220,6 +223,13 @@ List<ChangeMessageInfo> messages = gApi.changes().id(info._number).messages(); assertThat(messages).hasSize(1); assertThat(Iterables.getOnlyElement(messages).message).isEqualTo("Uploaded patch set 1."); + + RevisionInfo currentRevision = + gApi.changes().id(info.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision(); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); + assertThat(currentRevision.conflicts.ours).isNull(); + assertThat(currentRevision.conflicts.theirs).isNull(); } @Test @@ -535,7 +545,8 @@ targetSubject, fileName, targetContent); - ChangeInput input = newMergeChangeInput(targetBranch, sourceBranch, "", true); + ChangeInput input = + newMergeChangeInput(targetBranch, sourceBranch, "", /* allowConflicts= */ true); input.workInProgress = true; input.author = new AccountInput(); input.author.email = user.email(); @@ -684,14 +695,29 @@ @Test public void createMergeChange() throws Exception { - changeInTwoBranches("branchA", "a.txt", "branchB", "b.txt"); - ChangeInput in = newMergeChangeInput("branchA", "branchB", ""); + String sourceBranch = "sourceBranch"; + String targetBranch = "targetBranch"; + ImmutableMap<String, Result> results = + changeInTwoBranches(sourceBranch, "a.txt", targetBranch, "b.txt"); + ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, ""); ChangeInfo change = assertCreateSucceeds(in); // Verify the message that has been posted on the change. List<ChangeMessageInfo> messages = gApi.changes().id(change._number).messages(); assertThat(messages).hasSize(1); assertThat(Iterables.getOnlyElement(messages).message).isEqualTo("Uploaded patch set 1."); + + // Verify the conflicts information + RevisionInfo currentRevision = + gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(results.get(targetBranch).getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours) + .isEqualTo(results.get(targetBranch).getCommit().name()); + assertThat(currentRevision.conflicts.theirs) + .isEqualTo(results.get(sourceBranch).getCommit().name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); } @Test @@ -719,9 +745,24 @@ @Test public void createMergeChange_Conflicts_Ours() throws Exception { - changeInTwoBranches("branchA", "shared.txt", "branchB", "shared.txt"); - ChangeInput in = newMergeChangeInput("branchA", "branchB", "ours"); - assertCreateSucceeds(in); + String sourceBranch = "sourceBranch"; + String targetBranch = "targetBranch"; + ImmutableMap<String, Result> results = + changeInTwoBranches(sourceBranch, "shared.txt", targetBranch, "shared.txt"); + ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, "ours"); + ChangeInfo change = assertCreateSucceeds(in); + + // Verify the conflicts information + RevisionInfo currentRevision = + gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(results.get(targetBranch).getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours) + .isEqualTo(results.get(targetBranch).getCommit().name()); + assertThat(currentRevision.conflicts.theirs) + .isEqualTo(results.get(sourceBranch).getCommit().name()); + assertThat(currentRevision.conflicts.containsConflicts).isFalse(); } @Test @@ -733,18 +774,32 @@ String targetBranch = "targetBranch"; String targetSubject = "target change"; String targetContent = "target content"; - changeInTwoBranches( - sourceBranch, - sourceSubject, - fileName, - sourceContent, - targetBranch, - targetSubject, - fileName, - targetContent); - ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, "", true); + ImmutableMap<String, Result> results = + changeInTwoBranches( + sourceBranch, + sourceSubject, + fileName, + sourceContent, + targetBranch, + targetSubject, + fileName, + targetContent); + ChangeInput in = + newMergeChangeInput(targetBranch, sourceBranch, "", /* allowConflicts= */ true); ChangeInfo change = assertCreateSucceedsWithConflicts(in); + // Verify the conflicts information + RevisionInfo currentRevision = + gApi.changes().id(change.id).get(CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision(); + assertThat(currentRevision.commit.parents.get(0).commit) + .isEqualTo(results.get(targetBranch).getCommit().name()); + assertThat(currentRevision.conflicts).isNotNull(); + assertThat(currentRevision.conflicts.ours) + .isEqualTo(results.get(targetBranch).getCommit().name()); + assertThat(currentRevision.conflicts.theirs) + .isEqualTo(results.get(sourceBranch).getCommit().name()); + assertThat(currentRevision.conflicts.containsConflicts).isTrue(); + // Verify that the file content in the created change is correct. // We expect that it has conflict markers to indicate the conflict. BinaryResult bin = gApi.changes().id(change._number).current().file(fileName).content(); @@ -798,7 +853,8 @@ fileName, "target content"); String mergeStrategy = "simple-two-way-in-core"; - ChangeInput in = newMergeChangeInput(targetBranch, sourceBranch, mergeStrategy, true); + ChangeInput in = + newMergeChangeInput(targetBranch, sourceBranch, mergeStrategy, /* allowConflicts= */ true); assertCreateFails( in, BadRequestException.class, @@ -1373,11 +1429,54 @@ changeInput.validationOptions = ImmutableMap.of("key", "value"); TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + TestValidationOptionsListener testValidationOptionsListener = + new TestValidationOptionsListener(); try (Registration registration = - extensionRegistry.newRegistration().add(testCommitValidationListener)) { + extensionRegistry + .newRegistration() + .add(testCommitValidationListener) + .add(testValidationOptionsListener)) { assertCreateSucceeds(changeInput); assertThat(testCommitValidationListener.receiveEvent.pushOptions) .containsExactly("key", "value"); + assertThat(testValidationOptionsListener.validationOptions).containsExactly("key", "value"); + } + } + + @Test + public void commitValidationInfoListenerIsInvokedOnChangeCreation() throws Exception { + ChangeInput changeInput = new ChangeInput(); + changeInput.project = project.get(); + changeInput.branch = "master"; + changeInput.subject = "A change"; + changeInput.status = ChangeStatus.NEW; + changeInput.validationOptions = ImmutableMap.of("key", "value"); + + TestCommitValidationInfoListener testCommitValidationInfoListener = + new TestCommitValidationInfoListener(); + TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener(); + try (Registration registration = + extensionRegistry + .newRegistration() + .add(testCommitValidationInfoListener) + .add(testCommitValidationListener)) { + ChangeInfo changeInfo = assertCreateSucceeds(changeInput); + assertThat(testCommitValidationInfoListener.validationInfoByValidator) + .containsKey(TestCommitValidationListener.class.getName()); + assertThat( + testCommitValidationInfoListener + .validationInfoByValidator + .get(TestCommitValidationListener.class.getName()) + .status()) + .isEqualTo(CommitValidationInfo.Status.PASSED); + assertThat(testCommitValidationInfoListener.receiveEvent.commit.name()) + .isEqualTo(changeInfo.currentRevision); + assertThat(testCommitValidationInfoListener.receiveEvent.pushOptions) + .containsExactly("key", "value"); + assertThat(testCommitValidationInfoListener.patchSetId) + .isEqualTo(PatchSet.id(Change.id(changeInfo._number), changeInfo.currentRevisionNumber)); + assertThat(testCommitValidationInfoListener.hasChangeModificationRefContext).isTrue(); + assertThat(testCommitValidationInfoListener.hasDirectPushRefContext).isFalse(); } } @@ -1513,7 +1612,7 @@ } private ChangeInput newMergeChangeInput(String targetBranch, String sourceRef, String strategy) { - return newMergeChangeInput(targetBranch, sourceRef, strategy, false); + return newMergeChangeInput(targetBranch, sourceRef, strategy, /* allowConflicts= */ false); } private ChangeInput newMergeChangeInput( @@ -1612,15 +1711,4 @@ return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB); } - - 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/rest/change/GetPatchIT.java b/javatests/com/google/gerrit/acceptance/rest/change/GetPatchIT.java new file mode 100644 index 0000000..48afab5 --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/rest/change/GetPatchIT.java
@@ -0,0 +1,124 @@ +/* + * 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.common.io.BaseEncoding; +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.PushOneCommit; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.eclipse.jgit.util.RawParseUtils; +import org.junit.Test; + +public class GetPatchIT extends AbstractDaemonTest { + @Test + public void patchFormatsAreEquivalent() throws Exception { + interface TestVariant { + String getUrlParam(); + + String decode(ByteBuffer buf); + } + + TestVariant[] variants = { + // Plain text + new TestVariant() { + @Override + public String getUrlParam() { + return "?raw"; + } + + @Override + public String decode(ByteBuffer buf) { + // Simply utf-8 decode it + return RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()); + } + }, + // Base64 obfuscation + new TestVariant() { + @Override + public String getUrlParam() { + return ""; + } + + @Override + public String decode(ByteBuffer buf) { + var asUtf8Base64 = RawParseUtils.decode(buf.array(), buf.arrayOffset(), buf.limit()); + var decoded = BaseEncoding.base64().decode(asUtf8Base64); + return RawParseUtils.decode(decoded); + } + }, + // ZIP obfuscation + new TestVariant() { + @Override + public String getUrlParam() { + return "?zip"; + } + + @Override + public String decode(ByteBuffer buf) { + var unzipper = + new ZipInputStream( + new ByteArrayInputStream(buf.array(), buf.arrayOffset(), buf.limit())); + var entries = new HashMap<String, String>(); + + try { + ZipEntry nextEntry = unzipper.getNextEntry(); + while (nextEntry != null) { + var name = nextEntry.getName(); + entries.put(name, RawParseUtils.decode(unzipper.readAllBytes())); + + nextEntry = unzipper.getNextEntry(); + } + } catch (IOException ioe) { + throw new RuntimeException("got io exception doing in-memory io, wat", ioe); + } + + assertThat(entries.size()).isEqualTo(1); + + var content = entries.values().stream().findFirst(); + assertThat(content.isPresent()).isTrue(); + + return content.get(); + } + }, + }; + + String fileName = "a_new_file.txt"; + String fileContent = "First line\nSecond line\n"; + PushOneCommit.Result result = createChange("Add a file", fileName, fileContent); + String triplet = project.get() + "~master~" + result.getChangeId(); + + String patch = null; + for (var variant : variants) { + var apiResult = + userRestSession.get("/changes/" + triplet + "/revisions/1/patch" + variant.getUrlParam()); + apiResult.assertOK(); + var resultingPatch = variant.decode(apiResult.getRawContent()); + + if (patch == null) { + patch = resultingPatch; + } + assertThat(resultingPatch).isEqualTo(patch); + } + } +}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java index 70a8785..81656ec 100644 --- a/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/project/CheckMergeabilityIT.java
@@ -20,6 +20,7 @@ import com.google.gerrit.acceptance.AbstractDaemonTest; 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.BranchNameKey; import com.google.gerrit.extensions.api.changes.ChangeApi; @@ -276,6 +277,60 @@ assertBadRequest("master", "test", "octopus", "invalid merge strategy: octopus"); } + @Test + @GerritConfig(name = "core.useGitattributesForMerge", value = "true") + public void checkContentUnionMergedCommit() throws Exception { + testRepo + .branch("HEAD") + .commit() + .insertChangeId() + .message("add merge=union to gitattributes") + .add(".gitattributes", "*.txt merge=union") + .create(); + testRepo + .git() + .push() + .setRemote("origin") + .setRefSpecs(new RefSpec("HEAD:refs/heads/master")) + .call(); + + RevCommit initialHead = projectOperations.project(project).getHead("master"); + testRepo + .branch("HEAD") + .commit() + .insertChangeId() + .message("first commit") + .add("a.txt", "a contents ") + .create(); + testRepo + .git() + .push() + .setRemote("origin") + .setRefSpecs(new RefSpec("HEAD:refs/heads/master")) + .call(); + + testRepo.reset(initialHead); + // Add a commit that would normally cause a conflict + testRepo + .branch("HEAD") + .commit() + .insertChangeId() + .message("some change in a too") + .add("a.txt", "a contents too") + .create(); + testRepo + .git() + .push() + .setRemote("origin") + .setRefSpecs(new RefSpec("HEAD:refs/heads/test")) + .call(); + + // MergeableInfo.contentMerged is based on tree equality to indicate if the destination tree + // matches the source. It is not indicating if the merger performed a content merge and is + // expected to be false for this test. + assertMergeable("master", "test", "recursive"); + } + private void assertMergeable(String targetBranch, String source, String strategy) throws Exception { MergeableInfo mergeableInfo = getMergeableInfo(targetBranch, source, strategy);
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java index 6bfd988..53e3878 100644 --- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java +++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -38,6 +38,7 @@ import com.google.gerrit.acceptance.NoHttpd; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.TestAccount; +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.Account; @@ -50,6 +51,7 @@ import com.google.gerrit.extensions.api.changes.ReviewInput; import com.google.gerrit.extensions.client.ChangeKind; import com.google.gerrit.server.approval.ApprovalCopier; +import com.google.gerrit.server.experiments.ExperimentFeaturesConstants; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.update.RepoView; import com.google.gerrit.truth.ListSubject; @@ -232,6 +234,129 @@ } @Test + @GerritConfig( + name = "experiments.enabled", + values = {ExperimentFeaturesConstants.ENABLE_CENTRAL_OVERRIDE_FOR_CODE_REVIEW_COPY_CONDITION}) + @GerritConfig(name = "label.Code-Review.labelCopyRestriction", value = "changekind:REWORK") + public void forPatchSet_copyRestricted() throws Exception { + PushOneCommit.Result r = createChange(); + PatchSet.Id patchSet1Id = r.getPatchSetId(); + + // Add some approvals that are normally copied. + vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -2); + vote(r.getChangeId(), user, LabelId.VERIFIED, -1); + + r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo); + r.assertOkStatus(); + PatchSet.Id patchSet2Id = r.getPatchSetId(); + + ApprovalCopier.Result approvalCopierResult = + invokeApprovalCopierForCurrentPatchSet( + r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2); + assertThatList(approvalCopierResult.copiedApprovals()) + .comparingElementsUsing(hasTestId()) + .containsExactly( + PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1)); + assertThatList(approvalCopierResult.outdatedApprovals()) + .comparingElementsUsing(hasTestId()) + .containsExactly( + PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2)); + + // Code review evaluated additional restriction + ApprovalDataSubject codeReviewApprovalSubject = + assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id()); + codeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN", "changekind:REWORK"); + codeReviewApprovalSubject + .hasFailingAtomsThat() + .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE"); + + // Verified is unaffected + ApprovalDataSubject verifiedApprovalSubject = + assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id()); + verifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN"); + verifiedApprovalSubject.hasFailingAtomsThat().isEmpty(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {ExperimentFeaturesConstants.ENABLE_CENTRAL_OVERRIDE_FOR_CODE_REVIEW_COPY_CONDITION}) + @GerritConfig(name = "label.Code-Review.labelCopyEnforcement", value = "is:NEGATIVE") + public void forPatchSet_copyEnforced() throws Exception { + PushOneCommit.Result r = createChange(); + + // Add code-review approval that is normally not copied. + vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -1); + vote(r.getChangeId(), user, LabelId.VERIFIED, -1); + + r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo); + r.assertOkStatus(); + PatchSet.Id patchSet2Id = r.getPatchSetId(); + + ApprovalCopier.Result approvalCopierResult = + invokeApprovalCopierForCurrentPatchSet( + r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2); + assertThatList(approvalCopierResult.copiedApprovals()) + .comparingElementsUsing(hasTestId()) + .containsExactly( + PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1), + PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -1)); + assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty(); + + // Code review evaluated additional "forced copy" rule + ApprovalDataSubject codeReviewApprovalSubject = + assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id()); + codeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:NEGATIVE"); + codeReviewApprovalSubject + .hasFailingAtomsThat() + .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN"); + + // Verified is unaffected + ApprovalDataSubject verifiedApprovalSubject = + assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id()); + verifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN"); + verifiedApprovalSubject.hasFailingAtomsThat().isEmpty(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {ExperimentFeaturesConstants.ENABLE_CENTRAL_OVERRIDE_FOR_CODE_REVIEW_COPY_CONDITION}) + @GerritConfig(name = "label.Code-Review.labelCopyRestriction", value = "changekind:REWORK") + @GerritConfig(name = "label.Code-Review.labelCopyEnforcement", value = "is:NEGATIVE") + public void forPatchSet_enforceWinsOverRestriction() throws Exception { + PushOneCommit.Result r = createChange(); + + // Add code-review approval that is normally not copied. + vote(r.getChangeId(), admin, LabelId.CODE_REVIEW, -1); + vote(r.getChangeId(), user, LabelId.VERIFIED, -1); + + r = amendChange(r.getChangeId(), "refs/for/master", admin, testRepo); + r.assertOkStatus(); + PatchSet.Id patchSet2Id = r.getPatchSetId(); + + ApprovalCopier.Result approvalCopierResult = + invokeApprovalCopierForCurrentPatchSet( + r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2); + assertThatList(approvalCopierResult.copiedApprovals()) + .comparingElementsUsing(hasTestId()) + .containsExactly( + PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1), + PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -1)); + assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty(); + + // Code review evaluated both "forced" rule, but "forced copy" won + ApprovalDataSubject codeReviewApprovalSubject = + assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id()); + codeReviewApprovalSubject + .hasPassingAtomsThat() + .containsExactly("is:NEGATIVE", "changekind:REWORK"); + codeReviewApprovalSubject + .hasFailingAtomsThat() + .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN"); + } + + @Test public void forPatchSet_currentApprovals() throws Exception { PushOneCommit.Result r = createChange(); amendChange(r.getChangeId(), "refs/for/master", admin, testRepo).assertOkStatus(); @@ -514,13 +639,17 @@ public ListSubject<StringSubject, String> hasPassingAtomsThat() { return check("passingAtoms()") .about(elements()) - .that(approvalData().passingAtoms().asList(), StandardSubjectBuilder::that); + .that( + approvalData().approvalCopyResult().passingAtoms().asList(), + StandardSubjectBuilder::that); } public ListSubject<StringSubject, String> hasFailingAtomsThat() { return check("failingAtoms()") .about(elements()) - .that(approvalData().failingAtoms().asList(), StandardSubjectBuilder::that); + .that( + approvalData().approvalCopyResult().failingAtoms().asList(), + StandardSubjectBuilder::that); } private ApprovalCopier.Result.PatchSetApprovalData approvalData() {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java index 07e4866..535bd58 100644 --- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java +++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -758,7 +758,7 @@ ins = changeInserterFactory .create(id, commit, dest) - .setValidate(false) + .disableValidation() .setFireRevisionCreated(false) .setSendMail(false); bu.insertChange(ins).execute(); @@ -785,7 +785,7 @@ ins = patchSetInserterFactory .create(notes, nextPatchSetId(notes), commit) - .setValidate(false) + .disableValidation() .setFireRevisionCreated(false); bu.addOp(notes.getChangeId(), ins).execute(); }
diff --git a/javatests/com/google/gerrit/acceptance/server/group/PeriodicGroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/server/group/PeriodicGroupIndexerIT.java new file mode 100644 index 0000000..d101909 --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/server/group/PeriodicGroupIndexerIT.java
@@ -0,0 +1,99 @@ +// 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.group; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableSet; +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.entities.AccountGroup; +import com.google.gerrit.extensions.common.GroupInfo; +import com.google.gerrit.index.IndexConfig; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.query.FieldBundle; +import com.google.gerrit.server.group.PeriodicGroupIndexer; +import com.google.gerrit.server.index.group.GroupField; +import com.google.gerrit.server.index.group.GroupIndex; +import com.google.gerrit.server.index.group.GroupIndexCollection; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.Optional; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.junit.Test; + +@UseLocalDisk +public class PeriodicGroupIndexerIT extends AbstractDaemonTest { + + @Inject private GroupIndexCollection indexes; + @Inject private IndexConfig indexConfig; + + @Inject private PeriodicGroupIndexer periodicIndexer; + + private static final ImmutableSet<String> FIELDS = + ImmutableSet.of(GroupField.NAME_SPEC.getName()); + + @Test + public void removesNonExistingGroupsFromIndex() throws Exception { + ObjectId groupNamesObjectId = getGroupNamesRefObjectId(); + + GroupInfo info = gApi.groups().create("foo").get(); + AccountGroup.UUID uuid = AccountGroup.uuid(info.id); + System.out.println(">>> uuid = " + uuid.get()); + GroupIndex i = indexes.getSearchIndex(); + Optional<FieldBundle> result = i.getRaw(uuid, QueryOptions.create(indexConfig, 0, 1, FIELDS)); + assertThat(result).isPresent(); + + // Delete the group by directly updating the All-Users repository. + // Thus, Gerrit will not notice the deletion and will not remove the group + // from the index + deleteGroupRef(uuid); + forceUpdateGroupNamesRef(groupNamesObjectId); + groupCache.evict(uuid); + + result = i.getRaw(uuid, QueryOptions.create(indexConfig, 0, 1, FIELDS)); + assertThat(result).isPresent(); + + periodicIndexer.run(); + + result = i.getRaw(uuid, QueryOptions.create(indexConfig, 0, 1, FIELDS)); + assertThat(result).isEmpty(); + } + + private ObjectId getGroupNamesRefObjectId() throws Exception { + try (Repository repo = repoManager.openRepository(allUsers)) { + return repo.getRefDatabase().exactRef("refs/meta/group-names").getObjectId(); + } + } + + private void deleteGroupRef(AccountGroup.UUID uuid) throws IOException { + String groupRef = String.format("refs/groups/%s/%s", uuid.get().substring(0, 2), uuid.get()); + try (Repository repo = repoManager.openRepository(allUsers)) { + RefUpdate ru = repo.updateRef(groupRef); + ru.setForceUpdate(true); + ru.delete(); + } + } + + private void forceUpdateGroupNamesRef(ObjectId oid) throws IOException { + try (Repository repo = repoManager.openRepository(allUsers)) { + RefUpdate refUpdate = repo.updateRef("refs/meta/group-names"); + refUpdate.setNewObjectId(oid); + refUpdate.forceUpdate(); + } + } +}
diff --git a/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java b/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java index b8af367..d6aa709 100644 --- a/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java +++ b/javatests/com/google/gerrit/acceptance/server/index/change/LuceneChangeIndexerIT.java
@@ -26,6 +26,7 @@ import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.config.GerritConfig; import com.google.gerrit.entities.Project.NameKey; +import com.google.gerrit.extensions.common.ChangeInfo; import com.google.gerrit.index.IndexDefinition; import com.google.gerrit.index.RefState; import com.google.gerrit.index.SiteIndexer.Result; @@ -35,6 +36,7 @@ import com.google.gerrit.testing.ConfigSuite; import com.google.inject.Inject; import java.util.Collection; +import java.util.List; import java.util.Set; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; @@ -104,6 +106,21 @@ } } + @Test + public void deleteAllForProjectDeletesFromIndex() throws Exception { + createChange(); + createChange(); + createChange(); + + List<ChangeInfo> result = gApi.changes().query("project:" + project.get()).get(); + assertThat(result).hasSize(3); + + index.deleteAllForProject(project); + + result = gApi.changes().query("project:" + project.get()).get(); + assertThat(result).isEmpty(); + } + private void createIndexWithMissingChangeAndReindex(ChangeIndexedCounter changeIndexedCounter) throws Exception { PushOneCommit.Result res = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/server/project/PeriodicProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/server/project/PeriodicProjectIndexerIT.java new file mode 100644 index 0000000..28f675e --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/server/project/PeriodicProjectIndexerIT.java
@@ -0,0 +1,72 @@ +// 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.project; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableSet; +import com.google.common.io.MoreFiles; +import com.google.common.io.RecursiveDeleteOption; +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.UseLocalDisk; +import com.google.gerrit.entities.Project; +import com.google.gerrit.index.IndexConfig; +import com.google.gerrit.index.QueryOptions; +import com.google.gerrit.index.project.ProjectField; +import com.google.gerrit.index.project.ProjectIndex; +import com.google.gerrit.index.project.ProjectIndexCollection; +import com.google.gerrit.index.query.FieldBundle; +import com.google.gerrit.server.git.LocalDiskRepositoryManager; +import com.google.gerrit.server.project.PeriodicProjectIndexer; +import com.google.inject.Inject; +import java.nio.file.Path; +import java.util.Optional; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.RepositoryCache; +import org.junit.Test; + +@UseLocalDisk +public class PeriodicProjectIndexerIT extends AbstractDaemonTest { + + @Inject private ProjectIndexCollection indexes; + @Inject private IndexConfig indexConfig; + + @Inject private PeriodicProjectIndexer periodicIndexer; + + private static final ImmutableSet<String> FIELDS = + ImmutableSet.of(ProjectField.NAME_SPEC.getName()); + + @Test + public void removesNonExistingProjectsFromIndex() throws Exception { + Project.NameKey foo = Project.nameKey("foo"); + gApi.projects().create(foo.get()); + ProjectIndex i = indexes.getSearchIndex(); + Optional<FieldBundle> result = i.getRaw(foo, QueryOptions.create(indexConfig, 0, 1, FIELDS)); + assertThat(result).isPresent(); + Path basePath = ((LocalDiskRepositoryManager) repoManager).getBasePath(foo); + Path fooPath = basePath.resolve(foo.get() + Constants.DOT_GIT_EXT); + + MoreFiles.deleteRecursively(fooPath, RecursiveDeleteOption.ALLOW_INSECURE); + projectCache.evict(foo); + RepositoryCache.clear(); + + result = i.getRaw(foo, QueryOptions.create(indexConfig, 0, 1, FIELDS)); + assertThat(result).isPresent(); + + periodicIndexer.run(); + result = i.getRaw(foo, QueryOptions.create(indexConfig, 0, 1, FIELDS)); + assertThat(result).isEmpty(); + } +}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java index 58e9fb9..0510c03 100644 --- a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java +++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -20,6 +20,8 @@ import static org.junit.Assert.assertTrue; import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.ExtensionRegistry; +import com.google.gerrit.acceptance.ExtensionRegistry.Registration; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator; import com.google.gerrit.acceptance.testsuite.change.ChangeOperations; @@ -27,11 +29,16 @@ import com.google.gerrit.entities.Change; import com.google.gerrit.entities.PatchSet; import com.google.gerrit.extensions.client.ChangeKind; +import com.google.gerrit.index.query.Matchable; +import com.google.gerrit.index.query.OperatorPredicate; +import com.google.gerrit.index.query.Predicate; import com.google.gerrit.index.query.QueryParseException; import com.google.gerrit.server.change.ChangeKindCache; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.query.approval.ApprovalContext; import com.google.gerrit.server.query.approval.ApprovalQueryBuilder; +import com.google.gerrit.server.query.approval.ApprovalQueryBuilder.UserInOperandFactory; +import com.google.gerrit.server.query.approval.UserInPredicate; import com.google.gerrit.server.update.RepoView; import com.google.inject.Inject; import org.eclipse.jgit.lib.ObjectInserter; @@ -46,6 +53,7 @@ @Inject private ChangeNotes.Factory changeNotesFactory; @Inject private ChangeKindCache changeKindCache; @Inject private ChangeOperations changeOperations; + @Inject private ExtensionRegistry extensionRegistry; @Test public void magicValuePredicate() throws Exception { @@ -64,6 +72,14 @@ assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(-2))); assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(2))); assertTrue(queryBuilder.parse("is:aNy").asMatchable().match(contextForCodeReviewLabel(2))); + + assertTrue(queryBuilder.parse("is:POSITIVE").asMatchable().match(contextForCodeReviewLabel(1))); + assertTrue( + queryBuilder.parse("is:negative").asMatchable().match(contextForCodeReviewLabel(-2))); + assertFalse( + queryBuilder.parse("is:positive").asMatchable().match(contextForCodeReviewLabel(-1))); + assertFalse( + queryBuilder.parse("is:NEGATIVE").asMatchable().match(contextForCodeReviewLabel(2))); } @Test @@ -85,8 +101,8 @@ assertThat(thrown) .hasMessageThat() .contains( - "INVALID is not a valid value for operator 'is'. Valid values: ANY, MAX, MIN" - + " or integer"); + "INVALID is not a valid value for operator 'is'. Valid values: ANY, MAX, MIN, NEGATIVE," + + " POSITIVE or integer"); } @Test @@ -161,6 +177,14 @@ } @Test + public void changeIsPredicates_matches() throws Exception { + ApprovalContext context = contextForCodeReviewLabel(2); + + assertTrue(queryBuilder.parse("changeis:open").asMatchable().match(context)); + assertFalse(queryBuilder.parse("changeis:wip").asMatchable().match(context)); + } + + @Test public void uploaderInPredicate() throws Exception { String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id; @@ -286,6 +310,49 @@ assertThat(thrown).hasMessageThat().contains("Unsupported query: INVALID"); } + private static class CustomAlwaysTruePredicate extends OperatorPredicate<ApprovalContext> + implements UserInOperandFactory, Matchable<ApprovalContext> { + + public static final String OPERAND = "true-predicate"; + + public CustomAlwaysTruePredicate() { + super("uploaderin", OPERAND); + } + + @Override + public boolean match(ApprovalContext object) { + return true; + } + + @Override + public int getCost() { + return 0; + } + + @Override + public Predicate<ApprovalContext> create(UserInPredicate.Field field) + throws QueryParseException { + return this; + } + } + + @Test + public void pluginUploaderInQuery() throws Exception { + try (Registration registration = + extensionRegistry + .newRegistration() + .add(new CustomAlwaysTruePredicate(), CustomAlwaysTruePredicate.OPERAND)) { + assertTrue( + queryBuilder + .parse( + String.format( + "uploaderin:%s_%s", + CustomAlwaysTruePredicate.OPERAND, ExtensionRegistry.PLUGIN_NAME)) + .asMatchable() + .match(contextForCodeReviewLabel(2))); + } + } + private ApprovalContext contextForCodeReviewLabel(int value) throws Exception { PushOneCommit.Result result = createChange(); amendChange(result.getChangeId()); @@ -305,7 +372,7 @@ ObjectReader reader = ins.newReader(); RevWalk rw = new RevWalk(reader)) { return ApprovalContext.create( - changeNotes, + changeDataFactory.create(changeNotes), psId, approver, projectCache.get(project).get().getLabelTypes().byLabel("Code-Review").get(),
diff --git a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java index 7fead5c..448897c 100644 --- a/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java +++ b/javatests/com/google/gerrit/acceptance/ssh/CustomIndexIT.java
@@ -17,6 +17,7 @@ import static com.google.common.truth.Truth.assertThat; import com.google.common.collect.ImmutableMap; +import com.google.gerrit.index.IndexConfig; import com.google.gerrit.index.IndexType; import com.google.gerrit.index.Schema; import com.google.gerrit.index.project.ProjectIndex; @@ -108,7 +109,8 @@ SitePaths sitePaths, ChangeData.Factory changeDataFactory, @Assisted Schema<ChangeData> schema, - @GerritServerConfig Config cfg) { - super(sitePaths, changeDataFactory, schema, cfg); + @GerritServerConfig Config cfg, + IndexConfig indexConfig) { + super(sitePaths, changeDataFactory, schema, cfg, indexConfig); } }
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java index 8b06c7f..2efc11b 100644 --- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java +++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -48,6 +48,14 @@ .pushCertificate("my push certificate") .description("This is a patch set description.") .branch(Optional.of("refs/heads/master")) + .conflicts( + Optional.of( + PatchSet.Conflicts.create( + Optional.of( + ObjectId.fromString("fc5fcbe1cf0b3e4c203ff4cb77c3912208174695")), + Optional.of( + ObjectId.fromString("f72b99f6898debf8d1ceeffb92faa66bb5cb7a9f")), + true))) .build(); Entities.PatchSet proto = patchSetProtoConverter.toProto(patchSet); @@ -67,6 +75,16 @@ .setPushCertificate("my push certificate") .setDescription("This is a patch set description.") .setBranch("refs/heads/master") + .setConflicts( + Entities.Conflicts.newBuilder() + .setOurs( + Entities.ObjectId.newBuilder() + .setName("fc5fcbe1cf0b3e4c203ff4cb77c3912208174695")) + .setTheirs( + Entities.ObjectId.newBuilder() + .setName("f72b99f6898debf8d1ceeffb92faa66bb5cb7a9f")) + .setContainsConflicts(true) + .build()) .build(); assertThat(proto).isEqualTo(expectedProto); } @@ -112,6 +130,14 @@ .pushCertificate("my push certificate") .description("This is a patch set description.") .branch(Optional.of("refs/heads/master")) + .conflicts( + Optional.of( + PatchSet.Conflicts.create( + Optional.of( + ObjectId.fromString("fc5fcbe1cf0b3e4c203ff4cb77c3912208174695")), + Optional.of( + ObjectId.fromString("f72b99f6898debf8d1ceeffb92faa66bb5cb7a9f")), + true))) .build(); PatchSet convertedPatchSet = @@ -195,6 +221,7 @@ .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType()) .put("description", new TypeLiteral<Optional<String>>() {}.getType()) .put("branch", new TypeLiteral<Optional<String>>() {}.getType()) + .put("conflicts", new TypeLiteral<Optional<PatchSet.Conflicts>>() {}.getType()) .build()); } }
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java index 0c451ac..1af35e9 100644 --- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java +++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -87,7 +87,12 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.NO_CODE_CHANGE); } @@ -100,7 +105,7 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, rev, rev)) + p.getRepository().getDescription().getProject(), null, null, null, rev, rev)) .isEqualTo(ChangeKind.NO_CHANGE); } @@ -115,7 +120,12 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.NO_CHANGE); } @@ -130,7 +140,12 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.REWORK); } @@ -148,7 +163,12 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.REWORK); } @@ -166,7 +186,12 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.REWORK); } @@ -183,7 +208,12 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.TRIVIAL_REBASE); } @@ -200,10 +230,46 @@ assertThat( changeKindCache.getChangeKind( - p.getRepository().getDescription().getProject(), null, null, firstRev, secondRev)) + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) .isEqualTo(ChangeKind.TRIVIAL_REBASE_WITH_MESSAGE_UPDATE); } + @Test + public void trivialRebaseUnionContentMerge() throws Exception { + TestRepository<Repo> p = newRepo("p"); + RevCommit root = + p.commit().add("foo.txt", "foo-text").add(".gitattributes", "*.txt merge=union").create(); + RevCommit firstRev = + p.commit().parent(root).message("Commit message").add("foo.txt", "bar-text").create(); + // Same file was added. + RevCommit newRoot = p.commit().parent(root).add("foo.txt", "baz-text").create(); + // Simulate the rebase adding content from both without conflict markers + RevCommit secondRev = + p.commit() + .parent(newRoot) + .message("Commit message") + .add("foo.txt", "baz-text\nbar-text") + .create(); + + Config useGitattributesForMerge = new Config(); + useGitattributesForMerge.setBoolean("core", null, "useGitattributesForMerge", true); + changeKindCache = new NoCache(useGitattributesForMerge, null, repoManager); + assertThat( + changeKindCache.getChangeKind( + p.getRepository().getDescription().getProject(), + null, + null, + null, + firstRev, + secondRev)) + .isEqualTo(ChangeKind.TRIVIAL_REBASE); + } + 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 131fb23..36f0058 100644 --- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java +++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -50,7 +50,9 @@ CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg); GeneralPreferencesInfo general = CachedPreferences.general(Optional.empty(), pref); - assertThat(general.changesPerPage).isEqualTo(2); + GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults(); + expected.changesPerPage = 2; + assertThat(cleanGeneralPreferences(general)).isEqualTo(expected); } @Test @@ -61,7 +63,9 @@ CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg); DiffPreferencesInfo diff = CachedPreferences.diff(Optional.empty(), pref); - assertThat(diff.context).isEqualTo(3); + DiffPreferencesInfo expected = DiffPreferencesInfo.defaults(); + expected.context = 3; + assertThat(diff).isEqualTo(expected); } @Test @@ -72,7 +76,9 @@ CachedPreferences pref = CachedPreferences.fromLegacyConfig(originalCfg); EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref); - assertThat(edit.tabSize).isEqualTo(5); + EditPreferencesInfo expected = EditPreferencesInfo.defaults(); + expected.tabSize = 5; + assertThat(edit).isEqualTo(expected); } @Test @@ -100,7 +106,9 @@ CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto); GeneralPreferencesInfo general = CachedPreferences.general(Optional.empty(), pref); - assertThat(general.changesPerPage).isEqualTo(11); + GeneralPreferencesInfo expected = GeneralPreferencesInfo.defaults(); + expected.changesPerPage = 11; + assertThat(cleanGeneralPreferences(general)).isEqualTo(expected); } @Test @@ -113,7 +121,9 @@ CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto); DiffPreferencesInfo diff = CachedPreferences.diff(Optional.empty(), pref); - assertThat(diff.context).isEqualTo(13); + DiffPreferencesInfo expected = DiffPreferencesInfo.defaults(); + expected.context = 13; + assertThat(diff).isEqualTo(expected); } @Test @@ -126,7 +136,9 @@ CachedPreferences pref = CachedPreferences.fromUserPreferencesProto(originalProto); EditPreferencesInfo edit = CachedPreferences.edit(Optional.empty(), pref); - assertThat(edit.tabSize).isEqualTo(17); + EditPreferencesInfo expected = EditPreferencesInfo.defaults(); + expected.tabSize = 17; + assertThat(edit).isEqualTo(expected); } @Test @@ -155,10 +167,12 @@ UserPreferences originalProto = UserPreferences.newBuilder() .setGeneralPreferencesInfo( - UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(19)) + UserPreferences.GeneralPreferencesInfo.newBuilder() + .setChangesPerPage(19) + .setAllowAutocompletingComments(false)) .build(); Config originalCfg = new Config(); - originalCfg.fromText("[general]\n\tchangesPerPage = 19"); + originalCfg.fromText("[general]\n\tchangesPerPage = 19\n\tallowAutocompletingComments = false"); CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto); GeneralPreferencesInfo protoGeneral = CachedPreferences.general(Optional.empty(), protoPref); @@ -172,10 +186,13 @@ public void bothPreferencesTypes_getDiffPreferencesAreEqual() throws Exception { UserPreferences originalProto = UserPreferences.newBuilder() - .setDiffPreferencesInfo(UserPreferences.DiffPreferencesInfo.newBuilder().setContext(23)) + .setDiffPreferencesInfo( + UserPreferences.DiffPreferencesInfo.newBuilder() + .setContext(23) + .setHideTopMenu(false)) .build(); Config originalCfg = new Config(); - originalCfg.fromText("[diff]\n\tcontext = 23"); + originalCfg.fromText("[diff]\n\tcontext = 23\n\thideTopMenu = false"); CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto); DiffPreferencesInfo protoDiff = CachedPreferences.diff(Optional.empty(), protoPref); @@ -189,10 +206,13 @@ public void bothPreferencesTypes_getEditPreferencesAreEqual() throws Exception { UserPreferences originalProto = UserPreferences.newBuilder() - .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.newBuilder().setTabSize(27)) + .setEditPreferencesInfo( + UserPreferences.EditPreferencesInfo.newBuilder() + .setTabSize(27) + .setAutoCloseBrackets(true)) .build(); Config originalCfg = new Config(); - originalCfg.fromText("[edit]\n\ttabSize = 27"); + originalCfg.fromText("[edit]\n\ttabSize = 27\n\tautoCloseBrackets = true"); CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto); EditPreferencesInfo protoEdit = CachedPreferences.edit(Optional.empty(), protoPref); @@ -231,4 +251,15 @@ StorageException.class, () -> CachedPreferences.edit(Optional.of(defaults), userPreferences)); } + + /** + * {@link PreferencesParserUtil#parseGeneralPreferences} sets explicit values to {@link + * GeneralPreferencesInfo#my} and {@link GeneralPreferencesInfo#changeTable} in case of null + * defaults. Set these back to {@code null} for comparing with the defaults. + */ + private static GeneralPreferencesInfo cleanGeneralPreferences(GeneralPreferencesInfo pref) { + pref.my = null; + pref.changeTable = null; + return pref; + } }
diff --git a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java index 035878c..f8c04a9 100644 --- a/javatests/com/google/gerrit/server/config/ConfigUtilTest.java +++ b/javatests/com/google/gerrit/server/config/ConfigUtilTest.java
@@ -107,7 +107,7 @@ assertThat(out.ld).isEqualTo(d.ld); assertThat(out.b).isEqualTo(in.b); assertThat(out.bb).isEqualTo(in.bb); - assertThat(out.bd).isNull(); + assertThat(out.bd).isFalse(); assertThat(out.s).isEqualTo(in.s); assertThat(out.sd).isEqualTo(d.sd); assertThat(out.nd).isNull();
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java index c65e552..f5de4e8 100644 --- a/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java +++ b/javatests/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -34,6 +34,7 @@ import com.google.gerrit.index.query.OrPredicate; import com.google.gerrit.index.query.Predicate; import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.metrics.DisabledMetricMaker; import com.google.gerrit.server.query.change.AndChangeSource; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.query.change.ChangeQueryBuilder; @@ -57,7 +58,7 @@ @Before public void setUp() throws Exception { index = new FakeChangeIndex(FakeChangeIndex.V2); - indexes = new ChangeIndexCollection(); + indexes = new ChangeIndexCollection(new DisabledMetricMaker()); indexes.setSearchIndex(index); queryBuilder = new FakeQueryBuilder(indexes); rewrite =
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java index 1a51c00..e4ccfdb 100644 --- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java +++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -18,6 +18,7 @@ import com.google.common.collect.ImmutableList; import com.google.gerrit.entities.Change; +import com.google.gerrit.entities.Project; import com.google.gerrit.index.IndexedField; import com.google.gerrit.index.QueryOptions; import com.google.gerrit.index.Schema; @@ -105,6 +106,11 @@ } @Override + public void deleteAllForProject(Project.NameKey project) { + throw new UnsupportedOperationException(); + } + + @Override public void deleteAll() { throw new UnsupportedOperationException(); }
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java index 8717d99..56aa40f 100644 --- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java +++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -757,6 +757,99 @@ assertParseFails("Update change\n\nPatch-set: 1\nCurrent: blah"); } + @Test + public void parseConflicts() throws Exception { + // No conflicts information present + assertParseSucceeds( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n"); + + // Conflicts information present, Contains-Conflicts: true + assertParseSucceeds( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Contains-Conflicts: true\n" + + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n" + + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"); + + // Conflicts information present, Contains-Conflicts: false + assertParseSucceeds( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Contains-Conflicts: false\n" + + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n" + + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"); + + // Ours/Theirs is optional if "Contains-Conflicts: false" is set + assertParseSucceeds( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Contains-Conflicts: false\n"); + + // Ours/Theirs is ignored if Contains-Conflicts is missing + assertParseSucceeds( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n" + + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"); + + // Parsing fails if "Contains-Conflicts: true" is present without Ours/Theirs + assertParseFails( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Contains-Conflicts: true\n" + + "Theirs: aaeceb9f08df45748b1420ab2b0687906151ae59\n"); + assertParseFails( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Contains-Conflicts: true\n" + + "Ours: 2d1a400a2e56090699f8aeb522ec1f82bbd54d57\n"); + assertParseFails( + "Update change\n" + + "\n" + + "Branch: refs/heads/master\n" + + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n" + + "Patch-set: 2\n" + + "Subject: This is a test change\n" + + "Commit: abcd1234abcd1234abcd1234abcd1234abcd1234\n" + + "Contains-Conflicts: true\n"); + } + private RevCommit writeCommit(String body) throws Exception { ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class); return writeCommit(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java index 26635de..0aa4fcb 100644 --- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java +++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -1052,6 +1052,7 @@ .put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType()) .put("description", new TypeLiteral<Optional<String>>() {}.getType()) .put("branch", new TypeLiteral<Optional<String>>() {}.getType()) + .put("conflicts", new TypeLiteral<Optional<PatchSet.Conflicts>>() {}.getType()) .build()); }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java index 1f22fc1d..f85c8dd 100644 --- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java +++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -27,7 +27,7 @@ import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.util.time.TimeUtil; import com.google.gerrit.testing.TestChanges; -import java.time.ZoneId; +import java.time.ZoneOffset; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.revwalk.RevCommit; @@ -69,7 +69,8 @@ assertThat(author.getEmailAddress()).isEqualTo("1@gerrit"); assertThat(author.getWhenAsInstant().toEpochMilli()) .isEqualTo(c.getCreatedOn().toEpochMilli() + 1000); - assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7")); + assertThat(author.getZoneId().getRules().getOffset(author.getWhenAsInstant())) + .isEqualTo(ZoneOffset.ofHours(-7)); PersonIdent committer = commit.getCommitterIdent(); assertThat(committer.getName()).isEqualTo("Gerrit Server"); @@ -186,7 +187,8 @@ assertThat(author.getEmailAddress()).isEqualTo("1@gerrit"); assertThat(author.getWhenAsInstant().toEpochMilli()) .isEqualTo(c.getCreatedOn().toEpochMilli() + 2000); - assertThat(author.getZoneId()).isEqualTo(ZoneId.of("GMT-7")); + assertThat(author.getZoneId().getRules().getOffset(author.getWhenAsInstant())) + .isEqualTo(ZoneOffset.ofHours(-7)); PersonIdent committer = commit.getCommitterIdent(); assertThat(committer.getName()).isEqualTo("Gerrit Server");
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java index 1a546fa..b9b2f8d 100644 --- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java +++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -3151,6 +3151,41 @@ } @Test + @GerritConfig(name = "core.useGitattributesForMerge", value = "true") + public void conflictsUnionContentMerge() throws Exception { + Project.NameKey project = Project.nameKey("repo"); + repo = createAndOpenProject(project); + TestRepository<Repository>.CommitBuilder builder = + repo.commit().add(".gitattributes", "*.txt merge=union"); + builder.create(); + RevCommit commit1 = + repo.parseBody( + builder + .child() + .add("file1", "contents1") + .add("dir/file2.txt", "contents2") + .add("dir/file3", "contents3") + .create()); + RevCommit commit2 = repo.parseBody(builder.child().add("file1", "contents1").create()); + RevCommit commit3 = + repo.parseBody(builder.child().add("dir/file2.txt", "contents2 different").create()); + RevCommit commit4 = repo.parseBody(builder.child().add("file4", "contents4").create()); + RevCommit commit5 = + repo.parseBody(builder.child().add("dir/file3", "contents3 different").create()); + Change change1 = insert(project, newChangeForCommit(repo, commit1)); + Change change2 = insert(project, newChangeForCommit(repo, commit2)); + Change change3 = insert(project, newChangeForCommit(repo, commit3)); + Change change4 = insert(project, newChangeForCommit(repo, commit4)); + Change change5 = insert(project, newChangeForCommit(repo, commit5)); + + assertQuery("conflicts:" + change1.getId().get(), change5); + assertQuery("conflicts:" + change2.getId().get()); + assertQuery("conflicts:" + change3.getId().get()); + assertQuery("conflicts:" + change4.getId().get()); + assertQuery("conflicts:" + change5.getId().get(), change1); + } + + @Test @GerritConfig( name = "change.mergeabilityComputationBehavior", value = "API_REF_UPDATED_AND_CHANGE_REINDEX") @@ -4529,7 +4564,7 @@ Change.Id id = Change.id(seq.nextChangeId()); return changeFactory .create(id, commit, branch) - .setValidate(false) + .disableValidation() .setStatus(status) .setTopic(topic) .setWorkInProgress(workInProgress) @@ -4583,7 +4618,7 @@ patchSetFactory .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit) .setFireRevisionCreated(false) - .setValidate(false); + .disableValidation(); testRefAction( () -> { try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java index d9d5214..c1b1b52 100644 --- a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java +++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -133,4 +133,40 @@ public void openOtherContextByType_exceptionThrown() { assertThrows(Exception.class, () -> RefUpdateContext.open(OTHER)); } + + @Test + public void addCustomData() { + String customData = "customData"; + + try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { + assertThat(ctx.getCustomData(String.class)).isEmpty(); + try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) { + nestedCtx.addCustomData(customData); + assertThat(nestedCtx.getCustomData(String.class)).containsExactly(customData); + } + assertThat(ctx.getCustomData(String.class)).containsExactly(customData); + } + } + + @Test + public void addCustomData_withCallback() { + Callback callback = + new Callback() { + @Override + public void callback() {} + }; + + try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { + assertThat(ctx.getCustomData(String.class)).isEmpty(); + try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) { + nestedCtx.addCustomData(callback); + assertThat(nestedCtx.getCustomData(Callback.class)).containsExactly(callback); + } + assertThat(ctx.getCustomData(Callback.class)).containsExactly(callback); + } + } + + private static interface Callback { + void callback(); + } }
diff --git a/lib/BUILD b/lib/BUILD index a0debc1..42e4435 100644 --- a/lib/BUILD +++ b/lib/BUILD
@@ -73,12 +73,41 @@ runtime_deps = [":jgit"], ) +# JGit moved to jakarta servlet API in 7.0 but gerrit hasn't. +# +# The only difference for the http.server between jakarta and javax +# are the imports. Repack the jgit-servlet code replacing them. java_library( name = "jgit-servlet", + srcs = [":jgit-servlet-srcjar-with-javax"], data = ["//lib:LICENSE-jgit"], + resource_strip_prefix = "external/jgit/org.eclipse.jgit.http.server/resources", + resources = ["@jgit//org.eclipse.jgit.http.server:jgit-servlet-resources"], visibility = ["//visibility:public"], - exports = ["@jgit//org.eclipse.jgit.http.server:jgit-servlet"], runtime_deps = [":jgit"], + deps = [ + ":jgit", + "//lib:servlet-api", + ], +) + +# As the genrule requires a concrete list of outs, use srcjars: +# uncompress source jar of jgit-servlet, +# replace, +# recompress as srcjar +genrule( + name = "jgit-servlet-srcjar-with-javax", + srcs = ["@jgit//org.eclipse.jgit.http.server:libjgit-servlet-src.jar"], + outs = ["jgit-servlet-javax.srcjar"], + cmd = """ + TOP="$$(pwd)" + tmpdir="$$(mktemp -d)" + cd "$${tmpdir}" + jar xf "$${TOP}/$<" + find . -name "*.java" | xargs sed -i s/jakarta.servlet/javax.servlet/ + jar cf "$${TOP}/$@" . + rm -rf "$${tmpdir}" + """, ) java_library(
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD index f13a064..c1c6a2c 100644 --- a/lib/fonts/BUILD +++ b/lib/fonts/BUILD
@@ -5,9 +5,11 @@ name = "robotofonts", srcs = [ "opensans-latin-400.woff2", + "opensans-latin-500.woff2", "opensans-latin-600.woff2", "opensans-latin-700.woff2", "opensans-latin-ext-400.woff2", + "opensans-latin-ext-500.woff2", "opensans-latin-ext-600.woff2", "opensans-latin-ext-700.woff2", "roboto-latin-400.woff2",
diff --git a/lib/fonts/opensans-latin-500.woff2 b/lib/fonts/opensans-latin-500.woff2 new file mode 100644 index 0000000..a35be30 --- /dev/null +++ b/lib/fonts/opensans-latin-500.woff2 Binary files differ
diff --git a/lib/fonts/opensans-latin-ext-500.woff2 b/lib/fonts/opensans-latin-ext-500.woff2 new file mode 100644 index 0000000..5fa1408 --- /dev/null +++ b/lib/fonts/opensans-latin-ext-500.woff2 Binary files differ
diff --git a/package.json b/package.json index a6dd71d..e79235e 100644 --- a/package.json +++ b/package.json
@@ -53,7 +53,7 @@ "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", - "gjf": "./tools/run_gjf.sh" + "gjf": "./tools/gjf.sh run" }, "repository": { "type": "git",
diff --git a/plugins/delete-project b/plugins/delete-project index e1328bd..39f2681 160000 --- a/plugins/delete-project +++ b/plugins/delete-project
@@ -1 +1 @@ -Subproject commit e1328bd6cc11542ec909e0537c74b47ae8edecf8 +Subproject commit 39f2681d891ba38c68cc4d3fa81757d3c612da05
diff --git a/plugins/hooks b/plugins/hooks index 4f43f5d..83d5aae 160000 --- a/plugins/hooks +++ b/plugins/hooks
@@ -1 +1 @@ -Subproject commit 4f43f5db6b8aa7f36381f4f9a4c9ec1fc335d949 +Subproject commit 83d5aae0fce1956858c1b5595012e68867c53437
diff --git a/plugins/plugin-manager b/plugins/plugin-manager index 86f7ec6..ed7870e 160000 --- a/plugins/plugin-manager +++ b/plugins/plugin-manager
@@ -1 +1 @@ -Subproject commit 86f7ec61a9785df246f653a1336520b9607399b1 +Subproject commit ed7870eb3c8b6e48511d0eb3bd54606927b46019
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts index 945afe4..3652012 100644 --- a/polygerrit-ui/app/api/diff.ts +++ b/polygerrit-ui/app/api/diff.ts
@@ -360,6 +360,15 @@ linesRendered: number; } +/** + * The detail of the 'copy-info' event dispatched by gr-diff. + */ +export declare interface CopyInfoEventDetail { + side: Side; + range: TextRange; + length: number; +} + export declare interface DisplayLine { side: Side; lineNum: LineNumber;
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts index c595add..a952204 100644 --- a/polygerrit-ui/app/api/plugin.ts +++ b/polygerrit-ui/app/api/plugin.ts
@@ -36,6 +36,7 @@ ADMIN_MENU_LINKS = 'admin-menu-links', SHOW_DIFF = 'showdiff', REPLY_SENT = 'replysent', + PUBLISH_EDIT = 'publish-edit', } export declare interface PluginApi {
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts index 382a043..dad8ead 100644 --- a/polygerrit-ui/app/api/rest-api.ts +++ b/polygerrit-ui/app/api/rest-api.ts
@@ -1344,3 +1344,31 @@ replacements: FixReplacementInfo[]; log_probability?: number; } + +/** + * The LabelDefinitionInfo entity describes a review label. + * + * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-definition-info + */ +export interface LabelDefinitionInfo { + name: string; + description?: string; + project_name: string; + function: LabelDefinitionInfoFunction; + values: LabelValueToDescriptionMap; + default_value: number; + branches?: string[]; + can_override?: boolean; + copy_condition?: string; + allow_post_submit?: boolean; + ignore_self_approval?: boolean; +} + +export enum LabelDefinitionInfoFunction { + MaxWithBlock = 'MaxWithBlock', + AnyWithBlock = 'AnyWithBlock', + MaxNoBlock = 'MaxNoBlock', + NoBlock = 'NoBlock', + Noop = 'Noop', + PatchSetLock = 'PatchSetLock', +}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts index b21663a..c7f4edf 100644 --- a/polygerrit-ui/app/constants/constants.ts +++ b/polygerrit-ui/app/constants/constants.ts
@@ -7,7 +7,7 @@ /** * Tab names for primary tabs on change view page. */ -import {DiffViewMode} from '../api/diff'; +import {CopyInfoEventDetail, TextRange, DiffViewMode} from '../api/diff'; import {DiffPreferencesInfo} from '../types/diff'; import {EditPreferencesInfo, PreferencesInfo} from '../types/common'; import { @@ -35,6 +35,7 @@ ChangeStatus, CommentSide, ConfigParameterInfoType, + type CopyInfoEventDetail, DefaultDisplayNameConfig, EditableAccountField, FileInfoStatus, @@ -48,6 +49,7 @@ ReviewerState, RevisionKind, SubmitType, + type TextRange, }; export enum AccountTag {
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts index 98dd592..ee9b041 100644 --- a/polygerrit-ui/app/constants/reporting.ts +++ b/polygerrit-ui/app/constants/reporting.ts
@@ -118,6 +118,7 @@ CHECKS_CHIP_CLICKED = 'checks-chip-clicked', CHECKS_CHIP_LINK_CLICKED = 'checks-chip-link-clicked', CHECKS_RESULT_ROW_TOGGLE = 'checks-result-row-toggle', + CHECKS_RESULT_DIFF_RENDERED = 'checks-result-diff-rendered', CHECKS_ACTION_TRIGGERED = 'checks-action-triggered', CHECKS_TAG_CLICKED = 'checks-tag-clicked', CHECKS_RESULT_FILTER_CHANGED = 'checks-result-filter-changed',
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 0434481..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
@@ -83,9 +83,6 @@ @state() ownerOf?: GitRef[]; // private but used in test - @state() isOwner = false; - - // private but used in test @state() capabilities?: CapabilityInfoMap; // private but used in test @@ -239,7 +236,9 @@ > <gr-button id="saveBtn" - class=${this.isOwner ? '' : 'invisible'} + class=${this.ownerOf && this.ownerOf.length === 0 + ? 'invisible' + : ''} primary ?disabled=${!this.modified || this.disableSaveWithoutReview} @click=${this.handleSave} @@ -348,7 +347,6 @@ this.canUpload = res.can_upload; this.disableSaveWithoutReview = !!res.require_change_for_config_update; this.ownerOf = res.owner_of || []; - this.isOwner = res.is_owner ?? false; return toSortedPermissionsArray(this.local); }); @@ -716,7 +714,7 @@ // private but used in test computeMainClass() { const classList = []; - if (this.isOwner || this.canUpload) { + if ((this.ownerOf && this.ownerOf.length > 0) || this.canUpload) { classList.push('admin'); } if (this.editing) {
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 9b56a9e..c321abf 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
@@ -16,7 +16,12 @@ queryAndAssert, stubRestApi, } from '../../../test/test-utils'; -import {ChangeInfo, RepoName, UrlEncodedRepoName} from '../../../types/common'; +import { + ChangeInfo, + GitRef, + RepoName, + UrlEncodedRepoName, +} from '../../../types/common'; import {PermissionAction} from '../../../constants/constants'; import {AutocompleteCommitEvent, PageErrorEvent} from '../../../types/events'; import {GrButton} from '../../shared/gr-button/gr-button'; @@ -254,13 +259,13 @@ }); test('computeMainClass', () => { - element.isOwner = true; + element.ownerOf = ['refs/*'] as GitRef[]; element.editing = false; element.canUpload = false; assert.equal(element.computeMainClass(), 'admin'); element.editing = true; assert.equal(element.computeMainClass(), 'admin editing'); - element.isOwner = false; + element.ownerOf = []; element.editing = false; assert.equal(element.computeMainClass(), ''); element.editing = true; @@ -511,13 +516,13 @@ }); test('button visibility for ref owner', async () => { - element.isOwner = true; + element.ownerOf = ['refs/for/*'] as GitRef[]; await element.updateComplete; testEditSaveCancelBtns(true, false); }); test('button visibility for ref owner and upload', async () => { - element.isOwner = true; + element.ownerOf = ['refs/for/*'] as GitRef[]; element.canUpload = true; await element.updateComplete; testEditSaveCancelBtns(true, false);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts index fc5760b..9bcc862 100644 --- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts +++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -199,10 +199,10 @@ static override get styles() { return [ - changeListStyles, formStyles, sharedStyles, submitRequirementsStyles, + changeListStyles, css` :host { display: table-row;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts index 0799e03..8ad6c68 100644 --- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts +++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts
@@ -113,10 +113,10 @@ static override get styles() { return [ + sharedStyles, changeListStyles, fontStyles, formStyles, - sharedStyles, css` :host { display: contents; @@ -184,6 +184,9 @@ const columns = this.computeColumns(); const colSpan = this.computeColspan(columns); return html` + <tbody> + <tr class="groupGap"></tr> + </tbody> ${this.renderSectionHeader(colSpan)} <tbody class="groupContent"> ${this.isEmpty() @@ -228,7 +231,7 @@ <td aria-hidden="true" class="leftPadding"></td> <td aria-hidden="true" class="star" ?hidden=${!this.isLoggedIn}></td> <td class="cell" colspan=${colSpan}> - <h2 class="heading-3"> + <h2 class="heading-2"> <a href=${this.sectionHref(this.changeSection.query)} class="section-title"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts index 9495bea..37b19b2 100644 --- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts +++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -207,8 +207,13 @@ fontStyles, sharedStyles, css` + :host { + display: block; + padding: 0 8px; + box-sizing: border-box; + } #changeList { - border-collapse: collapse; + border-collapse: separate; width: 100%; } .section-count-label {
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 db1f2f5..f7a4082 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
@@ -10,6 +10,7 @@ import '../gr-create-change-help/gr-create-change-help'; import '../gr-create-destination-dialog/gr-create-destination-dialog'; import '../gr-user-header/gr-user-header'; +import '../../core/gr-notifications-prompt/gr-notifications-prompt'; import {getAppContext} from '../../../services/app-context'; import {changeIsOpen} from '../../../utils/change-util'; import {parseDate} from '../../../utils/date-util'; @@ -100,6 +101,9 @@ // private but used in test @state() showNewUserHelp = false; + // private but used in test + @state() showNotificationsPrompt = false; + private reporting = getAppContext().reportingService; private readonly restApiService = getAppContext().restApiService; @@ -303,6 +307,7 @@ <span>No changes need your attention 🎉</span> </div> </gr-change-list> + ${this.renderShowNotificationsPrompt()} </div> `; } @@ -333,6 +338,12 @@ `; } + private renderShowNotificationsPrompt() { + if (!this.showNotificationsPrompt) return; + + return html`<gr-notifications-prompt></gr-notifications-prompt>`; + } + // private but used in test getRepositoryDashboard( repo: RepoName, @@ -459,6 +470,9 @@ // the user is "New" ie. haven't created any changes yet. const lastResultSet = changes.pop(); this.showNewUserHelp = lastResultSet!.length === 0; + // Show the notifications prompt if the user has created any changes + // (meaning they are not a "New" user). + this.showNotificationsPrompt = !this.showNewUserHelp; } this.results = changes .map((results, i) => { @@ -475,6 +489,15 @@ i < res.sections.length && (!res.sections[i].hideIfEmpty || section.results.length) ); + + // Show the notifications prompt if the user has any results in their attention set. + this.showNotificationsPrompt = + this.showNotificationsPrompt || + this.results.filter( + changelistSection => + changelistSection.name === YOUR_TURN.name && + changelistSection.results.length > 0 + ).length !== 0; } /**
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 9b09c84..adf2cfc 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
@@ -38,6 +38,7 @@ import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api'; import {GrButton} from '../../shared/gr-button/gr-button'; import {DashboardType} from '../../../models/views/dashboard'; +import {GrNotificationsPrompt} from '../../core/gr-notifications-prompt/gr-notifications-prompt'; suite('gr-dashboard-view tests', () => { let element: GrDashboardView; @@ -91,6 +92,7 @@ <span> No changes need your attention  ðŸŽ‰ </span> </div> </gr-change-list> + <gr-notifications-prompt> </gr-notifications-prompt> </div> <dialog id="confirmDeleteModal" @@ -549,6 +551,27 @@ assert.isOk(query<GrCreateChangeHelp>(element, 'gr-create-change-help')); }); + test('showNotificationsPrompt', async () => { + element.viewState = { + view: GerritView.DASHBOARD, + type: DashboardType.USER, + }; + element.loading = false; + element.showNotificationsPrompt = false; + await element.updateComplete; + + query<GrNotificationsPrompt>(element, 'gr-notifications-prompt'); + assert.isNotOk( + query<GrNotificationsPrompt>(element, 'gr-notifications-prompt') + ); + element.showNotificationsPrompt = true; + await element.updateComplete; + + assert.isOk( + query<GrNotificationsPrompt>(element, 'gr-notifications-prompt') + ); + }); + test('gr-user-header', async () => { element.viewState = undefined; await element.updateComplete;
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 836d86a..773f529 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
@@ -1352,6 +1352,13 @@ ); } + sendPublishEditEvent() { + if (!this.change) return; + const change = this.change as ChangeInfo; + const revision = this.getRevision(change, this.latestPatchNum); + this.getPluginLoader().jsApiService.handlePublishEdit(change, revision); + } + // private but used in test getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) { for (const rev of Object.values(change.revisions ?? {})) { @@ -1750,6 +1757,7 @@ false, {notify: NotifyType.NONE} ); + this.sendPublishEditEvent(); } // private but used in test
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts index c086359..5a5152e 100644 --- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts +++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -24,6 +24,8 @@ import {configModelToken} from '../../../models/config/config-model'; import {shorten} from '../../../utils/patch-set-util'; +type DownloadKind = 'zip' | 'raw' | 'base64'; + @customElement('gr-download-dialog') export class GrDownloadDialog extends LitElement { /** @@ -181,11 +183,14 @@ <div class="patchFiles"> <label>Patch file</label> <div> - <a id="download" .href=${this.computeDownloadLink()} download> - ${this.computeDownloadFilename()} + <a id="download" .href=${this.computeDownloadLink('raw')} download> + ${this.computeDownloadFilename('raw')} </a> - <a .href=${this.computeDownloadLink(true)} download> - ${this.computeDownloadFilename(true)} + <a .href=${this.computeDownloadLink('base64')} download> + ${this.computeDownloadFilename('base64')} + </a> + <a .href=${this.computeDownloadLink('zip')} download> + ${this.computeDownloadFilename('zip')} </a> </div> </div> @@ -283,24 +288,49 @@ return commands; } - private computeDownloadLink(zip?: boolean) { + private computeDownloadLink(kind: DownloadKind) { if (this.change === undefined || this.patchNum === undefined) { return ''; } + let urlParam; + switch (kind) { + case 'zip': + urlParam = '&zip'; + break; + case 'raw': + urlParam = '&raw'; + break; + case 'base64': + urlParam = ''; + break; + } return ( changeBaseURL(this.change.project, this.change._number, this.patchNum) + - '/patch?' + - (zip ? 'zip' : 'download') + '/patch?download' + + urlParam ); } - private computeDownloadFilename(zip?: boolean) { + private computeDownloadFilename(kind: DownloadKind) { if (this.change === undefined || this.patchNum === undefined) { return ''; } + let ext; + switch (kind) { + case 'zip': + ext = '.zip'; + break; + case 'raw': + ext = ''; + break; + case 'base64': + ext = '.base64'; + break; + } + const rev = getRevisionKey(this.change, this.patchNum) ?? ''; - return shorten(rev)! + '.diff.' + (zip ? 'zip' : 'base64'); + return shorten(rev)! + '.diff' + ext; } // private but used in test
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts index 18da58d..9ed7668 100644 --- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts +++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -132,6 +132,7 @@ <div> <a download="" href="" id="download"> </a> <a download="" href=""> </a> + <a download="" href=""> </a> </div> </div> <div class="archivesContainer">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts b/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts index 5db8309..605b5e7 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-fix-preview.ts
@@ -19,6 +19,7 @@ import {subscribe} from '../lit/subscription-controller'; import {fire} from '../../utils/event-util'; import {OpenFixPreviewEventDetail} from '../../types/events'; +import {userModelToken} from '../../models/user/user-model'; /** * There is a certain overlap with `GrUserSuggestionsFix` which wraps @@ -56,8 +57,17 @@ @state() isChangeMerged = false; + @state() isChangeAbandoned = false; + + @state() loggedIn = false; + + @state() + selectedReplacementIdx = 0; + private readonly getChangeModel = resolve(this, changeModelToken); + private readonly getUserModel = resolve(this, userModelToken); + constructor() { super(); subscribe( @@ -80,6 +90,16 @@ () => this.getChangeModel().status$, status => (this.isChangeMerged = status === ChangeStatus.MERGED) ); + subscribe( + this, + () => this.getChangeModel().status$, + status => (this.isChangeAbandoned = status === ChangeStatus.ABANDONED) + ); + subscribe( + this, + () => this.getUserModel().loggedIn$, + loggedIn => (this.loggedIn = loggedIn) + ); } static override get styles() { @@ -103,6 +123,18 @@ border: 1px solid var(--border-color); padding: var(--spacing-xl); } + .fix-picker { + display: flex; + align-items: center; + margin-left: var(--spacing-m); + } + .fix-picker gr-button { + margin: 0 var(--spacing-s); + } + .title { + display: flex; + align-items: center; + } `, ]; } @@ -113,10 +145,38 @@ } private renderHeader() { + const replacementCount = this.fixSuggestionInfo?.replacements?.length ?? 0; return html` <div class="header"> <div class="title"> <span>Attached Fix</span> + ${replacementCount > 1 + ? html` + <div class="fix-picker"> + <span + >${this.selectedReplacementIdx + 1} of + ${replacementCount}</span + > + <gr-button + id="prevFix" + link + @click=${this.onPrevFixClick} + ?disabled=${this.selectedReplacementIdx === 0} + > + <gr-icon icon="chevron_left"></gr-icon> + </gr-button> + <gr-button + id="nextFix" + link + @click=${this.onNextFixClick} + ?disabled=${this.selectedReplacementIdx === + replacementCount - 1} + > + <gr-icon icon="chevron_right"></gr-icon> + </gr-button> + </div> + ` + : nothing} </div> <div> <gr-button @@ -145,9 +205,16 @@ } private renderDiff() { + // Create a new fixSuggestionInfo with just the current replacement + const newFixSuggestionInfo = this.fixSuggestionInfo && { + ...this.fixSuggestionInfo, + replacements: [ + this.fixSuggestionInfo.replacements[this.selectedReplacementIdx], + ], + }; return html` <gr-suggestion-diff-preview - .fixSuggestionInfo=${this.fixSuggestionInfo} + .fixSuggestionInfo=${newFixSuggestionInfo} .patchSet=${this.patchSet} .codeText=${'Loading fix preview ...'} @preview-loaded=${() => (this.previewLoaded = true)} @@ -184,15 +251,39 @@ private isApplyEditDisabled() { if (this.patchSet === undefined) return true; if (this.isChangeMerged) return true; + if (this.isChangeAbandoned) return true; + if (!this.loggedIn) return true; return !this.previewLoaded; } private computeApplyFixTooltip() { if (this.patchSet === undefined) return ''; if (this.isChangeMerged) return 'Change is already merged'; + if (this.isChangeAbandoned) return 'Change is abandoned'; if (!this.previewLoaded) return 'Fix is still loading ...'; + if (!this.loggedIn) return 'You must be logged in to apply a fix'; return ''; } + + private onPrevFixClick(e: Event) { + if (e) e.stopPropagation(); + if (this.selectedReplacementIdx >= 1) { + this.selectedReplacementIdx -= 1; + this.previewLoaded = false; + } + } + + private onNextFixClick(e: Event) { + if (e) e.stopPropagation(); + if ( + this.fixSuggestionInfo && + this.selectedReplacementIdx < + this.fixSuggestionInfo.replacements.length - 1 + ) { + this.selectedReplacementIdx += 1; + this.previewLoaded = false; + } + } } declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts index 16d3d3e..2a6f024 100644 --- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts +++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -24,6 +24,8 @@ import {commentsModelToken} from '../../models/comments/comments-model'; import {subscribe} from '../lit/subscription-controller'; import {changeModelToken} from '../../models/change/change-model'; +import {getAppContext} from '../../services/app-context'; +import {Interaction} from '../../constants/reporting'; @customElement('gr-diff-check-result') export class GrDiffCheckResult extends LitElement { @@ -50,6 +52,8 @@ private readonly getCommentsModel = resolve(this, commentsModelToken); + private readonly reporting = getAppContext().reportingService; + static override get styles() { return [ fontStyles, @@ -144,6 +148,14 @@ ); } + protected override firstUpdated(_changedProperties: PropertyValues): void { + // This component is only used in gr-diff-host, so we can assume that the + // result is always rendered in the diff. + this.reporting.reportInteraction(Interaction.CHECKS_RESULT_DIFF_RENDERED, { + checkName: this.result?.checkName, + }); + } + override render() { if (!this.result) return; const cat = this.result.category.toLowerCase();
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 d133bca..9775f56 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
@@ -344,7 +344,7 @@ .rightItems gr-endpoint-decorator:not(:empty), .mobileRightItems gr-endpoint-decorator:not(:empty) { - margin-left: var(--spacing-l); + margin-left: var(--spacing-s); } gr-smart-search { flex-grow: 1;
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 0c9d358..8e3963c 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
@@ -37,12 +37,13 @@ import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker'; import {highlightServiceToken} from '../../../services/highlight/highlight-service'; import {anyLineTooLong} from '../../../utils/diff-util'; -import {fireReload} from '../../../utils/event-util'; +import {fireError, fireReload} from '../../../utils/event-util'; 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'; import {ChangeStatus} from '../../../api/rest-api'; +import {SpecialFilePath} from '../../../constants/constants'; export interface DiffPreview { filepath: string; @@ -95,11 +96,21 @@ @state() loading = false; + @state() + hasEdit = false; + @state() isChangeMerged = false; + @state() isChangeAbandoned = false; + @state() onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[] = []; + @state() loggedIn = false; + + @state() + selectedReplacementIdx = 0; + private readonly restApiService = getAppContext().restApiService; private readonly getUserModel = resolve(this, userModelToken); @@ -130,6 +141,13 @@ ); subscribe( this, + () => this.getUserModel().loggedIn$, + loggedIn => { + this.loggedIn = loggedIn; + } + ); + subscribe( + this, () => this.getUserModel().diffPreferences$, diffPreferences => { if (!diffPreferences) return; @@ -157,6 +175,19 @@ () => this.getChangeModel().status$, status => (this.isChangeMerged = status === ChangeStatus.MERGED) ); + subscribe( + this, + () => this.getChangeModel().status$, + status => (this.isChangeAbandoned = status === ChangeStatus.ABANDONED) + ); + subscribe( + this, + () => this.getChangeModel().revisions$, + revisions => + (this.hasEdit = Object.values(revisions).some( + info => info._number === EDIT + )) + ); } static override get styles() { @@ -246,8 +277,11 @@ private renderFooter() { const fixCount = this.fixSuggestions?.length ?? 0; + const replacementCount = this.currentFix?.replacements?.length ?? 0; const reasonForDisabledApplyButton = this.computeTooltip(); - const shouldRenderNav = fixCount >= 2; + const shouldRenderNav = + fixCount >= 2 || + (this.currentFix?.fix_id === PROVIDED_FIX_ID && replacementCount >= 2); const shouldRenderWarning = !!reasonForDisabledApplyButton; if (!shouldRenderNav && !shouldRenderWarning) return nothing; @@ -255,7 +289,7 @@ return html` <div slot="footer" class="fix-picker"> ${when(shouldRenderNav, () => - this.renderNavForMultipleSuggestedFixes(fixCount) + this.renderNavigation(fixCount, replacementCount) )} ${when(shouldRenderWarning, () => this.renderWarning(reasonForDisabledApplyButton) @@ -264,6 +298,37 @@ `; } + private renderNavigation(fixCount: number, replacementCount: number) { + // TODO(b/389011542) Fix apply:fix endpoint to support multiple replacements + // apply:fix endpoint currently supports only one replacement + if (this.currentFix?.fix_id === PROVIDED_FIX_ID && replacementCount > 1) { + return this.renderReplacementNavigation(replacementCount); + } + // TODO(b/227463363) Remove once Robot Comments are deprecated. + return this.renderNavForMultipleSuggestedFixes(fixCount); + } + + private renderReplacementNavigation(replacementCount: number) { + const id = this.selectedReplacementIdx; + return html` + <span>Replacement ${id + 1} of ${replacementCount}</span> + <gr-button + id="prevFix" + @click=${this.onPrevReplacementClick} + ?disabled=${id === 0} + > + <gr-icon icon="chevron_left"></gr-icon> + </gr-button> + <gr-button + id="nextFix" + @click=${this.onNextReplacementClick} + ?disabled=${id === replacementCount - 1} + > + <gr-icon icon="chevron_right"></gr-icon> + </gr-button> + `; + } + private renderNavForMultipleSuggestedFixes(fixCount: number) { const id = this.selectedFixIdx; return html` @@ -324,10 +389,12 @@ let res: FilePathToDiffInfoMap | undefined; try { if (fixSuggestion.fix_id === PROVIDED_FIX_ID) { + const currentReplacement = + fixSuggestion.replacements[this.selectedReplacementIdx]; res = await this.restApiService.getFixPreview( this.changeNum, this.patchNum, - fixSuggestion.replacements + [currentReplacement] ); } else { // TODO(b/227463363) Remove once Robot Comments are deprecated. @@ -386,6 +453,25 @@ } } + private onPrevReplacementClick(e: Event) { + if (e) e.stopPropagation(); + if (this.selectedReplacementIdx >= 1) { + this.selectedReplacementIdx -= 1; + this.fetchFixPreview(this.currentFix!); + } + } + + private onNextReplacementClick(e: Event) { + if (e) e.stopPropagation(); + if ( + this.currentFix?.replacements && + this.selectedReplacementIdx < this.currentFix.replacements.length - 1 + ) { + this.selectedReplacementIdx += 1; + this.fetchFixPreview(this.currentFix); + } + } + private close(fixApplied: boolean) { this.currentFix = undefined; this.currentPreviews = []; @@ -399,13 +485,27 @@ private computeTooltip() { if (!this.change || !this.patchNum) return ''; if (this.isChangeMerged) return 'Change is already merged'; + if (this.isChangeAbandoned) return 'Change is abandoned'; if (this.isApplyFixLoading) return 'Fix is still loading ...'; + if (!this.loggedIn) return 'You must be logged in to apply a fix'; + if ( + this.currentPreviews[0]?.filepath === SpecialFilePath.COMMIT_MESSAGE && + this.patchNum !== this.latestPatchNum + ) + return 'You cannot apply a commit message edit from a previous patch set'; return ''; } private computeDisableApplyFixButton() { if (!this.change || !this.patchNum) return true; if (this.isChangeMerged) return true; + if (this.isChangeAbandoned) return true; + if (!this.loggedIn) return true; + if ( + this.currentPreviews[0]?.filepath === SpecialFilePath.COMMIT_MESSAGE && + this.patchNum !== this.latestPatchNum + ) + return true; return this.isApplyFixLoading; } @@ -419,20 +519,55 @@ } this.isApplyFixLoading = true; this.reporting.time(Timing.APPLY_FIX_LOAD); - let res; + let res: Response | undefined = undefined; + // Similar to gr-suggestion-diff-preview.ts:applyFix() if (this.fixSuggestions?.[0].fix_id === PROVIDED_FIX_ID) { - res = await this.restApiService.applyFixSuggestion( - changeNum, - patchNum, - this.fixSuggestions[0].replacements, - this.latestPatchNum - ); + let errorText = ''; + let status = ''; + try { + const currentReplacement = + this.fixSuggestions[0].replacements[this.selectedReplacementIdx]; + res = await this.restApiService.applyFixSuggestion( + changeNum, + patchNum, + [currentReplacement], + this.latestPatchNum + ); + } catch (error) { + if (error instanceof Error) { + errorText = error.message; + status = errorText.match(/\b\d{3}\b/)?.[0] || ''; + } + fireError(this, `Applying Fix failed.\n${errorText}`); + } finally { + this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, { + method: 'apply-fix-dialog', + description: this.fixSuggestions?.[0].description, + fileExtension: getFileExtension( + this.fixSuggestions?.[0]?.replacements?.[0].path ?? '' + ), + success: res?.ok ?? false, + status: res?.status ?? status, + errorText, + }); + } + // Robot Comments are deprecated } else { res = await this.restApiService.applyRobotFixSuggestion( changeNum, patchNum, this.currentFix.fix_id ); + this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, { + method: 'apply-fix-dialog', + description: this.fixSuggestions?.[0].description, + isRobotComment: true, + fileExtension: getFileExtension( + this.fixSuggestions?.[0].replacements?.[0].path ?? '' + ), + success: res.ok, + status: res.status, + }); } if (res?.ok) { this.getNavigation().setUrl( @@ -440,18 +575,12 @@ change, patchNum: EDIT, basePatchNum: patchNum as BasePatchSetNum, + forceReload: !this.hasEdit, }) ); this.close(true); } this.isApplyFixLoading = false; - 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-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts index d72a85e..35fb8c8 100644 --- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts +++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -233,6 +233,7 @@ 'applyRobotFixSuggestion' ).returns(Promise.resolve(new Response(null, {status: 200}))); element.currentFix = createFixSuggestionInfo('123'); + element.hasEdit = true; const closeFixPreviewEventSpy = sinon.spy(); element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts index 6dfa972..1805c0c 100644 --- a/polygerrit-ui/app/elements/gr-app-element.ts +++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -26,7 +26,6 @@ import './settings/gr-cla-view/gr-cla-view'; import './settings/gr-registration-dialog/gr-registration-dialog'; import './settings/gr-settings-view/gr-settings-view'; -import './core/gr-notifications-prompt/gr-notifications-prompt'; import {navigationToken} from './core/gr-navigation/gr-navigation'; import {getAppContext} from '../services/app-context'; import {routerToken} from './core/gr-router/gr-router'; @@ -276,7 +275,7 @@ modalStyles, css` :host { - background-color: var(--background-color-tertiary); + background-color: var(--background-color-secondary); display: flex; flex-direction: column; min-height: 100%; @@ -384,7 +383,6 @@ </main> ${this.renderFooter()} ${this.renderKeyboardShortcutsDialog()} ${this.renderRegistrationDialog()} - <gr-notifications-prompt></gr-notifications-prompt> <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator> <gr-error-manager id="errorManager"></gr-error-manager> <gr-plugin-host id="plugins"></gr-plugin-host>
diff --git a/polygerrit-ui/app/elements/gr-app-element_test.ts b/polygerrit-ui/app/elements/gr-app-element_test.ts index 6f2ea7e..736fc53 100644 --- a/polygerrit-ui/app/elements/gr-app-element_test.ts +++ b/polygerrit-ui/app/elements/gr-app-element_test.ts
@@ -51,7 +51,6 @@ <gr-endpoint-decorator name="footer-right"> </gr-endpoint-decorator> </div> </footer> - <gr-notifications-prompt> </gr-notifications-prompt> <gr-endpoint-decorator name="plugin-overlay"> </gr-endpoint-decorator> <gr-error-manager id="errorManager"> </gr-error-manager> <gr-plugin-host id="plugins"> </gr-plugin-host>
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts index 5d6c536..f2b1ecd 100644 --- a/polygerrit-ui/app/elements/lit/shortcut-controller.ts +++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -25,10 +25,7 @@ type Cleanup = () => void; export class ShortcutController implements ReactiveController { - private readonly getShortcutsService = resolve( - this.host, - shortcutsServiceToken - ); + private readonly getShortcutsService; private readonly listenersLocal: ShortcutListener[] = []; @@ -39,6 +36,7 @@ private cleanups: Cleanup[] = []; constructor(private readonly host: ReactiveControllerHost & HTMLElement) { + this.getShortcutsService = resolve(this.host, shortcutsServiceToken); host.addController(this); }
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 a65c8a4..3c311f2 100644 --- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts +++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -436,8 +436,6 @@ // When the experiment is over, move this back to render(), // removing this function. private renderBrowserNotifications() { - if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) - return nothing; if ( !this.flagsService.isEnabled( KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts index 818af06..126fc02 100644 --- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts +++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
@@ -163,6 +163,27 @@ </gr-select> </span> </section> + <section id="allowBrowserNotificationsSection"> + <div class="title"> + <label for="allowBrowserNotifications"> + Allow browser notifications + </label> + <a + href="/Documentation/user-attention-set.html#_browser_notifications" + rel="noopener noreferrer" + target="_blank" + > + <gr-icon icon="help" title="read documentation"></gr-icon> + </a> + </div> + <span class="value"> + <input + checked="" + id="allowBrowserNotifications" + type="checkbox" + /> + </span> + </section> <section> <label class="title" for="relativeDateInChangeTable"> Show Relative Dates In Changes Table
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 d4d0ad1..714ac43 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
@@ -429,7 +429,7 @@ </gr-dialog> </dialog> </fieldset> - <gr-preferences id="preferences"></gr-preferences> + <gr-preferences id="Preferences"></gr-preferences> <h2 id="DiffPreferences" class=${this.computeHeaderClass(this.diffPrefsChanged)}
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 ec8a0e2..907f145 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
@@ -189,7 +189,7 @@ </gr-dialog> </dialog> </fieldset> - <gr-preferences id="preferences"> </gr-preferences> + <gr-preferences id="Preferences"> </gr-preferences> <h2 id="DiffPreferences">Diff Preferences</h2> <fieldset id="diffPreferences"> <gr-diff-preferences id="diffPrefs"> </gr-diff-preferences>
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 893a6b2..9b3c2d1 100644 --- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts +++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -39,7 +39,6 @@ import { convertToCommentInput, createUserFixSuggestion, - getContentInCommentRange, getUserSuggestion, hasUserSuggestion, id, @@ -64,11 +63,7 @@ import {Subject} from 'rxjs'; import {debounceTime} from 'rxjs/operators'; import {changeModelToken} from '../../../models/change/change-model'; -import { - ChangeInfo, - FixSuggestionInfo, - isBase64FileContent, -} from '../../../api/rest-api'; +import {ChangeInfo, FixSuggestionInfo} from '../../../api/rest-api'; import {createDiffUrl} from '../../../models/views/change'; import {userModelToken} from '../../../models/user/user-model'; import {modalStyles} from '../../../styles/gr-modal-styles'; @@ -90,8 +85,11 @@ import {getFileExtension} from '../../../utils/file-util'; import {storageServiceToken} from '../../../services/storage/gr-storage_impl'; import {deepEqual} from '../../../utils/deep-util'; -import {GrSuggestionDiffPreview} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview'; -import {noAwait, waitUntil} from '../../../utils/async-util'; +import { + GrSuggestionDiffPreview, + PreviewLoadedDetail, +} from '../gr-suggestion-diff-preview/gr-suggestion-diff-preview'; +import {waitUntil} from '../../../utils/async-util'; import { AutocompleteCache, AutocompletionContext, @@ -1177,24 +1175,15 @@ .fixSuggestionInfo=${this.generatedFixSuggestion} .patchSet=${this.comment?.patch_set} .commentId=${this.comment?.id} + @preview-loaded=${(event: CustomEvent<PreviewLoadedDetail>) => + (this.previewedGeneratedFixSuggestion = + event.detail.previewLoadedFor)} ></gr-suggestion-diff-preview>`; } else { return nothing; } } - // visible for testing - async waitPreviewForGeneratedSuggestion() { - const generatedFixSuggestion = this.generatedFixSuggestion; - if (!generatedFixSuggestion) return; - await waitUntil( - () => - !!this.suggestionDiffPreview?.previewed && - this.suggestionDiffPreview?.previewLoadedFor === generatedFixSuggestion - ); - this.previewedGeneratedFixSuggestion = generatedFixSuggestion; - } - private renderGenerateSuggestEditButton() { if (!this.showGeneratedSuggestion()) { return nothing; @@ -1312,14 +1301,13 @@ response: suggestionResponse.responseCode, numSuggestions: suggestionResponse.fix_suggestions.length, fileExtension: getFileExtension(this.comment.path ?? ''), - logProbability: suggestionResponse.fix_suggestions?.[0].log_probability, + logProbability: suggestionResponse.fix_suggestions?.[0]?.log_probability, }); const suggestion = suggestionResponse.fix_suggestions?.[0]; if (!suggestion?.replacements || suggestion.replacements.length === 0) { return; } this.generatedFixSuggestion = suggestion; - noAwait(this.waitPreviewForGeneratedSuggestion()); try { await waitUntil(() => this.getFixSuggestions() !== undefined); @@ -1556,9 +1544,6 @@ assert(isDraft(this.comment), 'only drafts are editable'); if (this.editing) return; this.editing = true; - // For quickly opening and closing the comment, the suggestion diff preview - // might not have time to load and preview. - noAwait(this.waitPreviewForGeneratedSuggestion()); } // TODO: Move this out of gr-comment. gr-comment should not have a comments @@ -1582,7 +1567,13 @@ assert(!!replacement, 'malformed user suggestion'); let commentedCode = this.commentedText; if (!commentedCode) { - commentedCode = await this.getCommentedCode(); + commentedCode = await this.commentModel.getCommentedCode( + this.comment, + this.changeNum + ); + if (!commentedCode) { + throw new Error('unable to create preview fix event'); + } } return { @@ -1704,31 +1695,16 @@ async createSuggestEdit(e: MouseEvent) { e.stopPropagation(); - const line = await this.getCommentedCode(); + const line = await this.commentModel.getCommentedCode( + this.comment, + this.changeNum + ); const addNewLine = this.messageText.length !== 0; this.messageText += `${ addNewLine ? '\n' : '' }${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`; } - // TODO(milutin): Remove once feature flag is rollout and use only model - async getCommentedCode() { - assertIsDefined(this.comment, 'comment'); - assertIsDefined(this.changeNum, 'changeNum'); - const file = await this.restApiService.getFileContent( - this.changeNum, - this.comment.path!, - this.comment.patch_set! - ); - assert( - !!file && isBase64FileContent(file) && !!file.content, - 'file content for comment not found' - ); - const line = getContentInCommentRange(file.content, this.comment); - assert(!!line, 'file content for comment not found'); - return line; - } - // private, but visible for testing cancel() { assertIsDefined(this.comment, 'comment');
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 377e003..84eee96 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
@@ -1155,7 +1155,14 @@ suggestionDiffPreview.previewed = true; suggestionDiffPreview.previewLoadedFor = generatedFixSuggestion; await element.updateComplete; - await element.waitPreviewForGeneratedSuggestion(); + // trigger event preview-loaded on suggestionDiffPreview with detail + suggestionDiffPreview.dispatchEvent( + new CustomEvent('preview-loaded', { + bubbles: true, + detail: {previewLoadedFor: generatedFixSuggestion}, + }) + ); + // await element.waitPreviewForGeneratedSuggestion(); await element.updateComplete; element.save(); await element.updateComplete;
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 eeccda6..e368dc9 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
@@ -45,6 +45,11 @@ import {formStyles} from '../../../styles/form-styles'; import {changeViewModelToken} from '../../../models/views/change'; import {SpecialFilePath} from '../../../constants/constants'; +import { + detectFormattingErrorsInString, + formatCommitMessageString, + FormattingError, +} from '../../../utils/commit-message-formatter-util'; const RESTORED_MESSAGE = 'Content restored from a previous edit.'; const STORAGE_DEBOUNCE_INTERVAL_MS = 400; @@ -210,6 +215,7 @@ .editButtons { display: flex; justify-content: space-between; + align-items: center; } .show-all-container { background-color: var(--view-background-color); @@ -255,6 +261,12 @@ gr-button { padding: var(--spacing-xs); } + .format-button { + margin-right: var(--spacing-l); + } + gr-icon.warning { + color: var(--warning-foreground); + } `, ]; } @@ -308,6 +320,13 @@ if (!this.editing && !this.commitCollapsible && this.hideEditCommitMessage) return nothing; + let formatDisabled = true; + let formattedErrors: FormattingError[] = []; + if (this.newContent) { + formatDisabled = + formatCommitMessageString(this.newContent) === this.newContent; + formattedErrors = detectFormattingErrorsInString(this.newContent); + } return html` <div class="show-all-container font-normal"> ${when( @@ -362,6 +381,22 @@ <span></div>` )} <div class="editButtons"> + ${when( + formattedErrors.length > 0, + () => html`<gr-tooltip-content + .title=${formattedErrors + .map(e => `${e.line ? `Line ${e.line}: ` : ''}${e.message}`) + .join('\n')} + ><gr-icon class="warning" icon="warning" filled></gr-icon + ></gr-tooltip-content>` + )} + <gr-button + link + class="format-button" + @click=${this.handleFormat} + ?disabled=${formatDisabled} + >Format</gr-button + > <gr-button link class="cancel-button" @@ -548,6 +583,11 @@ }); } + handleFormat(e: Event) { + e.preventDefault(); + this.newContent = formatCommitMessageString(this.newContent); + } + private setCommitterEmail(e: CustomEvent<{value: string}>) { this.committerEmail = e.detail.value; }
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 a97a0de..d3fd0fb 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
@@ -61,6 +61,8 @@ @state() isChangeMerged = false; + @state() isChangeAbandoned = false; + private readonly getConfigModel = resolve(this, configModelToken); private readonly getChangeModel = resolve(this, changeModelToken); @@ -95,6 +97,11 @@ () => this.getChangeModel().status$, status => (this.isChangeMerged = status === ChangeStatus.MERGED) ); + subscribe( + this, + () => this.getChangeModel().status$, + status => (this.isChangeAbandoned = status === ChangeStatus.ABANDONED) + ); } override connectedCallback() { @@ -299,12 +306,14 @@ private isApplyEditDisabled() { if (this.comment?.patch_set === undefined) return true; if (this.isChangeMerged) return true; + if (this.isChangeAbandoned) return true; return !this.previewLoaded; } private computeApplyEditTooltip() { if (this.comment?.patch_set === undefined) return ''; if (this.isChangeMerged) return 'Change is already merged'; + if (this.isChangeAbandoned) return 'Change is abandoned'; if (!this.previewLoaded) return 'Fix is still loading ...'; return ''; }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts index b09502a..2ab8bc2 100644 --- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts +++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -65,6 +65,16 @@ return !cancelSubmit; } + handlePublishEdit(change: ChangeInfo, revision?: RevisionInfo | null) { + for (const cb of this._getEventCallbacks(EventType.PUBLISH_EDIT)) { + try { + cb(change, revision); + } catch (err: unknown) { + this.reportError(err, EventType.PUBLISH_EDIT); + } + } + } + /** For testing only. */ _removeEventCallbacks() { for (const type of Object.values(EventType)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts index dafa434..aa830d8 100644 --- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts +++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -45,6 +45,7 @@ revertSubmissionMsg: string, origMsg: string ): string; + handlePublishEdit(change: ChangeInfo, revision?: RevisionInfo | null): void; handleShowChange(detail: ShowChangeDetail): Promise<void>; handleShowRevisionActions(detail: ShowRevisionActionsDetail): void; handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
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 7c33764..7ce0b3d 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,13 +30,14 @@ import {DiffPreview} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog'; import {userModelToken} from '../../../models/user/user-model'; import {navigationToken} from '../../core/gr-navigation/gr-navigation'; -import {fire} from '../../../utils/event-util'; +import {fire, fireError} from '../../../utils/event-util'; import {Timing} from '../../../constants/reporting'; import {createChangeUrl} from '../../../models/views/change'; import {getFileExtension} from '../../../utils/file-util'; +import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; export interface PreviewLoadedDetail { - previewLoadedFor?: string | FixSuggestionInfo; + previewLoadedFor?: FixSuggestionInfo; } /** * Diff preview for @@ -275,20 +276,36 @@ if (!changeNum || !basePatchNum || !fixSuggestion) return; this.reporting.time(Timing.APPLY_FIX_LOAD); - const res = await this.restApiService.applyFixSuggestion( - changeNum, - basePatchNum, - fixSuggestion.replacements, - this.latestPatchNum - ); - this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, { - method: '1-click', - description: fixSuggestion.description, - fileExtension: getFileExtension( - fixSuggestion?.replacements?.[0].path ?? '' - ), - commentId: this.commentId ?? '', - }); + let res: Response | undefined = undefined; + let errorText = ''; + let status = ''; + try { + res = await this.restApiService.applyFixSuggestion( + changeNum, + basePatchNum, + fixSuggestion.replacements, + this.latestPatchNum, + throwingErrorCallback + ); + } catch (error) { + if (error instanceof Error) { + errorText = error.message; + status = errorText.match(/\b\d{3}\b/)?.[0] || ''; + } + fireError(this, `Applying Fix failed.\n${errorText}`); + } finally { + this.reporting.timeEnd(Timing.APPLY_FIX_LOAD, { + method: '1-click', + description: fixSuggestion.description, + fileExtension: getFileExtension( + fixSuggestion?.replacements?.[0].path ?? '' + ), + commentId: this.commentId ?? '', + success: res?.ok ?? false, + status: res?.status ?? status, + errorText, + }); + } if (res?.ok) { this.getNavigation().setUrl( createChangeUrl({
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts index 0a8d026..95c6a2a 100644 --- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts +++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -20,6 +20,8 @@ import {commentModelToken} from '../gr-comment-model/gr-comment-model'; import {createUserFixSuggestion} from '../../../utils/comment-util'; import {ChangeStatus} from '../../../api/rest-api'; +import {userModelToken} from '../../../models/user/user-model'; +import {SpecialFilePath} from '../../../constants/constants'; declare global { interface HTMLElementEventMap { @@ -52,12 +54,18 @@ @state() isChangeMerged = false; + @state() isChangeAbandoned = false; + + @state() loggedIn = false; + private readonly getConfigModel = resolve(this, configModelToken); private readonly getChangeModel = resolve(this, changeModelToken); private readonly getCommentModel = resolve(this, commentModelToken); + private readonly getUserModel = resolve(this, userModelToken); + constructor() { super(); subscribe( @@ -85,6 +93,16 @@ () => this.getChangeModel().status$, status => (this.isChangeMerged = status === ChangeStatus.MERGED) ); + subscribe( + this, + () => this.getChangeModel().status$, + status => (this.isChangeAbandoned = status === ChangeStatus.ABANDONED) + ); + subscribe( + this, + () => this.getUserModel().loggedIn$, + loggedIn => (this.loggedIn = loggedIn) + ); } static override get styles() { @@ -190,13 +208,27 @@ private isApplyEditDisabled() { if (this.comment?.patch_set === undefined) return true; if (this.isChangeMerged) return true; + if (this.isChangeAbandoned) return true; + if (!this.loggedIn) return true; + if ( + this.comment?.path === SpecialFilePath.COMMIT_MESSAGE && + this.comment?.patch_set !== this.latestPatchNum + ) + return true; return !this.previewLoaded; } private computeApplyEditTooltip() { if (this.comment?.patch_set === undefined) return ''; if (this.isChangeMerged) return 'Change is already merged'; + if (this.isChangeAbandoned) return 'Change is abandoned'; if (!this.previewLoaded) return 'Fix is still loading ...'; + if (!this.loggedIn) return 'You must be logged in to apply a fix'; + if ( + this.comment?.path === SpecialFilePath.COMMIT_MESSAGE && + this.comment?.patch_set !== this.latestPatchNum + ) + return 'You cannot apply a commit message edit from a previous patch set'; return ''; } }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts index f403ef9..7cc0ebd 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts
@@ -11,14 +11,15 @@ querySelectorAll, } from '../../../utils/dom-util'; import {DiffInfo} from '../../../types/diff'; -import {Side} from '../../../constants/constants'; +import {Side, TextRange} from '../../../constants/constants'; import { getLineElByChild, getSide, getSideByLineEl, isThreadEl, } from '../gr-diff/gr-diff-utils'; -import {getContentFromDiff} from '../../../utils/diff-util'; +import {getDiffLines, getContentFromDiff} from '../../../utils/diff-util'; +import {fire} from '../../../utils/event-util'; /** * Possible CSS classes indicating the state of selection. Dynamically added/ @@ -119,6 +120,14 @@ if (text && e.clipboardData) { e.clipboardData.setData('Text', text); e.preventDefault(); + const selectionInfo = this.getSelectionInfo(side); + if (selectionInfo) { + fire(this.diffTable, 'copy-info', { + side, + range: selectionInfo, + length: text.length, + }); + } } }; @@ -150,9 +159,24 @@ */ getSelectedText(side: Side) { if (!this.diff) return ''; + const selectionInfo = this.getSelectionInfo(side); + if (!selectionInfo) return ''; + + return getContentFromDiff( + this.diff, + selectionInfo.start_line, + selectionInfo.start_column, + selectionInfo.end_line, + selectionInfo.end_column, + side + ); + } + + private getSelectionInfo(side: Side): TextRange | undefined { + if (!this.diff) return undefined; const sel = this.getSelection(); if (!sel || sel.rangeCount !== 1) { - return ''; // No multi-select support yet. + return undefined; // No multi-select support yet. } const range = normalize(sel.getRangeAt(0)); const startLineEl = getLineElByChild(range.startContainer); @@ -176,14 +200,16 @@ const endLineDataValue = endLineEl.getAttribute('data-value'); if (endLineDataValue) endLineNum = Number(endLineDataValue); } - - return getContentFromDiff( - this.diff, - startLineNum, - range.startOffset, - endLineNum, - range.endOffset, - side - ); + // If endLineNum is still undefined, it means that the selection ends at the + // end of the file. + if (endLineNum === undefined) { + endLineNum = getDiffLines(this.diff, side).length; + } + return { + start_line: startLineNum, + end_line: endLineNum, + start_column: range.startOffset, + end_column: range.endOffset, + }; } }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts index dd39aa5..b374596 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts
@@ -194,6 +194,27 @@ assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga'); }); + test('copies content correctly when end is not a text node', () => { + const unknownElement = document.createElement('div'); + unknownElement.appendChild(document.createTextNode('Should not be copied')); + + diffTable.classList.add('selected-left'); + diffTable.classList.remove('selected-right'); + diffTable.appendChild(unknownElement); + + const selection = document.getSelection(); + if (selection === null) assert.fail('no selection'); + selection.removeAllRanges(); + const range = document.createRange(); + const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text'); + range.setStart(firstTextNode(texts[0]), 3); + range.setEnd(unknownElement, 0); + + selection.addRange(range); + + assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\n'); + }); + test('defers to default behavior for textarea', () => { diffTable.classList.add('selected-left'); diffTable.classList.remove('selected-right');
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 07aaf55..b5d0f15 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -48,6 +48,7 @@ LineNumber, ContentLoadNeededEventDetail, DiffContextExpandedExternalDetail, + CopyInfoEventDetail, } from '../../../api/diff'; import {isSafari} from '../../../utils/dom-util'; import {assertIsDefined} from '../../../utils/common-util'; @@ -1049,5 +1050,6 @@ 'diff-context-expanded': CustomEvent<DiffContextExpandedExternalDetail>; 'diff-context-expanded-internal-new': CustomEvent<DiffContextExpandedEventDetail>; 'content-load-needed': CustomEvent<ContentLoadNeededEventDetail>; + 'copy-info': CustomEvent<CopyInfoEventDetail>; } }
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts index 42f2452..5ffc4ce 100644 --- a/polygerrit-ui/app/embed/gr-textarea.ts +++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -274,6 +274,7 @@ contenteditable=${this.contentEditableAttributeValue} dir="ltr" role="textbox" + spellcheck="false" @input=${this.onInput} @focus=${this.onFocus} @blur=${this.onBlur}
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts index 86c8d62..2186647 100644 --- a/polygerrit-ui/app/models/browser/browser-model.ts +++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -33,17 +33,19 @@ state.screenWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX ); - readonly diffViewMode$: Observable<DiffViewMode> = select( - combineLatest([ - this.isScreenTooSmall$, - this.userModel.preferenceDiffViewMode$, - ]), - ([isScreenTooSmall, preferenceDiffViewMode]) => - isScreenTooSmall ? DiffViewMode.UNIFIED : preferenceDiffViewMode - ); + readonly diffViewMode$: Observable<DiffViewMode>; constructor(readonly userModel: UserModel) { super(initialState); + + this.diffViewMode$ = select( + combineLatest([ + this.isScreenTooSmall$, + this.userModel.preferenceDiffViewMode$, + ]), + ([isScreenTooSmall, preferenceDiffViewMode]) => + isScreenTooSmall ? DiffViewMode.UNIFIED : preferenceDiffViewMode + ); } /* Observe the screen width so that the app can react to changes to it */
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts index a070944..f90bcb7 100644 --- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts +++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -25,7 +25,7 @@ ReviewResult, } from '../../types/common'; import {getUserId} from '../../utils/account-util'; -import {getChangeNumber} from '../../utils/change-util'; +import {getChangeNumber, isChangeInfo} from '../../utils/change-util'; import {deepEqual} from '../../utils/deep-util'; import {throwingErrorCallback} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; import {assert} from '../../utils/common-util'; @@ -258,9 +258,13 @@ if (changes.length === 0) { return; } + + // Don't ask for SUBMIT_REQUIREMENTS if it is already available. + const needsSubmitRequirements = !this.hasSubmitRequirements(changes[0]); const changeDetails = await this.restApiService.getDetailedChangesWithActions( - changes.map(c => getChangeNumber(c)) + changes.map(c => getChangeNumber(c)), + needsSubmitRequirements ); currentState = this.getState(); // Return early if sync has been called again since starting the load. @@ -269,7 +273,13 @@ } const allDetailedChanges: Map<NumericChangeId, ChangeInfo> = new Map(); for (const detailedChange of changeDetails ?? []) { - allDetailedChanges.set(detailedChange._number, detailedChange); + allDetailedChanges.set(detailedChange._number, { + ...detailedChange, + submit_requirements: needsSubmitRequirements + ? detailedChange.submit_requirements + : (basicChanges.get(detailedChange._number) as ChangeInfo) + ?.submit_requirements, + }); } this.setState({ ...currentState, @@ -278,6 +288,12 @@ }); } + private hasSubmitRequirements( + change: ChangeInfo | RelatedChangeAndCommitInfo + ): boolean { + return isChangeInfo(change) && change.submit_requirements !== undefined; + } + private getNewReviewersToChange( change: ChangeInfo, state: ReviewerState,
diff --git a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts index 8f2c751..fc0430d 100644 --- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts +++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model_test.ts
@@ -413,8 +413,16 @@ ); assert.deepEqual(updatedChanges, [ - {...change1, hashtags: [existingHashtag, newHashtag]}, - {...change2, hashtags: [existingHashtag, newHashtag]}, + { + ...change1, + hashtags: [existingHashtag, newHashtag], + submit_requirements: undefined, + }, + { + ...change2, + hashtags: [existingHashtag, newHashtag], + submit_requirements: undefined, + }, ]); }); });
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts index dd292e6..1119389 100644 --- a/polygerrit-ui/app/models/change/change-model.ts +++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -269,8 +269,57 @@ * * Note that this selector can emit without the change being available! */ - public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> = - select( + public readonly patchNum$: Observable<RevisionPatchSetNum | undefined>; + + /** The user can enter edit mode without an `EDIT` patchset existing yet. */ + public readonly editMode$; + + /** + * Emits the base patchset number. This is identical to the + * `viewModel.basePatchNum$`, but has some special logic for merges. + * + * Note that this selector can emit without the change being available! + */ + public readonly basePatchNum$: Observable<BasePatchSetNum>; + + private selectRevision( + revisionNum$: Observable<RevisionPatchSetNum | BasePatchSetNum | undefined> + ) { + return select( + combineLatest([this.revisions$, revisionNum$]), + ([revisions, patchNum]) => { + if (!revisions || !patchNum || patchNum === PARENT) return undefined; + return Object.values(revisions).find( + revision => revision._number === patchNum + ); + } + ); + } + + public readonly revision$; + + public readonly baseRevision$; + + public readonly latestRevision$; + + public readonly latestRevisionWithEdit$; + + public readonly isOwner$: Observable<boolean>; + + public readonly messages$; + + public readonly revertingChangeIds$; + + constructor( + private readonly navigation: NavigationService, + private readonly viewModel: ChangeViewModel, + private readonly restApiService: RestApiService, + private readonly userModel: UserModel, + private readonly pluginLoader: PluginLoader, + private readonly reporting: ReportingService + ) { + super(initialState); + this.patchNum$ = select( combineLatest([ this.viewModel.state$, this.state$, @@ -290,26 +339,11 @@ ([viewModelState, _changeState, latestPatchN]) => viewModelState?.patchNum || latestPatchN ); - - /** The user can enter edit mode without an `EDIT` patchset existing yet. */ - public readonly editMode$ = select( - combineLatest([this.viewModel.edit$, this.patchNum$]), - ([edit, patchNum]) => !!edit || patchNum === EDIT - ); - - /** - * Emits the base patchset number. This is identical to the - * `viewModel.basePatchNum$`, but has some special logic for merges. - * - * Note that this selector can emit without the change being available! - */ - public readonly basePatchNum$: Observable<BasePatchSetNum> = - /** - * If you depend on both, view model and change state, then you want to - * filter out inconsistent state, e.g. view model changeNum already - * updated, change not yet reset to undefined. - */ - select( + this.editMode$ = select( + combineLatest([this.viewModel.edit$, this.patchNum$]), + ([edit, patchNum]) => !!edit || patchNum === EDIT + ); + this.basePatchNum$ = select( combineLatest([ this.viewModel.state$, this.state$, @@ -330,53 +364,22 @@ ([_, viewModelBasePatchNum, patchNum, change, preferences]) => computeBase(viewModelBasePatchNum, patchNum, change, preferences) ); - - private selectRevision( - revisionNum$: Observable<RevisionPatchSetNum | BasePatchSetNum | undefined> - ) { - return select( - combineLatest([this.revisions$, revisionNum$]), - ([revisions, patchNum]) => { - if (!revisions || !patchNum || patchNum === PARENT) return undefined; - return Object.values(revisions).find( - revision => revision._number === patchNum - ); - } + this.revision$ = this.selectRevision(this.patchNum$); + this.baseRevision$ = this.selectRevision(this.basePatchNum$) as Observable< + RevisionInfo | undefined + >; + this.latestRevision$ = this.selectRevision(this.latestPatchNum$); + this.latestRevisionWithEdit$ = this.selectRevision( + this.latestPatchNumWithEdit$ ); - } - - public readonly revision$ = this.selectRevision(this.patchNum$); - - public readonly baseRevision$ = this.selectRevision( - this.basePatchNum$ - ) as Observable<RevisionInfo | undefined>; - - public readonly latestRevision$ = this.selectRevision(this.latestPatchNum$); - - public readonly latestRevisionWithEdit$ = this.selectRevision( - this.latestPatchNumWithEdit$ - ); - - public readonly isOwner$: Observable<boolean> = select( - combineLatest([this.change$, this.userModel.account$]), - ([change, account]) => isOwner(change, account) - ); - - public readonly messages$ = select(this.change$, change => change?.messages); - - public readonly revertingChangeIds$ = select(this.messages$, messages => - getRevertCreatedChangeIds(messages ?? []) - ); - - constructor( - private readonly navigation: NavigationService, - private readonly viewModel: ChangeViewModel, - private readonly restApiService: RestApiService, - private readonly userModel: UserModel, - private readonly pluginLoader: PluginLoader, - private readonly reporting: ReportingService - ) { - super(initialState); + this.isOwner$ = select( + combineLatest([this.change$, this.userModel.account$]), + ([change, account]) => isOwner(change, account) + ); + this.messages$ = select(this.change$, change => change?.messages); + this.revertingChangeIds$ = select(this.messages$, messages => + getRevertCreatedChangeIds(messages ?? []) + ); this.subscriptions = [ this.loadChange(), this.loadMergeable(),
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts index 9e8718d..eff6f55 100644 --- a/polygerrit-ui/app/models/change/files-model.ts +++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -130,20 +130,11 @@ * all unmodified files that have comments with * `status: FileInfoStatus.UNMODIFIED`. */ - public readonly filesIncludingUnmodified$ = select( - combineLatest([this.files$, this.commentsModel.commentedPaths$]), - ([files, commentedPaths]) => addUnmodified(files, commentedPaths) - ); + public readonly filesIncludingUnmodified$; - public readonly filesLeftBase$ = select( - this.state$, - state => state.filesLeftBase - ); + public readonly filesLeftBase$; - public readonly filesRightBase$ = select( - this.state$, - state => state.filesRightBase - ); + public readonly filesRightBase$; constructor( readonly changeModel: ChangeModel, @@ -152,6 +143,14 @@ private readonly reporting: ReportingService ) { super(initialState); + + this.filesIncludingUnmodified$ = select( + combineLatest([this.files$, this.commentsModel.commentedPaths$]), + ([files, commentedPaths]) => addUnmodified(files, commentedPaths) + ); + this.filesLeftBase$ = select(this.state$, state => state.filesLeftBase); + this.filesRightBase$ = select(this.state$, state => state.filesRightBase); + this.subscriptions = [ this.reportChangeDataStart(), this.reportChangeDataEnd(),
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts index 9c0d60f..55f0b75 100644 --- a/polygerrit-ui/app/models/change/related-changes-model.ts +++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -99,16 +99,7 @@ * is a relation chain, and the change id is not the last item of the * relation chain, then there is a parent. */ - public readonly hasParent$ = select( - combineLatest([this.changeModel.change$, this.relatedChanges$]), - ([change, relatedChanges]) => { - if (!change) return undefined; - if (relatedChanges === undefined) return undefined; - if (relatedChanges.length === 0) return false; - const lastChangeId = relatedChanges[relatedChanges.length - 1].change_id; - return lastChangeId !== change.change_id; - } - ); + public readonly hasParent$; constructor( readonly changeModel: ChangeModel, @@ -116,6 +107,19 @@ readonly restApiService: RestApiService ) { super(initialState); + + this.hasParent$ = select( + combineLatest([this.changeModel.change$, this.relatedChanges$]), + ([change, relatedChanges]) => { + if (!change) return undefined; + if (relatedChanges === undefined) return undefined; + if (relatedChanges.length === 0) return false; + const lastChangeId = + relatedChanges[relatedChanges.length - 1].change_id; + return lastChangeId !== change.change_id; + } + ); + this.subscriptions = [ this.loadRelatedChanges(), this.loadSubmittedTogether(),
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts index f6fe242..2b187f4 100644 --- a/polygerrit-ui/app/models/checks/checks-fakes.ts +++ b/polygerrit-ui/app/models/checks/checks-fakes.ts
@@ -101,7 +101,7 @@ checkName: 'FAKE Super Check', startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000), finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000), - patchset: 3, + patchset: 1, labelName: 'Verified', isSingleAttempt: true, isLatestAttempt: true,
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts index 66a8bd3..77c65b8 100644 --- a/polygerrit-ui/app/models/checks/checks-model.ts +++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -245,178 +245,53 @@ private readonly visibilityChangeListener: () => void; - public checksSelectedPatchsetNumber$ = select( - this.changeViewModel.checksPatchset$, - ps => ps - ); + public checksSelectedPatchsetNumber$; - public checksSelectedAttemptNumber$ = select( - this.changeViewModel.attempt$, - attempt => attempt ?? LATEST_ATTEMPT - ); + public checksSelectedAttemptNumber$; - public runFilterRegexp$ = select( - this.changeViewModel.filter$, - filter => filter ?? '' - ); + public runFilterRegexp$; - public checksLatest$ = select(this.state$, state => state.pluginStateLatest); + public checksLatest$; - public checksSelected$ = select( - combineLatest([this.state$, this.changeViewModel.checksPatchset$]), - ([state, ps]) => { - const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST; - return this.getPluginState(state, checksPs); - } - ); + public checksSelected$; - public aPluginHasRegistered$ = select( - this.checksLatest$, - state => Object.keys(state).length > 0 - ); + public aPluginHasRegistered$; - private firstLoadCompleted$ = select(this.checksLatest$, state => { - const providers = Object.values(state); - if (providers.length === 0) return false; - if (providers.some(p => p.loading || p.firstTimeLoad)) return false; - return true; - }); + private firstLoadCompleted$; - public someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state => - Object.values(state).some( - provider => provider.loading && provider.firstTimeLoad - ) - ); + public someProvidersAreLoadingFirstTime$; - public someProvidersAreLoadingLatest$ = select(this.checksLatest$, state => - Object.values(state).some(providerState => providerState.loading) - ); + public someProvidersAreLoadingLatest$; - public someProvidersAreLoadingSelected$ = select( - this.checksSelected$, - state => Object.values(state).some(providerState => providerState.loading) - ); + public someProvidersAreLoadingSelected$; - public errorMessageLatest$ = select( - this.checksLatest$, + public errorMessageLatest$; - state => - Object.values(state).find( - providerState => providerState.errorMessage !== undefined - )?.errorMessage - ); + public errorMessagesLatest$; - public errorMessagesLatest$ = select(this.checksLatest$, state => { - const errorMessages: ErrorMessages = {}; - for (const providerState of Object.values(state)) { - if (providerState.errorMessage === undefined) continue; - errorMessages[providerState.pluginName] = providerState.errorMessage; - } - return errorMessages; - }); + public loginCallbackLatest$; - public loginCallbackLatest$ = select( - this.checksLatest$, - state => - Object.values(state).find( - providerState => providerState.loginCallback !== undefined - )?.loginCallback - ); + public topLevelActionsLatest$; - public topLevelActionsLatest$ = select(this.checksLatest$, state => - Object.values(state).reduce( - (allActions: Action[], providerState: ChecksProviderState) => [ - ...allActions, - ...providerState.actions, - ], - [] - ) - ); + public topLevelMessagesLatest$; - public topLevelMessagesLatest$ = select(this.checksLatest$, state => { - const messages = Object.values(state).map( - providerState => providerState.summaryMessage - ); - return messages.filter(m => !!m) as string[]; - }); + public topLevelActionsSelected$; - public topLevelActionsSelected$ = select(this.checksSelected$, state => - Object.values(state).reduce( - (allActions: Action[], providerState: ChecksProviderState) => [ - ...allActions, - ...providerState.actions, - ], - [] - ) - ); + public topLevelLinksSelected$; - public topLevelLinksSelected$ = select(this.checksSelected$, state => - Object.values(state).reduce( - (allLinks: Link[], providerState: ChecksProviderState) => [ - ...allLinks, - ...providerState.links, - ], - [] - ) - ); + public allRunsLatestPatchset$; - public allRunsLatestPatchset$ = select(this.checksLatest$, state => - Object.values(state).reduce( - (allRuns: CheckRun[], providerState: ChecksProviderState) => [ - ...allRuns, - ...providerState.runs, - ], - [] - ) - ); + public allRunsSelectedPatchset$; - public allRunsSelectedPatchset$ = select(this.checksSelected$, state => - Object.values(state).reduce( - (allRuns: CheckRun[], providerState: ChecksProviderState) => [ - ...allRuns, - ...providerState.runs, - ], - [] - ) - ); + public allRunsLatestPatchsetLatestAttempt$; - public allRunsLatestPatchsetLatestAttempt$ = select( - this.allRunsLatestPatchset$, - runs => runs.filter(run => run.isLatestAttempt) - ); + public checkToPluginMap$; - public checkToPluginMap$ = select(this.checksLatest$, state => { - const map = new Map<string, string>(); - for (const [pluginName, providerState] of Object.entries(state)) { - for (const run of providerState.runs) { - map.set(run.checkName, pluginName); - } - } - return map; - }); + public allResultsSelected$; - public allResultsSelected$ = select(this.checksSelected$, state => - Object.values(state) - .reduce(collectRunResults, []) - .filter(r => r !== undefined) - ); + public allResultsLatest$; - public allResultsLatest$ = select(this.checksLatest$, state => - Object.values(state) - .reduce(collectRunResults, []) - .filter(r => r !== undefined) - ); - - public allResults$ = select( - combineLatest([ - this.checksSelectedPatchsetNumber$, - this.changeModel.latestPatchNum$, - this.allResultsSelected$, - this.allResultsLatest$, - ]), - ([selectedPs, latestPs, selected, latest]) => - selectedPs && selectedPs !== latestPs ? [...selected, ...latest] : latest - ); + public allResults$; constructor( private readonly changeViewModel: ChangeViewModel, @@ -429,6 +304,159 @@ pluginStateSelected: {}, }); this.reporting.time(Timing.CHECKS_LOAD); + + this.checksSelectedPatchsetNumber$ = select( + this.changeViewModel.checksPatchset$, + ps => ps + ); + this.checksSelectedAttemptNumber$ = select( + this.changeViewModel.attempt$, + attempt => attempt ?? LATEST_ATTEMPT + ); + this.runFilterRegexp$ = select( + this.changeViewModel.filter$, + filter => filter ?? '' + ); + this.checksLatest$ = select(this.state$, state => state.pluginStateLatest); + this.checksSelected$ = select( + combineLatest([this.state$, this.changeViewModel.checksPatchset$]), + ([state, ps]) => { + const checksPs = ps ? ChecksPatchset.SELECTED : ChecksPatchset.LATEST; + return this.getPluginState(state, checksPs); + } + ); + this.aPluginHasRegistered$ = select( + this.checksLatest$, + state => Object.keys(state).length > 0 + ); + this.firstLoadCompleted$ = select(this.checksLatest$, state => { + const providers = Object.values(state); + if (providers.length === 0) return false; + if (providers.some(p => p.loading || p.firstTimeLoad)) return false; + return true; + }); + this.someProvidersAreLoadingFirstTime$ = select(this.checksLatest$, state => + Object.values(state).some( + provider => provider.loading && provider.firstTimeLoad + ) + ); + this.someProvidersAreLoadingLatest$ = select(this.checksLatest$, state => + Object.values(state).some(providerState => providerState.loading) + ); + this.someProvidersAreLoadingSelected$ = select( + this.checksSelected$, + state => Object.values(state).some(providerState => providerState.loading) + ); + this.errorMessageLatest$ = select( + this.checksLatest$, + + state => + Object.values(state).find( + providerState => providerState.errorMessage !== undefined + )?.errorMessage + ); + this.errorMessagesLatest$ = select(this.checksLatest$, state => { + const errorMessages: ErrorMessages = {}; + for (const providerState of Object.values(state)) { + if (providerState.errorMessage === undefined) continue; + errorMessages[providerState.pluginName] = providerState.errorMessage; + } + return errorMessages; + }); + this.loginCallbackLatest$ = select( + this.checksLatest$, + state => + Object.values(state).find( + providerState => providerState.loginCallback !== undefined + )?.loginCallback + ); + this.topLevelActionsLatest$ = select(this.checksLatest$, state => + Object.values(state).reduce( + (allActions: Action[], providerState: ChecksProviderState) => [ + ...allActions, + ...providerState.actions, + ], + [] + ) + ); + this.topLevelMessagesLatest$ = select(this.checksLatest$, state => { + const messages = Object.values(state).map( + providerState => providerState.summaryMessage + ); + return messages.filter(m => !!m) as string[]; + }); + this.topLevelActionsSelected$ = select(this.checksSelected$, state => + Object.values(state).reduce( + (allActions: Action[], providerState: ChecksProviderState) => [ + ...allActions, + ...providerState.actions, + ], + [] + ) + ); + this.topLevelLinksSelected$ = select(this.checksSelected$, state => + Object.values(state).reduce( + (allLinks: Link[], providerState: ChecksProviderState) => [ + ...allLinks, + ...providerState.links, + ], + [] + ) + ); + this.allRunsLatestPatchset$ = select(this.checksLatest$, state => + Object.values(state).reduce( + (allRuns: CheckRun[], providerState: ChecksProviderState) => [ + ...allRuns, + ...providerState.runs, + ], + [] + ) + ); + this.allRunsSelectedPatchset$ = select(this.checksSelected$, state => + Object.values(state).reduce( + (allRuns: CheckRun[], providerState: ChecksProviderState) => [ + ...allRuns, + ...providerState.runs, + ], + [] + ) + ); + this.allRunsLatestPatchsetLatestAttempt$ = select( + this.allRunsLatestPatchset$, + runs => runs.filter(run => run.isLatestAttempt) + ); + this.checkToPluginMap$ = select(this.checksLatest$, state => { + const map = new Map<string, string>(); + for (const [pluginName, providerState] of Object.entries(state)) { + for (const run of providerState.runs) { + map.set(run.checkName, pluginName); + } + } + return map; + }); + this.allResultsSelected$ = select(this.checksSelected$, state => + Object.values(state) + .reduce(collectRunResults, []) + .filter(r => r !== undefined) + ); + this.allResultsLatest$ = select(this.checksLatest$, state => + Object.values(state) + .reduce(collectRunResults, []) + .filter(r => r !== undefined) + ); + this.allResults$ = select( + combineLatest([ + this.checksSelectedPatchsetNumber$, + this.changeModel.latestPatchNum$, + this.allResultsSelected$, + this.allResultsLatest$, + ]), + ([selectedPs, latestPs, selected, latest]) => + selectedPs && selectedPs !== latestPs + ? [...selected, ...latest] + : latest + ); + this.subscriptions = [ this.changeModel.changeNum$.subscribe(x => (this.changeNum = x)), this.changeModel.latestPatchNum$.subscribe(
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts index 4b39522..f11e352 100644 --- a/polygerrit-ui/app/models/comments/comments-model.ts +++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -435,31 +435,11 @@ threads.filter(t => !isNewThread(t) && isDraftThread(t)) ); - public readonly threadsWithUnappliedSuggestions$ = select( - combineLatest([this.threads$, this.changeModel.latestPatchNum$]), - ([threads, latestPs]) => - threads.filter( - t => - isUnresolved(t) && - hasSuggestion(t) && - getFirstComment(t)?.patch_set === latestPs - ) - ); + public readonly threadsWithUnappliedSuggestions$; - public readonly commentedPaths$ = select( - combineLatest([ - this.changeComments$, - this.changeModel.basePatchNum$, - this.changeModel.patchNum$, - ]), - ([changeComments, basePatchNum, patchNum]) => { - if (!patchNum) return []; - const pathsMap = changeComments.getPaths({basePatchNum, patchNum}); - return Object.keys(pathsMap); - } - ); + public readonly commentedPaths$; - public readonly reloadAllComments$ = new BehaviorSubject(undefined); + public readonly reloadAllComments$; public thread$(id: UrlEncodedCommentId) { return select(this.threads$, threads => threads.find(t => t.rootId === id)); @@ -484,6 +464,31 @@ private readonly navigation: NavigationService ) { super(initialState); + + this.threadsWithUnappliedSuggestions$ = select( + combineLatest([this.threads$, this.changeModel.latestPatchNum$]), + ([threads, latestPs]) => + threads.filter( + t => + isUnresolved(t) && + hasSuggestion(t) && + getFirstComment(t)?.patch_set === latestPs + ) + ); + this.commentedPaths$ = select( + combineLatest([ + this.changeComments$, + this.changeModel.basePatchNum$, + this.changeModel.patchNum$, + ]), + ([changeComments, basePatchNum, patchNum]) => { + if (!patchNum) return []; + const pathsMap = changeComments.getPaths({basePatchNum, patchNum}); + return Object.keys(pathsMap); + } + ); + this.reloadAllComments$ = new BehaviorSubject(undefined); + this.subscriptions.push( this.savingInProgress$.subscribe(savingInProgress => { if (savingInProgress) {
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts index bc17a58..6fb3f82 100644 --- a/polygerrit-ui/app/models/views/search.ts +++ b/polygerrit-ui/app/models/views/search.ts
@@ -195,21 +195,7 @@ 'reload' ).pipe(startWith(undefined)); - private readonly reloadChangesTrigger$ = combineLatest([ - this.reload$, - this.query$, - this.offsetNumber$, - this.userModel.preferenceChangesPerPage$, - ]).pipe( - map(([_reload, query, offsetNumber, changesPerPage]) => { - const params: [string, number, number] = [ - query, - offsetNumber, - changesPerPage, - ]; - return params; - }) - ); + private readonly reloadChangesTrigger$; constructor( private readonly restApiService: RestApiService, @@ -217,6 +203,23 @@ private readonly getNavigation: Provider<NavigationService> ) { super(undefined); + + this.reloadChangesTrigger$ = combineLatest([ + this.reload$, + this.query$, + this.offsetNumber$, + this.userModel.preferenceChangesPerPage$, + ]).pipe( + map(([_reload, query, offsetNumber, changesPerPage]) => { + const params: [string, number, number] = [ + query, + offsetNumber, + changesPerPage, + ]; + return params; + }) + ); + this.subscriptions = [ this.reloadChangesTrigger$ .pipe(
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json index e506738..103f92e 100644 --- a/polygerrit-ui/app/package.json +++ b/polygerrit-ui/app/package.json
@@ -45,15 +45,10 @@ "polymer-resin": "^2.0.1", "resemblejs": "^5.0.0", "rxjs": "^6.6.7", - "safevalues": "0.3.1", + "safevalues": "^1.0.0-rc.1", "web-vitals": "^3.5.2" }, "dependencies // comments": { - "safevalues": [ - "There is a an issue with release 0.3.2, which exposes both an ESM and a CommonJS module:", - "https://github.com/google/safevalues/commit/16aa2567dc303759841b097b1901d1d6ff4e083e", - "That causes tests to fail claiming that 'sanitizeHtml' is not exported from safevalues." - ], "@polymer/polymer": [ "There is a an issue with release 3.5.2. Tests are failing with:", "NotSupportedError: Failed to execute 'define' on 'CustomElementRegistry':",
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts index 750b421..57e0e14 100644 --- a/polygerrit-ui/app/services/flags/flags.ts +++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -18,7 +18,6 @@ NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui', CHECKS_DEVELOPER = 'UiFeature__checks_developer', PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer', - PUSH_NOTIFICATIONS = 'UiFeature__push_notifications', REVISION_PARENTS_DATA = 'UiFeature__revision_parents_data', COMMENT_AUTOCOMPLETION = 'UiFeature__comment_autocompletion_enabled', SAVE_PROJECT_CONFIG_FOR_REVIEW = 'UiFeature__save_project_config_for_review',
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 5180d93..b36e342 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
@@ -1257,19 +1257,23 @@ }); } - async getDetailedChangesWithActions(changeNums: NumericChangeId[]) { + async getDetailedChangesWithActions( + changeNums: NumericChangeId[], + needsSubmitRequirements = false + ) { + const options = [ + ListChangesOption.CHANGE_ACTIONS, + ListChangesOption.CURRENT_REVISION, + ListChangesOption.DETAILED_LABELS, + ].concat( + needsSubmitRequirements ? [ListChangesOption.SUBMIT_REQUIREMENTS] : [] + ); const query = changeNums.map(num => `change:${num}`).join(' OR '); const changeDetails = await this.getChanges( undefined, query, undefined, - listChangesOptionsToHex( - ListChangesOption.CHANGE_ACTIONS, - ListChangesOption.CURRENT_REVISION, - ListChangesOption.DETAILED_LABELS, - // TODO: remove this option and merge requirements from dashboard req - ListChangesOption.SUBMIT_REQUIREMENTS - ) + listChangesOptionsToHex(...options) ); return changeDetails; } @@ -2374,7 +2378,8 @@ changeNum: NumericChangeId, fixPatchNum: PatchSetNum, fixReplacementInfos: FixReplacementInfo[], - targetPatchNum?: PatchSetNum + targetPatchNum?: PatchSetNum, + errFn?: ErrorCallback ): Promise<Response> { const url = await this._changeBaseURL( changeNum, @@ -2397,6 +2402,7 @@ }), url: `${url}/fix:apply`, anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:apply`, + errFn, reportServerError: true, }); }
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts index 204d626..997133b 100644 --- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts +++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -65,8 +65,7 @@ // They are not used in bulk actions. // ListChangesOption.CURRENT_ACTIONS, ListChangesOption.CURRENT_REVISION, - ListChangesOption.DETAILED_LABELS, - ListChangesOption.SUBMIT_REQUIREMENTS + ListChangesOption.DETAILED_LABELS ); suite('gr-rest-api-service-impl tests', () => { @@ -1562,6 +1561,30 @@ assert.isTrue(getChangesStub.calledOnce); }); + test('getDetailedChangesWithActions with SUBMIT_REQUIREMENTS', async () => { + const expectedQueryOptions = listChangesOptionsToHex( + ListChangesOption.CHANGE_ACTIONS, + ListChangesOption.CURRENT_REVISION, + ListChangesOption.DETAILED_LABELS, + ListChangesOption.SUBMIT_REQUIREMENTS + ); + const c1 = createChange(); + c1._number = 1 as NumericChangeId; + const c2 = createChange(); + c2._number = 2 as NumericChangeId; + const getChangesStub = sinon + .stub(element, 'getChanges') + .callsFake((changesPerPage, query, offset, options) => { + assert.isUndefined(changesPerPage); + assert.strictEqual(query, 'change:1 OR change:2'); + assert.isUndefined(offset); + assert.strictEqual(options, expectedQueryOptions); + return Promise.resolve([]); + }); + await element.getDetailedChangesWithActions([c1._number, c2._number], true); + assert.isTrue(getChangesStub.calledOnce); + }); + test('setChangeTopic', async () => { element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME); const fetchStub = sinon
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 799cf43..616d29c 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
@@ -491,7 +491,8 @@ ): Promise<{[pluginName: string]: PluginInfo} | undefined>; getDetailedChangesWithActions( - changeNums: NumericChangeId[] + changeNums: NumericChangeId[], + needsSubmitRequirements?: boolean ): Promise<ChangeInfo[] | undefined>; getChanges( @@ -740,7 +741,8 @@ changeNum: NumericChangeId, fixPatchNum: PatchSetNum, fixReplacementInfos: FixReplacementInfo[], - targetPatchNum?: PatchSetNum + targetPatchNum?: PatchSetNum, + errFn?: ErrorCallback ): Promise<Response>; /**
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts index 1c4a93d..2bede59 100644 --- a/polygerrit-ui/app/services/service-worker-installer.ts +++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -69,9 +69,6 @@ private readonly userModel: UserModel ) { super({initialized: false, shouldShowPrompt: false}); - if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) { - return; - } this.userModel.account$.subscribe(acc => (this.account = acc)); this.userModel.preferences$.subscribe(prefs => { if ( @@ -85,8 +82,7 @@ navigator.serviceWorker.controller?.postMessage({ type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE, allowBrowserNotificationsPreference: - this.allowBrowserNotificationsPreference && - this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS), + this.allowBrowserNotificationsPreference, }); } } @@ -104,9 +100,6 @@ private async init() { if (this.initialized) return; - if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) { - return; - } if (!this.areNotificationsEnabled()) return; if (!('serviceWorker' in navigator)) { @@ -134,9 +127,6 @@ // private, used in test shouldShowPrompt(): boolean { if (!this.initialized) return false; - if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) { - return false; - } if (!this.areNotificationsEnabled()) return false; return Notification.permission === 'default'; }
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css index d81c296..2112373 100644 --- a/polygerrit-ui/app/styles/fonts.css +++ b/polygerrit-ui/app/styles/fonts.css
@@ -25,6 +25,24 @@ @font-face { font-family: 'Open Sans'; font-style: normal; + font-weight: 500; + font-display: swap; + src: local('Open Sans Medium'), local('OpenSans-Medium'), url(../fonts/opensans-latin-ext-500.woff2) format('woff2'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local('Open Sans Medium'), local('OpenSans-Medium'), url(../fonts/opensans-latin-500.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* latin-ext */ +@font-face { + font-family: 'Open Sans'; + font-style: normal; font-weight: 600; font-display: swap; src: local('Open Sans SemiBold'), local('OpenSans-SemiBold'), url(../fonts/opensans-latin-ext-600.woff2) format('woff2');
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts index 0c7c151..67ce062 100644 --- a/polygerrit-ui/app/styles/gr-change-list-styles.ts +++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -6,7 +6,7 @@ import {css} from 'lit'; export const changeListStyles = css` - gr-change-list-item { + td { border-top: 1px solid var(--border-color); } gr-change-list-item[selected], @@ -20,6 +20,20 @@ gr-change-list-item[highlight]:focus { background-color: var(--line-item-highlight-selection-color); } + gr-change-list-item:last-child, + tr.noChanges { + --last-border-bottom: 1px solid var(--border-color); + --last-border-radius: 4px; + } + td { + border-bottom: var(--last-border-bottom); + } + td:first-child { + border-bottom-left-radius: var(--last-border-radius); + } + td:last-child { + border-bottom-right-radius: var(--last-border-radius); + } .groupTitle td, .cell { vertical-align: middle; @@ -30,17 +44,30 @@ } .groupTitle td { color: var(--deemphasized-text-color); + font-weight: var(--font-weight-bold); + font-family: var(--header-font-family); text-align: left; } + .groupGap { + height: 10px; + } + .groupHeader td { + background-color: var(--section-header-background-color); + border-top: 1px solid var(--border-color); + } + .groupHeader td:first-child { + border-top-left-radius: 4px; + } + .groupHeader td:last-child { + border-top-right-radius: 4px; + } .groupHeader { - background-color: transparent; - font-size: var(--font-size-h3); - font-weight: var(--font-weight-h3); - line-height: var(--line-height-h3); + font-size: var(--font-size-h2); + font-weight: var(--font-weight-h2); + line-height: var(--line-height-h2); } .groupContent { background-color: var(--background-color-primary); - box-shadow: var(--elevation-level-1); } .groupHeader a { color: var(--primary-text-color); @@ -54,7 +81,7 @@ padding: var(--spacing-s) 0; } .groupHeader .cell { - padding-top: var(--spacing-l); + padding: var(--spacing-xs) 0; } .star { padding: 0 var(--spacing-s) 0 0; @@ -62,6 +89,12 @@ .owner { --account-max-length: 100px; } + td:first-child { + border-left: 1px solid var(--border-color); + } + td:last-child { + border-right: 1px solid var(--border-color); + } .branch, .star, .label, @@ -122,19 +155,26 @@ flex-wrap: wrap; justify-content: space-between; padding: var(--spacing-xs) var(--spacing-m); + border-top: 1px solid var(--border-color); } gr-change-list-item[selected], - gr-change-list-item:focus { + gr-change-list-item:focus td { background-color: var(--view-background-color); border: none; - border-top: 1px solid var(--border-color); } gr-change-list-item:hover { background-color: var(--view-background-color); } + td.cell, + .groupHeader td.cell { + border-left: none; + border-right: none; + border-radius: 0px; + } .cell { align-items: center; display: flex; + border: none; } .groupTitle, .leftPadding,
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts index a85b5d0..c1b7922 100644 --- a/polygerrit-ui/app/styles/themes/app-theme.ts +++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -3,7 +3,7 @@ * Copyright 2015 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util'; +import {safeStyleSheet, setStyleTextContent} from '../../utils/inner-html-util'; const appThemeCss = safeStyleSheet` html { @@ -20,59 +20,77 @@ /* color palette */ --gerrit-blue-light: #1565c0; --gerrit-blue-dark: #90caf9; - --red-900: #a50e0e; + --red-50: #fce8e6; + --red-100: #fad2cf; + --red-200: #f6aea9; + --red-300: #f28b82; + --red-400: #ee675c; + --red-500: #ea4335; + --red-600: #d93025; --red-700: #c5221f; --red-700-04: #c5221f0a; --red-700-10: #c5221f1a; --red-700-12: #c5221f1f; - --red-600: #d93025; - --red-300: #f28b82; - --red-200: #f6aea9; - --red-50: #fce8e6; + --red-800: #b31412; + --red-900: #a50e0e; --red-tonal: #6c322f; - --blue-900: #174ea6; - --blue-800: #185abc; + --blue-50: #e8f0fe; + --blue-100: #d2e3fc; + --blue-200: #aecbfa; + --blue-200-16: #aecbfa29; + --blue-200-24: #aecbfa3d; + --blue-300: #8ab4f8; + --blue-300-24: #8ab4f83D; + --blue-400: #669df6; + --blue-500: #4285f4; + --blue-600: #1a73e8; --blue-700: #1967d2; --blue-700-04: #1967d20a; --blue-700-10: #1967d21a; --blue-700-12: #1967d21f; --blue-700-16: #1967d229; --blue-700-24: #1967d23d; - --blue-400: #669df6; - --blue-300: #8ab4f8; - --blue-300-24: #8ab4f83D; - --blue-200: #aecbfa; - --blue-200-16: #aecbfa29; - --blue-200-24: #aecbfa3d; - --blue-100: #d2e3fc; - --blue-50: #e8f0fe; + --blue-800: #185abc; + --blue-900: #174ea6; --blue-tonal: #314972; - --orange-900: #b06000; - --orange-800: #c26401; + --orange-50: #feefe3; + --orange-100: #fedfc8; + --orange-200: #fdc69c; + --orange-300: #fcad70; + --orange-400: #fa903e; + --orange-500: #fa7b17; + --orange-600: #e8710a; --orange-700: #d56e0c; --orange-700-04: #d56e0c0a; --orange-700-10: #d56e0c1a; --orange-700-12: #d56e0c1f; - --orange-400: #fa903e; - --orange-300: #fcad70; - --orange-200: #fdc69c; - --orange-50: #feefe3; + --orange-800: #c26401; + --orange-900: #b06000; --orange-tonal: #714625; - --cyan-900: #007b83; - --cyan-700: #129eaf; - --cyan-200: #a1e4f2; - --cyan-100: #cbf0f8; --cyan-50: #e4f7fb; + --cyan-100: #cbf0f8; + --cyan-200: #a1e4f2; + --cyan-300: #78d9ec; + --cyan-400: #4ecde6; + --cyan-500: #24c1e0; + --cyan-600: #12b5cb; + --cyan-700: #129eaf; + --cyan-800: #098591; + --cyan-900: #007b83; --cyan-tonal: #275e6b; - --green-900: #0d652d; + --green-50: #e6f4ea; + --green-100: #ceead6; + --green-200: #a8dab5; + --green-300: #81c995; + --green-400: #5bb974; + --green-500: #34a853; + --green-600: #1e8e3e; --green-700: #188038; --green-700-04: #1880380a; --green-700-10: #1880381a; --green-700-12: #1880381f; - --green-400: #5bb974; - --green-300: #81c995; - --green-200: #a8dab5; - --green-50: #e6f4ea; + --green-800: #137333; + --green-900: #0d652d; --green-tonal: #2c553a; --gray-900: #202124; --gray-800: #3c4043; @@ -91,21 +109,40 @@ --gray-100: #f1f3f4; --gray-50: #f8f9fa; --gray-tonal: #505357; - --purple-900: #681da8; - --purple-700: #8430ce; - --purple-500: #a142f4; - --purple-400: #af5cf7; - --purple-200: #d7aefb; - --purple-100: #e9d2fd; --purple-50: #f3e8fd; + --purple-100: #e9d2fd; + --purple-200: #d7aefb; + --purple-300: #c58af9; + --purple-400: #af5cf7; + --purple-500: #a142f4; + --purple-600: #9334e6; + --purple-700: #8430ce; + --purple-800: #7627bb; + --purple-900: #681da8; --purple-tonal: #523272; --deep-purple-800: #4527a0; --deep-purple-600: #5e35b1; - --pink-800: #b80672; - --pink-500: #f538a0; --pink-50: #fde7f3; + --pink-100: #fdcfe8; + --pink-200: #fba9d6; + --pink-300: #ff8bcb; + --pink-400: #ff63b8; + --pink-500: #f439a0; + --pink-600: #e52592; + --pink-700: #c92786; + --pink-800: #b80672; + --pink-900: #9c166b; --pink-tonal: #702f55; --yellow-50: #fef7e0; + --yellow-100: #feefc3; + --yellow-200: #fde293; + --yellow-300: #fdd663; + --yellow-400: #fcc934; + --yellow-500: #fbbc04; + --yellow-600: #f9ab00; + --yellow-700: #f29900; + --yellow-800: #ea8600; + --yellow-900: #e37400; --yellow-tonal: #6a5619; --brown-50: #efebe9; --brown-tonal: #6d4c41; @@ -262,6 +299,7 @@ --disabled-button-background-color: var(--disabled-background); --selection-background-color: rgba(161, 194, 250, 0.1); --tooltip-background-color: var(--gray-900); + --section-header-background-color: var(--blue-50); /* dashboard size background colors */ --dashboard-size-xs: var(--gray-200); @@ -352,8 +390,8 @@ --font-size-small: 0.857rem; /* 12px */ --font-size-normal: 1rem; /* 14px */ --font-size-h3: 1.143rem; /* 16px */ - --font-size-h2: 1.429rem; /* 20px */ - --font-size-h1: 1.714rem; /* 24px */ + --font-size-h2: 1.29rem; /* 18px */ + --font-size-h1: 1.57rem; /* 22px */ --line-height-mono: 1.286rem; /* 18px */ --line-height-small: 1.143rem; /* 16px */ --line-height-normal: 1.429rem; /* 20px */ @@ -362,8 +400,8 @@ --line-height-h1: 2.286rem; /* 32px */ --font-weight-normal: 400; /* 400 is the same as 'normal' */ --font-weight-bold: 500; - --font-weight-h1: 400; - --font-weight-h2: 400; + --font-weight-h1: 500; + --font-weight-h2: 500; --font-weight-h3: 400; --font-weight-h4: 600; --context-control-button-font: var(--font-weight-normal) @@ -513,7 +551,7 @@ const styleEl = document.createElement('style'); styleEl.setAttribute('id', 'light-theme'); -safeStyleEl.setTextContent(styleEl, appThemeCss); +setStyleTextContent(styleEl, appThemeCss); document.head.appendChild(styleEl); // TODO: The following can be removed when Paper and Iron components have been @@ -531,6 +569,6 @@ const customStyleEl = document.createElement('custom-style'); const innerStyleEl = document.createElement('style'); -safeStyleEl.setTextContent(innerStyleEl, appThemeCssPolymerLegacy); +setStyleTextContent(innerStyleEl, appThemeCssPolymerLegacy); customStyleEl.appendChild(innerStyleEl); document.head.appendChild(customStyleEl);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts index 574e6c2..bcda2b6 100644 --- a/polygerrit-ui/app/styles/themes/dark-theme.ts +++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -3,7 +3,7 @@ * Copyright 2015 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {safeStyleSheet, safeStyleEl} from '../../utils/inner-html-util'; +import {safeStyleSheet, setStyleTextContent} from '../../utils/inner-html-util'; // TODO: Replace `html` with `html.darkTheme`. But before we can do that we have // to ensure that all plugins also use `.darkTheme`, otherwise we would trump @@ -132,6 +132,7 @@ --disabled-button-background-color: #484a4d; --selection-background-color: rgba(161, 194, 250, 0.1); --tooltip-background-color: var(--gray-200); + --section-header-background-color: var(--blue-tonal); /* comment background colors */ --comment-background-color: #3c3f43; @@ -295,7 +296,7 @@ if (document.head.querySelector('#dark-theme')) return; const styleEl = document.createElement('style'); styleEl.setAttribute('id', 'dark-theme'); - safeStyleEl.setTextContent(styleEl, darkThemeCss); + setStyleTextContent(styleEl, darkThemeCss); // We would like to insert the dark theme styles after the light theme such // that the dark theme values override the defaults in the light theme. But
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 279dcb0..3d9a6fe 100644 --- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts +++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -269,7 +269,7 @@ getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> { return Promise.resolve(createSubmittedTogetherInfo()); }, - getDetailedChangesWithActions(changeNums: NumericChangeId[]) { + getDetailedChangesWithActions(changeNums: NumericChangeId[], _?: boolean) { return Promise.resolve( changeNums.map(changeNum => { return {
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util.ts b/polygerrit-ui/app/utils/commit-message-formatter-util.ts new file mode 100644 index 0000000..900a776 --- /dev/null +++ b/polygerrit-ui/app/utils/commit-message-formatter-util.ts
@@ -0,0 +1,350 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +interface CommitMessage { + subject: string; + body: string[]; + footer: string[]; + hasTrailingBlankLine: boolean; +} + +export interface FormattingError { + type: ErrorType; + line?: number; + message: string; +} + +export enum ErrorType { + SUBJECT_TOO_LONG, + LINE_TOO_LONG, + MISSING_BLANK_LINE, + EXTRA_BLANK_LINE, + INVALID_INDENTATION, + TRAILING_SPACES, + LEADING_SPACES, +} + +const MAX_SUBJECT_LENGTH = 72; +const MAX_LINE_LENGTH = 72; +const INDENTATION_THRESHOLD = 4; + +function formatCommitMessage(message: CommitMessage): CommitMessage { + const formattedSubject = formatSubject(message.subject); + const formattedBody = formatBody(message.body); + const formattedFooter = formatFooter(message.footer); + + return { + subject: formattedSubject, + body: formattedBody, + footer: formattedFooter, + hasTrailingBlankLine: message.hasTrailingBlankLine, + }; +} + +function formatSubject(subject: string): string { + return subject.trim(); +} + +function formatBody(body: string[]): string[] { + let inCodeBlock = false; + let paragraphLines: string[] = []; + const formattedBody: string[] = []; + + for (const line of body) { + if (line.trim().startsWith('```')) { + inCodeBlock = !inCodeBlock; + formattedBody.push(line); + continue; + } + + if (inCodeBlock || isUntouchedLine(line)) { + formattedBody.push(line.trimEnd()); + paragraphLines = []; // Reset paragraph + continue; + } + + if (line.trim() === '') { + if (paragraphLines.length > 0) { + formattedBody.push(...splitParagraph(paragraphLines.join(' '))); + paragraphLines = []; + } + formattedBody.push(''); + } else { + paragraphLines.push(line.trim()); + } + } + + if (paragraphLines.length > 0) { + formattedBody.push(...splitParagraph(paragraphLines.join(' '))); + } + + return removeConsecutiveBlankLines(formattedBody); +} + +function formatFooter(footer: string[]): string[] { + const formattedFooter = footer.map(line => line.trim()); + return removeConsecutiveBlankLines(formattedFooter); +} + +function isUntouchedLine(line: string): boolean { + return ( + line.trimStart().startsWith('> ') || + (line.length >= INDENTATION_THRESHOLD && + line.substring(0, INDENTATION_THRESHOLD).trim() === '') || + /^\s*[-+*#]\s/.test(line) + ); +} + +function splitParagraph(paragraph: string): string[] { + const words = paragraph.split(/\s+/); + const lines: string[] = []; + let currentLine = ''; + + for (const word of words) { + if (word.length > MAX_LINE_LENGTH) { + if (currentLine.length > 0) { + lines.push(currentLine); + currentLine = ''; + } + lines.push(word); + } else if ( + currentLine.length > 0 && + currentLine.length + word.length + 1 > MAX_LINE_LENGTH + ) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine += (currentLine ? ' ' : '') + word; + } + } + + if (currentLine) { + lines.push(currentLine); + } + + return lines; +} + +function removeConsecutiveBlankLines(lines: string[]): string[] { + const filteredLines: string[] = []; + let previousLineBlank = false; + + for (const line of lines) { + const isBlank = line.trim() === ''; + if (!isBlank || !previousLineBlank) { + filteredLines.push(line); + } + previousLineBlank = isBlank; + } + + // Remove leading and trailing blank lines + while (filteredLines.length > 0 && filteredLines[0].trim() === '') { + filteredLines.shift(); + } + while ( + filteredLines.length > 0 && + filteredLines[filteredLines.length - 1].trim() === '' + ) { + filteredLines.pop(); + } + return filteredLines; +} + +function detectFormattingErrors( + message: CommitMessage, + messageString: string +): FormattingError[] { + const errors: FormattingError[] = []; + + // Check subject + if (message.subject.length > MAX_SUBJECT_LENGTH) { + errors.push({ + type: ErrorType.SUBJECT_TOO_LONG, + line: 1, + message: `Subject exceeds ${MAX_SUBJECT_LENGTH} characters`, + }); + } + + if (message.subject.startsWith(' ')) { + errors.push({ + type: ErrorType.LEADING_SPACES, + line: 1, + message: 'Subject should not start with spaces', + }); + } + + if (message.subject.endsWith(' ')) { + errors.push({ + type: ErrorType.TRAILING_SPACES, + line: 1, + message: 'Subject should not end with spaces', + }); + } + + // Check for extra blank lines using the raw messageString + const lines = messageString.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (i > 0 && lines[i].trim() === '' && lines[i - 1].trim() === '') { + const isBetweenSubjectAndBody = i === 1 && message.body.length > 0; + const isBetweenBodyAndFooter = + i === message.body.length + (message.subject ? 1 : 0) && + message.footer.length > 0; + const isAtEndOfFooter = + i === lines.length - 1 && message.footer.length > 0; + + if ( + !isBetweenSubjectAndBody && + !isBetweenBodyAndFooter && + !isAtEndOfFooter + ) { + errors.push({ + type: ErrorType.EXTRA_BLANK_LINE, + line: i + 1, // Line numbers are 1-based + message: 'Consecutive blank lines are not allowed', + }); + } + } + } + + // Check body + let lineNumber = 2; + let inCodeBlock = false; + + for (const line of message.body) { + if (line.trim().startsWith('```')) { + inCodeBlock = !inCodeBlock; + } + + if ( + !inCodeBlock && + !isUntouchedLine(line) && + line.length > MAX_LINE_LENGTH && + !line.includes('://') // Don't flag long URLs + ) { + errors.push({ + type: ErrorType.LINE_TOO_LONG, + line: lineNumber, + message: `Line exceeds ${MAX_LINE_LENGTH} characters`, + }); + } + if (line.endsWith(' ')) { + errors.push({ + type: ErrorType.TRAILING_SPACES, + line: lineNumber, + message: 'Line should not end with spaces', + }); + } + lineNumber++; + } + + // Check footer + lineNumber = message.body.length + 2; + for (const line of message.footer) { + if (line.trim().startsWith('```')) { + inCodeBlock = !inCodeBlock; + } + + if ( + !inCodeBlock && + !isUntouchedLine(line) && + line.length > MAX_LINE_LENGTH && + !line.includes('://') // Don't flag long URLs + ) { + errors.push({ + type: ErrorType.LINE_TOO_LONG, + line: lineNumber, + message: `Line exceeds ${MAX_LINE_LENGTH} characters`, + }); + } + if (line.endsWith(' ')) { + errors.push({ + type: ErrorType.TRAILING_SPACES, + line: lineNumber, + message: 'Line should not end with spaces', + }); + } + if (line.startsWith(' ')) { + errors.push({ + type: ErrorType.LEADING_SPACES, + line: lineNumber, + message: 'Line should not start with spaces', + }); + } + lineNumber++; + } + + return errors; +} + +function parseCommitMessageString(messageString: string): CommitMessage { + const lines = messageString.split('\n'); + // Remove leading blank lines + while (lines.length > 0 && lines[0].trim() === '') { + lines.shift(); + } + + let subject = ''; + const body: string[] = []; + const footer: string[] = []; + + if (lines.length > 0) { + subject = lines[0]; // Subject is always the first line + } + + const hasTrailingBlankLine = + lines.length > 0 && lines[lines.length - 1].trim() === ''; + + // Find the start of the footer (from the end) + let footerStartIndex = lines.length - 1; + for (let i = lines.length - 1; i >= 1; i--) { + if (lines[i].trim() !== '') { + footerStartIndex = i + 1; + } + if (lines[i].trim() !== '' && i - 1 >= 1 && lines[i - 1].trim() === '') { + footerStartIndex = i; + break; // Found footer start + } + } + + // Extract footer lines (if any) + for (let i = footerStartIndex; i < lines.length; i++) { + footer.push(lines[i]); + } + + // Extract body lines (if any) + for (let i = 1; i < footerStartIndex; i++) { + body.push(lines[i]); + } + + return {subject, body, footer, hasTrailingBlankLine}; +} + +function formatCommitMessageToString(message: CommitMessage): string { + let result = message.subject; + if (message.body.length > 0) { + result += '\n\n' + message.body.join('\n'); + } + if (message.footer.length > 0) { + result += '\n\n' + message.footer.join('\n'); + } + if (message.hasTrailingBlankLine) { + result += '\n'; + } + return result; +} + +export function formatCommitMessageString(messageString: string): string { + const commitMessage = parseCommitMessageString(messageString); + const formattedMessage = formatCommitMessage(commitMessage); + return formatCommitMessageToString(formattedMessage); +} + +export function detectFormattingErrorsInString( + messageString: string +): FormattingError[] { + const commitMessage = parseCommitMessageString(messageString); + return detectFormattingErrors(commitMessage, messageString); +}
diff --git a/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts b/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts new file mode 100644 index 0000000..9b47025 --- /dev/null +++ b/polygerrit-ui/app/utils/commit-message-formatter-util_test.ts
@@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {assert} from '@open-wc/testing'; +import '../test/common-test-setup'; +import { + formatCommitMessageString, + detectFormattingErrorsInString, + ErrorType, + FormattingError, +} from './commit-message-formatter-util'; + +suite('commit-message-formatter-util tests', () => { + suite('formatCommitMessageString', () => { + test('subject exceeding 72 characters is not split', () => { + const longSubject = + 'Fix(some-component): This is a very long subject that exceeds 72 characters'; + const message = longSubject + '\n\nThis is body.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + longSubject + '\n\nThis is body.\n\nChange-Id: abcdefg\n' + ); + }); + + test('words are always kept whole', () => { + const message = + 'Fix the thing\n\nThis is a very long long long long line with a very-long-word-that-should-not-be-split.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is a very long long long long line with a\nvery-long-word-that-should-not-be-split.\n\nChange-Id: abcdefg\n' + ); + }); + + test('long strings without spaces are not split', () => { + const message = + 'Fix the thing\n\nhttps://very-long-url-without-spaces-that-exceeds-72-characters.com/with/some/path/and/query?params=and&more=params\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nhttps://very-long-url-without-spaces-that-exceeds-72-characters.com/with/some/path/and/query?params=and&more=params\n\nChange-Id: abcdefg\n' + ); + }); + + test('trailing spaces are removed', () => { + const message = + 'Fix the thing \n\nThis is a line with trailing spaces. \nAnd another one. \n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is a line with trailing spaces. And another one.\n\nChange-Id: abcdefg\n' + ); + }); + + test('leading spaces are removed from subject and first body line', () => { + const message = + ' Fix the thing\n\n This is the body.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is the body.\n\nChange-Id: abcdefg\n' + ); + }); + + test('empty lines at the beginning and end are removed', () => { + const message = + '\n\nFix the thing\n\nThis is the body.\n\n\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is the body.\n\nChange-Id: abcdefg\n' + ); + }); + + test('consecutive blank lines are combined', () => { + const message = + 'Fix the thing\n\nThis is the body.\n\n\nAnd another paragraph.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is the body.\n\nAnd another paragraph.\n\nChange-Id: abcdefg\n' + ); + }); + + test('lines in the same paragraph are merged and split', () => { + const message = + 'Fix the thing\n\nThis is a paragraph\nwith lines that should be\nmerged and then split\naccording to the line\nlength limit.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is a paragraph with lines that should be merged and then split\naccording to the line length limit.\n\nChange-Id: abcdefg\n' + ); + }); + + test('lines within footers are not split', () => { + const message = + 'Fix the thing\n\nThis is the body.\n\nFixes: #123\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\nThis is the body.\n\nFixes: #123\nChange-Id: abcdefg\n' + ); + }); + + test('indented lines are untouched', () => { + const message = + 'Fix the thing\n\n This is an indented line.\n This is another indented line.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\n This is an indented line.\n This is another indented line.\n\nChange-Id: abcdefg\n' + ); + }); + + test('quoted lines are untouched', () => { + const message = + 'Fix the thing\n\n> This is a quoted line.\n> And another one.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\n> This is a quoted line.\n> And another one.\n\nChange-Id: abcdefg\n' + ); + }); + + test('code blocks are untouched', () => { + const message = + 'Fix the thing\n\n```\nThis is a code block.\n It should not be formatted.\n```\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\n```\nThis is a code block.\n It should not be formatted.\n```\n\nChange-Id: abcdefg\n' + ); + }); + + test('bullet points are untouched', () => { + const message = + 'Fix the thing\n\n - This is a bullet point.\n * This is another one.\n + And one more.\n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\n - This is a bullet point.\n * This is another one.\n + And one more.\n\nChange-Id: abcdefg\n' + ); + }); + + test('body line starting with spaces is untouched', () => { + const message = + 'Fix the thing\n\n - This is a body line that starts with spaces and should be untouched\n \n\nChange-Id: abcdefg\n'; + assert.equal( + formatCommitMessageString(message), + 'Fix the thing\n\n - This is a body line that starts with spaces and should be untouched\n\nChange-Id: abcdefg\n' + ); + }); + }); + + suite('detectFormattingErrorsInString', () => { + function assertError( + errors: FormattingError[], + type: ErrorType, + line: number, + message: string + ) { + assert.isTrue( + errors.some( + error => + error.type === type && + error.line === line && + error.message === message + ), + `Expected error ${type} on line ${line} with message "${message}, but` + + JSON.stringify(errors) + ); + } + + test('subject too long', () => { + const longSubject = + 'Fix(some-component): This is a very long subject that exceeds 72 characters'; + const message = longSubject + '\n\nThis is body.\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.SUBJECT_TOO_LONG, + 1, + 'Subject exceeds 72 characters' + ); + }); + + test('subject has leading spaces', () => { + const message = + ' Fix the thing\n\nThis is the body.\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.LEADING_SPACES, + 1, + 'Subject should not start with spaces' + ); + }); + + test('subject has trailing spaces', () => { + const message = + 'Fix the thing \n\nThis is the body.\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.TRAILING_SPACES, + 1, + 'Subject should not end with spaces' + ); + }); + + test('line too long', () => { + const message = + 'Fix the thing\n\nThis is a very long line that exceeds the maximum allowed length of 72 characters.\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.LINE_TOO_LONG, + 3, + 'Line exceeds 72 characters' + ); + }); + + test('line too long in footer', () => { + const message = + 'Fix the thing\n\nThis is body.\n\nChange-Id: This is a very long line that exceeds the maximum allowed length of 72 characters.\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.LINE_TOO_LONG, + 5, + 'Line exceeds 72 characters' + ); + }); + + test('line with url not flagged as too long', () => { + const message = + 'Fix the thing\n\nThis line has a http://very-long-url-without-spaces-that-exceeds-72-characters.com/with/some/path/and/query?params=and&more=params\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assert.isEmpty(errors); + }); + + test('trailing spaces', () => { + const message = + 'Fix the thing\n\nThis is a line with trailing spaces. \nAnd another one. \n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.TRAILING_SPACES, + 3, + 'Line should not end with spaces' + ); + assertError( + errors, + ErrorType.TRAILING_SPACES, + 4, + 'Line should not end with spaces' + ); + }); + + test('trailing spaces in footer', () => { + const message = + 'Fix the thing\n\nThis is body.\n\nChange-Id: abcdefg \n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.TRAILING_SPACES, + 5, + 'Line should not end with spaces' + ); + }); + + test('extra blank lines', () => { + const message = + 'Fix the thing\n\nThis is the body.\n\n\nAnd another paragraph.\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.EXTRA_BLANK_LINE, + 5, + 'Consecutive blank lines are not allowed' + ); + }); + + test('extra blank lines in footer', () => { + const message = + 'Fix the thing\n\nThis is the body.\n\nTest: 123\n\n\nChange-Id: abcdefg\n'; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.EXTRA_BLANK_LINE, + 7, + 'Consecutive blank lines are not allowed' + ); + }); + + test('line in footer starts with spaces', () => { + const message = + 'Fix the thing\n\nThis is body.\n Change-Id: abcdefg\n '; + const errors = detectFormattingErrorsInString(message); + assertError( + errors, + ErrorType.LEADING_SPACES, + 4, + 'Line should not start with spaces' + ); + }); + }); +});
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts index e50506a..f02c185 100644 --- a/polygerrit-ui/app/utils/diff-util.ts +++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -22,7 +22,10 @@ }, 0); } -function getDiffLines(diff: DiffInfo, side: Side): string[] { +/** + * Get the lines of the diff for a given side. + */ +export function getDiffLines(diff: DiffInfo, side: Side): string[] { let lines: string[] = []; for (const chunk of diff.content) { if (chunk.skip) {
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts index ed9bfac..3590119 100644 --- a/polygerrit-ui/app/utils/inner-html-util.ts +++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -22,8 +22,9 @@ return styleSheet as SafeStyleSheet; } -export const safeStyleEl = { - setTextContent: (elem: HTMLStyleElement, safeStyleSheet: SafeStyleSheet) => { - elem.textContent = safeStyleSheet; - }, -}; +export function setStyleTextContent( + elem: HTMLStyleElement, + safeStyleSheet: SafeStyleSheet +) { + elem.textContent = safeStyleSheet; +}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts index bdc7cb03..e34e96b 100644 --- a/polygerrit-ui/app/utils/label-util.ts +++ b/polygerrit-ui/app/utils/label-util.ts
@@ -9,6 +9,7 @@ SubmitRequirementResultInfo, SubmitRequirementStatus, LabelNameToValuesMap, + LabelValueToDescriptionMap, } from '../api/rest-api'; import { AccountInfo, @@ -42,14 +43,18 @@ NEUTRAL = 'NEUTRAL', } -export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined { - if (!label || !isDetailedLabelInfo(label) || !label.values) return undefined; - const values = Object.keys(label.values).map(v => Number(v)); +export function getMinMaxValue(labelValues: LabelValueToDescriptionMap) { + const values = Object.keys(labelValues).map(v => Number(v)); values.sort((a, b) => a - b); if (!values.length) return undefined; return {min: values[0], max: values[values.length - 1]}; } +export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined { + if (!label || !isDetailedLabelInfo(label) || !label.values) return undefined; + return getMinMaxValue(label.values); +} + export function getVotingRangeOrDefault(label?: LabelInfo): VotingRangeInfo { const range = getVotingRange(label); return range ? range : {min: 0, max: 0};
diff --git a/polygerrit-ui/app/utils/location-util.ts b/polygerrit-ui/app/utils/location-util.ts index d0eac74..98c9ff9 100644 --- a/polygerrit-ui/app/utils/location-util.ts +++ b/polygerrit-ui/app/utils/location-util.ts
@@ -7,16 +7,16 @@ // This file adds some simple checks to match internal Google rules. // Internally at Google it has different a implementation. -import {safeLocation} from 'safevalues/dom'; +import {setLocationHref, locationReplace, locationAssign} from 'safevalues/dom'; export function setHref(loc: Location, url: string) { - safeLocation.setHref(loc, url); + setLocationHref(loc, url); } export function replace(loc: Location, url: string) { - safeLocation.replace(loc, url); + locationReplace(loc, url); } export function assign(loc: Location, url: string) { - safeLocation.assign(loc, url); + locationAssign(loc, url); }
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock index 7f94c24..7db7bc9 100644 --- a/polygerrit-ui/app/yarn.lock +++ b/polygerrit-ui/app/yarn.lock
@@ -851,10 +851,10 @@ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -safevalues@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/safevalues/-/safevalues-0.3.1.tgz#610a910290930ac5f25ba77055cb8a819b0a15a9" - integrity sha512-sp++LhKx0CiDw9QGrYSavXCxQRIoZUBsupt2NbucztV5cLpO3zzAwww+LZS8L3dgGU0f5/zw3hymq3ltrVebNA== +safevalues@^1.0.0-rc.1: + version "1.0.0-rc.1" + resolved "https://registry.yarnpkg.com/safevalues/-/safevalues-1.0.0-rc.1.tgz#f070e4893c686721291ab0035ca14ef1c1b4edb2" + integrity sha512-c+qs3AGL038dLJCyPXHLxg4/zGAiM2i8hGZ3NmlMZ881txypRGla6TNYv1BtY/Qz56r3q8HbEF9g2xxDo/+RYg== semver@^6.0.0: version "6.3.1"
diff --git a/proto/entities.proto b/proto/entities.proto index 3a59f09..1cf4473 100644 --- a/proto/entities.proto +++ b/proto/entities.proto
@@ -115,6 +115,14 @@ ABANDONED = 2; } +// Serialized form of com.google.gerrit.entities.PatchSet.Conflicts. +// Next ID: 4 +message Conflicts { + optional ObjectId ours = 1; + optional ObjectId theirs = 2; + optional bool containsConflicts = 3; +} + // Serialized form of com.google.gerrit.extensions.common.MergeInput. // Next ID: 5 message MergeInput { @@ -207,7 +215,7 @@ } // Serialized form of com.google.gerrit.entities.PatchSet. -// Next ID: 12 +// Next ID: 13 message PatchSet { required PatchSet_Id id = 1; optional ObjectId commitId = 2; @@ -218,6 +226,7 @@ optional string description = 9; optional Account_Id real_uploader_account_id = 10; optional string branch = 11; + optional Conflicts conflicts = 12; // Deleted fields, should not be reused: reserved 5; // draft
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy index 5ff1822..86970f8 100644 --- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy +++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -108,7 +108,7 @@ {/if} {if $useGoogleFonts} - <link rel="preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap"> + <link rel="preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,500,600,700&display=swap"> <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0" />{\n} {else} // $useGoogleFonts only exists so that hosts can opt-out of loading fonts from fonts.googleapis.com. @@ -116,9 +116,11 @@ // @see https://github.com/w3c/preload/issues/32 regarding crossorigin <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-400.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} + <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-500.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-600.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-700.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-400.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} + <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-500.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-600.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} <link rel="preload" href="{$staticResourcePath}/fonts/opensans-latin-ext-700.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} <link rel="preload" href="{$staticResourcePath}/fonts/roboto-latin-400.woff2" as="font" type="font/woff2" crossorigin="anonymous">{\n} @@ -153,7 +155,7 @@ // Now use preloaded resources {if $useGoogleFonts} - <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">{\n} + <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,500,600,700&display=swap">{\n} <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0" />{\n} {else} <link rel="stylesheet" href="{$staticResourcePath}/styles/fonts.css">{\n}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh index 4dc8881..8c958c9 100755 --- a/resources/com/google/gerrit/pgm/init/gerrit.sh +++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -51,7 +51,7 @@ usage() { me=`basename "$0"` - echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug_port|--debug_address ...] [--suspend]]" + echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site] [--debug [--debug_port|--debug_address ...] [--suspend]] [--count=n]" exit 1 } @@ -60,6 +60,8 @@ ################################################## # Some utility functions ################################################## +ztime() { date -u +"%Y-%m-%dT%H:%M:%SZ" ; } + running() { test -f $1 || return 1 PID=`cat $1` @@ -130,6 +132,7 @@ # Get the action and options ################################################## +COUNT='' ACTION=$1 shift @@ -156,6 +159,10 @@ JVM_DEBUG_SUSPEND=true shift ;; + --count=*) + COUNT=${1##--count=} + shift + ;; --debug-port=*) DEBUG_ADDRESS=${1##--debug-port=} shift @@ -247,8 +254,10 @@ exit 1 } -GERRIT_PID="$GERRIT_SITE/logs/gerrit.pid" -GERRIT_RUN="$GERRIT_SITE/logs/gerrit.run" +GERRIT_LOGS="$GERRIT_SITE/logs" +GERRIT_PID="$GERRIT_LOGS/gerrit.pid" +GERRIT_RUN="$GERRIT_LOGS/gerrit.run" +GERRIT_THREADS="$GERRIT_LOGS/threads" GERRIT_TMP="$GERRIT_SITE/tmp" export GERRIT_TMP @@ -651,7 +660,15 @@ threads) if running "$GERRIT_PID" ; then - thread_dump "$GERRIT_PID" + if test -z "$COUNT" ; then + thread_dump "$GERRIT_PID" + else + mkdir -p -- "$GERRIT_THREADS" + for N in `seq "$COUNT"` ; do + thread_dump "$GERRIT_PID" > "$GERRIT_THREADS/jstack-`ztime`" + sleep 1 + done + fi exit 0 else echo "Gerrit not running?"
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties index b6aca6f..95206f9 100644 --- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties +++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -3,6 +3,7 @@ batchChangesLimit = Batch Changes Limit createAccount = Create Account createGroup = Create Group +deleteGroup = Delete Group createProject = Create Project emailReviewers = Email Reviewers flushCaches = Flush Caches
diff --git a/tools/gjf.sh b/tools/gjf.sh new file mode 100755 index 0000000..a688b86 --- /dev/null +++ b/tools/gjf.sh
@@ -0,0 +1,197 @@ +#!/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 + +verify_version() { + local version=$1 + if [[ ! " ${SUPPORTED_VERSIONS[*]} " =~ "$version " ]] + then + echo "Unknown version: $version." + echo "" + echo "$HELP_TEXT" + exit 1 + fi +} + +get_tools_dir() { + # Set root relative to this script's location. + local root="$(git -C $(dirname $0) rev-parse --show-toplevel)" + if [[ -z "$root" ]]; then + echo "google-java-format setup requires a git working tree" + exit 1 + fi + echo "$root/tools" +} + +get_format_dir() { + local format_dir=$(get_tools_dir)/format + # Format directory is not guaranteed to exist, create if missing. + mkdir -p "$format_dir" + echo $format_dir +} + +get_gjf_name() { + local version=$1 + echo "google-java-format-$version" +} + +get_jar_name() { + local version=$1 + echo $(get_gjf_name $version)-all-deps.jar +} + +get_jar_location() { + local version=$1 + echo $(get_format_dir)/$(get_jar_name $version) +} + +get_launcher_location() { + local version=$1 + echo $(get_format_dir)/$(get_gjf_name $version) +} + +setup_google_java_format() { + local version=$1 + + if [ -f $(get_jar_location $version) ] && [ -f $(get_launcher_location $version) ]; then + echo "Google-Java-Format $version already set up.!" + return 0 + fi + + echo "Setting up Google Java Format $VERSION." + + local sha1 + local tag_prefix + + case "$version" in + 1.7) + sha1="b6d34a51e579b08db7c624505bdf9af4397f1702" + tag_prefix=google-java-format- + ;; + 1.22.0) + sha1="693d8fd04656886a2287cfe1d7a118c4697c3a57" + tag_prefix=v + ;; + 1.24.0) + sha1="3b55f08a70d53984ac4b3e7796dc992858d6bdd8" + tag_prefix=v + ;; + *) + echo "unknown google-java-format version: $version" + exit 1 + ;; + esac + + url="https://github.com/google/google-java-format/releases/download/$tag_prefix$version/$(get_jar_name $version)" + "$(get_tools_dir)/download_file.py" -o "$(get_jar_location $version)" -u "$url" -v "$sha1" + + launcher="$(get_launcher_location $version)" + + cat > "$launcher" <<EOF +#!/bin/bash +# +# Copyright (C) 2017 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +function abs_script_dir_path { + SOURCE=\${BASH_SOURCE[0]} + while [ -h "\$SOURCE" ]; do + DIR=\$( cd -P \$( dirname "\$SOURCE") && pwd ) + SOURCE=\$(readlink "\$SOURCE") + [[ \$SOURCE != /* ]] && SOURCE="\$DIR/\$SOURCE" + done + DIR=\$( cd -P \$( dirname "\$SOURCE" ) && pwd ) + echo \$DIR +} + +set -e + +dir="\$(abs_script_dir_path "\$0")" +exec java -jar "\$dir/$(get_jar_name $version)" "\$@" +EOF + + chmod +x "$launcher" + + cat <<EOF +Installed launcher script at $launcher +To set up an alias, add the following to your ~/.bashrc or equivalent: + alias google-java-format='$launcher' +EOF +} + +run_google_java_format() { + local version=$1 + echo 'Running google-java-format check...' + git show --diff-filter=AM --name-only --pretty="" HEAD | grep java$ | xargs $(get_launcher_location $version) -r +} + +# MAIN + +HELP_TEXT=" + Usage: + + $0 [run|setup] [VERSION] + $0 [default-version] + + Sets up or runs google-java-format of the specified version or returns the default version. + Supported versions are \"${SUPPORTED_VERSIONS[*]}\". + +" + +SUPPORTED_VERSIONS=(1.7 1.22.0 1.24.0) +# Keep the default version in sync with dev-contributing.txt. +DEFAULT_VERSION="1.24.0" +VERSION=${2:-$DEFAULT_VERSION} +verify_version $VERSION + +command=${1:-""} +case $command in + run) + setup_google_java_format $VERSION + run_google_java_format $VERSION + exit 0 + ;; + setup) + setup_google_java_format $VERSION + exit 0 + ;; + default-version) + echo $DEFAULT_VERSION + exit 0 + ;; + -h|--help) + echo "$HELP_TEXT" + exit 1 + ;; + *) + echo "Unknown command \"$command\"" + echo "" + echo "$HELP_TEXT" + exit 1 + ;; +esac
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml index 1488189..366f22c 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.11.1-SNAPSHOT</version> + <version>3.12.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 d6a8a07..59f9016 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.11.1-SNAPSHOT</version> + <version>3.12.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 41ff0e3..c549677 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.11.1-SNAPSHOT</version> + <version>3.12.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 a708ab0..9b26260 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.11.1-SNAPSHOT</version> + <version>3.12.0-SNAPSHOT</version> <packaging>war</packaging> <name>Gerrit Code Review - WAR</name> <description>Gerrit WAR</description>
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts index 642a749..b63685d 100644 --- a/tools/node_tools/node_modules_licenses/licenses-map.ts +++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -227,8 +227,14 @@ } const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames)); // Register all package.json files - such files exists in the root folder of each module - nodeModulesFiles.filter(f => path.basename(f) === "package.json") - .forEach(packageJsonFile => builder.addPackageJson(packageJsonFile)); + let packageJsonFiles = nodeModulesFiles.filter(f => path.basename(f) === "package.json"); + // The `safevalue` node module has an unusual setup: It also includes (meaningless) package.json + // file in the dist directory that the license map generator cannot quite process. So let's + // ignore them here. + if (packageJsonFiles.some(path => path.includes('safevalues/package.json'))) { + packageJsonFiles = packageJsonFiles.filter(path => !path.includes('safevalues/') || path.includes('safevalues/package.json')); + } + packageJsonFiles.forEach(packageJsonFile => builder.addPackageJson(packageJsonFile)); // Iterate through all files. builder adds each file to appropriate package nodeModulesFiles.forEach(f => builder.addFile(f)); return builder.build();
diff --git a/tools/run_gjf.sh b/tools/run_gjf.sh index 2e10dc8..e5962a0 100755 --- a/tools/run_gjf.sh +++ b/tools/run_gjf.sh
@@ -1,6 +1,5 @@ -#!/bin/bash # -# Copyright (C) 2024 The Android Open Source Project +# Copyright (C) 2017 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,13 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -set -eu +NEW_SCRIPT=$(dirname $0)/gjf.sh +VERSION=${1:-""} -GJF_VERSION=$(grep -o "^VERSION=.*$" tools/setup_gjf.sh | grep -o '[0-9][0-9]*\.[0-9][0-9]*[\.0-9]*') -GJF="tools/format/google-java-format-$GJF_VERSION" -if [ ! -f "$GJF" ]; then - tools/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 +echo +echo "WARNING:" +echo " Calling $0 is deprecated and $0 will be removed in 3.13. +echo " Call \"$NEW_SCRIPT run $VERSION\" instead"." +echo + +$NEW_SCRIPT run $VERSION
diff --git a/tools/setup_gjf.sh b/tools/setup_gjf.sh index 0ac618e..61a02db 100755 --- a/tools/setup_gjf.sh +++ b/tools/setup_gjf.sh
@@ -14,86 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -set -eu +NEW_SCRIPT=$(dirname $0)/gjf.sh +VERSION=${1:-""} -# Keep this version in sync with -# - Documentation/dev-crafting-changes.txt -# - Documentation/dev-eclipse.txt -VERSION=${1:-1.24.0} +echo +echo "WARNING:" +echo " Calling $0 is deprecated and $0 will be removed in 3.13. +echo " Call \"$NEW_SCRIPT setup $VERSION\" instead"." +echo +$NEW_SCRIPT setup $VERSION - -case "$VERSION" in -1.7) - SHA1="b6d34a51e579b08db7c624505bdf9af4397f1702" - TAG_PREFIX=google-java-format- - ;; -1.24.0) - SHA1="3b55f08a70d53984ac4b3e7796dc992858d6bdd8" - TAG_PREFIX=v - ;; -1.22.0) - SHA1="693d8fd04656886a2287cfe1d7a118c4697c3a57" - TAG_PREFIX=v - ;; -*) - echo "unknown google-java-format version: $VERSION" - exit 1 - ;; -esac - -root="$(git rev-parse --show-toplevel)" -if [[ -z "$root" ]]; then - echo "google-java-format setup requires a git working tree" - exit 1 -fi - -dir="$root/tools/format" -mkdir -p "$dir" - -name="google-java-format-$VERSION-all-deps.jar" -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" -cat > "$launcher" <<EOF -#!/bin/bash -# -# Copyright (C) 2017 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -function abs_script_dir_path { - SOURCE=\${BASH_SOURCE[0]} - while [ -h "\$SOURCE" ]; do - DIR=\$( cd -P \$( dirname "\$SOURCE") && pwd ) - SOURCE=\$(readlink "\$SOURCE") - [[ \$SOURCE != /* ]] && SOURCE="\$DIR/\$SOURCE" - done - DIR=\$( cd -P \$( dirname "\$SOURCE" ) && pwd ) - echo \$DIR -} - -set -e - -dir="\$(abs_script_dir_path "\$0")" -exec java -jar "\$dir/$name" "\$@" -EOF - -chmod +x "$launcher" - -cat <<EOF -Installed launcher script at $launcher -To set up an alias, add the following to your ~/.bashrc or equivalent: - alias google-java-format='$launcher' -EOF
diff --git a/version.bzl b/version.bzl index d41aea7..8d942e01 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.11.1-SNAPSHOT" +GERRIT_VERSION = "3.12.0-SNAPSHOT"