summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.bazelrc2
-rw-r--r--.gitignore2
-rw-r--r--Documentation/access-control.txt55
-rw-r--r--Documentation/cmd-create-project.txt3
-rw-r--r--Documentation/cmd-stream-events.txt15
-rw-r--r--Documentation/concept-changes.txt4
-rw-r--r--Documentation/config-accounts.txt8
-rw-r--r--Documentation/config-gerrit.txt109
-rw-r--r--Documentation/config-labels.txt6
-rw-r--r--Documentation/config-mail.txt6
-rw-r--r--Documentation/config-project-config.txt14
-rw-r--r--Documentation/config-submit-requirements.txt90
-rw-r--r--Documentation/config-validation.txt7
-rw-r--r--Documentation/dev-bazel.txt4
-rw-r--r--Documentation/dev-crafting-changes.txt2
-rw-r--r--Documentation/dev-design.txt4
-rw-r--r--Documentation/dev-eclipse.txt7
-rw-r--r--Documentation/dev-intellij.txt4
-rw-r--r--Documentation/dev-plugins.txt28
-rw-r--r--Documentation/dev-readme.txt2
-rw-r--r--Documentation/dev-release.txt16
-rw-r--r--Documentation/images/browser-notification-example.pngbin0 -> 52725 bytes
-rw-r--r--Documentation/images/browser-notification-preference.pngbin0 -> 10229 bytes
-rw-r--r--Documentation/images/user-review-ui-apply-fix.pngbin0 -> 209097 bytes
-rw-r--r--Documentation/images/user-review-ui-change-metadata.pngbin0 -> 172838 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-annotated.pngbin396510 -> 464816 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-change-info-labels.pngbin31758 -> 0 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-comments-tab.pngbin0 -> 254518 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-file-list.pngbin101459 -> 69085 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.pngbin150984 -> 232790 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-reply.pngbin139390 -> 96081 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen-topleft.pngbin93178 -> 60622 bytes
-rw-r--r--Documentation/images/user-review-ui-change-screen.pngbin278624 -> 413332 bytes
-rw-r--r--Documentation/images/user-review-ui-copy-links.pngbin0 -> 71744 bytes
-rw-r--r--Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.pngbin168859 -> 85058 bytes
-rw-r--r--Documentation/images/user-review-ui-side-by-side-diff-screen.pngbin269196 -> 258875 bytes
-rw-r--r--Documentation/images/user-review-ui-submit-requirements.pngbin0 -> 89819 bytes
-rw-r--r--Documentation/images/user-review-ui-suggest-fix.pngbin0 -> 67993 bytes
-rw-r--r--Documentation/images/user-suggest-edits-preview.pngbin0 -> 307599 bytes
-rw-r--r--Documentation/images/user-suggest-edits-reviewer-comment.pngbin0 -> 81997 bytes
-rw-r--r--Documentation/images/user-suggest-edits-reviewer-preview.pngbin0 -> 75906 bytes
-rw-r--r--Documentation/images/user-suggest-edits-reviewer-suggest-fix.pngbin0 -> 73304 bytes
-rw-r--r--Documentation/images/user-suggest-edits-suggestion.pngbin0 -> 72596 bytes
-rw-r--r--Documentation/index.txt10
-rw-r--r--Documentation/intro-user.txt2
-rw-r--r--Documentation/js_licenses.txt99
-rw-r--r--Documentation/licenses.txt99
-rw-r--r--Documentation/metrics.txt13
-rw-r--r--Documentation/pg-plugin-checks-api.txt4
-rw-r--r--Documentation/pg-plugin-dev.txt37
-rw-r--r--Documentation/pg-plugin-endpoints.txt4
-rw-r--r--Documentation/prolog-cookbook.txt9
-rw-r--r--Documentation/rest-api-accounts.txt3
-rw-r--r--Documentation/rest-api-changes.txt790
-rw-r--r--Documentation/rest-api-config.txt6
-rw-r--r--Documentation/rest-api-projects.txt10
-rw-r--r--Documentation/rest-api.txt10
-rw-r--r--Documentation/user-attention-set.txt29
-rw-r--r--Documentation/user-inline-edit.txt27
-rw-r--r--Documentation/user-notify.txt1
-rw-r--r--Documentation/user-review-ui.txt303
-rw-r--r--Documentation/user-search-accounts.txt5
-rw-r--r--Documentation/user-search.txt44
-rw-r--r--Documentation/user-suggest-edits.txt37
-rw-r--r--Documentation/user-upload.txt6
-rw-r--r--README.md2
-rw-r--r--WORKSPACE8
-rwxr-xr-xcontrib/git-gc-preserve149
-rw-r--r--java/com/google/gerrit/acceptance/AbstractDaemonTest.java199
-rw-r--r--java/com/google/gerrit/acceptance/AbstractNotificationTest.java3
-rw-r--r--java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java2
-rw-r--r--java/com/google/gerrit/acceptance/AccountCreator.java4
-rw-r--r--java/com/google/gerrit/acceptance/BUILD2
-rw-r--r--java/com/google/gerrit/acceptance/DisabledAccountIndex.java5
-rw-r--r--java/com/google/gerrit/acceptance/DisabledChangeIndex.java5
-rw-r--r--java/com/google/gerrit/acceptance/DisabledProjectIndex.java5
-rw-r--r--java/com/google/gerrit/acceptance/GerritServer.java2
-rw-r--r--java/com/google/gerrit/acceptance/HttpResponse.java2
-rw-r--r--java/com/google/gerrit/acceptance/ProjectResetter.java11
-rw-r--r--java/com/google/gerrit/acceptance/PushOneCommit.java27
-rw-r--r--java/com/google/gerrit/acceptance/TestMetricMaker.java85
-rw-r--r--java/com/google/gerrit/acceptance/config/BUILD1
-rw-r--r--java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java4
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java4
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java242
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java9
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java60
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java94
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java94
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/group/BUILD1
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java4
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/project/BUILD3
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java37
-rw-r--r--java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java36
-rw-r--r--java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java1
-rw-r--r--java/com/google/gerrit/auth/ldap/Helper.java2
-rw-r--r--java/com/google/gerrit/auth/ldap/LdapGroupBackend.java1
-rw-r--r--java/com/google/gerrit/auth/ldap/LdapQuery.java2
-rw-r--r--java/com/google/gerrit/auth/ldap/LdapRealm.java5
-rw-r--r--java/com/google/gerrit/auth/oauth/OAuthTokenCache.java2
-rw-r--r--java/com/google/gerrit/common/PageLinks.java4
-rw-r--r--java/com/google/gerrit/common/RawInputUtil.java2
-rw-r--r--java/com/google/gerrit/common/UsedAt.java3
-rw-r--r--java/com/google/gerrit/common/data/GlobalCapability.java11
-rw-r--r--java/com/google/gerrit/common/data/ParameterizedString.java5
-rw-r--r--java/com/google/gerrit/entities/Account.java4
-rw-r--r--java/com/google/gerrit/entities/AccountGroup.java3
-rw-r--r--java/com/google/gerrit/entities/Address.java1
-rw-r--r--java/com/google/gerrit/entities/BooleanProjectConfig.java7
-rw-r--r--java/com/google/gerrit/entities/Change.java19
-rw-r--r--java/com/google/gerrit/entities/Comment.java3
-rw-r--r--java/com/google/gerrit/entities/EmailHeader.java4
-rw-r--r--java/com/google/gerrit/entities/KeyUtil.java8
-rw-r--r--java/com/google/gerrit/entities/LabelFunction.java11
-rw-r--r--java/com/google/gerrit/entities/LabelType.java2
-rw-r--r--java/com/google/gerrit/entities/LabelTypes.java7
-rw-r--r--java/com/google/gerrit/entities/Patch.java23
-rw-r--r--java/com/google/gerrit/entities/PatchSet.java23
-rw-r--r--java/com/google/gerrit/entities/Permission.java90
-rw-r--r--java/com/google/gerrit/entities/PermissionRule.java3
-rw-r--r--java/com/google/gerrit/entities/Project.java4
-rw-r--r--java/com/google/gerrit/entities/ProjectUtil.java41
-rw-r--r--java/com/google/gerrit/entities/RefNames.java37
-rw-r--r--java/com/google/gerrit/entities/StoredCommentLinkInfo.java33
-rw-r--r--java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java2
-rw-r--r--java/com/google/gerrit/entities/converter/ChangeProtoConverter.java7
-rw-r--r--java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java14
-rw-r--r--java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java (renamed from java/com/google/gerrit/extensions/api/changes/AssigneeInput.java)14
-rw-r--r--java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java47
-rw-r--r--java/com/google/gerrit/extensions/api/changes/ChangeApi.java68
-rw-r--r--java/com/google/gerrit/extensions/api/changes/FileContentInput.java1
-rw-r--r--java/com/google/gerrit/extensions/api/changes/RebaseInput.java16
-rw-r--r--java/com/google/gerrit/extensions/api/changes/ReviewInput.java21
-rw-r--r--java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java5
-rw-r--r--java/com/google/gerrit/extensions/api/projects/ConfigInfo.java1
-rw-r--r--java/com/google/gerrit/extensions/api/projects/ConfigInput.java1
-rw-r--r--java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java2
-rw-r--r--java/com/google/gerrit/extensions/client/GerritTopMenu.java4
-rw-r--r--java/com/google/gerrit/extensions/client/Side.java3
-rw-r--r--java/com/google/gerrit/extensions/common/ActionInfo.java25
-rw-r--r--java/com/google/gerrit/extensions/common/ChangeConfigInfo.java4
-rw-r--r--java/com/google/gerrit/extensions/common/ChangeInfo.java12
-rw-r--r--java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java33
-rw-r--r--java/com/google/gerrit/extensions/common/ChangeInput.java7
-rw-r--r--java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java17
-rw-r--r--java/com/google/gerrit/extensions/common/FileInfo.java2
-rw-r--r--java/com/google/gerrit/extensions/common/RebaseChainInfo.java27
-rw-r--r--java/com/google/gerrit/extensions/common/RevisionInfo.java3
-rw-r--r--java/com/google/gerrit/extensions/common/WebLinkInfo.java20
-rw-r--r--java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java10
-rw-r--r--java/com/google/gerrit/extensions/registration/DynamicItemProvider.java2
-rw-r--r--java/com/google/gerrit/extensions/registration/DynamicSet.java2
-rw-r--r--java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java2
-rw-r--r--java/com/google/gerrit/extensions/restapi/IdString.java11
-rw-r--r--java/com/google/gerrit/extensions/restapi/Url.java3
-rw-r--r--java/com/google/gerrit/extensions/webui/UiAction.java21
-rw-r--r--java/com/google/gerrit/extensions/webui/WebLink.java14
-rw-r--r--java/com/google/gerrit/git/GitUpdateFailureException.java5
-rw-r--r--java/com/google/gerrit/git/LockFailureException.java15
-rw-r--r--java/com/google/gerrit/gpg/BUILD1
-rw-r--r--java/com/google/gerrit/gpg/GerritPublicKeyChecker.java3
-rw-r--r--java/com/google/gerrit/gpg/PublicKeyChecker.java6
-rw-r--r--java/com/google/gerrit/gpg/PublicKeyStore.java65
-rw-r--r--java/com/google/gerrit/gpg/PushCertificateChecker.java2
-rw-r--r--java/com/google/gerrit/gpg/server/GpgKeys.java3
-rw-r--r--java/com/google/gerrit/gpg/server/PostGpgKeys.java2
-rw-r--r--java/com/google/gerrit/httpd/BUILD2
-rw-r--r--java/com/google/gerrit/httpd/CacheBasedWebSession.java2
-rw-r--r--java/com/google/gerrit/httpd/GitOverHttpServlet.java2
-rw-r--r--java/com/google/gerrit/httpd/HtmlDomUtil.java35
-rw-r--r--java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java5
-rw-r--r--java/com/google/gerrit/httpd/ProjectOAuthFilter.java4
-rw-r--r--java/com/google/gerrit/httpd/RemoteUserUtil.java2
-rw-r--r--java/com/google/gerrit/httpd/UrlModule.java6
-rw-r--r--java/com/google/gerrit/httpd/WebSessionManager.java2
-rw-r--r--java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java4
-rw-r--r--java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java4
-rw-r--r--java/com/google/gerrit/httpd/auth/openid/LoginForm.java2
-rw-r--r--java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java2
-rw-r--r--java/com/google/gerrit/httpd/auth/restapi/BUILD1
-rw-r--r--java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java2
-rw-r--r--java/com/google/gerrit/httpd/gitweb/GitwebServlet.java6
-rw-r--r--java/com/google/gerrit/httpd/init/WebAppInitializer.java4
-rw-r--r--java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java4
-rw-r--r--java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java2
-rw-r--r--java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java8
-rw-r--r--java/com/google/gerrit/httpd/raw/DocServlet.java65
-rw-r--r--java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java3
-rw-r--r--java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java46
-rw-r--r--java/com/google/gerrit/httpd/raw/ResourceServlet.java40
-rw-r--r--java/com/google/gerrit/httpd/raw/StaticModule.java13
-rw-r--r--java/com/google/gerrit/httpd/raw/WarDocServlet.java8
-rw-r--r--java/com/google/gerrit/httpd/restapi/RestApiServlet.java23
-rw-r--r--java/com/google/gerrit/httpd/template/SiteHeaderFooter.java2
-rw-r--r--java/com/google/gerrit/index/FieldDef.java215
-rw-r--r--java/com/google/gerrit/index/Index.java13
-rw-r--r--java/com/google/gerrit/index/IndexType.java5
-rw-r--r--java/com/google/gerrit/index/IndexedField.java26
-rw-r--r--java/com/google/gerrit/index/Schema.java23
-rw-r--r--java/com/google/gerrit/index/SchemaFieldDefs.java12
-rw-r--r--java/com/google/gerrit/index/SchemaUtil.java30
-rw-r--r--java/com/google/gerrit/index/project/ProjectField.java71
-rw-r--r--java/com/google/gerrit/index/project/ProjectIndex.java5
-rw-r--r--java/com/google/gerrit/index/project/ProjectPredicate.java4
-rw-r--r--java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java27
-rw-r--r--java/com/google/gerrit/index/query/AndPredicate.java2
-rw-r--r--java/com/google/gerrit/index/query/AndSource.java2
-rw-r--r--java/com/google/gerrit/index/query/FieldBundle.java29
-rw-r--r--java/com/google/gerrit/index/query/IndexPredicate.java4
-rw-r--r--java/com/google/gerrit/index/query/IntegerRangePredicate.java4
-rw-r--r--java/com/google/gerrit/index/query/InternalQuery.java8
-rw-r--r--java/com/google/gerrit/index/query/LimitPredicate.java3
-rw-r--r--java/com/google/gerrit/index/query/PaginatingSource.java8
-rw-r--r--java/com/google/gerrit/index/query/QueryProcessor.java6
-rw-r--r--java/com/google/gerrit/index/query/RegexPredicate.java6
-rw-r--r--java/com/google/gerrit/index/query/TimestampRangePredicate.java4
-rw-r--r--java/com/google/gerrit/index/testing/AbstractFakeIndex.java36
-rw-r--r--java/com/google/gerrit/index/testing/BUILD4
-rw-r--r--java/com/google/gerrit/index/testing/TestIndexedFields.java16
-rw-r--r--java/com/google/gerrit/json/EnumTypeAdapterFactory.java2
-rw-r--r--java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java1
-rw-r--r--java/com/google/gerrit/json/SqlTimestampDeserializer.java2
-rw-r--r--java/com/google/gerrit/launcher/GerritLauncher.java7
-rw-r--r--java/com/google/gerrit/lucene/AbstractLuceneIndex.java23
-rw-r--r--java/com/google/gerrit/lucene/ChangeSubIndex.java9
-rw-r--r--java/com/google/gerrit/lucene/LuceneAccountIndex.java3
-rw-r--r--java/com/google/gerrit/lucene/LuceneChangeIndex.java32
-rw-r--r--java/com/google/gerrit/lucene/LuceneGroupIndex.java5
-rw-r--r--java/com/google/gerrit/lucene/LuceneProjectIndex.java15
-rw-r--r--java/com/google/gerrit/lucene/LuceneStoredValue.java5
-rw-r--r--java/com/google/gerrit/lucene/WrappableSearcherManager.java2
-rw-r--r--java/com/google/gerrit/mail/MailHeader.java1
-rw-r--r--java/com/google/gerrit/mail/RawMailParser.java3
-rw-r--r--java/com/google/gerrit/metrics/BUILD1
-rw-r--r--java/com/google/gerrit/metrics/dropwizard/MetricJson.java2
-rw-r--r--java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java2
-rw-r--r--java/com/google/gerrit/pgm/Daemon.java16
-rw-r--r--java/com/google/gerrit/pgm/Reindex.java3
-rw-r--r--java/com/google/gerrit/pgm/SwitchSecureStore.java2
-rw-r--r--java/com/google/gerrit/pgm/init/BUILD1
-rw-r--r--java/com/google/gerrit/pgm/init/BaseInit.java3
-rw-r--r--java/com/google/gerrit/pgm/init/Browser.java16
-rw-r--r--java/com/google/gerrit/pgm/init/InitAdminUser.java2
-rw-r--r--java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java2
-rw-r--r--java/com/google/gerrit/pgm/init/SitePathInitializer.java2
-rw-r--r--java/com/google/gerrit/pgm/init/api/ConsoleUI.java14
-rw-r--r--java/com/google/gerrit/pgm/init/api/InitUtil.java2
-rw-r--r--java/com/google/gerrit/pgm/init/api/Section.java1
-rw-r--r--java/com/google/gerrit/pgm/rules/PrologCompiler.java6
-rw-r--r--java/com/google/gerrit/pgm/util/AbstractProgram.java3
-rw-r--r--java/com/google/gerrit/pgm/util/BatchProgramModule.java8
-rw-r--r--java/com/google/gerrit/prettify/BUILD1
-rw-r--r--java/com/google/gerrit/prettify/common/SparseFileContent.java2
-rw-r--r--java/com/google/gerrit/server/AssigneeStatusUpdate.java35
-rw-r--r--java/com/google/gerrit/server/BranchUtil.java (renamed from java/com/google/gerrit/server/ProjectUtil.java)29
-rw-r--r--java/com/google/gerrit/server/ChangeMessagesUtil.java5
-rw-r--r--java/com/google/gerrit/server/ChangeUtil.java10
-rw-r--r--java/com/google/gerrit/server/CommentsUtil.java1
-rw-r--r--java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java97
-rw-r--r--java/com/google/gerrit/server/IdentifiedUser.java98
-rw-r--r--java/com/google/gerrit/server/PatchSetUtil.java55
-rw-r--r--java/com/google/gerrit/server/PublishCommentsOp.java24
-rw-r--r--java/com/google/gerrit/server/RefLogIdentityProvider.java110
-rw-r--r--java/com/google/gerrit/server/StarredChangesUtil.java138
-rw-r--r--java/com/google/gerrit/server/account/AccountControl.java4
-rw-r--r--java/com/google/gerrit/server/account/AccountDelta.java5
-rw-r--r--java/com/google/gerrit/server/account/AccountLimits.java2
-rw-r--r--java/com/google/gerrit/server/account/AccountManager.java58
-rw-r--r--java/com/google/gerrit/server/account/AccountResolver.java312
-rw-r--r--java/com/google/gerrit/server/account/AccountsUpdate.java102
-rw-r--r--java/com/google/gerrit/server/account/AuthRequest.java10
-rw-r--r--java/com/google/gerrit/server/account/CreateGroupArgs.java2
-rw-r--r--java/com/google/gerrit/server/account/DefaultRealm.java2
-rw-r--r--java/com/google/gerrit/server/account/DestinationList.java2
-rw-r--r--java/com/google/gerrit/server/account/Emails.java11
-rw-r--r--java/com/google/gerrit/server/account/GroupCache.java20
-rw-r--r--java/com/google/gerrit/server/account/GroupCacheImpl.java24
-rw-r--r--java/com/google/gerrit/server/account/GroupIncludeCache.java10
-rw-r--r--java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java49
-rw-r--r--java/com/google/gerrit/server/account/IncludingGroupMembership.java11
-rw-r--r--java/com/google/gerrit/server/account/InternalAccountDirectory.java8
-rw-r--r--java/com/google/gerrit/server/account/InternalGroupBackend.java2
-rw-r--r--java/com/google/gerrit/server/account/ProjectWatches.java1
-rw-r--r--java/com/google/gerrit/server/account/UniversalGroupBackend.java1
-rw-r--r--java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java2
-rw-r--r--java/com/google/gerrit/server/account/externalids/AllExternalIds.java7
-rw-r--r--java/com/google/gerrit/server/account/externalids/ExternalId.java3
-rw-r--r--java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java2
-rw-r--r--java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java1
-rw-r--r--java/com/google/gerrit/server/account/externalids/testing/BUILD1
-rw-r--r--java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java3
-rw-r--r--java/com/google/gerrit/server/api/accounts/AccountApiImpl.java3
-rw-r--r--java/com/google/gerrit/server/api/changes/ChangeApiImpl.java83
-rw-r--r--java/com/google/gerrit/server/api/changes/ChangesImpl.java8
-rw-r--r--java/com/google/gerrit/server/api/projects/ProjectApiImpl.java11
-rw-r--r--java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java (renamed from java/com/google/gerrit/server/events/AssigneeChangedEvent.java)21
-rw-r--r--java/com/google/gerrit/server/api/projects/TagApiImpl.java6
-rw-r--r--java/com/google/gerrit/server/approval/ApprovalCopier.java258
-rw-r--r--java/com/google/gerrit/server/approval/ApprovalsUtil.java226
-rw-r--r--java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java3
-rw-r--r--java/com/google/gerrit/server/args4j/ObjectIdHandler.java11
-rw-r--r--java/com/google/gerrit/server/args4j/ProjectHandler.java2
-rw-r--r--java/com/google/gerrit/server/cache/CacheInfo.java3
-rw-r--r--java/com/google/gerrit/server/cache/PerThreadProjectCache.java2
-rw-r--r--java/com/google/gerrit/server/cache/PerThreadRefDbCache.java44
-rw-r--r--java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java2
-rw-r--r--java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java1
-rw-r--r--java/com/google/gerrit/server/cache/h2/H2CacheImpl.java5
-rw-r--r--java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java2
-rw-r--r--java/com/google/gerrit/server/change/ActionJson.java4
-rw-r--r--java/com/google/gerrit/server/change/ArchiveFormatInternal.java3
-rw-r--r--java/com/google/gerrit/server/change/BatchAbandon.java38
-rw-r--r--java/com/google/gerrit/server/change/ChangeInserter.java55
-rw-r--r--java/com/google/gerrit/server/change/ChangeJson.java10
-rw-r--r--java/com/google/gerrit/server/change/ChangeResource.java3
-rw-r--r--java/com/google/gerrit/server/change/ConsistencyChecker.java49
-rw-r--r--java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java3
-rw-r--r--java/com/google/gerrit/server/change/DeleteReviewerOp.java18
-rw-r--r--java/com/google/gerrit/server/change/EmailNewPatchSet.java5
-rw-r--r--java/com/google/gerrit/server/change/FileInfoJsonImpl.java10
-rw-r--r--java/com/google/gerrit/server/change/GetRelatedChangesUtil.java33
-rw-r--r--java/com/google/gerrit/server/change/LabelNormalizer.java25
-rw-r--r--java/com/google/gerrit/server/change/LabelsJson.java58
-rw-r--r--java/com/google/gerrit/server/change/NotifyResolver.java2
-rw-r--r--java/com/google/gerrit/server/change/PatchSetInserter.java16
-rw-r--r--java/com/google/gerrit/server/change/RebaseChangeOp.java118
-rw-r--r--java/com/google/gerrit/server/change/RebaseUtil.java375
-rw-r--r--java/com/google/gerrit/server/change/RelatedChangesSorter.java52
-rw-r--r--java/com/google/gerrit/server/change/ReviewerModifier.java32
-rw-r--r--java/com/google/gerrit/server/change/RevisionJson.java3
-rw-r--r--java/com/google/gerrit/server/change/SetAssigneeOp.java140
-rw-r--r--java/com/google/gerrit/server/change/ValidationOptionsUtil.java38
-rw-r--r--java/com/google/gerrit/server/change/WorkInProgressOp.java3
-rw-r--r--java/com/google/gerrit/server/comment/CommentContextLoader.java5
-rw-r--r--java/com/google/gerrit/server/config/CapabilityConstants.java1
-rw-r--r--java/com/google/gerrit/server/config/ConfigUpdatedEvent.java3
-rw-r--r--java/com/google/gerrit/server/config/DownloadConfig.java5
-rw-r--r--java/com/google/gerrit/server/config/GerritGlobalModule.java12
-rw-r--r--java/com/google/gerrit/server/config/GitwebConfig.java13
-rw-r--r--java/com/google/gerrit/server/config/ProjectConfigEntry.java5
-rw-r--r--java/com/google/gerrit/server/config/RepositoryConfig.java1
-rw-r--r--java/com/google/gerrit/server/config/SitePaths.java9
-rw-r--r--java/com/google/gerrit/server/data/ChangeAttribute.java1
-rw-r--r--java/com/google/gerrit/server/documentation/MarkdownFormatter.java2
-rw-r--r--java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java2
-rw-r--r--java/com/google/gerrit/server/edit/ChangeEditModifier.java124
-rw-r--r--java/com/google/gerrit/server/edit/ChangeEditUtil.java92
-rw-r--r--java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java31
-rw-r--r--java/com/google/gerrit/server/events/EventFactory.java4
-rw-r--r--java/com/google/gerrit/server/events/EventTypes.java1
-rw-r--r--java/com/google/gerrit/server/events/StreamEventsApiListener.java24
-rw-r--r--java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java15
-rw-r--r--java/com/google/gerrit/server/extensions/events/AssigneeChanged.java76
-rw-r--r--java/com/google/gerrit/server/extensions/events/EventUtil.java1
-rw-r--r--java/com/google/gerrit/server/fixes/FixCalculator.java27
-rw-r--r--java/com/google/gerrit/server/git/BanCommit.java28
-rw-r--r--java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java3
-rw-r--r--java/com/google/gerrit/server/git/CommitUtil.java243
-rw-r--r--java/com/google/gerrit/server/git/DynamicRefDbRepository.java83
-rw-r--r--java/com/google/gerrit/server/git/GroupCollector.java2
-rw-r--r--java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java11
-rw-r--r--java/com/google/gerrit/server/git/MergeUtil.java1
-rw-r--r--java/com/google/gerrit/server/git/MultiProgressMonitor.java7
-rw-r--r--java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java1
-rw-r--r--java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java18
-rw-r--r--java/com/google/gerrit/server/git/WorkQueue.java94
-rw-r--r--java/com/google/gerrit/server/git/meta/TabFile.java3
-rw-r--r--java/com/google/gerrit/server/git/meta/VersionedMetaData.java94
-rw-r--r--java/com/google/gerrit/server/git/receive/ReceiveCommits.java137
-rw-r--r--java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java6
-rw-r--r--java/com/google/gerrit/server/git/receive/ReplaceOp.java9
-rw-r--r--java/com/google/gerrit/server/group/GroupResolver.java2
-rw-r--r--java/com/google/gerrit/server/group/SystemGroupBackend.java4
-rw-r--r--java/com/google/gerrit/server/group/db/GroupsUpdate.java76
-rw-r--r--java/com/google/gerrit/server/group/db/testing/BUILD1
-rw-r--r--java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java37
-rw-r--r--java/com/google/gerrit/server/group/testing/BUILD1
-rw-r--r--java/com/google/gerrit/server/group/testing/TestGroupBackend.java6
-rw-r--r--java/com/google/gerrit/server/index/IndexUtils.java16
-rw-r--r--java/com/google/gerrit/server/index/account/AccountField.java14
-rw-r--r--java/com/google/gerrit/server/index/account/AccountIndex.java3
-rw-r--r--java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java1
-rw-r--r--java/com/google/gerrit/server/index/change/AllChangesIndexer.java70
-rw-r--r--java/com/google/gerrit/server/index/change/ChangeField.java829
-rw-r--r--java/com/google/gerrit/server/index/change/ChangeIndex.java3
-rw-r--r--java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java1
-rw-r--r--java/com/google/gerrit/server/index/change/ChangeIndexer.java26
-rw-r--r--java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java268
-rw-r--r--java/com/google/gerrit/server/index/change/IndexedChangeQuery.java16
-rw-r--r--java/com/google/gerrit/server/index/change/StalenessChecker.java10
-rw-r--r--java/com/google/gerrit/server/index/group/GroupIndex.java3
-rw-r--r--java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java1
-rw-r--r--java/com/google/gerrit/server/index/project/StalenessChecker.java4
-rw-r--r--java/com/google/gerrit/server/ioutil/BUILD1
-rw-r--r--java/com/google/gerrit/server/ioutil/BasicSerialization.java2
-rw-r--r--java/com/google/gerrit/server/ioutil/HostPlatform.java3
-rw-r--r--java/com/google/gerrit/server/mail/EmailModule.java2
-rw-r--r--java/com/google/gerrit/server/mail/EmailSettings.java2
-rw-r--r--java/com/google/gerrit/server/mail/receive/MailProcessor.java18
-rw-r--r--java/com/google/gerrit/server/mail/receive/MailReceiver.java29
-rw-r--r--java/com/google/gerrit/server/mail/send/AddKeySender.java5
-rw-r--r--java/com/google/gerrit/server/mail/send/BranchEmailUtils.java98
-rw-r--r--java/com/google/gerrit/server/mail/send/ChangeEmail.java149
-rw-r--r--java/com/google/gerrit/server/mail/send/CommentSender.java9
-rw-r--r--java/com/google/gerrit/server/mail/send/DeleteKeySender.java5
-rw-r--r--java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java6
-rw-r--r--java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java2
-rw-r--r--java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java2
-rw-r--r--java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java78
-rw-r--r--java/com/google/gerrit/server/mail/send/MergedSender.java28
-rw-r--r--java/com/google/gerrit/server/mail/send/NewChangeSender.java21
-rw-r--r--java/com/google/gerrit/server/mail/send/NotificationEmail.java153
-rw-r--r--java/com/google/gerrit/server/mail/send/OutgoingEmail.java108
-rw-r--r--java/com/google/gerrit/server/mail/send/ProjectWatch.java46
-rw-r--r--java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java2
-rw-r--r--java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java11
-rw-r--r--java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java2
-rw-r--r--java/com/google/gerrit/server/mail/send/SetAssigneeSender.java69
-rw-r--r--java/com/google/gerrit/server/notedb/AbstractChangeNotes.java1
-rw-r--r--java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java6
-rw-r--r--java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java42
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java2
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNoteFooters.java44
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNoteUtil.java331
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNotes.java27
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNotesCache.java2
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNotesCommit.java13
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java334
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNotesParser.java97
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeNotesState.java41
-rw-r--r--java/com/google/gerrit/server/notedb/ChangeUpdate.java83
-rw-r--r--java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java35
-rw-r--r--java/com/google/gerrit/server/notedb/CommitRewriter.java36
-rw-r--r--java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java26
-rw-r--r--java/com/google/gerrit/server/notedb/DraftCommentNotes.java1
-rw-r--r--java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java1
-rw-r--r--java/com/google/gerrit/server/notedb/NoteDbUtil.java2
-rw-r--r--java/com/google/gerrit/server/notedb/RepoSequence.java48
-rw-r--r--java/com/google/gerrit/server/notedb/RobotCommentUpdate.java2
-rw-r--r--java/com/google/gerrit/server/patch/AutoMerger.java6
-rw-r--r--java/com/google/gerrit/server/patch/BaseCommitUtil.java20
-rw-r--r--java/com/google/gerrit/server/patch/DiffOperationsImpl.java12
-rw-r--r--java/com/google/gerrit/server/patch/DiffUtil.java85
-rw-r--r--java/com/google/gerrit/server/patch/FilePathAdapter.java2
-rw-r--r--java/com/google/gerrit/server/patch/IntraLineLoader.java4
-rw-r--r--java/com/google/gerrit/server/patch/PatchScriptBuilder.java6
-rw-r--r--java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java4
-rw-r--r--java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java41
-rw-r--r--java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java48
-rw-r--r--java/com/google/gerrit/server/permissions/AbstractLabelPermission.java155
-rw-r--r--java/com/google/gerrit/server/permissions/ChangeControl.java53
-rw-r--r--java/com/google/gerrit/server/permissions/ChangePermission.java34
-rw-r--r--java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java15
-rw-r--r--java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java7
-rw-r--r--java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java30
-rw-r--r--java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java21
-rw-r--r--java/com/google/gerrit/server/permissions/GlobalPermission.java3
-rw-r--r--java/com/google/gerrit/server/permissions/LabelPermission.java108
-rw-r--r--java/com/google/gerrit/server/permissions/LabelRemovalPermission.java94
-rw-r--r--java/com/google/gerrit/server/permissions/PermissionBackend.java31
-rw-r--r--java/com/google/gerrit/server/permissions/ProjectControl.java2
-rw-r--r--java/com/google/gerrit/server/permissions/RefControl.java2
-rw-r--r--java/com/google/gerrit/server/plugins/JarScanner.java2
-rw-r--r--java/com/google/gerrit/server/plugins/PluginLoader.java2
-rw-r--r--java/com/google/gerrit/server/plugins/ServerPlugin.java1
-rw-r--r--java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java16
-rw-r--r--java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java5
-rw-r--r--java/com/google/gerrit/server/project/CommentLinkProvider.java2
-rw-r--r--java/com/google/gerrit/server/project/CreateProjectArgs.java4
-rw-r--r--java/com/google/gerrit/server/project/DeleteVoteControl.java81
-rw-r--r--java/com/google/gerrit/server/project/GroupList.java1
-rw-r--r--java/com/google/gerrit/server/project/LabelDefinitionJson.java4
-rw-r--r--java/com/google/gerrit/server/project/ProjectCacheImpl.java34
-rw-r--r--java/com/google/gerrit/server/project/ProjectConfig.java86
-rw-r--r--java/com/google/gerrit/server/project/ProjectCreator.java54
-rw-r--r--java/com/google/gerrit/server/project/ProjectHierarchyIterator.java2
-rw-r--r--java/com/google/gerrit/server/project/ProjectState.java9
-rw-r--r--java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java2
-rw-r--r--java/com/google/gerrit/server/project/PrologRulesWarningValidator.java88
-rw-r--r--java/com/google/gerrit/server/project/RefUtil.java4
-rw-r--r--java/com/google/gerrit/server/project/RemoveReviewerControl.java18
-rw-r--r--java/com/google/gerrit/server/project/SectionMatcher.java2
-rw-r--r--java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java31
-rw-r--r--java/com/google/gerrit/server/project/SubmitRequirementsUtil.java7
-rw-r--r--java/com/google/gerrit/server/query/account/AccountPredicates.java17
-rw-r--r--java/com/google/gerrit/server/query/account/AccountQueryBuilder.java47
-rw-r--r--java/com/google/gerrit/server/query/account/InternalAccountQuery.java2
-rw-r--r--java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java3
-rw-r--r--java/com/google/gerrit/server/query/change/AddedPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/AfterPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/AgePredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/BeforePredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/BooleanPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java61
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeData.java41
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java6
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java7
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java6
-rw-r--r--java/com/google/gerrit/server/query/change/ChangePredicates.java89
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java316
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java6
-rw-r--r--java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java7
-rw-r--r--java/com/google/gerrit/server/query/change/DeletedPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/DeltaPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/DestinationPredicate.java43
-rw-r--r--java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java38
-rw-r--r--java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/FileExtensionPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/GroupPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/InternalChangeQuery.java11
-rw-r--r--java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java6
-rw-r--r--java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/LabelPredicate.java5
-rw-r--r--java/com/google/gerrit/server/query/change/MagicLabelPredicates.java31
-rw-r--r--java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/change/RegexPathPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/RegexProjectPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/RegexRefPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/ReviewerPredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java5
-rw-r--r--java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java74
-rw-r--r--java/com/google/gerrit/server/query/change/SubmittablePredicate.java2
-rw-r--r--java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java4
-rw-r--r--java/com/google/gerrit/server/query/group/InternalGroupQuery.java31
-rw-r--r--java/com/google/gerrit/server/query/project/ProjectPredicates.java10
-rw-r--r--java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java98
-rw-r--r--java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java106
-rw-r--r--java/com/google/gerrit/server/restapi/BUILD1
-rw-r--r--java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java142
-rw-r--r--java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java169
-rw-r--r--java/com/google/gerrit/server/restapi/account/GetCapabilities.java3
-rw-r--r--java/com/google/gerrit/server/restapi/account/GetEmails.java2
-rw-r--r--java/com/google/gerrit/server/restapi/account/GetExternalIds.java4
-rw-r--r--java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java2
-rw-r--r--java/com/google/gerrit/server/restapi/account/QueryAccounts.java6
-rw-r--r--java/com/google/gerrit/server/restapi/account/StarredChanges.java10
-rw-r--r--java/com/google/gerrit/server/restapi/change/Abandon.java23
-rw-r--r--java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java26
-rw-r--r--java/com/google/gerrit/server/restapi/change/AllowedFormats.java3
-rw-r--r--java/com/google/gerrit/server/restapi/change/ApplyPatch.java235
-rw-r--r--java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java194
-rw-r--r--java/com/google/gerrit/server/restapi/change/ChangeEdits.java12
-rw-r--r--java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java8
-rw-r--r--java/com/google/gerrit/server/restapi/change/ChangesCollection.java34
-rw-r--r--java/com/google/gerrit/server/restapi/change/CherryPickChange.java179
-rw-r--r--java/com/google/gerrit/server/restapi/change/CommentJson.java1
-rw-r--r--java/com/google/gerrit/server/restapi/change/CreateChange.java251
-rw-r--r--java/com/google/gerrit/server/restapi/change/CreateDraftComment.java18
-rw-r--r--java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java26
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteAssignee.java118
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteChange.java14
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java10
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteComment.java13
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java15
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeletePrivate.java9
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteReviewer.java32
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteVote.java55
-rw-r--r--java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java62
-rw-r--r--java/com/google/gerrit/server/restapi/change/Files.java1
-rw-r--r--java/com/google/gerrit/server/restapi/change/GetAssignee.java45
-rw-r--r--java/com/google/gerrit/server/restapi/change/GetChange.java1
-rw-r--r--java/com/google/gerrit/server/restapi/change/GetPastAssignees.java54
-rw-r--r--java/com/google/gerrit/server/restapi/change/GetPatch.java12
-rw-r--r--java/com/google/gerrit/server/restapi/change/Mergeable.java9
-rw-r--r--java/com/google/gerrit/server/restapi/change/Move.java13
-rw-r--r--java/com/google/gerrit/server/restapi/change/OnPostReview.java3
-rw-r--r--java/com/google/gerrit/server/restapi/change/PostHashtags.java18
-rw-r--r--java/com/google/gerrit/server/restapi/change/PostPrivate.java9
-rw-r--r--java/com/google/gerrit/server/restapi/change/PostReview.java159
-rw-r--r--java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java236
-rw-r--r--java/com/google/gerrit/server/restapi/change/PostReviewOp.java417
-rw-r--r--java/com/google/gerrit/server/restapi/change/PostReviewers.java16
-rw-r--r--java/com/google/gerrit/server/restapi/change/PutAssignee.java133
-rw-r--r--java/com/google/gerrit/server/restapi/change/PutDescription.java13
-rw-r--r--java/com/google/gerrit/server/restapi/change/PutDraftComment.java18
-rw-r--r--java/com/google/gerrit/server/restapi/change/PutMessage.java45
-rw-r--r--java/com/google/gerrit/server/restapi/change/PutTopic.java13
-rw-r--r--java/com/google/gerrit/server/restapi/change/Rebase.java190
-rw-r--r--java/com/google/gerrit/server/restapi/change/RebaseChain.java334
-rw-r--r--java/com/google/gerrit/server/restapi/change/RebaseMetrics.java61
-rw-r--r--java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java25
-rw-r--r--java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java10
-rw-r--r--java/com/google/gerrit/server/restapi/change/Restore.java12
-rw-r--r--java/com/google/gerrit/server/restapi/change/RevertSubmission.java128
-rw-r--r--java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java2
-rw-r--r--java/com/google/gerrit/server/restapi/change/Revisions.java1
-rw-r--r--java/com/google/gerrit/server/restapi/change/SetReadyForReview.java28
-rw-r--r--java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java16
-rw-r--r--java/com/google/gerrit/server/restapi/change/Submit.java7
-rw-r--r--java/com/google/gerrit/server/restapi/config/GetServerInfo.java26
-rw-r--r--java/com/google/gerrit/server/restapi/config/GetSummary.java14
-rw-r--r--java/com/google/gerrit/server/restapi/config/ReloadConfig.java4
-rw-r--r--java/com/google/gerrit/server/restapi/group/CreateGroup.java2
-rw-r--r--java/com/google/gerrit/server/restapi/group/ListGroups.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/BanCommit.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/CreateAccessChange.java36
-rw-r--r--java/com/google/gerrit/server/restapi/project/CreateBranch.java252
-rw-r--r--java/com/google/gerrit/server/restapi/project/CreateChange.java4
-rw-r--r--java/com/google/gerrit/server/restapi/project/CreateProject.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/CreateTag.java113
-rw-r--r--java/com/google/gerrit/server/restapi/project/DashboardsCollection.java1
-rw-r--r--java/com/google/gerrit/server/restapi/project/DeleteBranch.java7
-rw-r--r--java/com/google/gerrit/server/restapi/project/DeleteBranches.java9
-rw-r--r--java/com/google/gerrit/server/restapi/project/DeleteRef.java100
-rw-r--r--java/com/google/gerrit/server/restapi/project/DeleteTag.java7
-rw-r--r--java/com/google/gerrit/server/restapi/project/DeleteTags.java16
-rw-r--r--java/com/google/gerrit/server/restapi/project/GetAccess.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/ProjectsCollection.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/PutConfig.java2
-rw-r--r--java/com/google/gerrit/server/restapi/project/SetAccess.java67
-rw-r--r--java/com/google/gerrit/server/rules/PrologRuleEvaluator.java3
-rw-r--r--java/com/google/gerrit/server/rules/RulesCache.java4
-rw-r--r--java/com/google/gerrit/server/schema/AllProjectsCreator.java63
-rw-r--r--java/com/google/gerrit/server/schema/AllUsersCreator.java22
-rw-r--r--java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java469
-rw-r--r--java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java21
-rw-r--r--java/com/google/gerrit/server/schema/SchemaCreatorImpl.java54
-rw-r--r--java/com/google/gerrit/server/schema/Schema_184.java25
-rw-r--r--java/com/google/gerrit/server/securestore/DefaultSecureStore.java2
-rw-r--r--java/com/google/gerrit/server/securestore/SecureStore.java3
-rw-r--r--java/com/google/gerrit/server/submit/CherryPick.java2
-rw-r--r--java/com/google/gerrit/server/submit/EmailMerge.java6
-rw-r--r--java/com/google/gerrit/server/submit/MergeMetrics.java164
-rw-r--r--java/com/google/gerrit/server/submit/MergeOp.java176
-rw-r--r--java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java2
-rw-r--r--java/com/google/gerrit/server/submit/SubmitStrategy.java5
-rw-r--r--java/com/google/gerrit/server/submit/SubmitStrategyOp.java16
-rw-r--r--java/com/google/gerrit/server/submit/SubmoduleCommits.java2
-rw-r--r--java/com/google/gerrit/server/submit/SubmoduleOp.java13
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java (renamed from java/com/google/gerrit/server/query/change/ConstantPredicate.java)4
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java (renamed from java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java)4
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java (renamed from java/com/google/gerrit/server/query/FileEditsPredicate.java)2
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java108
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java (renamed from java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java)4
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java57
-rw-r--r--java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java71
-rw-r--r--java/com/google/gerrit/server/update/BatchUpdate.java263
-rw-r--r--java/com/google/gerrit/server/update/context/RefUpdateContext.java178
-rw-r--r--java/com/google/gerrit/server/util/AttentionSetEmail.java7
-rw-r--r--java/com/google/gerrit/server/util/LabelVote.java5
-rw-r--r--java/com/google/gerrit/server/util/MagicBranch.java2
-rw-r--r--java/com/google/gerrit/server/util/git/BUILD1
-rw-r--r--java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java2
-rw-r--r--java/com/google/gerrit/server/validators/AssigneeValidationListener.java32
-rw-r--r--java/com/google/gerrit/sshd/BaseCommand.java1
-rw-r--r--java/com/google/gerrit/sshd/Commands.java2
-rw-r--r--java/com/google/gerrit/sshd/DatabasePubKeyAuth.java2
-rw-r--r--java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java2
-rw-r--r--java/com/google/gerrit/sshd/SshPluginStarterCallback.java2
-rw-r--r--java/com/google/gerrit/sshd/commands/CreateAccountCommand.java2
-rw-r--r--java/com/google/gerrit/sshd/commands/ReviewCommand.java3
-rw-r--r--java/com/google/gerrit/sshd/commands/ShowQueue.java6
-rw-r--r--java/com/google/gerrit/testing/BUILD18
-rw-r--r--java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java141
-rw-r--r--java/com/google/gerrit/testing/GerritTestName.java3
-rw-r--r--java/com/google/gerrit/testing/InMemoryModule.java6
-rw-r--r--java/com/google/gerrit/testing/InMemoryRepositoryManager.java173
-rw-r--r--java/com/google/gerrit/testing/IndexVersions.java11
-rw-r--r--java/com/google/gerrit/testing/RefUpdateContextCollector.java92
-rw-r--r--java/com/google/gerrit/testing/SshMode.java3
-rw-r--r--java/com/google/gerrit/testing/TestActionRefUpdateContext.java73
-rw-r--r--java/com/google/gerrit/testing/TestChanges.java17
-rw-r--r--java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java176
-rw-r--r--java/com/google/gerrit/util/cli/BUILD17
-rw-r--r--java/com/google/gerrit/util/cli/CmdLineParser.java2
-rw-r--r--javatests/com/google/gerrit/acceptance/ProjectResetterTest.java44
-rw-r--r--javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java202
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java314
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java25
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java117
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/BUILD1
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java1
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java19
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java521
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java1186
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java22
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java540
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java317
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java40
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java31
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java302
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java1401
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java1412
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java1229
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/RevertIT.java228
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java21
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java312
-rw-r--r--javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java7
-rw-r--r--javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java45
-rw-r--r--javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java9
-rw-r--r--javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java46
-rw-r--r--javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/api/project/AccessIT.java112
-rw-r--r--javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java264
-rw-r--r--javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java6
-rw-r--r--javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java60
-rw-r--r--javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java80
-rw-r--r--javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java22
-rw-r--r--javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java37
-rw-r--r--javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java183
-rw-r--r--javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java25
-rw-r--r--javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java7
-rw-r--r--javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java72
-rw-r--r--javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java556
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java10
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/TraceIT.java46
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java1
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java6
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java5
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java14
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java374
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java4
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java32
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java21
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java212
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java95
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java8
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java283
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java180
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java2
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java6
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java9
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java43
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java31
-rw-r--r--javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java48
-rw-r--r--javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java166
-rw-r--r--javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java55
-rw-r--r--javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java143
-rw-r--r--javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java12
-rw-r--r--javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java56
-rw-r--r--javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java16
-rw-r--r--javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java5
-rw-r--r--javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java328
-rw-r--r--javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java6
-rw-r--r--javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java23
-rw-r--r--javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java29
-rw-r--r--javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java2
-rw-r--r--javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java168
-rw-r--r--javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java68
-rw-r--r--javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java3
-rw-r--r--javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java285
-rw-r--r--javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java13
-rw-r--r--javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java449
-rw-r--r--javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java141
-rw-r--r--javatests/com/google/gerrit/common/data/BUILD1
-rw-r--r--javatests/com/google/gerrit/common/data/GroupReferenceTest.java3
-rw-r--r--javatests/com/google/gerrit/entities/PermissionTest.java32
-rw-r--r--javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java5
-rw-r--r--javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java31
-rw-r--r--javatests/com/google/gerrit/extensions/BUILD1
-rw-r--r--javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java387
-rw-r--r--javatests/com/google/gerrit/extensions/restapi/IdStringTest.java (renamed from java/com/google/gerrit/extensions/events/AssigneeChangedListener.java)24
-rw-r--r--javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java3
-rw-r--r--javatests/com/google/gerrit/httpd/BUILD1
-rw-r--r--javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java42
-rw-r--r--javatests/com/google/gerrit/httpd/raw/DocServletTest.java167
-rw-r--r--javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java1
-rw-r--r--javatests/com/google/gerrit/httpd/raw/IndexServletTest.java7
-rw-r--r--javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java3
-rw-r--r--javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java82
-rw-r--r--javatests/com/google/gerrit/index/SchemaUtilTest.java77
-rw-r--r--javatests/com/google/gerrit/index/query/AndSourceTest.java2
-rw-r--r--javatests/com/google/gerrit/json/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/IdentifiedUserTest.java8
-rw-r--r--javatests/com/google/gerrit/server/account/AccountResolverTest.java44
-rw-r--r--javatests/com/google/gerrit/server/cache/BUILD2
-rw-r--r--javatests/com/google/gerrit/server/cache/mem/BUILD2
-rw-r--r--javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java19
-rw-r--r--javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java2
-rw-r--r--javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java3
-rw-r--r--javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java3
-rw-r--r--javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java17
-rw-r--r--javatests/com/google/gerrit/server/events/EventDeserializerTest.java16
-rw-r--r--javatests/com/google/gerrit/server/events/EventJsonTest.java45
-rw-r--r--javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java6
-rw-r--r--javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java35
-rw-r--r--javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java5
-rw-r--r--javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java1
-rw-r--r--javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java57
-rw-r--r--javatests/com/google/gerrit/server/group/db/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/index/IndexedFieldTest.java5
-rw-r--r--javatests/com/google/gerrit/server/index/account/AccountFieldTest.java3
-rw-r--r--javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java5
-rw-r--r--javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java20
-rw-r--r--javatests/com/google/gerrit/server/mail/send/BranchEmailUtilsTest.java (renamed from javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java)6
-rw-r--r--javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java5
-rw-r--r--javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java2
-rw-r--r--javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java23
-rw-r--r--javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java100
-rw-r--r--javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java56
-rw-r--r--javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java104
-rw-r--r--javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java12
-rw-r--r--javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java3
-rw-r--r--javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java348
-rw-r--r--javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java5
-rw-r--r--javatests/com/google/gerrit/server/project/ProjectConfigTest.java33
-rw-r--r--javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java124
-rw-r--r--javatests/com/google/gerrit/server/query/account/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java1447
-rw-r--r--javatests/com/google/gerrit/server/query/change/ChangeDataTest.java1
-rw-r--r--javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java76
-rw-r--r--javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java33
-rw-r--r--javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java27
-rw-r--r--javatests/com/google/gerrit/server/query/group/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java2
-rw-r--r--javatests/com/google/gerrit/server/query/project/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java1
-rw-r--r--javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java7
-rw-r--r--javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java5
-rw-r--r--javatests/com/google/gerrit/server/submit/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java3
-rw-r--r--javatests/com/google/gerrit/server/update/BUILD1
-rw-r--r--javatests/com/google/gerrit/server/update/BatchUpdateTest.java215
-rw-r--r--javatests/com/google/gerrit/server/update/RepoViewTest.java26
-rw-r--r--javatests/com/google/gerrit/server/update/context/BUILD14
-rw-r--r--javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java93
-rw-r--r--javatests/com/google/gerrit/util/http/testutil/BUILD1
-rw-r--r--javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java2
-rw-r--r--javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java12
m---------modules/jgit0
-rw-r--r--package.json9
-rw-r--r--plugins/BUILD31
m---------plugins/codemirror-editor0
m---------plugins/delete-project0
m---------plugins/gitiles0
-rw-r--r--plugins/package.json30
m---------plugins/replication0
m---------plugins/reviewnotes0
m---------plugins/singleusergroup0
-rw-r--r--plugins/yarn.lock1688
-rw-r--r--polygerrit-ui/FE_Style_Guide.md91
-rw-r--r--polygerrit-ui/README.md83
-rw-r--r--polygerrit-ui/app/.eslintrc.js15
-rw-r--r--polygerrit-ui/app/BUILD7
-rw-r--r--polygerrit-ui/app/api/annotation.ts17
-rw-r--r--polygerrit-ui/app/api/checks.ts3
-rw-r--r--polygerrit-ui/app/api/core.ts27
-rw-r--r--polygerrit-ui/app/api/diff.ts22
-rw-r--r--polygerrit-ui/app/api/embed.ts3
-rw-r--r--polygerrit-ui/app/api/gerrit.ts2
-rw-r--r--polygerrit-ui/app/api/package.json4
-rw-r--r--polygerrit-ui/app/api/plugin.ts8
-rw-r--r--polygerrit-ui/app/api/rest-api.ts73
-rw-r--r--polygerrit-ui/app/api/rest.ts5
-rw-r--r--polygerrit-ui/app/api/styles.ts21
-rw-r--r--polygerrit-ui/app/constants/constants.ts12
-rw-r--r--polygerrit-ui/app/constants/reporting.ts33
-rw-r--r--polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts42
-rw-r--r--polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts15
-rw-r--r--polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts61
-rw-r--r--polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts47
-rw-r--r--polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts43
-rw-r--r--polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts69
-rw-r--r--polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts15
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts20
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts170
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts103
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts20
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts11
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts16
-rw-r--r--polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts62
-rw-r--r--polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts2
-rw-r--r--polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts87
-rw-r--r--polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts32
-rw-r--r--polygerrit-ui/app/elements/admin/gr-group/gr-group.ts53
-rw-r--r--polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts43
-rw-r--r--polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts6
-rw-r--r--polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts14
-rw-r--r--polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts25
-rw-r--r--polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts11
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts2
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts63
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.ts9
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts58
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts21
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts87
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts290
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts79
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts280
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts15
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts20
-rw-r--r--polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts22
-rw-r--r--polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts38
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts3
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts3
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts23
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts10
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts39
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts67
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts12
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts13
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts46
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts52
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts72
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts111
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section.ts8
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts10
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts12
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts17
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts31
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts15
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts38
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts25
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts11
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts16
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts10
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts29
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts10
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts76
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.ts25
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts3
-rw-r--r--polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts10
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts291
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts220
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts86
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts26
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts214
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts131
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts25
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts18
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts1535
-rw-r--r--polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts1218
-rw-r--r--polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts231
-rw-r--r--polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts68
-rw-r--r--polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts36
-rw-r--r--polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts27
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts22
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts21
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts45
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts31
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts191
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts96
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts92
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts23
-rw-r--r--polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts23
-rw-r--r--polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts27
-rw-r--r--polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts168
-rw-r--r--polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts42
-rw-r--r--polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts267
-rw-r--r--polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts125
-rw-r--r--polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts8
-rw-r--r--polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts21
-rw-r--r--polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts7
-rw-r--r--polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts2
-rw-r--r--polygerrit-ui/app/elements/change/gr-message/gr-message.ts92
-rw-r--r--polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts111
-rw-r--r--polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts56
-rw-r--r--polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts44
-rw-r--r--polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts40
-rw-r--r--polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts137
-rw-r--r--polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts130
-rw-r--r--polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts44
-rw-r--r--polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts440
-rw-r--r--polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts558
-rw-r--r--polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts30
-rw-r--r--polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts19
-rw-r--r--polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts6
-rw-r--r--polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts6
-rw-r--r--polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts70
-rw-r--r--polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts43
-rw-r--r--polygerrit-ui/app/elements/checks/gr-checks-results.ts57
-rw-r--r--polygerrit-ui/app/elements/checks/gr-checks-results_test.ts2
-rw-r--r--polygerrit-ui/app/elements/checks/gr-checks-runs.ts2
-rw-r--r--polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts20
-rw-r--r--polygerrit-ui/app/elements/checks/gr-checks-util.ts9
-rw-r--r--polygerrit-ui/app/elements/checks/gr-diff-check-result.ts62
-rw-r--r--polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts27
-rw-r--r--polygerrit-ui/app/elements/checks/gr-hovercard-run.ts1
-rw-r--r--polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts14
-rw-r--r--polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts7
-rw-r--r--polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts122
-rw-r--r--polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts73
-rw-r--r--polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts9
-rw-r--r--polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts61
-rw-r--r--polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts15
-rw-r--r--polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts18
-rw-r--r--polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts138
-rw-r--r--polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts92
-rw-r--r--polygerrit-ui/app/elements/core/gr-router/gr-page.ts376
-rw-r--r--polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts118
-rw-r--r--polygerrit-ui/app/elements/core/gr-router/gr-router.ts782
-rw-r--r--polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts1513
-rw-r--r--polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts33
-rw-r--r--polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts13
-rw-r--r--polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts46
-rw-r--r--polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts2
-rw-r--r--polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts164
-rw-r--r--polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts70
-rw-r--r--polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts121
-rw-r--r--polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts44
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts294
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts287
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts67
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts10
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts1248
-rw-r--r--polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts1430
-rw-r--r--polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts69
-rw-r--r--polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts48
-rw-r--r--polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts9
-rw-r--r--polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts5
-rw-r--r--polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts14
-rw-r--r--polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts61
-rw-r--r--polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts222
-rw-r--r--polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts31
-rw-r--r--polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts110
-rw-r--r--polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts44
-rw-r--r--polygerrit-ui/app/elements/gr-app-element.ts283
-rw-r--r--polygerrit-ui/app/elements/gr-app-global-var-init.ts31
-rw-r--r--polygerrit-ui/app/elements/gr-app-types.ts5
-rw-r--r--polygerrit-ui/app/elements/gr-app.ts82
-rw-r--r--polygerrit-ui/app/elements/gr-app_test.ts15
-rw-r--r--polygerrit-ui/app/elements/gr-css-mixins.ts4
-rw-r--r--polygerrit-ui/app/elements/integration_test.ts74
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts9
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts5
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts15
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts13
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts5
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts27
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts15
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts5
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts10
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts75
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts124
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts12
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts27
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts17
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts20
-rw-r--r--polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts5
-rw-r--r--polygerrit-ui/app/elements/polymer-util.ts14
-rw-r--r--polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts86
-rw-r--r--polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts72
-rw-r--r--polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts2
-rw-r--r--polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts9
-rw-r--r--polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts16
-rw-r--r--polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts12
-rw-r--r--polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts30
-rw-r--r--polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts10
-rw-r--r--polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts16
-rw-r--r--polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts16
-rw-r--r--polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts9
-rw-r--r--polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts12
-rw-r--r--polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts6
-rw-r--r--polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts45
-rw-r--r--polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts21
-rw-r--r--polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts16
-rw-r--r--polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts12
-rw-r--r--polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts17
-rw-r--r--polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts2
-rw-r--r--polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts14
-rw-r--r--polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts46
-rw-r--r--polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts25
-rw-r--r--polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts2
-rw-r--r--polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts9
-rw-r--r--polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts17
-rw-r--r--polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts7
-rw-r--r--polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts196
-rw-r--r--polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts382
-rw-r--r--polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts284
-rw-r--r--polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts466
-rw-r--r--polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts28
-rw-r--r--polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts17
-rw-r--r--polygerrit-ui/app/elements/shared/gr-button/gr-button.ts22
-rw-r--r--polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts9
-rw-r--r--polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts22
-rw-r--r--polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts7
-rw-r--r--polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts180
-rw-r--r--polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts92
-rw-r--r--polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts472
-rw-r--r--polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts211
-rw-r--r--polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts17
-rw-r--r--polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts15
-rw-r--r--polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts14
-rw-r--r--polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts3
-rw-r--r--polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts (renamed from polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js)64
-rw-r--r--polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts99
-rw-r--r--polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts193
-rw-r--r--polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts26
-rw-r--r--polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts2
-rw-r--r--polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts9
-rw-r--r--polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts8
-rw-r--r--polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts18
-rw-r--r--polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts26
-rw-r--r--polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts15
-rw-r--r--polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts22
-rw-r--r--polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts3
-rw-r--r--polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts44
-rw-r--r--polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts18
-rw-r--r--polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts14
-rw-r--r--polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts7
-rw-r--r--polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts36
-rw-r--r--polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts28
-rw-r--r--polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts125
-rw-r--r--polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts280
-rw-r--r--polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts579
-rw-r--r--polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts385
-rw-r--r--polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts545
-rw-r--r--polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts375
-rw-r--r--polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts153
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts133
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js81
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts10
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts9
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts44
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts38
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts306
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts63
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts162
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts1
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js349
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts401
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts21
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts10
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts (renamed from polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js)116
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts68
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts82
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts230
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts84
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts14
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts7
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts43
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts51
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts77
-rw-r--r--polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts9
-rw-r--r--polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts4
-rw-r--r--polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts9
-rw-r--r--polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts70
-rw-r--r--polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts94
-rw-r--r--polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts162
-rw-r--r--polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts29
-rw-r--r--polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts77
-rw-r--r--polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts16
-rw-r--r--polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts8
-rw-r--r--polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts143
-rw-r--r--polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts86
-rw-r--r--polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js10
-rw-r--r--polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts91
-rw-r--r--polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts193
-rw-r--r--polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts3
-rw-r--r--polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts111
-rw-r--r--polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts54
-rw-r--r--polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts71
-rw-r--r--polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts56
-rw-r--r--polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts132
-rw-r--r--polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts70
-rw-r--r--polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts17
-rw-r--r--polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts75
-rw-r--r--polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts142
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts34
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts158
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts611
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts378
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts503
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts178
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts165
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts283
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts385
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts474
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts271
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts250
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts315
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts152
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts166
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts61
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts123
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts79
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts152
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts29
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts (renamed from polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js)250
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts13
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts8
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts18
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts97
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts16
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts11
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts13
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts14
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts47
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts163
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts100
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection.ts156
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff-selection/gr-diff-selection_test.ts283
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts72
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts59
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts5
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts671
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts62
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts106
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts904
-rw-r--r--polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts3361
-rw-r--r--polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts40
-rw-r--r--polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts15
-rw-r--r--polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts21
-rw-r--r--polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts14
-rw-r--r--polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts17
-rw-r--r--polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts19
-rw-r--r--polygerrit-ui/app/embed/gr-diff-app-context-init.ts24
-rw-r--r--polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts75
-rw-r--r--polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts12
-rw-r--r--polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts30
-rw-r--r--polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts30
-rw-r--r--polygerrit-ui/app/models/accounts-model/accounts-model.ts31
-rw-r--r--polygerrit-ui/app/models/browser/browser-model.ts3
-rw-r--r--polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts6
-rw-r--r--polygerrit-ui/app/models/change/change-model.ts542
-rw-r--r--polygerrit-ui/app/models/change/change-model_test.ts241
-rw-r--r--polygerrit-ui/app/models/change/files-model.ts49
-rw-r--r--polygerrit-ui/app/models/change/related-changes-model.ts242
-rw-r--r--polygerrit-ui/app/models/change/related-changes-model_test.ts286
-rw-r--r--polygerrit-ui/app/models/checks/checks-model.ts77
-rw-r--r--polygerrit-ui/app/models/checks/checks-model_test.ts87
-rw-r--r--polygerrit-ui/app/models/checks/checks-util.ts45
-rw-r--r--polygerrit-ui/app/models/checks/checks-util_test.ts2
-rw-r--r--polygerrit-ui/app/models/comments/comments-model.ts383
-rw-r--r--polygerrit-ui/app/models/comments/comments-model_test.ts75
-rw-r--r--polygerrit-ui/app/models/config/config-model.ts6
-rw-r--r--polygerrit-ui/app/models/dependency.ts59
-rw-r--r--polygerrit-ui/app/models/plugins/plugins-model.ts39
-rw-r--r--polygerrit-ui/app/models/plugins/plugins-model_test.ts4
-rw-r--r--polygerrit-ui/app/models/user/user-model.ts14
-rw-r--r--polygerrit-ui/app/models/views/admin.ts261
-rw-r--r--polygerrit-ui/app/models/views/admin_test.ts (renamed from polygerrit-ui/app/utils/admin-nav-util_test.ts)50
-rw-r--r--polygerrit-ui/app/models/views/base.ts10
-rw-r--r--polygerrit-ui/app/models/views/change.ts222
-rw-r--r--polygerrit-ui/app/models/views/change_test.ts118
-rw-r--r--polygerrit-ui/app/models/views/dashboard.ts22
-rw-r--r--polygerrit-ui/app/models/views/dashboard_test.ts35
-rw-r--r--polygerrit-ui/app/models/views/diff.ts104
-rw-r--r--polygerrit-ui/app/models/views/diff_test.ts64
-rw-r--r--polygerrit-ui/app/models/views/documentation.ts7
-rw-r--r--polygerrit-ui/app/models/views/edit.ts60
-rw-r--r--polygerrit-ui/app/models/views/edit_test.ts35
-rw-r--r--polygerrit-ui/app/models/views/group.ts6
-rw-r--r--polygerrit-ui/app/models/views/repo.ts12
-rw-r--r--polygerrit-ui/app/models/views/search.ts46
-rw-r--r--polygerrit-ui/app/models/views/search_test.ts36
-rw-r--r--polygerrit-ui/app/node_modules_licenses/licenses.ts12
-rw-r--r--polygerrit-ui/app/package.json5
-rw-r--r--polygerrit-ui/app/rollup.config.js7
-rw-r--r--polygerrit-ui/app/scripts/hiddenscroll.ts23
-rw-r--r--polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js4
-rw-r--r--polygerrit-ui/app/scripts/rootElement.ts10
-rw-r--r--polygerrit-ui/app/scripts/util.ts47
-rw-r--r--polygerrit-ui/app/services/app-context-init.ts309
-rw-r--r--polygerrit-ui/app/services/app-context.ts16
-rw-r--r--polygerrit-ui/app/services/flags/flags.ts4
-rw-r--r--polygerrit-ui/app/services/gr-auth/gr-auth.ts11
-rw-r--r--polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts21
-rw-r--r--polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts14
-rw-r--r--polygerrit-ui/app/services/gr-auth/gr-auth_test.ts31
-rw-r--r--polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts59
-rw-r--r--polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts126
-rw-r--r--polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js123
-rw-r--r--polygerrit-ui/app/services/gr-reporting/gr-reporting.ts2
-rw-r--r--polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts53
-rw-r--r--polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl.ts294
-rw-r--r--polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js1578
-rw-r--r--polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts1677
-rw-r--r--polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts63
-rw-r--r--polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts (renamed from polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts)24
-rw-r--r--polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts (renamed from polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts)2
-rw-r--r--polygerrit-ui/app/services/highlight/highlight-service.ts3
-rw-r--r--polygerrit-ui/app/services/router/router-model.ts31
-rw-r--r--polygerrit-ui/app/services/service-worker-installer.ts77
-rw-r--r--polygerrit-ui/app/services/service-worker-installer_test.ts7
-rw-r--r--polygerrit-ui/app/services/shortcuts/shortcuts-config.ts12
-rw-r--r--polygerrit-ui/app/services/shortcuts/shortcuts-service.ts14
-rw-r--r--polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts6
-rw-r--r--polygerrit-ui/app/services/storage/gr-storage.ts16
-rw-r--r--polygerrit-ui/app/services/storage/gr-storage_impl.ts50
-rw-r--r--polygerrit-ui/app/services/storage/gr-storage_mock.ts33
-rw-r--r--polygerrit-ui/app/services/storage/gr-storage_test.ts140
-rw-r--r--polygerrit-ui/app/styles/dashboard-header-styles.ts3
-rw-r--r--polygerrit-ui/app/styles/gr-change-list-styles.ts4
-rw-r--r--polygerrit-ui/app/styles/gr-form-styles.ts1
-rw-r--r--polygerrit-ui/app/styles/gr-menu-page-styles.ts2
-rw-r--r--polygerrit-ui/app/styles/gr-modal-styles.ts30
-rw-r--r--polygerrit-ui/app/styles/gr-page-nav-styles.ts5
-rw-r--r--polygerrit-ui/app/styles/shared-styles.ts5
-rw-r--r--polygerrit-ui/app/styles/themes/app-theme.ts31
-rw-r--r--polygerrit-ui/app/styles/themes/dark-theme.ts27
-rw-r--r--polygerrit-ui/app/test/common-test-setup.ts62
-rw-r--r--polygerrit-ui/app/test/functional/README.md54
-rw-r--r--polygerrit-ui/app/test/functional/infra/Dockerfile38
-rwxr-xr-xpolygerrit-ui/app/test/functional/infra/run.sh14
-rw-r--r--polygerrit-ui/app/test/functional/infra/test-infra.js24
-rwxr-xr-xpolygerrit-ui/app/test/functional/run_functional.sh10
-rw-r--r--polygerrit-ui/app/test/functional/test.js21
-rw-r--r--polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts31
-rw-r--r--polygerrit-ui/app/test/test-app-context-init.ts199
-rw-r--r--polygerrit-ui/app/test/test-data-generators.ts204
-rw-r--r--polygerrit-ui/app/test/test-utils.ts158
-rw-r--r--polygerrit-ui/app/tsconfig.json2
-rw-r--r--polygerrit-ui/app/types/common.ts212
-rw-r--r--polygerrit-ui/app/types/diff.ts13
-rw-r--r--polygerrit-ui/app/types/events.ts154
-rw-r--r--polygerrit-ui/app/types/types.ts21
-rw-r--r--polygerrit-ui/app/utils/account-util.ts38
-rw-r--r--polygerrit-ui/app/utils/admin-nav-util.ts249
-rw-r--r--polygerrit-ui/app/utils/async-util.ts180
-rw-r--r--polygerrit-ui/app/utils/async-util_test.ts52
-rw-r--r--polygerrit-ui/app/utils/attention-set-util.ts39
-rw-r--r--polygerrit-ui/app/utils/attention-set-util_test.ts44
-rw-r--r--polygerrit-ui/app/utils/change-util.ts25
-rw-r--r--polygerrit-ui/app/utils/change-util_test.ts28
-rw-r--r--polygerrit-ui/app/utils/comment-util.ts279
-rw-r--r--polygerrit-ui/app/utils/comment-util_test.ts28
-rw-r--r--polygerrit-ui/app/utils/common-util.ts7
-rw-r--r--polygerrit-ui/app/utils/date-util.ts108
-rw-r--r--polygerrit-ui/app/utils/date-util_test.ts12
-rw-r--r--polygerrit-ui/app/utils/display-name-util.ts6
-rw-r--r--polygerrit-ui/app/utils/dom-util.ts38
-rw-r--r--polygerrit-ui/app/utils/dom-util_test.ts63
-rw-r--r--polygerrit-ui/app/utils/event-util.ts91
-rw-r--r--polygerrit-ui/app/utils/file-util.ts45
-rw-r--r--polygerrit-ui/app/utils/file-util_test.ts38
-rw-r--r--polygerrit-ui/app/utils/label-util.ts9
-rw-r--r--polygerrit-ui/app/utils/link-util.ts40
-rw-r--r--polygerrit-ui/app/utils/link-util_test.ts94
-rw-r--r--polygerrit-ui/app/utils/lit-util.ts69
-rw-r--r--polygerrit-ui/app/utils/lit-util_test.ts118
-rw-r--r--polygerrit-ui/app/utils/message-util_test.ts37
-rw-r--r--polygerrit-ui/app/utils/page-wrapper-utils.ts42
-rw-r--r--polygerrit-ui/app/utils/patch-set-util.ts17
-rw-r--r--polygerrit-ui/app/utils/path-list-util.ts4
-rw-r--r--polygerrit-ui/app/utils/path-list-util_test.ts6
-rw-r--r--polygerrit-ui/app/utils/string-util.ts21
-rw-r--r--polygerrit-ui/app/utils/string-util_test.ts8
-rw-r--r--polygerrit-ui/app/utils/submit-requirement-util.ts12
-rw-r--r--polygerrit-ui/app/utils/url-util.ts149
-rw-r--r--polygerrit-ui/app/utils/url-util_test.ts110
-rw-r--r--polygerrit-ui/app/utils/weblink-util.ts41
-rw-r--r--polygerrit-ui/app/utils/weblink-util_test.ts17
-rw-r--r--polygerrit-ui/app/workers/service-worker-class.ts33
-rw-r--r--polygerrit-ui/app/workers/service-worker-class_test.ts2
-rw-r--r--polygerrit-ui/app/yarn.lock34
-rw-r--r--polygerrit-ui/package.json2
-rw-r--r--polygerrit-ui/yarn.lock52
-rw-r--r--proto/cache.proto16
-rw-r--r--proto/entities.proto3
-rwxr-xr-xresources/com/google/gerrit/server/commit-msg_test.sh25
-rw-r--r--resources/com/google/gerrit/server/config/CapabilityConstants.properties1
-rw-r--r--resources/com/google/gerrit/server/mail/Comment.soy7
-rw-r--r--resources/com/google/gerrit/server/mail/SetAssignee.soy71
-rw-r--r--resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy56
-rwxr-xr-xresources/com/google/gerrit/server/tools/root/hooks/commit-msg2
-rw-r--r--tools/BUILD16
-rw-r--r--tools/bzl/asciidoc.bzl2
-rw-r--r--tools/deps.bzl37
-rw-r--r--tools/maven/gerrit-acceptance-framework_pom.xml2
-rw-r--r--tools/maven/gerrit-extension-api_pom.xml2
-rw-r--r--tools/maven/gerrit-plugin-api_pom.xml2
-rw-r--r--tools/maven/gerrit-war_pom.xml2
-rw-r--r--tools/migration/html_to_link_commentlink.md47
-rw-r--r--tools/node_tools/node_modules_licenses/licenses-map.ts3
-rw-r--r--tools/nongoogle.bzl4
-rw-r--r--version.bzl2
1368 files changed, 58119 insertions, 35706 deletions
diff --git a/.bazelrc b/.bazelrc
index 407b00568a..cf5403d2b0 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -37,6 +37,6 @@ build --incompatible_strict_action_env
build --announce_rc
test --build_tests_only
-test --test_output=all
+test --test_output=errors
import %workspace%/tools/remote-bazelrc
diff --git a/.gitignore b/.gitignore
index 53bc9f6238..0bbcabacb4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,8 @@ js-to-ts.sh
/infer-out
/local.properties
/node_modules/
+/polygerrit-ui/node_modules/
+/polygerrit-ui/app/node_modules/
/package-lock.json
/plugins/*
/polygerrit-ui/coverage/
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 3da69dfb41..cf89982723 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -782,7 +782,7 @@ is already restricted to the correct set of users.
=== Rebase
This category permits users to rebase changes via the web UI by pushing
-the `Rebase Change` button.
+the `REBASE` button.
The change owner and submitters can always rebase changes in the web UI
(even without having the `Rebase` access right assigned).
@@ -800,6 +800,22 @@ the `Revert Change` button.
Users without this access right who are able to upload changes can
still do the revert locally and upload the revert commit as a new change.
+[[category_remove_label]]
+=== Remove Label (Remove Vote)
+
+For every configured label `My-Name` in the project, there is a
+corresponding permission `removeLabel-My-Name` with a range corresponding to
+the defined values. For these values, the users are permitted to remove
+other users' votes from a change.
+
+Change owners can always remove zero or positive votes (even without
+having the `Remove Vote` access right assigned).
+
+Project owners and site administrators can always remove any vote (even
+without having the `Remove Vote` access right assigned).
+
+Users without this access right can still remove their own votes.
+
[[category_remove_reviewer]]
=== Remove Reviewer
@@ -890,6 +906,9 @@ Change owner, server administrators and project owners can always flip
the Work In Progress bit of the change (even without having the
`Toggle Work In Progress state` access right assigned).
+Must be assigned on the target branch ref (i.e. on 'refs/heads/*', not on
+'refs/for/*').
+
[[category_delete_own_changes]]
=== Delete Own Changes
@@ -932,15 +951,6 @@ The change owner, branch owners, project owners, and site administrators
can always edit or remove hashtags (even without having the `Edit Hashtags`
access right assigned).
-[[category_edit_assigned_to]]
-=== Edit Assignee
-
-This category permits users to set who is assigned to a change that is
-uploaded for review.
-
-The change owner, ref owners, and the user currently assigned to a change
-can always change the assignee.
-
[[example_roles]]
== Examples of typical roles in a project
@@ -1143,7 +1153,7 @@ works across project inheritance, from the top down, so an administrator can
use 'BLOCK' rules to enforce site-wide restrictions.
For example, if a user in the 'Foo Users' group tries to push to
-'refs/heads/mater' with the permissions below, that user will be blocked
+'refs/heads/master' with the permissions below, that user will be blocked
[options="header"]
|=========================================================================
@@ -1351,10 +1361,11 @@ by navigating at the top of the page to BROWSE -> Groups, and then pushing the
[[capability_createProject]]
=== Create Project
-Allow project creation. This capability allows the granted group to
-either link:cmd-create-project.html[create new git projects via ssh]
-or via the web UI.
+Allow project creation.
+This capability allows the granted group to create projects via the web UI, via
+link:rest-api-projects.html#create-project][REST] and via
+link:cmd-create-project.html[SSH].
[[capability_emailReviewers]]
=== Email Reviewers
@@ -1406,8 +1417,8 @@ Implies the following capabilities:
Allow to link:cmd-set-account.html[modify accounts over the ssh prompt].
This capability allows the granted group members to modify any user account
-setting. In addition this capability is required to view secondary emails
-of other accounts.
+setting. In addition this capability allows to view secondary emails of other
+accounts.
[[capability_priority]]
=== Priority
@@ -1509,7 +1520,8 @@ setting.
This capability allows to view all accounts but not all account data.
E.g. secondary emails of all accounts can only be viewed with the
-link:#capability_modifyAccount[Modify Account] capability.
+link:#capability_viewSecondaryEmails[View Secondary Emails] capability
+or the link:#capability_modifyAccount[Modify Account] capability.
[[capability_viewCaches]]
@@ -1542,6 +1554,15 @@ allows the granted group to
link:cmd-show-queue.html[look at the Gerrit task queue via ssh].
+[[capability_viewSecondaryEmails]]
+=== View Secondary Emails
+
+Allows viewing secondary emails of other accounts.
+
+Users with the link:#capability_modifyAccount[Modify Account] capability have
+this capbility implicitly.
+
+
[[reference]]
== Permission evaluation reference
diff --git a/Documentation/cmd-create-project.txt b/Documentation/cmd-create-project.txt
index 358324d19a..87f385105a 100644
--- a/Documentation/cmd-create-project.txt
+++ b/Documentation/cmd-create-project.txt
@@ -108,6 +108,7 @@ Description values containing spaces should be quoted in single quotes
Action used by Gerrit to submit an approved change to its
destination branch. Supported options are:
+
+* INHERIT: inherits the submit-type from the parent project.
* FAST_FORWARD_ONLY: produces a strictly linear history.
* MERGE_IF_NECESSARY: create a merge commit when required.
* REBASE_IF_NECESSARY: rebase the commit when required.
@@ -116,7 +117,7 @@ Description values containing spaces should be quoted in single quotes
* CHERRY_PICK: always cherry-pick the commit.
+
-Defaults to MERGE_IF_NECESSARY unless
+Defaults to INHERIT unless
link:config-gerrit.html#repository.name.defaultSubmitType[
repository.<name>.defaultSubmitType] is set to a different value.
For more details see link:config-project-config.html#submit-type[
diff --git a/Documentation/cmd-stream-events.txt b/Documentation/cmd-stream-events.txt
index 5fd0bfc8c0..24566628be 100644
--- a/Documentation/cmd-stream-events.txt
+++ b/Documentation/cmd-stream-events.txt
@@ -58,21 +58,6 @@ this JSON stream should deal with that appropriately.
[[events]]
== EVENTS
-=== Assignee Changed
-
-Sent when the assignee of a change has been modified.
-
-type:: "assignee-changed"
-
-change:: link:json.html#change[change attribute]
-
-changer:: link:json.html#account[account attribute]
-
-oldAssignee:: Assignee before it was changed.
-
-eventCreatedOn:: Time in seconds since the UNIX epoch when this event was
-created.
-
=== Change Abandoned
Sent when a change has been abandoned.
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 6e76a8aafd..323b32a908 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -39,10 +39,6 @@ properties about that change.
`git push` command, or the user that triggered the patch set creation through
an action in the UI).
-|Assignee
-|The contributor responsible for the change. Often used when a change has
-mulitple reviewers to identify the individual responsible for final approval.
-
|Reviewers
|A list of one or more contributors responsible for reviewing the change.
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 716fa2f861..f46c821803 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -380,6 +380,14 @@ first update the external authentication record in that system,
log in to Gerrit, then Gerrit will update the external ID record with
the new email address.
+=== Transition from LDAP to Google OAuth
+
+When authentication is changed from LDAP to Google Oauth gerrit will automatically
+adjust the external IDs in the `refs/meta/external-ids` branch. Gerrit will re-use
+the same account ID that was used by the LDAP account. Transition to other OAuth
+mechanisms will fail and require manual changes to the `refs/meta/external-ids` branch.
+The LDAP e-mail and Google OAuth e-mail must be the same.
+
[[starred-changes]]
== Starred Changes
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 72519daf3e..dd75945ac7 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -562,7 +562,8 @@ By default this is false (no agreements are used).
+
To enable the actual usage of contributor agreement the project
specific config option in the `project.config` must be set:
-link:config-project-config.html[receive.requireContributorAgreement].
+link:config-project-config.html#receive.requireContributorAgreement[
+receive.requireContributorAgreement].
[[auth.trustContainerAuth]]auth.trustContainerAuth::
+
@@ -1429,20 +1430,6 @@ If set to true, users are not allowed to create private changes.
+
The default is false.
-[[change.enableAttentionSet]]change.enableAttentionSet::
-+
-If set to true, then all UI features for using and interacting with the
-attention set are enabled.
-+
-The default is true.
-
-[[change.enableAssignee]]change.enableAssignee::
-+
-If set to true, then all UI features for using and interacting with the
-assignee are enabled.
-+
-The default is false.
-
[[change.maxComments]]change.maxComments::
+
Maximum number of comments (regular plus robot) allowed per change. Additional
@@ -1551,6 +1538,13 @@ link:https://issues.gerritcodereview.com/issues/40009784[issue 40009784]).
+
By default true.
+[[change.enableRobotComments]]change.enableRobotComments::
++
+Are robot comments enabled in the Gerrit UI? This setting allows phasing out
+robot comments.
++
+By default true.
+
[[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
+
Maximum allowed size in characters of a robot comment. Robot comments which
@@ -1756,9 +1750,7 @@ In the following example configuration the 'changeid' comment link
will match typical Gerrit Change-Id values and create a hyperlink
to changes which reference it. The second configuration 'bugzilla'
will hyperlink terms such as 'bug 42' to an external bug tracker,
-supplying the argument record number '42' for display. The third
-configuration 'tracker' uses raw HTML to more precisely control
-how the replacement is displayed to the user.
+supplying the argument record number '42' for display.
commentlinks supports link:#reloadConfig[configuration reloads]. Though a
link:cmd-flush-caches.html[flush-caches] of "projects" is needed for the
@@ -1775,16 +1767,10 @@ commentlinks to be immediately available in the UI.
prefix = $1
suffix = $4
text = $2$3
-
-[commentlink "tracker"]
- match = ([Bb]ug:\\s+)(\\d+)
- html = $1<a href=\"http://trak.example.com/$2\">$2</a>
----
Comment links can also be specified in `project.config` and sections in
-children override those in parents. The only restriction is that to
-avoid injecting arbitrary user-supplied HTML in the page, comment links
-defined in `project.config` may only supply `link`, not `html`.
+children override those in parents.
[[commentlink.name.match]]commentlink.<name>.match::
+
@@ -1818,38 +1804,23 @@ In order to better control the visual presentation of the link `prefix`,
+
The URL to direct the user to whenever the regular expression is
matched. Groups in the match expression may be accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
[[commentlink.name.prefix]]commentlink.<name>.prefix::
+
The text inserted before the link. Groups in the match expression may be
accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
[[commentlink.name.suffix]]commentlink.<name>.suffix::
+
The text inserted after the link. Groups in the match expression may be
accessed as `$'n'`.
-+
-The link property is used only when the html property is not present.
[[commentlink.name.text]]commentlink.<name>.text::
+
The text content of the link. Groups in the match expression may be
accessed as `$'n'`.
+
-The link property is used only when the html property is not present.
-
-[[commentlink.name.html]]commentlink.<name>.html::
-+
-HTML to replace the entire matched string with. If present,
-this property overrides the link property above. Groups in the
-match expression may be accessed as `$'n'`.
-+
-The configuration file eats double quotes, so escaping them as
-`\"` is necessary to protect them from the parser.
+If not specified defaults to `$&` (the matched text).
[[commentlink.name.enabled]]commentlink.<name>.enabled::
+
@@ -1857,11 +1828,6 @@ Whether the comment link is enabled. A child project may override a
section in a parent or the site-wide config that is disabled by
specifying `enabled = true`.
+
-Disabling sections in `gerrit.config` can be used by site administrators
-to create a library of comment links with `html` set that are not
-user-supplied and thus can be verified to be XSS-free, but are only
-enabled for a subset of projects.
-+
By default, true.
+
Note that the names and contents of disabled sections are visible even
@@ -2109,6 +2075,16 @@ Values can be specified using standard time unit abbreviations (`ms`, `sec`,
+
Default is 1 hour.
+[[core.usePerRequestRefCache]]core.usePerRequestRefCache::
++
+Use a per request (currently per request thread) ref cache. The ref
+cache uses JGit's SnapshottingRefDirectory to ensure that packed
+refs are checked and potentially read at least once per request
+(lazily) if needed. This helps reduce the overhead of checking if
+the packed-refs file is outdated.
++
+Default is true.
+
[[dashboard]]
=== Section dashboard
@@ -2808,6 +2784,39 @@ groups either. This means there is no danger of ambiguous group names
when this parameter is removed and the system group uses the default
name again.
+[[groups.relevantGroup]]groups.relevantGroup::
++
+UUID of an external group that should always be considered as relevant
+when checking whether an account is visible.
++
+This setting is only relevant for external group backends and only if
+the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP`.
++
+If the link:#accounts.visibility[account visibility] is set to
+`SAME_GROUP` or `VISIBLE_GROUP` users should see all accounts that are
+a member of a group that contains themselves or that is visible to
+them. Checking this would require getting all groups of the current
+user and all groups of the accounts for which the visibility is being
+checked, but since getting all groups that a user is a member of is
+expensive for external group backends Gerrit doesn't query these groups
+but instead guesses the relevant groups. Guessing relevant groups
+limits the inspected groups to all groups that are mentioned in the
+ACLs of the projects that are currently cached (i.e. all groups that
+are listed in the link:config-project-config.html#file-groups[groups]
+files of the cached projects). This is not very reliable since it
+depends on which groups are mentioned in the ACLs and which projects
+are currently cached. To make this more reliable this configuration
+parameter allows to configure external groups that should always be
+considered as relevant.
++
+As said this setting is only relevant for external group backends. In
+Gerrit core this is only the LDAP backend, but it may apply to further
+group backends that are added by plugins.
++
+This parameter may be added multiple times to specify multiple relevant
+groups.
+
[[has-operand-alias]]
=== Section has operand alias
@@ -4356,6 +4365,14 @@ Values can be specified using standard time unit abbreviations ('ms',
+
Default is 5 seconds. Negative values will be converted to 0.
+[[plugins.transitionalPushOptions]]plugins.transitionalPushOptions::
++
+Additional push options which should be accepted by gerrit as valid
+options even if they are not registered by any plugin(e.g. "myplugin~foo").
++
+This config can be used when gerrit migrates from a deprecated plugin to the new one. The new plugin
+can (temporary) accept push options of the old plugin without registering such options.
+
[[receive]]
=== Section receive
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index e4eee102ea..ce63295527 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -200,9 +200,9 @@ values. It is not possible to modify an inherited label by adding
properties in the child project's configuration; all properties from
the parent definition must be redefined in the child.
-To remove a label in a child project, add an empty label with the same
-name as in the parent. This will override the parent label with
-a label containing the defaults (`function = MaxWithBlock`,
+To remove a label in a child project, add an empty label with a single "0"
+value, with the same name as in the parent. This will override the parent label
+with a label containing the defaults (`function = NoBlock`,
`defaultValue = 0` and no further allowed values)
[[label_layout]]
diff --git a/Documentation/config-mail.txt b/Documentation/config-mail.txt
index 8bd5dc709f..4f11ca8674 100644
--- a/Documentation/config-mail.txt
+++ b/Documentation/config-mail.txt
@@ -139,12 +139,6 @@ The Reverted templates will determine the contents of the email related to a
change being reverted. It is a `ChangeEmail`: see `ChangeSubject.soy` and
ChangeFooter.
-=== SetAssignee.soy and SetAssigneeHtml.soy
-
-The SetAssignee templates will determine the contents of the email related to a
-user being assigned to a change. It is a `ChangeEmail`: see `ChangeSubject.soy`
-and ChangeFooter.
-
== Mail Variables and Methods
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 781b458977..25fe9f3001 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -458,11 +458,6 @@ since they find the linear history easier to read.
NOTE: Rebasing merge commits is not supported. If a change with a merge commit
is submitted the link:#merge_if_necessary[merge if necessary] submit action is
applied.
-+
-When rebasing the patchset, Gerrit automatically appends onto the end of the
-commit message a short summary of the change's approvals, and a URL link back
-to the change in the web UI (see link:#submit-footers[below]). If a fast-forward
-is done no footers are added.
[[rebase_always]]
* 'rebase always':
@@ -737,6 +732,15 @@ Default is `INHERIT`, which means that this property is inherited from
the parent project. If the property is not set in any parent project, the
default value is `FALSE`.
+[[reviewer.skipAddingAuthorAndCommitterAsReviewers]]reviewer.skipAddingAuthorAndCommitterAsReviewers::
++
+Whether to skip adding the Git commit author and committer as reviewers for
+a new change.
++
+Default is `INHERIT`, which means that this property is inherited from
+the parent project. If the property is not set in any parent project, the
+default value is `FALSE`.
+
[[file-groups]]
== The file +groups+
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt
index 8298be39b7..fb12ff320b 100644
--- a/Documentation/config-submit-requirements.txt
+++ b/Documentation/config-submit-requirements.txt
@@ -160,6 +160,22 @@ specific regular expression pattern. The
link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
is used for the evaluation of such patterns.
+[[operator_committeremail]]
+committeremail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change committer's email address matches a
+specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
+[[operator_uploaderemail]]
+uploaderemail:'EMAIL_PATTERN'::
++
+An operator that returns true if the change uploader's primary email address
+matches a specific regular expression pattern. The
+link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
+is used for the evaluation of such patterns.
+
[[operator_distinctvoters]]
distinctvoters:'[Label1,Label2,...,LabelN],value=MAX,count>1'::
+
@@ -187,11 +203,57 @@ An operator that always returns false for all changes. An example usage is to
redefine a submit requirement in a child project and make the submit requirement
always non-applicable.
+[[operator_has_submodule_update]]
+has:submodule-update::
++
+An operator that returns true if the diff of the latest patchset against the
+default parent has a submodule modified file, that is, a ".gitmodules" or a
+git link file.
++
+The optional `base` parameter can also be supplied for merge commits like
+`has:submodule-update,base=1`, or `has:submodule-update,base=2`. In these cases,
+the operator returns true if the diff of the latest patchset against parent
+number identified by `base` has a submodule modified file. Note that the
+operator will return false if the base parameter is greater than the number of
+parents for the latest patchset for the change.
+
[[operator_file]]
file:"'<filePattern>',withDiffContaining='<contentPattern>'"::
+
An operator that returns true if the latest patchset contained a modified file
matching `<filePattern>` with a modified region matching `<contentPattern>`.
++
+Both `<filePattern>` and `<contentPattern>` support regular expressions if they
+start with the '^' character. Regular expressions are matched with the
+`java.util.regex` engine. When using regular expressions, special characters
+should be double escaped because the config is parsed twice when the server
+reads the `project.config` file and when the submit-requirement expressions
+are parsed as a predicate tree. For example, to match against modified files
+that end with ".cc" or ".cpp" the following `applicableIf` expression can be
+used:
++
+----
+ applicableIf = file:\"^.*\\\\.(cc|cpp)$\"
+----
++
+Below is another example that uses both `<filePattern>` and `<contentPattern>`:
++
+----
+ applicableIf = file:\"'^.*\\\\.(cc|cpp)$',withDiffContaining='^.*th[rR]ee$'\"
+----
++
+If no regular expression is used, the text is matched by checking that the file
+name contains the file pattern, or the edits of the file diff contain the edit
+pattern.
+
+[[operator_label]]
+label:labelName=+1,user=non_contributor::
++
+Submit requirements support an additional `user=non_contributor` argument for
+labels that returns true if the change has a label vote matching the specified
+value and the vote is applied from a gerrit account that's not the uploader,
+author or committer of the latest patchset. See the documentation for the labels
+operator in the link:user-search.html[user search] page.
[[unsupported_operators]]
=== Unsupported Operators
@@ -268,6 +330,34 @@ refs/meta/config branch.
canOverrideInChildProjects = true
----
+Branch configuration supports regular expressions as well, e.g. to exempt 'refs/heads/release/*' pattern,
+when migrating from the label Submit-Rule:
+
+----
+[label "Verified"]
+ branch = refs/heads/release/*
+----
+
+The following SR can be configured:
+
+----
+[submit-requirement "Verified"]
+ submittableIf = label:Verified=MAX AND -label:Verified=MIN
+ applicableIf = branch:^refs/heads/release/.*
+----
+
+[[require-footer-example]]
+=== Require a footer Example
+
+It's possible to use a submit requirement to require a footer to be present in
+the commit message.
+
+----
+[submit-requirement "Bug-Footer"]
+ description = Changes must include a 'Bug' footer
+ applicableIf = -branch:refs/meta/config AND -hasfooter:\"Bug\"
+ submittableIf = hasfooter:\"Bug\"
+----
[[test-submit-requirements]]
== Testing Submit Requirements
diff --git a/Documentation/config-validation.txt b/Documentation/config-validation.txt
index 56c9ecdc01..b0149fe81e 100644
--- a/Documentation/config-validation.txt
+++ b/Documentation/config-validation.txt
@@ -100,13 +100,6 @@ input arguments.
E.g. a plugin could use this to enforce a certain name scheme for
group names.
-[[assignee-validation]]
-== Assignee validation
-
-
-Plugins implementing the `AssigneeValidationListener` interface can perform
-validation of assignees before they are assigned to a change.
-
[[hashtag-validation]]
== Hashtag validation
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index a2bd6fca6e..1c3fd78808 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -365,7 +365,7 @@ Example:
----
Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
-configuration in Eclipe and specify the port `5005`.
+configuration in Eclipse and specify the port `5005`.
[[logging]]
=== Controlling logging level
@@ -564,7 +564,7 @@ bazel run @nodejs//:yarn add $package
----
Update the `polygerrit-ui/app/node_modules_licenses/licenses.ts` file. You should add licenses
-for the package itself and for all transitive depndencies. If you forgot to add a license, the
+for the package itself and for all transitive dependencies. If you forgot to add a license, the
`Documentation:check_licenses` test will fail.
After the update, commit all changes to the repository (including `yarn.lock`).
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index ac0780da71..6150c20890 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -28,7 +28,7 @@ guidelines:
might take a while until you could benefit from it. In that case,
implement the feature on master and, if you really need it on an
earlier `stable-*` branch, cherry-pick the change and build
- Gerrit on your own environent.
+ Gerrit in your own environment.
* Bug-fixes should generally at least cover the oldest affected and
still supported version. If you're affected and run an even older
version, you're welcome to upload to that older version, even if
diff --git a/Documentation/dev-design.txt b/Documentation/dev-design.txt
index 5636dfda3d..176b53f2ee 100644
--- a/Documentation/dev-design.txt
+++ b/Documentation/dev-design.txt
@@ -98,7 +98,7 @@ port, bypassing the HTTP server used by browsers.
User authentication is handled by identity realms. Gerrit supports the
following types of authentication:
-* OpenId (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
+* OpenID (see link:http://openid.net/developers/specs/[OpenID Specifications,role=external,window=_blank])
* OAuth2
* LDAP
* Google accounts (on googlesource.com)
@@ -373,7 +373,7 @@ Gerrit supports the Git wire protocol, and an API (one API for HTTP,
and one for SSH).
The git wire protocol does a client/server negotiation to avoid
-sending too much data. This negotation occupies a CPU, so the number
+sending too much data. This negotiation occupies a CPU, so the number
of concurrent push/fetch operations should be capped by the number of
CPUs.
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index afd2825c7e..f4238d1451 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -61,9 +61,10 @@ on Mac too. Edit, or create, the `$HOME/.bazelrc` file and add the following lin
startup --output_user_root=/Users/johndoe/.cache/bazel
----
-==== Increase the treshold for the cleanup of temporary files
-The default treshold for the cleanup can be overriden by creating a configuration file under
-`/etc/periodic.conf` and setting a larger value for the `daily_clean_tmps_days`.
+==== Increase the threshold for the cleanup of temporary files
+The default threshold for the cleanup can be overridden by creating a configuration
+file under `/etc/periodic.conf` and setting a larger value for the
+`daily_clean_tmps_days`.
An example `/etc/periodic.conf` file:
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index ab2082f9ed..be4196c51c 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -31,13 +31,13 @@ indicates that the Bazel plugin couldn't find Java 11.
=== Installation of IntelliJ IDEA
Please refer to the
-link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by Jetbrains,role=external,window=_blank]
+link:https://www.jetbrains.com/help/idea/installation-guide.html[installation guide provided by JetBrains,role=external,window=_blank]
to install it on your platform. Make sure to install a version compatible with
the Bazel plugin as mentioned above.
== Installation of the Bazel plugin
-The plugin is usually installed using the Jetbrains plugin repository as shown
+The plugin is usually installed using the JetBrains plugin repository as shown
in the steps below, but it's also possible to
link:https://github.com/bazelbuild/intellij[build it from source].
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 11f85dd5d9..3c4e9ea6a4 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -533,6 +533,24 @@ a DynamicItem, so Gerrit may only have one copy.
Certain operations in Gerrit can be validated by plugins by
implementing the corresponding link:config-validation.html[listeners].
+[[taskListeners]]
+== WorkQueue.TaskListeners
+
+It is possible for plugins to listen to
+`com.google.gerrit.server.git.WorkQueue$Task`s directly before they run, and
+directly after they complete. This may be used to delay task executions based
+on custom criteria by blocking, likely on a lock or semaphore, inside
+onStart(), and a lock/semaphore release in onStop(). Plugins may listen to
+tasks by implementing a `com.google.gerrit.server.git.WorkQueue$TaskListener`
+and registering the new listener like this:
+
+[source,java]
+----
+bind(TaskListener.class)
+ .annotatedWith(Exports.named("MyListener"))
+ .to(MyListener.class);
+----
+
[[change-message-modifier]]
== Change Message Modifier
@@ -945,7 +963,7 @@ additional parameters.
When calling command options not provided by your plugin, there is always
a risk that the options may not exist, perhaps because the options being
called are to be provided by another plugin, and said plugin is not
-currently installed. To protect againt this situation, it is possible to
+currently installed. To protect against this situation, it is possible to
define an option as being dependent on other options using the
@RequiresOptions() annotation. If the required options are not all not
currently present, then the dependent option will not be available or
@@ -981,7 +999,7 @@ public class JsonOutputOptionHandler<T> extends OptionHandler<T> {
@RequiresOptions("--format")
@Option(
name = "--special",
- usage = "ouptut results using json",
+ usage = "output results using json",
handler = JsonOutputOptionHandler.class
)
boolean json;
@@ -2191,7 +2209,6 @@ PatchSetWebLinks will appear to the right of the commit-SHA-1 in the UI.
----
import com.google.gerrit.extensions.annotations.Listen;
import com.google.gerrit.extensions.webui.PatchSetWebLink;;
-import com.google.gerrit.extensions.webui.WebLinkTarget;
@Listen
public class MyWeblinkPlugin implements PatchSetWebLink {
@@ -2204,8 +2221,7 @@ public class MyWeblinkPlugin implements PatchSetWebLink {
String commitMessage, String branchName) {
return new WebLinkInfo(name,
imageUrl,
- String.format(placeHolderUrlProjectCommit, project, commit),
- WebLinkTarget.BLANK);
+ String.format(placeHolderUrlProjectCommit, project, commit));
}
}
----
@@ -2725,7 +2741,7 @@ randomness.
Plugins are expected to support rules inheritance themselves, providing ways
to configure it and handling the logic behind it.
Please note that no inheritance is sometimes better than badly handled
-inheritance: mis-communication and strange behaviors caused by inheritance
+inheritance: miscommunication and strange behaviors caused by inheritance
may and will confuse the users. Each plugins is responsible for handling the
project hierarchy and taking wise actions. Gerrit does not enforce it.
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 6ff064cc2c..70f41af075 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -178,7 +178,7 @@ copying to the test site:
NOTE: To learn why using `java -jar` isn't sufficient, see
<<special_bazel_java_version,this explanation>>.
-NOTE: When launching the daemong this way, the settings from the `[container]` section from the
+NOTE: When launching the daemon this way, the settings from the `[container]` section from the
`$GERRIT_SITE/etc/gerrit.config` are not honored.
To debug the Gerrit server of this test site:
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index 85337c2b41..85371100f0 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -368,6 +368,22 @@ gerrit-documentation,role=external,window=_blank] storage bucket.
Submit any previously uploaded notes change on the homepage project.
+[[update-supported-releases]]
+=== Update list of supported releases
+
+If you created a new stable release update the list of supported releases
+in the link:https://www.gerritcodereview.com/support.html[support page].
+
+Gerrit releases are also listed on the
+link:https://endoflife.date/gerrit[endoflife website].
+Push a PR to
+link:https://github.com/endoflife-date/endoflife.date.git[endoflife.date repository]
+to update supported releases in `products/gerrit.md`. New release tags
+should be updated automatically by the site's automation job which uses
+Dependabot to
+link:https://github.com/endoflife-date/endoflife.date/wiki/Automation[auto-create PRs]
+for new release tags.
+
[[announce]]
==== Announce on Mailing List
diff --git a/Documentation/images/browser-notification-example.png b/Documentation/images/browser-notification-example.png
new file mode 100644
index 0000000000..2b60054064
--- /dev/null
+++ b/Documentation/images/browser-notification-example.png
Binary files differ
diff --git a/Documentation/images/browser-notification-preference.png b/Documentation/images/browser-notification-preference.png
new file mode 100644
index 0000000000..57d5fd6863
--- /dev/null
+++ b/Documentation/images/browser-notification-preference.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-apply-fix.png b/Documentation/images/user-review-ui-apply-fix.png
new file mode 100644
index 0000000000..d838d48623
--- /dev/null
+++ b/Documentation/images/user-review-ui-apply-fix.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-metadata.png b/Documentation/images/user-review-ui-change-metadata.png
new file mode 100644
index 0000000000..23abc07281
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-metadata.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-annotated.png b/Documentation/images/user-review-ui-change-screen-annotated.png
index 5c3f80acd7..4e12c96cc9 100644
--- a/Documentation/images/user-review-ui-change-screen-annotated.png
+++ b/Documentation/images/user-review-ui-change-screen-annotated.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-info-labels.png b/Documentation/images/user-review-ui-change-screen-change-info-labels.png
deleted file mode 100644
index 61e2b25417..0000000000
--- a/Documentation/images/user-review-ui-change-screen-change-info-labels.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-comments-tab.png b/Documentation/images/user-review-ui-change-screen-comments-tab.png
new file mode 100644
index 0000000000..d522f60c11
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-comments-tab.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-file-list.png b/Documentation/images/user-review-ui-change-screen-file-list.png
index 721b229a12..b0c2af3f6c 100644
--- a/Documentation/images/user-review-ui-change-screen-file-list.png
+++ b/Documentation/images/user-review-ui-change-screen-file-list.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
index 9ef8f27b19..224de2dee8 100644
--- a/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
+++ b/Documentation/images/user-review-ui-change-screen-keyboard-shortcuts.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
index 1c50fc5082..201db133a3 100644
--- a/Documentation/images/user-review-ui-change-screen-reply.png
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-topleft.png b/Documentation/images/user-review-ui-change-screen-topleft.png
index a1f78131c0..b3bf8e7f6d 100644
--- a/Documentation/images/user-review-ui-change-screen-topleft.png
+++ b/Documentation/images/user-review-ui-change-screen-topleft.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen.png b/Documentation/images/user-review-ui-change-screen.png
index ff2570b790..98a5d6d8fa 100644
--- a/Documentation/images/user-review-ui-change-screen.png
+++ b/Documentation/images/user-review-ui-change-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-copy-links.png b/Documentation/images/user-review-ui-copy-links.png
new file mode 100644
index 0000000000..f8fa114e9a
--- /dev/null
+++ b/Documentation/images/user-review-ui-copy-links.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
index 047034cfb2..98cf7afb6f 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
index 74d02e3eb2..ebdd177a8e 100644
--- a/Documentation/images/user-review-ui-side-by-side-diff-screen.png
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-submit-requirements.png b/Documentation/images/user-review-ui-submit-requirements.png
new file mode 100644
index 0000000000..e4b88c1a3d
--- /dev/null
+++ b/Documentation/images/user-review-ui-submit-requirements.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-suggest-fix.png b/Documentation/images/user-review-ui-suggest-fix.png
new file mode 100644
index 0000000000..e08fb26e85
--- /dev/null
+++ b/Documentation/images/user-review-ui-suggest-fix.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-preview.png b/Documentation/images/user-suggest-edits-preview.png
new file mode 100644
index 0000000000..0a6af91f5a
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-preview.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-reviewer-comment.png b/Documentation/images/user-suggest-edits-reviewer-comment.png
new file mode 100644
index 0000000000..76fbac1750
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-reviewer-comment.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-reviewer-preview.png b/Documentation/images/user-suggest-edits-reviewer-preview.png
new file mode 100644
index 0000000000..5b3015ed95
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-reviewer-preview.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-reviewer-suggest-fix.png b/Documentation/images/user-suggest-edits-reviewer-suggest-fix.png
new file mode 100644
index 0000000000..2ac28bd271
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-reviewer-suggest-fix.png
Binary files differ
diff --git a/Documentation/images/user-suggest-edits-suggestion.png b/Documentation/images/user-suggest-edits-suggestion.png
new file mode 100644
index 0000000000..a9c6d9c649
--- /dev/null
+++ b/Documentation/images/user-suggest-edits-suggestion.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index e0ece477fe..5d4a29bc88 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -15,18 +15,21 @@
== Contributor Guides
. link:dev-community.html[Gerrit Community]
. link:dev-community.html#how-to-contribute[How to Contribute]
+.. link:dev-readme.html[Developer Setup]
== User Guides
. link:intro-user.html[User Guide]
. link:intro-project-owner.html[Project Owner Guide]
. link:https://source.android.com/source/developing[Default Android Workflow,role=external,window=_blank] (external)
-== Tutorials
+== Features and Workflows
. Web
-.. link:user-review-ui.html[Reviewing Changes]
+.. link:user-review-ui.html[Review UI Overview]
.. link:user-search.html[Searching Changes]
.. link:user-inline-edit.html[Manipulating Changes in Browser]
.. link:user-notify.html[Subscribing to Email Notifications]
+.. link:user-attention-set.html[Attention Set]
+.. link:user-suggest-edits.html[User Suggest Edits]
. SSH
.. link:user-upload.html#ssh[SSH connection details]
.. link:cmd-index.html[Command Line Tools]
@@ -49,9 +52,6 @@
. Multi-project management
.. link:user-submodules.html[Submodules]
.. link:https://source.android.com/source/using-repo.html[Repo,role=external,window=_blank] (external)
-. Prolog rules
-.. link:prolog-cookbook.html[Prolog Cookbook]
-.. link:prolog-change-facts.html[Prolog Facts for Gerrit Changes]
. link:intro-project-owner.html#project-deletion[Project deletion]
== Customization and Integration
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 0f78e1f283..46422478c4 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -1014,7 +1014,7 @@ a file comment ("Rename this file to File.java") as well as a reply to an
inline comment ("Yeah, I see why, let me try again.").
[[security-fixes]]
--- Security Fixes
+== Security Fixes
If a security vulnerability is discovered you normally want to have an
embargo about it until fixed releases have been made available. This
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index ae3064c064..c032c367dd 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -603,39 +603,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
----
-[[codemirror-minified]]
-codemirror-minified
-
-* codemirror-minified
-
-[[codemirror-minified_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
[[font-roboto-local-fonts-roboto]]
font-roboto-local-fonts-roboto
@@ -1241,38 +1208,6 @@ SOFTWARE.
----
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
[[marked]]
marked
@@ -1330,7 +1265,7 @@ This software is provided by the copyright holders and contributors “as is”
[[page]]
page
-* page
+* polygerrit-gr-page
[[page_license]]
----
@@ -1360,38 +1295,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
----
-[[path-to-regexp]]
-path-to-regexp
-
-* path-to-regexp
-
-[[path-to-regexp_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
[[resemblejs]]
resemblejs
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 1706f138bf..2d5ab1d506 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -3507,39 +3507,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
----
-[[codemirror-minified]]
-codemirror-minified
-
-* codemirror-minified
-
-[[codemirror-minified_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.com>
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
[[font-roboto-local-fonts-roboto]]
font-roboto-local-fonts-roboto
@@ -4145,38 +4112,6 @@ SOFTWARE.
----
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
[[marked]]
marked
@@ -4234,7 +4169,7 @@ This software is provided by the copyright holders and contributors “as is”
[[page]]
page
-* page
+* polygerrit-gr-page
[[page_license]]
----
@@ -4264,38 +4199,6 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
----
-[[path-to-regexp]]
-path-to-regexp
-
-* path-to-regexp
-
-[[path-to-regexp_license]]
-----
-The MIT License (MIT)
-
-Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
-----
-
-
[[resemblejs]]
resemblejs
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 9573f247e8..93e0eb4aa8 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -200,6 +200,19 @@ setting.
=== Change
+* `change/count_rebases`: Total number of rebases
+** `on_behalf_of_uploader`:
+ Whether the rebase was done on behalf of the uploader.
+ If the uploader does a rebase with '`on_behalf_of_uploader = true`', the flag
+ is ignored and a normal rebase is done, hence such rebases are recorded as
+ '`on_behalf_of_uploader` = false`'.
+** `rebase_chain`:
+ Whether a chain was rebased.
+** `allow_conflicts`:
+ Whether the rebase was done with allowing conflicts.
+* `change/submitted_with_rebaser_approval`: Number of rebased changes that were
+ submitted with a Code-Review approval of the rebaser that would not have been
+ submittable if the rebase was not done on behalf of the uploader.
* `change/submit_rule_evaluation`: Latency for evaluating submit rules on a
change.
* `change/submit_type_evaluation`: Latency for evaluating the submit type on a
diff --git a/Documentation/pg-plugin-checks-api.txt b/Documentation/pg-plugin-checks-api.txt
index ebfb8624d9..39e2c9d4f0 100644
--- a/Documentation/pg-plugin-checks-api.txt
+++ b/Documentation/pg-plugin-checks-api.txt
@@ -10,6 +10,10 @@ Each plugin can link:#register[register] a checks provider that will be called
when a change page is loaded. Such a call would return a list of `Runs` and each
run can contain a list of `Results`.
+`Results` messages will render as markdown. It follows the
+[CommonMark](https://commonmark.org/help/) spec except inline images and direct
+HTML are not rendered and kept as plaintext.
+
The details of the ChecksApi are documented in the
link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/api/checks.ts[source code].
Note that this link points to the `master` branch and might thus reflect a
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index 61944b6752..7c93cc0d19 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -125,20 +125,29 @@ link:https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_[custom_properti
See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/styles/themes/[app-theme.ts]
for the list of available variables.
-Just add code like this to your JavaScript plugin:
+You can just create `<style>` elements yourself and add them to the
+`document.head`, but for your convenience the Plugin API provides a simple
+`styleApi().insertCSSRule()` method for doing just that. Typically you would
+define a CSS rule for `html`, which is always applied, or for a specific theme
+such as `html.lightTheme`.
``` js
Gerrit.install(plugin => {
- const styleEl = document.createElement('style');
- styleEl.innerHTML = `
- html {
- --header-background-color: #c3d9ff;
- }
- html.darkTheme {
- --header-background-color: #c3d9ff90;
- }
- `;
- document.head.appendChild(styleEl);
+ plugin.styleApi().insertCSSRule(`
+ html {
+ --header-text-color: black;
+ }
+ `);
+ plugin.styleApi().insertCSSRule(`
+ html.lightTheme {
+ --header-background-color: red;
+ }
+ `);
+ plugin.styleApi().insertCSSRule(`
+ html.darkTheme {
+ --header-background-color: blue;
+ }
+ `);
});
```
@@ -181,12 +190,6 @@ opt_options)`
See link:pg-plugin-endpoints.html[endpoints].
-=== registerStyleModule
-`plugin.registerStyleModule(endpointName, moduleName)`
-
-This API is deprecated and will be removed either in version 3.6 or 3.7,
-see link:#low-level-style[above] for an alternative.
-
=== on
Register a JavaScript callback to be invoked when events occur within
the web interface. Signature
diff --git a/Documentation/pg-plugin-endpoints.txt b/Documentation/pg-plugin-endpoints.txt
index 8fe0833ab5..0429f91723 100644
--- a/Documentation/pg-plugin-endpoints.txt
+++ b/Documentation/pg-plugin-endpoints.txt
@@ -151,6 +151,10 @@ screen.
=== settings-screen
This endpoint is situated at the end of the body of the settings screen.
+=== profile
+This endpoint is situated at the top of the Profile section of the settings
+screen below the section description text.
+
=== reply-text
This endpoint wraps the textarea in the reply dialog.
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index c083f2878d..6c771094cf 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -1,5 +1,12 @@
:linkattrs:
-= Gerrit Code Review - Prolog Submit Rules Cookbook
+= Gerrit Code Review - Prolog Submit Rules Cookbook (Deprecated)
+
+[WARNING]
+Prolog rules are no longer supported in Gerrit. Existing usages of prolog rules
+can be modified or deleted, but uploading new "rules.pl" files are rejected.
+Please use link:config-submit-requirements.html[submit requirements] instead.
+Note that the link:#SubmitType[Submit Type] being deprecated in this
+documentation page currently has no substitution in submit requirements.
[[SubmitRule]]
== Submit Rule
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 7e06e4a90f..7646777408 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -2316,6 +2316,9 @@ capability.
link:access-control.html#capability_viewPlugins[View Plugins] capability.
|`viewQueue` |not set if `false`|Whether the user has the
link:access-control.html#capability_viewQueue[View Queue] capability.
+|`viewSecondaryEmails`|not set if `false`|Whether the user has the
+link:access-control.html#capability_viewSecondaryEmails[View Secondary
+Emails] capability.
|=================================
[[contributor-agreement-info]]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 99a3485969..4bb4aadfac 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -103,15 +103,17 @@ Query for open changes of watched projects:
"id": "demo~master~Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
"project": "demo",
"branch": "master",
- "attention_set": [
- {
+ "attention_set": {
+ "1000096": {
"account": {
- "name": "John Doe"
+ "_account_id": 1000096,
+ "name": "John Doe",
+ "email": "john.doe@example.com"
},
- "last_update": "2012-07-17 07:19:27.766000000",
- "reason": "reviewer or cc replied"
+ "last_update": "2012-07-17 07:19:27.766000000",
+ "reason": "reviewer or cc replied"
}
- ]
+ },
"change_id": "Idaf5e098d70898b7119f6f4af5a6c13343d64b57",
"subject": "One change",
"status": "NEW",
@@ -545,15 +547,17 @@ describes the change.
"id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
"project": "myProject",
"branch": "master",
- "attention_set": [
- {
+ "attention_set": {
+ "1000096": {
"account": {
- "name": "John Doe"
+ "_account_id": 1000096,
+ "name": "John Doe",
+ "email": "john.doe@example.com"
},
- "last_update": "2013-02-21 11:16:36.775000000",
- "reason": "reviewer or cc replied"
+ "last_update": "2013-02-21 11:16:36.775000000",
+ "reason": "reviewer or cc replied"
}
- ]
+ },
"change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
"subject": "Implementing Feature X",
"status": "NEW",
@@ -612,15 +616,17 @@ returned. Only fields that differ between the change's two states are returned.
)]}'
{
"added": {
- "attention_set": [
- {
+ "attention_set": {
+ "1000096": {
"account": {
- "name": "John Doe"
+ "_account_id": 1000096,
+ "name": "John Doe",
+ "email": "john.doe@example.com"
},
- "last_update": "2013-02-21 11:16:36.775000000",
- "reason": "reviewer or cc replied"
+ "last_update": "2013-02-21 11:16:36.775000000",
+ "reason": "reviewer or cc replied"
}
- ]
+ },
"updated": "2013-02-21 11:16:36.775000000",
"topic": "new-topic"
},
@@ -651,15 +657,17 @@ returned. Only fields that differ between the change's two states are returned.
"id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
"project": "myProject",
"branch": "master",
- "attention_set": [
- {
+ "attention_set": {
+ "1000096": {
"account": {
- "name": "John Doe"
+ "_account_id": 1000096,
+ "name": "John Doe",
+ "email": "john.doe@example.com"
},
- "last_update": "2013-02-21 11:16:36.775000000",
- "reason": "reviewer or cc replied"
+ "last_update": "2013-02-21 11:16:36.775000000",
+ "reason": "reviewer or cc replied"
}
- ],
+ },
"change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
"subject": "Implementing Feature X",
"status": "NEW",
@@ -719,18 +727,18 @@ REJECTED > APPROVED > DISLIKED > RECOMMENDED.
"id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
"project": "myProject",
"branch": "master",
- "attention_set": [
- {
+ "attention_set": {
+ "1000096": {
"account": {
"_account_id": 1000096,
"name": "John Doe",
"email": "john.doe@example.com",
"username": "jdoe"
},
- "last_update": "2013-02-21 11:16:36.775000000",
- "reason": "reviewer or cc replied"
+ "last_update": "2013-02-21 11:16:36.775000000",
+ "reason": "reviewer or cc replied"
}
- ]
+ },
"change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
"subject": "Implementing Feature X",
"status": "NEW",
@@ -816,6 +824,26 @@ REJECTED > APPROVED > DISLIKED > RECOMMENDED.
"+2"
]
},
+ "removable_labels": {
+ "Code-Review": {
+ "-1": [
+ {
+ "_account_id": 1000096,
+ "name": "John Doe",
+ "email": "john.doe@example.com",
+ "username": "jdoe"
+ }
+ ],
+ "+1": [
+ {
+ "_account_id": 1000097,
+ "name": "Jane Roe",
+ "email": "jane.roe@example.com",
+ "username": "jroe"
+ }
+ ]
+ }
+ },
"removable_reviewers": [
{
"_account_id": 1000096,
@@ -1097,154 +1125,6 @@ Deletes the topic of a change.
HTTP/1.1 204 No Content
----
-[[get-assignee]]
-=== Get Assignee
---
-'GET /changes/link:#change-id[\{change-id\}]/assignee'
---
-
-Retrieves the account of the user assigned to a change.
-
-.Request
-----
- GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
-----
-
-As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
-describing the assigned account is returned.
-
-.Response
-----
- HTTP/1.1 200 OK
- Content-Disposition: attachment
- Content-Type: application/json; charset=UTF-8
-
- )]}'
- {
- "_account_id": 1000096,
- "name": "John Doe",
- "email": "john.doe@example.com",
- "username": "jdoe"
- }
-----
-
-If the change has no assignee the response is "`204 No Content`".
-
-[[get-past-assignees]]
-=== Get Past Assignees
---
-'GET /changes/link:#change-id[\{change-id\}]/past_assignees'
---
-
-Returns a list of every user ever assigned to a change, in the order in which
-they were first assigned.
-
-.Request
-----
- GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/past_assignees HTTP/1.0
-----
-
-As a response a list of link:rest-api-accounts.html#account-info[AccountInfo]
-entities is returned.
-
-.Response
-----
- HTTP/1.1 200 OK
- Content-Disposition: attachment
- Content-Type: application/json; charset=UTF-8
-
- )]}'
- [
- {
- "_account_id": 1000051,
- "name": "Jane Doe",
- "email": "jane.doe@example.com",
- "username": "janed"
- },
- {
- "_account_id": 1000096,
- "name": "John Doe",
- "email": "john.doe@example.com",
- "username": "jdoe"
- }
- ]
-
-----
-
-
-[[set-assignee]]
-=== Set Assignee
---
-'PUT /changes/link:#change-id[\{change-id\}]/assignee'
---
-
-Sets the assignee of a change.
-
-The new assignee must be provided in the request body inside a
-link:#assignee-input[AssigneeInput] entity.
-
-.Request
-----
- PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
- Content-Type: application/json; charset=UTF-8
-
- {
- "assignee": "jdoe"
- }
-----
-
-As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
-describing the assigned account is returned.
-
-.Response
-----
- HTTP/1.1 200 OK
- Content-Disposition: attachment
- Content-Type: application/json; charset=UTF-8
-
- )]}'
- {
- "_account_id": 1000096,
- "name": "John Doe",
- "email": "john.doe@example.com",
- "username": "jdoe"
- }
-----
-
-[[delete-assignee]]
-=== Delete Assignee
---
-'DELETE /changes/link:#change-id[\{change-id\}]/assignee'
---
-
-Deletes the assignee of a change.
-
-
-.Request
-----
- DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/assignee HTTP/1.0
-----
-
-As a response an link:rest-api-accounts.html#account-info[AccountInfo] entity
-describing the account of the deleted assignee is returned.
-
-.Response
-----
- HTTP/1.1 200 OK
- Content-Disposition: attachment
- Content-Type: application/json; charset=UTF-8
-
- )]}'
- {
- "_account_id": 1000096,
- "name": "John Doe",
- "email": "john.doe@example.com",
- "username": "jdoe"
- }
-----
-
-If the change had no assignee the response is "`204 No Content`".
-
[[get-pure-revert]]
=== Get Pure Revert
--
@@ -1505,6 +1385,250 @@ body.
The change could not be rebased due to a path conflict during merge.
----
+Rebasing a change is allowed for the change owner, users with the
+link:access-control.html#category_rebase[Rebase] permission and users
+with the link:access-control.html#category_submit[Submit] permission.
+
+In addition, the rebaser or the original uploader, if rebasing is done
+on behalf of the uploader (see `rebase_on_behalf_of_uploader` option in
+link:#rebase-input[RebaseInput]), needs to have all permissions that
+are required to create the new patch set:
+
+* the link:access-control.html#category_push[Push] permission
+* the link:access-control.html#category_add_patch_set[Add Patch Set]
+ permission (only if the user is not the change owner)
+* the link:access-control.html#category_forge_author[Forge Author]
+ permission (only if the commit author is forged)
+* the link:access-control.html#category_forge_server[Forge Server]
+ permission (only if the commit author is the server identity)
+
+The same permissions were required for the upload of the original patch
+set. This means if the rebase is done on behalf of the uploader these
+permission checks should just pass, unless the uploader lost
+permissions after the upload of the original patch set. In this case
+rebasing on behalf of the uploader is not possible and a normal rebase
+(on behalf of the rebaser) must be done, which means that the rebaser
+becomes the uploader and takes over the change. If self approvals are
+disallowed, this means that the rebaser can no longer approve the
+change (as approvals of the uploader are ignored).
+
+[[rebase-chain]]
+=== Rebase Chain
+--
+'POST /changes/link:#change-id[\{change-id\}]/rebase:chain'
+--
+
+Rebases an ancestry chain of changes.
+
+The operated change is treated as the chain tip. All unsubmitted ancestors are rebased.
+
+Requires a linear ancestry relation (single parenting throughout the chain).
+
+Optionally, the parent revision (of the oldest ancestor to be rebased) can be changed to another
+change, revision or branch through the link:#rebase-input[RebaseInput] entity.
+
+If the chain is outdated, i.e., there's a change that depends on an old revision of its parent, the
+result is the same as individually rebasing all outdated changes on top of their parent's latest
+revision before running the rebase chain action.
+
+.Request
+----
+ POST /changes/myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f/rebase:chain HTTP/1.0
+ Content-Type: application/json;charset=UTF-8
+
+ {
+ "base" : "1234",
+ }
+----
+
+As response a link:#rebase-chain-info[RebaseChainInfo] entity is returned that
+describes the rebased changes. Information about the current patch sets
+are included.
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "rebased_changes": [
+ {
+ "id": "myProject~master~I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+ "project": "myProject",
+ "branch": "master",
+ "hashtags": [
+
+ ],
+ "change_id": "I0e534de9d7f0d6f35b71f7d726acf835b2110c66",
+ "subject": "456",
+ "status": "NEW",
+ "created": "2022-11-21 20: 51: 31.000000000",
+ "updated": "2022-11-21 20: 56: 49.000000000",
+ "submit_type": "MERGE_IF_NECESSARY",
+ "insertions": 0,
+ "deletions": 0,
+ "total_comment_count": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "meta_rev_id": "a2a6692213f546e1045ecf4647439fac8d6d8faa",
+ "_number": 21,
+ "owner": {
+ "_account_id": 1000000
+ },
+ "current_revision": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+ "revisions": {
+ "c3b2ba222d42a56e05c90f88d4509a124620517d": {
+ "kind": "NO_CHANGE",
+ "_number": 2,
+ "created": "2022-11-21 20: 56: 49.000000000",
+ "uploader": {
+ "_account_id": 1000000
+ },
+ "ref": "refs/changes/21/21/2",
+ "fetch": {
+
+ },
+ "commit": {
+ "parents": [
+ {
+ "commit": "7803f427dd7c4a2441466e4d740a1850dcee1af4",
+ "subject": "123"
+ }
+ ],
+ "author": {
+ "name": "Nitzan Gur-Furman",
+ "email": "nitzan@google.com",
+ "date": "2022-11-21 20: 49: 39.000000000",
+ "tz": 60
+ },
+ "committer": {
+ "name": "Administrator",
+ "email": "admin@example.com",
+ "date": "2022-11-21 20: 56: 49.000000000",
+ "tz": 60
+ },
+ "subject": "456",
+ "message": "456\n"
+ },
+ "description": "Rebase"
+ }
+ },
+ "requirements": [
+
+ ],
+ "submit_records": [
+ {
+ "rule_name": "gerrit~DefaultSubmitRule",
+ "status": "NOT_READY",
+ "labels": [
+ {
+ "label": "Code-Review",
+ "status": "NEED"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "myProject~master~I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+ "project": "myProject",
+ "branch": "master",
+ "hashtags": [
+
+ ],
+ "change_id": "I08a021fb07b83fe845140a2c11508b3bdd93b48f",
+ "subject": "789",
+ "status": "NEW",
+ "created": "2022-11-21 20: 51: 31.000000000",
+ "updated": "2022-11-21 20: 56: 49.000000000",
+ "submit_type": "MERGE_IF_NECESSARY",
+ "insertions": 0,
+ "deletions": 0,
+ "total_comment_count": 0,
+ "unresolved_comment_count": 0,
+ "has_review_started": true,
+ "meta_rev_id": "3bfb843fea471f96e16b9199c3a30fff0285bc45",
+ "_number": 22,
+ "owner": {
+ "_account_id": 1000000
+ },
+ "current_revision": "77eb17a9501a5c21963bc6af56085e60f281acbb",
+ "revisions": {
+ "77eb17a9501a5c21963bc6af56085e60f281acbb": {
+ "kind": "NO_CHANGE",
+ "_number": 2,
+ "created": "2022-11-21 20: 56: 49.000000000",
+ "uploader": {
+ "_account_id": 1000000
+ },
+ "ref": "refs/changes/22/22/2",
+ "fetch": {
+
+ },
+ "commit": {
+ "parents": [
+ {
+ "commit": "c3b2ba222d42a56e05c90f88d4509a124620517d",
+ "subject": "456"
+ }
+ ],
+ "author": {
+ "name": "Nitzan Gur-Furman",
+ "email": "nitzan@google.com",
+ "date": "2022-11-21 20: 51: 07.000000000",
+ "tz": 60
+ },
+ "committer": {
+ "name": "Administrator",
+ "email": "admin@example.com",
+ "date": "2022-11-21 20: 56: 49.000000000",
+ "tz": 60
+ },
+ "subject": "789",
+ "message": "789\n"
+ },
+ "description": "Rebase"
+ }
+ },
+ "requirements": [
+
+ ],
+ "submit_records": [
+ {
+ "rule_name": "gerrit~DefaultSubmitRule",
+ "status": "NOT_READY",
+ "labels": [
+ {
+ "label": "Code-Review",
+ "status": "NEED"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ }
+----
+
+If the change cannot be rebased, e.g. due to conflicts, the response is
+"`409 Conflict`" and the error message is contained in the response
+body.
+
+.Response
+----
+ HTTP/1.1 409 Conflict
+ Content-Disposition: attachment
+ Content-Type: text/plain; charset=UTF-8
+
+ Change I0e534de9d7f0d6f35b71f7d726acf835b2110c66 could not be rebased due to a conflict during
+ merge.
+
+ merge conflict(s):
+ a.txt
+----
+
[[move-change]]
=== Move Change
--
@@ -2124,6 +2248,66 @@ otherwise only by administrators.
HTTP/1.1 204 No Content
----
+[[apply-patch]]
+=== Create patch-set from patch
+--
+'POST /changes/link:#change-id[\{change-id\}]/patch:apply'
+--
+
+Creates a new patch set on a destination change from the provided patch.
+
+The patch must be provided in the request body, inside a
+link:#applypatchpatchset-input[ApplyPatchPatchSetInput] entity.
+
+If a base commit is given, the patch is applied on top of it. Otherwise, the
+patch is applied on top of the target change's original parent.
+
+Applying the patch will fail if the destination change is closed, or in case of any conflicts.
+
+.Request
+----
+ POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/patch:apply HTTP/1.0
+ Content-Type: application/json; charset=UTF-8
+
+ {
+ "patch": {
+ "patch": "new file mode 100644\n--- /dev/null\n+++ b/a_new_file.txt\n@@ -0,0 +1,2 @@ \
++Patch compatible `git diff` output \
++For example: `link:#get-patch[<gerrit patch>] | base64 -d | sed -z 's/\n/\\n/g'`"
+ }
+ }
+----
+
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the destination change after applying the patch.
+
+.Response
+----
+ HTTP/1.1 200 OK
+ Content-Disposition: attachment
+ Content-Type: application/json; charset=UTF-8
+
+ )]}'
+ {
+ "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9941",
+ "project": "myProject",
+ "branch": "release-branch",
+ "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9941",
+ "subject": "Original change subject",
+ "status": "NEW",
+ "created": "2013-02-01 09:59:32.126000000",
+ "updated": "2013-02-21 11:16:36.775000000",
+ "mergeable": true,
+ "insertions": 12,
+ "deletions": 11,
+ "_number": 3965,
+ "owner": {
+ "name": "John Doe"
+ },
+ "current_revision": "184ebe53805e102605d11f6b143486d15c23a09c"
+ }
+----
+
[[get-included-in]]
=== Get Included In
--
@@ -2855,7 +3039,7 @@ entity is returned that describes the submit requirement result.
'GET /changes/link:#change-id[\{change-id\}]/edit
--
-Retrieves a change edit details.
+Retrieves the details of the change edit done by the caller to the given change.
.Request
----
@@ -2927,12 +3111,19 @@ To upload a file as binary data in the request body:
Content-Type: application/json; charset=UTF-8
{
- "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="
+ "binary_content": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==",
+ "file_mode": 100755
}
----
Note that it must be base-64 encoded data uri.
+The "file_mode" field is optional, and if provided must be in octal format. The field
+indicates whether the file is executable or not and has a value of either 100755
+(executable) or 100644 (not executable). If it's unset, this indicates no change
+has been made. New files default to not being executable if this parameter is not
+provided
+
When change edit doesn't exist for this change yet it is created. When file
content isn't provided, it is wiped out for that file. As response
"`204 No Content`" is returned.
@@ -3635,6 +3826,10 @@ a reviewer is removed the reviewer itself is still listed on the change.
If another user removed a user's vote, the user with the deleted vote will be
added to the attention set.
+The request returns:
+ * '204 No Content' if the vote is deleted successfully;
+ * '404 Not Found' when the vote to be deleted is zero or not present.
+
.Request
----
DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -6462,7 +6657,7 @@ This can be:
* a commit ID ("674ac754f91e64a0efb8087e59a176484bd534d1")
* an abbreviated commit ID that uniquely identifies one revision of the
change ("674ac754"), at least 4 digits are required
-* a legacy numeric patch number ("1" for first patch set of the change)
+* a numeric patch number ("1" for first patch set of the change)
* "0" or the literal `edit` for a change edit
[[json-entities]]
@@ -6513,8 +6708,94 @@ the user hovers the mouse.
If true the action is permitted at this time and the caller is
likely allowed to execute it. This may change if state is updated
at the server or permissions are modified. Not present if false.
+|`enabled_options` |optional|
+Optional list of enabled options. +
+See the list of suppported options link:#action-options[below].
|====================================
+[[action-options]]
+==== Action Options
+
+Options that are returned via the `enabled_options` field of
+link:#action-info[ActionInfo].
+
+[options="header",cols="1,^1,5"]
+|===================================
+|REST view |Option |Description
+|`rebase` |`rebase`|
+Present if the user can rebase the change. +
+This is the case for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission if they
+have the link:access-control.html#category_push[Push] permission.
+|`rebase` |`rebase_on_behalf_of_uploader`|
+Present if the user can rebase the change on behalf of the uploader. +
+This is the case for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission.
+|`rebase:chain`|`rebase`|
+Present if the user can rebase the chain. +
+This is the case if the calling user can rebase each change in the
+chain.
+Rebasing a change is allowed for the change owner and users with the
+link:access-control.html#category_submit[Submit] or
+link:access-control.html#category_rebase[Rebase] permission if they
+have the link:access-control.html#category_push[Push] permission.
+|`rebase:chain`|`rebase_on_behalf_of_uploader`|
+Present if the user can rebase the chain on behalf of the uploader. +
+This is the case if the calling user can rebase each change in the
+chain on behalf of the uploader.
+Rebasing a change on behalf of the uploader is allowed for the change
+owner and users with the link:access-control.html#category_submit[Submit]
+or link:access-control.html#category_rebase[Rebase] permission.
+|===================================
+
+For all other REST views no options are returned.
+
+[[applypatch-input]]
+=== ApplyPatchInput
+The `ApplyPatchInput` entity contains information about a patch to apply.
+
+A new commit will be created from the patch, and saved as a new patch set.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name ||Description
+|`patch` |required|
+The patch to be applied. Must be compatible with `git diff` output.
+For example, link:#get-patch[Get Patch] output.
+The patch must be provided as UTF-8 text, either directly or base64-encoded.
+|=================================
+
+[[applypatchpatchset-input]]
+=== ApplyPatchPatchSetInput
+The `ApplyPatchPatchSetInput` entity contains information for creating a new patch set from a
+given patch.
+
+[options="header",cols="1,^1,5"]
+|=================================
+|Field Name ||Description
+|`patch` |required|
+The details of the patch to be applied as a link:#applypatch-input[ApplyPatchInput] entity.
+|`commit_message` |optional|
+The commit message for the new patch set. If not specified, the latest patch-set message will be
+used.
+|`base` |optional|
+40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch set.
+If set, it must be a merged commit or a change revision on the destination branch.
+Otherwise, the target change's branch tip will be used.
+|`author` |optional|
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
+The caller needs "Forge Author" permission when using this field, unless specifies their own details.
+This field does not affect the owner of the change, which will continue to use the identity of the
+caller.
+|`response_format_options` |optional|
+List of link:#query-options[query options] to format the response.
+|=================================
+
+
[[approval-info]]
=== ApprovalInfo
The `ApprovalInfo` entity contains information about an approval from a
@@ -6548,18 +6829,6 @@ invocations of the REST call are required.
If true, this vote was made after the change was submitted.
|===========================
-[[assignee-input]]
-=== AssigneeInput
-The `AssigneeInput` entity contains the identity of the user to be set as assignee.
-
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name ||Description
-|`assignee` ||
-The link:rest-api-accounts.html#account-id[ID] of one account that
-should be added as assignee.
-|===========================
-
[[attention-set-info]]
=== AttentionSetInfo
The `AttentionSetInfo` entity contains details of users that are in
@@ -6668,9 +6937,6 @@ to link:#attention-set-info[AttentionSetInfo] of that account. Those are all
accounts that were in the attention set but were removed. The
link:#attention-set-info[AttentionSetInfo] is the latest and most recent removal
of the account from the attention set.
-|`assignee` |optional|
-The assignee of the change as an link:rest-api-accounts.html#account-info[
-AccountInfo] entity.
|`hashtags` |optional|
List of hashtags that are set on the change.
|`change_id` ||The Change-Id of the change.
@@ -6748,6 +7014,13 @@ labels] are requested.
A map of the permitted labels that maps a label name to the list of
values that are allowed for that label. +
Only set if link:#detailed-labels[detailed labels] are requested.
+|`removable_labels` |optional|
+A map of the removable labels that maps a label name to the map of
+values and reviewers (
+link:rest-api-accounts.html#account-info[AccountInfo] entities)
+that are allowed to be removed from the change. +
+Only set if link:#labels[labels] or
+link:#detailed-labels[detailed labels] are requested.
|`removable_reviewers`|optional|
The reviewers that can be removed by the calling user as a list of
link:rest-api-accounts.html#account-info[AccountInfo] entities. +
@@ -6866,11 +7139,16 @@ Whether the new change should be marked as private.
Whether the new change should be set to work in progress.
|`base_change` |optional|
A link:#change-id[\{change-id\}] that identifies the base change for a create
-change operation. Mutually exclusive with `base_commit`.
+change operation. +
+Mutually exclusive with `base_commit`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
|`base_commit` |optional|
A 40-digit hex SHA-1 of the commit which will be the parent commit of the newly
-created change. If set, it must be a merged commit on the destination branch.
-Mutually exclusive with `base_change`.
+created change. If set, it must be a merged commit on the destination branch. +
+Mutually exclusive with `base_change`. +
+If neither `base_commit` nor `base_change` are set, the target branch tip will
+be used as the parent commit.
|`new_branch` |optional, default to `false`|
Allow creating a new branch when set to `true`. Using this option is
only possible for non-merge commits (if the `merge` field is not set).
@@ -6887,10 +7165,12 @@ The detail of a merge commit as a link:#merge-input[MergeInput] entity.
If set, the target branch (see `branch` field) must exist (it is not
possible to create it automatically by setting the `new_branch` field
to `true`.
+|`patch` |optional|
+The detail of a patch to be applied as an link:#applypatch-input[ApplyPatchInput] entity.
|`author` |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
The caller needs "Forge Author" permission when using this field.
This field does not affect the owner of the change, which will
continue to use the identity of the caller.
@@ -6903,6 +7183,8 @@ If not set, the default is `ALL`.
Additional information about whom to notify about the change creation
as a map of link:user-notify.html#recipient-types[recipient type] to
link:#notify-info[NotifyInfo] entity.
+|`response_format_options` |optional|
+List of link:#query-options[query options] to format the response.
|==================================
[[change-message-info]]
@@ -6921,7 +7203,7 @@ Unset if written by the Gerrit system.
|`real_author` |optional|
Real author of the message as an
link:rest-api-accounts.html#account-info[AccountInfo] entity. +
-Set if the message was posted on behalf of another user.
+Only set if the message was posted on behalf of another user.
|`date` ||
The link:rest-api.html#timestamp[timestamp] this message was posted.
|`message` ||
@@ -7378,18 +7660,19 @@ the length calculation, and thus it is possible for the edits to span newlines.
[[diff-web-link-info]]
=== DiffWebLinkInfo
-The `DiffWebLinkInfo` entity describes a link on a diff screen to an
-external site.
+The `DiffWebLinkInfo` entity extends link:#web-link-info[WebLinkInfo] and
+describes a link on a diff screen to an external site.
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
|=======================
-|Field Name|Description
-|`name` |The link name.
-|`url` |The link URL.
-|`image_url`|URL to the icon of the link.
-|show_on_side_by_side_diff_view|
+|Field Name||Description
+|`name` ||See link:#web-link-info[WebLinkInfo]
+|`tooltip` |optional|See link:#web-link-info[WebLinkInfo]
+|`url` ||See link:#web-link-info[WebLinkInfo]
+|`image_url`|optional|See link:#web-link-info[WebLinkInfo]
+|show_on_side_by_side_diff_view||
Whether the web link should be shown on the side-by-side diff screen.
-|show_on_unified_diff_view|
+|show_on_unified_diff_view||
Whether the web link should be shown on the unified diff screen.
|=======================
@@ -7480,8 +7763,13 @@ An empty last line is not included in the count and hence this number can
differ by one from details provided in <<diff-info,DiffInfo>>.
|`size_delta` ||
Number of bytes by which the file size increased/decreased.
-|`size` ||
-File size in bytes.
+|`size` || File size in bytes.
+|`old_mode` |optional|File mode in octal (e.g. 100644) at the old commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For added files, this field will not be present.
+|`new_mode` |optional|File mode in octal (e.g. 100644) at the new commit.
+The first three digits indicate the file type and the last three digits contain
+the file permission bits. For deleted files, this field will not be present.
|=============================
[[fix-input]]
@@ -7723,11 +8011,11 @@ it's set. Otherwise, the current branch tip of the destination branch will be us
The detail of the source commit for merge as a link:#merge-input[MergeInput]
entity.
|`author` |optional|
-An link:rest-api-accounts.html#account-input[AccountInput] entity
-that will set the author of the commit to create. The author must be
-specified as name/email combination.
+The author of the commit to create. Must be an
+link:rest-api-accounts.html#account-input[AccountInput] entity with at least
+the `name` and `email` fields set.
The caller needs "Forge Author" permission when using this field.
-This field does not affect the owner of the change, which will
+This field does not affect the owner or the committer of the change, which will
continue to use the identity of the caller.
|==================================
@@ -7857,22 +8145,32 @@ The `RangeInfo` entity stores the coordinates of a range.
The `RebaseInput` entity contains information for changing parent when rebasing.
[options="header",cols="1,^1,5"]
-|===========================
-|Field Name ||Description
-|`base` |optional|
+|====================================
+|Field Name ||Description
+|`base` |optional|
The new parent revision. This can be a ref or a SHA-1 to a concrete patchset. +
Alternatively, a change number can be specified, in which case the current
patch set is inferred. +
Empty string is used for rebasing directly on top of the target branch,
which effectively breaks dependency towards a parent change.
-|`allow_conflicts` |optional, defaults to false|
+|`allow_conflicts` |optional, defaults to false|
If `true`, the rebase also succeeds if there are conflicts. +
If there are conflicts the file contents of the rebased patch set contain
git conflict markers to indicate the conflicts. +
Callers can find out whether there were conflicts by checking the
`contains_git_conflicts` field in the returned link:#change-info[ChangeInfo]. +
-If there are conflicts the change is marked as work-in-progress.
-|`validation_options`|optional|
+If there are conflicts the change is marked as work-in-progress. +
+Cannot be combined with the `on_behalf_of_uploader` option.
+|`on_behalf_of_uploader`|optional, defaults to false|
+If `true`, the rebase is done on behalf of the uploader. +
+This means the uploader of the current patch set will also be the uploader of
+the rebased patch set. The calling user will be recorded as the real user. +
+Rebasing on behalf of the uploader is only supported for trivial rebases.
+This means this option cannot be combined with the `allow_conflicts` option. +
+In addition, rebasing on behalf of the uploader is only supported for the
+current patch set of a change. +
+If the caller is the uploader this flag is ignored and a normal rebase is done.
+|`validation_options` |optional|
Map with key-value pairs that are forwarded as options to the commit validation
listeners (e.g. can be used to skip certain validations). Which validation
options are supported depends on the installed commit validation listeners.
@@ -7880,6 +8178,22 @@ Gerrit core doesn't support any validation options, but commit validation
listeners that are implemented in plugins may. Please refer to the
documentation of the installed plugins to learn whether they support validation
options. Unknown validation options are silently ignored.
+|====================================
+
+[[rebase-chain-info]]
+=== RebaseChainInfo
+
+The `RebaseChainInfo` entity contains information about a chain of changes
+that were rebased.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name ||Description
+|`rebased_changes` ||List of the unsubmitted ancestors, as link:#change-info[ChangeInfo]
+entities. Includes both rebased changes, and previously up-to-date ancestors. The list is ordered by
+ancestry, where the oldest ancestor is the first.
+|`contains_git_conflicts` ||Whether any of the rebased changes has conflicts
+due to rebasing.
|===========================
[[related-change-and-commit-info]]
@@ -7974,7 +8288,9 @@ RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
Topic can't contain quotation marks.
|`work_in_progress` |optional|
When present, change is marked as Work In Progress. The `notify` input is
-used if it's present, otherwise it will be overridden to `OWNER`. +
+used if it's present, otherwise it will be overridden to `NONE`. +
+Notifications for the reverted change will only sent once the result change is
+no longer WIP. +
If not set, the default is false.
|`validation_options`|optional|
Map with key-value pairs that are forwarded as options to the commit validation
@@ -8243,6 +8559,10 @@ created.
|`uploader` ||
The uploader of the patch set as an
link:rest-api-accounts.html#account-info[AccountInfo] entity.
+|`real_uploader`|optional|
+The real uploader of the patch set as an
+link:rest-api-accounts.html#account-info[AccountInfo] entity. +
+Only set if the upload was done on behalf of another user.
|`ref` ||The Git reference for the patch set.
|`fetch` ||
Information about how to fetch this patch set. The fetch information is
@@ -8372,7 +8692,13 @@ permission on the branch.
Notify handling that defines to whom email notifications should be sent after
the change is submitted. +
Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
-If not set, the default is `ALL`.
+If not set, the default is `ALL`.+
+Ignored if a post approval diff is present (i.e. if the change is submitted
+with copied approvals), because in this case everyone should be informed
+about the non-reviewed diff which has been applied after the change has been
+approved so that they can take action if the post approval diff looks
+unexpected. In other words if a post approval diff is present `ALL` is
+enforced.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
of link:user-notify.html#recipient-types[recipient type] to
@@ -8627,15 +8953,23 @@ to max values.
[[web-link-info]]
=== WebLinkInfo
-The `WebLinkInfo` entity describes a link to an external site.
+The `WebLinkInfo` entity describes a link to an external site. Depending on the
+context and the provided data the UI may decide to show the link as a text link,
+a linkified icon, or both.
+
+If the `tooltip` is not provided, then the UI may fall back to showing something
+like "Open in External Tool".
+
+Weblinks will always be opened in a new tab.
[options="header",cols="1,^1,5"]
|========================
|Field Name ||Description
-|`name` ||The link name.
+|`name` ||The text to be linkified.
+|`tooltip` |optional|Tooltip to show when hovering over the link. Using "Open
+in $NAME_OF_EXTERNAL_TOOL" is a good option here.
|`url` ||The link URL.
|`image_url`|optional|URL to the icon of the link.
-|`target` |optional|The target window in which the web link should be opened.
|========================
[[work-in-progress-input]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 45de1b19c8..fe9b13c10e 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1575,10 +1575,8 @@ Value of the link:config-gerrit.html#change.mergeabilityComputationBehavior[
configuration parameter] that controls whether the mergeability bit in
link:rest-api-changes.html#change-info[ChangeInfo] will never be set and if the
bit is indexed.
-|`enable_attention_set` |defaults to `false`|
-Returns true if attention set UI features are enabled.
-|`enable_assignee` |defaults to `true`|
-Returns true if assignee related UI features are enabled.
+|`enable_robot_comments`|not set if `false`|
+link:config-gerrit.html#change.enableRobotComments[Are robot comments enabled?].
|`conflicts_predicate_enabled`|not set if `false`|
link:config-gerrit.html#change.conflictsPredicateEnabled[Are conflicts enabled?].
|=============================
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index 578fe7520f..675c05424d 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -3817,13 +3817,13 @@ link:config-gerrit.html#commentlink.name.link[commentlink.name.link].
|`enabled` |optional|Whether the commentlink is enabled, as documented
in link:config-gerrit.html#commentlink.name.enabled[
commentlink.name.enabled]. If not set the commentlink is enabled.
+|==================================================
[[commentlink-input]]
=== CommentLinkInput
The `CommentLinkInput` entity describes the input for a
link:config-gerrit.html#commentlink[commentlink].
-|==================================================
[options="header",cols="1,^2,4"]
|==================================================
|Field Name | |Description
@@ -3909,17 +3909,19 @@ Not set if the project state is `ACTIVE`.
Map with the comment link configurations of the project. The name of
the comment link configuration is mapped to a link:#commentlink-info[
CommentlinkInfo] entity.
-|`plugin_config` |optional|
+|`plugin_config` |optional|
Plugin configuration as map which maps the plugin name to a map of
parameter names to link:#config-parameter-info[ConfigParameterInfo]
entities. Only filled for users who have read access to `refs/meta/config`.
-|`actions` |optional|
+|`actions` |optional|
Actions the caller might be able to perform on this project. The
information is a map of view names to
-|`reject_empty_commit` |optional|
+|`reject_empty_commit` |optional|
link:#inherited-boolean-info[InheritedBooleanInfo] that tells whether
empty commits should be rejected when a change is merged.
link:rest-api-changes.html#action-info[ActionInfo] entities.
+|`skip_adding_author_and_committer_as_reviewers` |optional|
+Whether to skip adding the Git commit author and committer as reviewers for a new change.
|=======================================================
[[config-input]]
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index 469bee5bcd..348af76771 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -76,6 +76,16 @@ an existing one by adding `If-None-Match: *` to the request HTTP
headers. If the named resource already exists the server will respond
with HTTP 412 Precondition Failed.
+[[backwards-compatibility]]
+=== Backwards Compatibility
+
+The REST API is regularly extended (e.g. addition of new REST endpoints or new fields in existing
+JSON entities). Callers of the REST API must be able to deal with this (e.g. ignore unknown fields
+in the REST responses). Incompatible changes (e.g. removal of REST endpoints, altering/removal of
+existing fields in JSON entities) are avoided if possible, but can happen in rare cases. If they
+happen, they are announced in the link:https://www.gerritcodereview.com/releases-readme.html[release
+notes].
+
[[output]]
=== Output Format
JSON responses are encoded using UTF-8 and use content type
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 5dc1416e0c..4fe5aae6e1 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -131,6 +131,27 @@ in the attention set, e.g.
Gerrit-Attention: Marian Harbach <mharbach@google.com>
----
+=== 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.
+
+image::images/browser-notification-example.png["browser notification example", align="center"]
+
+You can turn off automatic notifications in user preferences. They are enabled
+by default.
+
+image::images/browser-notification-preference.png["user preference for browser notifications", align="center"]
+
+The notifications work only when Gerrit is open in one of the browser tabs.
+The latency to get the notification is up to 5 minutes.
+
+If you are not getting notifications:
+ - Check your user preferences - Allow browser notification setting
+ - Make sure notifications are turned on for the Gerrit site in the browser
+ - 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
@@ -140,13 +161,7 @@ been replaced by the attention set.
=== For Gerrit Admins
-The Attention Set has been available since the 3.3 release (late 2020). It
-is enabled by default, but you can disable it by setting
-link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet] to false.
-
-As part of Gerrit 3.3 upgrade, the user group "Non-Interactive Users" is
-renamed "Service Users". For a new installation, the group is automatically
-created upon initialization.
+The Attention Set has been available since the 3.3 release (late 2020).
=== Important note for all host owners, project owners, and bot owners
diff --git a/Documentation/user-inline-edit.txt b/Documentation/user-inline-edit.txt
index 4a9d18f38c..7ed87b6c3e 100644
--- a/Documentation/user-inline-edit.txt
+++ b/Documentation/user-inline-edit.txt
@@ -15,6 +15,9 @@ To learn more, see the link:intro-user.html[Gerrit User's Guide].
[[create-change]]
== Creating a Change
+[[create_in_web_interface]]
+=== In the web interface
+
To create a change in the Gerrit web interface:
. From the link:http://gerrit-review.googlesource.com[Gerrit Code Review,role=external,window=_blank]
@@ -64,6 +67,22 @@ change.
. Add the files you want to be reviewed.
+[[create_from_url]]
+=== From URL
+
+Gerrit supports creating a new change and opening a specific file for edit
+in that change from an "Edit URL":
+```
+^\/admin\/repos\/edit\/repo\/(.+)\/branch\/(.+)\/file\/(.+)$
+```
+This enables other tools to provide a direct link to edit their configuration
+files in Gerrit.
+
+Ex:
+```
+https://gerrit.mycompany.com/admin/repos/edit/repo/my/repo/branch/refs/heads/master/file/Jenkinsfile # Jenkins build file
+https://gerrit.mycompany.com/admin/repos/edit/repo/my/repo/branch/refs/heads/master/file/catalog-info.yaml # Backstage catalog-info
+```
[[add-files]]
== Adding a File to a Change
@@ -173,7 +192,7 @@ patchset was uploaded later:
[[search-for-changes]]
== Searching for Changes with Pending Edits
-To find changes with pending edits:
+To find changes with pending edits created by you:
* From the Gerrit dashboard, select Your > Changes. All your changes are
listed, according to Work in progress, Outgoing reviews, Incoming reviews,
@@ -183,6 +202,12 @@ For more information about Search operators, see
link:user-search.html[Searching Changes]. For example, to find only
those changes that contain edits, see link:user-search.html#has[has:edit].
+[NOTE]
+Though edits created by others are not accessible from the Gerrit UI, edits
+are not considered to be private data, and are stored in non-encrypted special
+branches under the target repository. As such, they can be accessed by users who
+have access to the repository.
+
[[change-edit-actions]]
== Modifying Changes
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index de5ea57611..f420fe7a1b 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -181,7 +181,6 @@ of the following values.
* newpatchset
* restore
* revert
-* setassignee
[[Gerrit-Change-Id]]Gerrit-Change-Id::
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index 6f5f7297c0..39929e1311 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -1,21 +1,15 @@
:linkattrs:
-= Review UI
+= Review UI Overview
Reviewing changes is an important task and the Gerrit Web UI provides
many functionalities to make the review process comfortable and
efficient.
-The UI has three different main views,
-
-** The dashboard, which shows all changes that are relevant to you
-** The change screen, which shows the change with all its metadata
-** The diff view, which shows changes to a single file
-
[[change-screen]]
== Change Screen
-The change screen shows the details of a single change and provides
-various actions on it.
+The change screen is the main view for a change. It shows the details of a
+single change and allows various actions on it.
image::images/user-review-ui-change-screen.png[width=800, link="images/user-review-ui-change-screen.png"]
@@ -28,44 +22,81 @@ image::images/user-review-ui-change-screen-annotated.png[width=800, link="images
Top left, you find the status of the change, and a permalink.
-image::images/user-review-ui-change-screen-topleft.png[width=400, link="images/user-review-ui-change-screen-topleft.png"]
+image::images/user-review-ui-change-screen-topleft.png[width=600, link="images/user-review-ui-change-screen-topleft.png"]
[[change-status]]
The change status shows the state of the change:
-- [[active]]`Active`:
+- `Active`:
+
The change is under active review.
-- [[merge-conflict]]`Merge Conflict`:
+- `Merge Conflict`:
+
-The change can't be merged due to conflicts.
+The change can't be merged into the destination branch due to conflicts.
-- [[ready-to-submit]]`Ready to Submit`:
+- `Ready to Submit`:
+
-The change has all necessary approvals and may be submitted.
+The change has all necessary approvals and fulfils all other submit
+requirements. It can be submitted.
-- [[merged]]`Merged`:
+- `Merged`:
+
The change was successfully merged into the destination branch.
-- [[abandoned]]`Abandoned`:
+- `Abandoned`:
+
-The change was abandoned.
+The change was abandoned. It is not intended to be updated, reviewed or
+submitted anymore.
+
+- `Private`:
++
+The change is marked as link:intro-user.html#private-changes[Private]. And has
+reduced visibility.
+
+- `Revert Created|Revert Submitted`:
++
+The change has a corresponding revert change. Revert changes can be created
+through UI (see <<actions, Actions section>>).
+
+- `WIP`:
++
+The change was marked as "Work in Progress". For example to indicate to
+reviewers that they shouldn't review the change yet.
[[star]]
=== Star Change
-Clicking the star icon marks the change as a favorite: it turns on
+Clicking the star icon bookmarks the change: it turns on
email notifications for this change, and the change is added to the
list under `Your` > `Starred Changes`. They can be queried by the
link:user-search.html#is[is:starred] search operator.
+[[quick-links]]
+=== Links Menu
+
+Links menu contains various change related strings for quick copying. Such as:
+Change Number, URL, Title+Url, etc. The lines in this menu can also be accessed
+via shortcuts for convenience.
+
+image::images/user-review-ui-copy-links.png[width=600, link="images/user-review-ui-copy-links.png"]
+
[[change-info]]
=== Change metadata
-The change metadata block contains detailed information about the change
-and offers actions on the change.
+The change metadata block contains detailed information about the change.
+
+image::images/user-review-ui-change-metadata.png[width=600, link="images/user-review-ui-change-metadata.png"]
+
+- [[owner]]Owner/Uploader/Author/Committer:
++
+Owner is the person who created the change
++
+Uploader is the person who uploaded the latest patchset (the patchset that will
+be merged if the change is submitted)
++
+Author/Committer are concepts from Git and are retrieved from the commit when
+it's sent for review.
- [[reviewers]]Reviewers:
+
@@ -74,16 +105,36 @@ The reviewers of the change are displayed as chips.
For each reviewer there is a tooltip that shows on which labels the
reviewer is allowed to vote.
+
-New reviewers can be added by clicking on the pencil icon. Typing
-into the pop-up text field activates auto completion of user and group
-names.
+New reviewers can be added through reply dialog that is opened by clicking on
+the pencil icon or on "Reply" button. Typing into the reviewer text field
+activates auto completion of user and group names.
+
-[[remove-reviewer]]
-Reviewers can be removed from the change by clicking on the `x` icon
-in the reviewer's chip token. Removing a reviewer also removes the
-current votes of the reviewer. The removal of votes is recorded as a
-message on the change.
+
+- [[cc-list]]CC:
++
+Accounts in CC receive notifications for the updates on the change, but don't
+need to vote/review. If the CC'ed user votes they are moved to reviewers.
++
+
+- [[attention-set]]link:user-attention-set.html[Attention set]:
++
+Users in attention set are marked by "chevron" symbol (see screenshot above).
+The mark indicates that there are actions their attention is required on the
+change: Something updated/changed since last review, their vote is required,
+etc.
+
+Changes for which you are currently in attention set can be found using
+`attention:<User>` in search and show up in a separate category of personal
+dashboard.
++
+Clicking on the mark removes the user from attention set.
+
+
+[[remove-reviewer]]
+Reviewers can be removed from the change by selecting the appropriate option on
+the chip's hovercard. Removing a reviewer also removes current votes of the
+reviewer. The removal of votes is recorded in the change log.
+
Removing reviewers is protected by permissions:
** Users can always remove themselves.
@@ -92,10 +143,7 @@ Removing reviewers is protected by permissions:
Remove Reviewer] access right, the branch owner, the project owner
and Gerrit administrators may remove anyone.
-+
-image::images/user-review-ui-change-screen-info-reviewers.png[width=600, link="images/user-review-ui-change-screen-reviewers.png"]
-
-- [[project-branch-topic]]Project / Branch / Topic:
+- [[repo-branch-topic]]Project (Repo) / Branch / Topic:
+
The name of the project for which the change was done is displayed as a
link to the link:user-dashboards.html#project-default-dashboard[default
@@ -112,15 +160,55 @@ link:access-control.html#category_edit_topic_name[Edit Topic Name]
access right. To be able to set a topic on a closed change, the
`Edit Topic Name` must be assigned with the `force` flag.
+- [[parent]]Parent:
++
+Parent commit of the latest uploaded patchset. Or if the change has been merged
+the parent of the commit it was merged as into the destination branch.
+
+- [[merged-as]]Merged As:
++
+The SHA of the commit corresponding to the merged change on the destination
+branch.
+
+- [[revert-created-as]]Revert (Created|Submitted) As:
++
+Points to the revert change, if one was created.
+
+- [[cherry-pick-of]]Cherry-pick of:
++
+If the change was created as cherry-pick of some other change to a different
+branch, points to the original change.
+
- [[submit-strategy]]Submit Strategy:
+
The link:project-setup.html#submit_type[submit strategy] that will be
used to submit the change. The submit strategy is only displayed for
open changes.
-- [[actions]]Actions:
+- [[hastags]]Hashtags:
+
-Actions buttons are at the top, and in the overflow menu.
+Arbitrary string hashtags, that can be used to categorize changes and later use
+hashtags for search queries.
+
+[[submit-requirements]]
+=== Submit Requirements
+
+image::images/user-review-ui-submit-requirements.png[width=600, link="images/user-review-ui-copy-links.png"]
+
+Submit Requirements describe various conditions that must be fulfilled before
+the change can be submitted. Hovering over the requirement will show the
+description of the requirement, as well as additional information, such as:
+corresponding expression that is being evaluated, who can vote on the related
+labels etc.
+
+Approving votes are colored green; negative votes are colored red.
+
+For more detail on Submit Requirements see
+link:config-submit-requirements.html[Submit Requirement Configuration] page.
+
+[[actions]]
+=== Actions
+Actions buttons are at the top right and in the overflow menu.
Depending on the change state and the permissions of the user, different
actions are available on the change:
@@ -220,13 +308,7 @@ or if they are an administrator.
+
image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
-- [[labels]]Labels & Votes:
-+
-Approving votes are colored green; negative votes are colored red.
-+
-image::images/user-review-ui-change-screen-change-info-labels.png[width=400, link="images/user-review-ui-change-screen-change-info-labels.png"]
-
-[[files]]
+[[files-tab]]
=== File List
The file list shows the files that are modified in the currently viewed
@@ -251,17 +333,40 @@ information and the committer information.
The list of commits that are being integrated into the destination
branch by submitting the merge commit.
+Every file is accompanied by a number of extra information, such as status
+(modified, added, deleted, etc.), number of changed lines, type (executable,
+link, plain), comments and others. Hovering over most icons and columns reveals
+additional information.
+
+Each file can be expanded to view the contents of the file and diff. For more
+information see <<diff-view, Diff View>> section.
+
+[[comments-tab]]
+=== Comments Tab
+
+Instead of the file list, a comments tab can be selected. Comments tab presents
+comments along with related file/diff snippets. It also offers some filtering
+opportunities at the top (ex. only unresolved, only comments from user X, etc.)
+
+image::images/user-review-ui-change-screen-comments-tab.png[width=800, link="images/user-review-ui-change-screen-comments-tab.png"]
+
+[[checks-tab]]
+=== Checks Tab
+Checks tab contains results of different "Check Runs" installed by plugins. For
+more information see link:pg-plugin-checks-api.html[Checks API] page.
[[patch-sets]]
=== Patch Sets
-The change screen only presents one patch set at a time. Which patch
-set is currently viewed can be seen from the `Patch Sets` drop-down
-panel in the change header.
+The change screen only presents one pair of patch sets (`Patchset A` and
+`Patchset B`) at a time. `A` is always an earlier upload than `B` and serves as
+a base for diffing when viewing changes in the files. Which patch
+sets is currently viewed can be seen from the `Patch Sets` drop-down
+panel in the change header. If patchset 'A' is not selected a parent commit of
+patchset 'B' is used by default.
image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
-
[[download]]
=== Download
@@ -278,7 +383,8 @@ cherry-pick a patch set.
Each command has a copy-to-clipboard icon that allows the command to be
copied into the clipboard. This makes it easy to paste and execute the
-command on a Git command line.
+command on a Git command line. Additionally each line can copied to clipboard
+using number (1..9) of the appropriate line as a keyboard shortcut.
If several download schemes are configured on the server (e.g. SSH and
HTTP) there is a drop-down list to switch between the download schemes.
@@ -306,22 +412,20 @@ release is tagged).
image::images/user-review-ui-change-screen-included-in.png[width=800, link="images/user-review-ui-change-screen-included-in.png"]
-
-
[[related-changes]]
=== Related Changes
If there are changes that are related to the currently viewed change
they are displayed in the third column of the change screen.
-There are several lists of related changes and a tab control is used to
-display each list of related changes in its own tab.
+There are several lists of related changes that are displayed in separate
+sectionsunder each other.
-The following tabs may be displayed:
+The following sections may be displayed:
-- [[related-changes-tab]]`Related Changes`:
+- [[related-changes-section]]`Related Changes`:
+
-This tab page shows changes on which the current change depends
+This section shows changes on which the current change depends
(ancestors) and open changes that depend on the current change
(descendants). For merge commits it also shows the closed changes that
will be merged into the destination branch by submitting the merge
@@ -341,10 +445,10 @@ under review:
+
** [[not-current]]Not current:
+
-The selected patch set of the change is outdated; it is not the current
-patch set of the change.
+The patch set of the related change which is related to the current change is
+outdated; it is not the current patch set of the change.
+
-It means that the
+For ancestor it means that the
currently viewed patch set depends on a outdated patch set of the
ancestor change. This is because a new patch set for the ancestor
change was uploaded in the meantime and as result the currently viewed
@@ -364,20 +468,24 @@ this change and the descendant change now needs to be rebased. Please
note that following the link to an indirect descendant change may
result in a completely different related changes listing.
-** [[closed-ancestor]]Closed ancestor:
+** [[merged-related-change]]Merged
+
-Indicates a closed ancestor, e.g. the commit was directly pushed into
-the repository bypassing code review, or the ancestor change was
-reviewed and submitted on another branch. The latter may indicate that
-the user has accidentally pushed the commit to the wrong branch, e.g.
-the commit was done on `branch-a`, but was then pushed to
-`refs/for/branch-b`.
+The change has been merged.
++
+If the relationship to submitted change falls under conditions described in
+<<not-current, Not current>> the status is orange. Such changes can appear as
+both ancestors and descendants of the change.
+
+** [[submittable-related-change]]Submittable
++
+All the submit requirements are fulfilled for the related change and it can be
+submitted when all of its ancestors are submitted.
** [[closed-ancestor-abandoned]]Abandoned:
+
Indicates an abandoned change.
-- [[conflicts-with]]`Conflicts With`:
+- [[conflicts-with]]`Merge Conflicts`:
+
This section shows changes that conflict with the current change.
Non-mergeable changes are filtered out; only conflicting changes that
@@ -393,10 +501,9 @@ This section shows changes that will be submitted together with the
currently viewed change, when clicking the submit button. It includes
ancestors of the current patch set.
+
-This may include changes and its ancestors with the same topic if
-`change.submitWholeTopic` is enabled. Only open changes with the
-same topic are included in the list.
-+
+If `change.submitWholeTopic` is enabled this section also includes changes with
+the same topic. The list recursively includes all changes that can be reached by
+ancestor and topic relationships. Only open changes are included in the result.
- [[cherry-picks]]`Cherry-Picks`:
+
@@ -411,12 +518,18 @@ prefix in front of the change subject.
If there are no related changes for a tab, the tab is not displayed.
+- [[same-topic]]`Same Topic`:
++
+This section shows changes which are part of the same topic. If
+`change.submitWholeTopic` is enabled, then this section is omitted and changes
+are included as part of <<submitted-together, `Submitted Together`>>
+
[[reply]]
=== Reply
The `Reply...` button in the change header allows to reply to the
currently viewed patch set; one can add a summary comment, publish
-inline draft comments, and vote on the labels.
+inline draft comments, vote on the labels and adjust attention set.
image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
@@ -424,10 +537,8 @@ Clicking on the `Reply...` button opens a popup panel.
[[summary-comment]]
A text box allows to type a summary comment for the currently viewed
-patch set. Some basic markdown-like syntax is supported which renders
-indented lines preformatted, lines starting with "- " or "* " as list
-items, and lines starting with "> " as block quotes (also see replying to
-link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
+patch set. Markdown syntax is supported same as in other
+<<comments-markdown, Comments>>.
[[vote]]
If the current patch set is viewed, buttons are displayed for
@@ -439,7 +550,7 @@ separate section so that they can be reviewed before publishing. There
are links to navigate to the inline comments which can be used if a
comment needs to be edited.
-The `Post` button publishes the comments and the votes.
+The `SEND` button publishes the comments and the votes.
[[quick-approve]]
If a user can approve a label that is still required, a quick approve
@@ -460,12 +571,12 @@ open when the quick approve button is clicked.
image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
-[[history]]
-=== History
+[[change-log]]
+=== Change Log
The history of the change can be seen in the lower part of the screen.
-The history contains messages for all kinds of change updates, e.g. a
+The log contains messages for all kinds of change updates, e.g. a
message is added when a new patch set is uploaded or when a review was
done.
@@ -491,12 +602,12 @@ Frontend plugins can change the UI controls in arbitrary ways.
image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
-[[side-by-side]]
+[[diff-view]]
== Side-by-Side Diff Screen
-The side-by-side diff screen shows a single patch; the old file version
-is displayed on the left side of the screen; the new file version is
-displayed on the right side of the screen.
+The side-by-side diff screen shows a single patch (or difference between two
+patchsets); the old file version is displayed on the left side of the screen;
+the new file version is displayed on the right side of the screen.
This screen allows to review a patch and to comment on it.
@@ -557,6 +668,10 @@ highlighted by a yellow background.
Code blocks with comments may overlap. This means it is possible to
attach several comments to the same code.
+[[comments-markdown]]
+The comments support markdown. It follows the CommonMark spec, except inline
+images and direct HTML are not rendered and kept as plaintext.
+
[[line-links]]
The lines of the patch file are linkable: simply append
'#<linenumber>' to the URL, or click on the line-number. This not only
@@ -565,15 +680,14 @@ opens a draft comment box, but also sets the URL fragment.
[[reply-inline-comment]]
Clicking on the `Reply` button opens an editor to type the reply.
-Quoting is supported, but only by manually copying & pasting the old
-comment that should be quoted and prefixing every line by "> ". Please
-note that for a correct rendering it is important to leave a blank line
-between a quoted block and the reply to it.
+Previous comment can be quoted using "Quote" button. A new draft would be open
+on the same comment thread with the text of the previoused comment quoted using
+markdown syntax.
image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
Comments are first saved as drafts, and you can revisit the drafts as
-you read through code review. Finally, they should be published by
+you read through code review. Finally, they will be published by
clicking the "Reply".
[[done]]
@@ -610,6 +724,21 @@ Clicking on the `Save` button saves the new comment as a draft. To
make it visible to other users it must be published from the change
screen by link:#reply[replying] to the change.
+[[suggest-fix]]
+=== Suggest fix (WIP)
+Comments can contain suggested fixes.
+
+Clicking "Suggest Fix" will insert a special code-block in the text of the
+comment. The contents of this code block will replace the lines the comment is
+attached to (what gets highlighted when hovering over comment).
+
+image::images/user-review-ui-suggest-fix.png[width=400, link="images/user-review-ui-suggest-fix.png"]
+
+The author of the change can then preview and apply the change. This will created
+a new patchset with changes applied.
+
+image::images/user-review-ui-apply-fix.png[width=800, link="images/user-review-ui-apply-fix.png"]
+
[[file-level-comments]]
=== File Level Comments
diff --git a/Documentation/user-search-accounts.txt b/Documentation/user-search-accounts.txt
index 6bcd18e929..d5318c9e94 100644
--- a/Documentation/user-search-accounts.txt
+++ b/Documentation/user-search-accounts.txt
@@ -26,7 +26,10 @@ text with no operator, which will match against a variety of fields.
[[cansee]]
cansee:'CHANGE'::
+
-Matches accounts that can see the change 'CHANGE'.
+Matches accounts that can see the change 'CHANGE'. If the change is private,
+this operator will match with the owner/reviewers/ccs of the change if the
+caller is in owner/reviewers/ccs of the change. Otherwise, the request will fail
+with 404 `Bad Request` with "change not found" message.
[[email]]
email:'EMAIL'::
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index d09717ded8..67b8d75e4b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -43,6 +43,17 @@ presented instead of a list.
For more predictable results, use explicit search operators as described
in the following section.
+[IMPORTANT]
+--
+The change search API is backed by a secondary index and might sometimes return
+stale results if the re-indexing operation failed for a change update.
+
+Please also note that changes are not re-indexed if the project configuration
+is updated with newly added or modified
+link:config-submit-requirements.html[submit requirements].
+--
+
+
[[search-operators]]
== Search Operators
@@ -74,11 +85,6 @@ to include a unit suffix, for example `-age:2d`:
means 'everything older than 2 days' while `-age:2d` means
'everything with an age of at most 2 days'.
-[[assignee]]
-assignee:'USER'::
-+
-Changes assigned to the given user.
-
[[attention]]
attention:'USER'::
+
@@ -345,6 +351,18 @@ is used for the evaluation of such patterns. Note, that searching with
regular expressions is limited to the first 32766 bytes of the
commit message due to limitations in Lucene.
+[[subject]]
+subject:'SUBJECT'::
++
+Changes that have a commit message where the first line (aka the subject)
+matches 'SUBJECT'. The matching is done by full text search over the subject.
+
+[[prefixsubject]]
+prefixsubject:'PREFIX'::
++
+Changes that have a commit message where the first line (aka the subject)
+has the prefix 'PREFIX'.
+
[[comment]]
comment:'TEXT'::
+
@@ -359,6 +377,10 @@ with `^`. For example, to match all XML files use `file:"^.*\.xml$"`.
The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
is used for the evaluation of such patterns.
+
+Note that the Gerrit host may not support regular expression search.
+You will then see an error dialog when using expressions starting with
+`^`.
++
The `^` required at the beginning of the regular expression not only
denotes a regular expression, but it also has the usual meaning of
anchoring the match to the start of the string. To match all Java
@@ -460,20 +482,12 @@ True if the change has attention by the current user.
[[is]]
-is:assigned::
-+
-True if the change has an assignee.
-
[[is-starred]]
is:starred::
+
Same as 'has:star', true if the change has been starred by the
current user with the default label.
-is:unassigned::
-+
-True if the change does not have an assignee.
-
is:attention::
+
True if the change has attention by the current user.
@@ -618,7 +632,7 @@ commentby:'USER'::
+
Changes containing a top-level or inline comment by 'USER'. The special
case of `commentby:self` will find changes where the caller has
-commented.
+commented. Note that setting a vote is also considered as a comment.
[[from]]
from:'USER'::
@@ -633,7 +647,7 @@ Changes where 'USER' has commented on the change more recently than the
last update (comment or patch set) from the change owner.
[[author]]
-author:'AUTHOR'::
+author:'AUTHOR', a:'AUTHOR'::
+
Changes where 'AUTHOR' is the author of the current patch set. 'AUTHOR' may be
the author's exact email address, or part of the name or email address. The
diff --git a/Documentation/user-suggest-edits.txt b/Documentation/user-suggest-edits.txt
new file mode 100644
index 0000000000..99f17a416b
--- /dev/null
+++ b/Documentation/user-suggest-edits.txt
@@ -0,0 +1,37 @@
+= Gerrit Code Review - User suggested edits (Experiment)
+
+Easy and fast way for reviewers to suggest code changes that can be easily applied
+by change owner.
+
+== Reviewer workflow
+
+** Select line or multiple lines of diff and start comment
+
+image::images/user-suggest-edits-reviewer-comment.png["Comment example", align="center", width=400]
+
+** Click on suggest fix - that copies whole selected line/lines
+
+image::images/user-suggest-edits-reviewer-suggest-fix.png["Comment example", align="center", width=400]
+
+** Modify lines in the suggestion block. Optionally add more details as normal comment text before or after
+the suggestion block.
+
+image::images/user-suggest-edits-suggestion.png["Suggestion example", align="center", width=400]
+
+** Optionally you can preview suggested edit by clicking on Preview fix when you stop editing comment
+
+image::images/user-suggest-edits-reviewer-preview.png["Suggestion Draft example", align="center", width=400]
+
+image::images/user-suggest-edits-preview.png["Suggestion Preview", align="center", width=400]
+
+== Author workflow
+
+You can apply one or more suggested fixes. When suggested fix is applied - it creates
+a change edit that you can modify. link:user-inline-edit.html#editing-change[More about editing mode.]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 8c51207584..c6fce2a531 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -1,13 +1,15 @@
:linkattrs:
= Gerrit Code Review - Uploading Changes
-Gerrit supports three methods of uploading changes:
+Gerrit supports five methods of uploading changes:
* Use `repo upload`, to create changes for review
* Use `git push`, to create changes for review
+* link:user-inline-edit.html#create_in_web_interface[Create a change for review from the web interface]
+* link:user-inline-edit.html#create_from_url[Create a change for review by using an "Edit URL"]
* Use `git push`, and bypass code review
-All three methods rely on authentication, which must first be configured
+All five methods rely on authentication, which must first be configured
by the uploading user.
Gerrit supports two protocols for uploading changes; SSH and HTTP/HTTPS. These
diff --git a/README.md b/README.md
index 4df9271d72..c8f0b70391 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
[Gerrit](https://www.gerritcodereview.com) is a code review and project
management tool for Git based projects.
-[![Build Status](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/badge/icon)](https://gerrit-ci.gerritforge.com/job/Gerrit-bazel-java11-master/)
+[![Build Status](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/badge/icon)](https://gerrit-ci.gerritforge.com/view/Gerrit/job/Gerrit-bazel-master/)
![Maven Central](https://img.shields.io/maven-central/v/com.google.gerrit/gerrit-war)
## Objective
diff --git a/WORKSPACE b/WORKSPACE
index cf399dc5bb..047da6a480 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -65,8 +65,8 @@ protobuf_deps()
http_archive(
name = "build_bazel_rules_nodejs",
- sha256 = "0fad45a9bda7dc1990c47b002fd64f55041ea751fafc00cd34efb96107675778",
- urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.5.0/rules_nodejs-5.5.0.tar.gz"],
+ sha256 = "c29944ba9b0b430aadcaf3bf2570fece6fc5ebfb76df145c6cdad40d65c20811",
+ urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.7.0/rules_nodejs-5.7.0.tar.gz"],
)
load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies")
@@ -136,8 +136,8 @@ declare_nongoogle_deps()
load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install")
node_repositories(
- node_version = "16.15.0",
- yarn_version = "1.22.18",
+ node_version = "17.9.1",
+ yarn_version = "1.22.19",
)
yarn_install(
diff --git a/contrib/git-gc-preserve b/contrib/git-gc-preserve
new file mode 100755
index 0000000000..33c8f5b548
--- /dev/null
+++ b/contrib/git-gc-preserve
@@ -0,0 +1,149 @@
+#!/bin/bash
+# Copyright (C) 2022 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+usage() { # exit code
+ cat <<-EOF
+NAME
+ git-gc-preserve - Run git gc and preserve old packs to avoid races for JGit
+
+SYNOPSIS
+ git gc-preserve
+
+DESCRIPTION
+ Runs git gc and can preserve old packs to avoid races with concurrently
+ executed commands in JGit.
+
+ This command uses custom git config options to configure if preserved packs
+ from the last run of git gc should be pruned and if packs should be preserved.
+
+ This is similar to the implementation in JGit [1] which is used by
+ JGit to avoid errors [2] in such situations.
+
+ The command prevents concurrent runs of the command on the same repository
+ by acquiring an exclusive file lock on the file
+ "\$repopath/gc-preserve.pid"
+ If it cannot acquire the lock it fails immediately with exit code 3.
+
+ Failure Exit Codes
+ 1: General failure
+ 2: Couldn't determine repository path. If the current working directory
+ is outside of the working tree of the git repository use git option
+ --git-dir to pass the root path of the repository.
+ E.g.
+ $ git --git-dir ~/git/foo gc-preserve
+ 3: Another process already runs $0 on the same repository
+
+ [1] https://git.eclipse.org/r/c/jgit/jgit/+/87969
+ [2] https://git.eclipse.org/r/c/jgit/jgit/+/122288
+
+CONFIGURATION
+ "pack.prunepreserved": if set to "true" preserved packs from the last gc run
+ are pruned before current packs are preserved.
+
+ "pack.preserveoldpacks": if set to "true" current packs will be hard linked
+ to objects/pack/preserved before git gc is executed. JGit will
+ fallback to the preserved packs in this directory in case it comes
+ across missing objects which might be caused by a concurrent run of
+ git gc.
+EOF
+ exit "$1"
+}
+
+# acquire file lock, unlock when the script exits
+lock() { # repo
+ readonly LOCKFILE="$1/gc-preserve.pid"
+ test -f "$LOCKFILE" || touch "$LOCKFILE"
+ exec 9> "$LOCKFILE"
+ if flock -nx 9; then
+ echo -n "$$ $USERNAME@$HOSTNAME" >&9
+ trap unlock EXIT
+ else
+ echo "$0 is already running"
+ exit 3
+ fi
+}
+
+unlock() {
+ # only delete if the file descriptor 9 is open
+ if { : >&9 ; } &> /dev/null; then
+ rm -f "$LOCKFILE"
+ fi
+ # close the file handle to release file lock
+ exec 9>&-
+}
+
+# prune preserved packs if pack.prunepreserved == true
+prune_preserved() { # repo
+ configured=$(git --git-dir="$1" config --get pack.prunepreserved)
+ if [ "$configured" != "true" ]; then
+ return 0
+ fi
+ local preserved=$1/objects/pack/preserved
+ if [ -d "$preserved" ]; then
+ printf "Pruning old preserved packs: "
+ count=$(find "$preserved" -name "*.old-pack" | wc -l)
+ rm -rf "$preserved"
+ echo "$count, done."
+ fi
+}
+
+# preserve packs if pack.preserveoldpacks == true
+preserve_packs() { # repo
+ configured=$(git --git-dir="$1" config --get pack.preserveoldpacks)
+ if [ "$configured" != "true" ]; then
+ return 0
+ fi
+ local packdir=$1/objects/pack
+ pushd "$packdir" >/dev/null || exit 1
+ mkdir -p preserved
+ printf "Preserving packs: "
+ count=0
+ for file in pack-*{.pack,.idx} ; do
+ ln -f "$file" preserved/"$(get_preserved_packfile_name "$file")"
+ if [[ "$file" == pack-*.pack ]]; then
+ ((count++))
+ fi
+ done
+ echo "$count, done."
+ popd >/dev/null || exit 1
+}
+
+# pack-0...2.pack to pack-0...2.old-pack
+# pack-0...2.idx to pack-0...2.old-idx
+get_preserved_packfile_name() { # packfile > preserved_packfile
+ local old=${1/%\.pack/.old-pack}
+ old=${old/%\.idx/.old-idx}
+ echo "$old"
+}
+
+# main
+
+while [ $# -gt 0 ] ; do
+ case "$1" in
+ -u|-h) usage 0 ;;
+ esac
+ shift
+done
+args=$(git rev-parse --sq-quote "$@")
+
+repopath=$(git rev-parse --git-dir)
+if [ -z "$repopath" ]; then
+ usage 2
+fi
+
+lock "$repopath"
+prune_preserved "$repopath"
+preserve_packs "$repopath"
+git gc ${args:+"$args"} || { echo "git gc failed"; exit "$?"; }
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 01c4942bbe..f4e7ccea52 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -32,6 +32,8 @@ import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.server.project.testing.TestLabels.label;
import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList;
@@ -91,6 +93,7 @@ import com.google.gerrit.extensions.api.projects.ProjectInput;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
@@ -100,6 +103,7 @@ import com.google.gerrit.extensions.common.DiffInfo;
import com.google.gerrit.extensions.common.EditInfo;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.server.GerritPersonIdent;
@@ -1100,6 +1104,19 @@ public abstract class AbstractDaemonTest {
}
}
+ protected void setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean value)
+ throws Exception {
+ try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
+ ProjectConfig config = projectConfigFactory.read(md);
+ config.updateProject(
+ p ->
+ p.setBooleanConfig(
+ BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS, value));
+ config.commit(md);
+ projectCache.evictAndReindex(config.getProject());
+ }
+ }
+
protected void blockAnonymousRead() throws Exception {
String allRefs = RefNames.REFS + "*";
projectOperations
@@ -1123,6 +1140,42 @@ public abstract class AbstractDaemonTest {
gApi.changes().id(id).current().review(ReviewInput.recommend());
}
+ protected void assertThatAccountIsNotVisible(TestAccount... testAccounts) {
+ for (TestAccount testAccount : testAccounts) {
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(testAccount.id().get()).get());
+ }
+ }
+
+ protected void assertReviewers(String changeId, TestAccount... expectedReviewers)
+ throws RestApiException {
+ Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+ gApi.changes().id(changeId).get().reviewers;
+ assertThat(reviewerMap).containsKey(ReviewerState.REVIEWER);
+ List<Integer> reviewers =
+ reviewerMap.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+ assertThat(reviewers)
+ .containsExactlyElementsIn(
+ Arrays.stream(expectedReviewers).map(a -> a.id().get()).collect(toList()));
+ }
+
+ protected void assertCcs(String changeId, TestAccount... expectedCcs) throws RestApiException {
+ Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+ gApi.changes().id(changeId).get().reviewers;
+ assertThat(reviewerMap).containsKey(ReviewerState.CC);
+ List<Integer> ccs =
+ reviewerMap.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
+ assertThat(ccs)
+ .containsExactlyElementsIn(
+ Arrays.stream(expectedCcs).map(a -> a.id().get()).collect(toList()));
+ }
+
+ protected void assertNoCcs(String changeId) throws RestApiException {
+ Map<ReviewerState, Collection<AccountInfo>> reviewerMap =
+ gApi.changes().id(changeId).get().reviewers;
+ assertThat(reviewerMap).doesNotContainKey(ReviewerState.CC);
+ }
+
protected void assertSubmittedTogether(String chId, String... expected) throws Exception {
assertSubmittedTogether(chId, ImmutableSet.of(), expected);
}
@@ -1237,20 +1290,100 @@ public abstract class AbstractDaemonTest {
assertThat(refValues.keySet()).containsAnyIn(trees.keySet());
}
+ protected void assertDiffForFullyModifiedFile(
+ DiffInfo diff,
+ String commitName,
+ String path,
+ String expectedContentSideA,
+ String expectedContentSideB)
+ throws Exception {
+ assertDiffForFile(diff, commitName, path);
+
+ ImmutableList<String> expectedOldLines =
+ ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+ ImmutableList<String> expectedNewLines =
+ ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+
+ assertThat(diff.changeType).isEqualTo(ChangeType.MODIFIED);
+
+ assertThat(diff.metaA).isNotNull();
+ assertThat(diff.metaB).isNotNull();
+
+ assertThat(diff.metaA.name).isEqualTo(path);
+ assertThat(diff.metaA.lines).isEqualTo(expectedOldLines.size());
+ assertThat(diff.metaB.name).isEqualTo(path);
+ assertThat(diff.metaB.lines).isEqualTo(expectedNewLines.size());
+
+ DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+ assertThat(contentEntry.a).containsExactlyElementsIn(expectedOldLines).inOrder();
+ assertThat(contentEntry.b).containsExactlyElementsIn(expectedNewLines).inOrder();
+ assertThat(contentEntry.ab).isNull();
+ assertThat(contentEntry.common).isNull();
+ assertThat(contentEntry.editA).isNull();
+ assertThat(contentEntry.editB).isNull();
+ assertThat(contentEntry.skip).isNull();
+ }
+
protected void assertDiffForNewFile(
- DiffInfo diff, RevCommit commit, String path, String expectedContentSideB) throws Exception {
- List<String> expectedLines = ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
+ DiffInfo diff, @Nullable RevCommit commit, String path, String expectedContentSideB)
+ throws Exception {
+ assertDiffForNewFile(diff, commit.name(), path, expectedContentSideB);
+ }
+
+ protected void assertDiffForNewFile(
+ DiffInfo diff, String commitName, String path, String expectedContentSideB) throws Exception {
+ assertDiffForFile(diff, commitName, path);
+
+ ImmutableList<String> expectedLines =
+ ImmutableList.copyOf(expectedContentSideB.split("\n", -1));
- assertThat(diff.binary).isNull();
assertThat(diff.changeType).isEqualTo(ChangeType.ADDED);
- assertThat(diff.diffHeader).isNotNull();
- assertThat(diff.intralineStatus).isNull();
- assertThat(diff.webLinks).isNull();
- assertThat(diff.editWebLinks).isNull();
assertThat(diff.metaA).isNull();
assertThat(diff.metaB).isNotNull();
- assertThat(diff.metaB.commitId).isEqualTo(commit.name());
+
+ assertThat(diff.metaB.name).isEqualTo(path);
+ assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
+
+ DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+ assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
+ assertThat(contentEntry.a).isNull();
+ assertThat(contentEntry.ab).isNull();
+ assertThat(contentEntry.common).isNull();
+ assertThat(contentEntry.editA).isNull();
+ assertThat(contentEntry.editB).isNull();
+ assertThat(contentEntry.skip).isNull();
+ }
+
+ protected void assertDiffForDeletedFile(DiffInfo diff, String path, String expectedContentSideA)
+ throws Exception {
+ assertDiffHeaders(diff);
+
+ ImmutableList<String> expectedOriginalLines =
+ ImmutableList.copyOf(expectedContentSideA.split("\n", -1));
+
+ assertThat(diff.changeType).isEqualTo(ChangeType.DELETED);
+
+ assertThat(diff.metaA).isNotNull();
+ assertThat(diff.metaB).isNull();
+
+ assertThat(diff.metaA.name).isEqualTo(path);
+ assertThat(diff.metaA.lines).isEqualTo(expectedOriginalLines.size());
+
+ DiffInfo.ContentEntry contentEntry = diff.content.get(0);
+ assertThat(contentEntry.a).containsExactlyElementsIn(expectedOriginalLines).inOrder();
+ assertThat(contentEntry.b).isNull();
+ assertThat(contentEntry.ab).isNull();
+ assertThat(contentEntry.common).isNull();
+ assertThat(contentEntry.editA).isNull();
+ assertThat(contentEntry.editB).isNull();
+ assertThat(contentEntry.skip).isNull();
+ }
+
+ private void assertDiffForFile(DiffInfo diff, String commitName, String path) throws Exception {
+ assertDiffHeaders(diff);
+
+ assertThat(diff.metaB.commitId).isEqualTo(commitName);
String expectedContentType = "text/plain";
if (COMMIT_MSG.equals(path)) {
@@ -1258,21 +1391,19 @@ public abstract class AbstractDaemonTest {
} else if (MERGE_LIST.equals(path)) {
expectedContentType = FileContentUtil.TEXT_X_GERRIT_MERGE_LIST;
}
+
assertThat(diff.metaB.contentType).isEqualTo(expectedContentType);
- assertThat(diff.metaB.lines).isEqualTo(expectedLines.size());
assertThat(diff.metaB.name).isEqualTo(path);
assertThat(diff.metaB.webLinks).isNull();
+ }
- assertThat(diff.content).hasSize(1);
- DiffInfo.ContentEntry contentEntry = diff.content.get(0);
- assertThat(contentEntry.b).containsExactlyElementsIn(expectedLines).inOrder();
- assertThat(contentEntry.a).isNull();
- assertThat(contentEntry.ab).isNull();
- assertThat(contentEntry.common).isNull();
- assertThat(contentEntry.editA).isNull();
- assertThat(contentEntry.editB).isNull();
- assertThat(contentEntry.skip).isNull();
+ private void assertDiffHeaders(DiffInfo diff) throws Exception {
+ assertThat(diff.binary).isNull();
+ assertThat(diff.diffHeader).isNotNull();
+ assertThat(diff.intralineStatus).isNull();
+ assertThat(diff.webLinks).isNull();
+ assertThat(diff.editWebLinks).isNull();
}
protected void assertPermitted(ChangeInfo info, String label, Integer... expected) {
@@ -1286,6 +1417,17 @@ public abstract class AbstractDaemonTest {
}
}
+ protected void assertOnlyRemovableLabel(
+ ChangeInfo info, String labelId, String labelValue, TestAccount reviewer) {
+ assertThat(info.removableLabels).hasSize(1);
+ assertThat(info.removableLabels).containsKey(labelId);
+ assertThat(info.removableLabels.get(labelId)).hasSize(1);
+ assertThat(info.removableLabels.get(labelId)).containsKey(labelValue);
+ assertThat(info.removableLabels.get(labelId).get(labelValue)).hasSize(1);
+ assertThat(info.removableLabels.get(labelId).get(labelValue).get(0).email)
+ .isEqualTo(reviewer.email());
+ }
+
protected void assertPermissions(
Project.NameKey project,
GroupReference groupReference,
@@ -1589,11 +1731,14 @@ public abstract class AbstractDaemonTest {
}
public void save() throws Exception {
- metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.id()));
- projectConfig.commit(metaDataUpdate);
- metaDataUpdate.close();
- metaDataUpdate = null;
- projectCache.evictAndReindex(projectConfig.getProject());
+ testRefAction(
+ () -> {
+ metaDataUpdate.setAuthor(identifiedUserFactory.create(admin.id()));
+ projectConfig.commit(metaDataUpdate);
+ metaDataUpdate.close();
+ metaDataUpdate = null;
+ projectCache.evictAndReindex(projectConfig.getProject());
+ });
}
@Override
@@ -1636,6 +1781,14 @@ public abstract class AbstractDaemonTest {
.collect(ImmutableMap.toImmutableMap(branch -> branch.ref, branch -> branch));
}
+ protected void createRefLogFileIfMissing(Repository repo, String ref) throws IOException {
+ File log = new File(repo.getDirectory(), "logs/" + ref);
+ if (!log.exists()) {
+ log.getParentFile().mkdirs();
+ assertThat(log.createNewFile()).isTrue();
+ }
+ }
+
protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass)
throws Exception {
return installPlugin(pluginName, sysModuleClass, null, null);
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index da033c1ebb..8a9e56a911 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -343,7 +343,6 @@ public abstract class AbstractNotificationTest extends AbstractDaemonTest {
public final TestAccount reviewer;
public final TestAccount ccer;
public final TestAccount starrer;
- public final TestAccount assignee;
public final TestAccount watchingProjectOwner;
private final Map<NotifyType, TestAccount> watchers = new HashMap<>();
private final Map<String, TestAccount> accountsByEmail = new HashMap<>();
@@ -369,7 +368,6 @@ public abstract class AbstractNotificationTest extends AbstractDaemonTest {
reviewer = reindexAndCopy(existing.reviewer);
ccer = reindexAndCopy(existing.ccer);
starrer = reindexAndCopy(existing.starrer);
- assignee = reindexAndCopy(existing.assignee);
watchingProjectOwner = reindexAndCopy(existing.watchingProjectOwner);
watchers.putAll(existing.watchers);
return;
@@ -381,7 +379,6 @@ public abstract class AbstractNotificationTest extends AbstractDaemonTest {
uploader = testAccount("uploader");
ccer = testAccount("ccer");
starrer = testAccount("starrer");
- assignee = testAccount("assignee");
watchingProjectOwner = testAccount("watchingProjectOwner", "Administrators");
requestScopeOperations.setApiUser(watchingProjectOwner.id());
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 91fbf9e0ed..fe845c0612 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -303,6 +303,7 @@ public class AbstractPluginFieldsTest extends AbstractDaemonTest {
return pluginInfoFromChangeInfo(changeInfos.get(0));
}
+ @Nullable
protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
if (pluginInfo == null) {
@@ -331,6 +332,7 @@ public class AbstractPluginFieldsTest extends AbstractDaemonTest {
* @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
* @return decoded list of {@code MyInfo}s.
*/
+ @Nullable
protected static List<PluginDefinedInfo> decodeRawPluginsList(
Gson gson, @Nullable Object plugins) {
if (plugins == null) {
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index c67991dd5d..ff5bc0052a 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -141,6 +141,10 @@ public class AccountCreator {
return create(username, null, username, null, (String[]) null);
}
+ public TestAccount createValid(String username) throws Exception {
+ return create(username, username + "@example.com", username, username);
+ }
+
public TestAccount admin() throws Exception {
return create("admin", "admin@example.com", "Administrator", "Adminny", "Administrators");
}
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 2cf279f725..8b2160c93c 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -76,6 +76,8 @@ TEST_DEPS = [
"//java/com/google/gerrit/gpg/testing:gpg-test-util",
"//java/com/google/gerrit/git/testing",
"//java/com/google/gerrit/index/testing",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
+ "//lib/errorprone:annotations",
]
PGM_DEPLOY_ENV = [
diff --git a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
index 7bd0c73013..7660948abc 100644
--- a/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledAccountIndex.java
@@ -55,6 +55,11 @@ public class DisabledAccountIndex implements AccountIndex {
}
@Override
+ public void deleteByValue(AccountState value) {
+ throw new UnsupportedOperationException("AccountIndex is disabled");
+ }
+
+ @Override
public void delete(Account.Id key) {
throw new UnsupportedOperationException("AccountIndex is disabled");
}
diff --git a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
index 7671ad4068..c028a8e40b 100644
--- a/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledChangeIndex.java
@@ -62,6 +62,11 @@ public class DisabledChangeIndex implements ChangeIndex {
}
@Override
+ public void deleteByValue(ChangeData value) {
+ throw new UnsupportedOperationException("ChangeIndex is disabled");
+ }
+
+ @Override
public void delete(Change.Id key) {
throw new UnsupportedOperationException("ChangeIndex is disabled");
}
diff --git a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
index 2e3dd906b6..f2aad4aa48 100644
--- a/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
+++ b/java/com/google/gerrit/acceptance/DisabledProjectIndex.java
@@ -60,6 +60,11 @@ public class DisabledProjectIndex implements ProjectIndex {
}
@Override
+ public void deleteByValue(ProjectData value) {
+ throw new UnsupportedOperationException("ProjectIndex is disabled");
+ }
+
+ @Override
public void delete(Project.NameKey key) {
throw new UnsupportedOperationException("ProjectIndex is disabled");
}
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index a9ad3f237b..1199bf9a3e 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -70,6 +70,7 @@ import com.google.gerrit.server.ssh.NoSshModule;
import com.google.gerrit.server.util.ReplicaUtil;
import com.google.gerrit.server.util.SocketUtil;
import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.testing.FakeAccountPatchReviewStore.FakeAccountPatchReviewStoreModule;
import com.google.gerrit.testing.FakeEmailSender.FakeEmailSenderModule;
import com.google.gerrit.testing.InMemoryRepositoryManager;
import com.google.gerrit.testing.SshMode;
@@ -431,6 +432,7 @@ public class GerritServer implements AutoCloseable {
}
},
site);
+ daemon.setAccountPatchReviewStoreModuleForTesting(new FakeAccountPatchReviewStoreModule());
daemon.setEmailModuleForTesting(new FakeEmailSenderModule());
daemon.setAuditEventModuleForTesting(
MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditServiceModule()));
diff --git a/java/com/google/gerrit/acceptance/HttpResponse.java b/java/com/google/gerrit/acceptance/HttpResponse.java
index 88079a4046..76c0f0400d 100644
--- a/java/com/google/gerrit/acceptance/HttpResponse.java
+++ b/java/com/google/gerrit/acceptance/HttpResponse.java
@@ -19,6 +19,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
@@ -59,6 +60,7 @@ public class HttpResponse {
return getHeader("X-FYI-Content-Type");
}
+ @Nullable
public String getHeader(String name) {
Header hdr = response.getFirstHeader(name);
return hdr != null ? hdr.getValue() : null;
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java
index 46f7496e82..c8ab1a9ed0 100644
--- a/java/com/google/gerrit/acceptance/ProjectResetter.java
+++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -16,6 +16,7 @@ package com.google.gerrit.acceptance;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.entities.RefNames.REFS_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.util.stream.Collectors.toSet;
import com.google.common.collect.ImmutableList;
@@ -202,10 +203,12 @@ public class ProjectResetter implements AutoCloseable {
keptRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
restoredRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
deletedRefsByProject = MultimapBuilder.hashKeys().arrayListValues().build();
-
- restoreRefs();
- deleteNewlyCreatedRefs();
- evictCachesAndReindex();
+ testRefAction(
+ () -> {
+ restoreRefs();
+ deleteNewlyCreatedRefs();
+ evictCachesAndReindex();
+ });
}
/** Read the states of all matching refs. */
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 99db40a0be..9f38fcba90 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -24,6 +24,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Sets;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.common.UsedAt.Project;
@@ -40,6 +41,7 @@ import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.jgit.api.TagCommand;
@@ -280,6 +282,12 @@ public class PushOneCommit {
return this;
}
+ @CanIgnoreReturnValue
+ public PushOneCommit setTopLevelTreeId(ObjectId treeId) throws Exception {
+ commitBuilder.setTopLevelTree(treeId);
+ return this;
+ }
+
public PushOneCommit setParent(RevCommit parent) throws Exception {
commitBuilder.noParents();
commitBuilder.parent(parent);
@@ -291,6 +299,19 @@ public class PushOneCommit {
return this;
}
+ public PushOneCommit addFile(String path, String content, int fileMode) throws Exception {
+ RevBlob blobId = testRepo.blob(content);
+ commitBuilder.edit(
+ new PathEdit(path) {
+ @Override
+ public void apply(DirCacheEntry ent) {
+ ent.setFileMode(FileMode.fromBits(fileMode));
+ ent.setObjectId(blobId);
+ }
+ });
+ return this;
+ }
+
public PushOneCommit addSymlink(String path, String target) throws Exception {
RevBlob blobId = testRepo.blob(target);
commitBuilder.edit(
@@ -470,12 +491,14 @@ public class PushOneCommit {
public void assertMessage(String expectedMessage) {
RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
assertThat(refUpdate).isNotNull();
- assertThat(message(refUpdate).toLowerCase()).contains(expectedMessage.toLowerCase());
+ assertThat(message(refUpdate).toLowerCase(Locale.US))
+ .contains(expectedMessage.toLowerCase(Locale.US));
}
public void assertNotMessage(String message) {
RemoteRefUpdate refUpdate = result.getRemoteUpdate(ref);
- assertThat(message(refUpdate).toLowerCase()).doesNotContain(message.toLowerCase());
+ assertThat(message(refUpdate).toLowerCase(Locale.US))
+ .doesNotContain(message.toLowerCase(Locale.US));
}
public String getMessage() {
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index 881f389071..85233f25e1 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -14,13 +14,19 @@
package com.google.gerrit.acceptance;
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.DisabledMetricMaker;
import com.google.gerrit.metrics.Field;
import com.google.gerrit.metrics.Timer1;
import com.google.inject.Singleton;
+import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import org.apache.commons.lang3.mutable.MutableLong;
@@ -28,11 +34,9 @@ import org.apache.commons.lang3.mutable.MutableLong;
/**
* {@link com.google.gerrit.metrics.MetricMaker} to be bound in tests.
*
- * <p>Records how often {@link Counter0} and {@link Timer1} metrics are invoked. Metrics for other
- * types are not recorded.
+ * <p>Records how often counter metrics are invoked. Metrics of other types are not recorded.
*
- * <p>Allows test to check how much a {@link Counter0} and {@link Timer1} metric is increased by an
- * operation.
+ * <p>Allows test to check how much a counter metrics is increased by an operation.
*
* <p>Example:
*
@@ -53,15 +57,15 @@ import org.apache.commons.lang3.mutable.MutableLong;
*/
@Singleton
public class TestMetricMaker extends DisabledMetricMaker {
- private final ConcurrentHashMap<String, MutableLong> counts = new ConcurrentHashMap<>();
- private final ConcurrentHashMap<String, MutableLong> timers = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap<CounterKey, MutableLong> counts = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap<CounterKey, MutableLong> timers = new ConcurrentHashMap<>();
- public long getCount(String counter0Name) {
- return getCounterValue(counter0Name).longValue();
+ public long getCount(String counterName, Object... fieldValues) {
+ return getCounterValue(CounterKey.create(counterName, fieldValues)).longValue();
}
public long getTimer(String timerName) {
- return getTimerValue(timerName).longValue();
+ return getTimerValue(CounterKey.create(timerName)).longValue();
}
public void reset() {
@@ -69,11 +73,11 @@ public class TestMetricMaker extends DisabledMetricMaker {
timers.clear();
}
- private MutableLong getCounterValue(String counter0Name) {
- return counts.computeIfAbsent(counter0Name, name -> new MutableLong(0));
+ private MutableLong getCounterValue(CounterKey counterKey) {
+ return counts.computeIfAbsent(counterKey, name -> new MutableLong(0));
}
- private MutableLong getTimerValue(String timerName) {
+ private MutableLong getTimerValue(CounterKey timerName) {
return counts.computeIfAbsent(timerName, name -> new MutableLong(0));
}
@@ -82,7 +86,7 @@ public class TestMetricMaker extends DisabledMetricMaker {
return new Counter0() {
@Override
public void incrementBy(long value) {
- getCounterValue(name).add(value);
+ getCounterValue(CounterKey.create(name)).add(value);
}
@Override
@@ -96,11 +100,64 @@ public class TestMetricMaker extends DisabledMetricMaker {
return new Timer1<>(name, field1) {
@Override
protected void doRecord(F1 field1, long value, TimeUnit unit) {
- getTimerValue(name).add(value);
+ getTimerValue(CounterKey.create(name)).add(value);
}
@Override
public void remove() {}
};
}
+
+ @Override
+ public <F1> Counter1<F1> newCounter(String name, Description desc, Field<F1> field1) {
+ return new Counter1<>() {
+ @Override
+ public void incrementBy(F1 field1, long value) {
+ getCounterValue(CounterKey.create(name, field1)).add(value);
+ }
+
+ @Override
+ public void remove() {}
+ };
+ }
+
+ @Override
+ public <F1, F2> Counter2<F1, F2> newCounter(
+ String name, Description desc, Field<F1> field1, Field<F2> field2) {
+ return new Counter2<>() {
+ @Override
+ public void incrementBy(F1 field1, F2 field2, long value) {
+ getCounterValue(CounterKey.create(name, field1, field2)).add(value);
+ }
+
+ @Override
+ public void remove() {}
+ };
+ }
+
+ @Override
+ public <F1, F2, F3> Counter3<F1, F2, F3> newCounter(
+ String name, Description desc, Field<F1> field1, Field<F2> field2, Field<F3> field3) {
+ return new Counter3<>() {
+ @Override
+ public void incrementBy(F1 field1, F2 field2, F3 field3, long value) {
+ getCounterValue(CounterKey.create(name, field1, field2, field3)).add(value);
+ }
+
+ @Override
+ public void remove() {}
+ };
+ }
+
+ @AutoValue
+ abstract static class CounterKey {
+ abstract String name();
+
+ abstract ImmutableList<Object> fieldValues();
+
+ static CounterKey create(String name, Object... fieldValues) {
+ return new AutoValue_TestMetricMaker_CounterKey(
+ name, ImmutableList.copyOf(Arrays.asList(fieldValues)));
+ }
+ }
}
diff --git a/java/com/google/gerrit/acceptance/config/BUILD b/java/com/google/gerrit/acceptance/config/BUILD
index a8ccc1fae0..0da68b0f80 100644
--- a/java/com/google/gerrit/acceptance/config/BUILD
+++ b/java/com/google/gerrit/acceptance/config/BUILD
@@ -7,6 +7,7 @@ java_library(
srcs = glob(["*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//lib:guava",
"//lib:jgit",
"//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
index 32dfa83a6a..fc6be0376a 100644
--- a/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
+++ b/java/com/google/gerrit/acceptance/config/ConfigAnnotationParser.java
@@ -18,6 +18,7 @@ import com.google.auto.value.AutoAnnotation;
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -27,6 +28,7 @@ import org.eclipse.jgit.lib.Config;
public class ConfigAnnotationParser {
private static Splitter splitter = Splitter.on(".").trimResults();
+ @Nullable
public static Config parse(Config base, GerritConfigs annotation) {
if (annotation == null) {
return null;
@@ -55,6 +57,7 @@ public class ConfigAnnotationParser {
System.setProperty(annotation.name(), annotation.value());
}
+ @Nullable
public static Map<String, Config> parse(GlobalPluginConfigs annotation) {
if (annotation == null || annotation.value().length < 1) {
return null;
@@ -77,6 +80,7 @@ public class ConfigAnnotationParser {
return result;
}
+ @Nullable
public static Map<String, Config> parse(GlobalPluginConfig annotation) {
if (annotation == null) {
return null;
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
index 277d2195f3..e510ba3f92 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/TestSshKeys.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.testsuite.account;
import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.US_ASCII;
import com.google.gerrit.acceptance.SshEnabled;
@@ -88,7 +89,8 @@ public class TestSshKeys {
private KeyPair createKeyPair(Account.Id accountId, String username, @Nullable String email)
throws Exception {
KeyPair keyPair = SshSessionFactory.genSshKey();
- authorizedKeys.addKey(accountId, publicKey(keyPair, email));
+ testRefAction(() -> authorizedKeys.addKey(accountId, publicKey(keyPair, email)));
+
sshKeyCache.evict(username);
return keyPair;
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
index c1029be69c..5efcfc6505 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -16,10 +16,12 @@ package com.google.gerrit.acceptance.testsuite.change;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
@@ -43,6 +45,7 @@ import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -131,29 +134,33 @@ public class ChangeOperationsImpl implements ChangeOperations {
}
private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
- Change.Id changeId = Change.id(seq.nextChangeId());
- Project.NameKey project = getTargetProject(changeCreation);
-
- try (Repository repository = repositoryManager.openRepository(project);
- ObjectInserter objectInserter = repository.newObjectInserter();
- RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
- Instant now = TimeUtil.now();
- IdentifiedUser changeOwner = getChangeOwner(changeCreation);
- PersonIdent authorAndCommitter = changeOwner.newCommitterIdent(now, serverIdent.getZoneId());
- ObjectId commitId =
- createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
-
- String refName = RefNames.fullName(changeCreation.branch());
- ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
- changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
- inserter.setApprovals(changeCreation.approvals());
-
- try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
- batchUpdate.setRepository(repository, revWalk, objectInserter);
- batchUpdate.insertChange(inserter);
- batchUpdate.execute();
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ Change.Id changeId = Change.id(seq.nextChangeId());
+ Project.NameKey project = getTargetProject(changeCreation);
+
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Instant now = TimeUtil.now();
+ IdentifiedUser changeOwner = getChangeOwner(changeCreation);
+ PersonIdent author = getAuthorIdent(now, changeCreation);
+ PersonIdent committer = getCommitterIdent(now, changeCreation);
+ ObjectId commitId =
+ createCommit(repository, revWalk, objectInserter, changeCreation, author, committer);
+
+ String refName = RefNames.fullName(changeCreation.branch());
+ ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+ inserter.setGroups(getGroups(changeCreation));
+ changeCreation.topic().ifPresent(t -> inserter.setTopic(t));
+ inserter.setApprovals(changeCreation.approvals());
+
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.insertChange(inserter);
+ batchUpdate.execute();
+ }
+ return changeId;
}
- return changeId;
}
}
@@ -189,6 +196,30 @@ public class ChangeOperationsImpl implements ChangeOperations {
return getArbitraryUser();
}
+ private PersonIdent getAuthorIdent(Instant when, TestChangeCreation changeCreation)
+ throws IOException, ConfigInvalidException {
+ if (changeCreation.authorIdent().isPresent()) {
+ return new PersonIdent(changeCreation.authorIdent().get(), when);
+ }
+
+ return (changeCreation.author().isPresent()
+ ? userFactory.create(changeCreation.author().get())
+ : getChangeOwner(changeCreation))
+ .newCommitterIdent(when, serverIdent.getZoneId());
+ }
+
+ private PersonIdent getCommitterIdent(Instant when, TestChangeCreation changeCreation)
+ throws IOException, ConfigInvalidException {
+ if (changeCreation.committerIdent().isPresent()) {
+ return new PersonIdent(changeCreation.committerIdent().get(), when);
+ }
+
+ return (changeCreation.committer().isPresent()
+ ? userFactory.create(changeCreation.committer().get())
+ : getChangeOwner(changeCreation))
+ .newCommitterIdent(when, serverIdent.getZoneId());
+ }
+
private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
checkState(
@@ -202,20 +233,84 @@ public class ChangeOperationsImpl implements ChangeOperations {
RevWalk revWalk,
ObjectInserter objectInserter,
TestChangeCreation changeCreation,
- PersonIdent authorAndCommitter)
+ PersonIdent author,
+ PersonIdent committer)
throws IOException, BadRequestException {
ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
TreeCreator treeCreator =
getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
String commitMessage = correctCommitMessage(changeCreation.commitMessage());
- return createCommit(
- objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
+ return createCommit(objectInserter, tree, parentCommits, author, committer, commitMessage);
+ }
+
+ private ImmutableList<String> getGroups(TestChangeCreation changeCreation) {
+ return changeCreation
+ .parents()
+ .map(parents -> getGroups(parents))
+ .orElseGet(() -> ImmutableList.of());
+ }
+
+ private ImmutableList<String> getGroups(ImmutableList<TestCommitIdentifier> parents) {
+ return parents.stream()
+ .map(parent -> getGroups(parent))
+ .flatMap(groups -> groups.stream())
+ .collect(toImmutableList());
+ }
+
+ private ImmutableList<String> getGroups(TestCommitIdentifier parentCommit) {
+ switch (parentCommit.getKind()) {
+ case BRANCH:
+ return ImmutableList.of();
+ case CHANGE_ID:
+ return getGroupsFromChange(parentCommit.changeId());
+ case COMMIT_SHA_1:
+ return ImmutableList.of();
+ case PATCHSET_ID:
+ return getGroupsFromPatchset(parentCommit.patchsetId());
+ default:
+ throw new IllegalStateException(
+ String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
+ }
+ }
+
+ private ImmutableList<String> getGroupsFromChange(Change.Id changeId) {
+ Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+
+ if (changeNotes.isPresent() && changeNotes.get().getChange().isClosed()) {
+ return ImmutableList.of();
+ }
+
+ return changeNotes
+ .map(ChangeNotes::getCurrentPatchSet)
+ .map(PatchSet::groups)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Change %s not found and hence can't be used as parent.", changeId)));
+ }
+
+ private ImmutableList<String> getGroupsFromPatchset(PatchSet.Id patchsetId) {
+ Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
+
+ if (changeNotes.isPresent() && changeNotes.get().getChange().isClosed()) {
+ return ImmutableList.of();
+ }
+
+ return changeNotes
+ .map(ChangeNotes::getPatchSets)
+ .map(patchsets -> patchsets.get(patchsetId))
+ .map(PatchSet::groups)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Patchset %s not found and hence can't be used as parent.", patchsetId)));
}
private ImmutableList<ObjectId> getParentCommits(
Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
-
return changeCreation
.parents()
.map(parents -> resolveParents(repository, revWalk, parents))
@@ -426,29 +521,72 @@ public class ChangeOperationsImpl implements ChangeOperations {
private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
throws IOException, RestApiException, UpdateException {
- ChangeNotes changeNotes = getChangeNotes();
- Project.NameKey project = changeNotes.getProjectName();
- try (Repository repository = repositoryManager.openRepository(project);
- ObjectInserter objectInserter = repository.newObjectInserter();
- RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
- Instant now = TimeUtil.now();
- ObjectId newPatchsetCommit =
- createPatchsetCommit(
- repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ ChangeNotes changeNotes = getChangeNotes();
+ Project.NameKey project = changeNotes.getProjectName();
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Instant now = TimeUtil.now();
+ PersonIdent authorIdent = getAuthorIdent(now, patchsetCreation);
+ PersonIdent committerIdent = getCommitterIdent(now, patchsetCreation);
+ ObjectId newPatchsetCommit =
+ createPatchsetCommit(
+ repository,
+ revWalk,
+ objectInserter,
+ changeNotes,
+ patchsetCreation,
+ authorIdent,
+ committerIdent,
+ now);
+
+ PatchSet.Id patchsetId =
+ ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
+ PatchSetInserter patchSetInserter =
+ getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+
+ Account.Id uploaderId =
+ patchsetCreation.uploader().orElse(changeNotes.getChange().getOwner());
+ IdentifiedUser uploader = userFactory.create(uploaderId);
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, uploader, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.addOp(changeId, patchSetInserter);
+ batchUpdate.execute();
+ }
+ return patchsetId;
+ }
+ }
+ }
+
+ @Nullable
+ private PersonIdent getAuthorIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+ if (patchsetCreation.authorIdent().isPresent()) {
+ return new PersonIdent(patchsetCreation.authorIdent().get(), when);
+ }
- PatchSet.Id patchsetId =
- ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
- PatchSetInserter patchSetInserter =
- getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+ if (patchsetCreation.author().isPresent()) {
+ return userFactory
+ .create(patchsetCreation.author().get())
+ .newCommitterIdent(when, serverIdent.getZoneId());
+ }
- IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
- try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
- batchUpdate.setRepository(repository, revWalk, objectInserter);
- batchUpdate.addOp(changeId, patchSetInserter);
- batchUpdate.execute();
- }
- return patchsetId;
+ return null;
+ }
+
+ @Nullable
+ private PersonIdent getCommitterIdent(Instant when, TestPatchsetCreation patchsetCreation) {
+ if (patchsetCreation.committerIdent().isPresent()) {
+ return new PersonIdent(patchsetCreation.committerIdent().get(), when);
}
+
+ if (patchsetCreation.committer().isPresent()) {
+ return userFactory
+ .create(patchsetCreation.committer().get())
+ .newCommitterIdent(when, serverIdent.getZoneId());
+ }
+
+ return null;
}
private ObjectId createPatchsetCommit(
@@ -457,6 +595,8 @@ public class ChangeOperationsImpl implements ChangeOperations {
ObjectInserter objectInserter,
ChangeNotes changeNotes,
TestPatchsetCreation patchsetCreation,
+ @Nullable PersonIdent author,
+ @Nullable PersonIdent committer,
Instant now)
throws IOException, BadRequestException {
ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
@@ -472,9 +612,13 @@ public class ChangeOperationsImpl implements ChangeOperations {
changeNotes.getChange().getKey().get(),
patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
- PersonIdent author = getAuthor(oldPatchsetCommit);
- PersonIdent committer = getCommitter(oldPatchsetCommit, now);
- return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+ return createCommit(
+ objectInserter,
+ tree,
+ parentCommitIds,
+ Optional.ofNullable(author).orElse(getAuthor(oldPatchsetCommit)),
+ Optional.ofNullable(committer).orElse(getCommitter(oldPatchsetCommit, now)),
+ commitMessage);
}
private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
index d0ccd5b1df..1971c57bd2 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -28,13 +28,18 @@ import java.util.function.Consumer;
public class FileContentBuilder<T> {
private final T builder;
private final String filePath;
+ private final int newGitFileMode;
private final Consumer<TreeModification> modificationToBuilderAdder;
FileContentBuilder(
- T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+ T builder,
+ String filePath,
+ int newGitFileMode,
+ Consumer<TreeModification> modificationToBuilderAdder) {
checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
this.builder = builder;
this.filePath = filePath;
+ this.newGitFileMode = newGitFileMode;
this.modificationToBuilderAdder = modificationToBuilderAdder;
}
@@ -44,7 +49,7 @@ public class FileContentBuilder<T> {
Strings.emptyToNull(content),
"Empty file content is not supported. Adjust test API if necessary.");
modificationToBuilderAdder.accept(
- new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+ new ChangeFileContentModification(filePath, RawInputUtil.create(content), newGitFileMode));
return builder;
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
index 9b393ef5dd..3bd355b3d0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -14,6 +14,8 @@
package com.google.gerrit.acceptance.testsuite.change;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Comment.Status;
import com.google.gerrit.entities.HumanComment;
@@ -34,6 +36,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
@@ -101,21 +104,23 @@ public class PerPatchsetOperationsImpl implements PerPatchsetOperations {
private String createComment(TestCommentCreation commentCreation)
throws IOException, RestApiException, UpdateException {
- Project.NameKey project = changeNotes.getProjectName();
- try (Repository repository = repositoryManager.openRepository(project);
- ObjectInserter objectInserter = repository.newObjectInserter();
- RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
- Instant now = TimeUtil.now();
-
- IdentifiedUser author = getAuthor(commentCreation);
- CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
- try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
- batchUpdate.setRepository(repository, revWalk, objectInserter);
- batchUpdate.addOp(changeNotes.getChangeId(), commentAdditionOp);
- batchUpdate.execute();
+ Project.NameKey project = changeNotes.getProjectName();
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Instant now = TimeUtil.now();
+
+ IdentifiedUser author = getAuthor(commentCreation);
+ CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.addOp(changeNotes.getChangeId(), commentAdditionOp);
+ batchUpdate.execute();
+ }
+ return commentAdditionOp.createdCommentUuid;
}
- return commentAdditionOp.createdCommentUuid;
}
}
@@ -197,21 +202,22 @@ public class PerPatchsetOperationsImpl implements PerPatchsetOperations {
private String createRobotComment(TestRobotCommentCreation robotCommentCreation)
throws IOException, RestApiException, UpdateException {
Project.NameKey project = changeNotes.getProjectName();
-
- try (Repository repository = repositoryManager.openRepository(project);
- ObjectInserter objectInserter = repository.newObjectInserter();
- RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
- Instant now = TimeUtil.now();
-
- IdentifiedUser author = getAuthor(robotCommentCreation);
- RobotCommentAdditionOp robotCommentAdditionOp =
- new RobotCommentAdditionOp(robotCommentCreation);
- try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
- batchUpdate.setRepository(repository, revWalk, objectInserter);
- batchUpdate.addOp(changeNotes.getChangeId(), robotCommentAdditionOp);
- batchUpdate.execute();
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Instant now = TimeUtil.now();
+
+ IdentifiedUser author = getAuthor(robotCommentCreation);
+ RobotCommentAdditionOp robotCommentAdditionOp =
+ new RobotCommentAdditionOp(robotCommentCreation);
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.addOp(changeNotes.getChangeId(), robotCommentAdditionOp);
+ batchUpdate.execute();
+ }
+ return robotCommentAdditionOp.createdRobotCommentUuid;
}
- return robotCommentAdditionOp.createdRobotCommentUuid;
}
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
index a064d0212c..a0746e2156 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -14,6 +14,8 @@
package com.google.gerrit.acceptance.testsuite.change;
+import static com.google.common.base.Preconditions.checkState;
+
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -24,6 +26,7 @@ import com.google.gerrit.entities.Project;
import com.google.gerrit.server.edit.tree.TreeModification;
import java.util.Optional;
import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.merge.MergeStrategy;
/** Initial attributes of the change. If not provided, arbitrary values will be used. */
@@ -35,6 +38,14 @@ public abstract class TestChangeCreation {
public abstract Optional<Account.Id> owner();
+ public abstract Optional<Account.Id> author();
+
+ public abstract Optional<PersonIdent> authorIdent();
+
+ public abstract Optional<Account.Id> committer();
+
+ public abstract Optional<PersonIdent> committerIdent();
+
public abstract Optional<String> topic();
public abstract ImmutableMap<String, Short> approvals();
@@ -69,9 +80,65 @@ public abstract class TestChangeCreation {
*/
public abstract Builder branch(String branch);
- /** The change owner. Must be an existing user account. */
+ /**
+ * The change owner.
+ *
+ * <p>Must be an existing user account.
+ */
public abstract Builder owner(Account.Id owner);
+ /**
+ * The author of the commit for which the change is created.
+ *
+ * <p>Must be an existing user account.
+ *
+ * <p>Cannot be set together with {@link #authorIdent()} is set.
+ *
+ * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the author.
+ */
+ public abstract Builder author(Account.Id author);
+
+ /**
+ * The author ident of the commit for which the change is created.
+ *
+ * <p>Cannot be set together with {@link #author()} is set.
+ *
+ * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the author.
+ */
+ public abstract Builder authorIdent(PersonIdent authorIdent);
+
+ public abstract Optional<Account.Id> author();
+
+ public abstract Optional<PersonIdent> authorIdent();
+
+ /**
+ * The committer of the commit for which the change is created.
+ *
+ * <p>Must be an existing user account.
+ *
+ * <p>Cannot be set together with {@link #committerIdent()} is set.
+ *
+ * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the committer.
+ */
+ public abstract Builder committer(Account.Id committer);
+
+ /**
+ * The committer ident of the commit for which the change is created.
+ *
+ * <p>Cannot be set together with {@link #committer()} is set.
+ *
+ * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the committer.
+ */
+ public abstract Builder committerIdent(PersonIdent committerIdent);
+
+ public abstract Optional<Account.Id> committer();
+
+ public abstract Optional<PersonIdent> committerIdent();
+
/** The topic to add this change to. */
public abstract Builder topic(String topic);
@@ -89,7 +156,18 @@ public abstract class TestChangeCreation {
/** Modified file of the change. The file content is specified via the returned builder. */
public FileContentBuilder<Builder> file(String filePath) {
- return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+ return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+ }
+
+ /**
+ * Modified file of the change. The file content is specified via the returned builder. The
+ * second parameter indicates the git file mode for the modified file if it has been changed.
+ *
+ * @see org.eclipse.jgit.lib.FileMode
+ */
+ public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+ return new FileContentBuilder<>(
+ this, filePath, newGitFileMode, treeModificationsBuilder()::add);
}
abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
@@ -145,13 +223,23 @@ public abstract class TestChangeCreation {
abstract TestChangeCreation autoBuild();
+ public TestChangeCreation build() {
+ checkState(
+ author().isEmpty() || authorIdent().isEmpty(),
+ "author and authorIdent cannot be set together");
+ checkState(
+ committer().isEmpty() || committerIdent().isEmpty(),
+ "committer and committerIdent cannot be set together");
+ return autoBuild();
+ }
+
/**
* Creates the change.
*
* @return the {@code Change.Id} of the created change
*/
public Change.Id create() {
- TestChangeCreation changeUpdate = autoBuild();
+ TestChangeCreation changeUpdate = build();
return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
}
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
index fe9d909c10..f8ca977294 100644
--- a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -14,17 +14,31 @@
package com.google.gerrit.acceptance.testsuite.change;
+import static com.google.common.base.Preconditions.checkState;
+
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.server.edit.tree.TreeModification;
import java.util.Optional;
+import org.eclipse.jgit.lib.PersonIdent;
/** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
@AutoValue
public abstract class TestPatchsetCreation {
+ public abstract Optional<Account.Id> uploader();
+
+ public abstract Optional<Account.Id> author();
+
+ public abstract Optional<PersonIdent> authorIdent();
+
+ public abstract Optional<Account.Id> committer();
+
+ public abstract Optional<PersonIdent> committerIdent();
+
public abstract Optional<String> commitMessage();
public abstract ImmutableList<TreeModification> treeModifications();
@@ -40,12 +54,78 @@ public abstract class TestPatchsetCreation {
@AutoValue.Builder
public abstract static class Builder {
+ /**
+ * The uploader for the new patch set.
+ *
+ * <p>Must be an existing user account.
+ *
+ * <p>If not set the new patch set is uploaded by the change owner.
+ */
+ public abstract Builder uploader(Account.Id uploader);
+
+ /**
+ * The author of the commit for which the change is created.
+ *
+ * <p>Must be an existing user account.
+ *
+ * <p>Cannot be set together with {@link #authorIdent()} is set.
+ *
+ * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the author.
+ */
+ public abstract Builder author(Account.Id author);
+
+ /**
+ * The author ident of the commit for which the change is created.
+ *
+ * <p>Cannot be set together with {@link #author()} is set.
+ *
+ * <p>If neither {@link #author()} nor {@link #authorIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the author.
+ */
+ public abstract Builder authorIdent(PersonIdent authorIdent);
+
+ public abstract Optional<Account.Id> author();
+
+ public abstract Optional<PersonIdent> authorIdent();
+
+ /**
+ * The committer of the commit for which the change is created.
+ *
+ * <p>Must be an existing user account.
+ *
+ * <p>Cannot be set together with {@link #committerIdent()} is set.
+ *
+ * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the committer.
+ */
+ public abstract Builder committer(Account.Id committer);
+
+ /**
+ * The committer ident of the commit for which the change is created.
+ *
+ * <p>Cannot be set together with {@link #committer()} is set.
+ *
+ * <p>If neither {@link #committer()} nor {@link #committerIdent()} is set the {@link
+ * TestChangeCreation#owner()} is used as the committer.
+ */
+ public abstract Builder committerIdent(PersonIdent committerIdent);
+
+ public abstract Optional<Account.Id> committer();
+
+ public abstract Optional<PersonIdent> committerIdent();
public abstract Builder commitMessage(String commitMessage);
/** Modified file of the patchset. The file content is specified via the returned builder. */
public FileContentBuilder<Builder> file(String filePath) {
- return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+ return new FileContentBuilder<>(this, filePath, 0, treeModificationsBuilder()::add);
+ }
+
+ /** Modified file of the patchset. The file content is specified via the returned builder. */
+ public FileContentBuilder<Builder> file(String filePath, int newGitFileMode) {
+ return new FileContentBuilder<>(
+ this, filePath, newGitFileMode, treeModificationsBuilder()::add);
}
abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
@@ -86,13 +166,23 @@ public abstract class TestPatchsetCreation {
abstract TestPatchsetCreation autoBuild();
+ public TestPatchsetCreation build() {
+ checkState(
+ author().isEmpty() || authorIdent().isEmpty(),
+ "author and authorIdent cannot be set together");
+ checkState(
+ committer().isEmpty() || committerIdent().isEmpty(),
+ "committer and committerIdent cannot be set together");
+ return autoBuild();
+ }
+
/**
* Creates the patchset.
*
* @return the {@code PatchSet.Id} of the created patchset
*/
public PatchSet.Id create() {
- TestPatchsetCreation patchsetCreation = autoBuild();
+ TestPatchsetCreation patchsetCreation = build();
return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
}
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/BUILD b/java/com/google/gerrit/acceptance/testsuite/group/BUILD
index d4f117586a..2052105a41 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/group/BUILD
@@ -14,6 +14,7 @@ java_library(
"//java/com/google/gerrit/exceptions",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:guava",
"//lib:jgit",
"//lib:jgit-junit",
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
index 8bb7b23b9a..99899cf34d 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/TestGroupCreation.java
@@ -14,6 +14,8 @@
package com.google.gerrit.acceptance.testsuite.group;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
+
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
@@ -106,7 +108,7 @@ public abstract class TestGroupCreation {
*/
public AccountGroup.UUID create() {
TestGroupCreation groupCreation = autoBuild();
- return groupCreation.groupCreator().applyAndThrowSilently(groupCreation);
+ return testRefAction(() -> groupCreation.groupCreator().applyAndThrowSilently(groupCreation));
}
}
}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/BUILD b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
index 850a133e35..4ac2705fb0 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/BUILD
+++ b/java/com/google/gerrit/acceptance/testsuite/project/BUILD
@@ -4,14 +4,17 @@ package(default_testonly = 1)
java_library(
name = "project",
+ testonly = True,
srcs = glob(["*.java"]),
visibility = ["//visibility:public"],
deps = [
"//java/com/google/gerrit/acceptance:function",
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/common:server",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:guava",
"//lib:jgit",
"//lib:jgit-junit",
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index deeb84309f..bd3d65645f 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -17,6 +17,7 @@ package com.google.gerrit.acceptance.testsuite.project;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
@@ -26,6 +27,7 @@ import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
@@ -40,6 +42,7 @@ import com.google.gerrit.server.project.CreateProjectArgs;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectCreator;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
@@ -135,19 +138,21 @@ public class ProjectOperationsImpl implements ProjectOperations {
private void updateProject(TestProjectUpdate projectUpdate)
throws IOException, ConfigInvalidException {
- try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
- ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
- if (projectUpdate.removeAllAccessSections()) {
- projectConfig.getAccessSections().forEach(as -> projectConfig.remove(as));
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (MetaDataUpdate metaDataUpdate = metaDataUpdateFactory.create(nameKey)) {
+ ProjectConfig projectConfig = projectConfigFactory.read(metaDataUpdate);
+ if (projectUpdate.removeAllAccessSections()) {
+ projectConfig.getAccessSections().forEach(as -> projectConfig.remove(as));
+ }
+ removePermissions(projectConfig, projectUpdate.removedPermissions());
+ addCapabilities(projectConfig, projectUpdate.addedCapabilities());
+ addPermissions(projectConfig, projectUpdate.addedPermissions());
+ addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
+ setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
+ projectConfig.commit(metaDataUpdate);
}
- removePermissions(projectConfig, projectUpdate.removedPermissions());
- addCapabilities(projectConfig, projectUpdate.addedCapabilities());
- addPermissions(projectConfig, projectUpdate.addedPermissions());
- addLabelPermissions(projectConfig, projectUpdate.addedLabelPermissions());
- setExclusiveGroupPermissions(projectConfig, projectUpdate.exclusiveGroupPermissions());
- projectConfig.commit(metaDataUpdate);
+ projectCache.evictAndReindex(nameKey);
}
- projectCache.evictAndReindex(nameKey);
}
private void removePermissions(
@@ -196,8 +201,13 @@ public class ProjectOperationsImpl implements ProjectOperations {
PermissionRule.Builder rule = newRule(projectConfig, p.group());
rule.setAction(p.action());
rule.setRange(p.min(), p.max());
- String permissionName =
- p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+ String permissionName;
+ if (p.isAddPermission()) {
+ permissionName =
+ p.impersonation() ? Permission.forLabelAs(p.name()) : Permission.forLabel(p.name());
+ } else {
+ permissionName = Permission.forRemoveLabel(p.name());
+ }
projectConfig.upsertAccessSection(
p.ref(), as -> as.upsertPermission(permissionName).add(rule));
}
@@ -213,6 +223,7 @@ public class ProjectOperationsImpl implements ProjectOperations {
as -> as.upsertPermission(key.name()).setExclusiveGroup(exclusive)));
}
+ @Nullable
private RevCommit headOrNull(String branch) {
branch = RefNames.fullName(branch);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 9a9a21a3d5..5634c780ba 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -162,12 +162,34 @@ public abstract class TestProjectUpdate {
/** Starts a builder for allowing a label permission. */
public static TestLabelPermission.Builder allowLabel(String name) {
- return TestLabelPermission.builder().name(name).action(PermissionRule.Action.ALLOW);
+ return TestLabelPermission.builder()
+ .name(name)
+ .isAddPermission(true)
+ .action(PermissionRule.Action.ALLOW);
}
/** Starts a builder for denying a label permission. */
public static TestLabelPermission.Builder blockLabel(String name) {
- return TestLabelPermission.builder().name(name).action(PermissionRule.Action.BLOCK);
+ return TestLabelPermission.builder()
+ .name(name)
+ .isAddPermission(true)
+ .action(PermissionRule.Action.BLOCK);
+ }
+
+ /** Starts a builder for allowing a remove-label permission. */
+ public static TestLabelPermission.Builder allowLabelRemoval(String name) {
+ return TestLabelPermission.builder()
+ .name(name)
+ .isAddPermission(false)
+ .action(PermissionRule.Action.ALLOW);
+ }
+
+ /** Starts a builder for denying a remove-label permission. */
+ public static TestLabelPermission.Builder blockLabelRemoval(String name) {
+ return TestLabelPermission.builder()
+ .name(name)
+ .isAddPermission(false)
+ .action(PermissionRule.Action.BLOCK);
}
/** Records a label permission to be updated. */
@@ -191,6 +213,8 @@ public abstract class TestProjectUpdate {
abstract boolean impersonation();
+ abstract boolean isAddPermission();
+
/** Builder for {@link TestLabelPermission}. */
@AutoValue.Builder
public abstract static class Builder {
@@ -208,6 +232,8 @@ public abstract class TestProjectUpdate {
abstract Builder max(int max);
+ abstract Builder isAddPermission(boolean isAddPermission);
+
/** Sets the minimum and maximum values for the permission. */
public Builder range(int min, int max) {
checkArgument(min != 0 || max != 0, "empty range");
@@ -243,6 +269,12 @@ public abstract class TestProjectUpdate {
return TestPermissionKey.builder().name(Permission.forLabel(name));
}
+ /** Starts a builder for describing a label removal permission key for deletion. */
+ public static TestPermissionKey.Builder labelRemovalPermissionKey(String name) {
+ checkLabelName(name);
+ return TestPermissionKey.builder().name(Permission.forRemoveLabel(name));
+ }
+
/** Starts a builder for describing a capability key for deletion. */
public static TestPermissionKey.Builder capabilityKey(String name) {
return TestPermissionKey.builder().name(name).section(GLOBAL_CAPABILITIES);
diff --git a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
index 8fb4d35b3b..c40baba1f0 100644
--- a/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/FakeLdapGroupBackend.java
@@ -39,6 +39,7 @@ public class FakeLdapGroupBackend implements GroupBackend {
return uuid.get().startsWith(LDAP_UUID);
}
+ @Nullable
@Override
public GroupDescription.Basic get(AccountGroup.UUID uuid) {
if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/auth/ldap/Helper.java b/java/com/google/gerrit/auth/ldap/Helper.java
index a939c7272c..c11d045895 100644
--- a/java/com/google/gerrit/auth/ldap/Helper.java
+++ b/java/com/google/gerrit/auth/ldap/Helper.java
@@ -18,6 +18,7 @@ import com.google.common.base.Throwables;
import com.google.common.cache.Cache;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.ParameterizedString;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.metrics.Description;
@@ -224,6 +225,7 @@ class Helper {
return ctx;
}
+ @Nullable
private DirContext kerberosOpen(Properties env)
throws IOException, LoginException, NamingException {
LoginContext ctx = new LoginContext("KerberosLogin");
diff --git a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
index c3870f4833..bb6480a2c7 100644
--- a/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
+++ b/java/com/google/gerrit/auth/ldap/LdapGroupBackend.java
@@ -117,6 +117,7 @@ public class LdapGroupBackend implements GroupBackend {
return isLdapUUID(uuid);
}
+ @Nullable
@Override
public GroupDescription.Basic get(AccountGroup.UUID uuid) {
if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/auth/ldap/LdapQuery.java b/java/com/google/gerrit/auth/ldap/LdapQuery.java
index 409c9f5986..71dc141f88 100644
--- a/java/com/google/gerrit/auth/ldap/LdapQuery.java
+++ b/java/com/google/gerrit/auth/ldap/LdapQuery.java
@@ -14,6 +14,7 @@
package com.google.gerrit.auth.ldap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.ParameterizedString;
import com.google.gerrit.metrics.Timer0;
import java.util.ArrayList;
@@ -114,6 +115,7 @@ class LdapQuery {
return get("dn");
}
+ @Nullable
String get(String attName) throws NamingException {
final Attribute att = getAll(attName);
return att != null && 0 < att.size() ? String.valueOf(att.get(0)) : null;
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 7699799639..7dc2b1bca4 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -20,6 +20,7 @@ import com.google.common.base.Strings;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.ParameterizedString;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -162,6 +163,7 @@ class LdapRealm extends AbstractRealm {
return vlist;
}
+ @Nullable
static String optdef(Config c, String n, String d) {
final String[] v = c.getStringList("ldap", null, n);
if (v == null || v.length == 0) {
@@ -184,6 +186,7 @@ class LdapRealm extends AbstractRealm {
return v;
}
+ @Nullable
static ParameterizedString paramString(Config c, String n, String d) {
String expression = optdef(c, n, d);
if (expression == null) {
@@ -209,6 +212,7 @@ class LdapRealm extends AbstractRealm {
return !readOnlyAccountFields.contains(field);
}
+ @Nullable
static String apply(ParameterizedString p, LdapQuery.Result m) throws NamingException {
if (p == null) {
return null;
@@ -306,6 +310,7 @@ class LdapRealm extends AbstractRealm {
usernameCache.put(who.getLocalUser(), Optional.of(account.id()));
}
+ @Nullable
@Override
public Account.Id lookup(String accountName) {
if (Strings.isNullOrEmpty(accountName)) {
diff --git a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
index b0c1f5158f..ab53cde9d9 100644
--- a/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/auth/oauth/OAuthTokenCache.java
@@ -20,6 +20,7 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Converter;
import com.google.common.base.Strings;
import com.google.common.cache.Cache;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
@@ -109,6 +110,7 @@ public class OAuthTokenCache {
this.encrypter = encrypter;
}
+ @Nullable
public OAuthToken get(Account.Id id) {
OAuthToken accessToken = cache.getIfPresent(id);
if (accessToken == null) {
diff --git a/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java
index 38de5b15a1..836eb32619 100644
--- a/java/com/google/gerrit/common/PageLinks.java
+++ b/java/com/google/gerrit/common/PageLinks.java
@@ -106,10 +106,6 @@ public class PageLinks {
return toChangeQuery(op("owner", fullname) + " " + status(status));
}
- public static String toAssigneeQuery(String fullname) {
- return toChangeQuery(op("assignee", fullname));
- }
-
public static String toCustomDashboard(String params) {
return "/dashboard/?" + params;
}
diff --git a/java/com/google/gerrit/common/RawInputUtil.java b/java/com/google/gerrit/common/RawInputUtil.java
index 4a676e6a6e..23e4a23817 100644
--- a/java/com/google/gerrit/common/RawInputUtil.java
+++ b/java/com/google/gerrit/common/RawInputUtil.java
@@ -14,7 +14,6 @@
package com.google.gerrit.common;
-import static com.google.common.base.Preconditions.checkArgument;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
@@ -31,7 +30,6 @@ public class RawInputUtil {
public static RawInput create(byte[] bytes, String contentType) {
requireNonNull(bytes);
- checkArgument(bytes.length > 0);
return new RawInput() {
@Override
public InputStream getInputStream() throws IOException {
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index 6a482fb3cc..46b43c6821 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -46,7 +46,8 @@ public @interface UsedAt {
PLUGIN_SERVICEUSER,
PLUGIN_PULL_REPLICATION,
PLUGIN_WEBSESSION_FLATFILE,
- MODULE_GIT_REFS_FILTER
+ MODULE_GIT_REFS_FILTER,
+ MODULE_VIRTUALHOST
}
/** Reference to the project that uses the method annotated with this annotation. */
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 253266dce6..23151c2b46 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,7 @@
package com.google.gerrit.common.data;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.PermissionRange;
import java.util.ArrayList;
@@ -21,6 +22,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
/**
* Server wide capabilities. Represented as {@link Permission} objects.
@@ -127,6 +129,9 @@ public class GlobalCapability {
/** Can view all pending tasks in the queue (not just the filtered set). */
public static final String VIEW_QUEUE = "viewQueue";
+ /** Can view secondary emails of other accounts. */
+ public static final String VIEW_SECONDARY_EMAILS = "viewSecondaryEmails";
+
private static final List<String> NAMES_ALL;
private static final List<String> NAMES_LC;
private static final String[] RANGE_NAMES = {
@@ -158,10 +163,11 @@ public class GlobalCapability {
NAMES_ALL.add(VIEW_CONNECTIONS);
NAMES_ALL.add(VIEW_PLUGINS);
NAMES_ALL.add(VIEW_QUEUE);
+ NAMES_ALL.add(VIEW_SECONDARY_EMAILS);
NAMES_LC = new ArrayList<>(NAMES_ALL.size());
for (String name : NAMES_ALL) {
- NAMES_LC.add(name.toLowerCase());
+ NAMES_LC.add(name.toLowerCase(Locale.US));
}
}
@@ -172,7 +178,7 @@ public class GlobalCapability {
/** Returns true if the name is recognized as a capability name. */
public static boolean isGlobalCapability(String varName) {
- return NAMES_LC.contains(varName.toLowerCase());
+ return NAMES_LC.contains(varName.toLowerCase(Locale.US));
}
/** Returns true if the capability should have a range attached. */
@@ -190,6 +196,7 @@ public class GlobalCapability {
}
/** Returns the valid range for the capability if it has one, otherwise null. */
+ @Nullable
public static PermissionRange.WithDefaults getRange(String varName) {
if (QUERY_LIMIT.equalsIgnoreCase(varName)) {
return new PermissionRange.WithDefaults(
diff --git a/java/com/google/gerrit/common/data/ParameterizedString.java b/java/com/google/gerrit/common/data/ParameterizedString.java
index 84bb535829..c8c2b2bc69 100644
--- a/java/com/google/gerrit/common/data/ParameterizedString.java
+++ b/java/com/google/gerrit/common/data/ParameterizedString.java
@@ -19,6 +19,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
/** Performs replacements on strings such as <code>Hello ${user}</code>. */
@@ -213,7 +214,7 @@ public class ParameterizedString {
new Function() {
@Override
String apply(String a) {
- return a.toLowerCase();
+ return a.toLowerCase(Locale.US);
}
});
m.put(
@@ -221,7 +222,7 @@ public class ParameterizedString {
new Function() {
@Override
String apply(String a) {
- return a.toUpperCase();
+ return a.toUpperCase(Locale.US);
}
});
m.put(
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java
index 85dbdeb2b4..699acc0cf0 100644
--- a/java/com/google/gerrit/entities/Account.java
+++ b/java/com/google/gerrit/entities/Account.java
@@ -62,6 +62,7 @@ public abstract class Account {
return Optional.ofNullable(Ints.tryParse(str)).map(Account::id);
}
+ @Nullable
public static Id fromRef(String name) {
if (name == null) {
return null;
@@ -82,11 +83,13 @@ public abstract class Account {
* @param name a ref name with the following syntax: {@code "34/1234..."}. We assume that the
* caller has trimmed any prefix.
*/
+ @Nullable
public static Id fromRefPart(String name) {
Integer id = RefNames.parseShardedRefPart(name);
return id != null ? Account.id(id) : null;
}
+ @Nullable
public static Id parseAfterShardedRefPart(String name) {
Integer id = RefNames.parseAfterShardedRefPart(name);
return id != null ? Account.id(id) : null;
@@ -102,6 +105,7 @@ public abstract class Account {
* @param name ref name
* @return account ID, or null if not numeric.
*/
+ @Nullable
public static Id fromRefSuffix(String name) {
Integer id = RefNames.parseRefSuffix(name);
return id != null ? Account.id(id) : null;
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
index 001a544847..b5c97dada2 100644
--- a/java/com/google/gerrit/entities/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -15,6 +15,7 @@
package com.google.gerrit.entities;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
public final class AccountGroup {
public static NameKey nameKey(String n) {
@@ -65,6 +66,7 @@ public final class AccountGroup {
}
/** Parse an {@link AccountGroup.UUID} out of a ref-name. */
+ @Nullable
public static UUID fromRef(String ref) {
if (ref == null) {
return null;
@@ -81,6 +83,7 @@ public final class AccountGroup {
* @param refPart a ref name with the following syntax: {@code "12/1234..."}. We assume that the
* caller has trimmed any prefix.
*/
+ @Nullable
public static UUID fromRefPart(String refPart) {
String uuid = RefNames.parseShardedUuidFromRefPart(refPart);
return uuid != null ? AccountGroup.uuid(uuid) : null;
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
index 5d63476b6f..eb1da4600c 100644
--- a/java/com/google/gerrit/entities/Address.java
+++ b/java/com/google/gerrit/entities/Address.java
@@ -46,6 +46,7 @@ public abstract class Address {
throw new IllegalArgumentException("Invalid email address: " + in);
}
+ @Nullable
public static Address tryParse(String in) {
try {
return parse(in);
diff --git a/java/com/google/gerrit/entities/BooleanProjectConfig.java b/java/com/google/gerrit/entities/BooleanProjectConfig.java
index 5201f6dc26..09f63d4843 100644
--- a/java/com/google/gerrit/entities/BooleanProjectConfig.java
+++ b/java/com/google/gerrit/entities/BooleanProjectConfig.java
@@ -14,6 +14,8 @@
package com.google.gerrit.entities;
+import com.google.gerrit.common.Nullable;
+
/**
* Contains all inheritable boolean project configs and maps internal representations to API
* objects.
@@ -41,7 +43,9 @@ public enum BooleanProjectConfig {
ENABLE_REVIEWER_BY_EMAIL("reviewer", "enableByEmail"),
MATCH_AUTHOR_TO_COMMITTER_DATE("submit", "matchAuthorToCommitterDate"),
REJECT_EMPTY_COMMIT("submit", "rejectEmptyCommit"),
- WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault");
+ WORK_IN_PROGRESS_BY_DEFAULT("change", "workInProgressByDefault"),
+ SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS(
+ "reviewer", "skipAddingAuthorAndCommitterAsReviewers");
// Git config
private final String section;
@@ -56,6 +60,7 @@ public enum BooleanProjectConfig {
return section;
}
+ @Nullable
public String getSubSection() {
return null;
}
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 66e1a9644d..56fb748e1d 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -117,6 +117,7 @@ public final class Change {
return id != null ? Optional.of(Change.id(id)) : Optional.empty();
}
+ @Nullable
public static Id fromRef(String ref) {
if (RefNames.isRefsEdit(ref)) {
return fromEditRefPart(ref);
@@ -134,6 +135,7 @@ public final class Change {
return null;
}
+ @Nullable
public static Id fromAllUsersRef(String ref) {
if (ref == null) {
return null;
@@ -169,6 +171,7 @@ public final class Change {
return true;
}
+ @Nullable
public static Id fromEditRefPart(String ref) {
int startChangeId = ref.indexOf(RefNames.EDIT_PREFIX) + RefNames.EDIT_PREFIX.length();
int endChangeId = nextNonDigit(ref, startChangeId);
@@ -179,6 +182,7 @@ public final class Change {
return null;
}
+ @Nullable
public static Id fromRefPart(String ref) {
Integer id = RefNames.parseShardedRefPart(ref);
return id != null ? Change.id(id) : null;
@@ -404,6 +408,7 @@ public final class Change {
return changeStatus;
}
+ @Nullable
public static Status forCode(char c) {
for (Status s : Status.values()) {
if (s.code == c) {
@@ -414,6 +419,7 @@ public final class Change {
return null;
}
+ @Nullable
public static Status forChangeStatus(ChangeStatus cs) {
for (Status s : Status.values()) {
if (s.changeStatus == cs) {
@@ -471,9 +477,6 @@ public final class Change {
*/
@Nullable private String submissionId;
- /** Allows assigning a change to a user. */
- @Nullable private Account.Id assignee;
-
/** Whether the change is private. */
private boolean isPrivate;
@@ -503,7 +506,6 @@ public final class Change {
}
public Change(Change other) {
- assignee = other.assignee;
changeId = other.changeId;
changeKey = other.changeKey;
createdOn = other.createdOn;
@@ -542,14 +544,6 @@ public final class Change {
changeKey = k;
}
- public Account.Id getAssignee() {
- return assignee;
- }
-
- public void setAssignee(Account.Id a) {
- assignee = a;
- }
-
public Instant getCreatedOn() {
return createdOn;
}
@@ -599,6 +593,7 @@ public final class Change {
}
/** Get the id of the most current {@link PatchSet} in this change. */
+ @Nullable
public PatchSet.Id currentPatchSetId() {
if (currentPatchSetId > 0) {
return PatchSet.id(changeId, currentPatchSetId);
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 65a1559990..e1e143cf91 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -49,6 +49,7 @@ public abstract class Comment {
return code;
}
+ @Nullable
public static Status forCode(char c) {
for (Status s : Status.values()) {
if (s.code == c) {
@@ -263,7 +264,7 @@ public abstract class Comment {
public void setLineNbrAndRange(
Integer lineNbr, com.google.gerrit.extensions.client.Comment.Range range) {
- this.lineNbr = lineNbr != null ? lineNbr : range != null ? range.endLine : 0;
+ this.lineNbr = range != null ? range.endLine : lineNbr != null ? lineNbr : 0;
if (range != null) {
this.range = new Comment.Range(range);
}
diff --git a/java/com/google/gerrit/entities/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
index e43b6a3549..d3710c46ad 100644
--- a/java/com/google/gerrit/entities/EmailHeader.java
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -115,8 +115,8 @@ public abstract class EmailHeader {
byte[] buf = new String(Character.toChars(cp)).getBytes(UTF_8);
for (byte b : buf) {
r.append('=');
- r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
- r.append(Integer.toHexString(b & 0x0f).toUpperCase());
+ r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase(Locale.US));
+ r.append(Integer.toHexString(b & 0x0f).toUpperCase(Locale.US));
}
} else {
diff --git a/java/com/google/gerrit/entities/KeyUtil.java b/java/com/google/gerrit/entities/KeyUtil.java
index 0f14cd920c..4aec7ac07c 100644
--- a/java/com/google/gerrit/entities/KeyUtil.java
+++ b/java/com/google/gerrit/entities/KeyUtil.java
@@ -66,7 +66,13 @@ public class KeyUtil {
return r.toString();
}
- public static String decode(final String key) {
+ public static String decode(String key) {
+ // URLs use percentage encoding which replaces unsafe ASCII characters with a '%' followed by
+ // two hexadecimal digits. If there is '%' that is not followed by two hexadecimal digits
+ // the code below fails with an IllegalArgumentException. To prevent this replace any '%'
+ // that is not followed by two hexadecimal digits by "%25", which is the URL encoding for '%'.
+ key = key.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
+
if (key.indexOf('%') < 0) {
return key.replace('+', ' ');
}
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
index f361741297..d49ab0f188 100644
--- a/java/com/google/gerrit/entities/LabelFunction.java
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -14,6 +14,7 @@
package com.google.gerrit.entities;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.SubmitRecord.Label;
import java.util.Collections;
@@ -48,6 +49,16 @@ public enum LabelFunction {
ALL = Collections.unmodifiableMap(all);
}
+ public static final Map<String, LabelFunction> ALL_NON_DEPRECATED;
+
+ static {
+ Map<String, LabelFunction> allNonDeprecated = new LinkedHashMap<>();
+ for (LabelFunction f : ImmutableSet.of(NO_BLOCK, NO_OP, PATCH_SET_LOCK)) {
+ allNonDeprecated.put(f.getFunctionName(), f);
+ }
+ ALL_NON_DEPRECATED = Collections.unmodifiableMap(allNonDeprecated);
+ }
+
public static Optional<LabelFunction> parse(@Nullable String str) {
return Optional.ofNullable(ALL.get(str));
}
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 0349a73dd5..7a3266ecc3 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -132,6 +132,7 @@ public abstract class LabelType {
return psa.labelId().get().equalsIgnoreCase(getName());
}
+ @Nullable
public LabelValue getMin() {
if (getValues().isEmpty()) {
return null;
@@ -139,6 +140,7 @@ public abstract class LabelType {
return getValues().get(0);
}
+ @Nullable
public LabelValue getMax() {
if (getValues().isEmpty()) {
return null;
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index a2f2e0be89..fa7b7413da 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -19,6 +19,7 @@ import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
@@ -38,11 +39,11 @@ public class LabelTypes {
}
public Optional<LabelType> byLabel(LabelId labelId) {
- return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase()));
+ return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase(Locale.US)));
}
public Optional<LabelType> byLabel(String labelName) {
- return Optional.ofNullable(byLabel().get(labelName.toLowerCase()));
+ return Optional.ofNullable(byLabel().get(labelName.toLowerCase(Locale.US)));
}
private Map<String, LabelType> byLabel() {
@@ -52,7 +53,7 @@ public class LabelTypes {
Map<String, LabelType> l = new HashMap<>();
if (labelTypes != null) {
for (LabelType t : labelTypes) {
- l.put(t.getName().toLowerCase(), t);
+ l.put(t.getName().toLowerCase(Locale.US), t);
}
}
byLabel = l;
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 2d2804608b..bef658089d 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import com.google.auto.value.AutoValue;
import com.google.common.base.Splitter;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import java.util.List;
@@ -112,6 +113,7 @@ public final class Patch {
}
@UsedAt(UsedAt.Project.COLLABNET)
+ @Nullable
public static ChangeType forCode(char c) {
for (ChangeType s : ChangeType.values()) {
if (s.code == c) {
@@ -168,33 +170,40 @@ public final class Patch {
*/
public enum FileMode implements CodedEnum {
/** Mode indicating an entry is a tree (aka directory). */
- TREE('T'),
+ TREE('T', 0040000),
/** Mode indicating an entry is a symbolic link. */
- SYMLINK('S'),
+ SYMLINK('S', 0120000),
/** Mode indicating an entry is a non-executable file. */
- REGULAR_FILE('R'),
+ REGULAR_FILE('R', 0100644),
/** Mode indicating an entry is an executable file. */
- EXECUTABLE_FILE('E'),
+ EXECUTABLE_FILE('E', 0100755),
/** Mode indicating an entry is a submodule commit in another repository. */
- GITLINK('G'),
+ GITLINK('G', 0160000),
/** Mode indicating an entry is missing during parallel walks. */
- MISSING('M');
+ MISSING('M', 0000000);
private final char code;
- FileMode(char c) {
+ private final int mode;
+
+ FileMode(char c, int m) {
code = c;
+ mode = m;
}
@Override
public char getCode() {
return code;
}
+
+ public int getMode() {
+ return mode;
+ }
}
private Patch() {}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 6c52368a75..8784437aad 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -23,6 +23,7 @@ import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Ints;
import com.google.errorprone.annotations.InlineMe;
+import com.google.gerrit.common.Nullable;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@@ -66,7 +67,7 @@ public abstract class PatchSet {
}
@AutoValue
- public abstract static class Id {
+ public abstract static class Id implements Comparable<Id> {
/** Parse a PatchSet.Id out of a string representation. */
public static Id parse(String str) {
List<String> parts = Splitter.on(',').splitToList(str);
@@ -83,6 +84,7 @@ public abstract class PatchSet {
}
/** Parse a PatchSet.Id from a {@link #refName()} result. */
+ @Nullable
public static Id fromRef(String ref) {
int cs = Change.Id.startIndex(ref);
if (cs < 0) {
@@ -145,6 +147,11 @@ public abstract class PatchSet {
public final String toString() {
return getCommaSeparatedChangeAndPatchSetId();
}
+
+ @Override
+ public int compareTo(Id other) {
+ return Ints.compare(get(), other.get());
+ }
}
public static Builder builder() {
@@ -163,6 +170,8 @@ public abstract class PatchSet {
public abstract Builder uploader(Account.Id uploader);
+ public abstract Builder realUploader(Account.Id realUploader);
+
public abstract Builder createdOn(Instant createdOn);
public abstract Builder groups(Iterable<String> groups);
@@ -197,6 +206,9 @@ public abstract class PatchSet {
/**
* Account that uploaded the patch set.
*
+ * <p>If the upload was done on behalf of another user, the impersonated user on whom's behalf the
+ * patch set was uploaded.
+ *
* <p>If this is a deserialized instance that was originally serialized by an older version of
* Gerrit, and the old data erroneously did not include an {@code uploader}, then this method will
* return an account ID of 0.
@@ -204,6 +216,15 @@ public abstract class PatchSet {
public abstract Account.Id uploader();
/**
+ * The real account that uploaded the patch set.
+ *
+ * <p>If this is a deserialized instance that was originally serialized by an older version of
+ * Gerrit, and the old data did not include an {@code realUploader}, then this method will return
+ * the {@code uploader}.
+ */
+ public abstract Account.Id realUploader();
+
+ /**
* When this patch set was first introduced onto the change.
*
* <p>If this is a deserialized instance that was originally serialized by an older version of
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 95164bdee5..2a34579f88 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -22,6 +22,7 @@ import com.google.gerrit.common.Nullable;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.function.Consumer;
/** A single permission within an {@link AccessSection} of a project. */
@@ -35,7 +36,6 @@ public abstract class Permission implements Comparable<Permission> {
public static final String DELETE = "delete";
public static final String DELETE_CHANGES = "deleteChanges";
public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
- public static final String EDIT_ASSIGNEE = "editAssignee";
public static final String EDIT_HASHTAGS = "editHashtags";
public static final String EDIT_TOPIC_NAME = "editTopicName";
public static final String FORGE_AUTHOR = "forgeAuthor";
@@ -43,6 +43,7 @@ public abstract class Permission implements Comparable<Permission> {
public static final String FORGE_SERVER = "forgeServerAsCommitter";
public static final String LABEL = "label-";
public static final String LABEL_AS = "labelAs-";
+ public static final String REMOVE_LABEL = "removeLabel-";
public static final String OWNER = "owner";
public static final String PUSH = "push";
public static final String PUSH_MERGE = "pushMerge";
@@ -60,48 +61,53 @@ public abstract class Permission implements Comparable<Permission> {
private static final List<String> NAMES_LC;
private static final int LABEL_INDEX;
private static final int LABEL_AS_INDEX;
+ private static final int REMOVE_LABEL_INDEX;
static {
NAMES_LC = new ArrayList<>();
- NAMES_LC.add(ABANDON.toLowerCase());
- NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
- NAMES_LC.add(CREATE.toLowerCase());
- NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
- NAMES_LC.add(CREATE_TAG.toLowerCase());
- NAMES_LC.add(DELETE.toLowerCase());
- NAMES_LC.add(DELETE_CHANGES.toLowerCase());
- NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
- NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
- NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
- NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
- NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
- NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
- NAMES_LC.add(FORGE_SERVER.toLowerCase());
- NAMES_LC.add(LABEL.toLowerCase());
- NAMES_LC.add(LABEL_AS.toLowerCase());
- NAMES_LC.add(OWNER.toLowerCase());
- NAMES_LC.add(PUSH.toLowerCase());
- NAMES_LC.add(PUSH_MERGE.toLowerCase());
- NAMES_LC.add(READ.toLowerCase());
- NAMES_LC.add(REBASE.toLowerCase());
- NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
- NAMES_LC.add(REVERT.toLowerCase());
- NAMES_LC.add(SUBMIT.toLowerCase());
- NAMES_LC.add(SUBMIT_AS.toLowerCase());
- NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
- NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
+ NAMES_LC.add(ABANDON.toLowerCase(Locale.US));
+ NAMES_LC.add(ADD_PATCH_SET.toLowerCase(Locale.US));
+ NAMES_LC.add(CREATE.toLowerCase(Locale.US));
+ NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase(Locale.US));
+ NAMES_LC.add(CREATE_TAG.toLowerCase(Locale.US));
+ NAMES_LC.add(DELETE.toLowerCase(Locale.US));
+ NAMES_LC.add(DELETE_CHANGES.toLowerCase(Locale.US));
+ NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase(Locale.US));
+ NAMES_LC.add(EDIT_HASHTAGS.toLowerCase(Locale.US));
+ NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase(Locale.US));
+ NAMES_LC.add(FORGE_AUTHOR.toLowerCase(Locale.US));
+ NAMES_LC.add(FORGE_COMMITTER.toLowerCase(Locale.US));
+ NAMES_LC.add(FORGE_SERVER.toLowerCase(Locale.US));
+ NAMES_LC.add(LABEL.toLowerCase(Locale.US));
+ NAMES_LC.add(LABEL_AS.toLowerCase(Locale.US));
+ NAMES_LC.add(REMOVE_LABEL.toLowerCase(Locale.US));
+ NAMES_LC.add(OWNER.toLowerCase(Locale.US));
+ NAMES_LC.add(PUSH.toLowerCase(Locale.US));
+ NAMES_LC.add(PUSH_MERGE.toLowerCase(Locale.US));
+ NAMES_LC.add(READ.toLowerCase(Locale.US));
+ NAMES_LC.add(REBASE.toLowerCase(Locale.US));
+ NAMES_LC.add(REMOVE_REVIEWER.toLowerCase(Locale.US));
+ NAMES_LC.add(REVERT.toLowerCase(Locale.US));
+ NAMES_LC.add(SUBMIT.toLowerCase(Locale.US));
+ NAMES_LC.add(SUBMIT_AS.toLowerCase(Locale.US));
+ NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase(Locale.US));
+ NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase(Locale.US));
LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
- LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+ LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase(Locale.US));
+ REMOVE_LABEL_INDEX = NAMES_LC.indexOf(Permission.REMOVE_LABEL.toLowerCase(Locale.US));
}
/** Returns true if the name is recognized as a permission name. */
public static boolean isPermission(String varName) {
- return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+ return isLabel(varName)
+ || isLabelAs(varName)
+ || isRemoveLabel(varName)
+ || NAMES_LC.contains(varName.toLowerCase(Locale.US));
}
public static boolean hasRange(String varName) {
- return isLabel(varName) || isLabelAs(varName);
+ return isLabel(varName) || isLabelAs(varName) || isRemoveLabel(varName);
}
/** Returns true if the permission name is actually for a review label. */
@@ -114,6 +120,11 @@ public abstract class Permission implements Comparable<Permission> {
return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
}
+ /** Returns true if the permission is for impersonated review labels. */
+ public static boolean isRemoveLabel(String var) {
+ return var.startsWith(REMOVE_LABEL) && REMOVE_LABEL.length() < var.length();
+ }
+
/** Returns permission name for the given review label. */
public static String forLabel(String labelName) {
return LABEL + labelName;
@@ -124,11 +135,19 @@ public abstract class Permission implements Comparable<Permission> {
return LABEL_AS + labelName;
}
+ /** Returns permission name to remove a label for another user. */
+ public static String forRemoveLabel(String labelName) {
+ return REMOVE_LABEL + labelName;
+ }
+
+ @Nullable
public static String extractLabel(String varName) {
if (isLabel(varName)) {
return varName.substring(LABEL.length());
} else if (isLabelAs(varName)) {
return varName.substring(LABEL_AS.length());
+ } else if (isRemoveLabel(varName)) {
+ return varName.substring(REMOVE_LABEL.length());
}
return null;
}
@@ -204,9 +223,11 @@ public abstract class Permission implements Comparable<Permission> {
return LABEL_INDEX;
} else if (isLabelAs(a.getName())) {
return LABEL_AS_INDEX;
+ } else if (isRemoveLabel(a.getName())) {
+ return REMOVE_LABEL_INDEX;
}
- int index = NAMES_LC.indexOf(a.getName().toLowerCase());
+ int index = NAMES_LC.indexOf(a.getName().toLowerCase(Locale.US));
return 0 <= index ? index : NAMES_LC.size();
}
@@ -277,7 +298,10 @@ public abstract class Permission implements Comparable<Permission> {
public Permission build() {
setRules(
- rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+ rulesBuilders.stream()
+ .map(PermissionRule.Builder::build)
+ .distinct()
+ .collect(toImmutableList()));
return autoBuild();
}
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
index 9a2d31ecec..1665c1c7cf 100644
--- a/java/com/google/gerrit/entities/PermissionRule.java
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -202,6 +202,9 @@ public abstract class PermissionRule implements Comparable<PermissionRule> {
int dotdot = range.indexOf("..");
int min = parseInt(range.substring(0, dotdot));
int max = parseInt(range.substring(dotdot + 2));
+ if (min > max) {
+ throw new IllegalArgumentException("Invalid range in rule: " + orig);
+ }
rule.setRange(min, max);
} else {
throw new IllegalArgumentException("Invalid range in rule: " + orig);
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index 617b8272ea..72ca6a9ef8 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -61,7 +61,7 @@ public abstract class Project {
/** Parse a Project.NameKey out of a string representation. */
public static NameKey parse(String str) {
- return nameKey(KeyUtil.decode(str));
+ return nameKey(ProjectUtil.sanitizeProjectName(KeyUtil.decode(str)));
}
private final String name;
@@ -166,6 +166,7 @@ public abstract class Project {
* @return name key of the parent project, {@code null} if this project is the All-Projects
* project
*/
+ @Nullable
public Project.NameKey getParent(Project.NameKey allProjectsName) {
if (getParent() != null) {
return getParent();
@@ -178,6 +179,7 @@ public abstract class Project {
return allProjectsName;
}
+ @Nullable
public String getParentName() {
return getParent() != null ? getParent().get() : null;
}
diff --git a/java/com/google/gerrit/entities/ProjectUtil.java b/java/com/google/gerrit/entities/ProjectUtil.java
new file mode 100644
index 0000000000..98ce67a5f3
--- /dev/null
+++ b/java/com/google/gerrit/entities/ProjectUtil.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.entities;
+
+public class ProjectUtil {
+ public static String sanitizeProjectName(String name) {
+ name = stripGitSuffix(name);
+ name = stripTrailingSlash(name);
+ return name;
+ }
+
+ public static String stripGitSuffix(String name) {
+ if (name.endsWith(".git")) {
+ // Be nice and drop the trailing ".git" suffix, which we never keep
+ // in our database, but clients might mistakenly provide anyway.
+ //
+ name = name.substring(0, name.length() - 4);
+ name = stripTrailingSlash(name);
+ }
+ return name;
+ }
+
+ private static String stripTrailingSlash(String name) {
+ while (name.endsWith("/")) {
+ name = name.substring(0, name.length() - 1);
+ }
+ return name;
+ }
+}
diff --git a/java/com/google/gerrit/entities/RefNames.java b/java/com/google/gerrit/entities/RefNames.java
index b9c1b3c9c2..e79c530783 100644
--- a/java/com/google/gerrit/entities/RefNames.java
+++ b/java/com/google/gerrit/entities/RefNames.java
@@ -16,6 +16,7 @@ package com.google.gerrit.entities;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import java.util.List;
@@ -184,6 +185,21 @@ public class RefNames {
return ref.startsWith(REFS_CHANGES);
}
+ /** True if the provided ref is in {@code refs/sequences/*}. */
+ public static boolean isSequenceRef(String ref) {
+ return ref.startsWith(REFS_SEQUENCES);
+ }
+
+ /** True if the provided ref is in {@code refs/tags/*}. */
+ public static boolean isTagRef(String ref) {
+ return ref.startsWith(REFS_TAGS);
+ }
+
+ /** True if the provided ref is {@link #REFS_EXTERNAL_IDS}. */
+ public static boolean isExternalIdRef(String ref) {
+ return REFS_EXTERNAL_IDS.equals(ref);
+ }
+
public static String refsGroups(AccountGroup.UUID groupUuid) {
return REFS_GROUPS + shardUuid(groupUuid.get());
}
@@ -222,6 +238,7 @@ public class RefNames {
return REFS_CACHE_AUTOMERGE + hash.substring(0, 2) + '/' + hash.substring(2);
}
+ @Nullable
public static String shard(int id) {
if (id < 0) {
return null;
@@ -328,6 +345,21 @@ public class RefNames {
return REFS_CONFIG.equals(ref);
}
+ /** Whether the ref is the version branch, i.e. {@code refs/meta/version}. */
+ public static boolean isVersionRef(String ref) {
+ return REFS_VERSION.equals(ref);
+ }
+
+ /** Whether the ref is an auto-merge ref. */
+ public static boolean isAutoMergeRef(String ref) {
+ return ref.startsWith(REFS_CACHE_AUTOMERGE);
+ }
+
+ /** Whether the ref is an reject commit ref, i.e. {@code refs/meta/reject-commits} */
+ public static boolean isRejectCommitsRef(String ref) {
+ return REFS_REJECT_COMMITS.equals(ref);
+ }
+
/**
* Whether the ref is managed by Gerrit. Covers all Gerrit-internal refs like refs/cache-automerge
* and refs/meta as well as refs/changes. Does not cover user-created refs like branches or custom
@@ -343,6 +375,7 @@ public class RefNames {
return GERRIT_REFS.stream().anyMatch(internalRef -> ref.startsWith(internalRef));
}
+ @Nullable
static Integer parseShardedRefPart(String name) {
if (name == null) {
return null;
@@ -386,6 +419,7 @@ public class RefNames {
}
@UsedAt(UsedAt.Project.PLUGINS_ALL)
+ @Nullable
public static String parseShardedUuidFromRefPart(String name) {
if (name == null) {
return null;
@@ -420,6 +454,7 @@ public class RefNames {
* @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
* sharded ID
*/
+ @Nullable
static String skipShardedRefPart(String name) {
if (name == null) {
return null;
@@ -473,6 +508,7 @@ public class RefNames {
* ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
* ref part
*/
+ @Nullable
static Integer parseAfterShardedRefPart(String name) {
String rest = skipShardedRefPart(name);
if (rest == null || !rest.startsWith("/")) {
@@ -493,6 +529,7 @@ public class RefNames {
return Integer.parseInt(rest.substring(0, ie));
}
+ @Nullable
public static Integer parseRefSuffix(String name) {
if (name == null) {
return null;
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index 75bc034180..5ee76da04f 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -31,7 +31,7 @@ public abstract class StoredCommentLinkInfo {
public abstract String getMatch();
/**
- * The link to replace the match with. This can only be set if html is {@code null}.
+ * The link to replace the match with.
*
* <p>The constructed link is using {@link #getLink()} {@link #getPrefix()} {@link #getSuffix()}
* and {@link #getText()}, and has the shape of
@@ -41,31 +41,18 @@ public abstract class StoredCommentLinkInfo {
@Nullable
public abstract String getLink();
- /**
- * The text before the link tag that the match is replaced with. This can only be set if link is
- * not {@code null}.
- */
+ /** The optional text before the link tag that the match is replaced with. */
@Nullable
public abstract String getPrefix();
- /**
- * The text after the link tag that the match is replaced with. This can only be set if link is
- * not {@code null}.
- */
+ /** The optional text after the link tag that the match is replaced with. */
@Nullable
public abstract String getSuffix();
- /**
- * The content of the link tag that the match is replaced with. This can only be set if link is
- * not {@code null}.
- */
+ /** The content of the link tag that the match is replaced with. If not set full match is used. */
@Nullable
public abstract String getText();
- /** The html to replace the match with. This can only be set if link is {@code null}. */
- @Nullable
- public abstract String getHtml();
-
/** Weather this comment link is active. {@code null} means true. */
@Nullable
public abstract Boolean getEnabled();
@@ -103,7 +90,6 @@ public abstract class StoredCommentLinkInfo {
.setPrefix(src.prefix)
.setSuffix(src.suffix)
.setText(src.text)
- .setHtml(src.html)
.setEnabled(enabled)
.setOverrideOnly(false)
.build();
@@ -118,7 +104,6 @@ public abstract class StoredCommentLinkInfo {
info.prefix = getPrefix();
info.suffix = getSuffix();
info.text = getText();
- info.html = getHtml();
info.enabled = getEnabled();
return info;
}
@@ -137,25 +122,21 @@ public abstract class StoredCommentLinkInfo {
public abstract Builder setText(@Nullable String value);
- public abstract Builder setHtml(@Nullable String value);
-
public abstract Builder setEnabled(@Nullable Boolean value);
public abstract Builder setOverrideOnly(boolean value);
public StoredCommentLinkInfo build() {
checkArgument(getName() != null, "invalid commentlink.name");
- setLink(Strings.emptyToNull(getLink()));
setPrefix(Strings.emptyToNull(getPrefix()));
setSuffix(Strings.emptyToNull(getSuffix()));
setText(Strings.emptyToNull(getText()));
- setHtml(Strings.emptyToNull(getHtml()));
if (!getOverrideOnly()) {
checkArgument(
!Strings.isNullOrEmpty(getMatch()), "invalid commentlink.%s.match", getName());
checkArgument(
- (getLink() != null && getHtml() == null) || (getLink() == null && getHtml() != null),
- "commentlink.%s must have either link or html",
+ !Strings.isNullOrEmpty(getLink()),
+ "commentlink.%s must have link specified",
getName());
}
return autoBuild();
@@ -175,8 +156,6 @@ public abstract class StoredCommentLinkInfo {
protected abstract String getText();
- protected abstract String getHtml();
-
protected abstract boolean getOverrideOnly();
}
}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
index c24227d16b..fbb2fd7cbb 100644
--- a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -197,8 +197,6 @@ public abstract class SubmitRequirementExpressionResult {
@AutoValue.Builder
public abstract static class Builder {
- public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
-
protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
public abstract Builder predicateString(String value);
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 49033641ca..3b772d0b48 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -71,10 +71,6 @@ public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Chan
if (submissionId != null) {
builder.setSubmissionId(submissionId);
}
- Account.Id assignee = change.getAssignee();
- if (assignee != null) {
- builder.setAssignee(accountIdConverter.toProto(assignee));
- }
Change.Id revertOf = change.getRevertOf();
if (revertOf != null) {
builder.setRevertOf(changeIdConverter.toProto(revertOf));
@@ -114,9 +110,6 @@ public enum ChangeProtoConverter implements ProtoConverter<Entities.Change, Chan
if (proto.hasSubmissionId()) {
change.setSubmissionId(proto.getSubmissionId());
}
- if (proto.hasAssignee()) {
- change.setAssignee(accountIdConverter.fromProto(proto.getAssignee()));
- }
change.setPrivate(proto.getIsPrivate());
change.setWorkInProgress(proto.getWorkInProgress());
change.setReviewStarted(proto.getReviewStarted());
diff --git a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
index 210972d43f..b32f09a119 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetProtoConverter.java
@@ -42,6 +42,7 @@ public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet,
.setId(patchSetIdConverter.toProto(patchSet.id()))
.setCommitId(objectIdConverter.toProto(patchSet.commitId()))
.setUploaderAccountId(accountIdConverter.toProto(patchSet.uploader()))
+ .setRealUploaderAccountId(accountIdConverter.toProto(patchSet.realUploader()))
.setCreatedOn(patchSet.createdOn().toEpochMilli());
List<String> groups = patchSet.groups();
if (!groups.isEmpty()) {
@@ -75,15 +76,20 @@ public enum PatchSetProtoConverter implements ProtoConverter<Entities.PatchSet,
// Callers that encounter one of these sentinels will likely fail, for example by failing to
// look up the zeroId. They would have also failed back when the fields were nullable, for
// example with NPE; the current behavior just fails slightly differently.
+ Account.Id uploader =
+ proto.hasUploaderAccountId()
+ ? accountIdConverter.fromProto(proto.getUploaderAccountId())
+ : Account.id(0);
builder
.commitId(
proto.hasCommitId()
? objectIdConverter.fromProto(proto.getCommitId())
: ObjectId.zeroId())
- .uploader(
- proto.hasUploaderAccountId()
- ? accountIdConverter.fromProto(proto.getUploaderAccountId())
- : Account.id(0))
+ .uploader(uploader)
+ .realUploader(
+ proto.hasRealUploaderAccountId()
+ ? accountIdConverter.fromProto(proto.getRealUploaderAccountId())
+ : uploader)
.createdOn(
proto.hasCreatedOn() ? Instant.ofEpochMilli(proto.getCreatedOn()) : Instant.EPOCH);
diff --git a/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
index e17e1c9854..493329c7f0 100644
--- a/java/com/google/gerrit/extensions/api/changes/AssigneeInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchInput.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2022 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,8 +14,12 @@
package com.google.gerrit.extensions.api.changes;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-
-public class AssigneeInput {
- @DefaultInput public String assignee;
+/** Information about a patch to apply. */
+public class ApplyPatchInput {
+ /**
+ * Required. The patch to be applied.
+ *
+ * <p>Must be compatible with `git diff` output. For example, Gerrit API `Get Patch` output.
+ */
+ public String patch;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
new file mode 100644
index 0000000000..cf114df797
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/ApplyPatchPatchSetInput.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import java.util.List;
+
+/** Information for creating a new patch set from a given patch. */
+public class ApplyPatchPatchSetInput {
+
+ /** The patch to be applied. */
+ public ApplyPatchInput patch;
+
+ /**
+ * The commit message for the new patch set. If not specified, a predefined message will be used.
+ */
+ @Nullable public String commitMessage;
+
+ /**
+ * 40-hex digit SHA-1 of the commit which will be the parent commit of the newly created patch
+ * set. If set, it must be a merged commit or a change revision on the destination branch.
+ * Otherwise, the target change's branch tip will be used.
+ */
+ @Nullable public String base;
+
+ /**
+ * The author of the new patch set. Must include both {@link AccountInput#name} and {@link
+ * AccountInput#email} fields.
+ */
+ @Nullable public AccountInput author;
+
+ @Nullable public List<ListChangesOption> responseFormatOptions;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 018a6cfd1b..ef61b68941 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -27,12 +27,14 @@ import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.common.CommitMessageInput;
import com.google.gerrit.extensions.common.MergePatchSetInput;
import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
import com.google.gerrit.extensions.common.RevertSubmissionInfo;
import com.google.gerrit.extensions.common.RobotCommentInfo;
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;
import java.util.Collection;
@@ -152,6 +154,8 @@ public interface ChangeApi {
/** Create a merge patch set for the change. */
ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException;
+ ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException;
+
default List<ChangeInfo> submittedTogether() throws RestApiException {
SubmittedTogetherInfo info =
submittedTogether(
@@ -176,6 +180,24 @@ public interface ChangeApi {
/** Rebase the current revision of a change. */
void rebase(RebaseInput in) throws RestApiException;
+ /**
+ * Rebase the current revisions of a change's chain using default options.
+ *
+ * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+ * chain
+ */
+ default Response<RebaseChainInfo> rebaseChain() throws RestApiException {
+ return rebaseChain(new RebaseInput());
+ }
+
+ /**
+ * Rebase the current revisions of a change's chain.
+ *
+ * @return a {@code RebaseChainInfo} contains the {@code ChangeInfo} data for the rebased the
+ * chain
+ */
+ Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException;
+
/** Deletes a change. */
void delete() throws RestApiException;
@@ -325,22 +347,6 @@ public interface ChangeApi {
/** Adds a user to the attention set. */
AccountInfo addToAttentionSet(AttentionSetInput input) throws RestApiException;
- /** Set the assignee of a change. */
- AccountInfo setAssignee(AssigneeInput input) throws RestApiException;
-
- /** Get the assignee of a change. */
- AccountInfo getAssignee() throws RestApiException;
-
- /** Get all past assignees. */
- List<AccountInfo> getPastAssignees() throws RestApiException;
-
- /**
- * Delete the assignee of a change.
- *
- * @return the assignee that was deleted, or null if there was no assignee.
- */
- AccountInfo deleteAssignee() throws RestApiException;
-
/**
* Get all published comments on a change.
*
@@ -632,6 +638,11 @@ public interface ChangeApi {
}
@Override
+ public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public void delete() throws RestApiException {
throw new NotImplementedException();
}
@@ -719,26 +730,6 @@ public interface ChangeApi {
}
@Override
- public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
- throw new NotImplementedException();
- }
-
- @Override
- public AccountInfo getAssignee() throws RestApiException {
- throw new NotImplementedException();
- }
-
- @Override
- public List<AccountInfo> getPastAssignees() throws RestApiException {
- throw new NotImplementedException();
- }
-
- @Override
- public AccountInfo deleteAssignee() throws RestApiException {
- throw new NotImplementedException();
- }
-
- @Override
@Deprecated
public Map<String, List<CommentInfo>> comments() throws RestApiException {
throw new NotImplementedException();
@@ -824,6 +815,11 @@ public interface ChangeApi {
}
@Override
+ public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public PureRevertInfo pureRevert() throws RestApiException {
throw new NotImplementedException();
}
diff --git a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
index 0cfe908cbf..6349595431 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileContentInput.java
@@ -21,4 +21,5 @@ import com.google.gerrit.extensions.restapi.RawInput;
public class FileContentInput {
@DefaultInput public RawInput content;
public String binary_content;
+ public Integer fileMode;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
index e9b05cc274..a85bc73dd8 100644
--- a/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RebaseInput.java
@@ -27,5 +27,21 @@ public class RebaseInput {
*/
public boolean allowConflicts;
+ /**
+ * Whether the rebase should be done on behalf of the uploader.
+ *
+ * <p>This means the uploader of the current patch set will also be the uploader of the rebased
+ * patch set. The calling user will be recorded as the real user.
+ *
+ * <p>Rebasing on behalf of the uploader is only supported for trivial rebases. This means this
+ * option cannot be combined with the {@link #allowConflicts} option.
+ *
+ * <p>In addition, rebasing on behalf of the uploader is only supported for the current patch set
+ * of a change and not when rebasing a chain.
+ *
+ * <p>Using this option is not supported when rebasing a chain via the Rebase Chain REST endpoint.
+ */
+ public boolean onBehalfOfUploader;
+
public Map<String, String> validationOptions;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 11999ab548..8bfe468509 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -14,8 +14,10 @@
package com.google.gerrit.extensions.api.changes;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.extensions.client.Comment;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.FixSuggestionInfo;
@@ -117,11 +119,13 @@ public class ReviewInput {
public List<FixSuggestionInfo> fixSuggestions;
}
+ @CanIgnoreReturnValue
public ReviewInput message(String msg) {
message = msg != null && !msg.isEmpty() ? msg : null;
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput patchSetLevelComment(String message) {
Objects.requireNonNull(message);
CommentInput comment = new CommentInput();
@@ -131,6 +135,7 @@ public class ReviewInput {
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput label(String name, short value) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException();
@@ -142,6 +147,7 @@ public class ReviewInput {
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput label(String name, int value) {
if (value < Short.MIN_VALUE || value > Short.MAX_VALUE) {
throw new IllegalArgumentException();
@@ -149,14 +155,22 @@ public class ReviewInput {
return label(name, (short) value);
}
+ @CanIgnoreReturnValue
public ReviewInput label(String name) {
return label(name, (short) 1);
}
+ @CanIgnoreReturnValue
public ReviewInput reviewer(String reviewer) {
- return reviewer(reviewer, REVIEWER, false);
+ return reviewer(reviewer, REVIEWER, /* confirmed= */ false);
}
+ @CanIgnoreReturnValue
+ public ReviewInput cc(String cc) {
+ return reviewer(cc, CC, /* confirmed= */ false);
+ }
+
+ @CanIgnoreReturnValue
public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
ReviewerInput input = new ReviewerInput();
input.reviewer = reviewer;
@@ -169,6 +183,7 @@ public class ReviewInput {
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput addUserToAttentionSet(String user, String reason) {
AttentionSetInput input = new AttentionSetInput();
input.user = user;
@@ -180,6 +195,7 @@ public class ReviewInput {
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput removeUserFromAttentionSet(String user, String reason) {
AttentionSetInput input = new AttentionSetInput();
input.user = user;
@@ -191,17 +207,20 @@ public class ReviewInput {
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput blockAutomaticAttentionSetRules() {
ignoreAutomaticAttentionSetRules = true;
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput setWorkInProgress(boolean workInProgress) {
this.workInProgress = workInProgress;
ready = !workInProgress;
return this;
}
+ @CanIgnoreReturnValue
public ReviewInput setReady(boolean ready) {
this.ready = ready;
workInProgress = !ready;
diff --git a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
index b45fceed29..5c47ac3828 100644
--- a/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/CommentLinkInfo.java
@@ -24,7 +24,6 @@ public class CommentLinkInfo {
public String prefix;
public String suffix;
public String text;
- public String html;
public Boolean enabled; // null means true
public transient String name;
@@ -41,7 +40,6 @@ public class CommentLinkInfo {
&& Objects.equals(this.prefix, that.prefix)
&& Objects.equals(this.suffix, that.suffix)
&& Objects.equals(this.text, that.text)
- && Objects.equals(this.html, that.html)
&& Objects.equals(this.enabled, that.enabled);
}
return false;
@@ -49,7 +47,7 @@ public class CommentLinkInfo {
@Override
public int hashCode() {
- return Objects.hash(match, link, html, enabled);
+ return Objects.hash(match, link, prefix, suffix, text, enabled);
}
@Override
@@ -61,7 +59,6 @@ public class CommentLinkInfo {
.add("prefix", prefix)
.add("suffix", suffix)
.add("text", text)
- .add("html", html)
.add("enabled", enabled)
.toString();
}
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
index 3ba1277d8e..1a51c15b77 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInfo.java
@@ -40,6 +40,7 @@ public class ConfigInfo {
public InheritedBooleanInfo enableReviewerByEmail;
public InheritedBooleanInfo matchAuthorToCommitterDate;
public InheritedBooleanInfo rejectEmptyCommit;
+ public InheritedBooleanInfo skipAddingAuthorAndCommitterAsReviewers;
public MaxObjectSizeLimitInfo maxObjectSizeLimit;
@Deprecated // Equivalent to defaultSubmitType.value
diff --git a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
index 8005fc51b5..906fc4c3c6 100644
--- a/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
+++ b/java/com/google/gerrit/extensions/api/projects/ConfigInput.java
@@ -34,6 +34,7 @@ public class ConfigInput {
public InheritableBoolean enableReviewerByEmail;
public InheritableBoolean matchAuthorToCommitterDate;
public InheritableBoolean rejectEmptyCommit;
+ public InheritableBoolean skipAddingAuthorAndCommitterAsReviewers;
public String maxObjectSizeLimit;
public SubmitType submitType;
public ProjectState state;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 1ee2cd87ad..020351bc84 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -134,7 +134,6 @@ public class GeneralPreferencesInfo {
public DateFormat dateFormat;
public TimeFormat timeFormat;
public Boolean expandInlineDiffs;
- public Boolean highlightAssigneeInChangeTable;
public Boolean relativeDateInChangeTable;
public DiffView diffView;
public Boolean sizeBarInChangeTable;
@@ -195,7 +194,6 @@ public class GeneralPreferencesInfo {
p.dateFormat = DateFormat.STD;
p.timeFormat = TimeFormat.HHMM_12;
p.expandInlineDiffs = false;
- p.highlightAssigneeInChangeTable = true;
p.relativeDateInChangeTable = false;
p.diffView = DiffView.SIDE_BY_SIDE;
p.sizeBarInChangeTable = true;
diff --git a/java/com/google/gerrit/extensions/client/GerritTopMenu.java b/java/com/google/gerrit/extensions/client/GerritTopMenu.java
index b7e1a5a38e..b9a395e20e 100644
--- a/java/com/google/gerrit/extensions/client/GerritTopMenu.java
+++ b/java/com/google/gerrit/extensions/client/GerritTopMenu.java
@@ -14,6 +14,8 @@
package com.google.gerrit.extensions.client;
+import java.util.Locale;
+
public enum GerritTopMenu {
ALL,
MY,
@@ -25,6 +27,6 @@ public enum GerritTopMenu {
public final String menuName;
GerritTopMenu() {
- menuName = name().substring(0, 1) + name().substring(1).toLowerCase();
+ menuName = name().substring(0, 1) + name().substring(1).toLowerCase(Locale.US);
}
}
diff --git a/java/com/google/gerrit/extensions/client/Side.java b/java/com/google/gerrit/extensions/client/Side.java
index e077df2dc1..a87b37ab36 100644
--- a/java/com/google/gerrit/extensions/client/Side.java
+++ b/java/com/google/gerrit/extensions/client/Side.java
@@ -14,10 +14,13 @@
package com.google.gerrit.extensions.client;
+import com.google.gerrit.common.Nullable;
+
public enum Side {
PARENT,
REVISION;
+ @Nullable
public static Side fromShort(short s) {
if (s <= 0) {
return PARENT;
diff --git a/java/com/google/gerrit/extensions/common/ActionInfo.java b/java/com/google/gerrit/extensions/common/ActionInfo.java
index 2144ed5fcd..f148444227 100644
--- a/java/com/google/gerrit/extensions/common/ActionInfo.java
+++ b/java/com/google/gerrit/extensions/common/ActionInfo.java
@@ -15,6 +15,7 @@
package com.google.gerrit.extensions.common;
import com.google.gerrit.extensions.webui.UiAction;
+import java.util.List;
import java.util.Objects;
/**
@@ -50,11 +51,30 @@ public class ActionInfo {
*/
public Boolean enabled;
+ /**
+ * Optional list of enabled options.
+ *
+ * <p>For the {@code rebase} REST view the following options are supported:
+ *
+ * <ul>
+ * <li>{@code rebase}: Present if the user can rebase the change. This is the case for the
+ * change owner and users with the {@code Submit} or {@code Rebase} permission if they have
+ * the {@code Push} permission.
+ * <li>{@code rebase_on_behalf_of}: Present if the user can rebase the change on behalf of the
+ * uploader. This is the case for the change owner and users with the {@code Submit} or
+ * {@code Rebase} permission.
+ * </ul>
+ *
+ * <p>For all other REST views no options are returned.
+ */
+ public List<String> enabledOptions;
+
public ActionInfo(UiAction.Description d) {
method = d.getMethod();
label = d.getLabel();
title = d.getTitle();
enabled = d.isEnabled() ? true : null;
+ enabledOptions = d.getEnabledOptions();
}
@Override
@@ -64,14 +84,15 @@ public class ActionInfo {
return Objects.equals(method, actionInfo.method)
&& Objects.equals(label, actionInfo.label)
&& Objects.equals(title, actionInfo.title)
- && Objects.equals(enabled, actionInfo.enabled);
+ && Objects.equals(enabled, actionInfo.enabled)
+ && Objects.equals(enabledOptions, actionInfo.enabledOptions);
}
return false;
}
@Override
public int hashCode() {
- return Objects.hash(method, label, title, enabled);
+ return Objects.hash(method, label, title, enabled, enabledOptions);
}
protected ActionInfo() {}
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index 3bcd150e02..80bf130362 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -17,12 +17,10 @@ package com.google.gerrit.extensions.common;
/** API response containing values from the {@code change} section of {@code gerrit.config}. */
public class ChangeConfigInfo {
public Boolean allowBlame;
- public Boolean showAssigneeInChangesTable;
public Boolean disablePrivateChanges;
public int updateDelay;
public Boolean submitWholeTopic;
public String mergeabilityComputationBehavior;
- public Boolean enableAttentionSet;
- public Boolean enableAssignee;
+ public Boolean enableRobotComments;
public Boolean conflictsPredicateEnabled;
}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 40ae2eca81..dc9bc32ea8 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -49,7 +49,6 @@ public class ChangeInfo {
public Map<Integer, AttentionSetInfo> removedFromAttentionSet;
- public AccountInfo assignee;
public Collection<String> hashtags;
public String changeId;
public String subject;
@@ -105,6 +104,7 @@ public class ChangeInfo {
public Map<String, ActionInfo> actions;
public Map<String, LabelInfo> labels;
public Map<String, Collection<String>> permittedLabels;
+ public Map<String, Map<String, List<AccountInfo>>> removableLabels;
public Collection<AccountInfo> removableReviewers;
public Map<ReviewerState, Collection<AccountInfo>> reviewers;
public Map<ReviewerState, Collection<AccountInfo>> pendingReviewers;
@@ -174,4 +174,14 @@ public class ChangeInfo {
submitted = Timestamp.from(when);
submitter = who;
}
+
+ public RevisionInfo getCurrentRevision() {
+ RevisionInfo currentRevisionInfo = revisions.get(currentRevision);
+ if (currentRevisionInfo.commit != null) {
+ // If all revisions are requested the commit.commit field is not populated because the commit
+ // SHA1 is already present as the key in the revisions map.
+ currentRevisionInfo.commit.commit = currentRevision;
+ }
+ return currentRevisionInfo;
+ }
}
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index ad112d3a88..51c35dce71 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -21,6 +21,7 @@ import static java.util.stream.Collectors.groupingBy;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.sql.Timestamp;
@@ -147,16 +148,19 @@ public final class ChangeInfoDiffer {
}
/** Returns {@code null} if nothing has been added to {@code oldCollection} */
+ @Nullable
private static ImmutableList<?> getAddedForCollection(
- Collection<?> oldCollection, Collection<?> newCollection) {
- ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
+ @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+ ImmutableList<?> notInOldCollection = getAdditionsForCollection(oldCollection, newCollection);
return notInOldCollection.isEmpty() ? null : notInOldCollection;
}
- private static ImmutableList<Object> getAdditions(
- Collection<?> oldCollection, Collection<?> newCollection) {
- if (oldCollection == null)
- return newCollection != null ? ImmutableList.copyOf(newCollection) : null;
+ @Nullable
+ private static ImmutableList<Object> getAdditionsForCollection(
+ @Nullable Collection<?> oldCollection, Collection<?> newCollection) {
+ if (oldCollection == null) {
+ return ImmutableList.copyOf(newCollection);
+ }
Map<Object, List<Object>> duplicatesMap = newCollection.stream().collect(groupingBy(v -> v));
oldCollection.forEach(
@@ -169,7 +173,19 @@ public final class ChangeInfoDiffer {
}
/** Returns {@code null} if nothing has been added to {@code oldMap} */
- private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
+ @Nullable
+ private static ImmutableMap<Object, Object> getAddedForMap(
+ @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+ ImmutableMap<Object, Object> notInOldMap = getAdditionsForMap(oldMap, newMap);
+ return notInOldMap.isEmpty() ? null : notInOldMap;
+ }
+
+ @Nullable
+ private static ImmutableMap<Object, Object> getAdditionsForMap(
+ @Nullable Map<?, ?> oldMap, Map<?, ?> newMap) {
+ if (oldMap == null) {
+ return ImmutableMap.copyOf(newMap);
+ }
ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
for (Map.Entry<?, ?> entry : newMap.entrySet()) {
Object added = getAdded(oldMap.get(entry.getKey()), entry.getValue());
@@ -177,8 +193,7 @@ public final class ChangeInfoDiffer {
additionsBuilder.put(entry.getKey(), added);
}
}
- ImmutableMap<Object, Object> additions = additionsBuilder.build();
- return additions.isEmpty() ? null : additions;
+ return additionsBuilder.build();
}
private static Object get(Field field, Object obj) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index ea12ef1956..6f9cff7882 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -14,11 +14,15 @@
package com.google.gerrit.extensions.common;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.NotifyInfo;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import java.util.List;
import java.util.Map;
public class ChangeInput {
@@ -35,9 +39,12 @@ public class ChangeInput {
public Boolean newBranch;
public Map<String, String> validationOptions;
public MergeInput merge;
+ public ApplyPatchInput patch;
public AccountInput author;
+ @Nullable public List<ListChangesOption> responseFormatOptions;
+
public ChangeInput() {}
/**
diff --git a/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java b/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
index 9bcf2cf4c9..05029be7e8 100644
--- a/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/DiffWebLinkInfo.java
@@ -18,29 +18,26 @@ public class DiffWebLinkInfo extends WebLinkInfo {
public Boolean showOnSideBySideDiffView;
public Boolean showOnUnifiedDiffView;
- public static DiffWebLinkInfo forSideBySideDiffView(
- String name, String imageUrl, String url, String target) {
- return new DiffWebLinkInfo(name, imageUrl, url, target, true, false);
+ public static DiffWebLinkInfo forSideBySideDiffView(String name, String imageUrl, String url) {
+ return new DiffWebLinkInfo(name, imageUrl, url, true, false);
}
- public static DiffWebLinkInfo forUnifiedDiffView(
- String name, String imageUrl, String url, String target) {
- return new DiffWebLinkInfo(name, imageUrl, url, target, false, true);
+ public static DiffWebLinkInfo forUnifiedDiffView(String name, String imageUrl, String url) {
+ return new DiffWebLinkInfo(name, imageUrl, url, false, true);
}
public static DiffWebLinkInfo forSideBySideAndUnifiedDiffView(
- String name, String imageUrl, String url, String target) {
- return new DiffWebLinkInfo(name, imageUrl, url, target, true, true);
+ String name, String imageUrl, String url) {
+ return new DiffWebLinkInfo(name, imageUrl, url, true, true);
}
private DiffWebLinkInfo(
String name,
String imageUrl,
String url,
- String target,
boolean showOnSideBySideDiffView,
boolean showOnUnifiedDiffView) {
- super(name, imageUrl, url, target);
+ super(name, imageUrl, url);
this.showOnSideBySideDiffView = showOnSideBySideDiffView ? true : null;
this.showOnUnifiedDiffView = showOnUnifiedDiffView ? true : null;
}
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index c732663ebc..9526fbb475 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -18,6 +18,8 @@ import java.util.Objects;
public class FileInfo {
public Character status;
+ public Integer oldMode;
+ public Integer newMode;
public Boolean binary;
public String oldPath;
public Integer linesInserted;
diff --git a/java/com/google/gerrit/extensions/common/RebaseChainInfo.java b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
new file mode 100644
index 0000000000..b3270078f1
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/RebaseChainInfo.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.common;
+
+import java.util.List;
+
+public class RebaseChainInfo {
+ public List<ChangeInfo> rebasedChanges;
+ /**
+ * Whether any of the changes contain conflicts.
+ *
+ * <p>If {@code true}, some of the rebased changes are marked with conflicts.
+ */
+ public Boolean containsGitConflicts;
+}
diff --git a/java/com/google/gerrit/extensions/common/RevisionInfo.java b/java/com/google/gerrit/extensions/common/RevisionInfo.java
index 7c52c8c392..941dffeef8 100644
--- a/java/com/google/gerrit/extensions/common/RevisionInfo.java
+++ b/java/com/google/gerrit/extensions/common/RevisionInfo.java
@@ -32,6 +32,7 @@ public class RevisionInfo {
public Timestamp created;
public AccountInfo uploader;
+ public AccountInfo realUploader;
public String ref;
public Map<String, FetchInfo> fetch;
public CommitInfo commit;
@@ -72,6 +73,7 @@ public class RevisionInfo {
&& _number == revisionInfo._number
&& Objects.equals(created, revisionInfo.created)
&& Objects.equals(uploader, revisionInfo.uploader)
+ && Objects.equals(realUploader, revisionInfo.realUploader)
&& Objects.equals(ref, revisionInfo.ref)
&& Objects.equals(fetch, revisionInfo.fetch)
&& Objects.equals(commit, revisionInfo.commit)
@@ -92,6 +94,7 @@ public class RevisionInfo {
_number,
created,
uploader,
+ realUploader,
ref,
fetch,
commit,
diff --git a/java/com/google/gerrit/extensions/common/WebLinkInfo.java b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
index ba12be0e36..fbd8d2f0fb 100644
--- a/java/com/google/gerrit/extensions/common/WebLinkInfo.java
+++ b/java/com/google/gerrit/extensions/common/WebLinkInfo.java
@@ -14,24 +14,18 @@
package com.google.gerrit.extensions.common;
-import com.google.gerrit.extensions.webui.WebLink.Target;
import java.util.Objects;
public class WebLinkInfo {
public String name;
+ public String tooltip;
public String imageUrl;
public String url;
- public String target;
- public WebLinkInfo(String name, String imageUrl, String url, String target) {
+ public WebLinkInfo(String name, String imageUrl, String url) {
this.name = name;
this.imageUrl = imageUrl;
this.url = url;
- this.target = target;
- }
-
- public WebLinkInfo(String name, String imageUrl, String url) {
- this(name, imageUrl, url, Target.SELF);
}
@Override
@@ -41,14 +35,14 @@ public class WebLinkInfo {
}
WebLinkInfo i = (WebLinkInfo) o;
return Objects.equals(name, i.name)
+ && Objects.equals(tooltip, i.tooltip)
&& Objects.equals(imageUrl, i.imageUrl)
- && Objects.equals(url, i.url)
- && Objects.equals(target, i.target);
+ && Objects.equals(url, i.url);
}
@Override
public int hashCode() {
- return Objects.hash(name, imageUrl, url, target);
+ return Objects.hash(name, tooltip, imageUrl, url);
}
@Override
@@ -56,12 +50,12 @@ public class WebLinkInfo {
return getClass().getSimpleName()
+ "{name="
+ name
+ + ", tooltip="
+ + tooltip
+ ", imageUrl="
+ imageUrl
+ ", url="
+ url
- + ", target"
- + target
+ "}";
}
diff --git a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
index d011d5d21d..180a946916 100644
--- a/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/FileInfoSubject.java
@@ -45,6 +45,16 @@ public class FileInfoSubject extends Subject {
return check("linesDeleted").that(fileInfo.linesDeleted);
}
+ public IntegerSubject oldMode() {
+ isNotNull();
+ return check("oldMode").that(fileInfo.oldMode);
+ }
+
+ public IntegerSubject newMode() {
+ isNotNull();
+ return check("newMode").that(fileInfo.newMode);
+ }
+
public ComparableSubject<Character> status() {
isNotNull();
return check("status").that(fileInfo.status);
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
index d8dd1f925c..7ed7077e6b 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicItemProvider.java
@@ -14,6 +14,7 @@
package com.google.gerrit.extensions.registration;
+import com.google.gerrit.common.Nullable;
import com.google.inject.Binding;
import com.google.inject.Inject;
import com.google.inject.Injector;
@@ -39,6 +40,7 @@ class DynamicItemProvider<T> implements Provider<DynamicItem<T>> {
return new DynamicItem<>(key, find(injector, type), PluginName.GERRIT);
}
+ @Nullable
private static <T> Provider<T> find(Injector src, TypeLiteral<T> type) {
List<Binding<T>> bindings = src.findBindingsByType(type);
if (bindings != null && bindings.size() == 1) {
diff --git a/java/com/google/gerrit/extensions/registration/DynamicSet.java b/java/com/google/gerrit/extensions/registration/DynamicSet.java
index a0b2c6aa06..6dc8c6a307 100644
--- a/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -20,6 +20,7 @@ import static java.util.Comparator.naturalOrder;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.common.Nullable;
import com.google.inject.Binder;
import com.google.inject.Key;
import com.google.inject.Provider;
@@ -313,6 +314,7 @@ public class DynamicSet<T> implements Iterable<T> {
return key;
}
+ @Nullable
@Override
public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
Extension<T> n = new Extension<>(item.getPluginName(), newItem);
diff --git a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
index fb520b4082..67fc0683bd 100644
--- a/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
+++ b/java/com/google/gerrit/extensions/registration/PrivateInternals_DynamicMapImpl.java
@@ -16,6 +16,7 @@ package com.google.gerrit.extensions.registration;
import static java.util.Objects.requireNonNull;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.annotations.Export;
import com.google.inject.Key;
import com.google.inject.Provider;
@@ -79,6 +80,7 @@ public class PrivateInternals_DynamicMapImpl<T> extends DynamicMap<T> {
return key;
}
+ @Nullable
@Override
public ReloadableHandle replace(Key<T> newKey, Provider<T> newItem) {
if (items.replace(np, item, newItem)) {
diff --git a/java/com/google/gerrit/extensions/restapi/IdString.java b/java/com/google/gerrit/extensions/restapi/IdString.java
index b2538fa8d8..a69919f584 100644
--- a/java/com/google/gerrit/extensions/restapi/IdString.java
+++ b/java/com/google/gerrit/extensions/restapi/IdString.java
@@ -38,7 +38,16 @@ public class IdString {
/** Returns the decoded value of the string. */
public String get() {
- return Url.decode(urlEncoded);
+ String data = urlEncoded;
+
+ // URLs use percentage encoding which replaces unsafe ASCII characters with a '%' followed by
+ // two hexadecimal digits. If there is '%' that is not followed by two hexadecimal digits
+ // Url.decode(String) fails with an IllegalArgumentException. To prevent this replace any '%'
+ // that is not followed by two hexadecimal digits by "%25", which is the URL encoding for '%',
+ // before calling Url.decode(String).
+ data = data.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
+
+ return Url.decode(data);
}
/** Returns true if the string is the empty string. */
diff --git a/java/com/google/gerrit/extensions/restapi/Url.java b/java/com/google/gerrit/extensions/restapi/Url.java
index 9c69376eae..09def84c78 100644
--- a/java/com/google/gerrit/extensions/restapi/Url.java
+++ b/java/com/google/gerrit/extensions/restapi/Url.java
@@ -16,6 +16,7 @@ package com.google.gerrit.extensions.restapi;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.gerrit.common.Nullable;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
@@ -40,6 +41,7 @@ public final class Url {
* @param component a string containing text to encode.
* @return a string with all invalid URL characters escaped.
*/
+ @Nullable
public static String encode(String component) {
if (component != null) {
try {
@@ -52,6 +54,7 @@ public final class Url {
}
/** Decode a URL encoded string, e.g. from {@code "%2F"} to {@code "/"}. */
+ @Nullable
public static String decode(String str) {
if (str != null) {
try {
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index 2f21bf3fa4..9da0642352 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -14,10 +14,13 @@
package com.google.gerrit.extensions.webui;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.conditions.BooleanCondition;
import com.google.gerrit.extensions.restapi.RestResource;
import com.google.gerrit.extensions.restapi.RestView;
+import java.util.ArrayList;
+import java.util.List;
public interface UiAction<R extends RestResource> extends RestView<R> {
/**
@@ -40,6 +43,7 @@ public interface UiAction<R extends RestResource> extends RestView<R> {
private String title;
private BooleanCondition visible = BooleanCondition.TRUE;
private BooleanCondition enabled = BooleanCondition.TRUE;
+ private List<String> enabledOptions = new ArrayList<>();
public String getMethod() {
return method;
@@ -122,5 +126,22 @@ public interface UiAction<R extends RestResource> extends RestView<R> {
this.enabled = enabled;
return this;
}
+
+ @Nullable
+ public ImmutableList<String> getEnabledOptions() {
+ if (enabledOptions.isEmpty()) {
+ return null;
+ }
+ return ImmutableList.copyOf(enabledOptions);
+ }
+
+ public Description setOption(String optionName, boolean enabled) {
+ if (enabled) {
+ enabledOptions.add(optionName);
+ } else {
+ enabledOptions.remove(optionName);
+ }
+ return this;
+ }
}
}
diff --git a/java/com/google/gerrit/extensions/webui/WebLink.java b/java/com/google/gerrit/extensions/webui/WebLink.java
index 7cbeff2194..36ae50cfd2 100644
--- a/java/com/google/gerrit/extensions/webui/WebLink.java
+++ b/java/com/google/gerrit/extensions/webui/WebLink.java
@@ -15,16 +15,4 @@
package com.google.gerrit.extensions.webui;
/** Marks that the implementor has a method that provides a weblinkInfo */
-public interface WebLink {
- /** Class that holds target defaults for WebLink anchors. */
- class Target {
- /** Opens the link in a new window or tab */
- public static final String BLANK = "_blank";
- /** Opens the link in the frame it was clicked. */
- public static final String SELF = "_self";
- /** Opens link in parent frame. */
- public static final String PARENT = "_parent";
- /** Opens link in the full body of the window. */
- public static final String TOP = "_top";
- }
-}
+public interface WebLink {}
diff --git a/java/com/google/gerrit/git/GitUpdateFailureException.java b/java/com/google/gerrit/git/GitUpdateFailureException.java
index 7fcb828155..339339c3b1 100644
--- a/java/com/google/gerrit/git/GitUpdateFailureException.java
+++ b/java/com/google/gerrit/git/GitUpdateFailureException.java
@@ -46,6 +46,11 @@ public class GitUpdateFailureException extends IOException {
.collect(toImmutableList());
}
+ protected GitUpdateFailureException(String message, Throwable cause) {
+ super(message, cause);
+ this.failures = ImmutableList.of();
+ }
+
/** Returns the names of the refs for which the update failed. */
public ImmutableList<String> getFailedRefs() {
return failures.stream().map(GitUpdateFailure::ref).collect(toImmutableList());
diff --git a/java/com/google/gerrit/git/LockFailureException.java b/java/com/google/gerrit/git/LockFailureException.java
index 371488da3f..2908db22f3 100644
--- a/java/com/google/gerrit/git/LockFailureException.java
+++ b/java/com/google/gerrit/git/LockFailureException.java
@@ -14,6 +14,7 @@
package com.google.gerrit.git;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.RefUpdate;
@@ -21,6 +22,9 @@ import org.eclipse.jgit.lib.RefUpdate;
public class LockFailureException extends GitUpdateFailureException {
private static final long serialVersionUID = 1L;
+ private static final String REF_UPDATE_RETURN_CODE_WAS_LOCK_FAILURE =
+ "RefUpdate return code was: LOCK_FAILURE";
+
public LockFailureException(String message, RefUpdate refUpdate) {
super(message, refUpdate);
}
@@ -28,4 +32,15 @@ public class LockFailureException extends GitUpdateFailureException {
public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
super(message, batchRefUpdate);
}
+
+ protected LockFailureException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public static void throwIfLockFailure(ConcurrentRefUpdateException e)
+ throws LockFailureException {
+ if (e.getMessage().contains(REF_UPDATE_RETURN_CODE_WAS_LOCK_FAILURE)) {
+ throw new LockFailureException(e.getMessage(), e);
+ }
+ }
}
diff --git a/java/com/google/gerrit/gpg/BUILD b/java/com/google/gerrit/gpg/BUILD
index 0ee5212710..b2173c4c71 100644
--- a/java/com/google/gerrit/gpg/BUILD
+++ b/java/com/google/gerrit/gpg/BUILD
@@ -5,6 +5,7 @@ java_library(
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/exceptions",
"//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 71dff97541..fff4045dd9 100644
--- a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -37,6 +37,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -82,7 +83,7 @@ public class GerritPublicKeyChecker extends PublicKeyChecker {
if (strs.length != 0) {
Map<Long, Fingerprint> fps = Maps.newHashMapWithExpectedSize(strs.length);
for (String str : strs) {
- str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
+ str = CharMatcher.whitespace().removeFrom(str).toUpperCase(Locale.US);
Fingerprint fp = new Fingerprint(BaseEncoding.base16().decode(str));
fps.put(fp.getId(), fp);
}
diff --git a/java/com/google/gerrit/gpg/PublicKeyChecker.java b/java/com/google/gerrit/gpg/PublicKeyChecker.java
index 0a962120ad..534739858d 100644
--- a/java/com/google/gerrit/gpg/PublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/PublicKeyChecker.java
@@ -30,6 +30,7 @@ import static org.bouncycastle.openpgp.PGPSignature.DIRECT_KEY;
import static org.bouncycastle.openpgp.PGPSignature.KEY_REVOCATION;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
import java.io.IOException;
import java.time.Instant;
@@ -229,6 +230,7 @@ public class PublicKeyChecker {
|| PushCertificateChecker.getCreationTime(revocation).isBefore(now);
}
+ @Nullable
private PGPSignature scanRevocations(
PGPPublicKey key,
Instant now,
@@ -264,6 +266,7 @@ public class PublicKeyChecker {
return null;
}
+ @Nullable
private RevocationKey getRevocationKey(PGPPublicKey key, PGPSignature sig) throws PGPException {
if (sig.getKeyID() != key.getKeyID()) {
return null;
@@ -320,6 +323,7 @@ public class PublicKeyChecker {
}
}
+ @Nullable
private static RevocationReason getRevocationReason(PGPSignature sig) {
if (sig.getSignatureType() != KEY_REVOCATION) {
throw new IllegalArgumentException(
@@ -425,6 +429,7 @@ public class PublicKeyChecker {
return CheckResult.create(OK, problems);
}
+ @Nullable
private static PGPPublicKey getSigner(
PublicKeyStore store,
PGPSignature sig,
@@ -455,6 +460,7 @@ public class PublicKeyChecker {
}
}
+ @Nullable
private String checkTrustSubpacket(PGPSignature sig, int depth) {
SignatureSubpacket trustSub =
sig.getHashedSubPackets().getSubpacket(SignatureSubpacketTags.TRUST_SIG);
diff --git a/java/com/google/gerrit/gpg/PublicKeyStore.java b/java/com/google/gerrit/gpg/PublicKeyStore.java
index 2cce480f0f..def35d6c38 100644
--- a/java/com/google/gerrit/gpg/PublicKeyStore.java
+++ b/java/com/google/gerrit/gpg/PublicKeyStore.java
@@ -15,14 +15,17 @@
package com.google.gerrit.gpg;
import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GPG_KEYS_MODIFICATION;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.common.base.Preconditions;
import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -92,6 +95,7 @@ public class PublicKeyStore implements AutoCloseable {
* null} if none was found.
* @throws PGPException if an error occurred verifying the signature.
*/
+ @Nullable
public static PGPPublicKey getSigner(
Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, byte[] data) throws PGPException {
for (PGPPublicKeyRing kr : keyRings) {
@@ -126,6 +130,7 @@ public class PublicKeyStore implements AutoCloseable {
* {@code null} if none was found.
* @throws PGPException if an error occurred verifying the certification.
*/
+ @Nullable
public static PGPPublicKey getSigner(
Iterable<PGPPublicKeyRing> keyRings, PGPSignature sig, String userId, PGPPublicKey key)
throws PGPException {
@@ -210,6 +215,7 @@ public class PublicKeyStore implements AutoCloseable {
* @throws PGPException if an error occurred parsing the key data.
* @throws IOException if an error occurred reading the repository data.
*/
+ @Nullable
public PGPPublicKeyRing get(byte[] fingerprint) throws PGPException, IOException {
List<PGPPublicKeyRing> keyRings = get(Fingerprint.getId(fingerprint), fingerprint);
return !keyRings.isEmpty() ? keyRings.get(0) : null;
@@ -272,7 +278,7 @@ public class PublicKeyStore implements AutoCloseable {
}
public void rebuildSubkeyMasterKeyMap()
- throws MissingObjectException, IncorrectObjectTypeException, IOException, PGPException {
+ throws MissingObjectException, IncorrectObjectTypeException, IOException {
if (reader == null) {
load();
}
@@ -373,35 +379,36 @@ public class PublicKeyStore implements AutoCloseable {
newTip = ins.insert(cb);
ins.flush();
}
-
- RefUpdate ru = repo.updateRef(PublicKeyStore.REFS_GPG_KEYS);
- ru.setExpectedOldObjectId(tip);
- ru.setNewObjectId(newTip);
- ru.setRefLogIdent(cb.getCommitter());
- ru.setRefLogMessage("Store public keys", true);
- RefUpdate.Result result = ru.update();
- reset();
- switch (result) {
- case FAST_FORWARD:
- case NEW:
- case NO_CHANGE:
- toAdd.clear();
- toRemove.clear();
- break;
- case LOCK_FAILURE:
- throw new LockFailureException("Failed to store public keys", ru);
- case FORCED:
- case IO_FAILURE:
- case NOT_ATTEMPTED:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- break;
+ try (RefUpdateContext ctx = RefUpdateContext.open(GPG_KEYS_MODIFICATION)) {
+ RefUpdate ru = repo.updateRef(PublicKeyStore.REFS_GPG_KEYS);
+ ru.setExpectedOldObjectId(tip);
+ ru.setNewObjectId(newTip);
+ ru.setRefLogIdent(cb.getCommitter());
+ ru.setRefLogMessage("Store public keys", true);
+ RefUpdate.Result result = ru.update();
+ reset();
+ switch (result) {
+ case FAST_FORWARD:
+ case NEW:
+ case NO_CHANGE:
+ toAdd.clear();
+ toRemove.clear();
+ break;
+ case LOCK_FAILURE:
+ throw new LockFailureException("Failed to store public keys", ru);
+ case FORCED:
+ case IO_FAILURE:
+ case NOT_ATTEMPTED:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ break;
+ }
+ return result;
}
- return result;
}
private void saveToNotes(ObjectInserter ins, PGPPublicKeyRing keyRing)
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 17ca5a4679..b9ff50b0bc 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -22,6 +22,7 @@ import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
import com.google.common.base.Joiner;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.common.GpgKeyInfo.Status;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -176,6 +177,7 @@ public abstract class PushCertificateChecker {
return CheckResult.ok();
}
+ @Nullable
private PGPSignature readSignature(PushCertificate cert) throws IOException {
ArmoredInputStream in =
new ArmoredInputStream(new ByteArrayInputStream(Constants.encode(cert.getSignature())));
diff --git a/java/com/google/gerrit/gpg/server/GpgKeys.java b/java/com/google/gerrit/gpg/server/GpgKeys.java
index b3a2f53e3b..00a0f57ba6 100644
--- a/java/com/google/gerrit/gpg/server/GpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/GpgKeys.java
@@ -48,6 +48,7 @@ import java.io.IOException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
+import java.util.Locale;
import java.util.Map;
import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.openpgp.PGPException;
@@ -106,7 +107,7 @@ public class GpgKeys implements ChildCollection<AccountResource, GpgKey> {
static ExternalId findGpgKey(String str, Iterable<ExternalId> existingExtIds)
throws ResourceNotFoundException {
- str = CharMatcher.whitespace().removeFrom(str).toUpperCase();
+ str = CharMatcher.whitespace().removeFrom(str).toUpperCase(Locale.US);
if ((str.length() != 8 && str.length() != 40)
|| !CharMatcher.anyOf("0123456789ABCDEF").matchesAllOf(str)) {
throw new ResourceNotFoundException(str);
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 334180687f..d51ee6adca 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -29,6 +29,7 @@ import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.exceptions.StorageException;
@@ -299,6 +300,7 @@ public class PostGpgKeys implements RestModifyView<AccountResource, GpgKeysInput
return externalIdKeyFactory.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
}
+ @Nullable
private Account getAccountByExternalId(ExternalId.Key extIdKey) {
List<AccountState> accountStates = accountQueryProvider.get().byExternalId(extIdKey);
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index 1284829bfb..01420315e5 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -38,9 +38,11 @@ java_library(
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
"//lib/commons:lang3",
+ "//lib/errorprone:annotations",
"//lib/flogger:api",
"//lib/guice",
"//lib/guice:guice-assistedinject",
"//lib/guice:guice-servlet",
+ "//lib/jsoup",
],
)
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5b62f96216..9625039fc0 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -116,6 +116,7 @@ public abstract class CacheBasedWebSession extends WebSession {
}
}
+ @Nullable
private static String readCookie(HttpServletRequest request) {
Cookie[] all = request.getCookies();
if (all != null) {
@@ -219,6 +220,7 @@ public abstract class CacheBasedWebSession extends WebSession {
}
}
+ @Nullable
@Override
public String getSessionId() {
return val != null ? val.getSessionId() : null;
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index e1ead597bf..e513a721ca 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.Capable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.registration.DynamicSet;
@@ -666,6 +667,7 @@ public class GitOverHttpServlet extends GitServlet {
public void destroy() {}
}
+ @Nullable
private static String getSessionIdOrNull(Provider<WebSession> sessionProvider) {
WebSession session = sessionProvider.get();
if (session.isSignedIn()) {
diff --git a/java/com/google/gerrit/httpd/HtmlDomUtil.java b/java/com/google/gerrit/httpd/HtmlDomUtil.java
index 57f2664184..16e09389de 100644
--- a/java/com/google/gerrit/httpd/HtmlDomUtil.java
+++ b/java/com/google/gerrit/httpd/HtmlDomUtil.java
@@ -16,7 +16,9 @@ package com.google.gerrit.httpd;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -25,6 +27,8 @@ import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
import java.util.zip.GZIPOutputStream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
@@ -39,6 +43,7 @@ import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
+import org.jsoup.parser.Parser;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
@@ -47,6 +52,8 @@ import org.xml.sax.SAXException;
/** Utility functions to deal with HTML using W3C DOM operations. */
public class HtmlDomUtil {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
/** Standard character encoding we prefer (UTF-8). */
public static final Charset ENC = UTF_8;
@@ -89,6 +96,7 @@ public class HtmlDomUtil {
}
/** Find an element by its "id" attribute; null if no element is found. */
+ @Nullable
public static Element find(Node parent, String name) {
NodeList list = parent.getChildNodes();
for (int i = 0; i < list.getLength(); i++) {
@@ -139,6 +147,7 @@ public class HtmlDomUtil {
}
/** Parse an XHTML file from our CLASSPATH and return the instance. */
+ @Nullable
public static Document parseFile(Class<?> context, String name) throws IOException {
try (InputStream in = context.getResourceAsStream(name)) {
if (in == null) {
@@ -168,6 +177,7 @@ public class HtmlDomUtil {
}
/** Read a Read a UTF-8 text file from our CLASSPATH and return it. */
+ @Nullable
public static String readFile(Class<?> context, String name) throws IOException {
try (InputStream in = context.getResourceAsStream(name)) {
if (in == null) {
@@ -180,6 +190,7 @@ public class HtmlDomUtil {
}
/** Parse an XHTML file from the local drive and return the instance. */
+ @Nullable
public static Document parseFile(Path path) throws IOException {
try (InputStream in = Files.newInputStream(path)) {
Document doc = newBuilder().parse(in);
@@ -193,6 +204,7 @@ public class HtmlDomUtil {
}
/** Read a UTF-8 text file from the local drive. */
+ @Nullable
public static String readFile(Path parentDir, String name) throws IOException {
if (parentDir == null) {
return null;
@@ -215,4 +227,27 @@ public class HtmlDomUtil {
factory.setCoalescing(true);
return factory.newDocumentBuilder();
}
+
+ /**
+ * Attaches nonce to all script elements in html.
+ *
+ * <p>The returned html is not guaranteed to have the same formatting as the input.
+ *
+ * @return Updated html or {#link Optional.empty()} if parsing failed.
+ */
+ public static Optional<String> attachNonce(String html, String nonce) {
+ Parser parser = Parser.htmlParser();
+ org.jsoup.nodes.Document document = parser.parseInput(html, "");
+ if (!parser.getErrors().isEmpty()) {
+ logger.atSevere().atMostEvery(5, TimeUnit.MINUTES).log(
+ "Html couldn't be parsed to attach nonce. Errors: %s", parser.getErrors());
+ return Optional.empty();
+ }
+ document.getElementsByTag("script").attr("nonce", nonce);
+ return Optional.of(
+ document
+ .outputSettings(
+ new org.jsoup.nodes.Document.OutputSettings().prettyPrint(false).indentAmount(0))
+ .outerHtml());
+ }
}
diff --git a/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java b/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
index 6943faa87f..85dc20068c 100644
--- a/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
+++ b/java/com/google/gerrit/httpd/HttpCanonicalWebUrlProvider.java
@@ -14,6 +14,8 @@
package com.google.gerrit.httpd;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
import com.google.gerrit.server.config.CanonicalWebUrlProvider;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.inject.Inject;
@@ -30,7 +32,8 @@ public class HttpCanonicalWebUrlProvider extends CanonicalWebUrlProvider {
private Provider<HttpServletRequest> requestProvider;
@Inject
- HttpCanonicalWebUrlProvider(@GerritServerConfig Config config) {
+ @UsedAt(Project.MODULE_VIRTUALHOST)
+ protected HttpCanonicalWebUrlProvider(@GerritServerConfig Config config) {
super(config);
}
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index de6ae500ea..5a99cab974 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -23,6 +23,7 @@ import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
import com.google.gerrit.extensions.registration.DynamicItem;
@@ -226,6 +227,7 @@ class ProjectOAuthFilter implements Filter {
}
}
+ @Nullable
private AuthInfo extractAuthInfo(String hdr, String encoding)
throws UnsupportedEncodingException {
byte[] decoded = BaseEncoding.base64().decode(hdr.substring(BASIC.length()));
@@ -241,6 +243,7 @@ class ProjectOAuthFilter implements Filter {
defaultAuthProvider);
}
+ @Nullable
private AuthInfo extractAuthInfo(Cookie cookie) throws UnsupportedEncodingException {
String username =
URLDecoder.decode(cookie.getName().substring(GIT_COOKIE_PREFIX.length()), UTF_8.name());
@@ -272,6 +275,7 @@ class ProjectOAuthFilter implements Filter {
return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
}
+ @Nullable
private static Cookie findGitCookie(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/RemoteUserUtil.java b/java/com/google/gerrit/httpd/RemoteUserUtil.java
index 84954dc8d5..6f3e9c45d8 100644
--- a/java/com/google/gerrit/httpd/RemoteUserUtil.java
+++ b/java/com/google/gerrit/httpd/RemoteUserUtil.java
@@ -19,6 +19,7 @@ import static com.google.common.net.HttpHeaders.AUTHORIZATION;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.Nullable;
import javax.servlet.http.HttpServletRequest;
public class RemoteUserUtil {
@@ -62,6 +63,7 @@ public class RemoteUserUtil {
* @param auth header value which is used for extracting.
* @return username if available or null.
*/
+ @Nullable
public static String extractUsername(String auth) {
auth = emptyToNull(auth);
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index 029efba301..69adf82d02 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -92,11 +92,17 @@ class UrlModule extends ServletModule {
// which is bound in HttpPluginModule. We cannot bind it here again although
// this means that plugins can't add REST views on PLUGIN_KIND.
serveRegex("^/(?:a/)?access/(.*)$").with(AccessRestApiServlet.class);
+ serveRegex("^/(?:a/)?access$").with(AccessRestApiServlet.class);
serveRegex("^/(?:a/)?accounts/(.*)$").with(AccountsRestApiServlet.class);
+ serveRegex("^/(?:a/)?accounts$").with(AccountsRestApiServlet.class);
serveRegex("^/(?:a/)?changes/(.*)$").with(ChangesRestApiServlet.class);
+ serveRegex("^/(?:a/)?changes$").with(ChangesRestApiServlet.class);
serveRegex("^/(?:a/)?config/(.*)$").with(ConfigRestApiServlet.class);
+ serveRegex("^/(?:a/)?config$").with(ConfigRestApiServlet.class);
serveRegex("^/(?:a/)?groups/(.*)?$").with(GroupsRestApiServlet.class);
+ serveRegex("^/(?:a/)?groups$").with(GroupsRestApiServlet.class);
serveRegex("^/(?:a/)?projects/(.*)?$").with(ProjectsRestApiServlet.class);
+ serveRegex("^/(?:a/)?projects$").with(ProjectsRestApiServlet.class);
serveRegex("^/Documentation$").with(redirectDocumentation());
serveRegex("^/Documentation/$").with(redirectDocumentation());
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index 87bf3a6451..1137b6529a 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -30,6 +30,7 @@ import static java.util.concurrent.TimeUnit.SECONDS;
import com.google.common.cache.Cache;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
@@ -149,6 +150,7 @@ public class WebSessionManager {
return -1;
}
+ @Nullable
Val get(Key key) {
Val val = self.getIfPresent(key.token);
if (val != null && val.expiresAt <= nowMs()) {
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 2f760f08a2..bc8a01a416 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -17,6 +17,7 @@ package com.google.gerrit.httpd.auth.become;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.registration.DynamicItem;
@@ -185,6 +186,7 @@ class BecomeAnyAccountLoginServlet extends HttpServlet {
return account.map(a -> new AuthResult(a.account().id(), null, false));
}
+ @Nullable
private AuthResult auth(Account.Id account) {
if (account != null) {
return new AuthResult(account, null, false);
@@ -192,6 +194,7 @@ class BecomeAnyAccountLoginServlet extends HttpServlet {
return null;
}
+ @Nullable
private AuthResult byUserName(String userName) {
List<AccountState> accountStates = queryProvider.get().byExternalId(SCHEME_USERNAME, userName);
if (accountStates.isEmpty()) {
@@ -223,6 +226,7 @@ class BecomeAnyAccountLoginServlet extends HttpServlet {
}
}
+ @Nullable
private AuthResult create() throws IOException {
try {
return accountManager.authenticate(
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 5a5de0a135..f0a8b89994 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GER
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.httpd.HtmlDomUtil;
import com.google.gerrit.httpd.RemoteUserUtil;
@@ -143,6 +144,7 @@ class HttpAuthFilter implements Filter {
: remoteUser;
}
+ @Nullable
String getRemoteDisplayname(HttpServletRequest req) {
if (displaynameHeader != null) {
String raw = emptyToNull(req.getHeader(displaynameHeader));
@@ -153,6 +155,7 @@ class HttpAuthFilter implements Filter {
return null;
}
+ @Nullable
String getRemoteEmail(HttpServletRequest req) {
if (emailHeader != null) {
return emptyToNull(req.getHeader(emailHeader));
@@ -160,6 +163,7 @@ class HttpAuthFilter implements Filter {
return null;
}
+ @Nullable
String getRemoteExternalIdToken(HttpServletRequest req) {
if (externalIdHeader != null) {
return emptyToNull(req.getHeader(externalIdHeader));
diff --git a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 0b6008c1a0..e7057ad514 100644
--- a/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -334,6 +334,7 @@ class LoginForm extends HttpServlet {
form.appendChild(div);
}
+ @Nullable
private OAuthServiceProvider lookupOAuthServiceProvider(String providerId) {
if (providerId.startsWith("http://")) {
providerId = providerId.substring("http://".length());
@@ -350,6 +351,7 @@ class LoginForm extends HttpServlet {
return null;
}
+ @Nullable
private static String getLastId(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if (cookies != null) {
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index fcd16ae3c8..0c71d6897b 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -15,6 +15,7 @@
package com.google.gerrit.httpd.auth.openid;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.common.auth.openid.OpenIdUrls;
import com.google.gerrit.entities.Account;
@@ -518,6 +519,7 @@ class OpenIdServiceImpl {
rsp.sendRedirect(rdr.toString());
}
+ @Nullable
private State init(
HttpServletRequest req,
final String openidIdentifier,
diff --git a/java/com/google/gerrit/httpd/auth/restapi/BUILD b/java/com/google/gerrit/httpd/auth/restapi/BUILD
index d499768157..9ab51c571e 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/BUILD
+++ b/java/com/google/gerrit/httpd/auth/restapi/BUILD
@@ -6,6 +6,7 @@ java_library(
visibility = ["//visibility:public"],
deps = [
"//java/com/google/gerrit/auth",
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server",
"//lib/flogger:api",
diff --git a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
index 3594c7c3db..2eee4150f5 100644
--- a/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
+++ b/java/com/google/gerrit/httpd/auth/restapi/GetOAuthToken.java
@@ -16,6 +16,7 @@ package com.google.gerrit.httpd.auth.restapi;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.auth.oauth.OAuthTokenCache;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -70,6 +71,7 @@ public class GetOAuthToken implements RestReadView<AccountResource> {
return Response.ok(accessTokenInfo);
}
+ @Nullable
private static String getHostName(String canonicalWebUrl) {
if (canonicalWebUrl == null) {
logger.atSevere().log(
diff --git a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
index 4c31253c34..d6718ca9d3 100644
--- a/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
+++ b/java/com/google/gerrit/httpd/gitweb/GitwebServlet.java
@@ -79,6 +79,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -111,6 +112,7 @@ class GitwebServlet extends HttpServlet {
private final Provider<CurrentUser> userProvider;
private final EnvList _env;
+ @SuppressWarnings("CheckReturnValue")
@Inject
GitwebServlet(
GitRepositoryManager repoManager,
@@ -158,7 +160,7 @@ class GitwebServlet extends HttpServlet {
if (!_env.envMap.containsKey("SystemRoot")) {
String os = System.getProperty("os.name");
- if (os != null && os.toLowerCase().contains("windows")) {
+ if (os != null && os.toLowerCase(Locale.US).contains("windows")) {
String sysroot = System.getenv("SystemRoot");
if (sysroot == null || sysroot.isEmpty()) {
sysroot = "C:\\WINDOWS";
@@ -575,7 +577,7 @@ class GitwebServlet extends HttpServlet {
for (String name : getHeaderNames(req)) {
final String value = req.getHeader(name);
- env.set("HTTP_" + name.toUpperCase().replace('-', '_'), value);
+ env.set("HTTP_" + name.toUpperCase(Locale.US).replace('-', '_'), value);
}
Project.NameKey nameKey = projectState.getNameKey();
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index dcabac0e53..e3cc0a5ccb 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -46,6 +46,7 @@ import com.google.gerrit.lifecycle.LifecycleModule;
import com.google.gerrit.lucene.LuceneIndexModule;
import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
import com.google.gerrit.server.LibModuleLoader;
import com.google.gerrit.server.LibModuleType;
import com.google.gerrit.server.ModuleOverloader;
@@ -55,6 +56,7 @@ import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccount
import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
import com.google.gerrit.server.api.GerritApiModule;
import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
import com.google.gerrit.server.audit.AuditModule;
import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -308,6 +310,8 @@ public class WebAppInitializer extends GuiceServletContextListener implements Fi
modules.add(new MimeUtil2Module());
modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
modules.add(new GerritApiModule());
+ modules.add(new ProjectQueryBuilderModule());
+ modules.add(new DefaultRefLogIdentityProvider.Module());
modules.add(new PluginApiModule());
modules.add(new ChangesByProjectCache.Module(ChangesByProjectCache.UseIndex.TRUE, config));
modules.add(new InternalAccountDirectoryModule());
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index a03aa36e27..9b8f4c654b 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -35,6 +35,7 @@ import com.google.common.collect.Maps;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.httpd.resources.Resource;
import com.google.gerrit.httpd.resources.ResourceKey;
import com.google.gerrit.httpd.resources.SmallResource;
@@ -174,6 +175,7 @@ class HttpPluginServlet extends HttpServlet implements StartPluginListener, Relo
plugins.put(name, holder);
}
+ @Nullable
private GuiceFilter load(Plugin plugin) {
if (plugin.getHttpInjector() != null) {
final String name = plugin.getName();
@@ -327,6 +329,7 @@ class HttpPluginServlet extends HttpServlet implements StartPluginListener, Relo
}
}
+ @Nullable
private static Pattern makeAllowOrigin(Config cfg) {
String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
if (allow.length > 0) {
@@ -720,6 +723,7 @@ class HttpPluginServlet extends HttpServlet implements StartPluginListener, Relo
this.docPrefix = getPrefix(plugin, "Gerrit-HttpDocumentationPrefix", "Documentation/");
}
+ @Nullable
private static String getPrefix(Plugin plugin, String attr, String def) {
Path path = plugin.getSrcFile();
PluginContentScanner scanner = plugin.getContentScanner();
diff --git a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
index fc0ec39fa5..ed29629123 100644
--- a/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/LfsPluginServlet.java
@@ -19,6 +19,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_NOT_IMPLEMENTED;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.httpd.resources.Resource;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.plugins.Plugin;
@@ -119,6 +120,7 @@ public class LfsPluginServlet extends HttpServlet
filter.set(guiceFilter);
}
+ @Nullable
private GuiceFilter load(Plugin plugin) {
if (plugin.getHttpInjector() != null) {
final String name = plugin.getName();
diff --git a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
index c13286e697..3f59084857 100644
--- a/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/DirectoryDocServlet.java
@@ -15,15 +15,17 @@
package com.google.gerrit.httpd.raw;
import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
import java.nio.file.Path;
-class DirectoryDocServlet extends ResourceServlet {
+class DirectoryDocServlet extends DocServlet {
private static final long serialVersionUID = 1L;
private final Path doc;
- DirectoryDocServlet(Cache<Path, Resource> cache, Path unpackedWar) {
- super(cache, true);
+ DirectoryDocServlet(
+ Cache<Path, Resource> cache, Path unpackedWar, ExperimentFeatures experimentFeatures) {
+ super(cache, true, experimentFeatures);
this.doc = unpackedWar.resolve("Documentation");
}
diff --git a/java/com/google/gerrit/httpd/raw/DocServlet.java b/java/com/google/gerrit/httpd/raw/DocServlet.java
new file mode 100644
index 0000000000..d5027ba4d7
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/DocServlet.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.httpd.HtmlDomUtil;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.Optional;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+abstract class DocServlet extends ResourceServlet {
+ private static final long serialVersionUID = 1L;
+
+ private final ExperimentFeatures experimentFeatures;
+
+ DocServlet(Cache<Path, Resource> cache, boolean refresh, ExperimentFeatures experimentFeatures) {
+ super(cache, refresh);
+ this.experimentFeatures = experimentFeatures;
+ }
+
+ @Override
+ protected boolean shouldProcessResourceBeforeServe(
+ HttpServletRequest req, HttpServletResponse rsp, Path p) {
+ String nonce = (String) req.getAttribute("nonce");
+ if (!experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION)
+ || nonce == null) {
+ return false;
+ }
+ return ResourceServlet.contentType(p.toString()).equals("text/html");
+ }
+
+ @Override
+ protected Resource processResourceBeforeServe(
+ HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+ // ResourceServlet doesn't set character encoding for a resource. Gerrit will
+ // default to setting charset to utf-8, if none provided. So we guess UTF_8 here.
+ Optional<String> updatedHtml =
+ HtmlDomUtil.attachNonce(
+ new String(resource.raw, StandardCharsets.UTF_8), (String) req.getAttribute("nonce"));
+ if (updatedHtml.isEmpty()) {
+ return resource;
+ }
+ return new Resource(
+ resource.lastModified,
+ resource.contentType,
+ updatedHtml.get().getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 72bfe40c3b..fb28d30061 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -101,6 +101,7 @@ public class IndexHtmlUtil {
IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
data.put("changeNum", IndexPreloadingUtil.computeChangeNum(requestedPath, page).get());
break;
+ case PROFILE:
case DASHBOARD:
// Dashboard is preloaded queries are added later when we check user is authenticated.
case PAGE_WITHOUT_PRELOADING:
@@ -121,7 +122,7 @@ public class IndexHtmlUtil {
data.put("userIsAuthenticated", true);
if (page == RequestedPage.DASHBOARD) {
data.put("defaultDashboardHex", ListOption.toHex(IndexPreloadingUtil.DASHBOARD_OPTIONS));
- data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
+ data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList());
}
} catch (AuthException e) {
logger.atFine().log("Can't inline account-related data because user is unauthenticated");
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index 1c6e058024..402e48a99e 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -22,9 +22,7 @@ import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.common.UsedAt.Project;
-import com.google.gerrit.extensions.api.config.Server;
import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.Url;
import java.net.URI;
import java.net.URISyntaxException;
@@ -42,6 +40,7 @@ public class IndexPreloadingUtil {
CHANGE,
DIFF,
DASHBOARD,
+ PROFILE,
PAGE_WITHOUT_PRELOADING,
}
@@ -52,31 +51,28 @@ public class IndexPreloadingUtil {
public static final Pattern DIFF_URL_PATTERN =
Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "(/(.+))" + "/?$");
public static final Pattern DASHBOARD_PATTERN = Pattern.compile("/dashboard/self$");
+ public static final Pattern PROFILE_PATTERN = Pattern.compile("/profile/self$");
public static final String ROOT_PATH = "/";
// These queries should be kept in sync with PolyGerrit:
// polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
public static final String YOUR_TURN = "attention:${user} limit:25";
- public static final String DASHBOARD_ASSIGNED_QUERY =
- "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open limit:25";
public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
"is:open owner:${user} is:wip limit:25";
public static final String DASHBOARD_OUTGOING_QUERY = "is:open owner:${user} -is:wip limit:25";
public static final String DASHBOARD_INCOMING_QUERY =
- "is:open -owner:${user} -is:wip (reviewer:${user} OR assignee:${user}) limit:25";
+ "is:open -owner:${user} -is:wip reviewer:${user} limit:25";
public static final String CC_QUERY = "is:open -is:wip cc:${user} limit:10";
public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
"is:closed (-is:wip OR owner:self) "
- + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
- + "OR cc:${user}) -age:4w limit:10";
+ + "(owner:${user} OR reviewer:${user} OR cc:${user}) "
+ + "-age:4w limit:10";
public static final String NEW_USER = "owner:${user} limit:1";
public static final String SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY =
DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY.replaceAll("\\$\\{user}", "self");
public static final String SELF_YOUR_TURN = YOUR_TURN.replaceAll("\\$\\{user}", "self");
- public static final String SELF_DASHBOARD_ASSIGNED_QUERY =
- DASHBOARD_ASSIGNED_QUERY.replaceAll("\\$\\{user}", "self");
public static final ImmutableList<String> SELF_DASHBOARD_QUERIES =
Stream.of(
DASHBOARD_WORK_IN_PROGRESS_QUERY,
@@ -106,6 +102,7 @@ public class IndexPreloadingUtil {
ListChangesOption.SKIP_DIFFSTAT,
ListChangesOption.SUBMIT_REQUIREMENTS);
+ @Nullable
public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
if (requestedURL == null) {
return null;
@@ -135,6 +132,11 @@ public class IndexPreloadingUtil {
return RequestedPage.DASHBOARD;
}
+ Matcher profileMatcher = IndexPreloadingUtil.PROFILE_PATTERN.matcher(requestedPath);
+ if (profileMatcher.matches()) {
+ return RequestedPage.PROFILE;
+ }
+
if (ROOT_PATH.equals(requestedPath)) {
return RequestedPage.DASHBOARD;
}
@@ -153,6 +155,7 @@ public class IndexPreloadingUtil {
matcher = DIFF_URL_PATTERN.matcher(requestedURL);
break;
case DASHBOARD:
+ case PROFILE:
case PAGE_WITHOUT_PRELOADING:
default:
return Optional.empty();
@@ -177,6 +180,7 @@ public class IndexPreloadingUtil {
matcher = DIFF_URL_PATTERN.matcher(requestedURL);
break;
case DASHBOARD:
+ case PROFILE:
case PAGE_WITHOUT_PRELOADING:
default:
return Optional.empty();
@@ -191,34 +195,14 @@ public class IndexPreloadingUtil {
return Optional.empty();
}
- public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
+ public static List<String> computeDashboardQueryList() {
List<String> queryList = new ArrayList<>();
queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
- if (isEnabledAttentionSet(serverApi)) {
- queryList.add(SELF_YOUR_TURN);
- }
- if (isEnabledAssignee(serverApi)) {
- queryList.add(SELF_DASHBOARD_ASSIGNED_QUERY);
- }
-
+ queryList.add(SELF_YOUR_TURN);
queryList.addAll(SELF_DASHBOARD_QUERIES);
return queryList;
}
- private static boolean isEnabledAttentionSet(Server serverApi) throws RestApiException {
- return serverApi.getInfo() != null
- && serverApi.getInfo().change != null
- && serverApi.getInfo().change.enableAttentionSet != null
- && serverApi.getInfo().change.enableAttentionSet;
- }
-
- private static boolean isEnabledAssignee(Server serverApi) throws RestApiException {
- return serverApi.getInfo() != null
- && serverApi.getInfo().change != null
- && serverApi.getInfo().change.enableAssignee != null
- && serverApi.getInfo().change.enableAssignee;
- }
-
private IndexPreloadingUtil() {}
}
diff --git a/java/com/google/gerrit/httpd/raw/ResourceServlet.java b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
index 8be4abc03a..871ec78b1e 100644
--- a/java/com/google/gerrit/httpd/raw/ResourceServlet.java
+++ b/java/com/google/gerrit/httpd/raw/ResourceServlet.java
@@ -126,6 +126,34 @@ public abstract class ResourceServlet extends HttpServlet {
*/
protected abstract Path getResourcePath(String pathInfo) throws IOException;
+ /**
+ * Indicates that resource requires some processing before being served.
+ *
+ * <p>If true, the caching headers in response are set to not cache. Additionally, streaming
+ * option is disabled.
+ *
+ * @param req the HTTP servlet request
+ * @param rsp the HTTP servlet response
+ * @param p URL path
+ * @return true if the {@link #processResourceBeforeServe(HttpServletRequest, HttpServletResponse,
+ * Resource)} should be called.
+ */
+ protected boolean shouldProcessResourceBeforeServe(
+ HttpServletRequest req, HttpServletResponse rsp, Path p) {
+ return false;
+ }
+
+ /**
+ * Edits the resource before adding it to the response.
+ *
+ * @param req the HTTP servlet request
+ * @param rsp the HTTP servlet response
+ */
+ protected Resource processResourceBeforeServe(
+ HttpServletRequest req, HttpServletResponse rsp, Resource resource) {
+ return resource;
+ }
+
protected FileTime getLastModifiedTime(Path p) throws IOException {
return Files.getLastModifiedTime(p);
}
@@ -148,10 +176,11 @@ public abstract class ResourceServlet extends HttpServlet {
return;
}
+ boolean requiresPostProcess = shouldProcessResourceBeforeServe(req, rsp, p);
Resource r = cache.getIfPresent(p);
try {
if (r == null) {
- if (maybeStream(p, req, rsp)) {
+ if (!requiresPostProcess && maybeStream(p, req, rsp)) {
return; // Bypass cache for large resource.
}
r = cache.get(p, newLoader(p));
@@ -176,11 +205,16 @@ public abstract class ResourceServlet extends HttpServlet {
CacheHeaders.setNotCacheable(rsp);
rsp.setStatus(SC_NOT_FOUND);
return;
- } else if (cacheOnClient && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
+ } else if (!requiresPostProcess
+ && cacheOnClient
+ && r.etag.equals(req.getHeader(IF_NONE_MATCH))) {
rsp.setStatus(SC_NOT_MODIFIED);
return;
}
+ if (requiresPostProcess) {
+ r = processResourceBeforeServe(req, rsp, r);
+ }
byte[] tosend = r.raw;
if (!r.contentType.equals(JS) && RequestUtil.acceptsGzipEncoding(req)) {
byte[] gz = HtmlDomUtil.compress(tosend);
@@ -190,7 +224,7 @@ public abstract class ResourceServlet extends HttpServlet {
}
}
- if (cacheOnClient) {
+ if (!requiresPostProcess && cacheOnClient) {
rsp.setHeader(ETAG, r.etag);
} else {
CacheHeaders.setNotCacheable(rsp);
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 15dcf42e0e..8319d9db21 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -77,9 +77,9 @@ public class StaticModule extends ServletModule {
"/x/*",
"/admin/*",
"/dashboard/*",
+ "/profile/*",
"/groups/self",
"/settings/*",
- "/topic/*",
"/Documentation/q/*");
/**
@@ -144,12 +144,13 @@ public class StaticModule extends ServletModule {
@Provides
@Singleton
@Named(DOC_SERVLET)
- HttpServlet getDocServlet(@Named(CACHE) Cache<Path, Resource> cache) {
+ HttpServlet getDocServlet(
+ @Named(CACHE) Cache<Path, Resource> cache, ExperimentFeatures experimentFeatures) {
Paths p = getPaths();
if (p.warFs != null) {
- return new WarDocServlet(cache, p.warFs);
+ return new WarDocServlet(cache, p.warFs, experimentFeatures);
} else if (p.unpackedWar != null && !p.isDev()) {
- return new DirectoryDocServlet(cache, p.unpackedWar);
+ return new DirectoryDocServlet(cache, p.unpackedWar, experimentFeatures);
} else {
return new HttpServlet() {
private static final long serialVersionUID = 1L;
@@ -305,6 +306,7 @@ public class StaticModule extends ServletModule {
sourceRoot = getSourceRootOrNull();
}
+ @Nullable
private static Path getSourceRootOrNull() {
try {
return GerritLauncher.resolveInSourceRoot(".");
@@ -313,6 +315,7 @@ public class StaticModule extends ServletModule {
}
}
+ @Nullable
private FileSystem getDistributionArchive(File war) throws IOException {
if (war == null) {
return null;
@@ -320,6 +323,7 @@ public class StaticModule extends ServletModule {
return GerritLauncher.getZipFileSystem(war.toPath());
}
+ @Nullable
private File getLauncherLoadedFrom() {
File war;
try {
@@ -441,6 +445,7 @@ public class StaticModule extends ServletModule {
super(req);
}
+ @Nullable
@Override
public String getPathInfo() {
String uri = getRequestURI();
diff --git a/java/com/google/gerrit/httpd/raw/WarDocServlet.java b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
index 27520e3f01..718d46d00e 100644
--- a/java/com/google/gerrit/httpd/raw/WarDocServlet.java
+++ b/java/com/google/gerrit/httpd/raw/WarDocServlet.java
@@ -15,20 +15,22 @@
package com.google.gerrit.httpd.raw;
import com.google.common.cache.Cache;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
import com.google.gerrit.server.util.time.TimeUtil;
import java.nio.file.FileSystem;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
-class WarDocServlet extends ResourceServlet {
+class WarDocServlet extends DocServlet {
private static final long serialVersionUID = 1L;
private static final FileTime NOW = FileTime.fromMillis(TimeUtil.nowMs());
private final FileSystem warFs;
- WarDocServlet(Cache<Path, Resource> cache, FileSystem warFs) {
- super(cache, false);
+ WarDocServlet(
+ Cache<Path, Resource> cache, FileSystem warFs, ExperimentFeatures experimentFeatures) {
+ super(cache, false, experimentFeatures);
this.warFs = warFs;
}
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 543e794adc..44e7854bf9 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,7 +30,6 @@ import static com.google.common.net.HttpHeaders.AUTHORIZATION;
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
import static com.google.common.net.HttpHeaders.ORIGIN;
import static com.google.common.net.HttpHeaders.VARY;
-import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
import static java.math.RoundingMode.CEILING;
import static java.nio.charset.StandardCharsets.ISO_8859_1;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -120,9 +119,7 @@ import com.google.gerrit.server.cancellation.RequestCancelledException;
import com.google.gerrit.server.cancellation.RequestStateContext;
import com.google.gerrit.server.cancellation.RequestStateProvider;
import com.google.gerrit.server.change.ChangeFinder;
-import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.experiments.ExperimentFeatures;
import com.google.gerrit.server.group.GroupAuditService;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -270,7 +267,6 @@ public class RestApiServlet extends HttpServlet {
final PluginSetContext<ExceptionHook> exceptionHooks;
final Injector injector;
final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
- final ExperimentFeatures experimentFeatures;
final DeadlineChecker.Factory deadlineCheckerFactory;
final CancellationMetrics cancellationMetrics;
@@ -291,7 +287,6 @@ public class RestApiServlet extends HttpServlet {
PluginSetContext<ExceptionHook> exceptionHooks,
Injector injector,
DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
- ExperimentFeatures experimentFeatures,
DeadlineChecker.Factory deadlineCheckerFactory,
CancellationMetrics cancellationMetrics) {
this.currentUser = currentUser;
@@ -310,11 +305,11 @@ public class RestApiServlet extends HttpServlet {
allowOrigin = makeAllowOrigin(config);
this.injector = injector;
this.dynamicBeans = dynamicBeans;
- this.experimentFeatures = experimentFeatures;
this.deadlineCheckerFactory = deadlineCheckerFactory;
this.cancellationMetrics = cancellationMetrics;
}
+ @Nullable
private static Pattern makeAllowOrigin(Config cfg) {
String[] allow = cfg.getStringList("site", null, "allowOriginRegex");
if (allow.length > 0) {
@@ -854,17 +849,13 @@ public class RestApiServlet extends HttpServlet {
}
}
+ @Nullable
private String getEtagWithRetry(
HttpServletRequest req, TraceContext traceContext, RestResource.HasETag rsrc) {
try (TraceTimer ignored =
TraceContext.newTimer(
"RestApiServlet#getEtagWithRetry:resource",
Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
- if (rsrc instanceof RevisionResource
- && globals.experimentFeatures.isFeatureEnabled(
- GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
- return null;
- }
return invokeRestEndpointWithRetry(
req,
traceContext,
@@ -1277,6 +1268,7 @@ public class RestApiServlet extends HttpServlet {
return ((ParameterizedType) supertype).getActualTypeArguments()[2];
}
+ @Nullable
private Object parseRequest(HttpServletRequest req, Type type)
throws IOException, BadRequestException, SecurityException, IllegalArgumentException,
NoSuchMethodException, IllegalAccessException, InstantiationException,
@@ -1394,22 +1386,21 @@ public class RestApiServlet extends HttpServlet {
throw new BadRequestException("Expected JSON object");
}
- @SuppressWarnings("unchecked")
private static Object createInstance(Type type)
throws NoSuchMethodException, InstantiationException, IllegalAccessException,
InvocationTargetException {
if (type instanceof Class) {
- Class<Object> clazz = (Class<Object>) type;
- Constructor<Object> c = clazz.getDeclaredConstructor();
+ Class<?> clazz = (Class<?>) type;
+ Constructor<?> c = clazz.getDeclaredConstructor();
c.setAccessible(true);
return c.newInstance();
}
if (type instanceof ParameterizedType) {
Type rawType = ((ParameterizedType) type).getRawType();
- if (rawType instanceof Class && List.class.isAssignableFrom((Class<Object>) rawType)) {
+ if (rawType instanceof Class && List.class.isAssignableFrom((Class<?>) rawType)) {
return new ArrayList<>();
}
- if (rawType instanceof Class && Map.class.isAssignableFrom((Class<Object>) rawType)) {
+ if (rawType instanceof Class && Map.class.isAssignableFrom((Class<?>) rawType)) {
return new HashMap<>();
}
}
diff --git a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
index 655f4ca0e5..2065a315e5 100644
--- a/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
+++ b/java/com/google/gerrit/httpd/template/SiteHeaderFooter.java
@@ -18,6 +18,7 @@ import static com.google.gerrit.common.FileUtil.lastModified;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.httpd.HtmlDomUtil;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
@@ -125,6 +126,7 @@ public class SiteHeaderFooter {
return cssFile.isStale() || headerFile.isStale() || footerFile.isStale();
}
+ @Nullable
private static Element readXml(FileInfo src) throws IOException {
Document d = HtmlDomUtil.parseFile(src.path);
return d != null ? d.getDocumentElement() : null;
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
deleted file mode 100644
index 1c2074b7dd..0000000000
--- a/java/com/google/gerrit/index/FieldDef.java
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.SchemaFieldDefs.Getter;
-import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
-import com.google.gerrit.index.SchemaFieldDefs.Setter;
-import java.io.IOException;
-import java.sql.Timestamp;
-import java.util.Optional;
-
-/**
- * Definition of a field stored in the secondary index.
- *
- * <p>{@link FieldDef}-s must not be changed once introduced to the codebase. Instead, a new
- * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see {@code
- * com.google.gerrit.index.IndexUpgradeValidator}).
- *
- * <p>Note that {@link FieldDef} does not override {@link Object#equals(Object)}. It relies on
- * instances being singletons so that the default (i.e. reference) comparison works.
- *
- * @param <I> input type from which documents are created and search results are returned.
- * @param <T> type that should be extracted from the input object when converting to an index
- * document.
- */
-public final class FieldDef<I, T> implements SchemaField<I, T> {
- public static FieldDef.Builder<String> exact(String name) {
- return new FieldDef.Builder<>(FieldType.EXACT, name);
- }
-
- public static FieldDef.Builder<String> fullText(String name) {
- return new FieldDef.Builder<>(FieldType.FULL_TEXT, name);
- }
-
- public static FieldDef.Builder<Integer> intRange(String name) {
- return new FieldDef.Builder<>(FieldType.INTEGER_RANGE, name).stored();
- }
-
- public static FieldDef.Builder<Integer> integer(String name) {
- return new FieldDef.Builder<>(FieldType.INTEGER, name);
- }
-
- public static FieldDef.Builder<String> prefix(String name) {
- return new FieldDef.Builder<>(FieldType.PREFIX, name);
- }
-
- public static FieldDef.Builder<byte[]> storedOnly(String name) {
- return new FieldDef.Builder<>(FieldType.STORED_ONLY, name).stored();
- }
-
- public static FieldDef.Builder<Timestamp> timestamp(String name) {
- return new FieldDef.Builder<>(FieldType.TIMESTAMP, name);
- }
-
- public static class Builder<T> {
- private final FieldType<T> type;
- private final String name;
- private boolean stored;
-
- public Builder(FieldType<T> type, String name) {
- this.type = requireNonNull(type);
- this.name = requireNonNull(name);
- }
-
- public Builder<T> stored() {
- this.stored = true;
- return this;
- }
-
- public <I> FieldDef<I, T> build(Getter<I, T> getter) {
- return new FieldDef<>(name, type, stored, false, getter, null);
- }
-
- public <I> FieldDef<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
- return new FieldDef<>(name, type, stored, false, getter, setter);
- }
-
- public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
- return new FieldDef<>(name, type, stored, true, getter, null);
- }
-
- public <I> FieldDef<I, Iterable<T>> buildRepeatable(
- Getter<I, Iterable<T>> getter, Setter<I, Iterable<T>> setter) {
- return new FieldDef<>(name, type, stored, true, getter, setter);
- }
- }
-
- private final String name;
- private final FieldType<?> type;
- /** Allow reading the actual data from the index. */
- private final boolean stored;
-
- private final boolean repeatable;
- private final Getter<I, T> getter;
- private final Optional<Setter<I, T>> setter;
-
- private FieldDef(
- String name,
- FieldType<?> type,
- boolean stored,
- boolean repeatable,
- Getter<I, T> getter,
- @Nullable Setter<I, T> setter) {
- checkArgument(
- !(repeatable && type == FieldType.INTEGER_RANGE),
- "Range queries against repeated fields are unsupported");
- this.name = checkName(name);
- this.type = requireNonNull(type);
- this.stored = stored;
- this.repeatable = repeatable;
- this.getter = requireNonNull(getter);
- this.setter = Optional.ofNullable(setter);
- }
-
- private static String checkName(String name) {
- CharMatcher m = CharMatcher.anyOf("abcdefghijklmnopqrstuvwxyz0123456789_");
- checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
- return name;
- }
-
- /** Returns name of the field. */
- @Override
- public String getName() {
- return name;
- }
-
- /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
- @Override
- public FieldType<?> getType() {
- return type;
- }
-
- /** Returns whether the field should be stored in the index. */
- @Override
- public boolean isStored() {
- return stored;
- }
-
- /**
- * Get the field contents from the input object.
- *
- * @param input input object.
- * @return the field value(s) to index.
- */
- @Override
- @Nullable
- public T get(I input) {
- try {
- return getter.get(input);
- } catch (IOException e) {
- throw new StorageException(e);
- }
- }
-
- /**
- * Set the field contents back to an object. Used to reconstruct fields from indexed values. No-op
- * if the field can't be reconstructed.
- *
- * @param object input object.
- * @param doc indexed document
- * @return {@code true} if the field was set, {@code false} otherwise
- */
- @SuppressWarnings("unchecked")
- @Override
- public boolean setIfPossible(I object, StoredValue doc) {
- if (!setter.isPresent()) {
- return false;
- }
-
- if (FieldType.STRING_TYPES.stream().anyMatch(t -> t.getName().equals(getType().getName()))) {
- setter.get().set(object, (T) (isRepeatable() ? doc.asStrings() : doc.asString()));
- return true;
- } else if (FieldType.INTEGER_TYPES.stream()
- .anyMatch(t -> t.getName().equals(getType().getName()))) {
- setter.get().set(object, (T) (isRepeatable() ? doc.asIntegers() : doc.asInteger()));
- return true;
- } else if (FieldType.LONG.getName().equals(getType().getName())) {
- setter.get().set(object, (T) (isRepeatable() ? doc.asLongs() : doc.asLong()));
- return true;
- } else if (FieldType.STORED_ONLY.getName().equals(getType().getName())) {
- setter.get().set(object, (T) (isRepeatable() ? doc.asByteArrays() : doc.asByteArray()));
- return true;
- } else if (FieldType.TIMESTAMP.getName().equals(getType().getName())) {
- checkState(!isRepeatable(), "can't repeat timestamp values");
- setter.get().set(object, (T) doc.asTimestamp());
- return true;
- }
- return false;
- }
-
- /** Returns whether the field is repeatable. */
- @Override
- public boolean isRepeatable() {
- return repeatable;
- }
-}
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index cc3117d8bd..870d827132 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -60,6 +60,9 @@ public interface Index<K, V> {
*/
void replace(V obj);
+ /** Delete a document from the index by value */
+ void deleteByValue(V value);
+
/**
* Delete a document from the index by key.
*
@@ -153,4 +156,14 @@ public interface Index<K, V> {
default boolean isEnabled() {
return true;
}
+
+ /**
+ * Rewriter that should be invoked on queries to this index.
+ *
+ * <p>The default implementation does not do anything. Should be overridden by implementation, if
+ * needed.
+ */
+ default IndexRewriter<V> getIndexRewriter() {
+ return (in, opts) -> in;
+ }
}
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index 75f83516f6..d8c8f6a339 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
+import java.util.Locale;
import java.util.Optional;
/**
@@ -51,7 +52,7 @@ public class IndexType {
if (Strings.isNullOrEmpty(value)) {
return Optional.empty();
}
- value = value.toUpperCase().replace("-", "_");
+ value = value.toUpperCase(Locale.US).replace("-", "_");
IndexType type = new IndexType(value);
if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
checkArgument(
@@ -67,7 +68,7 @@ public class IndexType {
}
public IndexType(@Nullable String type) {
- this.type = type == null ? getDefault() : type.toLowerCase();
+ this.type = type == null ? getDefault() : type.toLowerCase(Locale.US);
}
public static String getDefault() {
diff --git a/java/com/google/gerrit/index/IndexedField.java b/java/com/google/gerrit/index/IndexedField.java
index e44f562f5e..94943d6ca9 100644
--- a/java/com/google/gerrit/index/IndexedField.java
+++ b/java/com/google/gerrit/index/IndexedField.java
@@ -36,6 +36,7 @@ import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.sql.Timestamp;
import java.util.HashMap;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.stream.StreamSupport;
@@ -118,7 +119,7 @@ public abstract class IndexedField<I, T> {
/**
* Defines how {@link IndexedField} can be searched and how the index tokens are generated.
*
- * <p>Multiple {@link SearchSpec} can be defined on single {@link IndexedField}.
+ * <p>Multiple {@link SearchSpec} can be defined on a single {@link IndexedField}.
*
* <p>Depending on the implementation, indexes can choose to store {@link IndexedField} and {@link
* SearchSpec} separately. The searches are issues to {@link SearchSpec}.
@@ -249,6 +250,8 @@ public abstract class IndexedField<I, T> {
public SearchSpec integerRange(String name) {
checkState(fieldType().equals(INTEGER_TYPE));
+ // we currently store all integer range fields, this may change in the future
+ checkState(stored());
return addSearchSpec(name, SearchOption.RANGE);
}
@@ -349,7 +352,8 @@ public abstract class IndexedField<I, T> {
private static String checkName(String name) {
String allowedCharacters = "abcdefghijklmnopqrstuvwxyz0123456789_";
- CharMatcher m = CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase());
+ CharMatcher m =
+ CharMatcher.anyOf(allowedCharacters + allowedCharacters.toUpperCase(Locale.US));
checkArgument(name != null && m.matchesAllOf(name), "illegal field name: %s", name);
return name;
}
@@ -357,13 +361,22 @@ public abstract class IndexedField<I, T> {
private Map<String, SearchSpec> searchSpecs = new HashMap<>();
- /** The name to store this field under. */
+ /**
+ * The name to store this field under.
+ *
+ * <p>The name should use the UpperCamelCase format, see {@link Builder#checkName}.
+ */
public abstract String name();
/** Optional description of the field data. */
public abstract Optional<String> description();
- /** True if this field is mandatory. Default is false. */
+ /**
+ * True if this field is mandatory. Default is false.
+ *
+ * <p>This property is not enforced by the common indexing logic. It is up to the index
+ * implementations to enforce that the field is required.
+ */
public abstract boolean required();
/** Allow reading the actual data from the index. Default is false. */
@@ -375,6 +388,11 @@ public abstract class IndexedField<I, T> {
/**
* Optional size constrain on the field. The size is not constrained if this property is {@link
* Optional#empty()}
+ *
+ * <p>This property is not enforced by the common indexing logic. It is up to the index
+ * implementations to enforce the size.
+ *
+ * <p>If the field is {@link #repeatable()}, the constraint applies to each element separately.
*/
public abstract Optional<Integer> size();
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 403be35455..893f12d40a 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -63,22 +63,6 @@ public class Schema<T> {
}
@SafeVarargs
- public final Builder<T> add(FieldDef<T, ?>... fields) {
- return add(ImmutableList.copyOf(fields));
- }
-
- public final Builder<T> add(ImmutableList<FieldDef<T, ?>> fields) {
- this.searchFields.addAll(fields);
- return this;
- }
-
- @SafeVarargs
- public final Builder<T> remove(FieldDef<T, ?>... fields) {
- this.searchFields.removeAll(Arrays.asList(fields));
- return this;
- }
-
- @SafeVarargs
public final Builder<T> addSearchSpecs(IndexedField<T, ?>.SearchSpec... searchSpecs) {
return addSearchSpecs(ImmutableList.copyOf(searchSpecs));
}
@@ -202,14 +186,17 @@ public class Schema<T> {
* @return all fields in this schema indexed by name.
*/
public final ImmutableMap<String, SchemaField<T, ?>> getSchemaFields() {
- return ImmutableMap.copyOf(schemaFields);
+ return schemaFields;
}
public final ImmutableMap<String, IndexedField<T, ?>> getIndexFields() {
return indexedFields;
}
- /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
+ /**
+ * Returns names of {@link SchemaField} fields in this schema where {@link SchemaField#isStored()}
+ * is true.
+ */
public final ImmutableSet<String> getStoredFields() {
return storedFields;
}
diff --git a/java/com/google/gerrit/index/SchemaFieldDefs.java b/java/com/google/gerrit/index/SchemaFieldDefs.java
index e0b5dd2bf6..36173c7558 100644
--- a/java/com/google/gerrit/index/SchemaFieldDefs.java
+++ b/java/com/google/gerrit/index/SchemaFieldDefs.java
@@ -24,8 +24,8 @@ public class SchemaFieldDefs {
* Definition of a field stored in the secondary index.
*
* <p>{@link SchemaField}-s must not be changed once introduced to the codebase. Instead, a new
- * FieldDef must be added and the old one removed from the schema (in two upgrade steps, see
- * {@code com.google.gerrit.index.IndexUpgradeValidator}).
+ * {@link SchemaField} must be added and the old one removed from the schema (in two upgrade
+ * steps, see {@code com.google.gerrit.index.IndexUpgradeValidator}).
*
* @param <I> input type from which documents are created and search results are returned.
* @param <T> type that should be extracted from the input object when converting to an index
@@ -100,4 +100,12 @@ public class SchemaFieldDefs {
public interface Setter<I, T> {
void set(I object, T value);
}
+
+ public static boolean isProtoField(SchemaField<?, ?> schemaField) {
+ if (!(schemaField instanceof IndexedField<?, ?>.SearchSpec)) {
+ return false;
+ }
+ IndexedField<?, ?> indexedField = ((IndexedField<?, ?>.SearchSpec) schemaField).getField();
+ return indexedField.isProtoType() || indexedField.isProtoIterableType();
+ }
}
diff --git a/java/com/google/gerrit/index/SchemaUtil.java b/java/com/google/gerrit/index/SchemaUtil.java
index 8f47cf5c91..b358c63282 100644
--- a/java/com/google/gerrit/index/SchemaUtil.java
+++ b/java/com/google/gerrit/index/SchemaUtil.java
@@ -70,33 +70,16 @@ public class SchemaUtil {
return ImmutableSortedMap.copyOf(schemas);
}
- @SafeVarargs
- public static <V> Schema<V> schema(FieldDef<V, ?>... fields) {
- return new Schema.Builder<V>().version(0).add(fields).build();
- }
-
- @SafeVarargs
- public static <V> Schema<V> schema(int version, FieldDef<V, ?>... fields) {
- return new Schema.Builder<V>().version(version).add(fields).build();
- }
-
- public static <V> Schema<V> schema(int version, ImmutableList<FieldDef<V, ?>> fields) {
- return new Schema.Builder<V>().version(version).add(fields).build();
- }
-
- @SafeVarargs
- public static <V> Schema<V> schema(Schema<V> schema, FieldDef<V, ?>... moreFields) {
- return new Schema.Builder<V>().add(schema).add(moreFields).build();
+ public static <V> Schema<V> schema(int version) {
+ return new Schema.Builder<V>().version(version).build();
}
public static <V> Schema<V> schema(
int version,
- ImmutableList<FieldDef<V, ?>> fieldDefs,
ImmutableList<IndexedField<V, ?>> indexedFields,
ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
return new Schema.Builder<V>()
.version(version)
- .add(fieldDefs)
.addIndexedFields(indexedFields)
.addSearchSpecs(searchSpecs)
.build();
@@ -104,22 +87,23 @@ public class SchemaUtil {
public static <V> Schema<V> schema(
Schema<V> schema,
- ImmutableList<FieldDef<V, ?>> fieldDefs,
ImmutableList<IndexedField<V, ?>> indexedFields,
ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
return new Schema.Builder<V>()
.add(schema)
- .add(fieldDefs)
.addIndexedFields(indexedFields)
.addSearchSpecs(searchSpecs)
.build();
}
+ public static <V> Schema<V> schema(Schema<V> schema) {
+ return new Schema.Builder<V>().add(schema).build();
+ }
+
public static <V> Schema<V> schema(
- ImmutableList<FieldDef<V, ?>> fieldDefs,
ImmutableList<IndexedField<V, ?>> indexFields,
ImmutableList<IndexedField<V, ?>.SearchSpec> searchSpecs) {
- return schema(/* version= */ 0, fieldDefs, indexFields, searchSpecs);
+ return schema(/* version= */ 0, indexFields, searchSpecs);
}
public static Set<String> getPersonParts(PersonIdent person) {
diff --git a/java/com/google/gerrit/index/project/ProjectField.java b/java/com/google/gerrit/index/project/ProjectField.java
index 3114b4c16f..ff555465b2 100644
--- a/java/com/google/gerrit/index/project/ProjectField.java
+++ b/java/com/google/gerrit/index/project/ProjectField.java
@@ -15,14 +15,10 @@
package com.google.gerrit.index.project;
import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.RefState;
import com.google.gerrit.index.SchemaUtil;
@@ -38,23 +34,53 @@ public class ProjectField {
.toByteArray(project.getNameKey());
}
- public static final FieldDef<ProjectData, String> NAME =
- exact("name").stored().build(p -> p.getProject().getName());
+ public static final IndexedField<ProjectData, String> NAME_FIELD =
+ IndexedField.<ProjectData>stringBuilder("RepoName")
+ .required()
+ .size(200)
+ .stored()
+ .build(p -> p.getProject().getName());
- public static final FieldDef<ProjectData, String> DESCRIPTION =
- fullText("description").stored().build(p -> p.getProject().getDescription());
+ public static final IndexedField<ProjectData, String>.SearchSpec NAME_SPEC =
+ NAME_FIELD.exact("name");
- public static final FieldDef<ProjectData, String> PARENT_NAME =
- exact("parent_name").build(p -> p.getProject().getParentName());
+ public static final IndexedField<ProjectData, String> DESCRIPTION_FIELD =
+ IndexedField.<ProjectData>stringBuilder("Description")
+ .stored()
+ .build(p -> p.getProject().getDescription());
- public static final FieldDef<ProjectData, Iterable<String>> NAME_PART =
- prefix("name_part").buildRepeatable(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+ public static final IndexedField<ProjectData, String>.SearchSpec DESCRIPTION_SPEC =
+ DESCRIPTION_FIELD.fullText("description");
- public static final FieldDef<ProjectData, String> STATE =
- exact("state").stored().build(p -> p.getProject().getState().name());
+ public static final IndexedField<ProjectData, String> PARENT_NAME_FIELD =
+ IndexedField.<ProjectData>stringBuilder("ParentName")
+ .build(p -> p.getProject().getParentName());
- public static final FieldDef<ProjectData, Iterable<String>> ANCESTOR_NAME =
- exact("ancestor_name").buildRepeatable(ProjectData::getParentNames);
+ public static final IndexedField<ProjectData, String>.SearchSpec PARENT_NAME_SPEC =
+ PARENT_NAME_FIELD.exact("parent_name");
+
+ public static final IndexedField<ProjectData, Iterable<String>> NAME_PART_FIELD =
+ IndexedField.<ProjectData>iterableStringBuilder("NamePart")
+ .size(200)
+ .build(p -> SchemaUtil.getNameParts(p.getProject().getName()));
+
+ public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec NAME_PART_SPEC =
+ NAME_PART_FIELD.prefix("name_part");
+
+ public static final IndexedField<ProjectData, String> STATE_FIELD =
+ IndexedField.<ProjectData>stringBuilder("State")
+ .stored()
+ .build(p -> p.getProject().getState().name());
+
+ public static final IndexedField<ProjectData, String>.SearchSpec STATE_SPEC =
+ STATE_FIELD.exact("state");
+
+ public static final IndexedField<ProjectData, Iterable<String>> ANCESTOR_NAME_FIELD =
+ IndexedField.<ProjectData>iterableStringBuilder("AncestorName")
+ .build(ProjectData::getParentNames);
+
+ public static final IndexedField<ProjectData, Iterable<String>>.SearchSpec ANCESTOR_NAME_SPEC =
+ ANCESTOR_NAME_FIELD.exact("ancestor_name");
/**
* All values of all refs that were used in the course of indexing this document. This covers
@@ -62,12 +88,17 @@ public class ProjectField {
*
* <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
*/
- public static final FieldDef<ProjectData, Iterable<byte[]>> REF_STATE =
- storedOnly("ref_state")
- .buildRepeatable(
+ public static final IndexedField<ProjectData, Iterable<byte[]>> REF_STATE_FIELD =
+ IndexedField.<ProjectData>iterableByteArrayBuilder("RefState")
+ .stored()
+ .required()
+ .build(
projectData ->
projectData.tree().stream()
.filter(p -> p.getProject().getConfigRefState() != null)
.map(p -> toRefState(p.getProject()))
.collect(toImmutableList()));
+
+ public static final IndexedField<ProjectData, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+ REF_STATE_FIELD.storedOnly("ref_state");
}
diff --git a/java/com/google/gerrit/index/project/ProjectIndex.java b/java/com/google/gerrit/index/project/ProjectIndex.java
index b2ddaff269..0aa7393e02 100644
--- a/java/com/google/gerrit/index/project/ProjectIndex.java
+++ b/java/com/google/gerrit/index/project/ProjectIndex.java
@@ -18,6 +18,7 @@ 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;
+import java.util.function.Function;
/**
* Index for Gerrit projects (repositories). This class is mainly used for typing the generic parent
@@ -30,6 +31,8 @@ public interface ProjectIndex extends Index<Project.NameKey, ProjectData> {
@Override
default Predicate<ProjectData> keyPredicate(Project.NameKey nameKey) {
- return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+ return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
}
+
+ Function<ProjectData, Project.NameKey> ENTITY_TO_KEY = (p) -> p.getProject().getNameKey();
}
diff --git a/java/com/google/gerrit/index/project/ProjectPredicate.java b/java/com/google/gerrit/index/project/ProjectPredicate.java
index 11875ef502..0eaf2b68d0 100644
--- a/java/com/google/gerrit/index/project/ProjectPredicate.java
+++ b/java/com/google/gerrit/index/project/ProjectPredicate.java
@@ -14,12 +14,12 @@
package com.google.gerrit.index.project;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.IndexPredicate;
/** Predicate that is mapped to a field in the project index. */
public class ProjectPredicate extends IndexPredicate<ProjectData> {
- public ProjectPredicate(FieldDef<ProjectData, ?> def, String value) {
+ public ProjectPredicate(SchemaField<ProjectData, ?> def, String value) {
super(def, value);
}
}
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 061956676d..3ac594e455 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -16,6 +16,8 @@ package com.google.gerrit.index.project;
import static com.google.gerrit.index.SchemaUtil.schema;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.Schema;
import com.google.gerrit.index.SchemaDefinitions;
@@ -31,14 +33,27 @@ public class ProjectSchemaDefinitions extends SchemaDefinitions<ProjectData> {
static final Schema<ProjectData> V1 =
schema(
/* version= */ 1,
- ProjectField.NAME,
- ProjectField.DESCRIPTION,
- ProjectField.PARENT_NAME,
- ProjectField.NAME_PART,
- ProjectField.ANCESTOR_NAME);
+ ImmutableList.of(
+ ProjectField.NAME_FIELD,
+ ProjectField.DESCRIPTION_FIELD,
+ ProjectField.PARENT_NAME_FIELD,
+ ProjectField.NAME_PART_FIELD,
+ ProjectField.ANCESTOR_NAME_FIELD),
+ ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+ ProjectField.NAME_SPEC,
+ ProjectField.DESCRIPTION_SPEC,
+ ProjectField.PARENT_NAME_SPEC,
+ ProjectField.NAME_PART_SPEC,
+ ProjectField.ANCESTOR_NAME_SPEC));
@Deprecated
- static final Schema<ProjectData> V2 = schema(V1, ProjectField.STATE, ProjectField.REF_STATE);
+ static final Schema<ProjectData> V2 =
+ schema(
+ V1,
+ ImmutableList.<IndexedField<ProjectData, ?>>of(
+ ProjectField.STATE_FIELD, ProjectField.REF_STATE_FIELD),
+ ImmutableList.<IndexedField<ProjectData, ?>.SearchSpec>of(
+ ProjectField.STATE_SPEC, ProjectField.REF_STATE_SPEC));
// Bump Lucene version requires reindexing
@Deprecated static final Schema<ProjectData> V3 = schema(V2);
diff --git a/java/com/google/gerrit/index/query/AndPredicate.java b/java/com/google/gerrit/index/query/AndPredicate.java
index 23ae312162..fda961d49e 100644
--- a/java/com/google/gerrit/index/query/AndPredicate.java
+++ b/java/com/google/gerrit/index/query/AndPredicate.java
@@ -134,7 +134,7 @@ public class AndPredicate<T> extends Predicate<T>
cmp = a.estimateCost() - b.estimateCost();
}
- if (cmp == 0 && a instanceof DataSource && b instanceof DataSource) {
+ if (cmp == 0 && a instanceof DataSource) {
DataSource<?> as = (DataSource<?>) a;
DataSource<?> bs = (DataSource<?>) b;
cmp = as.getCardinality() - bs.getCardinality();
diff --git a/java/com/google/gerrit/index/query/AndSource.java b/java/com/google/gerrit/index/query/AndSource.java
index f4c14644e8..3adf881310 100644
--- a/java/com/google/gerrit/index/query/AndSource.java
+++ b/java/com/google/gerrit/index/query/AndSource.java
@@ -92,7 +92,7 @@ public class AndSource<T> extends AndPredicate<T> implements DataSource<T> {
@SuppressWarnings("unchecked")
private PaginatingSource<T> toPaginatingSource(Predicate<T> pred) {
- return new PaginatingSource<T>((DataSource<T>) pred, start, indexConfig) {
+ return new PaginatingSource<>((DataSource<T>) pred, start, indexConfig) {
@Override
protected boolean match(T object) {
return AndSource.this.match(object);
diff --git a/java/com/google/gerrit/index/query/FieldBundle.java b/java/com/google/gerrit/index/query/FieldBundle.java
index 60881dfdec..551de92c86 100644
--- a/java/com/google/gerrit/index/query/FieldBundle.java
+++ b/java/com/google/gerrit/index/query/FieldBundle.java
@@ -16,9 +16,12 @@ package com.google.gerrit.index.query;
import static com.google.common.base.Preconditions.checkArgument;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.IndexedField;
+import com.google.gerrit.index.IndexedField.SearchSpec;
import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
/** FieldBundle is an abstraction that allows retrieval of raw values from different sources. */
@@ -27,8 +30,22 @@ public class FieldBundle {
// Map String => List{Integer, Long, Timestamp, String, byte[]}
private ImmutableListMultimap<String, Object> fields;
- public FieldBundle(ListMultimap<String, Object> fields) {
+ /**
+ * Depending on the index implementation 1) either {@link IndexedField} are stored once and
+ * referenced by {@link com.google.gerrit.index.IndexedField.SearchSpec} on the queries, 2) or
+ * each {@link com.google.gerrit.index.IndexedField.SearchSpec} is stored individually.
+ *
+ * <p>In case #1 {@link #storesIndexedFields} is set to {@code true} and the {@link #fields}
+ * contain a map from {@link IndexedField#name()} to a stored value.
+ *
+ * <p>In case #2 {@link #storesIndexedFields} is set to {@code false} and the {@link #fields}
+ * contain a map from {@link SearchSpec#name()} to a stored value.
+ */
+ private final boolean storesIndexedFields;
+
+ public FieldBundle(ListMultimap<String, Object> fields, boolean storesIndexedFields) {
this.fields = ImmutableListMultimap.copyOf(fields);
+ this.storesIndexedFields = storesIndexedFields;
}
/**
@@ -46,13 +63,17 @@ public class FieldBundle {
@SuppressWarnings("unchecked")
public <T> T getValue(SchemaField<?, T> schemaField) {
checkArgument(schemaField.isStored(), "Field must be stored");
+ String storedFieldName =
+ storesIndexedFields && schemaField instanceof IndexedField<?, ?>.SearchSpec
+ ? ((IndexedField<?, ?>.SearchSpec) schemaField).getField().name()
+ : schemaField.getName();
checkArgument(
- fields.containsKey(schemaField.getName()) || schemaField.isRepeatable(),
+ fields.containsKey(storedFieldName) || schemaField.isRepeatable(),
"Field %s is not in result set %s",
- schemaField.getName(),
+ storedFieldName,
fields.keySet());
- Iterable<Object> result = fields.get(schemaField.getName());
+ ImmutableList<Object> result = fields.get(storedFieldName);
if (schemaField.isRepeatable()) {
return (T) result;
}
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index de81c47fd0..0bde6405b5 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -23,6 +23,7 @@ import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
import com.google.gerrit.index.FieldType;
import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
+import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.stream.StreamSupport;
@@ -117,7 +118,8 @@ public abstract class IndexPredicate<I> extends OperatorPredicate<I> implements
}
private static ImmutableSet<String> tokenizeString(String value) {
- return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
+ return StreamSupport.stream(
+ FULL_TEXT_SPLITTER.split(value.toLowerCase(Locale.US)).spliterator(), false)
.filter(s -> !s.trim().isEmpty())
.collect(toImmutableSet());
}
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 850c4a5e16..278d2afbf5 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -14,13 +14,13 @@
package com.google.gerrit.index.query;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.RangeUtil.Range;
public abstract class IntegerRangePredicate<T> extends IndexPredicate<T> {
private final Range range;
- protected IntegerRangePredicate(FieldDef<T, Integer> type, String value)
+ protected IntegerRangePredicate(SchemaField<T, Integer> type, String value)
throws QueryParseException {
super(type, value);
range = RangeUtil.getRange(value, Integer.MIN_VALUE, Integer.MAX_VALUE);
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 5c003bc6d9..b6418a90f0 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -20,12 +20,13 @@ import static java.util.stream.Collectors.toSet;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.Index;
import com.google.gerrit.index.IndexCollection;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;
@@ -76,10 +77,10 @@ public class InternalQuery<T, Q extends InternalQuery<T, Q>> {
}
@SafeVarargs
- public final Q setRequestedFields(FieldDef<T, ?>... fields) {
+ public final Q setRequestedFields(SchemaField<T, ?>... fields) {
checkArgument(fields.length > 0, "requested field list is empty");
queryProcessor.setRequestedFields(
- Arrays.stream(fields).map(FieldDef::getName).collect(toSet()));
+ Arrays.stream(fields).map(SchemaField::getName).collect(toSet()));
return self();
}
@@ -118,6 +119,7 @@ public class InternalQuery<T, Q extends InternalQuery<T, Q>> {
}
}
+ @Nullable
protected final Schema<T> schema() {
Index<?, T> index = indexes != null ? indexes.getSearchIndex() : null;
return index != null ? index.getSchema() : null;
diff --git a/java/com/google/gerrit/index/query/LimitPredicate.java b/java/com/google/gerrit/index/query/LimitPredicate.java
index 23e0f6dbd0..9196811386 100644
--- a/java/com/google/gerrit/index/query/LimitPredicate.java
+++ b/java/com/google/gerrit/index/query/LimitPredicate.java
@@ -14,8 +14,11 @@
package com.google.gerrit.index.query;
+import com.google.gerrit.common.Nullable;
+
public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
@SuppressWarnings("unchecked")
+ @Nullable
public static Integer getLimit(String fieldName, Predicate<?> p) {
IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
return ip != null ? ip.intValue() : null;
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index 03d749ac14..98a0ed33f8 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -113,7 +113,7 @@ public class PaginatingSource<T> implements DataSource<T> {
@Override
public ResultSet<FieldBundle> readRaw() {
- // TOOD(hiesel): Implement
+ // TODO(hiesel): Implement
throw new UnsupportedOperationException("not implemented");
}
@@ -122,6 +122,12 @@ public class PaginatingSource<T> implements DataSource<T> {
.transformAndConcat(this::transformBuffer);
}
+ /**
+ * Checks whether the given object matches.
+ *
+ * @param object the object to be matched
+ * @return whether the given object matches
+ */
protected boolean match(T object) {
return true;
}
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index c27b7c42d5..1f8266a3bc 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -273,7 +273,9 @@ public abstract class QueryProcessor<T> {
Ints.saturatedCast((long) limit + 1),
getRequestedFields());
logger.atFine().log("Query options: %s", opts);
- Predicate<T> pred = rewriter.rewrite(q, opts);
+ // Apply index-specific rewrite first
+ Predicate<T> pred = indexes.getSearchIndex().getIndexRewriter().rewrite(q, opts);
+ pred = rewriter.rewrite(pred, opts);
if (enforceVisibility) {
pred = enforceVisibility(pred);
}
@@ -287,7 +289,7 @@ public abstract class QueryProcessor<T> {
@SuppressWarnings("unchecked")
DataSource<T> s = (DataSource<T>) pred;
if (initialPageSize < limit && !(pred instanceof AndSource)) {
- s = new PaginatingSource<T>(s, start, indexConfig);
+ s = new PaginatingSource<>(s, start, indexConfig);
}
sources.add(s);
}
diff --git a/java/com/google/gerrit/index/query/RegexPredicate.java b/java/com/google/gerrit/index/query/RegexPredicate.java
index 60a2a9ea91..4c767706be 100644
--- a/java/com/google/gerrit/index/query/RegexPredicate.java
+++ b/java/com/google/gerrit/index/query/RegexPredicate.java
@@ -14,14 +14,14 @@
package com.google.gerrit.index.query;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
public abstract class RegexPredicate<I> extends IndexPredicate<I> {
- protected RegexPredicate(FieldDef<I, ?> def, String value) {
+ protected RegexPredicate(SchemaField<I, ?> def, String value) {
super(def, value);
}
- protected RegexPredicate(FieldDef<I, ?> def, String name, String value) {
+ protected RegexPredicate(SchemaField<I, ?> def, String name, String value) {
super(def, name, value);
}
}
diff --git a/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
index 29d6f228f6..1fd81a6a33 100644
--- a/java/com/google/gerrit/index/query/TimestampRangePredicate.java
+++ b/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.index.query;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.json.JavaSqlTimestampHelper;
import java.sql.Timestamp;
import java.time.Instant;
@@ -30,7 +30,7 @@ public abstract class TimestampRangePredicate<I> extends IndexPredicate<I> {
}
}
- protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
+ protected TimestampRangePredicate(SchemaField<I, Timestamp> def, String name, String value) {
super(def, name, value);
}
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index c790fe4b04..e4c6745ded 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -29,6 +29,7 @@ import com.google.gerrit.entities.Project;
import com.google.gerrit.index.Index;
import com.google.gerrit.index.QueryOptions;
import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs;
import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.project.ProjectData;
import com.google.gerrit.index.project.ProjectIndex;
@@ -166,6 +167,7 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
@Override
public ResultSet<V> read() {
return new ListResultSet<>(results) {
+ @Nullable
@Override
public Object searchAfter() {
@Nullable V last = Iterables.getLast(results, null);
@@ -190,7 +192,7 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
fields.put(field.getName(), field.get(result));
}
}
- fieldBundles.add(new FieldBundle(fields.build()));
+ fieldBundles.add(new FieldBundle(fields.build(), /* storesIndexedFields= */ false));
searchAfter = keyFor(result);
}
ImmutableList<FieldBundle> resultSet = fieldBundles.build();
@@ -228,7 +230,7 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
* <p>This index is special in that ChangeData is a mutable object. Therefore we can't just hold
* onto the object that the caller wanted us to index. We also can't just create a new ChangeData
* from scratch because there are tests that assert that certain computations (e.g. diffs) are
- * only done once. So we do what the prod indices do: We read and write fields using FieldDef.
+ * only done once. So we do what the prod indices do: We read and write fields using SchemaField.
*/
public static class FakeChangeIndex
extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex {
@@ -266,7 +268,7 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
protected Map<String, Object> docFor(ChangeData value) {
ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
- if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+ if (ChangeField.MERGEABLE_SPEC.getName().equals(field.getName()) && skipMergable) {
continue;
}
Object docifiedValue = field.get(value);
@@ -281,16 +283,23 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
protected ChangeData valueFor(Map<String, Object> doc) {
ChangeData cd =
changeDataFactory.create(
- Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
- Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
+ Project.nameKey((String) doc.get(ChangeField.PROJECT_SPEC.getName())),
+ Change.id(
+ Integer.valueOf((String) doc.get(ChangeField.NUMERIC_ID_STR_SPEC.getName()))));
for (SchemaField<ChangeData, ?> field : getSchema().getSchemaFields().values()) {
- field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
+ boolean isProtoField = SchemaFieldDefs.isProtoField(field);
+ field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName()), isProtoField));
}
return cd;
}
@Override
public void insert(ChangeData obj) {}
+
+ @Override
+ public void deleteByValue(ChangeData value) {
+ delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+ }
}
/** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
@@ -323,6 +332,11 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
@Override
public void insert(AccountState obj) {}
+
+ @Override
+ public void deleteByValue(AccountState value) {
+ delete(AccountIndex.ENTITY_TO_KEY.apply(value));
+ }
}
/** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
@@ -356,6 +370,11 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
@Override
public void insert(InternalGroup obj) {}
+
+ @Override
+ public void deleteByValue(InternalGroup value) {
+ delete(GroupIndex.ENTITY_TO_KEY.apply(value));
+ }
}
/** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
@@ -388,5 +407,10 @@ public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
@Override
public void insert(ProjectData obj) {}
+
+ @Override
+ public void deleteByValue(ProjectData value) {
+ delete(ProjectIndex.ENTITY_TO_KEY.apply(value));
+ }
}
}
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
index 9af2598cb5..44bf70d357 100644
--- a/java/com/google/gerrit/index/testing/BUILD
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -10,13 +10,9 @@ java_library(
deps = [
"//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
- "//java/com/google/gerrit/exceptions",
"//java/com/google/gerrit/index",
- "//java/com/google/gerrit/index:query_exception",
"//java/com/google/gerrit/index/project",
- "//java/com/google/gerrit/proto",
"//java/com/google/gerrit/server",
- "//java/com/google/gerrit/server/logging",
"//lib:guava",
"//lib:jgit",
"//lib:protobuf",
diff --git a/java/com/google/gerrit/index/testing/TestIndexedFields.java b/java/com/google/gerrit/index/testing/TestIndexedFields.java
index f80b8a1f2a..51440fbeae 100644
--- a/java/com/google/gerrit/index/testing/TestIndexedFields.java
+++ b/java/com/google/gerrit/index/testing/TestIndexedFields.java
@@ -164,6 +164,12 @@ public final class TestIndexedFields {
public static final IndexedField<TestIndexedData, String>.SearchSpec STRING_FIELD_SPEC =
STRING_FIELD.fullText("string_test");
+ public static final IndexedField<TestIndexedData, String>.SearchSpec PREFIX_STRING_FIELD_SPEC =
+ STRING_FIELD.prefix("prefix_string_test");
+
+ public static final IndexedField<TestIndexedData, String>.SearchSpec EXACT_STRING_FIELD_SPEC =
+ STRING_FIELD.exact("exact_string_test");
+
public static final IndexedField<TestIndexedData, Iterable<byte[]>> ITERABLE_STORED_BYTE_FIELD =
IndexedField.<TestIndexedData>iterableByteArrayBuilder("IterableByteTestField")
.stored()
@@ -182,7 +188,10 @@ public final class TestIndexedFields {
public static final IndexedField<TestIndexedData, Entities.Change> STORED_PROTO_FIELD =
IndexedField.<TestIndexedData, Entities.Change>builder(
- "TestChange", new TypeToken<Entities.Change>() {})
+ "TestChange",
+ new TypeToken<Entities.Change>() {
+ private static final long serialVersionUID = 1L;
+ })
.stored()
.build(getter(), setter(), ChangeProtoConverter.INSTANCE);
@@ -192,7 +201,10 @@ public final class TestIndexedFields {
public static final IndexedField<TestIndexedData, Iterable<Entities.Change>>
ITERABLE_STORED_PROTO_FIELD =
IndexedField.<TestIndexedData, Iterable<Entities.Change>>builder(
- "IterableTestChange", new TypeToken<Iterable<Entities.Change>>() {})
+ "IterableTestChange",
+ new TypeToken<Iterable<Entities.Change>>() {
+ private static final long serialVersionUID = 1L;
+ })
.stored()
.build(getter(), setter(), ChangeProtoConverter.INSTANCE);
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
index 9c32aa8c24..b6cb5f922e 100644
--- a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -14,6 +14,7 @@
package com.google.gerrit.json;
+import com.google.gerrit.common.Nullable;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
import com.google.gson.TypeAdapter;
@@ -34,6 +35,7 @@ import java.io.IOException;
public class EnumTypeAdapterFactory implements TypeAdapterFactory {
@SuppressWarnings({"rawtypes", "unchecked"})
+ @Nullable
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
TypeAdapter<T> defaultEnumAdapter = TypeAdapters.ENUM_FACTORY.create(gson, typeToken);
diff --git a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
index d35b8fb3fd..2557515474 100644
--- a/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
+++ b/java/com/google/gerrit/json/OptionalSubmitRequirementExpressionResultAdapterFactory.java
@@ -45,6 +45,7 @@ public class OptionalSubmitRequirementExpressionResultAdapterFactory implements
TypeToken.get(SubmitRequirementExpressionResult.class);
@SuppressWarnings({"unchecked"})
+ @Nullable
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> typeToken) {
if (typeToken.equals(OPTIONAL_SR_EXPRESSION_RESULT_TOKEN)) {
diff --git a/java/com/google/gerrit/json/SqlTimestampDeserializer.java b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
index e1cf38210a..9aeda2b7d2 100644
--- a/java/com/google/gerrit/json/SqlTimestampDeserializer.java
+++ b/java/com/google/gerrit/json/SqlTimestampDeserializer.java
@@ -14,6 +14,7 @@
package com.google.gerrit.json;
+import com.google.gerrit.common.Nullable;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
@@ -30,6 +31,7 @@ import java.util.TimeZone;
class SqlTimestampDeserializer implements JsonDeserializer<Timestamp>, JsonSerializer<Timestamp> {
private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
+ @Nullable
@Override
public Timestamp deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 550597b7e7..07a071ab2e 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -44,6 +44,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Properties;
@@ -222,7 +223,7 @@ public final class GerritLauncher {
String cn = programClassName(name);
clazz = Class.forName(PKG + "." + cn, true, loader);
} catch (ClassNotFoundException cnfe) {
- if (name.equals(name.toLowerCase())) {
+ if (name.equals(name.toLowerCase(Locale.US))) {
clazz = Class.forName(PKG + "." + name, true, loader);
} else {
throw cnfe;
@@ -266,7 +267,7 @@ public final class GerritLauncher {
}
private static String programClassName(String cn) {
- if (cn.equals(cn.toLowerCase())) {
+ if (cn.equals(cn.toLowerCase(Locale.US))) {
StringBuilder buf = new StringBuilder();
buf.append(Character.toUpperCase(cn.charAt(0)));
for (int i = 1; i < cn.length(); i++) {
@@ -560,6 +561,7 @@ public final class GerritLauncher {
return myHome;
}
+ @SuppressWarnings("ReturnMissingNullable")
private static File tmproot() {
File tmp;
String gerritTemp = System.getenv("GERRIT_TMP");
@@ -599,6 +601,7 @@ public final class GerritLauncher {
}
}
+ @SuppressWarnings("ReturnMissingNullable")
private static File locateHomeDirectory() {
// Try to find the user's home directory. If we can't find it
// return null so the JVM's default temporary directory is used
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 229674bf9e..2fd1c45f56 100644
--- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -40,16 +40,19 @@ import com.google.gerrit.index.PaginationType;
import com.google.gerrit.index.QueryOptions;
import com.google.gerrit.index.Schema;
import com.google.gerrit.index.Schema.Values;
+import com.google.gerrit.index.SchemaFieldDefs;
import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.DataSource;
import com.google.gerrit.index.query.FieldBundle;
import com.google.gerrit.index.query.ListResultSet;
import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.index.IndexUtils;
import com.google.gerrit.server.index.options.AutoFlush;
import com.google.gerrit.server.logging.LoggingContextAwareExecutorService;
import com.google.gerrit.server.logging.LoggingContextAwareScheduledExecutorService;
+import com.google.protobuf.MessageLite;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.Set;
@@ -106,6 +109,7 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
private final Set<NrtFuture> notDoneNrtFutures;
private final AutoFlush autoFlush;
private ScheduledExecutorService autoCommitExecutor;
+ private final Function<V, K> valueToKeyFunction;
@SuppressWarnings("ThreadPriorityCheck")
AbstractLuceneIndex(
@@ -117,7 +121,8 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
String subIndex,
GerritIndexWriterConfig writerConfig,
SearcherFactory searcherFactory,
- AutoFlush autoFlush)
+ AutoFlush autoFlush,
+ Function<V, K> valueToKeyFunction)
throws IOException {
this.schema = schema;
this.sitePaths = sitePaths;
@@ -125,6 +130,7 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
this.name = name;
this.skipFields = skipFields;
this.autoFlush = autoFlush;
+ this.valueToKeyFunction = valueToKeyFunction;
String index = Joiner.on('_').skipNulls().join(name, subIndex);
long commitPeriod = writerConfig.getCommitWithinMs();
@@ -298,6 +304,11 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
}
@Override
+ public void deleteByValue(V value) {
+ delete(valueToKeyFunction.apply(value));
+ }
+
+ @Override
public void deleteAll() {
try {
writer.deleteAll();
@@ -368,8 +379,12 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
doc.add(new TextField(name, (String) value, store));
}
} else if (type == FieldType.STORED_ONLY) {
+ boolean isProtoField = SchemaFieldDefs.isProtoField(values.getField());
for (Object value : values.getValues()) {
- doc.add(new StoredField(name, (byte[]) value));
+ // Lucene stores protos as bytes
+ doc.add(
+ new StoredField(
+ name, isProtoField ? Protos.toByteArray((MessageLite) value) : (byte[]) value));
}
} else {
throw FieldType.badFieldType(type);
@@ -402,7 +417,7 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
throw FieldType.badFieldType(type);
}
}
- return new FieldBundle(rawFields);
+ return new FieldBundle(rawFields, /* storesIndexedFields= */ false);
}
private static Field.Store store(SchemaField<?, ?> f) {
@@ -550,7 +565,7 @@ public abstract class AbstractLuceneIndex<K, V> implements Index<K, V> {
}
}
ScoreDoc searchAfter = scoreDoc;
- return new ListResultSet<T>(b.build()) {
+ return new ListResultSet<>(b.build()) {
@Override
public Object searchAfter() {
return searchAfter;
diff --git a/java/com/google/gerrit/lucene/ChangeSubIndex.java b/java/com/google/gerrit/lucene/ChangeSubIndex.java
index ce50473888..024b102c66 100644
--- a/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -85,7 +85,8 @@ public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
subIndex,
writerConfig,
searcherFactory,
- autoFlush);
+ autoFlush,
+ ChangeIndex.ENTITY_TO_KEY);
}
@Override
@@ -119,13 +120,13 @@ public class ChangeSubIndex extends AbstractLuceneIndex<Change.Id, ChangeData>
void add(Document doc, Values<ChangeData> values) {
// Add separate DocValues fields for those fields needed for sorting.
SchemaField<ChangeData, ?> f = values.getField();
- if (f == ChangeField.LEGACY_ID_STR) {
+ if (f == ChangeField.NUMERIC_ID_STR_SPEC) {
String v = (String) getOnlyElement(values.getValues());
doc.add(new NumericDocValuesField(ID_STR_SORT_FIELD, Integer.valueOf(v)));
- } else if (f == ChangeField.UPDATED) {
+ } else if (f == ChangeField.UPDATED_SPEC) {
long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
doc.add(new NumericDocValuesField(UPDATED_SORT_FIELD, t));
- } else if (f == ChangeField.MERGED_ON) {
+ } else if (f == ChangeField.MERGED_ON_SPEC) {
long t = ((Timestamp) getOnlyElement(values.getValues())).getTime();
doc.add(new NumericDocValuesField(MERGED_ON_SORT_FIELD, t));
}
diff --git a/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index 2e1771f965..9c0baa8dfe 100644
--- a/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -108,7 +108,8 @@ public class LuceneAccountIndex extends AbstractLuceneIndex<Account.Id, AccountS
null,
new GerritIndexWriterConfig(cfg, ACCOUNTS),
new SearcherFactory(),
- autoFlush);
+ autoFlush,
+ AccountIndex.ENTITY_TO_KEY);
this.accountCache = accountCache;
indexWriterConfig = new GerritIndexWriterConfig(cfg, ACCOUNTS);
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index eaae40f5bd..4c05f70029 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,8 +17,8 @@ package com.google.gerrit.lucene;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
import static java.util.Objects.requireNonNull;
@@ -33,6 +33,7 @@ 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.ListeningExecutorService;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.converter.ChangeProtoConverter;
@@ -102,21 +103,21 @@ import org.eclipse.jgit.lib.Config;
public class LuceneChangeIndex implements ChangeIndex {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
- static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON);
- static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID_STR);
+ static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED_SPEC);
+ static final String MERGED_ON_SORT_FIELD = sortFieldName(ChangeField.MERGED_ON_SPEC);
+ static final String ID_STR_SORT_FIELD = sortFieldName(ChangeField.NUMERIC_ID_STR_SPEC);
private static final String CHANGES = "changes";
private static final String CHANGES_OPEN = "open";
private static final String CHANGES_CLOSED = "closed";
- private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
+ private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
static Term idTerm(ChangeData cd) {
return idTerm(cd.getVirtualId());
}
static Term idTerm(Change.Id id) {
- return QueryBuilder.stringTerm(LEGACY_ID_STR.getName(), Integer.toString(id.get()));
+ return QueryBuilder.stringTerm(NUMERIC_ID_STR_SPEC.getName(), Integer.toString(id.get()));
}
private final ListeningExecutorService executor;
@@ -142,7 +143,7 @@ public class LuceneChangeIndex implements ChangeIndex {
this.skipFields =
MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()
? ImmutableSet.of()
- : ImmutableSet.of(ChangeField.MERGEABLE.getName());
+ : ImmutableSet.of(ChangeField.MERGEABLE_SPEC.getName());
GerritIndexWriterConfig openConfig = new GerritIndexWriterConfig(cfg, "changes_open");
GerritIndexWriterConfig closedConfig = new GerritIndexWriterConfig(cfg, "changes_closed");
@@ -242,6 +243,11 @@ public class LuceneChangeIndex implements ChangeIndex {
}
@Override
+ public void deleteByValue(ChangeData value) {
+ delete(ChangeIndex.ENTITY_TO_KEY.apply(value));
+ }
+
+ @Override
public void delete(Change.Id changeId) {
Term idTerm = LuceneChangeIndex.idTerm(changeId);
try {
@@ -454,11 +460,13 @@ public class LuceneChangeIndex implements ChangeIndex {
* @param subIndex change sub-index
* @return the score doc that can be used to page result sets
*/
+ @Nullable
private ScoreDoc getSearchAfter(ChangeSubIndex subIndex) {
if (isSearchAfterPagination
&& opts.searchAfter() != null
- && opts.searchAfter() instanceof Map) {
- return ((Map<ChangeSubIndex, ScoreDoc>) opts.searchAfter()).get(subIndex);
+ && opts.searchAfter() instanceof Map
+ && ((Map<?, ?>) opts.searchAfter()).get(subIndex) instanceof ScoreDoc) {
+ return (ScoreDoc) ((Map<?, ?>) opts.searchAfter()).get(subIndex);
}
return null;
}
@@ -498,7 +506,7 @@ public class LuceneChangeIndex implements ChangeIndex {
ImmutableList.Builder<ChangeData> result =
ImmutableList.builderWithExpectedSize(docs.size());
for (Document doc : docs) {
- result.add(toChangeData(fields(doc, fields), fields, LEGACY_ID_STR.getName()));
+ result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
}
return result.build();
} catch (InterruptedException e) {
@@ -547,7 +555,7 @@ public class LuceneChangeIndex implements ChangeIndex {
Change.Id id = Change.id(Integer.valueOf(f.stringValue()));
// IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
- IndexableField project = doc.get(PROJECT.getName()).iterator().next();
+ IndexableField project = doc.get(PROJECT_SPEC.getName()).iterator().next();
cd = changeDataFactory.create(Project.nameKey(project.stringValue()), id);
}
diff --git a/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index d475ab7acc..630142145f 100644
--- a/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -18,6 +18,7 @@ import static com.google.common.collect.Iterables.getOnlyElement;
import static com.google.gerrit.server.index.group.GroupField.UUID_FIELD_SPEC;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.exceptions.StorageException;
@@ -97,7 +98,8 @@ public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, Int
null,
new GerritIndexWriterConfig(cfg, GROUPS),
new SearcherFactory(),
- autoFlush);
+ autoFlush,
+ GroupIndex.ENTITY_TO_KEY);
this.groupCache = groupCache;
indexWriterConfig = new GerritIndexWriterConfig(cfg, GROUPS);
@@ -151,6 +153,7 @@ public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, Int
new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
}
+ @Nullable
@Override
protected InternalGroup fromDocument(Document doc) {
AccountGroup.UUID uuid =
diff --git a/java/com/google/gerrit/lucene/LuceneProjectIndex.java b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
index 96b22db2c0..911d91fad1 100644
--- a/java/com/google/gerrit/lucene/LuceneProjectIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneProjectIndex.java
@@ -15,9 +15,10 @@
package com.google.gerrit.lucene;
import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.gerrit.index.project.ProjectField.NAME;
+import static com.google.gerrit.index.project.ProjectField.NAME_SPEC;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.QueryOptions;
@@ -57,14 +58,14 @@ public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, Pro
implements ProjectIndex {
private static final String PROJECTS = "projects";
- private static final String NAME_SORT_FIELD = sortFieldName(NAME);
+ private static final String NAME_SORT_FIELD = sortFieldName(NAME_SPEC);
private static Term idTerm(ProjectData projectState) {
return idTerm(projectState.getProject().getNameKey());
}
private static Term idTerm(Project.NameKey nameKey) {
- return QueryBuilder.stringTerm(NAME.getName(), nameKey.get());
+ return QueryBuilder.stringTerm(NAME_SPEC.getName(), nameKey.get());
}
private final GerritIndexWriterConfig indexWriterConfig;
@@ -97,7 +98,8 @@ public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, Pro
null,
new GerritIndexWriterConfig(cfg, PROJECTS),
new SearcherFactory(),
- autoFlush);
+ autoFlush,
+ ProjectIndex.ENTITY_TO_KEY);
this.projectCache = projectCache;
indexWriterConfig = new GerritIndexWriterConfig(cfg, PROJECTS);
@@ -108,7 +110,7 @@ public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, Pro
void add(Document doc, Values<ProjectData> values) {
// Add separate DocValues field for the field that is needed for sorting.
SchemaField<ProjectData, ?> f = values.getField();
- if (f == NAME) {
+ if (f == NAME_SPEC) {
String value = (String) getOnlyElement(values.getValues());
doc.add(new SortedDocValuesField(NAME_SORT_FIELD, new BytesRef(value)));
}
@@ -151,9 +153,10 @@ public class LuceneProjectIndex extends AbstractLuceneIndex<Project.NameKey, Pro
new Sort(new SortField(NAME_SORT_FIELD, SortField.Type.STRING, false)));
}
+ @Nullable
@Override
protected ProjectData fromDocument(Document doc) {
- Project.NameKey nameKey = Project.nameKey(doc.getField(NAME.getName()).stringValue());
+ Project.NameKey nameKey = Project.nameKey(doc.getField(NAME_SPEC.getName()).stringValue());
return projectCache.get().get(nameKey).map(ProjectState::toProjectData).orElse(null);
}
}
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
index 1f8c039a2c..58ae3e0cbd 100644
--- a/java/com/google/gerrit/lucene/LuceneStoredValue.java
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -38,6 +38,7 @@ public class LuceneStoredValue implements StoredValue {
this.field = field;
}
+ @Nullable
@Override
public String asString() {
return Iterables.getFirst(asStrings(), null);
@@ -48,6 +49,7 @@ public class LuceneStoredValue implements StoredValue {
return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
}
+ @Nullable
@Override
public Integer asInteger() {
return Iterables.getFirst(asIntegers(), null);
@@ -58,6 +60,7 @@ public class LuceneStoredValue implements StoredValue {
return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
}
+ @Nullable
@Override
public Long asLong() {
return Iterables.getFirst(asLongs(), null);
@@ -68,11 +71,13 @@ public class LuceneStoredValue implements StoredValue {
return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
}
+ @Nullable
@Override
public Timestamp asTimestamp() {
return asLong() == null ? null : new Timestamp(asLong());
}
+ @Nullable
@Override
public byte[] asByteArray() {
return Iterables.getFirst(asByteArrays(), null);
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index c164b29b57..56cb2202dd 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -17,6 +17,7 @@ package com.google.gerrit.lucene;
* limitations under the License.
*/
+import com.google.gerrit.common.Nullable;
import java.io.IOException;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.FilterDirectoryReader;
@@ -132,6 +133,7 @@ final class WrappableSearcherManager extends ReferenceManager<IndexSearcher> {
reference.getIndexReader().decRef();
}
+ @Nullable
@Override
protected IndexSearcher refreshIfNeeded(IndexSearcher referenceToRefresh) throws IOException {
final IndexReader r = referenceToRefresh.getIndexReader();
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
index 2700f81262..6933140973 100644
--- a/java/com/google/gerrit/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -17,7 +17,6 @@ package com.google.gerrit.mail;
/** Variables used by emails to hold data */
public enum MailHeader {
// Gerrit metadata holders
- ASSIGNEE("Gerrit-Assignee"),
ATTENTION("Gerrit-Attention"),
BRANCH("Gerrit-Branch"),
CC("Gerrit-CC"),
diff --git a/java/com/google/gerrit/mail/RawMailParser.java b/java/com/google/gerrit/mail/RawMailParser.java
index 929e9f9681..79d1cb8ff0 100644
--- a/java/com/google/gerrit/mail/RawMailParser.java
+++ b/java/com/google/gerrit/mail/RawMailParser.java
@@ -26,6 +26,7 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.time.Instant;
+import java.util.Locale;
import org.apache.james.mime4j.MimeException;
import org.apache.james.mime4j.dom.Entity;
import org.apache.james.mime4j.dom.Message;
@@ -90,7 +91,7 @@ public class RawMailParser {
// Add additional headers
mimeMessage.getHeader().getFields().stream()
- .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase()))
+ .filter(f -> !MAIN_HEADERS.contains(f.getName().toLowerCase(Locale.US)))
.forEach(f -> messageBuilder.addAdditionalHeader(f.getName() + ": " + f.getBody()));
// Add text and html body parts
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 5f4e0c04d6..0f80a0c685 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -5,6 +5,7 @@ java_library(
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/common:server",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/lifecycle",
diff --git a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
index d59a1d93a1..27e9377229 100644
--- a/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
+++ b/java/com/google/gerrit/metrics/dropwizard/MetricJson.java
@@ -23,6 +23,7 @@ import com.codahale.metrics.Snapshot;
import com.codahale.metrics.Timer;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Field;
import java.util.ArrayList;
@@ -144,6 +145,7 @@ class MetricJson {
}
}
+ @Nullable
private static Boolean toBool(ImmutableMap<String, String> atts, String key) {
return Description.TRUE_VALUE.equals(atts.get(key)) ? true : null;
}
diff --git a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
index ef0ced61c6..84f23205ff 100644
--- a/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
+++ b/java/com/google/gerrit/metrics/proc/OperatingSystemMXBeanFactory.java
@@ -15,6 +15,7 @@
package com.google.gerrit.metrics.proc;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.sun.management.UnixOperatingSystemMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.OperatingSystemMXBean;
@@ -25,6 +26,7 @@ class OperatingSystemMXBeanFactory {
private OperatingSystemMXBeanFactory() {}
+ @Nullable
static OperatingSystemMXBeanInterface create() {
OperatingSystemMXBean sys = ManagementFactory.getOperatingSystemMXBean();
if (sys instanceof UnixOperatingSystemMXBean) {
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 2f28db55de..166d35e87b 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -55,6 +55,7 @@ import com.google.gerrit.pgm.util.ErrorLogFile;
import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
import com.google.gerrit.pgm.util.RuntimeShutdown;
import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
import com.google.gerrit.server.LibModuleLoader;
import com.google.gerrit.server.LibModuleType;
import com.google.gerrit.server.ModuleOverloader;
@@ -64,6 +65,7 @@ import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccount
import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
import com.google.gerrit.server.api.GerritApiModule;
import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
import com.google.gerrit.server.audit.AuditModule;
import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -213,6 +215,7 @@ public class Daemon extends SiteProgram {
private Path runFile;
private boolean inMemoryTest;
private AbstractModule indexModule;
+ private Module accountPatchReviewStoreModule;
private Module emailModule;
private List<Module> testSysModules = new ArrayList<>();
private List<Module> testSshModules = new ArrayList<>();
@@ -335,6 +338,11 @@ public class Daemon extends SiteProgram {
}
@VisibleForTesting
+ public void setAccountPatchReviewStoreModuleForTesting(Module module) {
+ accountPatchReviewStoreModule = module;
+ }
+
+ @VisibleForTesting
public void setEmailModuleForTesting(Module module) {
emailModule = module;
}
@@ -445,12 +453,18 @@ public class Daemon extends SiteProgram {
modules.add(new WorkQueueModule());
modules.add(new StreamEventsApiListenerModule());
modules.add(new EventBrokerModule());
- modules.add(new JdbcAccountPatchReviewStoreModule(config));
+ if (accountPatchReviewStoreModule != null) {
+ modules.add(accountPatchReviewStoreModule);
+ } else {
+ modules.add(new JdbcAccountPatchReviewStoreModule(config));
+ }
modules.add(new SysExecutorModule());
modules.add(new DiffExecutorModule());
modules.add(new MimeUtil2Module());
modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
modules.add(new GerritApiModule());
+ modules.add(new ProjectQueryBuilderModule());
+ modules.add(new DefaultRefLogIdentityProvider.Module());
modules.add(new PluginApiModule());
modules.add(
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index ecfca0dbc9..b4344d749d 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -36,6 +36,7 @@ import com.google.gerrit.server.cache.CacheDisplay;
import com.google.gerrit.server.cache.CacheInfo;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
import com.google.gerrit.server.index.IndexModule;
import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
import com.google.gerrit.server.index.options.AutoFlush;
@@ -175,6 +176,8 @@ public class Reindex extends SiteProgram {
}
boolean replica = ReplicaUtil.isReplica(globalConfig);
List<Module> modules = new ArrayList<>();
+ modules.add(new WorkQueueModule());
+
Module indexModule;
IndexType indexType = IndexModule.getIndexType(dbInjector);
if (indexType.isLucene()) {
diff --git a/java/com/google/gerrit/pgm/SwitchSecureStore.java b/java/com/google/gerrit/pgm/SwitchSecureStore.java
index 824a9a70fa..6dec2d8d2a 100644
--- a/java/com/google/gerrit/pgm/SwitchSecureStore.java
+++ b/java/com/google/gerrit/pgm/SwitchSecureStore.java
@@ -19,6 +19,7 @@ import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.SiteLibraryLoaderUtil;
import com.google.gerrit.pgm.util.SiteProgram;
import com.google.gerrit.server.config.SitePaths;
@@ -185,6 +186,7 @@ public class SwitchSecureStore extends SiteProgram {
}
}
+ @Nullable
private Path findJarWithSecureStore(SitePaths sitePaths, String secureStoreClass)
throws IOException {
List<Path> jars = SiteLibraryLoaderUtil.listJars(sitePaths.lib_dir);
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index 5849711c22..0c0d937f48 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -23,7 +23,6 @@ java_library(
"//java/com/google/gerrit/server/schema",
"//java/com/google/gerrit/server/util/time",
"//lib:guava",
- "//lib:h2",
"//lib:jgit",
"//lib/commons:validator",
"//lib/flogger:api",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 4592cbb856..b59b92421a 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -22,6 +22,7 @@ import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Die;
import com.google.gerrit.common.IoUtil;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.IndexType;
import com.google.gerrit.metrics.DisabledMetricMaker;
@@ -175,6 +176,7 @@ public class BaseInit extends SiteProgram {
*/
protected void afterInit(SiteRun run) throws Exception {}
+ @Nullable
protected List<String> getInstallPlugins() {
try {
if (pluginsToInstall != null && pluginsToInstall.isEmpty()) {
@@ -304,6 +306,7 @@ public class BaseInit extends SiteProgram {
return ConsoleUI.getInstance(false);
}
+ @Nullable
private SecureStoreInitData discoverSecureStoreClass() {
String secureStore = getSecureStoreLib();
if (Strings.isNullOrEmpty(secureStore)) {
diff --git a/java/com/google/gerrit/pgm/init/Browser.java b/java/com/google/gerrit/pgm/init/Browser.java
index 2e49e13bf0..228a528462 100644
--- a/java/com/google/gerrit/pgm/init/Browser.java
+++ b/java/com/google/gerrit/pgm/init/Browser.java
@@ -24,7 +24,7 @@ import java.net.URI;
import java.net.URISyntaxException;
import org.eclipse.jgit.lib.Config;
-/** Opens the user's web browser to the web UI. */
+/** Points the user to a web browser URL. */
public class Browser {
private final Config cfg;
@@ -57,7 +57,7 @@ public class Browser {
return;
}
waitForServer(uri);
- openBrowser(uri, link);
+ printBrowserUrl(uri, link);
}
private void waitForServer(URI uri) throws IOException {
@@ -97,17 +97,9 @@ public class Browser {
return url;
}
- private void openBrowser(URI uri, String link) {
+ private void printBrowserUrl(URI uri, String link) {
String url = resolveUrl(uri, link);
- System.err.format("Opening %s ...", url);
+ System.err.format("Please open the following URL in the browser: %s", url);
System.err.flush();
- try {
- org.h2.tools.Server.openBrowser(url);
- System.err.println("OK");
- } catch (Exception e) {
- System.err.println("FAILED");
- System.err.println("Open Gerrit with a JavaScript capable browser:");
- System.err.println(" " + url);
- }
}
}
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 4e854b59d2..3dce97447d 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,6 +17,7 @@ package com.google.gerrit.pgm.init;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.InternalGroup;
@@ -182,6 +183,7 @@ public class InitAdminUser implements InitStep {
return email;
}
+ @Nullable
private AccountSshKey readSshKey(Account.Id id) throws IOException {
String defaultPublicSshKeyFile = "";
Path defaultPublicSshKeyPath = Paths.get(System.getProperty("user.home"), ".ssh", "id_rsa.pub");
diff --git a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index a7f9c5d543..16c4ce7f6c 100644
--- a/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -16,6 +16,7 @@ package com.google.gerrit.pgm.init;
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.pgm.init.api.ConsoleUI;
import com.google.gerrit.pgm.init.api.InitStep;
@@ -62,6 +63,7 @@ public class InitPluginStepsLoader {
return pluginsInitSteps;
}
+ @Nullable
private InitStep loadInitStep(Path jar) {
try {
URLClassLoader pluginLoader =
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 236d185bf0..a057e669d0 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -144,8 +144,6 @@ public class SitePathInitializer {
extractMailExample("RestoredHtml.soy");
extractMailExample("Reverted.soy");
extractMailExample("RevertedHtml.soy");
- extractMailExample("SetAssignee.soy");
- extractMailExample("SetAssigneeHtml.soy");
if (!ui.isBatch()) {
System.err.println();
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index dffdde74b8..865f7d7e17 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -17,8 +17,10 @@ package com.google.gerrit.pgm.init.api;
import com.google.errorprone.annotations.FormatMethod;
import com.google.errorprone.annotations.FormatString;
import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
import java.io.Console;
import java.util.EnumSet;
+import java.util.Locale;
import java.util.Set;
/** Console based interaction with the invoking user. */
@@ -164,21 +166,22 @@ public abstract class ConsoleUI {
String def, Set<String> allowedValues, @FormatString String fmt, Object... args) {
for (; ; ) {
String r = readString(def, fmt, args);
- if (allowedValues.contains(r.toLowerCase())) {
- return r.toLowerCase();
+ if (allowedValues.contains(r.toLowerCase(Locale.US))) {
+ return r.toLowerCase(Locale.US);
}
if (!"?".equals(r)) {
console.printf("error: '%s' is not a valid choice\n", r);
}
console.printf(" Supported options are:\n");
for (String v : allowedValues) {
- console.printf(" %s\n", v.toLowerCase());
+ console.printf(" %s\n", v.toLowerCase(Locale.US));
}
}
}
@Override
@FormatMethod
+ @Nullable
public String password(String fmt, Object... args) {
final String prompt = String.format(fmt, args);
for (; ; ) {
@@ -208,7 +211,8 @@ public abstract class ConsoleUI {
T def, A options, String fmt, Object... args) {
final String prompt = String.format(fmt, args);
for (; ; ) {
- String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString().toLowerCase());
+ String r =
+ console.readLine("%-30s [%s/?]: ", prompt, def.toString().toLowerCase(Locale.US));
if (r == null) {
throw abort();
}
@@ -226,7 +230,7 @@ public abstract class ConsoleUI {
}
console.printf(" Supported options are:\n");
for (T e : options) {
- console.printf(" %s\n", e.toString().toLowerCase());
+ console.printf(" %s\n", e.toString().toLowerCase(Locale.US));
}
}
}
diff --git a/java/com/google/gerrit/pgm/init/api/InitUtil.java b/java/com/google/gerrit/pgm/init/api/InitUtil.java
index d038de74ad..76887286c3 100644
--- a/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -18,6 +18,7 @@ import static com.google.gerrit.common.FileUtil.modified;
import com.google.common.io.ByteStreams;
import com.google.gerrit.common.Die;
+import com.google.gerrit.common.Nullable;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileNotFoundException;
@@ -127,6 +128,7 @@ public class InitUtil {
}
}
+ @Nullable
private static InputStream open(Class<?> sibling, String name) {
final InputStream in = sibling.getResourceAsStream(name);
if (in == null) {
diff --git a/java/com/google/gerrit/pgm/init/api/Section.java b/java/com/google/gerrit/pgm/init/api/Section.java
index b5d35f40e6..5cc4b5dbca 100644
--- a/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/java/com/google/gerrit/pgm/init/api/Section.java
@@ -166,6 +166,7 @@ public class Section {
return nv;
}
+ @Nullable
public String password(String username, String password) {
final String ov = getSecure(password);
diff --git a/java/com/google/gerrit/pgm/rules/PrologCompiler.java b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
index 0a41db5828..e68e203620 100644
--- a/java/com/google/gerrit/pgm/rules/PrologCompiler.java
+++ b/java/com/google/gerrit/pgm/rules/PrologCompiler.java
@@ -14,6 +14,9 @@
package com.google.gerrit.pgm.rules;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.Version;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.config.GerritServerConfig;
@@ -83,7 +86,7 @@ public class PrologCompiler implements Callable<PrologCompiler.Status> {
return Status.NO_RULES;
}
- ObjectId rulesId = git.resolve(metaConfig.name() + ":rules.pl");
+ ObjectId rulesId = git.resolve(metaConfig.name() + ":" + RULES_PL_FILE);
if (rulesId == null) {
return Status.NO_RULES;
}
@@ -182,6 +185,7 @@ public class PrologCompiler implements Callable<PrologCompiler.Status> {
}
}
+ @Nullable
private String getMyClasspath() {
StringBuilder cp = new StringBuilder();
appendClasspath(cp, getClass().getClassLoader());
diff --git a/java/com/google/gerrit/pgm/util/AbstractProgram.java b/java/com/google/gerrit/pgm/util/AbstractProgram.java
index 96b042aada..00cba3100a 100644
--- a/java/com/google/gerrit/pgm/util/AbstractProgram.java
+++ b/java/com/google/gerrit/pgm/util/AbstractProgram.java
@@ -18,6 +18,7 @@ import com.google.gerrit.common.Die;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.gerrit.util.cli.OptionHandlers;
import java.io.StringWriter;
+import java.util.Locale;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.args4j.Option;
@@ -35,7 +36,7 @@ public abstract class AbstractProgram {
if (0 < dot) {
n = n.substring(dot + 1);
}
- return n.toLowerCase();
+ return n.toLowerCase(Locale.US);
}
public final int main(String[] argv) throws Exception {
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index f58387e367..1e41cbc1bf 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -28,6 +28,7 @@ import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.InternalUser;
import com.google.gerrit.server.LibModuleLoader;
@@ -84,18 +85,19 @@ import com.google.gerrit.server.project.ProjectCacheImpl;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
import com.google.gerrit.server.query.approval.ApprovalModule;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
import com.google.gerrit.server.restapi.group.GroupModule;
import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
import com.google.gerrit.server.rules.PrologModule;
import com.google.gerrit.server.rules.SubmitRule;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.inject.Injector;
import com.google.inject.Key;
@@ -127,6 +129,7 @@ public class BatchProgramModule extends FactoryModule {
modules.add(PatchListCacheImpl.module());
modules.add(new DefaultUrlFormatterModule());
modules.add(DiffOperationsImpl.module());
+ modules.add(new DefaultRefLogIdentityProvider.Module());
// There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
// listener().to(SomeClassImplementingLifecycleListener.class);
@@ -197,6 +200,7 @@ public class BatchProgramModule extends FactoryModule {
factory(ChangeData.AssistedFactory.class);
factory(ChangeIsVisibleToPredicate.Factory.class);
factory(DistinctVotersPredicate.Factory.class);
+ factory(HasSubmoduleUpdatePredicate.Factory.class);
factory(ProjectState.Factory.class);
DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
diff --git a/java/com/google/gerrit/prettify/BUILD b/java/com/google/gerrit/prettify/BUILD
index 0a15fda05a..a5c8b77b01 100644
--- a/java/com/google/gerrit/prettify/BUILD
+++ b/java/com/google/gerrit/prettify/BUILD
@@ -5,6 +5,7 @@ java_library(
srcs = glob(["common/**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//lib:guava",
"//lib:jgit",
"//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/prettify/common/SparseFileContent.java b/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 1249b6594c..f40222abad 100644
--- a/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -17,6 +17,7 @@ package com.google.gerrit.prettify.common;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
/**
* A class to store subset of a file's lines in a memory efficient way. Internally, it stores lines
@@ -134,6 +135,7 @@ public abstract class SparseFileContent {
return getSize();
}
+ @Nullable
private String getLine(int idx) {
// Most requests are sequential in nature, fetching the next
// line from the current range, or the next range.
diff --git a/java/com/google/gerrit/server/AssigneeStatusUpdate.java b/java/com/google/gerrit/server/AssigneeStatusUpdate.java
deleted file mode 100644
index 812aad17ff..0000000000
--- a/java/com/google/gerrit/server/AssigneeStatusUpdate.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2019 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.entities.Account;
-import java.time.Instant;
-import java.util.Optional;
-
-/** Change to an assignee's status. */
-@AutoValue
-public abstract class AssigneeStatusUpdate {
- public static AssigneeStatusUpdate create(
- Instant ts, Account.Id updatedBy, Optional<Account.Id> currentAssignee) {
- return new AutoValue_AssigneeStatusUpdate(ts, updatedBy, currentAssignee);
- }
-
- public abstract Instant date();
-
- public abstract Account.Id updatedBy();
-
- public abstract Optional<Account.Id> currentAssignee();
-}
diff --git a/java/com/google/gerrit/server/ProjectUtil.java b/java/com/google/gerrit/server/BranchUtil.java
index fa056b3585..78f693d8a4 100644
--- a/java/com/google/gerrit/server/ProjectUtil.java
+++ b/java/com/google/gerrit/server/BranchUtil.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2012 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -21,8 +21,7 @@ import java.io.IOException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Repository;
-public class ProjectUtil {
-
+public class BranchUtil {
/**
* Checks whether the specified branch exists.
*
@@ -45,28 +44,4 @@ public class ProjectUtil {
return exists;
}
}
-
- public static String sanitizeProjectName(String name) {
- name = stripGitSuffix(name);
- name = stripTrailingSlash(name);
- return name;
- }
-
- public static String stripGitSuffix(String name) {
- if (name.endsWith(".git")) {
- // Be nice and drop the trailing ".git" suffix, which we never keep
- // in our database, but clients might mistakenly provide anyway.
- //
- name = name.substring(0, name.length() - 4);
- name = stripTrailingSlash(name);
- }
- return name;
- }
-
- private static String stripTrailingSlash(String name) {
- while (name.endsWith("/")) {
- name = name.substring(0, name.length() - 1);
- }
- return name;
- }
}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 81cff6e039..400da582b3 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -40,8 +40,6 @@ public class ChangeMessagesUtil {
public static final String TAG_ABANDON = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "abandon";
public static final String TAG_CHERRY_PICK_CHANGE =
AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "cherryPickChange";
- public static final String TAG_DELETE_ASSIGNEE =
- AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteAssignee";
public static final String TAG_DELETE_REVIEWER =
AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteReviewer";
public static final String TAG_DELETE_VOTE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "deleteVote";
@@ -49,7 +47,6 @@ public class ChangeMessagesUtil {
public static final String TAG_MOVE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "move";
public static final String TAG_RESTORE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "restore";
public static final String TAG_REVERT = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "revert";
- public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "setAssignee";
public static final String TAG_UPDATE_ATTENTION_SET =
AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "updateAttentionSet";
public static final String TAG_SET_DESCRIPTION =
@@ -150,7 +147,7 @@ public class ChangeMessagesUtil {
cmi.tag = message.getTag();
cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
Account.Id realAuthor = message.getRealAuthor();
- if (realAuthor != null) {
+ if (realAuthor != null && !realAuthor.equals(message.getAuthor())) {
cmi.realAuthor = accountLoader.get(realAuthor);
}
cmi.accountsInMessage =
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index d9edf42c48..2265055c18 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -31,6 +31,7 @@ import java.io.IOException;
import java.security.SecureRandom;
import java.util.Collection;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
@@ -124,7 +125,10 @@ public class ChangeUtil {
* @throws BadRequestException if the new commit message is null or empty
*/
public static void ensureChangeIdIsCorrect(
- boolean requireChangeId, String currentChangeId, String newCommitMessage)
+ boolean requireChangeId,
+ String currentChangeId,
+ String newCommitMessage,
+ UrlFormatter urlFormatter)
throws ResourceConflictException, BadRequestException {
RevCommit revCommit =
RevCommit.parse(
@@ -133,7 +137,7 @@ public class ChangeUtil {
// Check that the commit message without footers is not empty
CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
- List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
+ List<String> changeIdFooters = getChangeIdsFromFooter(revCommit, urlFormatter);
if (requireChangeId && changeIdFooters.isEmpty()) {
throw new ResourceConflictException("missing Change-Id footer");
}
@@ -146,7 +150,7 @@ public class ChangeUtil {
}
public static String status(Change c) {
- return c != null ? c.getStatus().name().toLowerCase() : "deleted";
+ return c != null ? c.getStatus().name().toLowerCase(Locale.US) : "deleted";
}
private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 8198ce446a..285657e860 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -104,6 +104,7 @@ public class CommentsUtil {
return PatchSet.id(changeId, comment.key.patchSetId);
}
+ @Nullable
public static String extractMessageId(@Nullable String tag) {
if (tag == null || !tag.startsWith("mailMessageId=")) {
return null;
diff --git a/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
new file mode 100644
index 0000000000..10b4ec5d9c
--- /dev/null
+++ b/java/com/google/gerrit/server/DefaultRefLogIdentityProvider.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+@Singleton
+public class DefaultRefLogIdentityProvider implements RefLogIdentityProvider {
+ public static class Module extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(RefLogIdentityProvider.class).to(DefaultRefLogIdentityProvider.class);
+ }
+ }
+
+ private final String anonymousCowardName;
+ private final Boolean enablePeerIPInReflogRecord;
+
+ @Inject
+ DefaultRefLogIdentityProvider(
+ @AnonymousCowardName String anonymousCowardName,
+ @EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord) {
+ this.anonymousCowardName = anonymousCowardName;
+ this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
+ }
+
+ @Override
+ public PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId) {
+ Account account = user.getAccount();
+
+ String name = account.fullName();
+ if (name == null || name.isEmpty()) {
+ name = account.preferredEmail();
+ }
+ if (name == null || name.isEmpty()) {
+ name = anonymousCowardName;
+ }
+
+ String email;
+ if (enablePeerIPInReflogRecord) {
+ email = constructMailAddress(user, guessHost(user));
+ } else {
+ email =
+ Strings.isNullOrEmpty(account.preferredEmail())
+ ? constructMailAddress(user, getDefaultDomain())
+ : account.preferredEmail();
+ }
+
+ return new PersonIdent(name, email, when, zoneId);
+ }
+
+ private String constructMailAddress(IdentifiedUser user, String host) {
+ return user.getUserName().orElse("")
+ + "|account-"
+ + user.getAccountId().toString()
+ + "@"
+ + host;
+ }
+
+ private String guessHost(IdentifiedUser user) {
+ String host = null;
+ SocketAddress remotePeer = user.getRemotePeer();
+ if (remotePeer instanceof InetSocketAddress) {
+ InetSocketAddress sa = (InetSocketAddress) remotePeer;
+ InetAddress in = sa.getAddress();
+ host = in != null ? in.getHostAddress() : sa.getHostName();
+ }
+ if (Strings.isNullOrEmpty(host)) {
+ return getDefaultDomain();
+ }
+ return host;
+ }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 65a81f7457..36d78886ec 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -19,7 +19,6 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.flogger.LazyArgs.lazy;
import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
@@ -44,8 +43,6 @@ import com.google.inject.Provider;
import com.google.inject.ProvisionException;
import com.google.inject.Singleton;
import com.google.inject.util.Providers;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.SocketAddress;
import java.net.URL;
@@ -66,6 +63,7 @@ public class IdentifiedUser extends CurrentUser {
private final AuthConfig authConfig;
private final Realm realm;
private final String anonymousCowardName;
+ private final RefLogIdentityProvider refLogIdentityProvider;
private final Provider<String> canonicalUrl;
private final AccountCache accountCache;
private final GroupBackend groupBackend;
@@ -76,6 +74,7 @@ public class IdentifiedUser extends CurrentUser {
AuthConfig authConfig,
Realm realm,
@AnonymousCowardName String anonymousCowardName,
+ RefLogIdentityProvider refLogIdentityProvider,
@CanonicalWebUrl Provider<String> canonicalUrl,
@EnablePeerIPInReflogRecord Boolean enablePeerIPInReflogRecord,
AccountCache accountCache,
@@ -83,6 +82,7 @@ public class IdentifiedUser extends CurrentUser {
this.authConfig = authConfig;
this.realm = realm;
this.anonymousCowardName = anonymousCowardName;
+ this.refLogIdentityProvider = refLogIdentityProvider;
this.canonicalUrl = canonicalUrl;
this.accountCache = accountCache;
this.groupBackend = groupBackend;
@@ -94,36 +94,37 @@ public class IdentifiedUser extends CurrentUser {
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
canonicalUrl,
accountCache,
groupBackend,
enablePeerIPInReflogRecord,
Providers.of(null),
state,
- null);
+ /* realUser= */ null);
}
public IdentifiedUser create(Account.Id id) {
- return create(null, id);
+ return create(/* remotePeer= */ null, id);
}
@VisibleForTesting
@UsedAt(UsedAt.Project.GOOGLE)
public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
- return runAs(null, id, null, properties);
+ return runAs(/* remotePeer= */ null, id, /* caller= */ null, properties);
}
- public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
- return runAs(remotePeer, id, null);
+ public IdentifiedUser create(@Nullable SocketAddress remotePeer, Account.Id id) {
+ return runAs(remotePeer, id, /* caller= */ null);
}
public IdentifiedUser runAs(
- SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+ @Nullable SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
}
private IdentifiedUser runAs(
- SocketAddress remotePeer,
+ @Nullable SocketAddress remotePeer,
Account.Id id,
@Nullable CurrentUser caller,
PropertyMap properties) {
@@ -131,6 +132,7 @@ public class IdentifiedUser extends CurrentUser {
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
canonicalUrl,
accountCache,
groupBackend,
@@ -153,6 +155,7 @@ public class IdentifiedUser extends CurrentUser {
private final AuthConfig authConfig;
private final Realm realm;
private final String anonymousCowardName;
+ private final RefLogIdentityProvider refLogIdentityProvider;
private final Provider<String> canonicalUrl;
private final AccountCache accountCache;
private final GroupBackend groupBackend;
@@ -164,6 +167,7 @@ public class IdentifiedUser extends CurrentUser {
AuthConfig authConfig,
Realm realm,
@AnonymousCowardName String anonymousCowardName,
+ RefLogIdentityProvider refLogIdentityProvider,
@CanonicalWebUrl Provider<String> canonicalUrl,
AccountCache accountCache,
GroupBackend groupBackend,
@@ -172,6 +176,7 @@ public class IdentifiedUser extends CurrentUser {
this.authConfig = authConfig;
this.realm = realm;
this.anonymousCowardName = anonymousCowardName;
+ this.refLogIdentityProvider = refLogIdentityProvider;
this.canonicalUrl = canonicalUrl;
this.accountCache = accountCache;
this.groupBackend = groupBackend;
@@ -188,6 +193,7 @@ public class IdentifiedUser extends CurrentUser {
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
canonicalUrl,
accountCache,
groupBackend,
@@ -203,6 +209,7 @@ public class IdentifiedUser extends CurrentUser {
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
canonicalUrl,
accountCache,
groupBackend,
@@ -224,6 +231,7 @@ public class IdentifiedUser extends CurrentUser {
private final Realm realm;
private final GroupBackend groupBackend;
private final String anonymousCowardName;
+ private final RefLogIdentityProvider refLogIdentityProvider;
private final Boolean enablePeerIPInReflogRecord;
private final Set<String> validEmails = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
private final CurrentUser realUser; // Must be final since cached properties depend on it.
@@ -235,22 +243,25 @@ public class IdentifiedUser extends CurrentUser {
private boolean loadedAllEmails;
private Set<String> invalidEmails;
private GroupMembership effectiveGroups;
+ private PersonIdent refLogIdent;
private IdentifiedUser(
AuthConfig authConfig,
Realm realm,
String anonymousCowardName,
+ RefLogIdentityProvider refLogIdentityProvider,
Provider<String> canonicalUrl,
AccountCache accountCache,
GroupBackend groupBackend,
Boolean enablePeerIPInReflogRecord,
- @Nullable Provider<SocketAddress> remotePeerProvider,
+ Provider<SocketAddress> remotePeerProvider,
AccountState state,
@Nullable CurrentUser realUser) {
this(
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
canonicalUrl,
accountCache,
groupBackend,
@@ -266,11 +277,12 @@ public class IdentifiedUser extends CurrentUser {
AuthConfig authConfig,
Realm realm,
String anonymousCowardName,
+ RefLogIdentityProvider refLogIdentityProvider,
Provider<String> canonicalUrl,
AccountCache accountCache,
GroupBackend groupBackend,
Boolean enablePeerIPInReflogRecord,
- @Nullable Provider<SocketAddress> remotePeerProvider,
+ Provider<SocketAddress> remotePeerProvider,
Account.Id id,
@Nullable CurrentUser realUser,
PropertyMap properties) {
@@ -281,6 +293,7 @@ public class IdentifiedUser extends CurrentUser {
this.authConfig = authConfig;
this.realm = realm;
this.anonymousCowardName = anonymousCowardName;
+ this.refLogIdentityProvider = refLogIdentityProvider;
this.enablePeerIPInReflogRecord = enablePeerIPInReflogRecord;
this.remotePeerProvider = remotePeerProvider;
this.accountId = id;
@@ -426,36 +439,27 @@ public class IdentifiedUser extends CurrentUser {
return getAccountId();
}
+ @Nullable
+ public SocketAddress getRemotePeer() {
+ try {
+ return remotePeerProvider.get();
+ } catch (OutOfScopeException | ProvisionException e) {
+ return null;
+ }
+ }
+
public PersonIdent newRefLogIdent() {
- return newRefLogIdent(Instant.now(), ZoneId.systemDefault());
+ return refLogIdentityProvider.newRefLogIdent(this);
}
public PersonIdent newRefLogIdent(Instant when, ZoneId zoneId) {
- final Account ua = getAccount();
-
- String name = ua.fullName();
- if (name == null || name.isEmpty()) {
- name = ua.preferredEmail();
+ if (refLogIdent != null) {
+ refLogIdent =
+ new PersonIdent(refLogIdent.getName(), refLogIdent.getEmailAddress(), when, zoneId);
+ return refLogIdent;
}
- if (name == null || name.isEmpty()) {
- name = anonymousCowardName;
- }
-
- String user;
- if (enablePeerIPInReflogRecord) {
- user = constructMailAddress(ua, guessHost());
- } else {
- user =
- Strings.isNullOrEmpty(ua.preferredEmail())
- ? constructMailAddress(ua, "unknown")
- : ua.preferredEmail();
- }
-
- return new PersonIdent(name, user, when, zoneId);
- }
-
- private String constructMailAddress(Account ua, String host) {
- return getUserName().orElse("") + "|account-" + ua.id().toString() + "@" + host;
+ refLogIdent = refLogIdentityProvider.newRefLogIdent(this, when, zoneId);
+ return refLogIdent;
}
public PersonIdent newCommitterIdent(PersonIdent ident) {
@@ -533,6 +537,7 @@ public class IdentifiedUser extends CurrentUser {
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
Providers.of(canonicalUrl.get()),
accountCache,
groupBackend,
@@ -546,23 +551,4 @@ public class IdentifiedUser extends CurrentUser {
public boolean hasSameAccountId(CurrentUser other) {
return getAccountId().get() == other.getAccountId().get();
}
-
- private String guessHost() {
- String host = null;
- SocketAddress remotePeer = null;
- try {
- remotePeer = remotePeerProvider.get();
- } catch (OutOfScopeException | ProvisionException e) {
- // Leave null.
- }
- if (remotePeer instanceof InetSocketAddress) {
- InetSocketAddress sa = (InetSocketAddress) remotePeer;
- InetAddress in = sa.getAddress();
- host = in != null ? in.getHostAddress() : sa.getHostName();
- }
- if (Strings.isNullOrEmpty(host)) {
- return "unknown";
- }
- return host;
- }
}
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 3d449b70c5..68d2314cf0 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -35,11 +35,14 @@ import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.update.RepoContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.lib.ObjectId;
@@ -105,6 +108,7 @@ public class PatchSetUtil {
.id(psId)
.commitId(commit)
.uploader(update.getAccountId())
+ .realUploader(update.getRealAccountId())
.createdOn(update.getWhen())
.groups(groups)
.pushCertificate(Optional.ofNullable(pushCertificate))
@@ -169,4 +173,55 @@ public class PatchSetUtil {
return src;
}
}
+
+ /**
+ * Gets the commit ID for the latest patch-set of a given change.
+ *
+ * <p>This also takes into account the patch sets that are added in the provided {@link
+ * RepoContext}.
+ *
+ * @param ctx to look for pending updates in.
+ * @param notesFactory to fetch existing patch sets with.
+ * @param changeId to get the latest commit for.
+ * @return the latest commit ID.
+ * @throws IOException if no committed nor pending commits found for the change.
+ */
+ public static RevCommit getCurrentRevCommitIncludingPending(
+ RepoContext ctx, ChangeNotes.Factory notesFactory, Change.Id changeId) throws IOException {
+ Map<String, ObjectId> refUpdates = ctx.getRepoView().getRefs(changeId.toRefPrefix());
+ refUpdates.remove("meta");
+ if (!refUpdates.isEmpty()) {
+ Optional<PatchSet.Id> latestPendingPatchSet =
+ refUpdates.keySet().stream()
+ .map(r -> PatchSet.Id.fromRef(changeId.toRefPrefix() + r))
+ .filter(Objects::nonNull)
+ .max(PatchSet.Id::compareTo);
+ if (latestPendingPatchSet.isPresent()) {
+ return ctx.getRevWalk().parseCommit(refUpdates.get(latestPendingPatchSet.get().getId()));
+ }
+ }
+ return getCurrentCommittedRevCommit(ctx.getProject(), ctx.getRevWalk(), notesFactory, changeId);
+ }
+
+ /**
+ * Gets the commit ID for the latest committed patch-set of a given change.
+ *
+ * <p>This DOES NOT take into account the patch sets that are added in the provided {@link
+ * RepoContext}.
+ *
+ * @param project name.
+ * @param notesFactory to fetch existing patch sets with.
+ * @param changeId to get the latest commit for.
+ * @return the latest commit ID.
+ * @throws IOException if no committed commits found for the change.
+ */
+ public static RevCommit getCurrentCommittedRevCommit(
+ Project.NameKey project,
+ RevWalk revWalk,
+ ChangeNotes.Factory notesFactory,
+ Change.Id changeId)
+ throws IOException {
+ ChangeNotes notes = notesFactory.createChecked(project, changeId);
+ return revWalk.parseCommit(notes.getCurrentPatchSet().commitId());
+ }
}
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 827c078616..830928aeed 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -23,7 +23,6 @@ import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -112,19 +111,16 @@ public class PublishCommentsOp implements BatchUpdateOp {
}
ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
PatchSet ps = psUtil.get(changeNotes, psId);
- NotifyResolver.Result notify = ctx.getNotify(changeNotes.getChangeId());
- if (notify.shouldNotify()) {
- email
- .create(
- ctx,
- ps,
- preUpdateMetaId,
- mailMessage,
- comments,
- /* patchSetComment= */ null,
- /* labels= */ ImmutableList.of())
- .sendAsync();
- }
+ email
+ .create(
+ ctx,
+ ps,
+ preUpdateMetaId,
+ mailMessage,
+ comments,
+ /* patchSetComment= */ null,
+ /* labels= */ ImmutableList.of())
+ .sendAsync();
commentAdded.fire(
ctx.getChangeData(changeNotes),
ps,
diff --git a/java/com/google/gerrit/server/RefLogIdentityProvider.java b/java/com/google/gerrit/server/RefLogIdentityProvider.java
new file mode 100644
index 0000000000..613b2bb768
--- /dev/null
+++ b/java/com/google/gerrit/server/RefLogIdentityProvider.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.stream.Collectors.joining;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import java.time.Instant;
+import java.time.ZoneId;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * Extension point that allows to control which identity should be recorded in the reflog for ref
+ * updates done by a user or done on behalf of a user.
+ */
+public interface RefLogIdentityProvider {
+ /**
+ * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+ * the reflog for ref updates done by this user or done on behalf of this user.
+ *
+ * <p>The returned {@link PersonIdent} is created with the current timestamp and the system
+ * default timezone.
+ *
+ * @param user the user for which a reflog identity should be created
+ */
+ default PersonIdent newRefLogIdent(IdentifiedUser user) {
+ return newRefLogIdent(user, Instant.now(), ZoneId.systemDefault());
+ }
+
+ /**
+ * Creates a {@link PersonIdent} for the given user that should be used as the user identity in
+ * the reflog for ref updates done by this user or done on behalf of this user.
+ *
+ * @param user the user for which a reflog identity should be created
+ * @param when the timestamp that should be used to create the {@link PersonIdent}
+ * @param zoneId the zone ID identifying the timezone that should be used to create the {@link
+ * PersonIdent}
+ */
+ PersonIdent newRefLogIdent(IdentifiedUser user, Instant when, ZoneId zoneId);
+
+ /**
+ * Creates a {@link PersonIdent} for the given users that should be used as the user identity in
+ * the reflog for ref updates done by these users or done on behalf of these users.
+ *
+ * <p>Usually ref updates are done by a single user or on behalf of a single user, but with {@link
+ * com.google.gerrit.server.update.BatchUpdate} it's possible that updates of different users are
+ * batched together into a single ref update.
+ *
+ * <p>If a single user is provided or all provided users reference the same account a reflog
+ * identity for that user/account is created and returned.
+ *
+ * <p>If multiple users (that reference different accounts) are provided a shared reflog identity
+ * is created and returned. The shared reflog identity lists all involved accounts. How the shared
+ * reflog identity looks like doesn't matter much, as long as it's not the reflog identity of a
+ * real user (e.g. if impersonated updates of multiple users are batched together it must not be
+ * the reflog identity of the real user).
+ *
+ * @param users the users for which a reflog identity should be created
+ * @param when the timestamp that should be used to create the {@link PersonIdent}
+ * @param zoneId the zone ID identifying the timezone that should be used to create the {@link
+ * PersonIdent}
+ */
+ default PersonIdent newRefLogIdent(
+ ImmutableList<IdentifiedUser> users, Instant when, ZoneId zoneId) {
+ checkState(!users.isEmpty(), "expected at least one user");
+
+ // If it's a single user create a reflog ident for that user.
+ // Use IdentifiedUser.newReflogIdent(Instant, ZoneId) rather than invoking
+ // #newRefLogIdent(IdentifiedUser, Instant ZoneId) directly, so that we can benefit from the
+ // reflog ident caching in IdentifiedUser.
+ if (users.size() == 1 || users.stream().allMatch(user -> user.hasSameAccountId(users.get(0)))) {
+ return users.get(0).newRefLogIdent(when, zoneId);
+ }
+
+ // Multiple users (for different accounts) have been provided. Create a shared relog identity
+ // that lists all involved accounts.
+ String accounts =
+ users.stream()
+ .map(IdentifiedUser::getAccountId)
+ .map(Account.Id::get)
+ .distinct()
+ .sorted()
+ .map(id -> "account-" + id)
+ .collect(joining("|"));
+ return new PersonIdent(
+ accounts, String.format("%s@%s", accounts, getDefaultDomain()), when, zoneId);
+ }
+
+ /**
+ * Returns the default domain for constructing email addresses if guessing the correct host is not
+ * possible.
+ */
+ default String getDefaultDomain() {
+ return "unknown";
+ }
+}
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 0f5629eeb2..2d1805451f 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
@@ -23,7 +24,6 @@ import com.google.auto.value.AutoValue;
import com.google.common.base.CharMatcher;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
@@ -32,7 +32,6 @@ import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.git.GitUpdateFailureException;
@@ -40,21 +39,18 @@ import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.ChangeField;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
-import java.util.List;
import java.util.NavigableSet;
+import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -80,6 +76,7 @@ public class StarredChangesUtil {
public abstract static class StarField {
private static final String SEPARATOR = ":";
+ @Nullable
public static StarField parse(String s) {
int p = s.indexOf(SEPARATOR);
if (p >= 0) {
@@ -166,20 +163,17 @@ public class StarredChangesUtil {
private final GitReferenceUpdated gitRefUpdated;
private final AllUsersName allUsers;
private final Provider<PersonIdent> serverIdent;
- private final Provider<InternalChangeQuery> queryProvider;
@Inject
StarredChangesUtil(
GitRepositoryManager repoManager,
GitReferenceUpdated gitRefUpdated,
AllUsersName allUsers,
- @GerritPersonIdent Provider<PersonIdent> serverIdent,
- Provider<InternalChangeQuery> queryProvider) {
+ @GerritPersonIdent Provider<PersonIdent> serverIdent) {
this.repoManager = repoManager;
this.gitRefUpdated = gitRefUpdated;
this.allUsers = allUsers;
this.serverIdent = serverIdent;
- this.queryProvider = queryProvider;
}
public NavigableSet<String> getLabels(Account.Id accountId, Change.Id changeId) {
@@ -194,7 +188,7 @@ public class StarredChangesUtil {
}
}
- public void star(Account.Id accountId, Project.NameKey project, Change.Id changeId, Operation op)
+ public void star(Account.Id accountId, Change.Id changeId, Operation op)
throws IllegalLabelException {
try (Repository repo = repoManager.openRepository(allUsers)) {
String refName = RefNames.refsStarredChanges(changeId, accountId);
@@ -238,7 +232,7 @@ public class StarredChangesUtil {
batchUpdate.setAllowNonFastForwards(true);
batchUpdate.setRefLogIdent(serverIdent.get());
batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
- for (Account.Id accountId : byChangeFromIndex(changeId).keySet()) {
+ for (Account.Id accountId : getStars(repo, changeId)) {
String refName = RefNames.refsStarredChanges(changeId, accountId);
Ref ref = repo.getRefDatabase().exactRef(refName);
if (ref != null) {
@@ -264,12 +258,7 @@ public class StarredChangesUtil {
public ImmutableMap<Account.Id, StarRef> byChange(Change.Id changeId) {
try (Repository repo = repoManager.openRepository(allUsers)) {
ImmutableMap.Builder<Account.Id, StarRef> builder = ImmutableMap.builder();
- for (String refPart : getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))) {
- Integer id = Ints.tryParse(refPart);
- if (id == null) {
- continue;
- }
- Account.Id accountId = Account.id(id);
+ for (Account.Id accountId : getStars(repo, changeId)) {
builder.put(accountId, readLabels(repo, RefNames.refsStarredChanges(changeId, accountId)));
}
return builder.build();
@@ -279,7 +268,7 @@ public class StarredChangesUtil {
}
}
- public ImmutableSet<Change.Id> byAccountId(Account.Id accountId, String label) {
+ public ImmutableSet<Change.Id> byAccountId(Account.Id accountId) {
try (Repository repo = repoManager.openRepository(allUsers)) {
ImmutableSet.Builder<Change.Id> builder = ImmutableSet.builder();
for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_STARRED_CHANGES)) {
@@ -290,7 +279,7 @@ public class StarredChangesUtil {
}
// Skip all refs that don't contain the required label.
StarRef starRef = readLabels(repo, ref.getName());
- if (!starRef.labels().contains(label)) {
+ if (!starRef.labels().contains(DEFAULT_LABEL)) {
continue;
}
@@ -308,22 +297,15 @@ public class StarredChangesUtil {
}
}
- public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId) {
- List<ChangeData> changeData =
- queryProvider
- .get()
- .setRequestedFields(ChangeField.ID, ChangeField.STAR)
- .byLegacyChangeId(changeId);
- if (changeData.size() != 1) {
- throw new NoSuchChangeException(changeId);
- }
- return changeData.get(0).stars();
- }
-
- private static Set<String> getRefNames(Repository repo, String prefix) throws IOException {
- RefDatabase refDb = repo.getRefDatabase();
+ private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+ throws IOException {
+ String prefix = RefNames.refsStarredChangesPrefix(changeId);
+ RefDatabase refDb = allUsers.getRefDatabase();
return refDb.getRefsByPrefix(prefix).stream()
.map(r -> r.getName().substring(prefix.length()))
+ .map(refPart -> Ints.tryParse(refPart))
+ .filter(Objects::nonNull)
+ .map(id -> Account.id(id))
.collect(toSet());
}
@@ -408,27 +390,29 @@ public class StarredChangesUtil {
u.setNewObjectId(writeLabels(repo, labels));
u.setRefLogIdent(serverIdent.get());
u.setRefLogMessage("Update star labels", true);
- RefUpdate.Result result = u.update(rw);
- switch (result) {
- case NEW:
- case FORCED:
- case NO_CHANGE:
- case FAST_FORWARD:
- gitRefUpdated.fire(allUsers, u, null);
- return;
- case LOCK_FAILURE:
- throw new LockFailureException(
- String.format("Update star labels on ref %s failed", refName), u);
- case IO_FAILURE:
- case NOT_ATTEMPTED:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- throw new StorageException(
- String.format("Update star labels on ref %s failed: %s", refName, result.name()));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ RefUpdate.Result result = u.update(rw);
+ switch (result) {
+ case NEW:
+ case FORCED:
+ case NO_CHANGE:
+ case FAST_FORWARD:
+ gitRefUpdated.fire(allUsers, u, null);
+ return;
+ case LOCK_FAILURE:
+ throw new LockFailureException(
+ String.format("Update star labels on ref %s failed", refName), u);
+ case IO_FAILURE:
+ case NOT_ATTEMPTED:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ throw new StorageException(
+ String.format("Update star labels on ref %s failed: %s", refName, result.name()));
+ }
}
}
}
@@ -447,26 +431,28 @@ public class StarredChangesUtil {
u.setExpectedOldObjectId(oldObjectId);
u.setRefLogIdent(serverIdent.get());
u.setRefLogMessage("Unstar change", true);
- RefUpdate.Result result = u.delete();
- switch (result) {
- case FORCED:
- gitRefUpdated.fire(allUsers, u, null);
- return;
- case LOCK_FAILURE:
- throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
- case NEW:
- case NO_CHANGE:
- case FAST_FORWARD:
- case IO_FAILURE:
- case NOT_ATTEMPTED:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- throw new StorageException(
- String.format("Delete star ref %s failed: %s", refName, result.name()));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ RefUpdate.Result result = u.delete();
+ switch (result) {
+ case FORCED:
+ gitRefUpdated.fire(allUsers, u, null);
+ return;
+ case LOCK_FAILURE:
+ throw new LockFailureException(String.format("Delete star ref %s failed", refName), u);
+ case NEW:
+ case NO_CHANGE:
+ case FAST_FORWARD:
+ case IO_FAILURE:
+ case NOT_ATTEMPTED:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ throw new StorageException(
+ String.format("Delete star ref %s failed: %s", refName, result.name()));
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index c80059b965..ca63565ce7 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -82,12 +82,12 @@ public class AccountControl {
* accounts.
*/
@UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
- public AccountControl get(IdentifiedUser identifiedUser) {
+ public AccountControl get(CurrentUser user) {
return new AccountControl(
permissionBackend,
projectCache,
groupControlFactory,
- identifiedUser,
+ user,
userFactory,
accountVisibility);
}
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java
index fac3233346..8f285b5987 100644
--- a/java/com/google/gerrit/server/account/AccountDelta.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -161,6 +161,11 @@ public abstract class AccountDelta {
*/
public abstract Optional<EditPreferencesInfo> getEditPreferences();
+ public boolean hasExternalIdUpdates() {
+ return !this.getCreatedExternalIds().isEmpty()
+ || !this.getDeletedExternalIds().isEmpty()
+ || !this.getUpdatedExternalIds().isEmpty();
+ }
/**
* Class to build an {@link AccountDelta}.
*
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 5549d288f8..d97563aa6a 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.account;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.PermissionRange;
import com.google.gerrit.entities.PermissionRule;
@@ -105,6 +106,7 @@ public class AccountLimits {
}
/** The range of permitted values associated with a label permission. */
+ @Nullable
public PermissionRange getRange(String permission) {
if (GlobalCapability.hasRange(permission)) {
return toRange(permission, getRules(permission));
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 891a467577..edec52c1f7 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -16,6 +16,8 @@ package com.google.gerrit.server.account;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
import com.google.common.annotations.VisibleForTesting;
@@ -146,10 +148,7 @@ public class AccountManager {
try {
Optional<ExternalId> optionalExtId = externalIds.get(who.getExternalIdKey());
if (!optionalExtId.isPresent()) {
- logger.atFine().log(
- "External ID for account %s not found. A new account will be automatically created.",
- who.getUserName());
- return create(who);
+ return createOrLinkAccount(who);
}
ExternalId extId = optionalExtId.get();
@@ -180,6 +179,46 @@ public class AccountManager {
}
}
+ /**
+ * Determines if a new account should be created or if we should link to an existing account.
+ *
+ * @param who identity of the user, with any details we received about them.
+ * @return the result of authenticating the user.
+ * @throws AccountException the account does not exist, and cannot be created, or exists, but
+ * cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
+ * added to the admin group (only for the first account).
+ */
+ private AuthResult createOrLinkAccount(AuthRequest who)
+ throws AccountException, IOException, ConfigInvalidException {
+ // TODO: in case of extension of further migration paths this code should
+ // probably be refactored out by creating an AccountMigrator extension point.
+ if (who.getExternalIdKey().isScheme(SCHEME_GOOGLE_OAUTH)) {
+ Optional<ExternalId> existingLDAPExtID = findLdapExternalId(who);
+ if (existingLDAPExtID.isPresent()) {
+ return migrateLdapAccountToOauth(who, existingLDAPExtID.get());
+ }
+ }
+ logger.atFine().log(
+ "External ID for account %s not found. A new account will be automatically created.",
+ who.getEmailAddress());
+ return create(who);
+ }
+
+ private AuthResult migrateLdapAccountToOauth(AuthRequest who, ExternalId ldapExternalId)
+ throws AccountException, IOException, ConfigInvalidException {
+ Account.Id extAccId = ldapExternalId.accountId();
+ AuthResult res = link(extAccId, who);
+ accountsUpdateProvider
+ .get()
+ .update(
+ "remove existing LDAP externalId with matching e-mail",
+ extAccId,
+ u -> {
+ u.deleteExternalId(ldapExternalId);
+ });
+ return res;
+ }
+
private void deactivateAccountIfItExists(AuthRequest authRequest) {
if (!shouldUpdateActiveStatus(authRequest)) {
return;
@@ -277,6 +316,17 @@ public class AccountManager {
}
}
+ private Optional<ExternalId> findLdapExternalId(AuthRequest who) throws IOException {
+ String email = who.getEmailAddress();
+ if (email == null || email.isEmpty()) {
+ return Optional.empty();
+ }
+
+ Optional<ExternalId> ldapExternalId =
+ externalIds.byEmail(email).stream().filter(a -> a.isScheme(SCHEME_GERRIT)).findFirst();
+ return ldapExternalId;
+ }
+
private AuthResult create(AuthRequest who)
throws AccountException, IOException, ConfigInvalidException {
Account.Id newId = Account.id(sequences.nextAccountId());
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 8824d563f3..2020d2f5b4 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -25,6 +25,7 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
@@ -43,6 +44,7 @@ import com.google.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@@ -132,12 +134,18 @@ public class AccountResolver {
private final String input;
private final ImmutableList<AccountState> list;
private final ImmutableList<AccountState> filteredInactive;
+ private final CurrentUser searchedAsUser;
@VisibleForTesting
- Result(String input, List<AccountState> list, List<AccountState> filteredInactive) {
+ Result(
+ String input,
+ List<AccountState> list,
+ List<AccountState> filteredInactive,
+ CurrentUser searchedAsUser) {
this.input = requireNonNull(input);
this.list = canonicalize(list);
this.filteredInactive = canonicalize(filteredInactive);
+ this.searchedAsUser = requireNonNull(searchedAsUser);
}
private ImmutableList<AccountState> canonicalize(List<AccountState> list) {
@@ -180,13 +188,21 @@ public class AccountResolver {
}
}
- public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
+ private void ensureSelfIsUniqueIdentifiedUser() throws UnresolvableAccountException {
ensureUnique();
+ if (!searchedAsUser.isIdentifiedUser()) {
+ throw new UnresolvableAccountException(this);
+ }
+ }
+
+ public IdentifiedUser asUniqueUser() throws UnresolvableAccountException {
if (isSelf()) {
+ ensureSelfIsUniqueIdentifiedUser();
// In the special case of "self", use the exact IdentifiedUser from the request context, to
// preserve the peer address and any other per-request state.
- return self.get().asIdentifiedUser();
+ return searchedAsUser.asIdentifiedUser();
}
+ ensureUnique();
return userFactory.create(asUnique());
}
@@ -194,11 +210,10 @@ public class AccountResolver {
throws UnresolvableAccountException {
ensureUnique();
if (isSelf()) {
- // TODO(dborowitz): This preserves old behavior, but it seems wrong to discard the caller.
- return self.get().asIdentifiedUser();
+ return searchedAsUser.asIdentifiedUser();
}
return userFactory.runAs(
- null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
+ /* remotePeer= */ null, list.get(0).account().id(), requireNonNull(caller).getRealUser());
}
@VisibleForTesting
@@ -217,20 +232,57 @@ public class AccountResolver {
return true;
}
- default boolean callerMayAssumeCandidatesAreVisible() {
+ /**
+ * Searches can be done on behalf of either the current user or another provided user. The
+ * results of some searchers, such as BySelf, are affected by the context user.
+ */
+ default boolean requiresContextUser() {
return false;
}
Optional<I> tryParse(String input) throws IOException;
- Stream<AccountState> search(I input) throws IOException, ConfigInvalidException;
+ /**
+ * This method should be implemented for every searcher which doesn't require a context user.
+ *
+ * @param input to search for
+ * @return stream of the matching accounts
+ * @throws IOException by some subclasses
+ * @throws ConfigInvalidException by some subclasses
+ */
+ default Stream<AccountState> search(I input) throws IOException, ConfigInvalidException {
+ throw new IllegalStateException("search(I) default implementation should never be called.");
+ }
+
+ /**
+ * This method should be implemented for every searcher which requires a context user.
+ *
+ * @param input to search for
+ * @param asUser the context user for the search
+ * @return stream of the matching accounts
+ * @throws IOException by some subclasses
+ * @throws ConfigInvalidException by some subclasses
+ */
+ default Stream<AccountState> search(I input, CurrentUser asUser)
+ throws IOException, ConfigInvalidException {
+ if (!requiresContextUser()) {
+ return search(input);
+ }
+ throw new IllegalStateException(
+ "The searcher requires a context user, but doesn't implement search(input, asUser).");
+ }
boolean shortCircuitIfNoResults();
- default Optional<Stream<AccountState>> trySearch(String input)
+ default Optional<Stream<AccountState>> trySearch(String input, CurrentUser asUser)
throws IOException, ConfigInvalidException {
Optional<I> parsed = tryParse(input);
- return parsed.isPresent() ? Optional.of(search(parsed.get())) : Optional.empty();
+ if (parsed.isEmpty()) {
+ return Optional.empty();
+ }
+ return requiresContextUser()
+ ? Optional.of(search(parsed.get(), asUser))
+ : Optional.of(search(parsed.get()));
}
}
@@ -251,14 +303,14 @@ public class AccountResolver {
}
}
- private class BySelf extends StringSearcher {
+ private static class BySelf extends StringSearcher {
@Override
public boolean callerShouldFilterOutInactiveCandidates() {
return false;
}
@Override
- public boolean callerMayAssumeCandidatesAreVisible() {
+ public boolean requiresContextUser() {
return true;
}
@@ -268,12 +320,11 @@ public class AccountResolver {
}
@Override
- public Stream<AccountState> search(String input) {
- CurrentUser user = self.get();
- if (!user.isIdentifiedUser()) {
+ public Stream<AccountState> search(String input, CurrentUser asUser) {
+ if (!asUser.isIdentifiedUser()) {
return Stream.empty();
}
- return Stream.of(user.asIdentifiedUser().state());
+ return Stream.of(asUser.asIdentifiedUser().state());
}
@Override
@@ -372,13 +423,70 @@ public class AccountResolver {
private class ByEmail extends StringSearcher {
@Override
+ public boolean requiresContextUser() {
+ return true;
+ }
+
+ @Override
protected boolean matches(String input) {
return input.contains("@");
}
@Override
- public Stream<AccountState> search(String input) throws IOException {
- return toAccountStates(emails.getAccountFor(input));
+ public Stream<AccountState> search(String input, CurrentUser asUser) throws IOException {
+ boolean canViewSecondaryEmails = false;
+ try {
+ if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+ canViewSecondaryEmails = true;
+ }
+ } catch (PermissionBackendException e) {
+ // remains false
+ }
+
+ if (canViewSecondaryEmails) {
+ return toAccountStates(emails.getAccountFor(input));
+ }
+
+ // User cannot see secondary emails, hence search by preferred email only.
+ List<AccountState> accountStates = accountQueryProvider.get().byPreferredEmail(input);
+
+ if (accountStates.size() == 1) {
+ return Stream.of(Iterables.getOnlyElement(accountStates));
+ }
+
+ if (accountStates.size() > 1) {
+ // An email can only belong to a single account. If multiple accounts are found it means
+ // there is an inconsistency, i.e. some of the found accounts have a preferred email set
+ // that they do not own via an external ID. Hence in this case we return only the one
+ // account that actually owns the email via an external ID.
+ for (AccountState accountState : accountStates) {
+ if (accountState.externalIds().stream()
+ .map(ExternalId::email)
+ .filter(Objects::nonNull)
+ .anyMatch(email -> email.equals(input))) {
+ return Stream.of(accountState);
+ }
+ }
+
+ // None of the matched accounts owns the email, return all matches to be consistent with
+ // the behavior of Emails.getAccountFor(String) that is used above if the user can see
+ // secondary emails.
+ return accountStates.stream();
+ }
+
+ // No match by preferred email. Since users can always see their own secondary emails, check
+ // if the input matches a secondary email of the user and if yes, return the account of the
+ // user.
+ if (asUser.isIdentifiedUser()
+ && asUser.asIdentifiedUser().state().externalIds().stream()
+ .map(ExternalId::email)
+ .filter(Objects::nonNull)
+ .anyMatch(email -> email.equals(input))) {
+ return Stream.of(asUser.asIdentifiedUser().state());
+ }
+
+ // No match.
+ return Stream.empty();
}
@Override
@@ -399,22 +507,19 @@ public class AccountResolver {
}
}
- private class ByFullName implements Searcher<AccountState> {
- @Override
- public boolean callerMayAssumeCandidatesAreVisible() {
- return true; // Rely on enforceVisibility from the index.
+ private class ByFullName extends StringSearcher {
+ ByFullName() {
+ super();
}
@Override
- public Optional<AccountState> tryParse(String input) {
- List<AccountState> results =
- accountQueryProvider.get().enforceVisibility(true).byFullName(input);
- return results.size() == 1 ? Optional.of(results.get(0)) : Optional.empty();
+ protected boolean matches(String input) {
+ return true;
}
@Override
- public Stream<AccountState> search(AccountState input) {
- return Stream.of(input);
+ public Stream<AccountState> search(String input) {
+ return accountQueryProvider.get().byFullName(input).stream();
}
@Override
@@ -424,9 +529,13 @@ public class AccountResolver {
}
private class ByDefaultSearch extends StringSearcher {
+ ByDefaultSearch() {
+ super();
+ }
+
@Override
- public boolean callerMayAssumeCandidatesAreVisible() {
- return true; // Rely on enforceVisibility from the index.
+ public boolean requiresContextUser() {
+ return true;
}
@Override
@@ -435,21 +544,20 @@ public class AccountResolver {
}
@Override
- public Stream<AccountState> search(String input) {
+ public Stream<AccountState> search(String input, CurrentUser asUser) {
// At this point we have no clue. Just perform a whole bunch of suggestions and pray we come
// up with a reasonable result list.
// TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
// more strict here.
- boolean canSeeSecondaryEmails = false;
+ boolean canViewSecondaryEmails = false;
try {
- if (permissionBackend.user(self.get()).test(GlobalPermission.MODIFY_ACCOUNT)) {
- canSeeSecondaryEmails = true;
+ if (permissionBackend.user(asUser).test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+ canViewSecondaryEmails = true;
}
} catch (PermissionBackendException e) {
// remains false
}
- return accountQueryProvider.get().enforceVisibility(true)
- .byDefault(input, canSeeSecondaryEmails).stream();
+ return accountQueryProvider.get().byDefault(input, canViewSecondaryEmails).stream();
}
@Override
@@ -538,12 +646,59 @@ public class AccountResolver {
* @throws IOException if an error occurs.
*/
public Result resolve(String input) throws ConfigInvalidException, IOException {
- return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::isActive);
+ return searchImpl(
+ input, searchers, self.get(), this::currentUserCanSeePredicate, AccountResolver::isActive);
}
public Result resolve(String input, Predicate<AccountState> accountActivityPredicate)
throws ConfigInvalidException, IOException {
- return searchImpl(input, searchers, this::canSeePredicate, accountActivityPredicate);
+ return searchImpl(
+ input, searchers, self.get(), this::currentUserCanSeePredicate, accountActivityPredicate);
+ }
+
+ /**
+ * Resolves all accounts matching the input string, visible to the provided user.
+ *
+ * <p>The following input formats are recognized:
+ *
+ * <ul>
+ * <li>The strings {@code "self"} and {@code "me"}, if the provided user is an {@link
+ * IdentifiedUser}. In this case, may return exactly one inactive account.
+ * <li>A bare account ID ({@code "18419"}). In this case, may return exactly one inactive
+ * account. This case short-circuits if the input matches.
+ * <li>An account ID in parentheses following a full name ({@code "Full Name (18419)"}). This
+ * case short-circuits if the input matches.
+ * <li>A username ({@code "username"}).
+ * <li>A full name and email address ({@code "Full Name <email@example>"}). This case
+ * short-circuits if the input matches.
+ * <li>An email address ({@code "email@example"}. This case short-circuits if the input matches.
+ * <li>An account name recognized by the configured {@link Realm#lookup(String)} Realm}.
+ * <li>A full name ({@code "Full Name"}).
+ * <li>As a fallback, a {@link
+ * com.google.gerrit.server.query.account.AccountPredicates#defaultPredicate(Schema,
+ * boolean, String) default search} against the account index.
+ * </ul>
+ *
+ * @param asUser user to resolve the users by.
+ * @param input input string.
+ * @return a result describing matching accounts. Never null even if the result set is empty.
+ * @throws ConfigInvalidException if an error occurs.
+ * @throws IOException if an error occurs.
+ */
+ public Result resolveAsUser(CurrentUser asUser, String input)
+ throws ConfigInvalidException, IOException {
+ return resolveAsUser(asUser, input, AccountResolver::isActive);
+ }
+
+ public Result resolveAsUser(
+ CurrentUser asUser, String input, Predicate<AccountState> accountActivityPredicate)
+ throws ConfigInvalidException, IOException {
+ return searchImpl(
+ input,
+ searchers,
+ asUser,
+ new ProvidedUserCanSeePredicate(asUser),
+ accountActivityPredicate);
}
/**
@@ -556,17 +711,35 @@ public class AccountResolver {
* instead will be stored as a link to the corresponding Gerrit Account.
*/
public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
- return searchImpl(input, searchers, this::canSeePredicate, AccountResolver::allVisible);
+ return searchImpl(
+ input,
+ searchers,
+ self.get(),
+ this::currentUserCanSeePredicate,
+ AccountResolver::allVisible);
+ }
+
+ public Result resolveIncludeInactiveIgnoreVisibility(String input)
+ throws ConfigInvalidException, IOException {
+ return searchImpl(
+ input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::allVisible);
}
public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
- return searchImpl(input, searchers, this::allVisiblePredicate, AccountResolver::isActive);
+ return searchImpl(
+ input, searchers, self.get(), this::allVisiblePredicate, AccountResolver::isActive);
}
- public Result resolveIgnoreVisibility(
- String input, Predicate<AccountState> accountActivityPredicate)
+ public Result resolveAsUserIgnoreVisibility(CurrentUser asUser, String input)
throws ConfigInvalidException, IOException {
- return searchImpl(input, searchers, this::allVisiblePredicate, accountActivityPredicate);
+ return resolveAsUserIgnoreVisibility(asUser, input, AccountResolver::isActive);
+ }
+
+ public Result resolveAsUserIgnoreVisibility(
+ CurrentUser asUser, String input, Predicate<AccountState> accountActivityPredicate)
+ throws ConfigInvalidException, IOException {
+ return searchImpl(
+ input, searchers, asUser, this::allVisiblePredicate, accountActivityPredicate);
}
/**
@@ -595,7 +768,11 @@ public class AccountResolver {
@Deprecated
public Result resolveByNameOrEmail(String input) throws ConfigInvalidException, IOException {
return searchImpl(
- input, nameOrEmailSearchers, this::canSeePredicate, AccountResolver::isActive);
+ input,
+ nameOrEmailSearchers,
+ self.get(),
+ this::currentUserCanSeePredicate,
+ AccountResolver::isActive);
}
/**
@@ -614,16 +791,26 @@ public class AccountResolver {
return searchImpl(
input,
ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
- this::canSeePredicate,
+ self.get(),
+ this::currentUserCanSeePredicate,
AccountResolver::isActive);
}
- private Predicate<AccountState> canSeePredicate() {
- return this::canSee;
+ private Predicate<AccountState> currentUserCanSeePredicate() {
+ return accountControlFactory.get()::canSee;
}
- private boolean canSee(AccountState accountState) {
- return accountControlFactory.get().canSee(accountState);
+ private class ProvidedUserCanSeePredicate implements Supplier<Predicate<AccountState>> {
+ CurrentUser asUser;
+
+ ProvidedUserCanSeePredicate(CurrentUser asUser) {
+ this.asUser = asUser;
+ }
+
+ @Override
+ public Predicate<AccountState> get() {
+ return accountControlFactory.get(asUser)::canSee;
+ }
}
private Predicate<AccountState> allVisiblePredicate() {
@@ -643,22 +830,24 @@ public class AccountResolver {
Result searchImpl(
String input,
ImmutableList<Searcher<?>> searchers,
+ CurrentUser asUser,
Supplier<Predicate<AccountState>> visibilitySupplier,
Predicate<AccountState> accountActivityPredicate)
throws ConfigInvalidException, IOException {
+ requireNonNull(asUser);
visibilitySupplier = Suppliers.memoize(visibilitySupplier::get);
List<AccountState> inactive = new ArrayList<>();
for (Searcher<?> searcher : searchers) {
- Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input);
+ Optional<Stream<AccountState>> maybeResults = searcher.trySearch(input, asUser);
if (!maybeResults.isPresent()) {
continue;
}
Stream<AccountState> results = maybeResults.get();
- if (!searcher.callerMayAssumeCandidatesAreVisible()) {
- results = results.filter(visibilitySupplier.get());
- }
+ // Filter out non-visible results, except if it's the BySelf searcher. Since users can always
+ // see themselves checking the visibility is not needed for the BySelf searcher.
+ results = searcher instanceof BySelf ? results : results.filter(visibilitySupplier.get());
List<AccountState> list;
if (searcher.callerShouldFilterOutInactiveCandidates()) {
@@ -672,22 +861,25 @@ public class AccountResolver {
}
if (!list.isEmpty()) {
- return createResult(input, list);
+ return createResult(input, list, asUser);
}
if (searcher.shortCircuitIfNoResults()) {
// For a short-circuiting searcher, return results even if empty.
- return !inactive.isEmpty() ? emptyResult(input, inactive) : createResult(input, list);
+ return !inactive.isEmpty()
+ ? emptyResult(input, inactive, asUser)
+ : createResult(input, list, asUser);
}
}
- return emptyResult(input, inactive);
+ return emptyResult(input, inactive, asUser);
}
- private Result createResult(String input, List<AccountState> list) {
- return new Result(input, list, ImmutableList.of());
+ private Result createResult(String input, List<AccountState> list, CurrentUser searchedAsUser) {
+ return new Result(input, list, ImmutableList.of(), searchedAsUser);
}
- private Result emptyResult(String input, List<AccountState> inactive) {
- return new Result(input, ImmutableList.of(), inactive);
+ private Result emptyResult(
+ String input, List<AccountState> inactive, CurrentUser searchedAsUser) {
+ return new Result(input, ImmutableList.of(), inactive, searchedAsUser);
}
private Stream<AccountState> toAccountStates(Set<Account.Id> ids) {
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 8d5fea430e..b706bca933 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.account;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
+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;
import static java.util.stream.Collectors.toSet;
@@ -25,6 +26,7 @@ import com.google.common.base.Strings;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.exceptions.StorageException;
@@ -45,6 +47,7 @@ import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.RetryableAction.Action;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
@@ -199,10 +202,9 @@ public class AccountsUpdate {
private final Runnable beforeCommit;
/** Single instance that accumulates updates from the batch. */
- private ExternalIdNotes externalIdNotes;
+ @Nullable private ExternalIdNotes externalIdNotes;
@AssistedInject
- @SuppressWarnings("BindingAnnotationWithoutInject")
AccountsUpdate(
GitRepositoryManager repoManager,
GitReferenceUpdated gitRefUpdated,
@@ -228,7 +230,6 @@ public class AccountsUpdate {
}
@AssistedInject
- @SuppressWarnings("BindingAnnotationWithoutInject")
AccountsUpdate(
GitRepositoryManager repoManager,
GitReferenceUpdated gitRefUpdated,
@@ -402,13 +403,17 @@ public class AccountsUpdate {
delta.getDeletedExternalIds()),
updateArguments.accountId);
- if (externalIdNotes == null) {
- externalIdNotes =
- extIdNotesLoader.load(
- repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+ if (delta.hasExternalIdUpdates()) {
+ // Only load the externalIds if they are going to be updated
+ // This makes e.g. preferences updates faster.
+ if (externalIdNotes == null) {
+ externalIdNotes =
+ extIdNotesLoader.load(
+ repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+ }
+ externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
+ externalIdNotes.upsert(delta.getUpdatedExternalIds());
}
- externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
- externalIdNotes.upsert(delta.getUpdatedExternalIds());
CachedPreferences cachedDefaultPreferences =
CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
@@ -445,28 +450,32 @@ public class AccountsUpdate {
private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
throws IOException, ConfigInvalidException {
- List<Optional<AccountState>> accountState = new ArrayList<>();
- List<UpdatedAccount> updatedAccounts = new ArrayList<>();
- executeWithRetry(
- () -> {
- // Reset state for retry.
- externalIdNotes = null;
- accountState.clear();
- updatedAccounts.clear();
-
- try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
- for (ExecutableUpdate executableUpdate : executableUpdates) {
- updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+ try (RefUpdateContext ctx = RefUpdateContext.open(ACCOUNTS_UPDATE)) {
+ List<Optional<AccountState>> accountState = new ArrayList<>();
+ List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+ executeWithRetry(
+ () -> {
+
+ // Reset state for retry.
+ externalIdNotes = null;
+ accountState.clear();
+ updatedAccounts.clear();
+ try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+ for (ExecutableUpdate executableUpdate : executableUpdates) {
+ updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+ }
+ commit(
+ allUsersRepo,
+ updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+ for (UpdatedAccount ua : updatedAccounts) {
+ accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+ }
}
- commit(
- allUsersRepo, updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
- for (UpdatedAccount ua : updatedAccounts) {
- accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
- }
- }
- return null;
- });
- return ImmutableList.copyOf(accountState);
+ return null;
+ });
+
+ return ImmutableList.copyOf(accountState);
+ }
}
private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
@@ -505,17 +514,22 @@ public class AccountsUpdate {
beforeCommit.run();
BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
-
- String externalIdUpdateMessage =
- updatedAccounts.size() == 1
- ? Iterables.getOnlyElement(updatedAccounts).message
- : "Batch update for " + updatedAccounts.size() + " accounts";
- ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
- // These update the same ref, so they need to be stacked on top of one another using the same
- // ExternalIdNotes instance.
- RevCommit revCommit =
- commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
- boolean externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
+ // External ids may be not updated if:
+ // * externalIdNotes is not loaded (there were no externalId updates in the delta)
+ // * new revCommit is identical to the previous externalId tip
+ boolean externalIdsUpdated = false;
+ if (externalIdNotes != null) {
+ String externalIdUpdateMessage =
+ updatedAccounts.size() == 1
+ ? Iterables.getOnlyElement(updatedAccounts).message
+ : "Batch update for " + updatedAccounts.size() + " accounts";
+ ObjectId oldExternalIdsRevision = externalIdNotes.getRevision();
+ // These update the same ref, so they need to be stacked on top of one another using the same
+ // ExternalIdNotes instance.
+ RevCommit revCommit =
+ commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+ externalIdsUpdated = !Objects.equals(revCommit.getId(), oldExternalIdsRevision);
+ }
for (UpdatedAccount updatedAccount : updatedAccounts) {
// These updates are all for different refs (because batches never update the same account
@@ -540,8 +554,10 @@ public class AccountsUpdate {
RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
- extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
- externalIdNotes, accountsToSkipForReindex);
+ if (externalIdsUpdated) {
+ extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
+ externalIdNotes, accountsToSkipForReindex);
+ }
gitRefUpdated.fire(
allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
index cceda70fe2..cf1e5522f7 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.account;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
import com.google.common.base.Strings;
@@ -66,6 +67,14 @@ public class AuthRequest {
return r;
}
+ public AuthRequest createForOAuthUser(String userName) {
+ AuthRequest r =
+ new AuthRequest(
+ externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, userName), externalIdKeyFactory);
+ r.setUserName(userName);
+ return r;
+ }
+
/**
* Create a request for an email address registration.
*
@@ -102,6 +111,7 @@ public class AuthRequest {
return externalId;
}
+ @Nullable
public String getLocalUser() {
if (externalId.isScheme(SCHEME_GERRIT)) {
return externalId.id();
diff --git a/java/com/google/gerrit/server/account/CreateGroupArgs.java b/java/com/google/gerrit/server/account/CreateGroupArgs.java
index ba58c3f0ae..9d9fe9d21a 100644
--- a/java/com/google/gerrit/server/account/CreateGroupArgs.java
+++ b/java/com/google/gerrit/server/account/CreateGroupArgs.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.account;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import java.util.Collection;
@@ -30,6 +31,7 @@ public class CreateGroupArgs {
return groupName;
}
+ @Nullable
public String getGroupName() {
return groupName != null ? groupName.get() : null;
}
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java
index 329825f4d1..cfffceb11b 100644
--- a/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.account;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.AccountFieldName;
@@ -79,6 +80,7 @@ public class DefaultRealm extends AbstractRealm {
@Override
public void onCreateAccount(AuthRequest who, Account account) {}
+ @Nullable
@Override
public Account.Id lookup(String accountName) throws IOException {
if (emailExpander.canExpand(accountName)) {
diff --git a/java/com/google/gerrit/server/account/DestinationList.java b/java/com/google/gerrit/server/account/DestinationList.java
index 15c1e25af0..084a3acced 100644
--- a/java/com/google/gerrit/server/account/DestinationList.java
+++ b/java/com/google/gerrit/server/account/DestinationList.java
@@ -18,6 +18,7 @@ import com.google.common.collect.Lists;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.git.ValidationError;
@@ -39,6 +40,7 @@ public class DestinationList extends TabFile {
destinations.replaceValues(label, toSet(parse(text, DIR_NAME + label, TRIM, null, errors)));
}
+ @Nullable
String asText(String label) {
Set<BranchNameKey> dests = destinations.get(label);
if (dests == null) {
diff --git a/java/com/google/gerrit/server/account/Emails.java b/java/com/google/gerrit/server/account/Emails.java
index 8c3f033169..13385d0156 100644
--- a/java/com/google/gerrit/server/account/Emails.java
+++ b/java/com/google/gerrit/server/account/Emails.java
@@ -57,12 +57,11 @@ public class Emails {
* are needed it is more efficient to use {@link #getAccountsFor(String...)} as this method reads
* the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
*
- * <p>In addition accounts are included that have the given email as preferred email even if they
- * have no external ID for the preferred email. Having accounts with a preferred email that does
- * not exist as external ID is an inconsistency, but existing functionality relies on still
- * getting those accounts, which is why they are included. Accounts by preferred email are fetched
- * from the account index as a fallback for email addresses that could not be resolved using
- * {@link ExternalIds}.
+ * <p>If there is no account that owns the email via an external ID all accounts that have the
+ * email set as a preferred email are returned. Having accounts with a preferred email that does
+ * not exist as external ID is an inconsistency, but existing functionality relies on getting
+ * those accounts, which is why they are returned as a fall-back by fetching them from the account
+ * index.
*
* @see #getAccountsFor(String...)
*/
diff --git a/java/com/google/gerrit/server/account/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index 1e28d7dc54..46c730c0ef 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,11 +14,15 @@
package com.google.gerrit.server.account;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.exceptions.StorageException;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
/** Tracks group objects in memory for efficient access. */
public interface GroupCache {
@@ -62,6 +66,22 @@ public interface GroupCache {
Map<AccountGroup.UUID, InternalGroup> get(Collection<AccountGroup.UUID> groupUuids);
/**
+ * Returns an {@code InternalGroup} instance for the given {@code AccountGroup.UUID} at the given
+ * {@code metaId} of {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+ *
+ * <p>The caller is responsible to ensure the presence of {@code metaId} and the corresponding
+ * meta ref.
+ *
+ * @param groupUuid the UUID of the internal group
+ * @param metaId the sha1 of commit in {@link com.google.gerrit.entities.RefNames#refsGroups} ref.
+ * @return the internal group at specific sha1 {@code metaId}
+ * @throws StorageException if no internal group with this UUID exists on this server at the
+ * specific sha1, or if an error occurred during lookup.
+ */
+ @UsedAt(Project.GOOGLE)
+ InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId) throws StorageException;
+
+ /**
* Removes the association of the given ID with a group.
*
* <p>The next call to {@link #get(AccountGroup.Id)} won't provide a cached value.
diff --git a/java/com/google/gerrit/server/account/GroupCacheImpl.java b/java/com/google/gerrit/server/account/GroupCacheImpl.java
index eaec9baecd..6f4fce9434 100644
--- a/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -23,9 +23,11 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.cache.proto.Cache;
@@ -121,15 +123,19 @@ public class GroupCacheImpl implements GroupCache {
private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
private final LoadingCache<String, Optional<InternalGroup>> byName;
private final LoadingCache<String, Optional<InternalGroup>> byUUID;
+ private final LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache;
@Inject
GroupCacheImpl(
@Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
@Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
- @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID) {
+ @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
+ @Named(BYUUID_NAME_PERSISTED)
+ LoadingCache<Cache.GroupKeyProto, InternalGroup> persistedByUuidCache) {
this.byId = byId;
this.byName = byName;
this.byUUID = byUUID;
+ this.persistedByUuidCache = persistedByUuidCache;
}
@Override
@@ -184,6 +190,21 @@ public class GroupCacheImpl implements GroupCache {
}
@Override
+ public InternalGroup getFromMetaId(AccountGroup.UUID groupUuid, ObjectId metaId)
+ throws StorageException {
+ Cache.GroupKeyProto key =
+ Cache.GroupKeyProto.newBuilder()
+ .setUuid(groupUuid.get())
+ .setRevision(ObjectIdConverter.create().toByteString(metaId))
+ .build();
+ try {
+ return persistedByUuidCache.get(key);
+ } catch (ExecutionException e) {
+ throw new StorageException(e);
+ }
+ }
+
+ @Override
public void evict(AccountGroup.Id groupId) {
if (groupId != null) {
logger.atFine().log("Evict group %s by ID", groupId.get());
@@ -346,6 +367,7 @@ public class GroupCacheImpl implements GroupCache {
return Protos.toByteArray(InternalGroupSerializer.serialize(value));
}
+ @Nullable
@Override
public InternalGroup deserialize(byte[] in) {
if (Strings.fromByteArray(in).isEmpty()) {
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCache.java b/java/com/google/gerrit/server/account/GroupIncludeCache.java
index d92d9fc105..266f85829c 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -16,7 +16,9 @@ package com.google.gerrit.server.account;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountGroup.UUID;
import java.util.Collection;
+import java.util.Set;
/** Tracks group inclusions in memory for efficient access. */
public interface GroupIncludeCache {
@@ -37,6 +39,14 @@ public interface GroupIncludeCache {
*/
Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
+ /**
+ * Returns the parent groups of the provided subgroups.
+ *
+ * @param groupId the UUID of the subgroup
+ * @return the UUIDs of all direct parent groups
+ */
+ Collection<AccountGroup.UUID> parentGroupsOf(Set<UUID> groupId);
+
/** Returns set of any UUIDs that are not internal groups. */
Collection<AccountGroup.UUID> allExternalMembers();
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index f20324028c..fc6087b5be 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -22,6 +22,8 @@ import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -45,6 +47,9 @@ import com.google.inject.TypeLiteral;
import com.google.inject.name.Named;
import java.util.Collection;
import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ExecutionException;
/** Tracks group inclusions in memory for efficient access. */
@@ -70,7 +75,7 @@ public class GroupIncludeCacheImpl implements GroupIncludeCache {
cache(
PARENT_GROUPS_NAME,
AccountGroup.UUID.class,
- new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
+ new TypeLiteral<ImmutableSet<AccountGroup.UUID>>() {})
.loader(ParentGroupsLoader.class);
/**
@@ -101,7 +106,7 @@ public class GroupIncludeCacheImpl implements GroupIncludeCache {
}
private final LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember;
- private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups;
+ private final LoadingCache<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> parentGroups;
private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
@Inject
@@ -109,7 +114,7 @@ public class GroupIncludeCacheImpl implements GroupIncludeCache {
@Named(GROUPS_WITH_MEMBER_NAME)
LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember,
@Named(PARENT_GROUPS_NAME)
- LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups,
+ LoadingCache<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> parentGroups,
@Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
this.groupsWithMember = groupsWithMember;
this.parentGroups = parentGroups;
@@ -137,6 +142,18 @@ public class GroupIncludeCacheImpl implements GroupIncludeCache {
}
@Override
+ public Collection<AccountGroup.UUID> parentGroupsOf(Set<AccountGroup.UUID> groupIds) {
+ try {
+ Set<AccountGroup.UUID> parents = new HashSet<>();
+ parentGroups.getAll(groupIds).values().forEach(p -> parents.addAll(p));
+ return parents;
+ } catch (ExecutionException e) {
+ logger.atWarning().withCause(e).log("Cannot load included groups");
+ return Collections.emptySet();
+ }
+ }
+
+ @Override
public void evictGroupsWithMember(Account.Id memberId) {
if (memberId != null) {
logger.atFine().log("Evict groups with member %d", memberId.get());
@@ -194,7 +211,10 @@ public class GroupIncludeCacheImpl implements GroupIncludeCache {
}
static class ParentGroupsLoader
- extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
+ extends CacheLoader<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> {
+ // Be conservative with batching: We don't want to exhaust the number of
+ // results per page and maximum terms per query. Both are usually 1000+.
+ private static final int MAX_BATCH_SIZE = 100;
private final Provider<InternalGroupQuery> groupQueryProvider;
@Inject
@@ -203,13 +223,26 @@ public class GroupIncludeCacheImpl implements GroupIncludeCache {
}
@Override
- public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) {
+ public ImmutableSet<AccountGroup.UUID> load(AccountGroup.UUID key) {
try (TraceTimer timer =
TraceContext.newTimer(
"Loading parent groups", Metadata.builder().groupUuid(key.get()).build())) {
- return groupQueryProvider.get().bySubgroup(key).stream()
- .map(InternalGroup::getGroupUUID)
- .collect(toImmutableList());
+ return loadAll(ImmutableList.of(key)).get(key);
+ }
+ }
+
+ @Override
+ public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> loadAll(
+ Iterable<? extends AccountGroup.UUID> keys) {
+ int numKeys = Iterables.size(keys);
+ Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> result =
+ Maps.newHashMapWithExpectedSize(numKeys);
+ try (TraceTimer timer = TraceContext.newTimer("Loading " + numKeys + " parent groups")) {
+ Iterables.partition(keys, MAX_BATCH_SIZE)
+ .forEach(
+ keyPartition ->
+ result.putAll(groupQueryProvider.get().bySubgroups(ImmutableSet.copyOf(keys))));
+ return result;
}
}
}
diff --git a/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 8cec8bf1c9..e1edf10b9c 100644
--- a/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -16,7 +16,6 @@ package com.google.gerrit.server.account;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
@@ -25,7 +24,6 @@ import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.util.Collection;
import java.util.HashSet;
-import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -135,7 +133,7 @@ public class IncludingGroupMembership implements GroupMembership {
Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
r.remove(null);
- List<AccountGroup.UUID> q = Lists.newArrayList(r);
+ Set<AccountGroup.UUID> q = Sets.newHashSet(r);
for (AccountGroup.UUID g : membership.intersection(includeCache.allExternalMembers())) {
if (g != null && r.add(g)) {
q.add(g);
@@ -143,9 +141,10 @@ public class IncludingGroupMembership implements GroupMembership {
}
while (!q.isEmpty()) {
- AccountGroup.UUID id = q.remove(q.size() - 1);
- for (AccountGroup.UUID g : includeCache.parentGroupsOf(id)) {
- if (g != null && r.add(g)) {
+ Collection<AccountGroup.UUID> parents = includeCache.parentGroupsOf(q);
+ q.clear();
+ for (AccountGroup.UUID g : parents) {
+ if (r.add(g)) {
q.add(g);
memberOf.put(g, true);
}
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index b895834652..64b8ec054f 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -96,12 +96,12 @@ public class InternalAccountDirectory extends AccountDirectory {
return;
}
- boolean canModifyAccount = false;
+ boolean canViewSecondaryEmails = false;
Account.Id currentUserId = null;
if (self.get().isIdentifiedUser()) {
currentUserId = self.get().getAccountId();
- if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
- canModifyAccount = true;
+ if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
+ canViewSecondaryEmails = true;
}
}
@@ -115,7 +115,7 @@ public class InternalAccountDirectory extends AccountDirectory {
if (state != null) {
if (!options.contains(FillOptions.SECONDARY_EMAILS)
|| Objects.equals(currentUserId, state.account().id())
- || canModifyAccount) {
+ || canViewSecondaryEmails) {
fill(info, accountStates.get(id), options);
} else {
// user is not allowed to see secondary emails
diff --git a/java/com/google/gerrit/server/account/InternalGroupBackend.java b/java/com/google/gerrit/server/account/InternalGroupBackend.java
index 91fe7010ea..01254a0964 100644
--- a/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.account;
import static java.util.stream.Collectors.toList;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
@@ -60,6 +61,7 @@ public class InternalGroupBackend implements GroupBackend {
return ObjectId.isId(uuid.get()); // [0-9a-f]{40};
}
+ @Nullable
@Override
public GroupDescription.Internal get(AccountGroup.UUID uuid) {
if (!handles(uuid)) {
diff --git a/java/com/google/gerrit/server/account/ProjectWatches.java b/java/com/google/gerrit/server/account/ProjectWatches.java
index 42137c16dd..86132d3eef 100644
--- a/java/com/google/gerrit/server/account/ProjectWatches.java
+++ b/java/com/google/gerrit/server/account/ProjectWatches.java
@@ -201,6 +201,7 @@ public class ProjectWatches {
@AutoValue
public abstract static class NotifyValue {
+ @Nullable
public static NotifyValue parse(
Account.Id accountId,
String project,
diff --git a/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index 1587bc591c..476ca79d00 100644
--- a/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -130,6 +130,7 @@ public class UniversalGroupBackend implements GroupBackend {
return true;
}
+ @Nullable
@Override
public GroupDescription.Basic get(AccountGroup.UUID uuid) {
if (uuid == null) {
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 555a2c148d..1fce3d5313 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -20,6 +20,7 @@ import static java.util.stream.Collectors.toList;
import com.google.common.base.Strings;
import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.InvalidSshKeyException;
@@ -194,6 +195,7 @@ public class VersionedAuthorizedKeys extends VersionedMetaData {
* @return the SSH key, <code>null</code> if there is no SSH key with this sequence number, or if
* the SSH key with this sequence number has been deleted
*/
+ @Nullable
private AccountSshKey getKey(int seq) {
checkLoaded();
return keys.get(seq - 1).orElse(null);
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index e718bcbf91..14aa368f0c 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -45,6 +45,13 @@ public abstract class AllExternalIds {
return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
}
+ static AllExternalIds create(
+ ImmutableMap<ExternalId.Key, ExternalId> byKey,
+ ImmutableSetMultimap<Account.Id, ExternalId> byAccount,
+ ImmutableSetMultimap<String, ExternalId> byEmail) {
+ return new AutoValue_AllExternalIds(byKey, byAccount, byEmail);
+ }
+
public abstract ImmutableMap<ExternalId.Key, ExternalId> byKey();
public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 161619853c..9196db8812 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -150,6 +150,9 @@ public abstract class ExternalId implements Serializable {
/** Scheme for xri resources. OpenID in particular makes use of these external IDs. */
public static final String SCHEME_XRI = "xri";
+ /** Scheme for Google OAuth external IDs. */
+ public static final String SCHEME_GOOGLE_OAUTH = "google-oauth";
+
@AutoValue
public abstract static class Key implements Serializable {
private static final long serialVersionUID = 1L;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 7984d7e7f5..bf281a5440 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -272,7 +272,7 @@ public class ExternalIdCacheLoader {
}
}
}
- return new AutoValue_AllExternalIds(
+ return AllExternalIds.create(
ImmutableMap.<ExternalId.Key, ExternalId>builder().putAll(byKeyMutableMap).build(),
byAccount.build(),
byEmail.build());
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index b0618ba07a..48c403c52c 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -1020,6 +1020,7 @@ public class ExternalIdNotes extends VersionedMetaData {
* @return the external ID that was removed, {@code null} if no external ID with the specified key
* exists
*/
+ @Nullable
private ExternalId remove(
RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
throws IOException, ConfigInvalidException {
diff --git a/java/com/google/gerrit/server/account/externalids/testing/BUILD b/java/com/google/gerrit/server/account/externalids/testing/BUILD
index 0e469e3206..e2de6da3b8 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/BUILD
+++ b/java/com/google/gerrit/server/account/externalids/testing/BUILD
@@ -8,6 +8,7 @@ java_library(
deps = [
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/server",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:jgit",
],
)
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
index a42afc30e3..7878ee24d5 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.account.externalids.testing;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
@@ -143,7 +144,7 @@ public class ExternalIdTestUtil {
RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
u.setExpectedOldObjectId(rev);
u.setNewObjectId(commitId);
- RefUpdate.Result res = u.update();
+ RefUpdate.Result res = testRefAction(() -> u.update());
switch (res) {
case NEW:
case FAST_FORWARD:
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index b23782fcce..828f8682a4 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.api.accounts;
import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
import static javax.servlet.http.HttpServletResponse.SC_OK;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.extensions.api.accounts.AccountApi;
import com.google.gerrit.extensions.api.accounts.AgreementInput;
@@ -575,6 +576,7 @@ public class AccountApiImpl implements AccountApi {
}
}
+ @Nullable
@Override
public String generateHttpPassword() throws RestApiException {
HttpPasswordInput input = new HttpPasswordInput();
@@ -589,6 +591,7 @@ public class AccountApiImpl implements AccountApi {
}
}
+ @Nullable
@Override
public String setHttpPassword(String password) throws RestApiException {
HttpPasswordInput input = new HttpPasswordInput();
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 171317122a..4fba660d20 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -20,7 +20,7 @@ import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
import com.google.gerrit.extensions.api.changes.AttentionSetApi;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -53,6 +53,7 @@ import com.google.gerrit.extensions.common.Input;
import com.google.gerrit.extensions.common.InputWithMessage;
import com.google.gerrit.extensions.common.MergePatchSetInput;
import com.google.gerrit.extensions.common.PureRevertInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
import com.google.gerrit.extensions.common.RevertSubmissionInfo;
import com.google.gerrit.extensions.common.RobotCommentInfo;
import com.google.gerrit.extensions.common.SubmitRequirementInput;
@@ -69,20 +70,18 @@ import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.WorkInProgressOp;
import com.google.gerrit.server.restapi.change.Abandon;
import com.google.gerrit.server.restapi.change.AddToAttentionSet;
+import com.google.gerrit.server.restapi.change.ApplyPatch;
import com.google.gerrit.server.restapi.change.AttentionSet;
import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
import com.google.gerrit.server.restapi.change.ChangeMessages;
import com.google.gerrit.server.restapi.change.Check;
import com.google.gerrit.server.restapi.change.CheckSubmitRequirement;
import com.google.gerrit.server.restapi.change.CreateMergePatchSet;
-import com.google.gerrit.server.restapi.change.DeleteAssignee;
import com.google.gerrit.server.restapi.change.DeleteChange;
import com.google.gerrit.server.restapi.change.DeletePrivate;
-import com.google.gerrit.server.restapi.change.GetAssignee;
import com.google.gerrit.server.restapi.change.GetChange;
import com.google.gerrit.server.restapi.change.GetHashtags;
import com.google.gerrit.server.restapi.change.GetMetaDiff;
-import com.google.gerrit.server.restapi.change.GetPastAssignees;
import com.google.gerrit.server.restapi.change.GetPureRevert;
import com.google.gerrit.server.restapi.change.GetTopic;
import com.google.gerrit.server.restapi.change.Index;
@@ -94,10 +93,10 @@ import com.google.gerrit.server.restapi.change.Move;
import com.google.gerrit.server.restapi.change.PostHashtags;
import com.google.gerrit.server.restapi.change.PostPrivate;
import com.google.gerrit.server.restapi.change.PostReviewers;
-import com.google.gerrit.server.restapi.change.PutAssignee;
import com.google.gerrit.server.restapi.change.PutMessage;
import com.google.gerrit.server.restapi.change.PutTopic;
import com.google.gerrit.server.restapi.change.Rebase;
+import com.google.gerrit.server.restapi.change.RebaseChain;
import com.google.gerrit.server.restapi.change.Restore;
import com.google.gerrit.server.restapi.change.Revert;
import com.google.gerrit.server.restapi.change.RevertSubmission;
@@ -139,8 +138,10 @@ class ChangeApiImpl implements ChangeApi {
private final RevertSubmission revertSubmission;
private final Restore restore;
private final CreateMergePatchSet updateByMerge;
+ private final ApplyPatch applyPatch;
private final Provider<SubmittedTogether> submittedTogether;
private final Rebase.CurrentRevision rebase;
+ private final RebaseChain rebaseChain;
private final DeleteChange deleteChange;
private final GetTopic getTopic;
private final PutTopic putTopic;
@@ -153,10 +154,6 @@ class ChangeApiImpl implements ChangeApi {
private final AttentionSet attentionSet;
private final AttentionSetApiImpl.Factory attentionSetApi;
private final AddToAttentionSet addToAttentionSet;
- private final PutAssignee putAssignee;
- private final GetAssignee getAssignee;
- private final GetPastAssignees getPastAssignees;
- private final DeleteAssignee deleteAssignee;
private final Provider<ListChangeComments> listCommentsProvider;
private final ListChangeRobotComments listChangeRobotComments;
private final Provider<ListChangeDrafts> listDraftsProvider;
@@ -191,8 +188,10 @@ class ChangeApiImpl implements ChangeApi {
RevertSubmission revertSubmission,
Restore restore,
CreateMergePatchSet updateByMerge,
+ ApplyPatch applyPatch,
Provider<SubmittedTogether> submittedTogether,
Rebase.CurrentRevision rebase,
+ RebaseChain rebaseChain,
DeleteChange deleteChange,
GetTopic getTopic,
PutTopic putTopic,
@@ -205,10 +204,6 @@ class ChangeApiImpl implements ChangeApi {
AttentionSet attentionSet,
AttentionSetApiImpl.Factory attentionSetApi,
AddToAttentionSet addToAttentionSet,
- PutAssignee putAssignee,
- GetAssignee getAssignee,
- GetPastAssignees getPastAssignees,
- DeleteAssignee deleteAssignee,
Provider<ListChangeComments> listCommentsProvider,
ListChangeRobotComments listChangeRobotComments,
Provider<ListChangeDrafts> listDraftsProvider,
@@ -241,8 +236,10 @@ class ChangeApiImpl implements ChangeApi {
this.abandon = abandon;
this.restore = restore;
this.updateByMerge = updateByMerge;
+ this.applyPatch = applyPatch;
this.submittedTogether = submittedTogether;
this.rebase = rebase;
+ this.rebaseChain = rebaseChain;
this.deleteChange = deleteChange;
this.getTopic = getTopic;
this.putTopic = putTopic;
@@ -255,10 +252,6 @@ class ChangeApiImpl implements ChangeApi {
this.attentionSet = attentionSet;
this.attentionSetApi = attentionSetApi;
this.addToAttentionSet = addToAttentionSet;
- this.putAssignee = putAssignee;
- this.getAssignee = getAssignee;
- this.getPastAssignees = getPastAssignees;
- this.deleteAssignee = deleteAssignee;
this.listCommentsProvider = listCommentsProvider;
this.listChangeRobotComments = listChangeRobotComments;
this.listDraftsProvider = listDraftsProvider;
@@ -389,6 +382,15 @@ class ChangeApiImpl implements ChangeApi {
}
@Override
+ public ChangeInfo applyPatch(ApplyPatchPatchSetInput in) throws RestApiException {
+ try {
+ return applyPatch.apply(change, in).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot apply patch", e);
+ }
+ }
+
+ @Override
public SubmittedTogetherInfo submittedTogether(
EnumSet<ListChangesOption> listOptions, EnumSet<SubmittedTogetherOption> submitOptions)
throws RestApiException {
@@ -413,6 +415,15 @@ class ChangeApiImpl implements ChangeApi {
}
@Override
+ public Response<RebaseChainInfo> rebaseChain(RebaseInput in) throws RestApiException {
+ try {
+ return rebaseChain.apply(change, in);
+ } catch (Exception e) {
+ throw asRestApiException("Cannot rebase chain", e);
+ }
+ }
+
+ @Override
public void delete() throws RestApiException {
try {
deleteChange.apply(change, null);
@@ -575,44 +586,6 @@ class ChangeApiImpl implements ChangeApi {
}
@Override
- public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
- try {
- return putAssignee.apply(change, input).value();
- } catch (Exception e) {
- throw asRestApiException("Cannot set assignee", e);
- }
- }
-
- @Override
- public AccountInfo getAssignee() throws RestApiException {
- try {
- Response<AccountInfo> r = getAssignee.apply(change);
- return r.isNone() ? null : r.value();
- } catch (Exception e) {
- throw asRestApiException("Cannot get assignee", e);
- }
- }
-
- @Override
- public List<AccountInfo> getPastAssignees() throws RestApiException {
- try {
- return getPastAssignees.apply(change).value();
- } catch (Exception e) {
- throw asRestApiException("Cannot get past assignees", e);
- }
- }
-
- @Override
- public AccountInfo deleteAssignee() throws RestApiException {
- try {
- Response<AccountInfo> r = deleteAssignee.apply(change, null);
- return r.isNone() ? null : r.value();
- } catch (Exception e) {
- throw asRestApiException("Cannot delete assignee", e);
- }
- }
-
- @Override
public CommentsRequest commentsRequest() {
return new CommentsRequest() {
@Override
diff --git a/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index 059652412d..6b107f15e7 100644
--- a/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -21,6 +21,7 @@ import static java.util.Objects.requireNonNull;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.Changes;
import com.google.gerrit.extensions.client.ListChangesOption;
@@ -41,6 +42,7 @@ import com.google.inject.Injector;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
@Singleton
class ChangesImpl implements Changes {
@@ -101,7 +103,11 @@ class ChangesImpl implements Changes {
public ChangeApi create(ChangeInput in) throws RestApiException {
try {
ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
- return api.create(changes.parse(Change.id(out._number)));
+ return api.create(
+ changes.parse(
+ Project.nameKey(out.project),
+ Change.id(out._number),
+ ObjectId.fromString(out.metaRevId)));
} catch (Exception e) {
throw asRestApiException("Cannot create change", e);
}
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 3a892bc88e..ad42ae61ed 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -16,6 +16,8 @@ package com.google.gerrit.server.api.projects;
import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
import static com.google.gerrit.server.restapi.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.HEAD_MODIFICATION;
import static java.util.stream.Collectors.toList;
import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
@@ -89,6 +91,7 @@ import com.google.gerrit.server.restapi.project.PutDescription;
import com.google.gerrit.server.restapi.project.SetAccess;
import com.google.gerrit.server.restapi.project.SetHead;
import com.google.gerrit.server.restapi.project.SetParent;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
@@ -595,7 +598,9 @@ public class ProjectApiImpl implements ProjectApi {
@Override
public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
try {
- deleteBranches.apply(checkExists(), in);
+ try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+ deleteBranches.apply(checkExists(), in);
+ }
} catch (Exception e) {
throw asRestApiException("Cannot delete branches", e);
}
@@ -686,7 +691,9 @@ public class ProjectApiImpl implements ProjectApi {
HeadInput input = new HeadInput();
input.ref = head;
try {
- setHead.apply(checkExists(), input);
+ try (RefUpdateContext ctx = RefUpdateContext.open(HEAD_MODIFICATION)) {
+ setHead.apply(checkExists(), input);
+ }
} catch (Exception e) {
throw asRestApiException("Cannot set HEAD", e);
}
diff --git a/java/com/google/gerrit/server/events/AssigneeChangedEvent.java b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
index 490d6d1435..8ed1175178 100644
--- a/java/com/google/gerrit/server/events/AssigneeChangedEvent.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectQueryBuilderModule.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,18 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.server.events;
+package com.google.gerrit.server.api.projects;
-import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.data.AccountAttribute;
+import com.google.gerrit.server.query.project.ProjectQueryBuilder;
+import com.google.gerrit.server.query.project.ProjectQueryBuilderImpl;
+import com.google.inject.AbstractModule;
-public class AssigneeChangedEvent extends ChangeEvent {
- static final String TYPE = "assignee-changed";
- public Supplier<AccountAttribute> changer;
- public Supplier<AccountAttribute> oldAssignee;
-
- public AssigneeChangedEvent(Change change) {
- super(TYPE, change);
+public class ProjectQueryBuilderModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(ProjectQueryBuilder.class).to(ProjectQueryBuilderImpl.class);
}
}
diff --git a/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 005486a870..f9bd048a24 100644
--- a/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.api.projects;
import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
import com.google.gerrit.extensions.api.projects.TagApi;
import com.google.gerrit.extensions.api.projects.TagInfo;
@@ -29,6 +30,7 @@ import com.google.gerrit.server.restapi.project.CreateTag;
import com.google.gerrit.server.restapi.project.DeleteTag;
import com.google.gerrit.server.restapi.project.ListTags;
import com.google.gerrit.server.restapi.project.TagsCollection;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
@@ -83,7 +85,9 @@ public class TagApiImpl implements TagApi {
@Override
public void delete() throws RestApiException {
try {
- deleteTag.apply(resource(), new Input());
+ try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+ deleteTag.apply(resource(), new Input());
+ }
} catch (Exception e) {
throw asRestApiException("Cannot delete tag", e);
}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java
index 059445eb93..a1889dacd3 100644
--- a/java/com/google/gerrit/server/approval/ApprovalCopier.java
+++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -32,6 +32,7 @@ import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.ChangeKindCache;
@@ -45,6 +46,7 @@ import com.google.gerrit.server.project.ProjectCache;
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.util.LabelVote;
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.inject.Inject;
@@ -85,7 +87,7 @@ public class ApprovalCopier {
* <li>the approval is not overridden by a current approval on the patch set
* </ul>
*/
- public abstract ImmutableSet<PatchSetApproval> copiedApprovals();
+ public abstract ImmutableSet<PatchSetApprovalData> copiedApprovals();
/**
* Approvals on the previous patch set that have not been copied to the patch set.
@@ -96,7 +98,7 @@ public class ApprovalCopier {
* <p>Only returns non-copied approvals of the previous patch set. Approvals from earlier patch
* sets that were outdated before are not included.
*/
- public abstract ImmutableSet<PatchSetApproval> outdatedApprovals();
+ public abstract ImmutableSet<PatchSetApprovalData> outdatedApprovals();
static Result empty() {
return create(
@@ -105,10 +107,68 @@ public class ApprovalCopier {
@VisibleForTesting
public static Result create(
- ImmutableSet<PatchSetApproval> copiedApprovals,
- ImmutableSet<PatchSetApproval> outdatedApprovals) {
+ ImmutableSet<PatchSetApprovalData> copiedApprovals,
+ ImmutableSet<PatchSetApprovalData> outdatedApprovals) {
return new AutoValue_ApprovalCopier_Result(copiedApprovals, outdatedApprovals);
}
+
+ /**
+ * A {@link PatchSetApproval} with information about which atoms of the copy condition are
+ * passing/failing.
+ */
+ @AutoValue
+ public abstract static class PatchSetApprovalData {
+ /** 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();
+
+ @VisibleForTesting
+ public static PatchSetApprovalData create(
+ PatchSetApproval approval,
+ ImmutableSet<String> passingAtoms,
+ ImmutableSet<String> failingAtoms) {
+ return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+ approval, passingAtoms, failingAtoms);
+ }
+
+ private static PatchSetApprovalData createForMissingLabelType(PatchSetApproval approval) {
+ return new AutoValue_ApprovalCopier_Result_PatchSetApprovalData(
+ approval, ImmutableSet.of(), ImmutableSet.of());
+ }
+ }
}
private final GitRepositoryManager repoManager;
@@ -227,17 +287,18 @@ public class ApprovalCopier {
followUpPatchSet.commitId());
boolean isMerge = isMerge(changeNotes.getProjectName(), revWalk, followUpPatchSet);
- if (canCopy(
- changeNotes,
- priorPatchSet.id(),
- followUpPatchSet,
- approverId,
- labelType.get(),
- approvalValue,
- changeKind,
- isMerge,
- revWalk,
- repo.getConfig())) {
+ if (computeCopyResult(
+ changeNotes,
+ priorPatchSet.id(),
+ followUpPatchSet,
+ approverId,
+ labelType.get(),
+ approvalValue,
+ changeKind,
+ isMerge,
+ revWalk,
+ repo.getConfig())
+ .canCopy()) {
targetPatchSetsBuilder.add(followUpPatchSetId);
} else {
// The approval is not copyable to this follow-up patch set.
@@ -251,7 +312,14 @@ public class ApprovalCopier {
return targetPatchSetsBuilder.build();
}
- private boolean canCopy(
+ /**
+ * Checks whether a given approval can be copied from the given source patch set to the given
+ * target patch set.
+ *
+ * <p>The returned result also informs about which atoms of the copy condition are
+ * passing/failing.
+ */
+ private ApprovalCopyResult computeCopyResult(
ChangeNotes changeNotes,
PatchSet.Id sourcePatchSetId,
PatchSet targetPatchSet,
@@ -263,7 +331,7 @@ public class ApprovalCopier {
RevWalk revWalk,
Config repoConfig) {
if (!labelType.getCopyCondition().isPresent()) {
- return false;
+ return ApprovalCopyResult.createForMissingCopyCondition();
}
ApprovalContext ctx =
ApprovalContext.create(
@@ -283,15 +351,33 @@ public class ApprovalCopier {
// request (e.g. a group used in this query might not be visible to the person sending this
// request).
try (ManualRequestContext ignored = requestContext.open()) {
- return approvalQueryBuilder
- .parse(labelType.getCopyCondition().get())
- .asMatchable()
- .match(ctx);
+ 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);
}
} catch (QueryParseException e) {
logger.atWarning().withCause(e).log(
"Unable to copy label because config is invalid. This should have been caught before.");
- return false;
+ return ApprovalCopyResult.createForNonParseableCopyCondition();
}
}
@@ -321,8 +407,10 @@ public class ApprovalCopier {
nonCopiedApprovalsForGivenPatchSet.forEach(
psa -> currentApprovalsByUser.put(psa.label(), psa.accountId(), psa));
- Table<String, Account.Id, PatchSetApproval> copiedApprovalsByUser = HashBasedTable.create();
- ImmutableSet.Builder<PatchSetApproval> outdatedApprovalsBuilder = ImmutableSet.builder();
+ Table<String, Account.Id, Result.PatchSetApprovalData> copiedApprovalsByUser =
+ HashBasedTable.create();
+ ImmutableSet.Builder<Result.PatchSetApprovalData> outdatedApprovalsBuilder =
+ ImmutableSet.builder();
ImmutableList<PatchSetApproval> priorApprovals =
notes.load().getApprovals().all().get(priorPatchSet.getKey());
@@ -362,35 +450,55 @@ public class ApprovalCopier {
priorPsa.key().patchSetId().changeId().get(),
targetPsId.get(),
projectName);
- outdatedApprovalsBuilder.add(priorPsa);
+ outdatedApprovalsBuilder.add(
+ Result.PatchSetApprovalData.createForMissingLabelType(priorPsa));
continue;
}
- if (canCopy(
- notes,
- priorPsa.patchSetId(),
- targetPatchSet,
- priorPsa.accountId(),
- labelType.get(),
- priorPsa.value(),
- changeKind,
- isMerge,
- rw,
- repoConfig)) {
+ ApprovalCopyResult approvalCopyResult =
+ computeCopyResult(
+ notes,
+ priorPsa.patchSetId(),
+ targetPatchSet,
+ priorPsa.accountId(),
+ labelType.get(),
+ priorPsa.value(),
+ changeKind,
+ isMerge,
+ rw,
+ repoConfig);
+ if (approvalCopyResult.canCopy()) {
if (!currentApprovalsByUser.contains(priorPsa.label(), priorPsa.accountId())) {
+ PatchSetApproval copiedApproval = priorPsa.copyWithPatchSet(targetPatchSet.id());
+
+ // Normalize the copied approval.
+ Optional<PatchSetApproval> copiedApprovalNormalized =
+ labelNormalizer.normalize(notes, copiedApproval);
+ logger.atFine().log(
+ "Copied approval %s has been normalized to %s",
+ copiedApproval,
+ copiedApprovalNormalized.map(PatchSetApproval::toString).orElse("n/a"));
+ if (!copiedApprovalNormalized.isPresent()) {
+ continue;
+ }
+
copiedApprovalsByUser.put(
priorPsa.label(),
priorPsa.accountId(),
- priorPsa.copyWithPatchSet(targetPatchSet.id()));
+ Result.PatchSetApprovalData.create(
+ copiedApprovalNormalized.get(),
+ approvalCopyResult.passingAtoms(),
+ approvalCopyResult.failingAtoms()));
}
} else {
- outdatedApprovalsBuilder.add(priorPsa);
+ outdatedApprovalsBuilder.add(
+ Result.PatchSetApprovalData.create(
+ priorPsa, approvalCopyResult.passingAtoms(), approvalCopyResult.failingAtoms()));
continue;
}
}
- ImmutableSet<PatchSetApproval> copiedApprovals =
- labelNormalizer.normalize(notes, copiedApprovalsByUser.values()).getNormalized();
- return Result.create(copiedApprovals, outdatedApprovalsBuilder.build());
+ return Result.create(
+ ImmutableSet.copyOf(copiedApprovalsByUser.values()), outdatedApprovalsBuilder.build());
}
private boolean isMerge(Project.NameKey project, RevWalk rw, PatchSet patchSet) {
@@ -404,4 +512,72 @@ public class ApprovalCopier {
e);
}
}
+
+ /**
+ * Evaluates a predicate of the copy condition and adds its passing and failing atoms to the given
+ * builders.
+ *
+ * @param predicate a predicate of the copy condition that should be evaluated
+ * @param approvalContext the approval context against which the predicate should be evaluated
+ * @param passingAtoms a builder to which passing atoms should be added
+ * @param failingAtoms a builder to which failing atoms should be added
+ */
+ private static void evaluateAtoms(
+ Predicate<ApprovalContext> predicate,
+ ApprovalContext approvalContext,
+ ImmutableSet.Builder<String> passingAtoms,
+ ImmutableSet.Builder<String> failingAtoms) {
+ if (predicate.isLeaf()) {
+ boolean isPassing = predicate.asMatchable().match(approvalContext);
+ (isPassing ? passingAtoms : failingAtoms).add(predicate.getPredicateString());
+ return;
+ }
+ predicate
+ .getChildren()
+ .forEach(
+ 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 620f712fd7..8fae13a34f 100644
--- a/java/com/google/gerrit/server/approval/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -15,8 +15,10 @@
package com.google.gerrit.server.approval;
import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -25,6 +27,7 @@ import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Function;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
@@ -36,6 +39,7 @@ import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
@@ -84,6 +88,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
+import java.util.StringTokenizer;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.revwalk.RevWalk;
@@ -403,12 +408,17 @@ public class ApprovalsUtil {
ChangeUpdate changeUpdate) {
ApprovalCopier.Result approvalCopierResult =
approvalCopier.forPatchSet(notes, patchSet, revWalk, repoConfig);
- approvalCopierResult.copiedApprovals().forEach(a -> changeUpdate.putCopiedApproval(a));
+ approvalCopierResult
+ .copiedApprovals()
+ .forEach(approvalData -> changeUpdate.putCopiedApproval(approvalData.patchSetApproval()));
if (!notes.getChange().isWorkInProgress()) {
// The attention set should not be updated when the change is work-in-progress.
addAttentionSetUpdatesForOutdatedApprovals(
- changeUpdate, approvalCopierResult.outdatedApprovals());
+ changeUpdate,
+ approvalCopierResult.outdatedApprovals().stream()
+ .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+ .collect(toImmutableSet()));
}
return approvalCopierResult;
@@ -515,31 +525,40 @@ public class ApprovalsUtil {
* "is:FOO")}
* </ul>
*
- * @param approvals the approvals that should be formatted
+ * @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<PatchSetApproval> approvals, LabelTypes labelTypes) {
+ ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+ LabelTypes labelTypes) {
StringBuilder message = new StringBuilder();
// sort approvals by label vote so that we list them in a deterministic order
- ImmutableList<PatchSetApproval> approvalsSortedByLabelVote =
- approvals.stream()
- .sorted(comparing(psa -> LabelVote.create(psa.label(), psa.value()).format()))
+ ImmutableList<ApprovalCopier.Result.PatchSetApprovalData> approvalsSortedByLabelVote =
+ approvalDatas.stream()
+ .sorted(
+ comparing(
+ approvalData ->
+ LabelVote.create(
+ approvalData.patchSetApproval().label(),
+ approvalData.patchSetApproval().value())
+ .format()))
.collect(toImmutableList());
- ImmutableListMultimap<String, PatchSetApproval> approvalsByLabel =
- Multimaps.index(approvalsSortedByLabelVote, PatchSetApproval::label);
+ ImmutableListMultimap<String, ApprovalCopier.Result.PatchSetApprovalData> approvalsByLabel =
+ Multimaps.index(
+ approvalsSortedByLabelVote, approvalData -> approvalData.patchSetApproval().label());
- for (Map.Entry<String, Collection<PatchSetApproval>> approvalsByLabelEntry :
- approvalsByLabel.asMap().entrySet()) {
+ for (Map.Entry<String, Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+ approvalsByLabelEntry : approvalsByLabel.asMap().entrySet()) {
String label = approvalsByLabelEntry.getKey();
- Collection<PatchSetApproval> approvalsForSameLabel = approvalsByLabelEntry.getValue();
+ Collection<ApprovalCopier.Result.PatchSetApprovalData> approvalsForSameLabel =
+ approvalsByLabelEntry.getValue();
- message.append("* ");
if (!labelTypes.byLabel(label).isPresent()) {
message
+ .append("* ")
.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
.append(" (label type is missing)\n");
continue;
@@ -547,22 +566,65 @@ public class ApprovalsUtil {
LabelType labelType = labelTypes.byLabel(label).get();
if (!labelType.getCopyCondition().isPresent()) {
- message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel)).append("\n");
+ message
+ .append("* ")
+ .append(formatApprovalsAsLabelVotesList(approvalsForSameLabel))
+ .append("\n");
continue;
}
- message
- .append(
- formatApprovalsWithCopyCondition(
- approvalsForSameLabel, labelType.getCopyCondition().get()))
- .append("\n");
+ // 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);
+
+ // 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).
+ approvalsForSameLabelByPassingAndFailingAtoms
+ .asMap()
+ .values()
+ .forEach(
+ approvalsForSameLabelAndSamePassingAtoms ->
+ checkThatPropertyIsTheSameForAllApprovals(
+ approvalsForSameLabelAndSamePassingAtoms,
+ "failing atoms",
+ approvalData -> approvalData.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
+ // the passing atoms.
+ for (Collection<ApprovalCopier.Result.PatchSetApprovalData>
+ approvalsForSameLabelWithSamePassingAndFailingAtoms :
+ approvalsForSameLabelByPassingAndFailingAtoms.asMap().entrySet().stream()
+ .sorted(
+ comparing(
+ (Map.Entry<
+ ImmutableSet<String>,
+ Collection<ApprovalCopier.Result.PatchSetApprovalData>>
+ e) -> e.getKey().toString()))
+ .map(Map.Entry::getValue)
+ .collect(toImmutableList())) {
+ message
+ .append("* ")
+ .append(
+ formatApprovalsWithCopyCondition(
+ approvalsForSameLabelWithSamePassingAndFailingAtoms,
+ labelType.getCopyCondition().get()))
+ .append("\n");
+ }
}
return message.toString();
}
/**
- * Formats the given approvals of the same label with the given copy condition.
+ * Formats the given approvals with the given copy condition.
+ *
+ * <p>The given approvals must have the same label and the same passing and failing atoms.
*
* <p>E.g.: {Code-Review+1, Code-Review+2 (copy condition: "is:MIN")}
*
@@ -582,12 +644,29 @@ public class ApprovalsUtil {
* "is:FOO")}
* </ul>
*
- * @param approvalsForSameLabel the approvals that should be formatted, must be for the same label
+ * @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<PatchSetApproval> approvalsForSameLabel, String copyCondition) {
+ Collection<ApprovalCopier.Result.PatchSetApprovalData>
+ approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+ String copyCondition) {
+ // Check that all given approvals have the same label and the same passing and failing atoms.
+ checkThatPropertyIsTheSameForAllApprovals(
+ approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+ "label",
+ approvalData -> approvalData.patchSetApproval().label());
+ checkThatPropertyIsTheSameForAllApprovals(
+ approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+ "passing atoms",
+ approvalData -> approvalData.passingAtoms());
+ checkThatPropertyIsTheSameForAllApprovals(
+ approvalsWithSameLabelAndSamePassingAndFailingAtoms,
+ "failing atoms",
+ approvalData -> approvalData.failingAtoms());
+
StringBuilder message = new StringBuilder();
boolean containsUserInPredicate;
@@ -595,7 +674,8 @@ public class ApprovalsUtil {
containsUserInPredicate = containsUserInPredicate(copyCondition);
} catch (QueryParseException e) {
logger.atWarning().withCause(e).log("Non-parsable query condition");
- message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+ message.append(
+ formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
message.append(String.format(" (non-parseable copy condition: \"%s\")", copyCondition));
return message.toString();
}
@@ -622,26 +702,35 @@ public class ApprovalsUtil {
// sort the approvals by their approvers name-email so that the approvers always appear in a
// deterministic order
- ImmutableList<PatchSetApproval> approvalsSortedByLabelVoteAndApprover =
- approvalsForSameLabel.stream()
- .sorted(
- comparing(
- (PatchSetApproval psa) ->
- LabelVote.create(psa.label(), psa.value()).format())
- .thenComparing(
- psa ->
- accountCache
- .getEvenIfMissing(psa.accountId())
- .account()
- .getNameEmail(anonymousCowardName)))
- .collect(toImmutableList());
+ ImmutableList<ApprovalCopier.Result.PatchSetApprovalData>
+ approvalsSortedByLabelVoteAndApprover =
+ approvalsWithSameLabelAndSamePassingAndFailingAtoms.stream()
+ .sorted(
+ comparing(
+ (ApprovalCopier.Result.PatchSetApprovalData approvalData) ->
+ LabelVote.create(
+ approvalData.patchSetApproval().label(),
+ approvalData.patchSetApproval().value())
+ .format())
+ .thenComparing(
+ approvalData ->
+ accountCache
+ .getEvenIfMissing(approvalData.patchSetApproval().accountId())
+ .account()
+ .getNameEmail(anonymousCowardName)))
+ .collect(toImmutableList());
ImmutableListMultimap<LabelVote, Account.Id> approversByLabelVote =
Multimaps.index(
approvalsSortedByLabelVoteAndApprover,
- psa -> LabelVote.create(psa.label(), psa.value()))
+ approvalData ->
+ LabelVote.create(
+ approvalData.patchSetApproval().label(),
+ approvalData.patchSetApproval().value()))
.entries().stream()
- .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue().accountId()));
+ .collect(
+ toImmutableListMultimap(
+ e -> e.getKey(), e -> e.getValue().patchSetApproval().accountId()));
message.append(
approversByLabelVote.asMap().entrySet().stream()
.map(
@@ -651,12 +740,64 @@ public class ApprovalsUtil {
.collect(joining(", ")));
} else {
// copy condition doesn't contain a UserInPredicate
- message.append(formatApprovalsAsLabelVotesList(approvalsForSameLabel));
+ message.append(
+ formatApprovalsAsLabelVotesList(approvalsWithSameLabelAndSamePassingAndFailingAtoms));
}
- message.append(String.format(" (copy condition: \"%s\")", copyCondition));
+ ImmutableSet<String> passingAtoms =
+ !approvalsWithSameLabelAndSamePassingAndFailingAtoms.isEmpty()
+ ? approvalsWithSameLabelAndSamePassingAndFailingAtoms.iterator().next().passingAtoms()
+ : ImmutableSet.of();
+ message.append(
+ String.format(
+ " (copy condition: \"%s\")",
+ formatCopyConditionAsMarkdown(copyCondition, passingAtoms)));
return message.toString();
}
+ /** Checks that all given approvals have the same value for a given property. */
+ private void checkThatPropertyIsTheSameForAllApprovals(
+ Collection<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+ String propertyName,
+ Function<ApprovalCopier.Result.PatchSetApprovalData, ?> propertyExtractor) {
+ if (approvals.isEmpty()) {
+ return;
+ }
+
+ Object propertyOfFirstEntry = propertyExtractor.apply(approvals.iterator().next());
+ approvals.forEach(
+ approvalData ->
+ checkState(
+ propertyExtractor.apply(approvalData).equals(propertyOfFirstEntry),
+ "property %s of approval %s does not match, expected value: %s",
+ propertyName,
+ approvalData,
+ propertyOfFirstEntry));
+ }
+
+ /**
+ * Formats the given copy condition as a Markdown string.
+ *
+ * <p>Passing atoms are formatted as bold.
+ *
+ * @param copyCondition the copy condition that should be formatted
+ * @param passingAtoms atoms of the copy conditions which are passing/matching
+ * @return the formatted copy condition as a Markdown string
+ */
+ private String formatCopyConditionAsMarkdown(
+ String copyCondition, ImmutableSet<String> passingAtoms) {
+ StringBuilder formattedCopyCondition = new StringBuilder();
+ StringTokenizer tokenizer = new StringTokenizer(copyCondition, " ()", /* returnDelims= */ true);
+ while (tokenizer.hasMoreTokens()) {
+ String token = tokenizer.nextToken();
+ if (passingAtoms.contains(token)) {
+ formattedCopyCondition.append("**" + token.replace("*", "\\*") + "**");
+ } else {
+ formattedCopyCondition.append(token);
+ }
+ }
+ return formattedCopyCondition.toString();
+ }
+
private boolean containsUserInPredicate(String copyCondition) throws QueryParseException {
// 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
@@ -679,8 +820,9 @@ public class ApprovalsUtil {
* @return the given approvals as a comma-separated list of label votes
*/
private String formatApprovalsAsLabelVotesList(
- Collection<PatchSetApproval> sortedApprovalsForSameLabel) {
+ Collection<ApprovalCopier.Result.PatchSetApprovalData> sortedApprovalsForSameLabel) {
return sortedApprovalsForSameLabel.stream()
+ .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
.map(psa -> LabelVote.create(psa.label(), psa.value()))
.distinct()
.map(LabelVote::format)
@@ -729,6 +871,7 @@ public class ApprovalsUtil {
return filterApprovals(byPatchSet(notes, psId), accountId);
}
+ @Nullable
public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
if (c == null) {
return null;
@@ -741,6 +884,7 @@ public class ApprovalsUtil {
}
}
+ @Nullable
public static PatchSetApproval getSubmitter(PatchSet.Id c, Iterable<PatchSetApproval> approvals) {
if (c == null) {
return null;
diff --git a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
index 89727c7e9d..676640d902 100644
--- a/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
+++ b/java/com/google/gerrit/server/approval/testing/TestPatchSetApprovalUuidGenerator.java
@@ -20,6 +20,7 @@ import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.PatchSetApproval.UUID;
import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
import java.time.Instant;
+import java.util.Locale;
import javax.inject.Singleton;
/**
@@ -44,6 +45,6 @@ public class TestPatchSetApprovalUuidGenerator implements PatchSetApprovalUuidGe
value,
invocationCount)
.replace("-", "_")
- .toLowerCase());
+ .toLowerCase(Locale.US));
}
}
diff --git a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
index aa8a9583b1..3cad7ceae7 100644
--- a/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/ObjectIdHandler.java
@@ -16,9 +16,11 @@ package com.google.gerrit.server.args4j;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.lib.ObjectId;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.NamedOptionDef;
import org.kohsuke.args4j.OptionDef;
import org.kohsuke.args4j.spi.OptionHandler;
import org.kohsuke.args4j.spi.Parameters;
@@ -37,7 +39,14 @@ public class ObjectIdHandler extends OptionHandler<ObjectId> {
@Override
public int parseArguments(Parameters params) throws CmdLineException {
final String n = params.getParameter(0);
- setter.addValue(ObjectId.fromString(n));
+ try {
+ setter.addValue(ObjectId.fromString(n));
+ } catch (InvalidObjectIdException e) {
+ throw new CmdLineException(
+ owner,
+ String.format("expected SHA1 for option %s: %s", ((NamedOptionDef) option).name(), n),
+ e);
+ }
return 1;
}
diff --git a/java/com/google/gerrit/server/args4j/ProjectHandler.java b/java/com/google/gerrit/server/args4j/ProjectHandler.java
index a1e45e909f..dc7fa2487e 100644
--- a/java/com/google/gerrit/server/args4j/ProjectHandler.java
+++ b/java/com/google/gerrit/server/args4j/ProjectHandler.java
@@ -18,8 +18,8 @@ import static com.google.gerrit.util.cli.Localizable.localizable;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectUtil;
import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ProjectUtil;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/cache/CacheInfo.java b/java/com/google/gerrit/server/cache/CacheInfo.java
index d6eb065339..94a9e05a61 100644
--- a/java/com/google/gerrit/server/cache/CacheInfo.java
+++ b/java/com/google/gerrit/server/cache/CacheInfo.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.cache;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheStats;
+import com.google.gerrit.common.Nullable;
public class CacheInfo {
@@ -53,6 +54,7 @@ public class CacheInfo {
}
}
+ @Nullable
private static String duration(double ns) {
if (ns < 0.5) {
return null;
@@ -118,6 +120,7 @@ public class CacheInfo {
disk = percent(value, total);
}
+ @Nullable
private static Integer percent(long value, long total) {
if (total <= 0) {
return null;
diff --git a/java/com/google/gerrit/server/cache/PerThreadProjectCache.java b/java/com/google/gerrit/server/cache/PerThreadProjectCache.java
index 86f1d2d3c4..8394343285 100644
--- a/java/com/google/gerrit/server/cache/PerThreadProjectCache.java
+++ b/java/com/google/gerrit/server/cache/PerThreadProjectCache.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.cache;
import com.google.common.collect.Maps;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.entities.Project;
import java.util.Map;
import java.util.function.Supplier;
@@ -39,6 +40,7 @@ public class PerThreadProjectCache {
private PerThreadProjectCache() {}
+ @CanIgnoreReturnValue
public static <T> T getOrCompute(PerThreadCache.Key<Project.NameKey> key, Supplier<T> loader) {
PerThreadCache perThreadCache = PerThreadCache.get();
if (perThreadCache != null) {
diff --git a/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java b/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java
new file mode 100644
index 0000000000..82c2856b50
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadRefDbCache.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+import org.eclipse.jgit.internal.storage.file.RefDirectory;
+import org.eclipse.jgit.lib.RefDatabase;
+
+/** A per request thread cache of RefDatabases by directory (Project). */
+public class PerThreadRefDbCache {
+ protected static final PerThreadCache.Key<PerThreadRefDbCache> REFDB_CACHE_KEY =
+ PerThreadCache.Key.create(PerThreadRefDbCache.class);
+
+ public static RefDatabase getRefDatabase(File path, RefDatabase refDb) {
+ if (PerThreadCache.get() != null) {
+ return PerThreadCache.get()
+ .get(REFDB_CACHE_KEY, PerThreadRefDbCache::new)
+ .computeIfAbsent(path, p -> ((RefDirectory) refDb).createSnapshottingRefDirectory());
+ }
+ return refDb;
+ }
+
+ protected final Map<File, RefDatabase> refDbByRefsDir = new HashMap<>();
+
+ public RefDatabase computeIfAbsent(
+ File path, Function<? super File, ? extends RefDatabase> mappingFunction) {
+ return refDbByRefsDir.computeIfAbsent(path, mappingFunction);
+ }
+}
diff --git a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
index ec527bae51..e9b254bc6f 100644
--- a/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
+++ b/java/com/google/gerrit/server/cache/PersistentCacheBaseFactory.java
@@ -18,6 +18,7 @@ import com.google.common.cache.Cache;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import java.io.IOException;
@@ -80,6 +81,7 @@ public abstract class PersistentCacheBaseFactory implements PersistentCacheFacto
return !diskEnabled || diskLimit <= 0;
}
+ @Nullable
private static Path getCacheDir(SitePaths site, String name) {
if (name == null) {
return null;
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
index aa62745715..b744058c89 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheDefProxy.java
@@ -47,6 +47,7 @@ class H2CacheDefProxy<K, V> implements PersistentCacheDef<K, V> {
return source.refreshAfterWrite();
}
+ @Nullable
@Override
public Weigher<K, V> weigher() {
Weigher<K, V> weigher = source.weigher();
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 7db4443008..29bf0e6add 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -103,6 +103,7 @@ public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements Per
this.mem = mem;
}
+ @Nullable
@Override
public V getIfPresent(Object objKey) {
if (!keyType.getRawType().isInstance(objKey)) {
@@ -426,6 +427,7 @@ public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements Per
return b == null || b.mightContain(key);
}
+ @Nullable
private BloomFilter<K> buildBloomFilter() {
SqlHandle c = null;
try {
@@ -475,6 +477,7 @@ public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements Per
}
}
+ @Nullable
ValueHolder<V> getIfPresent(K key) {
SqlHandle c = null;
try {
@@ -720,6 +723,7 @@ public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements Per
}
}
+ @Nullable
private SqlHandle close(SqlHandle h) {
if (h != null) {
h.close();
@@ -779,6 +783,7 @@ public class H2CacheImpl<K, V> extends AbstractLoadingCache<K, V> implements Per
}
}
+ @Nullable
private PreparedStatement closeStatement(PreparedStatement ps) {
if (ps != null) {
try {
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
index 5aa7a2a849..5ac9ac4dd6 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializer.java
@@ -30,7 +30,6 @@ public class StoredCommentLinkInfoSerializer {
.setPrefix(emptyToNull(proto.getPrefix()))
.setSuffix(emptyToNull(proto.getSuffix()))
.setText(emptyToNull(proto.getText()))
- .setHtml(emptyToNull(proto.getHtml()))
.setEnabled(proto.getEnabled())
.setOverrideOnly(proto.getOverrideOnly())
.build();
@@ -44,7 +43,6 @@ public class StoredCommentLinkInfoSerializer {
.setPrefix(nullToEmpty(autoValue.getPrefix()))
.setSuffix(nullToEmpty(autoValue.getSuffix()))
.setText(nullToEmpty(autoValue.getText()))
- .setHtml(nullToEmpty(autoValue.getHtml()))
.setEnabled(Optional.ofNullable(autoValue.getEnabled()).orElse(true))
.setOverrideOnly(autoValue.getOverrideOnly())
.build();
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java
index 63e2c08ef5..e5a95340fa 100644
--- a/java/com/google/gerrit/server/change/ActionJson.java
+++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -106,6 +106,7 @@ public class ActionJson {
to.actions = toActionMap(rsrc, visitors, changeInfo, copy(visitors, to));
}
+ @Nullable
private ChangeInfo copy(List<ActionVisitor> visitors, ChangeInfo changeInfo) {
if (visitors.isEmpty()) {
return null;
@@ -122,7 +123,6 @@ public class ActionJson {
changeInfo.removedFromAttentionSet == null
? null
: ImmutableMap.copyOf(changeInfo.removedFromAttentionSet);
- copy.assignee = changeInfo.assignee;
copy.hashtags = changeInfo.hashtags;
copy.changeId = changeInfo.changeId;
copy.submitType = changeInfo.submitType;
@@ -152,6 +152,7 @@ public class ActionJson {
return copy;
}
+ @Nullable
private RevisionInfo copy(List<ActionVisitor> visitors, RevisionInfo revisionInfo) {
if (visitors.isEmpty()) {
return null;
@@ -164,6 +165,7 @@ public class ActionJson {
copy.ref = revisionInfo.ref;
copy.created = revisionInfo.created;
copy.uploader = revisionInfo.uploader;
+ copy.realUploader = revisionInfo.realUploader;
copy.fetch = revisionInfo.fetch;
copy.kind = revisionInfo.kind;
copy.description = revisionInfo.description;
diff --git a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
index f6e9ff9862..0ed1f118bf 100644
--- a/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
+++ b/java/com/google/gerrit/server/change/ArchiveFormatInternal.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.change;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
+import java.util.Locale;
import org.apache.commons.compress.archivers.ArchiveOutputStream;
import org.eclipse.jgit.api.ArchiveCommand;
import org.eclipse.jgit.api.ArchiveCommand.Format;
@@ -47,7 +48,7 @@ public enum ArchiveFormatInternal {
}
public String getShortName() {
- return name().toLowerCase();
+ return name().toLowerCase(Locale.US);
}
public String getMimeType() {
diff --git a/java/com/google/gerrit/server/change/BatchAbandon.java b/java/com/google/gerrit/server/change/BatchAbandon.java
index 2efa02761b..9070006649 100644
--- a/java/com/google/gerrit/server/change/BatchAbandon.java
+++ b/java/com/google/gerrit/server/change/BatchAbandon.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -25,6 +27,7 @@ import com.google.gerrit.server.plugincontext.PluginItemContext;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -68,24 +71,27 @@ public class BatchAbandon {
return;
}
AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
- try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
- u.setNotify(notify);
- for (ChangeData change : changes) {
- if (!project.equals(change.project())) {
- throw new ResourceConflictException(
- String.format(
- "Project name \"%s\" doesn't match \"%s\"",
- change.project().get(), project.get()));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u = updateFactory.create(project, user, TimeUtil.now())) {
+ u.setNotify(notify);
+ for (ChangeData change : changes) {
+ if (!project.equals(change.project())) {
+ throw new ResourceConflictException(
+ String.format(
+ "Project name \"%s\" doesn't match \"%s\"",
+ change.project().get(), project.get()));
+ }
+ u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
+ u.addOp(
+ change.getId(),
+ storeSubmitRequirementsOpFactory.create(
+ change.submitRequirements().values(), change));
}
- u.addOp(change.getId(), abandonOpFactory.create(accountState, msgTxt));
- u.addOp(
- change.getId(),
- storeSubmitRequirementsOpFactory.create(change.submitRequirements().values(), change));
- }
- u.execute();
+ u.execute();
- if (cfg.getCleanupAccountPatchReview()) {
- cleanupAccountPatchReview(changes);
+ if (cfg.getCleanupAccountPatchReview()) {
+ cleanupAccountPatchReview(changes);
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index edaca702b7..8773bb7e9d 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -29,7 +29,10 @@ import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+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.LabelType;
@@ -242,32 +245,38 @@ public class ChangeInserter implements InsertChangeOp {
return change;
}
+ @CanIgnoreReturnValue
public ChangeInserter setTopic(String topic) {
checkState(change == null, "setTopic(String) only valid before creating change");
this.topic = topic;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setCherryPickOf(PatchSet.Id cherryPickOf) {
this.cherryPickOf = cherryPickOf;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setMessage(String message) {
this.message = message;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setPatchSetDescription(String patchSetDescription) {
this.patchSetDescription = patchSetDescription;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setValidate(boolean validate) {
this.validate = validate;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setReviewersAndCcs(
Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
return setReviewersAndCcsAsStrings(
@@ -275,35 +284,57 @@ public class ChangeInserter implements InsertChangeOp {
Iterables.transform(ccs, Account.Id::toString));
}
+ @CanIgnoreReturnValue
+ public ChangeInserter setReviewersAndCcsIgnoreVisibility(
+ Iterable<Account.Id> reviewers, Iterable<Account.Id> ccs) {
+ return setReviewersAndCcsAsStrings(
+ Iterables.transform(reviewers, Account.Id::toString),
+ Iterables.transform(ccs, Account.Id::toString),
+ /* skipVisibilityCheck= */ true);
+ }
+
+ @CanIgnoreReturnValue
public ChangeInserter setReviewersAndCcsAsStrings(
Iterable<String> reviewers, Iterable<String> ccs) {
+ return setReviewersAndCcsAsStrings(reviewers, ccs, /* skipVisibilityCheck= */ false);
+ }
+
+ @CanIgnoreReturnValue
+ private ChangeInserter setReviewersAndCcsAsStrings(
+ Iterable<String> reviewers, Iterable<String> ccs, boolean skipVisibilityCheck) {
reviewerInputs =
Streams.concat(
Streams.stream(reviewers)
.distinct()
- .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
- Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
+ .map(id -> newReviewerInput(id, ReviewerState.REVIEWER, skipVisibilityCheck)),
+ Streams.stream(ccs)
+ .distinct()
+ .map(id -> newReviewerInput(id, ReviewerState.CC, skipVisibilityCheck)))
.collect(toImmutableList());
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setPrivate(boolean isPrivate) {
checkState(change == null, "setPrivate(boolean) only valid before creating change");
this.isPrivate = isPrivate;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setWorkInProgress(boolean workInProgress) {
this.workInProgress = workInProgress;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setStatus(Change.Status status) {
checkState(change == null, "setStatus(Change.Status) only valid before creating change");
this.status = status;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setGroups(List<String> groups) {
requireNonNull(groups, "groups may not be empty");
checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
@@ -311,6 +342,7 @@ public class ChangeInserter implements InsertChangeOp {
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setValidationOptions(
ImmutableListMultimap<String, String> validationOptions) {
requireNonNull(validationOptions, "validationOptions may not be null");
@@ -322,21 +354,25 @@ public class ChangeInserter implements InsertChangeOp {
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
this.fireRevisionCreated = fireRevisionCreated;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setSendMail(boolean sendMail) {
this.sendMail = sendMail;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setRequestScopePropagator(RequestScopePropagator r) {
this.requestScopePropagator = r;
return this;
}
+ @CanIgnoreReturnValue
public ChangeInserter setRevertOf(Change.Id revertOf) {
this.revertOf = revertOf;
return this;
@@ -351,6 +387,7 @@ public class ChangeInserter implements InsertChangeOp {
return patchSet;
}
+ @CanIgnoreReturnValue
public ChangeInserter setApprovals(Map<String, Short> approvals) {
this.approvals = approvals;
return this;
@@ -368,11 +405,13 @@ public class ChangeInserter implements InsertChangeOp {
* @param updateRef whether to update the ref during {@link #updateRepo(RepoContext)}.
*/
@Deprecated
+ @CanIgnoreReturnValue
public ChangeInserter setUpdateRef(boolean updateRef) {
this.updateRef = updateRef;
return this;
}
+ @Nullable
public String getChangeMessage() {
if (message == null) {
return null;
@@ -486,7 +525,7 @@ public class ChangeInserter implements InsertChangeOp {
public void postUpdate(PostUpdateContext ctx) throws Exception {
reviewerAdditions.postUpdate(ctx);
NotifyResolver.Result notify = ctx.getNotify(change.getId());
- if (sendMail && notify.shouldNotify()) {
+ if (sendMail) {
Runnable sender =
new Runnable() {
@Override
@@ -595,7 +634,8 @@ public class ChangeInserter implements InsertChangeOp {
}
}
- private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
+ private static InternalReviewerInput newReviewerInput(
+ String reviewer, ReviewerState state, boolean skipVisibilityCheck) {
// Disable individual emails when adding reviewers, as all reviewers will receive the single
// bulk new change email.
InternalReviewerInput input =
@@ -606,12 +646,17 @@ public class ChangeInserter implements InsertChangeOp {
// certain commit footers: putting a nonexistent user in a footer should not cause an error. In
// theory we could provide finer control to do this for some reviewers and not others, but it's
// not worth complicating the ChangeInserter interface further at this time.
- input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+ input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
+
+ input.skipVisibilityCheck = skipVisibilityCheck;
return input;
}
private ImmutableList<InternalReviewerInput> getReviewerInputs() {
+ if (projectState.is(BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS)) {
+ return reviewerInputs;
+ }
return Streams.concat(
reviewerInputs.stream(),
Streams.stream(
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 02b0a60aec..f733a7b2e6 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -45,6 +45,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
@@ -145,7 +146,7 @@ public class ChangeJson {
public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().build();
- static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
+ public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
ImmutableSet.of(
ALL_COMMITS,
ALL_REVISIONS,
@@ -616,7 +617,6 @@ public class ChangeJson {
a -> a.account().get(),
a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
}
- out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
out.hashtags = cd.hashtags();
out.changeId = in.getKey().get();
if (in.isNew()) {
@@ -687,6 +687,7 @@ public class ChangeJson {
!cd.change().isAbandoned()
? labelsJson.permittedLabels(user.getAccountId(), cd)
: ImmutableMap.of();
+ out.removableLabels = labelsJson.removableLabels(accountLoader, user, cd);
}
}
@@ -931,11 +932,12 @@ public class ChangeJson {
}
src = Collections.singletonList(ps);
}
- Map<PatchSet.Id, PatchSet> map = Maps.newHashMapWithExpectedSize(src.size());
+ // Sort by patch set ID in increasing order to have a stable output.
+ ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> map = ImmutableSortedMap.naturalOrder();
for (PatchSet patchSet : src) {
map.put(patchSet.id(), patchSet);
}
- return map;
+ return map.build();
}
private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 919586ebc4..c5c0be0814 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -177,9 +177,6 @@ public class ChangeResource implements RestResource, HasETag {
byte[] buf = new byte[20];
Set<Account.Id> accounts = new HashSet<>();
accounts.add(getChange().getOwner());
- if (getChange().getAssignee() != null) {
- accounts.add(getChange().getAssignee());
- }
try {
patchSetUtil.byChange(getNotes()).stream().map(PatchSet::uploader).forEach(accounts::add);
diff --git a/java/com/google/gerrit/server/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 0775647c93..063903be73 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
@@ -57,6 +58,7 @@ import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -174,29 +176,31 @@ public class ConsistencyChecker {
public Result check(ChangeNotes notes, @Nullable FixInput f) {
requireNonNull(notes);
try {
- return retryHelper
- .changeUpdate(
- "checkChangeConsistency",
- buf -> {
- try {
- reset();
- this.updateFactory = buf;
- this.notes = notes;
- fix = f;
- checkImpl();
- return result();
- } finally {
- if (rw != null) {
- rw.getObjectReader().close();
- rw.close();
- oi.close();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ return retryHelper
+ .changeUpdate(
+ "checkChangeConsistency",
+ buf -> {
+ try {
+ reset();
+ this.updateFactory = buf;
+ this.notes = notes;
+ fix = f;
+ checkImpl();
+ return result();
+ } finally {
+ if (rw != null) {
+ rw.getObjectReader().close();
+ rw.close();
+ oi.close();
+ }
+ if (repo != null) {
+ repo.close();
+ }
}
- if (repo != null) {
- repo.close();
- }
- }
- })
- .call();
+ })
+ .call();
+ }
} catch (RestApiException e) {
return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
} catch (UpdateException e) {
@@ -764,6 +768,7 @@ public class ConsistencyChecker {
return serverIdent.get();
}
+ @Nullable
private RevCommit parseCommit(ObjectId objId, String desc) {
try {
return rw.parseCommit(objId);
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index b512a2dc14..f3fd68e841 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -76,9 +76,6 @@ public class DeleteReviewerByEmailOp extends ReviewerOp {
if (sendEmail) {
try {
NotifyResolver.Result notify = ctx.getNotify(change.getId());
- if (!notify.shouldNotify()) {
- return;
- }
DeleteReviewerSender emailSender =
deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
emailSender.setFrom(ctx.getAccountId());
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 1199be5657..fc07592a9f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -187,21 +187,19 @@ public class DeleteReviewerOp extends ReviewerOp {
if (input.notify == null
&& currChange.isWorkInProgress()
&& !oldApprovals.isEmpty()
- && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
+ && notify.handling().equals(NotifyHandling.NONE)) {
// Override NotifyHandling from the context to notify owner if votes were removed on a WIP
// change.
notify = notify.withHandling(NotifyHandling.OWNER);
}
try {
- if (notify.shouldNotify()) {
- emailReviewers(
- ctx.getProject(),
- currChange,
- mailMessage,
- Timestamp.from(ctx.getWhen()),
- notify,
- ctx.getRepoView());
- }
+ emailReviewers(
+ ctx.getProject(),
+ currChange,
+ mailMessage,
+ Timestamp.from(ctx.getWhen()),
+ notify,
+ ctx.getRepoView());
} catch (Exception err) {
logger.atSevere().withCause(err).log(
"Cannot email update for change %s", currChange.getId());
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
index f6ae6a3cc2..f67ce4a394 100644
--- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java
+++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.change;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
@@ -52,7 +53,7 @@ public class EmailNewPatchSet {
EmailNewPatchSet create(
PostUpdateContext postUpdateContext,
PatchSet patchSet,
- String message,
+ @Nullable String message,
ImmutableSet<PatchSetApproval> outdatedApprovals,
@Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
@Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
@@ -75,7 +76,7 @@ public class EmailNewPatchSet {
MessageIdGenerator messageIdGenerator,
@Assisted PostUpdateContext postUpdateContext,
@Assisted PatchSet patchSet,
- @Assisted String message,
+ @Nullable @Assisted String message,
@Assisted ImmutableSet<PatchSetApproval> outdatedApprovals,
@Assisted("reviewers") ImmutableSet<Account.Id> reviewers,
@Assisted("extraCcs") ImmutableSet<Account.Id> extraCcs,
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index 44b4dedb2d..d9c30d7f65 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -42,6 +42,7 @@ public class FileInfoJsonImpl implements FileInfoJson {
this.diffs = diffOperations;
}
+ @Nullable
@Override
public Map<String, FileInfo> getFileInfoMap(
Change change, ObjectId objectId, @Nullable PatchSet base)
@@ -63,6 +64,7 @@ public class FileInfoJsonImpl implements FileInfoJson {
}
}
+ @Nullable
@Override
public Map<String, FileInfo> getFileInfoMap(
Project.NameKey project, ObjectId objectId, int parent)
@@ -102,6 +104,14 @@ public class FileInfoJsonImpl implements FileInfoJson {
fileInfo.oldPath = FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType());
fileInfo.sizeDelta = fileDiff.sizeDelta();
fileInfo.size = fileDiff.size();
+ fileInfo.oldMode =
+ fileDiff.oldMode().isPresent() && !fileDiff.oldMode().get().equals(Patch.FileMode.MISSING)
+ ? fileDiff.oldMode().get().getMode()
+ : null;
+ fileInfo.newMode =
+ fileDiff.newMode().isPresent() && !fileDiff.newMode().get().equals(Patch.FileMode.MISSING)
+ ? fileDiff.newMode().get().getMode()
+ : null;
if (fileDiff.patchType().get() == Patch.PatchType.BINARY) {
fileInfo.binary = true;
} else {
diff --git a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
index b1f9726b0f..834a623f86 100644
--- a/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -65,6 +65,33 @@ public class GetRelatedChangesUtil {
*/
public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
throws IOException, PermissionBackendException {
+ List<ChangeData> cds = getUnsortedRelated(changeData, basePs, false);
+ if (cds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ return sorter.sort(cds, basePs);
+ }
+
+ /**
+ * Gets ancestor changes of a specific change revision.
+ *
+ * @param changeData the change of the inputted revision.
+ * @param basePs the revision that the method checks for related changes.
+ * @param alwaysIncludeOriginalChange whether to return the given change when no ancestors found.
+ * @return list of ancestor changes, sorted via {@link RelatedChangesSorter}
+ */
+ public List<RelatedChangesSorter.PatchSetData> getAncestors(
+ ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange)
+ throws IOException, PermissionBackendException {
+ List<ChangeData> cds = getUnsortedRelated(changeData, basePs, alwaysIncludeOriginalChange);
+ if (cds.isEmpty()) {
+ return Collections.emptyList();
+ }
+ return sorter.sortAncestors(cds, basePs);
+ }
+
+ private List<ChangeData> getUnsortedRelated(
+ ChangeData changeData, PatchSet basePs, boolean alwaysIncludeOriginalChange) {
Set<String> groups = getAllGroups(changeData.patchSets());
logger.atFine().log("groups = %s", groups);
if (groups.isEmpty()) {
@@ -78,12 +105,10 @@ public class GetRelatedChangesUtil {
return Collections.emptyList();
}
if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
- return Collections.emptyList();
+ return alwaysIncludeOriginalChange ? cds : Collections.emptyList();
}
- cds = reloadChangeIfStale(cds, changeData, basePs);
-
- return sorter.sort(cds, basePs);
+ return reloadChangeIfStale(cds, changeData, basePs);
}
private List<ChangeData> reloadChangeIfStale(
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 79e2054cba..b1fcf48ef9 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.server.project.ProjectCache.illegalState;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelType;
@@ -43,6 +44,16 @@ import java.util.Set;
* what labels are defined for the project. The label definition can change between the time a vote
* is originally made and a later point, for example when a change is submitted. This class
* normalizes old votes against current project configuration.
+ *
+ * <p>Normalizing a vote means making it compliant with the current label definition:
+ *
+ * <ul>
+ * <li>If the voting value is greater than the max allowed value according to the label
+ * definition, the voting value is changed to the max allowed value.
+ * <li>If the voting value is lower than the min allowed value according to the label definition,
+ * the voting value is changed to the min allowed value.
+ * <li>If the label definition for a vote is missing, the vote is deleted.
+ * </ul>
*/
@Singleton
public class LabelNormalizer {
@@ -121,6 +132,20 @@ public class LabelNormalizer {
return Result.create(unchanged, updated, deleted);
}
+ /**
+ * Returns a copy of the given approval normalized to the defined ranges for the label type. If
+ * the approval is for an unknown label {@link Optional#empty()} is returned
+ *
+ * @param notes change notes containing the given approval
+ * @param approval approval that should be normalized
+ */
+ public Optional<PatchSetApproval> normalize(ChangeNotes notes, PatchSetApproval approval) {
+ Result result = normalize(notes, ImmutableSet.of(approval));
+ return Optional.ofNullable(
+ Iterables.getFirst(
+ result.unchanged(), Iterables.getFirst(result.updated(), /* defaultValue= */ null)));
+ }
+
private PatchSetApproval applyTypeFloor(LabelType lt, PatchSetApproval a) {
PatchSetApproval.Builder b = a.toBuilder();
LabelValue atMin = lt.getMin();
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index 69a84dd83b..5555ba6b1c 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -36,15 +36,19 @@ import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.VotingRangeInfo;
import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DeleteVoteControl;
+import com.google.gerrit.server.project.RemoveReviewerControl;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -69,10 +73,17 @@ public class LabelsJson {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final PermissionBackend permissionBackend;
+ private final DeleteVoteControl deleteVoteControl;
+ private final RemoveReviewerControl removeReviewerControl;
@Inject
- LabelsJson(PermissionBackend permissionBackend) {
+ LabelsJson(
+ PermissionBackend permissionBackend,
+ DeleteVoteControl deleteVoteControl,
+ RemoveReviewerControl removeReviewerControl) {
this.permissionBackend = permissionBackend;
+ this.deleteVoteControl = deleteVoteControl;
+ this.removeReviewerControl = removeReviewerControl;
}
/**
@@ -80,6 +91,7 @@ public class LabelsJson {
* lazily populate accounts. Callers have to call {@link AccountLoader#fill()} afterwards to
* populate all accounts in the returned {@link LabelInfo}s.
*/
+ @Nullable
Map<String, LabelInfo> labelsFor(
AccountLoader accountLoader, ChangeData cd, boolean standard, boolean detailed)
throws PermissionBackendException {
@@ -132,6 +144,46 @@ public class LabelsJson {
return permitted.asMap();
}
+ /**
+ * Returns A map of all labels that the provided user has permission to remove.
+ *
+ * @param accountLoader to load the reviewers' data with.
+ * @param user a Gerrit user.
+ * @param cd {@link ChangeData} corresponding to a specific gerrit change.
+ * @return A Map of {@code labelName} -> {Map of {@code value} -> List of {@link AccountInfo}}
+ * that the user can remove votes from.
+ */
+ Map<String, Map<String, List<AccountInfo>>> removableLabels(
+ AccountLoader accountLoader, CurrentUser user, ChangeData cd)
+ throws PermissionBackendException {
+ if (cd.change().isMerged()) {
+ return new HashMap<>();
+ }
+
+ Map<String, Map<String, List<AccountInfo>>> res = new HashMap<>();
+ LabelTypes labelTypes = cd.getLabelTypes();
+ for (PatchSetApproval approval : cd.currentApprovals()) {
+ Optional<LabelType> labelType = labelTypes.byLabel(approval.labelId());
+ if (!labelType.isPresent()) {
+ continue;
+ }
+ if (!(deleteVoteControl.testDeleteVotePermissions(user, cd, approval, labelType.get())
+ || removeReviewerControl.testRemoveReviewer(
+ cd, user, approval.accountId(), approval.value()))) {
+ continue;
+ }
+ if (!res.containsKey(approval.label())) {
+ res.put(approval.label(), new HashMap<>());
+ }
+ String labelValue = LabelValue.formatValue(approval.value());
+ if (!res.get(approval.label()).containsKey(labelValue)) {
+ res.get(approval.label()).put(labelValue, new ArrayList<>());
+ }
+ res.get(approval.label()).get(labelValue).add(accountLoader.get(approval.accountId()));
+ }
+ return res;
+ }
+
private static void clearOnlyZerosEntries(SetMultimap<String, String> permitted) {
List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
@@ -216,10 +268,10 @@ public class LabelsJson {
}
}
- private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
+ private Map<String, Short> currentLabels(@Nullable Account.Id accountId, ChangeData cd) {
Map<String, Short> result = new HashMap<>();
for (PatchSetApproval psa : cd.currentApprovals()) {
- if (psa.accountId().equals(accountId)) {
+ if (accountId == null || psa.accountId().equals(accountId)) {
result.put(psa.label(), psa.value());
}
}
diff --git a/java/com/google/gerrit/server/change/NotifyResolver.java b/java/com/google/gerrit/server/change/NotifyResolver.java
index 27951cad8e..ff87bff340 100644
--- a/java/com/google/gerrit/server/change/NotifyResolver.java
+++ b/java/com/google/gerrit/server/change/NotifyResolver.java
@@ -66,7 +66,7 @@ public class NotifyResolver {
}
public boolean shouldNotify() {
- return !accounts().isEmpty() || handling().compareTo(NotifyHandling.NONE) > 0;
+ return !accounts().isEmpty() || !handling().equals(NotifyHandling.NONE);
}
}
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index f7bec1c0af..4a09f84096 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -15,12 +15,14 @@
package com.google.gerrit.server.change;
import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
@@ -285,7 +287,7 @@ public class PatchSetInserter implements BatchUpdateOp {
psUtil.insert(
ctx.getRevWalk(), ctx.getUpdate(psId), psId, commitId, newGroups, null, description);
- if (ctx.getNotify(change.getId()).handling() != NotifyHandling.NONE) {
+ if (!ctx.getNotify(change.getId()).handling().equals(NotifyHandling.NONE)) {
oldReviewers = approvalsUtil.getReviewers(ctx.getNotes());
}
@@ -360,17 +362,17 @@ public class PatchSetInserter implements BatchUpdateOp {
@Override
public void postUpdate(PostUpdateContext ctx) {
NotifyResolver.Result notify = ctx.getNotify(change.getId());
- if (notify.shouldNotify() && sendEmail) {
- requireNonNull(mailMessage);
-
+ if (sendEmail) {
emailNewPatchSetFactory
.create(
ctx,
patchSet,
mailMessage,
- approvalCopierResult.outdatedApprovals(),
- oldReviewers.byState(REVIEWER),
- oldReviewers.byState(CC),
+ approvalCopierResult.outdatedApprovals().stream()
+ .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+ .collect(toImmutableSet()),
+ oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(REVIEWER),
+ oldReviewers == null ? ImmutableSet.of() : oldReviewers.byState(CC),
changeKind,
preUpdateMetaId)
.sendAsync();
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index 4de21d693b..ed87c7649c 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -22,7 +22,9 @@ import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MergeConflictException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -30,6 +32,8 @@ import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.RebaseUtil.Base;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -46,8 +50,9 @@ import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.RepoContext;
-import com.google.inject.Inject;
+import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@@ -73,19 +78,24 @@ import org.eclipse.jgit.revwalk.RevWalk;
public class RebaseChangeOp implements BatchUpdateOp {
public interface Factory {
RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
+
+ RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, Change.Id baseChangeId);
}
private final PatchSetInserter.Factory patchSetInserterFactory;
private final MergeUtilFactory mergeUtilFactory;
private final RebaseUtil rebaseUtil;
private final ChangeResource.Factory changeResourceFactory;
+ private final ChangeNotes.Factory notesFactory;
private final ChangeNotes notes;
private final PatchSet originalPatchSet;
private final IdentifiedUser.GenericFactory identifiedUserFactory;
private final ProjectCache projectCache;
+ private final Project.NameKey projectName;
private ObjectId baseCommitId;
+ private Change.Id baseChangeId;
private PersonIdent committerIdent;
private boolean fireRevisionCreated = true;
private boolean validate = true;
@@ -104,26 +114,78 @@ public class RebaseChangeOp implements BatchUpdateOp {
private PatchSetInserter patchSetInserter;
private PatchSet rebasedPatchSet;
- @Inject
+ @AssistedInject
RebaseChangeOp(
PatchSetInserter.Factory patchSetInserterFactory,
MergeUtilFactory mergeUtilFactory,
RebaseUtil rebaseUtil,
ChangeResource.Factory changeResourceFactory,
- IdentifiedUser.GenericFactory identifiedUserFactory,
+ ChangeNotes.Factory notesFactory,
+ GenericFactory identifiedUserFactory,
ProjectCache projectCache,
@Assisted ChangeNotes notes,
@Assisted PatchSet originalPatchSet,
@Assisted ObjectId baseCommitId) {
+ this(
+ patchSetInserterFactory,
+ mergeUtilFactory,
+ rebaseUtil,
+ changeResourceFactory,
+ notesFactory,
+ identifiedUserFactory,
+ projectCache,
+ notes,
+ originalPatchSet);
+ this.baseCommitId = baseCommitId;
+ this.baseChangeId = null;
+ }
+
+ @AssistedInject
+ RebaseChangeOp(
+ PatchSetInserter.Factory patchSetInserterFactory,
+ MergeUtilFactory mergeUtilFactory,
+ RebaseUtil rebaseUtil,
+ ChangeResource.Factory changeResourceFactory,
+ ChangeNotes.Factory notesFactory,
+ GenericFactory identifiedUserFactory,
+ ProjectCache projectCache,
+ @Assisted ChangeNotes notes,
+ @Assisted PatchSet originalPatchSet,
+ @Assisted Change.Id baseChangeId) {
+ this(
+ patchSetInserterFactory,
+ mergeUtilFactory,
+ rebaseUtil,
+ changeResourceFactory,
+ notesFactory,
+ identifiedUserFactory,
+ projectCache,
+ notes,
+ originalPatchSet);
+ this.baseChangeId = baseChangeId;
+ this.baseCommitId = null;
+ }
+
+ private RebaseChangeOp(
+ PatchSetInserter.Factory patchSetInserterFactory,
+ MergeUtilFactory mergeUtilFactory,
+ RebaseUtil rebaseUtil,
+ ChangeResource.Factory changeResourceFactory,
+ ChangeNotes.Factory notesFactory,
+ GenericFactory identifiedUserFactory,
+ ProjectCache projectCache,
+ ChangeNotes notes,
+ PatchSet originalPatchSet) {
this.patchSetInserterFactory = patchSetInserterFactory;
this.mergeUtilFactory = mergeUtilFactory;
this.rebaseUtil = rebaseUtil;
this.changeResourceFactory = changeResourceFactory;
+ this.notesFactory = notesFactory;
this.identifiedUserFactory = identifiedUserFactory;
this.projectCache = projectCache;
this.notes = notes;
+ this.projectName = notes.getProjectName();
this.originalPatchSet = originalPatchSet;
- this.baseCommitId = baseCommitId;
}
public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -204,14 +266,23 @@ public class RebaseChangeOp implements BatchUpdateOp {
@Override
public void updateRepo(RepoContext ctx)
- throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
- NoSuchChangeException, PermissionBackendException {
+ throws InvalidChangeOperationException, RestApiException, IOException, NoSuchChangeException,
+ PermissionBackendException {
// Ok that originalPatchSet was not read in a transaction, since we just
// need its revision.
RevWalk rw = ctx.getRevWalk();
RevCommit original = rw.parseCommit(originalPatchSet.commitId());
rw.parseBody(original);
- RevCommit baseCommit = rw.parseCommit(baseCommitId);
+ RevCommit baseCommit;
+ if (baseCommitId != null && baseChangeId == null) {
+ baseCommit = rw.parseCommit(baseCommitId);
+ } else if (baseChangeId != null) {
+ baseCommit =
+ PatchSetUtil.getCurrentRevCommitIncludingPending(ctx, notesFactory, baseChangeId);
+ } else {
+ throw new IllegalStateException(
+ "Exactly one of base commit and base change must be provided.");
+ }
CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
String newCommitMessage;
@@ -224,12 +295,12 @@ public class RebaseChangeOp implements BatchUpdateOp {
newCommitMessage = original.getFullMessage();
}
- rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
+ rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage, notes.getChangeId());
Base base =
rebaseUtil.parseBase(
new RevisionResource(
changeResourceFactory.create(notes, changeOwner), originalPatchSet),
- baseCommitId.name());
+ baseCommit.getName());
rebasedPatchSetId =
ChangeUtil.nextPatchSetIdFromChangeRefs(
@@ -256,7 +327,8 @@ public class RebaseChangeOp implements BatchUpdateOp {
if (postMessage) {
patchSetInserter.setMessage(
- messageForRebasedChange(rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
+ messageForRebasedChange(
+ ctx.getIdentifiedUser(), rebasedPatchSetId, originalPatchSet.id(), rebasedCommit));
}
if (base != null && !base.notes().getChange().isMerged()) {
@@ -274,13 +346,22 @@ public class RebaseChangeOp implements BatchUpdateOp {
}
private static String messageForRebasedChange(
- PatchSet.Id rebasePatchSetId, PatchSet.Id originalPatchSetId, CodeReviewCommit commit) {
+ IdentifiedUser user,
+ PatchSet.Id rebasePatchSetId,
+ PatchSet.Id originalPatchSetId,
+ CodeReviewCommit commit) {
StringBuilder stringBuilder =
new StringBuilder(
String.format(
"Patch Set %d: Patch Set %d was rebased",
rebasePatchSetId.get(), originalPatchSetId.get()));
+ if (user.isImpersonating()) {
+ stringBuilder.append(
+ String.format(
+ " on behalf of %s", AccountTemplateUtil.getAccountTemplate(user.getAccountId())));
+ }
+
if (!commit.getFilesWithGitConflicts().isEmpty()) {
stringBuilder.append("\n\nThe following files contain Git conflicts:\n");
commit.getFilesWithGitConflicts().stream()
@@ -320,8 +401,7 @@ public class RebaseChangeOp implements BatchUpdateOp {
}
private MergeUtil newMergeUtil() {
- ProjectState project =
- projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
+ ProjectState project = projectCache.get(projectName).orElseThrow(illegalState(projectName));
return forceContentMerge
? mergeUtilFactory.create(project, true)
: mergeUtilFactory.create(project);
@@ -338,7 +418,11 @@ public class RebaseChangeOp implements BatchUpdateOp {
* @throws IOException the merge failed for another reason.
*/
private CodeReviewCommit rebaseCommit(
- RepoContext ctx, RevCommit original, ObjectId base, String commitMessage)
+ RepoContext ctx,
+ RevCommit original,
+ ObjectId base,
+ String commitMessage,
+ Change.Id originalChangeId)
throws ResourceConflictException, IOException {
RevCommit parentCommit = original.getParent(0);
@@ -372,8 +456,9 @@ public class RebaseChangeOp implements BatchUpdateOp {
if (!allowConflicts || !(merger instanceof ResolveMerger)) {
throw new MergeConflictException(
- "The change could not be rebased due to a conflict during merge.\n\n"
- + MergeUtil.createConflictMessage(conflicts));
+ String.format(
+ "Change %s could not be rebased due to a conflict during merge.\n\n%s",
+ originalChangeId.toString(), MergeUtil.createConflictMessage(conflicts)));
}
Map<String, MergeResult<? extends Sequence>> mergeResults =
@@ -413,7 +498,6 @@ public class RebaseChangeOp implements BatchUpdateOp {
cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
}
ObjectId objectId = ctx.getInserter().insert(cb);
- ctx.getInserter().flush();
CodeReviewCommit commit = ((CodeReviewRevWalk) ctx.getRevWalk()).parseCommit(objectId);
commit.setFilesWithGitConflicts(filesWithGitConflicts);
return commit;
diff --git a/java/com/google/gerrit/server/change/RebaseUtil.java b/java/com/google/gerrit/server/change/RebaseUtil.java
index 2d36df204a..56ab9365e8 100644
--- a/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -17,22 +17,39 @@ package com.google.gerrit.server.change;
import com.google.auto.value.AutoValue;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+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.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -42,24 +59,251 @@ import org.eclipse.jgit.revwalk.RevWalk;
public class RebaseUtil {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ private final Provider<PersonIdent> serverIdent;
+ private final IdentifiedUser.GenericFactory userFactory;
+ private final PermissionBackend permissionBackend;
+ private final ChangeResource.Factory changeResourceFactory;
+ private final GitRepositoryManager repoManager;
private final Provider<InternalChangeQuery> queryProvider;
private final ChangeNotes.Factory notesFactory;
private final PatchSetUtil psUtil;
+ private final RebaseChangeOp.Factory rebaseFactory;
@Inject
RebaseUtil(
+ @GerritPersonIdent Provider<PersonIdent> serverIdent,
+ IdentifiedUser.GenericFactory userFactory,
+ PermissionBackend permissionBackend,
+ ChangeResource.Factory changeResourceFactory,
+ GitRepositoryManager repoManager,
Provider<InternalChangeQuery> queryProvider,
ChangeNotes.Factory notesFactory,
- PatchSetUtil psUtil) {
+ PatchSetUtil psUtil,
+ RebaseChangeOp.Factory rebaseFactory) {
+ this.serverIdent = serverIdent;
+ this.userFactory = userFactory;
+ this.permissionBackend = permissionBackend;
+ this.changeResourceFactory = changeResourceFactory;
+ this.repoManager = repoManager;
this.queryProvider = queryProvider;
this.notesFactory = notesFactory;
this.psUtil = psUtil;
+ this.rebaseFactory = rebaseFactory;
+ }
+
+ /**
+ * Checks that the uploader has permissions to create a new patch set and creates a new {@link
+ * RevisionResource} that contains the uploader (aka the impersonated user) as the current user
+ * which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader.
+ *
+ * <p>The following permissions are required for the uploader:
+ *
+ * <ul>
+ * <li>The {@code Read} permission that allows to see the change.
+ * <li>The {@code Push} permission that allows upload.
+ * <li>The {@code Add Patch Set} permission, required if the change is owned by another user
+ * (change owners implicitly have this permission).
+ * <li>The {@code Forge Author} permission if the patch set that is rebased has a forged author
+ * (author != uploader).
+ * <li>The {@code Forge Server} permission if the patch set that is rebased has the server
+ * identity as the author.
+ * </ul>
+ *
+ * <p>Usually the uploader should have all these permission since they were already required for
+ * the original upload, but there is the edge case that the uploader had the permission when doing
+ * the original upload and then the permission was revoked.
+ *
+ * <p>Note that patch sets with a forged committer (committer != uploader) can be rebased on
+ * behalf of the uploader, even if the uploader doesn't have the {@code Forge Committer}
+ * permission. This is because on rebase on behalf of the uploader the uploader will become the
+ * committer of the new rebased patch set, hence for the rebased patch set the committer is no
+ * longer forged (committer == uploader) and hence the {@code Forge Committer} permission is not
+ * required.
+ *
+ * <p>Note that the {@code Rebase} permission is not required for the uploader since the {@code
+ * Rebase} permission is specifically about allowing a user to do a rebase via the web UI by
+ * clicking on the {@code REBASE} button and the uploader is not clicking on this button.
+ *
+ * <p>The permissions of the uploader are checked explicitly here so that we can return a {@code
+ * 409 Conflict} response with a proper error message if they are missing (the error message says
+ * that the permission is missing for the uploader). The normal code path also checks these
+ * permission but the exception thrown there would result in a {@code 403 Forbidden} response and
+ * the error message would wrongly look like the caller (i.e. the rebaser) is missing the
+ * permission.
+ *
+ * <p>Note that this method doesn't check permissions for the rebaser (aka the impersonating user
+ * aka the calling user). Callers should check the permissions for the rebaser before calling this
+ * method.
+ *
+ * @param rsrc the revision resource that should be rebased
+ * @param rebaseInput the request input containing options for the rebase
+ * @return revision resource that contains the uploader (aka the impersonated user) as the current
+ * user which can be used for {@link BatchUpdate} to do the rebase on behalf of the uploader
+ */
+ public RevisionResource onBehalfOf(RevisionResource rsrc, RebaseInput rebaseInput)
+ throws IOException, PermissionBackendException, BadRequestException,
+ ResourceConflictException {
+ if (rebaseInput.allowConflicts) {
+ throw new BadRequestException(
+ "allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+ }
+
+ if (rsrc.getPatchSet().id().get() != rsrc.getChange().currentPatchSetId().get()) {
+ throw new BadRequestException(
+ String.format(
+ "change %s: non-current patch set cannot be rebased on behalf of the uploader",
+ rsrc.getChange().getId()));
+ }
+
+ CurrentUser caller = rsrc.getUser();
+ Account.Id uploaderId = rsrc.getPatchSet().uploader();
+ IdentifiedUser uploader = userFactory.runAs(/*remotePeer= */ null, uploaderId, caller);
+ logger.atFine().log(
+ "%s is rebasing patch set %s of project %s on behalf of uploader %s",
+ caller.getLoggableName(),
+ rsrc.getPatchSet().id(),
+ rsrc.getProject(),
+ uploader.getLoggableName());
+
+ checkPermissionForUploader(
+ uploader,
+ rsrc.getNotes(),
+ ChangePermission.READ,
+ String.format(
+ "change %s: uploader %s cannot read change",
+ rsrc.getChange().getId(), uploader.getLoggableName()));
+ checkPermissionForUploader(
+ uploader,
+ rsrc.getNotes(),
+ ChangePermission.ADD_PATCH_SET,
+ String.format(
+ "change %s: uploader %s cannot add patch set",
+ rsrc.getChange().getId(), uploader.getLoggableName()));
+
+ try (Repository repo = repoManager.openRepository(rsrc.getProject())) {
+ RevCommit commit = repo.parseCommit(rsrc.getPatchSet().commitId());
+
+ if (!uploader.hasEmailAddress(commit.getAuthorIdent().getEmailAddress())) {
+ checkPermissionForUploader(
+ uploader,
+ rsrc.getNotes(),
+ RefPermission.FORGE_AUTHOR,
+ String.format(
+ "change %s: author of patch set %d is forged and the uploader %s cannot forge author",
+ rsrc.getChange().getId(),
+ rsrc.getPatchSet().id().get(),
+ uploader.getLoggableName()));
+
+ if (serverIdent.get().getEmailAddress().equals(commit.getAuthorIdent().getEmailAddress())) {
+ checkPermissionForUploader(
+ uploader,
+ rsrc.getNotes(),
+ RefPermission.FORGE_SERVER,
+ String.format(
+ "change %s: author of patch set %d is the server identity and the uploader %s cannot forge"
+ + " the server identity",
+ rsrc.getChange().getId(),
+ rsrc.getPatchSet().id().get(),
+ uploader.getLoggableName()));
+ }
+ }
+ }
+
+ return new RevisionResource(
+ changeResourceFactory.create(rsrc.getNotes(), uploader), rsrc.getPatchSet());
+ }
+
+ private void checkPermissionForUploader(
+ IdentifiedUser uploader,
+ ChangeNotes changeNotes,
+ ChangePermission changePermission,
+ String errorMessage)
+ throws PermissionBackendException, ResourceConflictException {
+ try {
+ permissionBackend.user(uploader).change(changeNotes).check(changePermission);
+ } catch (AuthException e) {
+ throw new ResourceConflictException(errorMessage, e);
+ }
+ }
+
+ private void checkPermissionForUploader(
+ IdentifiedUser uploader,
+ ChangeNotes changeNotes,
+ RefPermission refPermission,
+ String errorMessage)
+ throws PermissionBackendException, ResourceConflictException {
+ try {
+ permissionBackend.user(uploader).ref(changeNotes.getChange().getDest()).check(refPermission);
+ } catch (AuthException e) {
+ throw new ResourceConflictException(errorMessage, e);
+ }
+ }
+
+ /**
+ * Checks whether the given change fulfills all preconditions to be rebased.
+ *
+ * <p>This method does not check whether the calling user is allowed to rebase the change.
+ */
+ public void verifyRebasePreconditions(RevWalk rw, ChangeNotes changeNotes, PatchSet patchSet)
+ throws ResourceConflictException, IOException {
+ // Not allowed to rebase if the current patch set is locked.
+ psUtil.checkPatchSetNotLocked(changeNotes);
+
+ Change change = changeNotes.getChange();
+ if (!change.isNew()) {
+ throw new ResourceConflictException(
+ String.format("Change %s is %s", change.getId(), ChangeUtil.status(change)));
+ }
+
+ if (!hasOneParent(rw, patchSet)) {
+ throw new ResourceConflictException(
+ String.format(
+ "Error rebasing %s. Cannot rebase %s",
+ change.getId(),
+ countParents(rw, patchSet) > 1 ? "merge commits" : "commit with no ancestor"));
+ }
+ }
+
+ public static boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
+ // Prevent rebase of exotic changes (merge commit, no ancestor).
+ return countParents(rw, ps) == 1;
+ }
+
+ private static int countParents(RevWalk rw, PatchSet ps) throws IOException {
+ RevCommit c = rw.parseCommit(ps.commitId());
+ return c.getParentCount();
+ }
+
+ private static boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
+ ObjectId baseId = base.commitId();
+ ObjectId tipId = tip.commitId();
+ return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
}
public boolean canRebase(PatchSet patchSet, BranchNameKey dest, Repository git, RevWalk rw) {
try {
- findBaseRevision(patchSet, dest, git, rw);
- return true;
+ RevCommit commit = rw.parseCommit(patchSet.commitId());
+
+ if (commit.getParentCount() > 1) {
+ throw new UnprocessableEntityException("Cannot rebase a change with multiple parents.");
+ } else if (commit.getParentCount() == 0) {
+ throw new UnprocessableEntityException(
+ "Cannot rebase a change without any parents (is this the initial commit?).");
+ }
+
+ Ref destRef = git.getRefDatabase().exactRef(dest.branch());
+ if (destRef == null) {
+ throw new UnprocessableEntityException(
+ "The destination branch does not exist: " + dest.branch());
+ }
+
+ // Change can be rebased if its parent commit differs from the HEAD commit of the destination
+ // branch.
+ // It's possible that the change is part of a chain that is based on the HEAD commit of the
+ // destination branch and the chain cannot be rebased, but then the change can still be
+ // rebased onto the destination branch to break the relation to its parent change.
+ ObjectId parentId = commit.getParent(0);
+ return !destRef.getObjectId().equals(parentId);
} catch (RestApiException e) {
return false;
} catch (StorageException | IOException e) {
@@ -71,6 +315,7 @@ public class RebaseUtil {
@AutoValue
public abstract static class Base {
+ @Nullable
private static Base create(ChangeNotes notes, PatchSet ps) {
if (notes == null) {
return null;
@@ -127,6 +372,100 @@ public class RebaseUtil {
}
/**
+ * Parse or find the commit onto which a patch set should be rebased.
+ *
+ * <p>If a {@code rebaseInput.base} is provided, parse it. Otherwise, finds the latest patch set
+ * of the change corresponding to this commit's parent, or the destination branch tip in the case
+ * where the parent's change is merged.
+ *
+ * @param git the repository.
+ * @param rw the RevWalk.
+ * @param permissionBackend to check base reading permissions with.
+ * @param rsrc to find the base for
+ * @param rebaseInput to optionally parse the base from.
+ * @param verifyNeedsRebase whether to verify if the change base is not already up to date
+ * @return the commit onto which the patch set should be rebased.
+ * @throws RestApiException if rebase is not possible.
+ * @throws IOException if accessing the repository fails.
+ * @throws PermissionBackendException if the user don't have permissions to read the base change.
+ */
+ public ObjectId parseOrFindBaseRevision(
+ Repository git,
+ RevWalk rw,
+ PermissionBackend permissionBackend,
+ RevisionResource rsrc,
+ RebaseInput rebaseInput,
+ boolean verifyNeedsRebase)
+ throws RestApiException, IOException, PermissionBackendException {
+ Change change = rsrc.getChange();
+
+ if (rebaseInput == null || rebaseInput.base == null) {
+ return findBaseRevision(rsrc.getPatchSet(), change.getDest(), git, rw, verifyNeedsRebase);
+ }
+
+ String inputBase = rebaseInput.base.trim();
+
+ if (inputBase.isEmpty()) {
+ return getDestRefTip(git, change.getDest());
+ }
+
+ Base base;
+ try {
+ base = parseBase(rsrc, inputBase);
+ } catch (NoSuchChangeException e) {
+ throw new UnprocessableEntityException(
+ String.format("Base change not found: %s", inputBase), e);
+ }
+ if (base == null) {
+ throw new ResourceConflictException(
+ "base revision is missing from the destination branch: " + inputBase);
+ }
+ return getLatestRevisionForBaseChange(rw, permissionBackend, rsrc, base);
+ }
+
+ private ObjectId getDestRefTip(Repository git, BranchNameKey destRefKey)
+ throws ResourceConflictException, IOException {
+ // Remove existing dependency to other patch set.
+ Ref destRef = git.exactRef(destRefKey.branch());
+ if (destRef == null) {
+ throw new ResourceConflictException(
+ "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
+ }
+ return destRef.getObjectId();
+ }
+
+ private ObjectId getLatestRevisionForBaseChange(
+ RevWalk rw, PermissionBackend permissionBackend, RevisionResource childRsrc, Base base)
+ throws ResourceConflictException, AuthException, PermissionBackendException, IOException {
+
+ Change child = childRsrc.getChange();
+ PatchSet.Id baseId = base.patchSet().id();
+ if (child.getId().equals(baseId.changeId())) {
+ throw new ResourceConflictException(
+ String.format("cannot rebase change %s onto itself", childRsrc.getChange().getId()));
+ }
+
+ permissionBackend.user(childRsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
+
+ Change baseChange = base.notes().getChange();
+ if (!baseChange.getProject().equals(child.getProject())) {
+ throw new ResourceConflictException(
+ "base change is in wrong project: " + baseChange.getProject());
+ } else if (!baseChange.getDest().equals(child.getDest())) {
+ throw new ResourceConflictException(
+ "base change is targeting wrong branch: " + baseChange.getDest());
+ } else if (baseChange.isAbandoned()) {
+ throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
+ } else if (isMergedInto(rw, childRsrc.getPatchSet(), base.patchSet())) {
+ throw new ResourceConflictException(
+ "base change "
+ + baseChange.getKey()
+ + " is a descendant of the current change - recursion not allowed");
+ }
+ return base.patchSet().commitId();
+ }
+
+ /**
* Find the commit onto which a patch set should be rebased.
*
* <p>This is defined as the latest patch set of the change corresponding to this commit's parent,
@@ -136,12 +475,17 @@ public class RebaseUtil {
* @param destBranch the destination branch.
* @param git the repository.
* @param rw the RevWalk.
+ * @param verifyNeedsRebase whether to verify if the change base is not already up to date
* @return the commit onto which the patch set should be rebased.
* @throws RestApiException if rebase is not possible.
* @throws IOException if accessing the repository fails.
*/
public ObjectId findBaseRevision(
- PatchSet patchSet, BranchNameKey destBranch, Repository git, RevWalk rw)
+ PatchSet patchSet,
+ BranchNameKey destBranch,
+ Repository git,
+ RevWalk rw,
+ boolean verifyNeedsRebase)
throws RestApiException, IOException {
ObjectId baseId = null;
RevCommit commit = rw.parseCommit(patchSet.commitId());
@@ -168,7 +512,7 @@ public class RebaseUtil {
}
if (depChange.isNew()) {
- if (depPatchSet.id().equals(depChange.currentPatchSetId())) {
+ if (verifyNeedsRebase && depPatchSet.id().equals(depChange.currentPatchSetId())) {
throw new ResourceConflictException(
"Change is already based on the latest patch set of the dependent change.");
}
@@ -187,10 +531,29 @@ public class RebaseUtil {
"The destination branch does not exist: " + destBranch.branch());
}
baseId = destRef.getObjectId();
- if (baseId.equals(parentId)) {
+ if (verifyNeedsRebase && baseId.equals(parentId)) {
throw new ResourceConflictException("Change is already up to date.");
}
}
return baseId;
}
+
+ public RebaseChangeOp getRebaseOp(RevisionResource revRsrc, RebaseInput input, ObjectId baseRev) {
+ return applyRebaseInputToOp(
+ rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseRev), input);
+ }
+
+ public RebaseChangeOp getRebaseOp(
+ RevisionResource revRsrc, RebaseInput input, Change.Id baseChange) {
+ return applyRebaseInputToOp(
+ rebaseFactory.create(revRsrc.getNotes(), revRsrc.getPatchSet(), baseChange), input);
+ }
+
+ private RebaseChangeOp applyRebaseInputToOp(RebaseChangeOp op, RebaseInput input) {
+ return op.setForceContentMerge(true)
+ .setAllowConflicts(input.allowConflicts)
+ .setValidationOptions(
+ ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions))
+ .setFireRevisionCreated(true);
+ }
}
diff --git a/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index b6e3121271..f4b1a83cb2 100644
--- a/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -75,16 +75,7 @@ public class RelatedChangesSorter {
checkArgument(!in.isEmpty(), "Input may not be empty");
// Map of all patch sets, keyed by commit SHA-1.
Map<ObjectId, PatchSetData> byId = collectById(in);
- PatchSetData start = byId.get(startPs.commitId());
- requireNonNull(
- start,
- () ->
- String.format(
- "commit %s of patch set %s not found in %s",
- startPs.commitId().name(),
- startPs.id(),
- byId.entrySet().stream()
- .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+ PatchSetData start = getCheckedPatchSetData(byId, startPs);
// Map of patch set -> immediate parent.
ListMultimap<PatchSetData, PatchSetData> parents =
@@ -120,6 +111,34 @@ public class RelatedChangesSorter {
return result;
}
+ public List<PatchSetData> sortAncestors(List<ChangeData> in, PatchSet startPs)
+ throws IOException, PermissionBackendException {
+ checkArgument(!in.isEmpty(), "Input may not be empty");
+ // Map of all patch sets, keyed by commit SHA-1.
+ Map<ObjectId, PatchSetData> byId = collectById(in);
+ PatchSetData start = getCheckedPatchSetData(byId, startPs);
+
+ // Map of patch set -> immediate parent.
+ ListMultimap<PatchSetData, PatchSetData> parents =
+ MultimapBuilder.hashKeys(in.size()).arrayListValues(3).build();
+
+ for (ChangeData cd : in) {
+ for (PatchSet ps : cd.patchSets()) {
+ PatchSetData thisPsd = requireNonNull(byId.get(ps.commitId()));
+
+ for (RevCommit p : thisPsd.commit().getParents()) {
+ PatchSetData parentPsd = byId.get(p);
+ if (parentPsd != null) {
+ parents.put(thisPsd, parentPsd);
+ }
+ }
+ }
+ }
+
+ Collection<PatchSetData> ancestors = walkAncestors(parents, start);
+ return List.copyOf(ancestors);
+ }
+
private Map<ObjectId, PatchSetData> collectById(List<ChangeData> in) throws IOException {
Project.NameKey project = in.get(0).change().getProject();
Map<ObjectId, PatchSetData> result = Maps.newHashMapWithExpectedSize(in.size() * 3);
@@ -143,6 +162,19 @@ public class RelatedChangesSorter {
return result;
}
+ private PatchSetData getCheckedPatchSetData(Map<ObjectId, PatchSetData> byId, PatchSet ps) {
+ PatchSetData psData = byId.get(ps.commitId());
+ return requireNonNull(
+ psData,
+ () ->
+ String.format(
+ "commit %s of patch set %s not found in %s",
+ ps.commitId().name(),
+ ps.id(),
+ byId.entrySet().stream()
+ .collect(toMap(e -> e.getKey().name(), e -> e.getValue().patchSet().id()))));
+ }
+
private Collection<PatchSetData> walkAncestors(
ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
index 9580565c72..b5e01810a5 100644
--- a/java/com/google/gerrit/server/change/ReviewerModifier.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -94,9 +94,21 @@ public class ReviewerModifier {
public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
public static final int DEFAULT_MAX_REVIEWERS = 20;
+ /**
+ * Controls which failures should be ignored.
+ *
+ * <p>If a failure is ignored the operation succeeds, but the reviewer is not added. If not
+ * ignored a failure means that the operation fails.
+ */
public enum FailureBehavior {
+ // All failures cause the operation to fail.
FAIL,
- IGNORE;
+
+ // Only not found failures cause the operation to fail, all other failures are ignored.
+ IGNORE_EXCEPT_NOT_FOUND,
+
+ // All failures are ignored.
+ IGNORE_ALL;
}
private enum FailureType {
@@ -113,6 +125,9 @@ public class ReviewerModifier {
* resolving to an account/group/email.
*/
public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
+
+ /** Whether the visibility check for the reviewer account should be skipped. */
+ public boolean skipVisibilityCheck = false;
}
public static InternalReviewerInput newReviewerInput(
@@ -143,7 +158,7 @@ public class ReviewerModifier {
in.reviewer = accountId.toString();
in.state = CC;
in.notify = notify;
- in.otherFailureBehavior = FailureBehavior.IGNORE;
+ in.otherFailureBehavior = FailureBehavior.IGNORE_ALL;
return Optional.of(in);
}
@@ -262,7 +277,14 @@ public class ReviewerModifier {
IdentifiedUser reviewerUser;
boolean exactMatchFound = false;
try {
- reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+ if (ReviewerState.REMOVED.equals(input.state)
+ || (input instanceof InternalReviewerInput
+ && ((InternalReviewerInput) input).skipVisibilityCheck)) {
+ reviewerUser =
+ accountResolver.resolveIncludeInactiveIgnoreVisibility(input.reviewer).asUniqueUser();
+ } else {
+ reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
+ }
if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
|| input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
exactMatchFound = true;
@@ -577,7 +599,9 @@ public class ReviewerModifier {
(input instanceof InternalReviewerInput)
? ((InternalReviewerInput) input).otherFailureBehavior
: FailureBehavior.FAIL;
- return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
+ return behavior == FailureBehavior.IGNORE_ALL
+ || (failureType == FailureType.OTHER
+ && behavior == FailureBehavior.IGNORE_EXCEPT_NOT_FOUND);
}
}
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index bd9c52b00d..c4fd5bec3a 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -289,6 +289,9 @@ public class RevisionJson {
out.ref = in.refName();
out.setCreated(in.createdOn());
out.uploader = accountLoader.get(in.uploader());
+ if (!in.uploader().equals(in.realUploader())) {
+ out.realUploader = accountLoader.get(in.realUploader());
+ }
out.fetch = makeFetchMap(cd, in);
out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
out.description = in.description().orElse(null);
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
deleted file mode 100644
index fd3e972bd7..0000000000
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.extensions.events.AssigneeChanged;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.SetAssigneeSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.validators.AssigneeValidationListener;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-
-public class SetAssigneeOp implements BatchUpdateOp {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
- public interface Factory {
- SetAssigneeOp create(IdentifiedUser assignee);
- }
-
- private final ChangeMessagesUtil cmUtil;
- private final PluginSetContext<AssigneeValidationListener> validationListeners;
- private final IdentifiedUser newAssignee;
- private final AssigneeChanged assigneeChanged;
- private final SetAssigneeSender.Factory setAssigneeSenderFactory;
- private final Provider<IdentifiedUser> user;
- private final IdentifiedUser.GenericFactory userFactory;
- private final MessageIdGenerator messageIdGenerator;
-
- private Change change;
- private IdentifiedUser oldAssignee;
-
- @Inject
- SetAssigneeOp(
- ChangeMessagesUtil cmUtil,
- PluginSetContext<AssigneeValidationListener> validationListeners,
- AssigneeChanged assigneeChanged,
- SetAssigneeSender.Factory setAssigneeSenderFactory,
- Provider<IdentifiedUser> user,
- IdentifiedUser.GenericFactory userFactory,
- MessageIdGenerator messageIdGenerator,
- @Assisted IdentifiedUser newAssignee) {
- this.cmUtil = cmUtil;
- this.validationListeners = validationListeners;
- this.assigneeChanged = assigneeChanged;
- this.setAssigneeSenderFactory = setAssigneeSenderFactory;
- this.user = user;
- this.userFactory = userFactory;
- this.messageIdGenerator = messageIdGenerator;
- this.newAssignee = requireNonNull(newAssignee, "assignee");
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws RestApiException {
- change = ctx.getChange();
- if (newAssignee.getAccountId().equals(change.getAssignee())) {
- return false;
- }
- try {
- validationListeners.runEach(
- l -> l.validateAssignee(change, newAssignee.getAccount()), ValidationException.class);
- } catch (ValidationException e) {
- throw new ResourceConflictException(e.getMessage(), e);
- }
-
- if (change.getAssignee() != null) {
- oldAssignee = userFactory.create(change.getAssignee());
- }
-
- ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
- // notedb
- update.setAssignee(newAssignee.getAccountId());
- // reviewdb
- change.setAssignee(newAssignee.getAccountId());
- addMessage(ctx);
- return true;
- }
-
- private void addMessage(ChangeContext ctx) {
- StringBuilder msg = new StringBuilder();
- msg.append("Assignee ");
- if (oldAssignee == null) {
- msg.append("added: ");
- msg.append(AccountTemplateUtil.getAccountTemplate(newAssignee.getAccountId()));
- } else {
- msg.append("changed from: ");
- msg.append(AccountTemplateUtil.getAccountTemplate(oldAssignee.getAccountId()));
- msg.append(" to: ");
- msg.append(AccountTemplateUtil.getAccountTemplate(newAssignee.getAccountId()));
- }
- cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
- }
-
- @Override
- public void postUpdate(PostUpdateContext ctx) {
- try {
- SetAssigneeSender emailSender =
- setAssigneeSenderFactory.create(
- change.getProject(), change.getId(), newAssignee.getAccountId());
- emailSender.setFrom(user.get().getAccountId());
- emailSender.setMessageId(
- messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
- emailSender.send();
- } catch (Exception err) {
- logger.atSevere().withCause(err).log(
- "Cannot send email to new assignee of change %s", change.getId());
- }
- assigneeChanged.fire(
- ctx.getChangeData(change),
- ctx.getAccount(),
- oldAssignee != null ? oldAssignee.state() : null,
- ctx.getWhen());
- }
-}
diff --git a/java/com/google/gerrit/server/change/ValidationOptionsUtil.java b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
new file mode 100644
index 0000000000..137239c60e
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ValidationOptionsUtil.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+
+/** Utilities for validation options parsing. */
+public final class ValidationOptionsUtil {
+ public static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
+ @Nullable Map<String, String> validationOptions) {
+ if (validationOptions == null) {
+ return ImmutableListMultimap.of();
+ }
+
+ ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
+ ImmutableListMultimap.builder();
+ validationOptions
+ .entrySet()
+ .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
+ return validationOptionsBuilder.build();
+ }
+
+ private ValidationOptionsUtil() {}
+}
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index 04fd1c0e77..a0bf5b4ba4 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -124,7 +124,8 @@ public class WorkInProgressOp implements BatchUpdateOp {
stateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
NotifyResolver.Result notify = ctx.getNotify(change.getId());
if (workInProgress
- || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
+ || notify.handling().equals(NotifyHandling.OWNER)
+ || notify.handling().equals(NotifyHandling.NONE)
|| !sendEmail) {
return;
}
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index 9a1710d37e..79e5312722 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -225,6 +225,11 @@ public class CommentContextLoader {
private static Optional<Range> getStartAndEndLines(ContextInput comment) {
if (comment.range() != null) {
+ if (comment.range().endLine < comment.range().startLine) {
+ // Seems like comments, created in reply to robot comments sometimes have invalid ranges
+ // Fix here, otherwise the range is invalid and we throw an error later on.
+ return Optional.of(Range.create(comment.range().startLine, comment.range().startLine + 1));
+ }
return Optional.of(Range.create(comment.range().startLine, comment.range().endLine + 1));
} else if (comment.lineNumber() > 0) {
return Optional.of(Range.create(comment.lineNumber(), comment.lineNumber() + 1));
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 59819bb25c..1f799c6485 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -45,4 +45,5 @@ public class CapabilityConstants extends TranslationBundle {
public String viewConnections;
public String viewPlugins;
public String viewQueue;
+ public String viewSecondaryEmails;
}
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index 7fd075e66f..5e6a520762 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -18,6 +18,7 @@ import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.Multimap;
import java.util.Collections;
import java.util.LinkedHashSet;
+import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
@@ -139,7 +140,7 @@ public class ConfigUpdatedEvent {
@Override
public String toString() {
- return StringUtils.capitalize(name().toLowerCase());
+ return StringUtils.capitalize(name().toLowerCase(Locale.US));
}
}
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 85081e436e..4fdbd4a4ee 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.config;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.CoreDownloadSchemes;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -28,6 +29,7 @@ import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import org.eclipse.jgit.lib.Config;
@@ -97,9 +99,10 @@ public class DownloadConfig {
return list.size() == 1 && list.get(0) == null;
}
+ @Nullable
private static String toCoreScheme(String s) {
try {
- Field f = CoreDownloadSchemes.class.getField(s.toUpperCase());
+ Field f = CoreDownloadSchemes.class.getField(s.toUpperCase(Locale.US));
int m = Modifier.PUBLIC | Modifier.STATIC | Modifier.FINAL;
if ((f.getModifiers() & m) == m && f.getType() == String.class) {
return (String) f.get(null);
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 13024a207e..4325ec42c5 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -36,7 +36,6 @@ import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
import com.google.gerrit.extensions.events.AccountActivationListener;
import com.google.gerrit.extensions.events.AccountIndexedListener;
import com.google.gerrit.extensions.events.AgreementSignupListener;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
import com.google.gerrit.extensions.events.AttentionSetListener;
import com.google.gerrit.extensions.events.ChangeAbandonedListener;
import com.google.gerrit.extensions.events.ChangeDeletedListener;
@@ -191,16 +190,15 @@ import com.google.gerrit.server.project.CommentLinkProvider;
import com.google.gerrit.server.project.ProjectCacheImpl;
import com.google.gerrit.server.project.ProjectNameLockManager;
import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.PrologRulesWarningValidator;
import com.google.gerrit.server.project.SubmitRequirementConfigValidator;
import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.FileEditsPredicate;
import com.google.gerrit.server.query.approval.ApprovalModule;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ConflictsCacheImpl;
-import com.google.gerrit.server.query.change.DistinctVotersPredicate;
import com.google.gerrit.server.quota.QuotaEnforcer;
import com.google.gerrit.server.restapi.change.OnPostReview;
import com.google.gerrit.server.restapi.change.SuggestReviewers;
@@ -216,12 +214,14 @@ import com.google.gerrit.server.submit.GitModules;
import com.google.gerrit.server.submit.MergeSuperSetComputation;
import com.google.gerrit.server.submit.SubmitStrategy;
import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
import com.google.gerrit.server.tools.ToolsCatalog;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.util.IdGenerator;
import com.google.gerrit.server.util.ThreadLocalRequestContext;
import com.google.gerrit.server.validators.AccountActivationValidationListener;
-import com.google.gerrit.server.validators.AssigneeValidationListener;
import com.google.gerrit.server.validators.GroupCreationValidationListener;
import com.google.gerrit.server.validators.HashtagValidationListener;
import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
@@ -300,6 +300,7 @@ public class GerritGlobalModule extends FactoryModule {
factory(ChangeJson.AssistedFactory.class);
factory(ChangeIsVisibleToPredicate.Factory.class);
factory(DistinctVotersPredicate.Factory.class);
+ factory(HasSubmoduleUpdatePredicate.Factory.class);
factory(DeadlineChecker.Factory.class);
factory(EmailNewPatchSet.Factory.class);
factory(MultiProgressMonitor.Factory.class);
@@ -359,7 +360,6 @@ public class GerritGlobalModule extends FactoryModule {
DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
DynamicSet.setOf(binder(), GitReferenceUpdatedListener.class);
DynamicSet.setOf(binder(), GitBatchRefUpdateListener.class);
- DynamicSet.setOf(binder(), AssigneeChangedListener.class);
DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
DynamicSet.setOf(binder(), ChangeDeletedListener.class);
DynamicSet.setOf(binder(), CommentAddedListener.class);
@@ -403,6 +403,7 @@ public class GerritGlobalModule extends FactoryModule {
DynamicSet.setOf(binder(), CommitValidationListener.class);
DynamicSet.bind(binder(), CommitValidationListener.class)
.to(SubmitRequirementConfigValidator.class);
+ DynamicSet.bind(binder(), CommitValidationListener.class).to(PrologRulesWarningValidator.class);
DynamicSet.setOf(binder(), CommentValidator.class);
DynamicSet.setOf(binder(), ChangeMessageModifier.class);
DynamicSet.setOf(binder(), RefOperationValidationListener.class);
@@ -440,7 +441,6 @@ public class GerritGlobalModule extends FactoryModule {
DynamicSet.setOf(binder(), AccountExternalIdCreator.class);
DynamicSet.setOf(binder(), WebUiPlugin.class);
DynamicItem.itemOf(binder(), AccountPatchReviewStore.class);
- DynamicSet.setOf(binder(), AssigneeValidationListener.class);
DynamicSet.setOf(binder(), ActionVisitor.class);
DynamicItem.itemOf(binder(), MergeSuperSetComputation.class);
DynamicItem.itemOf(binder(), ProjectNameLockManager.class);
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 99bd62da0f..f8c0592a5a 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -99,6 +99,7 @@ public class GitwebConfig {
return values.length > 0 && isNullOrEmpty(values[0]);
}
+ @Nullable
private static GitwebType typeFromConfig(Config cfg) {
GitwebType defaultType = defaultType(cfg.getString("gitweb", null, "type"));
if (defaultType == null) {
@@ -136,6 +137,7 @@ public class GitwebConfig {
return type;
}
+ @Nullable
private static GitwebType defaultType(String typeName) {
GitwebType type = new GitwebType();
switch (nullToEmpty(typeName)) {
@@ -283,6 +285,7 @@ public class GitwebConfig {
this.tag = parse(type.getTag());
}
+ @Nullable
@Override
public WebLinkInfo getBranchWebLink(String projectName, String branchName) {
if (branch != null) {
@@ -295,6 +298,7 @@ public class GitwebConfig {
return null;
}
+ @Nullable
@Override
public WebLinkInfo getTagWebLink(String projectName, String tagName) {
if (tag != null) {
@@ -304,6 +308,7 @@ public class GitwebConfig {
return null;
}
+ @Nullable
@Override
public WebLinkInfo getFileHistoryWebLink(String projectName, String revision, String fileName) {
if (fileHistory != null) {
@@ -317,6 +322,7 @@ public class GitwebConfig {
return null;
}
+ @Nullable
@Override
public WebLinkInfo getFileWebLink(
String projectName, String revision, String hash, String fileName) {
@@ -331,6 +337,7 @@ public class GitwebConfig {
return null;
}
+ @Nullable
@Override
public WebLinkInfo getPatchSetWebLink(
String projectName, String commit, String commitMessage, String branchName) {
@@ -359,6 +366,7 @@ public class GitwebConfig {
return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
}
+ @Nullable
@Override
public WebLinkInfo getProjectWeblink(String projectName) {
if (project != null) {
@@ -375,9 +383,12 @@ public class GitwebConfig {
}
private WebLinkInfo link(String rest) {
- return new WebLinkInfo(type.getLinkName(), null, url + rest, null);
+ WebLinkInfo webLink = new WebLinkInfo(type.getLinkName(), null, url + rest);
+ webLink.tooltip = "Open in GitWeb";
+ return webLink;
}
+ @Nullable
private static ParameterizedString parse(String pattern) {
if (!isNullOrEmpty(pattern)) {
return new ParameterizedString(pattern);
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index c09988e31d..e11d6aa7c1 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.config;
import static java.util.stream.Collectors.toList;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.annotations.ExtensionPoint;
@@ -360,6 +361,7 @@ public class ProjectConfigEntry {
}
}
+ @Nullable
private ProjectConfig parseConfig(Project.NameKey p, String idStr)
throws IOException, ConfigInvalidException, RepositoryNotFoundException {
ObjectId id = ObjectId.fromString(idStr);
@@ -382,14 +384,17 @@ public class ProjectConfigEntry {
}
}
+ @Nullable
private static Boolean toBoolean(String value) {
return value != null ? Boolean.parseBoolean(value) : null;
}
+ @Nullable
private static Integer toInt(String value) {
return value != null ? Integer.parseInt(value) : null;
}
+ @Nullable
private static Long toLong(String value) {
return value != null ? Long.parseLong(value) : null;
}
diff --git a/java/com/google/gerrit/server/config/RepositoryConfig.java b/java/com/google/gerrit/server/config/RepositoryConfig.java
index f7223216e7..d569c872cb 100644
--- a/java/com/google/gerrit/server/config/RepositoryConfig.java
+++ b/java/com/google/gerrit/server/config/RepositoryConfig.java
@@ -55,6 +55,7 @@ public class RepositoryConfig {
cfg.getStringList(SECTION_NAME, findSubSection(project.get()), OWNER_GROUP_NAME));
}
+ @Nullable
public Path getBasePath(Project.NameKey project) {
String basePath = cfg.getString(SECTION_NAME, findSubSection(project.get()), BASE_PATH_NAME);
return basePath != null ? Paths.get(basePath) : null;
diff --git a/java/com/google/gerrit/server/config/SitePaths.java b/java/com/google/gerrit/server/config/SitePaths.java
index 5e268da347..9f85857add 100644
--- a/java/com/google/gerrit/server/config/SitePaths.java
+++ b/java/com/google/gerrit/server/config/SitePaths.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.config;
import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -29,7 +30,6 @@ public final class SitePaths {
public static final String CSS_FILENAME = "GerritSite.css";
public static final String HEADER_FILENAME = "GerritSiteHeader.html";
public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
- public static final String THEME_FILENAME = "gerrit-theme.html";
public static final String THEME_JS_FILENAME = "gerrit-theme.js";
public final Path site_path;
@@ -69,8 +69,7 @@ public final class SitePaths {
public final Path site_css;
public final Path site_header;
public final Path site_footer;
- public final Path site_theme; // For PolyGerrit UI only.
- public final Path site_theme_js; // For PolyGerrit UI only.
+ public final Path site_theme_js;
public final Path site_gitweb;
/** {@code true} if {@link #site_path} has not been initialized. */
@@ -118,9 +117,6 @@ public final class SitePaths {
site_header = etc_dir.resolve(HEADER_FILENAME);
site_footer = etc_dir.resolve(FOOTER_FILENAME);
site_gitweb = etc_dir.resolve("gitweb_config.perl");
-
- // For PolyGerrit UI.
- site_theme = static_dir.resolve(THEME_FILENAME);
site_theme_js = static_dir.resolve(THEME_JS_FILENAME);
boolean isNew;
@@ -140,6 +136,7 @@ public final class SitePaths {
* @param path the path string to resolve. May be null.
* @return the resolved path; null if {@code path} was null or empty.
*/
+ @Nullable
public Path resolve(String path) {
if (path != null && !path.isEmpty()) {
Path loc = site_path.resolve(path).normalize();
diff --git a/java/com/google/gerrit/server/data/ChangeAttribute.java b/java/com/google/gerrit/server/data/ChangeAttribute.java
index ec27c0ccae..cdc982f52d 100644
--- a/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -32,7 +32,6 @@ public class ChangeAttribute {
public int number;
public String subject;
public AccountAttribute owner;
- public AccountAttribute assignee;
public String url;
public String commitMessage;
public List<String> hashtags;
diff --git a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
index c2c0b0581e..0e911b907a 100644
--- a/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
+++ b/java/com/google/gerrit/server/documentation/MarkdownFormatter.java
@@ -21,6 +21,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.vladsch.flexmark.ast.Heading;
import com.vladsch.flexmark.ast.util.TextCollectingVisitor;
import com.vladsch.flexmark.html.HtmlRenderer;
@@ -126,6 +127,7 @@ public class MarkdownFormatter {
return findTitle(parseMarkdown(md));
}
+ @Nullable
private String findTitle(Node root) {
if (root instanceof Heading) {
Heading h = (Heading) root;
diff --git a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index 7d3ddf1fcf..cd49ea6276 100644
--- a/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.documentation;
import com.google.common.collect.ImmutableMap;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -99,6 +100,7 @@ public class QueryDocumentationExecutor {
}
}
+ @Nullable
protected Directory readIndexDirectory() throws IOException {
Directory dir = new ByteBuffersDirectory();
byte[] buffer = new byte[4096];
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 765dd591cf..e813c097cd 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -15,13 +15,16 @@
package com.google.gerrit.server.edit;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Charsets;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MergeConflictException;
@@ -34,6 +37,7 @@ import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
import com.google.gerrit.server.edit.tree.DeleteFileModification;
import com.google.gerrit.server.edit.tree.RenameFileModification;
@@ -48,6 +52,7 @@ import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.InvalidChangeOperationException;
import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -100,6 +105,7 @@ public class ChangeEditModifier {
private final PatchSetUtil patchSetUtil;
private final ProjectCache projectCache;
private final NoteDbEdits noteDbEdits;
+ private final DynamicItem<UrlFormatter> urlFormatter;
@Inject
ChangeEditModifier(
@@ -110,15 +116,16 @@ public class ChangeEditModifier {
ChangeEditUtil changeEditUtil,
PatchSetUtil patchSetUtil,
ProjectCache projectCache,
- GitReferenceUpdated gitRefUpdated) {
+ GitReferenceUpdated gitReferenceUpdated,
+ DynamicItem<UrlFormatter> urlFormatter) {
this.currentUser = currentUser;
this.permissionBackend = permissionBackend;
this.zoneId = gerritIdent.getZoneId();
this.changeEditUtil = changeEditUtil;
this.patchSetUtil = patchSetUtil;
this.projectCache = projectCache;
-
- noteDbEdits = new NoteDbEdits(zoneId, indexer, currentUser, gitRefUpdated);
+ noteDbEdits = new NoteDbEdits(gitReferenceUpdated, zoneId, indexer, currentUser);
+ this.urlFormatter = urlFormatter;
}
/**
@@ -175,10 +182,14 @@ public class ChangeEditModifier {
notes.getChangeId(), currentPatchSet.id()));
}
- rebase(repository, changeEdit, currentPatchSet);
+ rebase(notes.getProjectName(), repository, changeEdit, currentPatchSet);
}
- private void rebase(Repository repository, ChangeEdit changeEdit, PatchSet currentPatchSet)
+ private void rebase(
+ Project.NameKey project,
+ Repository repository,
+ ChangeEdit changeEdit,
+ PatchSet currentPatchSet)
throws IOException, MergeConflictException, InvalidChangeOperationException {
RevCommit currentEditCommit = changeEdit.getEditCommit();
if (currentEditCommit.getParentCount() == 0) {
@@ -196,7 +207,13 @@ public class ChangeEditModifier {
createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
noteDbEdits.baseEditOnDifferentPatchset(
- repository, changeEdit, currentPatchSet, currentEditCommit, newEditCommitId, nowTimestamp);
+ project,
+ repository,
+ changeEdit,
+ currentPatchSet,
+ currentEditCommit,
+ newEditCommitId,
+ nowTimestamp);
}
/**
@@ -229,13 +246,18 @@ public class ChangeEditModifier {
* @param notes the {@link ChangeNotes} of the change whose change edit should be modified
* @param filePath the path of the file whose contents should be modified
* @param newContent the new file content
+ * @param newGitFileMode the new file mode in octal format. {@code null} indicates no change
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file already had the specified content
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void modifyFile(
- Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
+ Repository repository,
+ ChangeNotes notes,
+ String filePath,
+ RawInput newContent,
+ @Nullable Integer newGitFileMode)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
modifyCommit(
@@ -243,7 +265,8 @@ public class ChangeEditModifier {
notes,
new ModificationIntention.LatestCommit(),
CommitModification.builder()
- .addTreeModification(new ChangeFileContentModification(filePath, newContent))
+ .addTreeModification(
+ new ChangeFileContentModification(filePath, newContent, newGitFileMode))
.build());
}
@@ -392,8 +415,10 @@ public class ChangeEditModifier {
ObjectId newEditCommit =
createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
- return editBehavior.updateEditInStorage(
- repository, notes, basePatchset, newEditCommit, nowTimestamp);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ return editBehavior.updateEditInStorage(
+ repository, notes, basePatchset, newEditCommit, nowTimestamp);
+ }
}
private void assertCanEdit(ChangeNotes notes)
@@ -495,7 +520,8 @@ public class ChangeEditModifier {
"New commit message cannot be same as existing commit message");
}
- ChangeUtil.ensureChangeIdIsCorrect(requireChangeId, currentChangeId, newCommitMessage);
+ ChangeUtil.ensureChangeIdIsCorrect(
+ requireChangeId, currentChangeId, newCommitMessage, urlFormatter.get());
return newCommitMessage;
}
@@ -715,17 +741,17 @@ public class ChangeEditModifier {
private final ZoneId zoneId;
private final ChangeIndexer indexer;
private final Provider<CurrentUser> currentUser;
- private final GitReferenceUpdated gitRefUpdated;
+ private final GitReferenceUpdated gitReferenceUpdated;
NoteDbEdits(
+ GitReferenceUpdated gitReferenceUpdated,
ZoneId zoneId,
ChangeIndexer indexer,
- Provider<CurrentUser> currentUser,
- GitReferenceUpdated gitRefUpdated) {
+ Provider<CurrentUser> currentUser) {
this.zoneId = zoneId;
this.indexer = indexer;
this.currentUser = currentUser;
- this.gitRefUpdated = gitRefUpdated;
+ this.gitReferenceUpdated = gitReferenceUpdated;
}
ChangeEdit createEdit(
@@ -755,6 +781,10 @@ public class ChangeEditModifier {
return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchset.id());
}
+ private AccountState getUpdater() {
+ return currentUser.get().asIdentifiedUser().state();
+ }
+
ChangeEdit updateEdit(
Project.NameKey projectName,
Repository repository,
@@ -781,27 +811,29 @@ public class ChangeEditModifier {
ObjectId targetObjectId,
Instant timestamp)
throws IOException {
- AccountState userAccountState = currentUser.get().asIdentifiedUser().state();
- RefUpdate ru = repository.updateRef(refName);
- ru.setExpectedOldObjectId(currentObjectId);
- ru.setNewObjectId(targetObjectId);
- ru.setRefLogIdent(getRefLogIdent(timestamp));
- ru.setRefLogMessage("inline edit (amend)", false);
- ru.setForceUpdate(true);
- try (RevWalk revWalk = new RevWalk(repository)) {
- RefUpdate.Result res = ru.update(revWalk);
- String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
- if (res == RefUpdate.Result.LOCK_FAILURE) {
- throw new LockFailureException(message, ru);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ RefUpdate ru = repository.updateRef(refName);
+ ru.setExpectedOldObjectId(currentObjectId);
+ ru.setNewObjectId(targetObjectId);
+ ru.setRefLogIdent(getRefLogIdent(timestamp));
+ ru.setRefLogMessage("inline edit (amend)", false);
+ ru.setForceUpdate(true);
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ RefUpdate.Result res = ru.update(revWalk);
+ String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
+ if (res == RefUpdate.Result.LOCK_FAILURE) {
+ throw new LockFailureException(message, ru);
+ }
+ if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
+ throw new IOException(message);
+ }
}
- if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
- throw new IOException(message);
- }
- gitRefUpdated.fire(projectName, ru, userAccountState);
+ gitReferenceUpdated.fire(projectName, ru, getUpdater());
}
}
void baseEditOnDifferentPatchset(
+ Project.NameKey project,
Repository repository,
ChangeEdit changeEdit,
PatchSet currentPatchSet,
@@ -811,6 +843,7 @@ public class ChangeEditModifier {
throws IOException {
String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
updateReferenceWithNameChange(
+ project,
repository,
changeEdit.getRefName(),
currentEditCommit,
@@ -821,6 +854,7 @@ public class ChangeEditModifier {
}
private void updateReferenceWithNameChange(
+ Project.NameKey projectName,
Repository repository,
String currentRefName,
ObjectId currentObjectId,
@@ -828,19 +862,23 @@ public class ChangeEditModifier {
ObjectId targetObjectId,
Instant timestamp)
throws IOException {
- BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
- batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
- batchRefUpdate.addCommand(
- new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
- batchRefUpdate.setRefLogMessage("rebase edit", false);
- batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
- try (RevWalk revWalk = new RevWalk(repository)) {
- batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
- }
- for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
- if (cmd.getResult() != ReceiveCommand.Result.OK) {
- throw new IOException("failed: " + cmd);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
+ batchRefUpdate.addCommand(
+ new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
+ batchRefUpdate.addCommand(
+ new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
+ batchRefUpdate.setRefLogMessage("rebase edit", false);
+ batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+ }
+ for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+ if (cmd.getResult() != ReceiveCommand.Result.OK) {
+ throw new IOException("failed: " + cmd);
+ }
}
+ gitReferenceUpdated.fire(projectName, batchRefUpdate, getUpdater());
}
}
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 96a84f605b..e7de3227fb 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.edit;
import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
@@ -29,7 +30,6 @@ import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.change.ChangeKindCache;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
@@ -41,6 +41,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -71,7 +72,7 @@ public class ChangeEditUtil {
private final Provider<CurrentUser> userProvider;
private final ChangeKindCache changeKindCache;
private final PatchSetUtil psUtil;
- private final GitReferenceUpdated gitRefUpdated;
+ private final GitReferenceUpdated gitReferenceUpdated;
@Inject
ChangeEditUtil(
@@ -81,14 +82,14 @@ public class ChangeEditUtil {
Provider<CurrentUser> userProvider,
ChangeKindCache changeKindCache,
PatchSetUtil psUtil,
- GitReferenceUpdated gitRefUpdated) {
+ GitReferenceUpdated gitReferenceUpdated) {
this.gitManager = gitManager;
this.patchSetInserterFactory = patchSetInserterFactory;
this.indexer = indexer;
this.userProvider = userProvider;
this.changeKindCache = changeKindCache;
this.psUtil = psUtil;
- this.gitRefUpdated = gitRefUpdated;
+ this.gitReferenceUpdated = gitReferenceUpdated;
}
/**
@@ -189,20 +190,22 @@ public class ChangeEditUtil {
} else {
message.append("Published edit on patch set ").append(basePatchSet.number()).append(".");
}
-
- try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
- bu.setRepository(repo, rw, oi);
- bu.setNotify(notify);
- bu.addOp(change.getId(), inserter.setMessage(message.toString()));
- bu.addOp(
- change.getId(),
- new BatchUpdateOp() {
- @Override
- public void updateRepo(RepoContext ctx) throws Exception {
- ctx.addRefUpdate(edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
- }
- });
- bu.execute();
+ try (RefUpdateContext changeCtx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu = updateFactory.create(change.getProject(), user, TimeUtil.now())) {
+ bu.setRepository(repo, rw, oi);
+ bu.setNotify(notify);
+ bu.addOp(change.getId(), inserter.setMessage(message.toString()));
+ bu.addOp(
+ change.getId(),
+ new BatchUpdateOp() {
+ @Override
+ public void updateRepo(RepoContext ctx) throws Exception {
+ ctx.addRefUpdate(
+ edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
+ }
+ });
+ bu.execute();
+ }
}
}
}
@@ -243,31 +246,34 @@ public class ChangeEditUtil {
}
private void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
- AccountState userAccountState = userProvider.get().asIdentifiedUser().state();
- String refName = edit.getRefName();
- RefUpdate ru = repo.updateRef(refName, true);
- ru.setExpectedOldObjectId(edit.getEditCommit());
- ru.setForceUpdate(true);
- RefUpdate.Result result = ru.delete();
- switch (result) {
- case FORCED:
- case NEW:
- gitRefUpdated.fire(edit.getChange().getProject(), ru, userAccountState);
- break;
- case NO_CHANGE:
- break;
- case LOCK_FAILURE:
- throw new LockFailureException(String.format("Failed to delete ref %s", refName), ru);
- case FAST_FORWARD:
- case IO_FAILURE:
- case NOT_ATTEMPTED:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ String refName = edit.getRefName();
+ RefUpdate ru = repo.updateRef(refName, true);
+ ru.setExpectedOldObjectId(edit.getEditCommit());
+ ru.setForceUpdate(true);
+ RefUpdate.Result result = ru.delete();
+ switch (result) {
+ case FORCED:
+ case NEW:
+ case NO_CHANGE:
+ break;
+ case LOCK_FAILURE:
+ throw new LockFailureException(String.format("Failed to delete ref %s", refName), ru);
+ case FAST_FORWARD:
+ case IO_FAILURE:
+ case NOT_ATTEMPTED:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
+ }
+ gitReferenceUpdated.fire(
+ edit.getChange().getProject(),
+ ru,
+ /* updater= */ userProvider.get().asIdentifiedUser().state());
}
}
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 9c0b92a444..96c66850a0 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -22,6 +22,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.restapi.RawInput;
import java.io.IOException;
import java.io.InputStream;
@@ -42,16 +43,26 @@ public class ChangeFileContentModification implements TreeModification {
private final String filePath;
private final RawInput newContent;
+ private final Integer newGitFileMode;
public ChangeFileContentModification(String filePath, RawInput newContent) {
this.filePath = filePath;
this.newContent = requireNonNull(newContent, "new content required");
+ this.newGitFileMode = null;
+ }
+
+ public ChangeFileContentModification(
+ String filePath, RawInput newContent, @Nullable Integer newGitFileMode) {
+ this.filePath = filePath;
+ this.newContent = requireNonNull(newContent, "new content required");
+ this.newGitFileMode = newGitFileMode;
}
@Override
public List<DirCacheEditor.PathEdit> getPathEdits(
Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
- DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
+ DirCacheEditor.PathEdit changeContentEdit =
+ new ChangeContent(filePath, newContent, repository, newGitFileMode);
return Collections.singletonList(changeContentEdit);
}
@@ -70,16 +81,32 @@ public class ChangeFileContentModification implements TreeModification {
private final RawInput newContent;
private final Repository repository;
+ private final Integer newGitFileMode;
- ChangeContent(String filePath, RawInput newContent, Repository repository) {
+ ChangeContent(
+ String filePath,
+ RawInput newContent,
+ Repository repository,
+ @Nullable Integer newGitFileMode) {
super(filePath);
this.newContent = newContent;
this.repository = repository;
+ this.newGitFileMode = newGitFileMode;
+ }
+
+ private boolean isValidGitFileMode(int gitFileMode) {
+ return (gitFileMode == 100755) || (gitFileMode == 100644);
}
@Override
public void apply(DirCacheEntry dirCacheEntry) {
try {
+ if (newGitFileMode != null && newGitFileMode != 0) {
+ if (!isValidGitFileMode(newGitFileMode)) {
+ throw new IllegalStateException("GitFileMode " + newGitFileMode + " is invalid");
+ }
+ dirCacheEntry.setFileMode(FileMode.fromBits(newGitFileMode));
+ }
if (dirCacheEntry.getFileMode() == FileMode.GITLINK) {
dirCacheEntry.setLength(0);
dirCacheEntry.setLastModified(Instant.EPOCH);
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 95f6d96602..678f4d0ff4 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,6 +20,7 @@ import static java.util.Objects.requireNonNull;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
@@ -132,7 +133,6 @@ public class EventFactory {
a.subject = change.getSubject();
a.url = getChangeUrl(change);
a.owner = asAccountAttribute(change.getOwner(), accountLoader);
- a.assignee = asAccountAttribute(change.getAssignee(), accountLoader);
a.status = change.getStatus();
a.createdOn = change.getCreatedOn().getEpochSecond();
a.wip = change.isWorkInProgress() ? true : null;
@@ -523,6 +523,7 @@ public class EventFactory {
}
/** Create an AuthorAttribute for the given account suitable for serialization to JSON. */
+ @Nullable
public AccountAttribute asAccountAttribute(Account.Id id) {
if (id == null) {
return null;
@@ -590,6 +591,7 @@ public class EventFactory {
}
/** Get a link to the change; null if the server doesn't know its own address. */
+ @Nullable
private String getChangeUrl(Change change) {
if (change != null) {
return urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId()).orElse(null);
diff --git a/java/com/google/gerrit/server/events/EventTypes.java b/java/com/google/gerrit/server/events/EventTypes.java
index 229ef86603..e24bbd2de2 100644
--- a/java/com/google/gerrit/server/events/EventTypes.java
+++ b/java/com/google/gerrit/server/events/EventTypes.java
@@ -23,7 +23,6 @@ public class EventTypes {
private static final Map<String, Class<?>> typesByString = new HashMap<>();
static {
- register(AssigneeChangedEvent.TYPE, AssigneeChangedEvent.class);
register(ChangeAbandonedEvent.TYPE, ChangeAbandonedEvent.class);
register(ChangeDeletedEvent.TYPE, ChangeDeletedEvent.class);
register(ChangeMergedEvent.TYPE, ChangeMergedEvent.class);
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index afe2a7c913..50c15b7fe1 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,6 +20,7 @@ import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
@@ -31,7 +32,6 @@ import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
import com.google.gerrit.extensions.events.ChangeAbandonedListener;
import com.google.gerrit.extensions.events.ChangeDeletedListener;
import com.google.gerrit.extensions.events.ChangeMergedListener;
@@ -72,8 +72,7 @@ import org.eclipse.jgit.revwalk.RevWalk;
@Singleton
public class StreamEventsApiListener
- implements AssigneeChangedListener,
- ChangeAbandonedListener,
+ implements ChangeAbandonedListener,
ChangeDeletedListener,
ChangeMergedListener,
ChangeRestoredListener,
@@ -94,7 +93,6 @@ public class StreamEventsApiListener
public static class StreamEventsApiListenerModule extends AbstractModule {
@Override
protected void configure() {
- DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
DynamicSet.bind(binder(), ChangeAbandonedListener.class).to(StreamEventsApiListener.class);
DynamicSet.bind(binder(), ChangeDeletedListener.class).to(StreamEventsApiListener.class);
DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
@@ -234,6 +232,7 @@ public class StreamEventsApiListener
});
}
+ @Nullable
String[] hashtagArray(Collection<String> hashtags) {
if (hashtags != null && !hashtags.isEmpty()) {
return Sets.newHashSet(hashtags).toArray(new String[hashtags.size()]);
@@ -242,23 +241,6 @@ public class StreamEventsApiListener
}
@Override
- public void onAssigneeChanged(AssigneeChangedListener.Event ev) {
- try {
- ChangeNotes notes = getNotes(ev.getChange());
- Change change = notes.getChange();
- AssigneeChangedEvent event = new AssigneeChangedEvent(change);
-
- event.change = changeAttributeSupplier(change, notes);
- event.changer = accountAttributeSupplier(ev.getWho());
- event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
-
- dispatcher.run(d -> d.postEvent(change, event));
- } catch (StorageException e) {
- logger.atSevere().withCause(e).log("Failed to dispatch event");
- }
- }
-
- @Override
public void onTopicEdited(TopicEditedListener.Event ev) {
try {
ChangeNotes notes = getNotes(ev.getChange());
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index b8763419e5..e294d55caa 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -20,14 +20,13 @@ import com.google.common.collect.ImmutableSet;
public class ExperimentFeaturesConstants {
/** Features that are known experiments and can be referenced in the code. */
- public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
-
- public static String UI_FEATURE_SUBMIT_REQUIREMENTS_UI = "UiFeature__submit_requirements_ui";
-
- public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
- "GerritBackendRequestFeature__remove_revision_etag";
+ public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION =
+ "GerritBackendFeature__attach_nonce_to_documentation";
/** Features, enabled by default in the current release. */
- public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES =
- ImmutableSet.of(UI_FEATURE_PATCHSET_COMMENTS, UI_FEATURE_SUBMIT_REQUIREMENTS_UI);
+ public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of();
+
+ /** On BatchUpdate, do not await index completion before returning to the user */
+ public static String GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING =
+ "GerritBackendFeature__do_not_await_change_indexing";
}
diff --git a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
deleted file mode 100644
index 8e4d1e2dd8..0000000000
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.events.AssigneeChangedListener;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.time.Instant;
-
-/** Helper class to fire an event when a user has been set as assignee on a change. */
-@Singleton
-public class AssigneeChanged {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
- private final PluginSetContext<AssigneeChangedListener> listeners;
- private final EventUtil util;
-
- @Inject
- AssigneeChanged(PluginSetContext<AssigneeChangedListener> listeners, EventUtil util) {
- this.listeners = listeners;
- this.util = util;
- }
-
- public void fire(
- ChangeData changeData, AccountState accountState, AccountState oldAssignee, Instant when) {
- if (listeners.isEmpty()) {
- return;
- }
- try {
- Event event =
- new Event(
- util.changeInfo(changeData),
- util.accountInfo(accountState),
- util.accountInfo(oldAssignee),
- when);
- listeners.runEach(l -> l.onAssigneeChanged(event));
- } catch (StorageException e) {
- logger.atSevere().withCause(e).log("Couldn't fire event");
- }
- }
-
- /** Event to be fired when a user has been set as assignee on a change. */
- private static class Event extends AbstractChangeEvent implements AssigneeChangedListener.Event {
- private final AccountInfo oldAssignee;
-
- Event(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee, Instant when) {
- super(change, editor, when, NotifyHandling.ALL);
- this.oldAssignee = oldAssignee;
- }
-
- @Override
- public AccountInfo getOldAssignee() {
- return oldAssignee;
- }
- }
-}
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index b669571259..7c8777fa63 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -99,6 +99,7 @@ public class EventUtil {
return revisionJsonFactory.create(changeOptions).getRevisionInfo(cd, ps);
}
+ @Nullable
public AccountInfo accountInfo(@Nullable AccountState accountState) {
if (accountState == null || accountState.account().id() == null) {
return null;
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java
index df20fbfbda..c471245da3 100644
--- a/java/com/google/gerrit/server/fixes/FixCalculator.java
+++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -17,10 +17,12 @@ package com.google.gerrit.server.fixes;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.jgit.diff.ReplaceEdit;
+import com.google.gerrit.server.patch.IntraLineLoader;
import com.google.gerrit.server.patch.Text;
import java.util.ArrayList;
import java.util.Comparator;
@@ -47,7 +49,8 @@ public class FixCalculator {
public static String getNewFileContent(
String originalContent, List<FixReplacement> fixReplacements)
throws ResourceConflictException {
- FixResult fixResult = calculateFix(new Text(originalContent.getBytes(UTF_8)), fixReplacements);
+ FixResult fixResult =
+ calculateFix(new Text(originalContent.getBytes(UTF_8)), fixReplacements, false);
return fixResult.text.getString(0, fixResult.text.size(), false);
}
@@ -60,7 +63,8 @@ public class FixCalculator {
* @throws ResourceConflictException if the fixReplacements contains invalid data (for example, if
* an item points to an invalid range or if some ranges are intersected).
*/
- public static FixResult calculateFix(Text originalText, List<FixReplacement> fixReplacements)
+ public static FixResult calculateFix(
+ Text originalText, List<FixReplacement> fixReplacements, boolean intraline)
throws ResourceConflictException {
List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
@@ -70,7 +74,7 @@ public class FixCalculator {
"Cannot calculate fix replacement for range %s",
toString(sortedReplacements.get(0).range)));
}
- ContentBuilder builder = new ContentBuilder(originalText);
+ ContentBuilder builder = new ContentBuilder(originalText, intraline);
for (FixReplacement fixReplacement : sortedReplacements) {
try {
builder.addReplacement(fixReplacement);
@@ -164,12 +168,16 @@ public class FixCalculator {
}
private final ContentProcessor contentProcessor;
+ private final Text src;
+ private final boolean intraline;
final ImmutableList.Builder<Edit> edits;
FixRegion currentRegion;
- ContentBuilder(Text src) {
+ ContentBuilder(Text src, boolean intraline) {
this.contentProcessor = new ContentProcessor(src);
+ this.src = src;
this.edits = new ImmutableList.Builder<>();
+ this.intraline = intraline;
}
void addReplacement(FixReplacement replacement) {
@@ -193,9 +201,18 @@ public class FixCalculator {
}
}
+ private ImmutableList<Edit> buildEdits() {
+ if (this.intraline) {
+ return IntraLineLoader.compute(
+ this.src, this.getNewText(), edits.build(), ImmutableSet.of())
+ .getEdits();
+ }
+ return edits.build();
+ }
+
public FixResult build() {
finish();
- return new FixResult(edits.build(), this.getNewText());
+ return new FixResult(this.buildEdits(), this.getNewText());
}
private void finishExistingEdit() {
diff --git a/java/com/google/gerrit/server/git/BanCommit.java b/java/com/google/gerrit/server/git/BanCommit.java
index e27197cb33..e00012ad2b 100644
--- a/java/com/google/gerrit/server/git/BanCommit.java
+++ b/java/com/google/gerrit/server/git/BanCommit.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.git;
import static com.google.gerrit.entities.RefNames.REFS_REJECT_COMMITS;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BAN_COMMIT;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.gerrit.entities.Project;
@@ -26,6 +27,7 @@ import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -128,21 +130,23 @@ public class BanCommit {
banCommitNotes.set(commitToBan, noteId);
}
NotesBranchUtil notesBranchUtil = notesBranchUtilFactory.create(project, repo, inserter);
- NoteMap newlyCreated =
- notesBranchUtil.commitNewNotes(
- banCommitNotes,
- REFS_REJECT_COMMITS,
- createPersonIdent(),
- buildCommitMessage(commitsToBan, reason));
+ try (RefUpdateContext ctx = RefUpdateContext.open(BAN_COMMIT)) {
+ NoteMap newlyCreated =
+ notesBranchUtil.commitNewNotes(
+ banCommitNotes,
+ REFS_REJECT_COMMITS,
+ createPersonIdent(),
+ buildCommitMessage(commitsToBan, reason));
- for (Note n : banCommitNotes) {
- if (newlyCreated.contains(n)) {
- result.commitBanned(n);
- } else {
- result.commitAlreadyBanned(n);
+ for (Note n : banCommitNotes) {
+ if (newlyCreated.contains(n)) {
+ result.commitBanned(n);
+ } else {
+ result.commitAlreadyBanned(n);
+ }
}
+ return result;
}
- return result;
}
}
diff --git a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
index 1e1c7a3d62..094287b7be 100644
--- a/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/git/ChangesByProjectCacheImpl.java
@@ -132,7 +132,8 @@ public class ChangesByProjectCacheImpl implements ChangesByProjectCache {
"Querying changes of project", Metadata.builder().projectName(project.get()).build())) {
return queryProvider
.get()
- .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER, ChangeField.REF_STATE)
+ .setRequestedFields(
+ ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC, ChangeField.REF_STATE_SPEC)
.byProject(project);
}
}
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index fa46bf445e..ffb6c66187 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -15,9 +15,9 @@
package com.google.gerrit.server.git;
import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
@@ -26,10 +26,13 @@ import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RevertInput;
import com.google.gerrit.extensions.common.CommitInfo;
+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.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CommonConverters;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
@@ -38,17 +41,21 @@ import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.ChangeInserter;
import com.google.gerrit.server.change.ChangeMessages;
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.mail.send.MessageIdGenerator;
import com.google.gerrit.server.mail.send.RevertedSender;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -58,15 +65,18 @@ import java.text.MessageFormat;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet;
-import java.util.Map;
+import java.util.List;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
@@ -85,6 +95,7 @@ public class CommitUtil {
private final NotifyResolver notifyResolver;
private final RevertedSender.Factory revertedSenderFactory;
private final ChangeMessagesUtil cmUtil;
+ private final ChangeNotes.Factory changeNotesFactory;
private final ChangeReverted changeReverted;
private final BatchUpdate.Factory updateFactory;
private final MessageIdGenerator messageIdGenerator;
@@ -99,6 +110,7 @@ public class CommitUtil {
NotifyResolver notifyResolver,
RevertedSender.Factory revertedSenderFactory,
ChangeMessagesUtil cmUtil,
+ ChangeNotes.Factory changeNotesFactory,
ChangeReverted changeReverted,
BatchUpdate.Factory updateFactory,
MessageIdGenerator messageIdGenerator) {
@@ -110,6 +122,7 @@ public class CommitUtil {
this.notifyResolver = notifyResolver;
this.revertedSenderFactory = revertedSenderFactory;
this.cmUtil = cmUtil;
+ this.changeNotesFactory = changeNotesFactory;
this.changeReverted = changeReverted;
this.updateFactory = updateFactory;
this.messageIdGenerator = messageIdGenerator;
@@ -151,18 +164,19 @@ public class CommitUtil {
ChangeNotes notes, CurrentUser user, RevertInput input, Instant timestamp)
throws RestApiException, UpdateException, ConfigInvalidException, IOException {
String message = Strings.emptyToNull(input.message);
-
- try (Repository git = repoManager.openRepository(notes.getProjectName());
- ObjectInserter oi = git.newObjectInserter();
- ObjectReader reader = oi.newReader();
- RevWalk revWalk = new RevWalk(reader)) {
- ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
- ObjectId revCommit =
- createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId);
- return createRevertChangeFromCommit(
- revCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git);
- } catch (RepositoryNotFoundException e) {
- throw new ResourceNotFoundException(notes.getChangeId().toString(), e);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (Repository git = repoManager.openRepository(notes.getProjectName());
+ ObjectInserter oi = git.newObjectInserter();
+ ObjectReader reader = oi.newReader();
+ RevWalk revWalk = new RevWalk(reader)) {
+ ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
+ ObjectId revCommit =
+ createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId);
+ return createRevertChangeFromCommit(
+ revCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git);
+ } catch (RepositoryNotFoundException e) {
+ throw new ResourceNotFoundException(notes.getChangeId().toString(), e);
+ }
}
}
@@ -190,6 +204,41 @@ public class CommitUtil {
}
/**
+ * Creates a commit with the specified tree ID.
+ *
+ * @param oi ObjectInserter for inserting the newly created commit.
+ * @param authorIdent of the new commit
+ * @param committerIdent of the new commit
+ * @param parentCommit of the new commit. Can be null.
+ * @param commitMessage for the new commit.
+ * @param treeId of the content for the new commit.
+ * @return the newly created commit.
+ * @throws IOException if fails to insert the commit.
+ */
+ public static ObjectId createCommitWithTree(
+ ObjectInserter oi,
+ PersonIdent authorIdent,
+ PersonIdent committerIdent,
+ @Nullable RevCommit parentCommit,
+ String commitMessage,
+ ObjectId treeId)
+ throws IOException {
+ logger.atFine().log("Creating commit with tree: %s", treeId.getName());
+ CommitBuilder commit = new CommitBuilder();
+ commit.setTreeId(treeId);
+ if (parentCommit != null) {
+ commit.setParentId(parentCommit);
+ }
+ commit.setAuthor(authorIdent);
+ commit.setCommitter(committerIdent);
+ commit.setMessage(commitMessage);
+
+ ObjectId id = oi.insert(commit);
+ oi.flush();
+ return id;
+ }
+
+ /**
* Creates a revert commit.
*
* @param message Commit message for the revert commit.
@@ -227,12 +276,6 @@ public class CommitUtil {
RevCommit parentToCommitToRevert = commitToRevert.getParent(0);
revWalk.parseHeaders(parentToCommitToRevert);
- CommitBuilder revertCommitBuilder = new CommitBuilder();
- revertCommitBuilder.addParentId(commitToRevert);
- revertCommitBuilder.setTreeId(parentToCommitToRevert.getTree());
- revertCommitBuilder.setAuthor(authorIdent);
- revertCommitBuilder.setCommitter(authorIdent);
-
Change changeToRevert = notes.getChange();
String subject = changeToRevert.getSubject();
if (subject.length() > 63) {
@@ -244,11 +287,11 @@ public class CommitUtil {
ChangeMessages.get().revertChangeDefaultMessage, subject, patch.commitId().name());
}
if (generatedChangeId != null) {
- revertCommitBuilder.setMessage(ChangeIdUtil.insertId(message, generatedChangeId, true));
+ message = ChangeIdUtil.insertId(message, generatedChangeId, true);
}
- ObjectId id = oi.insert(revertCommitBuilder);
- oi.flush();
- return id;
+
+ return createCommitWithTree(
+ oi, authorIdent, committerIdent, commitToRevert, message, parentToCommitToRevert.getTree());
}
private Change.Id createRevertChangeFromCommit(
@@ -263,20 +306,21 @@ public class CommitUtil {
Repository git)
throws IOException, RestApiException, UpdateException, ConfigInvalidException {
RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
- Change changeToRevert = notes.getChange();
Change.Id changeId = Change.id(seq.nextChangeId());
if (input.workInProgress) {
- input.notify = firstNonNull(input.notify, NotifyHandling.OWNER);
+ input.notify = firstNonNull(input.notify, NotifyHandling.NONE);
}
NotifyResolver.Result notify =
notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
+ Change changeToRevert = notes.getChange();
ChangeInserter ins =
changeInserterFactory
- .create(changeId, revertCommit, notes.getChange().getDest().branch())
+ .create(changeId, revertCommit, changeToRevert.getDest().branch())
.setTopic(input.topic == null ? changeToRevert.getTopic() : input.topic.trim());
ins.setMessage("Uploaded patch set 1.");
- ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+ ins.setValidationOptions(
+ ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
@@ -286,7 +330,7 @@ public class CommitUtil {
reviewers.remove(user.getAccountId());
Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
ccs.remove(user.getAccountId());
- ins.setReviewersAndCcs(reviewers, ccs);
+ ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
ins.setRevertOf(notes.getChangeId());
ins.setWorkInProgress(input.workInProgress);
@@ -294,68 +338,149 @@ public class CommitUtil {
bu.setRepository(git, revWalk, oi);
bu.setNotify(notify);
bu.insertChange(ins);
- bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
- bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(generatedChangeId));
+ if (!input.workInProgress) {
+ addChangeRevertedNotificationOps(
+ bu, changeToRevert.getId(), changeId, generatedChangeId.name());
+ }
bu.execute();
}
return changeId;
}
- private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
- @Nullable Map<String, String> validationOptions) {
- if (validationOptions == null) {
- return ImmutableListMultimap.of();
- }
-
- ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
- ImmutableListMultimap.builder();
- validationOptions
- .entrySet()
- .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
- return validationOptionsBuilder.build();
+ /**
+ * Notify the owners of a change that their change is being reverted.
+ *
+ * @param bu to append the notification actions to.
+ * @param revertedChangeId to be notified.
+ * @param revertingChangeId to notify about.
+ * @param revertingChangeKey to notify about.
+ */
+ public void addChangeRevertedNotificationOps(
+ BatchUpdate bu,
+ Change.Id revertedChangeId,
+ Change.Id revertingChangeId,
+ String revertingChangeKey) {
+ bu.addOp(revertingChangeId, new ChangeRevertedNotifyOp(revertedChangeId, revertingChangeId));
+ bu.addOp(revertedChangeId, new PostRevertedMessageOp(revertingChangeKey));
}
- private class NotifyOp implements BatchUpdateOp {
- private final Change change;
- private final ChangeInserter ins;
+ private class ChangeRevertedNotifyOp implements BatchUpdateOp {
+ private final Change.Id revertedChangeId;
+ private final Change.Id revertingChangeId;
- NotifyOp(Change change, ChangeInserter ins) {
- this.change = change;
- this.ins = ins;
+ ChangeRevertedNotifyOp(Change.Id revertedChangeId, Change.Id revertingChangeId) {
+ this.revertedChangeId = revertedChangeId;
+ this.revertingChangeId = revertingChangeId;
}
@Override
public void postUpdate(PostUpdateContext ctx) throws Exception {
- changeReverted.fire(
- ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
+ ChangeData revertedChange =
+ ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertedChangeId));
+ ChangeData revertingChange =
+ ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertingChangeId));
+ changeReverted.fire(revertedChange, revertingChange, ctx.getWhen());
try {
- RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
+ RevertedSender emailSender =
+ revertedSenderFactory.create(ctx.getProject(), revertedChange.getId());
emailSender.setFrom(ctx.getAccountId());
- emailSender.setNotify(ctx.getNotify(change.getId()));
+ emailSender.setNotify(ctx.getNotify(revertedChangeId));
emailSender.setMessageId(
- messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+ messageIdGenerator.fromChangeUpdate(
+ ctx.getRepoView(), revertedChange.currentPatchSet().id()));
emailSender.send();
} catch (Exception err) {
logger.atSevere().withCause(err).log(
- "Cannot send email for revert change %s", change.getId());
+ "Cannot send email for revert change %s", revertedChangeId);
}
}
}
private class PostRevertedMessageOp implements BatchUpdateOp {
- private final ObjectId computedChangeId;
+ private final String revertingChangeKey;
- PostRevertedMessageOp(ObjectId computedChangeId) {
- this.computedChangeId = computedChangeId;
+ PostRevertedMessageOp(String revertingChangeKey) {
+ this.revertingChangeKey = revertingChangeKey;
}
@Override
public boolean updateChange(ChangeContext ctx) {
cmUtil.setChangeMessage(
ctx,
- "Created a revert of this change as I" + computedChangeId.name(),
+ "Created a revert of this change as I" + revertingChangeKey,
ChangeMessagesUtil.TAG_REVERT);
return true;
}
}
+
+ /**
+ * Returns the parent commit for a new commit.
+ *
+ * <p>If {@code baseSha1} is provided, the method verifies it can be used as a base. If {@code
+ * baseSha1} is not provided the tip of the {@code destRef} is returned.
+ *
+ * @param project The name of the project.
+ * @param changeQuery Used for looking up the base commit.
+ * @param revWalk Used for parsing the base commit.
+ * @param destRef The destination branch.
+ * @param baseSha1 The hash of the base commit. Nullable.
+ * @return the base commit. Either the commit matching the provided hash, or the direct parent if
+ * a hash was not provided.
+ * @throws IOException if the branch reference cannot be parsed.
+ * @throws RestApiException if the base commit cannot be fetched.
+ */
+ public static RevCommit getBaseCommit(
+ String project,
+ InternalChangeQuery changeQuery,
+ RevWalk revWalk,
+ Ref destRef,
+ @Nullable String baseSha1)
+ throws IOException, RestApiException {
+ RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
+ // The tip commit of the destination ref is the default base for the newly created change.
+ if (Strings.isNullOrEmpty(baseSha1)) {
+ return destRefTip;
+ }
+
+ ObjectId baseObjectId;
+ try {
+ baseObjectId = ObjectId.fromString(baseSha1);
+ } catch (InvalidObjectIdException e) {
+ throw new BadRequestException(
+ String.format("Base %s doesn't represent a valid SHA-1", baseSha1), e);
+ }
+
+ RevCommit baseCommit;
+ try {
+ baseCommit = revWalk.parseCommit(baseObjectId);
+ } catch (MissingObjectException e) {
+ throw new UnprocessableEntityException(
+ String.format("Base %s doesn't exist", baseObjectId.name()), e);
+ }
+
+ changeQuery.enforceVisibility(true);
+ List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), baseSha1);
+
+ if (changeDatas.isEmpty()) {
+ if (revWalk.isMergedInto(baseCommit, destRefTip)) {
+ // The base commit is a merged commit with no change associated.
+ return baseCommit;
+ }
+ throw new UnprocessableEntityException(
+ String.format("Commit %s does not exist on branch %s", baseSha1, destRef.getName()));
+ } else if (changeDatas.size() != 1) {
+ throw new ResourceConflictException("Multiple changes found for commit " + baseSha1);
+ }
+
+ Change change = changeDatas.get(0).change();
+ if (!change.isAbandoned()) {
+ // The base commit is a valid change revision.
+ return baseCommit;
+ }
+
+ throw new ResourceConflictException(
+ String.format(
+ "Change %s with commit %s is %s",
+ change.getChangeId(), baseSha1, ChangeUtil.status(change)));
+ }
}
diff --git a/java/com/google/gerrit/server/git/DynamicRefDbRepository.java b/java/com/google/gerrit/server/git/DynamicRefDbRepository.java
new file mode 100644
index 0000000000..2e81ad4c28
--- /dev/null
+++ b/java/com/google/gerrit/server/git/DynamicRefDbRepository.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.function.BiFunction;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache;
+import org.eclipse.jgit.util.FS;
+
+/** A FileRepository with a dynamic RefDatabase supplied via a BiFunction. */
+public class DynamicRefDbRepository extends FileRepository {
+ public static class FileKey extends RepositoryCache.FileKey {
+ private BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier;
+
+ public static FileKey lenient(
+ File directory, FS fs, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier) {
+ final File gitdir = resolve(directory, fs);
+ return new FileKey(gitdir != null ? gitdir : directory, fs, refDatabaseSupplier);
+ }
+
+ private final FS fs;
+
+ /**
+ * @param directory exact location of the repository.
+ * @param fs the file system abstraction which will be necessary to perform certain file system
+ * operations.
+ */
+ public FileKey(
+ File directory, FS fs, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier) {
+ super(canonical(directory), fs);
+ this.fs = fs;
+ this.refDatabaseSupplier = refDatabaseSupplier;
+ }
+
+ @Override
+ public Repository open(boolean mustExist) throws IOException {
+ if (mustExist && !isGitRepository(getFile(), fs))
+ throw new RepositoryNotFoundException(getFile());
+ return new DynamicRefDbRepository(getFile(), refDatabaseSupplier);
+ }
+
+ private static File canonical(File path) {
+ try {
+ return path.getCanonicalFile();
+ } catch (IOException e) {
+ return path.getAbsoluteFile();
+ }
+ }
+ }
+
+ private final File path;
+ private final BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier;
+
+ public DynamicRefDbRepository(
+ File path, BiFunction<File, RefDatabase, RefDatabase> refDatabaseSupplier)
+ throws IOException {
+ super(path);
+ this.path = path;
+ this.refDatabaseSupplier = refDatabaseSupplier;
+ }
+
+ @Override
+ public RefDatabase getRefDatabase() {
+ return refDatabaseSupplier.apply(path, super.getRefDatabase());
+ }
+}
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 5bbe5e21e5..455b221af9 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -27,6 +27,7 @@ import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import com.google.common.collect.SortedSetMultimap;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.PatchSetUtil;
@@ -258,6 +259,7 @@ public class GroupCollector {
return actual;
}
+ @Nullable
private ObjectId parseGroup(ObjectId forCommit, String group) {
try {
return ObjectId.fromString(group);
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 57d37fae08..5eb913dbff 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -20,6 +20,7 @@ import com.google.gerrit.entities.Project.NameKey;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.cache.PerThreadRefDbCache;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.inject.Inject;
@@ -112,6 +113,7 @@ public class LocalDiskRepositoryManager implements GitRepositoryManager {
private final Path basePath;
private final Map<Project.NameKey, FileKey> fileKeyByProject = new ConcurrentHashMap<>();
+ private final boolean usePerRequestRefCache;
@Inject
LocalDiskRepositoryManager(SitePaths site, @GerritServerConfig Config cfg) {
@@ -119,6 +121,7 @@ public class LocalDiskRepositoryManager implements GitRepositoryManager {
if (basePath == null) {
throw new IllegalStateException("gerrit.basePath must be configured");
}
+ usePerRequestRefCache = cfg.getBoolean("core", null, "usePerRequestRefCache", true);
}
/**
@@ -168,7 +171,13 @@ public class LocalDiskRepositoryManager implements GitRepositoryManager {
if (isUnreasonableName(name)) {
throw new RepositoryNotFoundException("Invalid name: " + name);
}
- FileKey location = FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
+ FileKey location =
+ usePerRequestRefCache
+ ? DynamicRefDbRepository.FileKey.lenient(
+ getBasePath(name).resolve(name.get()).toFile(),
+ FS.DETECTED,
+ (path, refDb) -> PerThreadRefDbCache.getRefDatabase(path, refDb))
+ : FileKey.lenient(getBasePath(name).resolve(name.get()).toFile(), FS.DETECTED);
try {
Repository repo = RepositoryCache.open(location);
fileKeyByProject.put(name, location);
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index ae247ad556..6922efbfba 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -1027,6 +1027,7 @@ public class MergeUtil {
}
}
+ @Nullable
public static CodeReviewCommit findAnyMergedInto(
CodeReviewRevWalk rw, Iterable<CodeReviewCommit> commits, CodeReviewCommit tip)
throws IOException {
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index b88985dad0..ab5c988813 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -16,8 +16,10 @@ package com.google.gerrit.server.git;
import static com.google.gerrit.server.DeadlineChecker.getTimeoutFormatter;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.base.Ticker;
import com.google.common.flogger.FluentLogger;
@@ -597,9 +599,12 @@ public class MultiProgressMonitor implements RequestStateProvider {
}
private void send(StringBuilder s) {
+ String progress = s.toString();
+ logger.atInfo().atMostEvery(1, MINUTES).log(
+ "%s", CharMatcher.javaIsoControl().removeFrom(progress));
if (!clientDisconnected) {
try {
- out.write(Constants.encode(s.toString()));
+ out.write(Constants.encode(progress));
out.flush();
} catch (IOException e) {
logger.atWarning().withCause(e).log(
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index f66a08989c..fb347539bd 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -82,6 +82,7 @@ public class PermissionAwareReadOnlyRefDatabase extends DelegateRefDatabase {
throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
}
+ @Nullable
@Override
public Ref exactRef(String name) throws IOException {
Ref ref = getDelegate().getRefDatabase().exactRef(name);
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 79449139dc..83024e3f45 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -40,9 +40,9 @@ import com.google.inject.Provider;
import com.google.inject.Singleton;
import com.google.inject.TypeLiteral;
import com.google.inject.name.Named;
-import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.stream.Stream;
import org.eclipse.jgit.lib.Repository;
@@ -151,14 +151,20 @@ public class SearchingChangeCacheImpl
List<ChangeData> cds =
queryProvider
.get()
- .setRequestedFields(ChangeField.CHANGE, ChangeField.REVIEWER)
+ .setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.REVIEWER_SPEC)
.byProject(key);
- List<CachedChange> result = new ArrayList<>(cds.size());
+ Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
for (ChangeData cd : cds) {
- result.add(
+ if (result.containsKey(cd.getId())) {
+ logger.atWarning().log(
+ "Duplicate changes returned from change query by project %s: %s, %s",
+ key, cd.change(), result.get(cd.getId()).change());
+ }
+ result.put(
+ cd.getId(),
new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
}
- return Collections.unmodifiableList(result);
+ return List.copyOf(result.values());
}
}
}
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 3032bfeebf..e8b7c62f15 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -18,8 +18,10 @@ import static java.util.stream.Collectors.toList;
import com.google.common.base.CaseFormat;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.lifecycle.LifecycleModule;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.MetricMaker;
@@ -27,6 +29,7 @@ import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.ScheduleConfig.Schedule;
import com.google.gerrit.server.logging.LoggingContext;
import com.google.gerrit.server.logging.LoggingContextAwareRunnable;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
import com.google.gerrit.server.util.IdGenerator;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -49,8 +52,8 @@ import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
import org.eclipse.jgit.lib.Config;
/** Delayed execution of tasks using a background thread pool. */
@@ -58,6 +61,30 @@ import org.eclipse.jgit.lib.Config;
public class WorkQueue {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ /**
+ * To register a TaskListener, which will be called directly before Tasks run, and directly after
+ * they complete, bind the TaskListener like this:
+ *
+ * <p><code>
+ * bind(TaskListener.class)
+ * .annotatedWith(Exports.named("MyListener"))
+ * .to(MyListener.class);
+ * </code>
+ */
+ public interface TaskListener {
+ public static class NoOp implements TaskListener {
+ @Override
+ public void onStart(Task<?> task) {}
+
+ @Override
+ public void onStop(Task<?> task) {}
+ }
+
+ void onStart(Task<?> task);
+
+ void onStop(Task<?> task);
+ }
+
public static class Lifecycle implements LifecycleListener {
private final WorkQueue workQueue;
@@ -78,6 +105,7 @@ public class WorkQueue {
public static class WorkQueueModule extends LifecycleModule {
@Override
protected void configure() {
+ DynamicMap.mapOf(binder(), WorkQueue.TaskListener.class);
bind(WorkQueue.class);
listener().to(Lifecycle.class);
}
@@ -87,18 +115,32 @@ public class WorkQueue {
private final IdGenerator idGenerator;
private final MetricMaker metrics;
private final CopyOnWriteArrayList<Executor> queues;
+ private final PluginMapContext<TaskListener> listeners;
@Inject
- WorkQueue(IdGenerator idGenerator, @GerritServerConfig Config cfg, MetricMaker metrics) {
- this(idGenerator, Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2), metrics);
+ WorkQueue(
+ IdGenerator idGenerator,
+ @GerritServerConfig Config cfg,
+ MetricMaker metrics,
+ PluginMapContext<TaskListener> listeners) {
+ this(
+ idGenerator,
+ Math.max(cfg.getInt("execution", "defaultThreadPoolSize", 2), 2),
+ metrics,
+ listeners);
}
/** Constructor to allow binding the WorkQueue more explicitly in a vhost setup. */
- public WorkQueue(IdGenerator idGenerator, int defaultThreadPoolSize, MetricMaker metrics) {
+ public WorkQueue(
+ IdGenerator idGenerator,
+ int defaultThreadPoolSize,
+ MetricMaker metrics,
+ PluginMapContext<TaskListener> listeners) {
this.idGenerator = idGenerator;
this.metrics = metrics;
this.queues = new CopyOnWriteArrayList<>();
this.defaultQueue = createQueue(defaultThreadPoolSize, "WorkQueue", true);
+ this.listeners = listeners;
}
/** Get the default work queue, for miscellaneous tasks. */
@@ -200,6 +242,7 @@ public class WorkQueue {
}
/** Locate a task by its unique id, null if no task matches. */
+ @Nullable
public Task<?> getTask(int id) {
Task<?> result = null;
for (Executor e : queues) {
@@ -215,6 +258,7 @@ public class WorkQueue {
return result;
}
+ @Nullable
public ScheduledThreadPoolExecutor getExecutor(String queueName) {
for (Executor e : queues) {
if (e.queueName.equals(queueName)) {
@@ -438,6 +482,14 @@ public class WorkQueue {
Collection<Task<?>> getTasks() {
return all.values();
}
+
+ public void onStart(Task<?> task) {
+ listeners.runEach(extension -> extension.getProvider().get().onStart(task));
+ }
+
+ public void onStop(Task<?> task) {
+ listeners.runEach(extension -> extension.getProvider().get().onStop(task));
+ }
}
private static void logUncaughtException(Thread t, Throwable e) {
@@ -474,18 +526,23 @@ public class WorkQueue {
* <ol>
* <li>{@link #SLEEPING}: if scheduled with a non-zero delay.
* <li>{@link #READY}: waiting for an available worker thread.
+ * <li>{@link #STARTING}: onStart() actively executing on a worker thread.
* <li>{@link #RUNNING}: actively executing on a worker thread.
+ * <li>{@link #STOPPING}: onStop() actively executing on a worker thread.
* <li>{@link #DONE}: finished executing, if not periodic.
* </ol>
*/
public enum State {
// Ordered like this so ordinal matches the order we would
// prefer to see tasks sorted in: done before running,
- // running before ready, ready before sleeping.
+ // stopping before running, running before starting,
+ // starting before ready, ready before sleeping.
//
DONE,
CANCELLED,
+ STOPPING,
RUNNING,
+ STARTING,
READY,
SLEEPING,
OTHER
@@ -495,15 +552,16 @@ public class WorkQueue {
private final RunnableScheduledFuture<V> task;
private final Executor executor;
private final int taskId;
- private final AtomicBoolean running;
private final Instant startTime;
+ // runningState is non-null when listener or task code is running in an executor thread
+ private final AtomicReference<State> runningState = new AtomicReference<>();
+
Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor, int taskId) {
this.runnable = runnable;
this.task = task;
this.executor = executor;
this.taskId = taskId;
- this.running = new AtomicBoolean();
this.startTime = Instant.now();
}
@@ -514,10 +572,13 @@ public class WorkQueue {
public State getState() {
if (isCancelled()) {
return State.CANCELLED;
+ }
+
+ State r = runningState.get();
+ if (r != null) {
+ return r;
} else if (isDone() && !isPeriodic()) {
return State.DONE;
- } else if (running.get()) {
- return State.RUNNING;
}
final long delay = getDelay(TimeUnit.MILLISECONDS);
@@ -538,14 +599,14 @@ public class WorkQueue {
@Override
public boolean cancel(boolean mayInterruptIfRunning) {
if (task.cancel(mayInterruptIfRunning)) {
- // Tiny abuse of running: if the task needs to know it was
- // canceled (to clean up resources) and it hasn't started
+ // Tiny abuse of runningState: if the task needs to know it
+ // was canceled (to clean up resources) and it hasn't started
// yet the task's run method won't execute. So we tag it
// as running and allow it to clean up. This ensures we do
// not invoke cancel twice.
//
if (runnable instanceof CancelableRunnable) {
- if (running.compareAndSet(false, true)) {
+ if (runningState.compareAndSet(null, State.RUNNING)) {
((CancelableRunnable) runnable).cancel();
} else if (runnable instanceof CanceledWhileRunning) {
((CanceledWhileRunning) runnable).setCanceledWhileRunning();
@@ -605,16 +666,21 @@ public class WorkQueue {
@Override
public void run() {
- if (running.compareAndSet(false, true)) {
+ if (runningState.compareAndSet(null, State.STARTING)) {
String oldThreadName = Thread.currentThread().getName();
try {
+ executor.onStart(this);
+ runningState.set(State.RUNNING);
Thread.currentThread().setName(oldThreadName + "[" + task.toString() + "]");
task.run();
} finally {
Thread.currentThread().setName(oldThreadName);
+ runningState.set(State.STOPPING);
+ executor.onStop(this);
if (isPeriodic()) {
- running.set(false);
+ runningState.set(null);
} else {
+ runningState.set(State.DONE);
executor.remove(this);
}
}
diff --git a/java/com/google/gerrit/server/git/meta/TabFile.java b/java/com/google/gerrit/server/git/meta/TabFile.java
index 80570a563c..5f76b39159 100644
--- a/java/com/google/gerrit/server/git/meta/TabFile.java
+++ b/java/com/google/gerrit/server/git/meta/TabFile.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.git.meta;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.server.git.ValidationError;
import java.io.BufferedReader;
import java.io.IOException;
@@ -84,6 +85,7 @@ public class TabFile {
return map;
}
+ @Nullable
protected static String asText(String left, String right, Map<String, String> entries) {
if (entries.isEmpty()) {
return null;
@@ -96,6 +98,7 @@ public class TabFile {
return asText(left, right, rows);
}
+ @Nullable
protected static String asText(String left, String right, List<Row> rows) {
if (rows.isEmpty()) {
return null;
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index 61bd8a8844..4f0bde8f54 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.git.meta;
import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.VERSIONED_META_DATA_CHANGE;
import com.google.common.base.MoreObjects;
import com.google.common.flogger.FluentLogger;
@@ -27,6 +28,7 @@ import com.google.gerrit.server.InvalidConfigFileException;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import java.io.BufferedReader;
import java.io.File;
@@ -438,53 +440,55 @@ public abstract class VersionedMetaData {
private RevCommit updateRef(AnyObjectId oldId, AnyObjectId newId, String refName)
throws IOException {
- BatchRefUpdate bru = update.getBatch();
- if (bru != null) {
- bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
- if (objInserter == null) {
- inserter.flush();
+ try (RefUpdateContext ctx = RefUpdateContext.open(VERSIONED_META_DATA_CHANGE)) {
+ BatchRefUpdate bru = update.getBatch();
+ if (bru != null) {
+ bru.addCommand(new ReceiveCommand(oldId.toObjectId(), newId.toObjectId(), refName));
+ if (objInserter == null) {
+ inserter.flush();
+ }
+ revision = rw.parseCommit(newId);
+ return revision;
}
- revision = rw.parseCommit(newId);
- return revision;
- }
- RefUpdate ru = db.updateRef(refName);
- ru.setExpectedOldObjectId(oldId);
- ru.setNewObjectId(newId);
- ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
- String message = update.getCommitBuilder().getMessage();
- if (message == null) {
- message = "meta data update";
- }
- try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
- // read the subject line and use it as reflog message
- ru.setRefLogMessage("commit: " + reader.readLine(), true);
- }
- logger.atFine().log("Saving commit '%s' on project '%s'", message.trim(), projectName);
- inserter.flush();
- RefUpdate.Result result = ru.update();
- switch (result) {
- case NEW:
- case FAST_FORWARD:
- revision = rw.parseCommit(ru.getNewObjectId());
- update.fireGitRefUpdatedEvent(ru);
- logger.atFine().log(
- "Saved commit '%s' as revision '%s' on project '%s'",
- message.trim(), revision.name(), projectName);
- return revision;
- case LOCK_FAILURE:
- throw new LockFailureException(errorMsg(ru, db.getDirectory()), ru);
- case FORCED:
- case IO_FAILURE:
- case NOT_ATTEMPTED:
- case NO_CHANGE:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- throw new GitUpdateFailureException(errorMsg(ru, db.getDirectory()), ru);
+ RefUpdate ru = db.updateRef(refName);
+ ru.setExpectedOldObjectId(oldId);
+ ru.setNewObjectId(newId);
+ ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
+ String message = update.getCommitBuilder().getMessage();
+ if (message == null) {
+ message = "meta data update";
+ }
+ try (BufferedReader reader = new BufferedReader(new StringReader(message))) {
+ // read the subject line and use it as reflog message
+ ru.setRefLogMessage("commit: " + reader.readLine(), true);
+ }
+ logger.atFine().log("Saving commit '%s' on project '%s'", message.trim(), projectName);
+ inserter.flush();
+ RefUpdate.Result result = ru.update();
+ switch (result) {
+ case NEW:
+ case FAST_FORWARD:
+ revision = rw.parseCommit(ru.getNewObjectId());
+ update.fireGitRefUpdatedEvent(ru);
+ logger.atFine().log(
+ "Saved commit '%s' as revision '%s' on project '%s'",
+ message.trim(), revision.name(), projectName);
+ return revision;
+ case LOCK_FAILURE:
+ throw new LockFailureException(errorMsg(ru, db.getDirectory()), ru);
+ case FORCED:
+ case IO_FAILURE:
+ case NOT_ATTEMPTED:
+ case NO_CHANGE:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ throw new GitUpdateFailureException(errorMsg(ru, db.getDirectory()), ru);
+ }
}
}
};
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 0f5e3bc1ba..2baca53b1c 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -33,6 +33,8 @@ import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_
import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+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 static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.joining;
@@ -192,6 +194,8 @@ import com.google.gerrit.server.update.SubmissionExecutor;
import com.google.gerrit.server.update.SubmissionListener;
import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
import com.google.gerrit.server.util.LabelVote;
import com.google.gerrit.server.util.MagicBranch;
import com.google.gerrit.server.util.RequestScopePropagator;
@@ -439,6 +443,7 @@ class ReceiveCommits {
private MessageSender messageSender;
private ReceiveCommitsResult.Builder result;
private ImmutableMap<String, String> loggingTags;
+ private ImmutableList<String> transitionalPluginOptions;
/** This object is for single use only. */
private boolean used;
@@ -590,6 +595,8 @@ class ReceiveCommits {
useRefCache
? ReceivePackRefCache.withAdvertisedRefs(() -> allRefsWatcher.getAllRefs())
: ReceivePackRefCache.noCache(receivePack.getRepository().getRefDatabase());
+ this.transitionalPluginOptions =
+ ImmutableList.copyOf(config.getStringList("plugins", null, "transitionalPushOptions"));
}
void init() {
@@ -756,13 +763,15 @@ class ReceiveCommits {
String pushKind = magicBranch != null && magicBranch.submit ? "direct_submit" : "magic";
metrics.pushCount.increment(pushKind, project.getName(), getUpdateType(magicCommands));
}
- if (!regularCommands.isEmpty()) {
- metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
- }
+ try (RefUpdateContext ctx = RefUpdateContext.open(DIRECT_PUSH)) {
+ if (!regularCommands.isEmpty()) {
+ metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+ }
- if (!regularCommands.isEmpty()) {
- handleRegularCommands(regularCommands, progress);
- return;
+ if (!regularCommands.isEmpty()) {
+ handleRegularCommands(regularCommands, progress);
+ return;
+ }
}
boolean first = true;
@@ -883,7 +892,10 @@ class ReceiveCommits {
case UPDATE:
case UPDATE_NONFASTFORWARD:
Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
- autoCloseChanges(c, closeProgress);
+ try (RefUpdateContext ctx =
+ RefUpdateContext.open(RefUpdateType.AUTO_CLOSE_CHANGES)) {
+ autoCloseChanges(c, closeProgress);
+ }
closeProgress.end();
break;
@@ -1012,59 +1024,61 @@ class ReceiveCommits {
private void insertChangesAndPatchSets(
ImmutableList<CreateRequest> newChanges, Task replaceProgress) {
- try (TraceTimer traceTimer =
- newTimer(
- "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
- ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
- if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
- logger.atWarning().log(
- "Skipping change updates on %s because ref update failed: %s %s",
- project.getName(),
- magicBranchCmd.getResult(),
- Strings.nullToEmpty(magicBranchCmd.getMessage()));
- return;
- }
- try {
- if (!newChanges.isEmpty()) {
- // TODO: Retry lock failures on new change insertions. The retry will
- // likely have to move to a higher layer to be able to achieve that
- // due to state that needs to be reset with each retry attempt.
- insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
- } else {
- retryHelper
- .changeUpdate(
- "insertPatchSets",
- updateFactory -> {
- insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
- return null;
- })
- .defaultTimeoutMultiplier(5)
- .call();
- }
- } catch (ResourceConflictException e) {
- addError(e.getMessage());
- reject(magicBranchCmd, "conflict");
- } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
- logger.atFine().withCause(e).log("Rejecting due to client error");
- reject(magicBranchCmd, e.getMessage());
- } catch (RestApiException | IOException | UpdateException e) {
- throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
- }
-
- if (magicBranch != null && magicBranch.submit) {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (TraceTimer traceTimer =
+ newTimer(
+ "insertChangesAndPatchSets", Metadata.builder().resourceCount(newChanges.size()))) {
+ ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+ if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
+ logger.atWarning().log(
+ "Skipping change updates on %s because ref update failed: %s %s",
+ project.getName(),
+ magicBranchCmd.getResult(),
+ Strings.nullToEmpty(magicBranchCmd.getMessage()));
+ return;
+ }
try {
- submit(newChanges, replaceByChange.values());
+ if (!newChanges.isEmpty()) {
+ // TODO: Retry lock failures on new change insertions. The retry will
+ // likely have to move to a higher layer to be able to achieve that
+ // due to state that needs to be reset with each retry attempt.
+ insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+ } else {
+ retryHelper
+ .changeUpdate(
+ "insertPatchSets",
+ updateFactory -> {
+ insertChangesAndPatchSets(magicBranchCmd, newChanges, replaceProgress);
+ return null;
+ })
+ .defaultTimeoutMultiplier(5)
+ .call();
+ }
} catch (ResourceConflictException e) {
addError(e.getMessage());
reject(magicBranchCmd, "conflict");
- } catch (RestApiException
- | StorageException
- | UpdateException
- | IOException
- | ConfigInvalidException
- | PermissionBackendException e) {
- logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
- reject(magicBranchCmd, "error during submit");
+ } catch (BadRequestException | UnprocessableEntityException | AuthException e) {
+ logger.atFine().withCause(e).log("Rejecting due to client error");
+ reject(magicBranchCmd, e.getMessage());
+ } catch (RestApiException | IOException | UpdateException e) {
+ throw new StorageException("Can't insert change/patch set for " + project.getName(), e);
+ }
+
+ if (magicBranch != null && magicBranch.submit) {
+ try {
+ submit(newChanges, replaceByChange.values());
+ } catch (ResourceConflictException e) {
+ addError(e.getMessage());
+ reject(magicBranchCmd, "conflict");
+ } catch (RestApiException
+ | StorageException
+ | UpdateException
+ | IOException
+ | ConfigInvalidException
+ | PermissionBackendException e) {
+ logger.atSevere().withCause(e).log("Error submitting changes to %s", project.getName());
+ reject(magicBranchCmd, "error during submit");
+ }
}
}
}
@@ -1096,15 +1110,15 @@ class ReceiveCommits {
publishCommentsOp.create(replace.psId, project.getNameKey()));
Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
if (!changeNotes.isPresent()) {
- // If not present, no need to update attention set here since this is a
- // new change.
+ // If not present, no need to update attention set here since this is
+ // a new change.
continue;
}
List<HumanComment> drafts =
commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
if (drafts.isEmpty()) {
- // If no comments, attention set shouldn't update since the user didn't
- // reply.
+ // If no comments, attention set shouldn't update since the user
+ // didn't reply.
continue;
}
replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
@@ -2155,6 +2169,9 @@ class ReceiveCommits {
}
private boolean isPluginPushOption(String pushOptionName) {
+ if (transitionalPluginOptions.contains(pushOptionName)) {
+ return true;
+ }
return StreamSupport.stream(pluginPushOptions.entries().spliterator(), /* parallel= */ false)
.anyMatch(e -> pushOptionName.equals(e.getPluginName() + "~" + e.get().getName()));
}
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index e545c707f6..7c22bd8048 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -111,10 +111,10 @@ public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
.get()
.setRequestedFields(
// Required for ChangeIsVisibleToPrdicate.
- ChangeField.CHANGE,
- ChangeField.REVIEWER,
+ ChangeField.CHANGE_SPEC,
+ ChangeField.REVIEWER_SPEC,
// Required during advertiseOpenChanges.
- ChangeField.PATCH_SET)
+ ChangeField.PATCH_SET_SPEC)
.enforceVisibility(true)
.setLimit(limit)
.query(
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index 644f82e169..0e17342277 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -405,7 +405,7 @@ public class ReplaceOp implements BatchUpdateOp {
// Ignore failures for reasons like the reviewer being inactive or being unable to see the
// change. See discussion in ChangeInserter.
- input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
+ input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE_EXCEPT_NOT_FOUND;
return input;
}
@@ -441,6 +441,7 @@ public class ReplaceOp implements BatchUpdateOp {
update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
}
+ @Nullable
private String changeKindMessage(ChangeKind changeKind) {
switch (changeKind) {
case MERGE_FIRST_PARENT_UPDATE:
@@ -509,7 +510,9 @@ public class ReplaceOp implements BatchUpdateOp {
ctx,
newPatchSet,
mailMessage,
- approvalCopierResult.outdatedApprovals(),
+ approvalCopierResult.outdatedApprovals().stream()
+ .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
+ .collect(toImmutableSet()),
Streams.concat(
oldRecipients.getReviewers().stream(),
reviewerAdditions.flattenResults(ReviewerOp.Result::addedReviewers).stream()
@@ -594,6 +597,7 @@ public class ReplaceOp implements BatchUpdateOp {
return Optional.of(
"The following approvals got outdated and were removed:\n"
+ approvalCopierResult.outdatedApprovals().stream()
+ .map(ApprovalCopier.Result.PatchSetApprovalData::patchSetApproval)
.map(
outdatedApproval ->
String.format(
@@ -624,6 +628,7 @@ public class ReplaceOp implements BatchUpdateOp {
return cmd;
}
+ @Nullable
private static String findMergedInto(Context ctx, String first, RevCommit commit) {
try {
RevWalk rw = ctx.getRevWalk();
diff --git a/java/com/google/gerrit/server/group/GroupResolver.java b/java/com/google/gerrit/server/group/GroupResolver.java
index 546614c143..001a153d37 100644
--- a/java/com/google/gerrit/server/group/GroupResolver.java
+++ b/java/com/google/gerrit/server/group/GroupResolver.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.group;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
@@ -84,6 +85,7 @@ public class GroupResolver {
* @param id ID of the group, can be a group UUID, a group name or a legacy group ID
* @return the group, null if no group is found for the given group ID
*/
+ @Nullable
public GroupDescription.Basic parseId(String id) {
logger.atFine().log("Parsing group %s", id);
diff --git a/java/com/google/gerrit/server/group/SystemGroupBackend.java b/java/com/google/gerrit/server/group/SystemGroupBackend.java
index 5a9b9e595d..0471acc953 100644
--- a/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -21,6 +21,7 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
@@ -140,6 +141,7 @@ public class SystemGroupBackend extends AbstractGroupBackend {
return isSystemGroup(uuid);
}
+ @Nullable
@Override
public GroupDescription.Basic get(AccountGroup.UUID uuid) {
final GroupReference ref = uuids.get(uuid);
@@ -157,11 +159,13 @@ public class SystemGroupBackend extends AbstractGroupBackend {
return ref.getUUID();
}
+ @Nullable
@Override
public String getUrl() {
return null;
}
+ @Nullable
@Override
public String getEmailAddress() {
return null;
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index c0c934b140..14f8825551 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.group.db;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
@@ -45,6 +47,7 @@ import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Provider;
import com.google.inject.assistedinject.Assisted;
@@ -115,7 +118,6 @@ public class GroupsUpdate {
private final RetryHelper retryHelper;
@AssistedInject
- @SuppressWarnings("BindingAnnotationWithoutInject")
GroupsUpdate(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
@@ -150,7 +152,6 @@ public class GroupsUpdate {
}
@AssistedInject
- @SuppressWarnings("BindingAnnotationWithoutInject")
GroupsUpdate(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
@@ -185,7 +186,6 @@ public class GroupsUpdate {
Optional.of(currentUser));
}
- @SuppressWarnings("BindingAnnotationWithoutInject")
private GroupsUpdate(
GitRepositoryManager repoManager,
AllUsersName allUsersName,
@@ -308,16 +308,18 @@ public class GroupsUpdate {
private InternalGroup createGroupInNoteDbWithRetry(
InternalGroupCreation groupCreation, GroupDelta groupDelta)
throws IOException, ConfigInvalidException, DuplicateKeyException {
- try {
- return retryHelper
- .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
- .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);
+ try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
+ try {
+ return retryHelper
+ .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
+ .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);
+ }
}
}
@@ -364,30 +366,32 @@ public class GroupsUpdate {
@VisibleForTesting
public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
- try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
- GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
- groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
- if (!groupConfig.getLoadedGroup().isPresent()) {
- throw new NoSuchGroupException(groupUuid);
+ try (RefUpdateContext ctx = RefUpdateContext.open(GROUPS_UPDATE)) {
+ try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+ GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
+ groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
+ if (!groupConfig.getLoadedGroup().isPresent()) {
+ throw new NoSuchGroupException(groupUuid);
+ }
+
+ InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
+ GroupNameNotes groupNameNotes = null;
+ if (groupDelta.getName().isPresent()) {
+ AccountGroup.NameKey oldName = originalGroup.getNameKey();
+ AccountGroup.NameKey newName = groupDelta.getName().get();
+ groupNameNotes =
+ GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
+ }
+
+ commit(allUsersRepo, groupConfig, groupNameNotes);
+
+ InternalGroup updatedGroup =
+ groupConfig
+ .getLoadedGroup()
+ .orElseThrow(
+ () -> new IllegalStateException("Updated group wasn't automatically loaded"));
+ return getUpdateResult(originalGroup, updatedGroup);
}
-
- InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
- GroupNameNotes groupNameNotes = null;
- if (groupDelta.getName().isPresent()) {
- AccountGroup.NameKey oldName = originalGroup.getNameKey();
- AccountGroup.NameKey newName = groupDelta.getName().get();
- groupNameNotes =
- GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
- }
-
- commit(allUsersRepo, groupConfig, groupNameNotes);
-
- InternalGroup updatedGroup =
- groupConfig
- .getLoadedGroup()
- .orElseThrow(
- () -> new IllegalStateException("Updated group wasn't automatically loaded"));
- return getUpdateResult(originalGroup, updatedGroup);
}
}
diff --git a/java/com/google/gerrit/server/group/db/testing/BUILD b/java/com/google/gerrit/server/group/db/testing/BUILD
index 8f33f98a48..a4f49e9d40 100644
--- a/java/com/google/gerrit/server/group/db/testing/BUILD
+++ b/java/com/google/gerrit/server/group/db/testing/BUILD
@@ -8,6 +8,7 @@ java_library(
srcs = glob(["*.java"]),
deps = [
"//java/com/google/gerrit/server",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:guava",
"//lib:jgit",
"//lib:jgit-junit",
diff --git a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
index fa0628138c..e36ccf0d48 100644
--- a/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
+++ b/java/com/google/gerrit/server/group/db/testing/GroupTestUtil.java
@@ -14,8 +14,11 @@
package com.google.gerrit.server.group.db.testing;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
@@ -45,25 +48,27 @@ public class GroupTestUtil {
String fileName,
String contents)
throws Exception {
- try (RevWalk rw = new RevWalk(allUsersRepo);
- TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw)) {
- TestRepository<Repository>.CommitBuilder builder =
- testRepository
- .branch(refName)
- .commit()
- .add(fileName, contents)
- .message("update group file")
- .author(serverIdent)
- .committer(serverIdent);
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (RevWalk rw = new RevWalk(allUsersRepo);
+ TestRepository<Repository> testRepository = new TestRepository<>(allUsersRepo, rw)) {
+ TestRepository<Repository>.CommitBuilder builder =
+ testRepository
+ .branch(refName)
+ .commit()
+ .add(fileName, contents)
+ .message("update group file")
+ .author(serverIdent)
+ .committer(serverIdent);
- Ref ref = allUsersRepo.exactRef(refName);
- if (ref != null) {
- RevCommit c = rw.parseCommit(ref.getObjectId());
- if (c != null) {
- builder.parent(c);
+ Ref ref = allUsersRepo.exactRef(refName);
+ if (ref != null) {
+ RevCommit c = rw.parseCommit(ref.getObjectId());
+ if (c != null) {
+ builder.parent(c);
+ }
}
+ builder.create();
}
- builder.create();
}
}
diff --git a/java/com/google/gerrit/server/group/testing/BUILD b/java/com/google/gerrit/server/group/testing/BUILD
index 77bb777f0f..bb2b20dcee 100644
--- a/java/com/google/gerrit/server/group/testing/BUILD
+++ b/java/com/google/gerrit/server/group/testing/BUILD
@@ -7,6 +7,7 @@ java_library(
testonly = True,
srcs = glob(["*.java"]),
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/server",
"//lib:guava",
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 2d9c798dda..1f3dbcb1f2 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
@@ -32,7 +33,7 @@ import java.util.Map;
/** Implementation of GroupBackend for tests. */
public class TestGroupBackend implements GroupBackend {
- private static final String PREFIX = "testbackend:";
+ public static final String PREFIX = "testbackend:";
private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
private final Map<Account.Id, GroupMembership> memberships = new HashMap<>();
@@ -72,11 +73,13 @@ public class TestGroupBackend implements GroupBackend {
}
@Override
+ @Nullable
public String getEmailAddress() {
return null;
}
@Override
+ @Nullable
public String getUrl() {
return null;
}
@@ -116,6 +119,7 @@ public class TestGroupBackend implements GroupBackend {
return false;
}
+ @Nullable
@Override
public GroupDescription.Basic get(AccountGroup.UUID uuid) {
return uuid == null ? null : groups.get(uuid);
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 80cc463485..352d376e9a 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,9 +14,9 @@
package com.google.gerrit.server.index;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
@@ -77,14 +77,14 @@ public final class IndexUtils {
// change ID and project, which can either come via the Change field or
// separate fields.
Set<String> fs = opts.fields();
- if (fs.contains(CHANGE.getName())) {
+ if (fs.contains(CHANGE_SPEC.getName())) {
// A Change is always sufficient.
return fs;
}
- if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID_STR.getName())) {
+ if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
return fs;
}
- return Sets.union(fs, ImmutableSet.of(LEGACY_ID_STR.getName(), PROJECT.getName()));
+ return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
}
/**
@@ -116,9 +116,9 @@ public final class IndexUtils {
*/
public static Set<String> projectFields(QueryOptions opts) {
Set<String> fs = opts.fields();
- return fs.contains(ProjectField.NAME.getName())
+ return fs.contains(ProjectField.NAME_SPEC.getName())
? fs
- : Sets.union(fs, ImmutableSet.of(ProjectField.NAME.getName()));
+ : Sets.union(fs, ImmutableSet.of(ProjectField.NAME_SPEC.getName()));
}
private IndexUtils() {
diff --git a/java/com/google/gerrit/server/index/account/AccountField.java b/java/com/google/gerrit/server/index/account/AccountField.java
index c8022051eb..e67500378a 100644
--- a/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/java/com/google/gerrit/server/index/account/AccountField.java
@@ -66,7 +66,8 @@ public class AccountField {
* External IDs.
*
* <p>This field includes secondary emails. Use this field only if the current user is allowed to
- * see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
+ * see secondary emails (requires the {@link GlobalCapability#VIEW_SECONDARY_EMAILS} capability or
+ * the {@link GlobalCapability#MODIFY_ACCOUNT} capability).
*/
public static final IndexedField<AccountState, Iterable<String>> EXTERNAL_ID_FIELD =
IndexedField.<AccountState>iterableStringBuilder("ExternalId")
@@ -80,8 +81,9 @@ public class AccountField {
* Fuzzy prefix match on name and email parts.
*
* <p>This field includes parts from the secondary emails. Use this field only if the current user
- * is allowed to see secondary emails (requires the {@link GlobalCapability#MODIFY_ACCOUNT}
- * capability).
+ * is allowed to see secondary emails (requires requires the {@link
+ * GlobalCapability#VIEW_SECONDARY_EMAILS} capability or the {@link
+ * GlobalCapability#MODIFY_ACCOUNT} capability).
*
* <p>Use the {@link AccountField#NAME_PART_NO_SECONDARY_EMAIL_SPEC} if the current user can't see
* secondary emails.
@@ -111,9 +113,7 @@ public class AccountField {
NAME_PART_NO_SECONDARY_EMAIL_SPEC = NAME_PART_NO_SECONDARY_EMAIL_FIELD.prefix("name2");
public static final IndexedField<AccountState, String> FULL_NAME_FIELD =
- IndexedField.<AccountState>stringBuilder("FullName")
- .required()
- .build(a -> a.account().fullName());
+ IndexedField.<AccountState>stringBuilder("FullName").build(a -> a.account().fullName());
public static final IndexedField<AccountState, String>.SearchSpec FULL_NAME_SPEC =
FULL_NAME_FIELD.exact("full_name");
@@ -152,7 +152,7 @@ public class AccountField {
.build(
a -> {
String preferredEmail = a.account().preferredEmail();
- return preferredEmail != null ? preferredEmail.toLowerCase() : null;
+ return preferredEmail != null ? preferredEmail.toLowerCase(Locale.US) : null;
});
public static final IndexedField<AccountState, String>.SearchSpec
diff --git a/java/com/google/gerrit/server/index/account/AccountIndex.java b/java/com/google/gerrit/server/index/account/AccountIndex.java
index ca7264c525..66b85af5c7 100644
--- a/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -20,6 +20,7 @@ import com.google.gerrit.index.IndexDefinition;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.query.account.AccountPredicates;
+import java.util.function.Function;
/**
* Index for Gerrit accounts. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@ public interface AccountIndex extends Index<Account.Id, AccountState> {
default Predicate<AccountState> keyPredicate(Account.Id id) {
return AccountPredicates.id(getSchema(), id);
}
+
+ Function<AccountState, Account.Id> ENTITY_TO_KEY = (a) -> a.account().id();
}
diff --git a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 31fbf36685..8e7d9649dd 100644
--- a/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -34,7 +34,6 @@ public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
static final Schema<AccountState> V8 =
schema(
/* version= */ 8,
- ImmutableList.of(),
ImmutableList.of(
AccountField.ID_FIELD,
AccountField.ACTIVE_FIELD,
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index ace3d6c1d2..4f411a2ff7 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -27,6 +27,7 @@ import com.google.common.flogger.FluentLogger;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
import com.google.gerrit.index.SiteIndexer;
@@ -117,6 +118,11 @@ public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, Change
ImmutableMap<Change.Id, ObjectId> metaIdByChange) {
return new AutoValue_AllChangesIndexer_ProjectSlice(name, slice, slices, metaIdByChange);
}
+
+ private static ProjectSlice oneSlice(
+ Project.NameKey name, ImmutableMap<Change.Id, ObjectId> metaIdByChange) {
+ return new AutoValue_AllChangesIndexer_ProjectSlice(name, 0, 1, metaIdByChange);
+ }
}
@Override
@@ -180,50 +186,39 @@ public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, Change
return Result.create(sw, ok.get(), nDone, nFailed);
}
+ @Nullable
public Callable<Void> reindexProject(
ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
try (Repository repo = repoManager.openRepository(project)) {
- return reindexProject(
- indexer, project, 0, 1, ChangeNotes.Factory.scanChangeIds(repo), done, failed);
+ return reindexProjectSlice(
+ indexer,
+ ProjectSlice.oneSlice(project, ChangeNotes.Factory.scanChangeIds(repo)),
+ done,
+ failed);
} catch (IOException e) {
logger.atSevere().log("%s", e.getMessage());
return null;
}
}
- public Callable<Void> reindexProject(
- ChangeIndexer indexer,
- Project.NameKey project,
- int slice,
- int slices,
- ImmutableMap<Change.Id, ObjectId> metaIdByChange,
- Task done,
- Task failed) {
- return new ProjectIndexer(indexer, project, slice, slices, metaIdByChange, done, failed);
+ public Callable<Void> reindexProjectSlice(
+ ChangeIndexer indexer, ProjectSlice projectSlice, Task done, Task failed) {
+ return new ProjectSliceIndexer(indexer, projectSlice, done, failed);
}
- private class ProjectIndexer implements Callable<Void> {
+ private class ProjectSliceIndexer implements Callable<Void> {
private final ChangeIndexer indexer;
- private final Project.NameKey project;
- private final int slice;
- private final int slices;
- private final ImmutableMap<Change.Id, ObjectId> metaIdByChange;
+ private final ProjectSlice projectSlice;
private final ProgressMonitor done;
private final ProgressMonitor failed;
- private ProjectIndexer(
+ private ProjectSliceIndexer(
ChangeIndexer indexer,
- Project.NameKey project,
- int slice,
- int slices,
- ImmutableMap<Change.Id, ObjectId> metaIdByChange,
+ ProjectSlice projectSlice,
ProgressMonitor done,
ProgressMonitor failed) {
this.indexer = indexer;
- this.project = project;
- this.slice = slice;
- this.slices = slices;
- this.metaIdByChange = metaIdByChange;
+ this.projectSlice = projectSlice;
this.done = done;
this.failed = failed;
}
@@ -237,7 +232,10 @@ public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, Change
// but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
// we don't have concrete proof that improving packfile locality would help.
notesFactory
- .scan(metaIdByChange, project, id -> (id.get() % slices) == slice)
+ .scan(
+ projectSlice.metaIdByChange(),
+ projectSlice.name(),
+ id -> (id.get() % projectSlice.slices()) == projectSlice.slice())
.forEach(r -> index(r));
OnlineReindexMode.end();
return null;
@@ -276,10 +274,15 @@ public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, Change
@Override
public String toString() {
- if (slices == 1) {
- return "Index all changes of project " + project.get();
+ if (projectSlice.slices() == 1) {
+ return "Index all changes of project " + projectSlice.name();
}
- return "Index changes slice " + slice + "/" + slices + " of project " + project.get();
+ return "Index changes slice "
+ + projectSlice.slice()
+ + "/"
+ + projectSlice.slices()
+ + " of project "
+ + projectSlice.name();
}
}
@@ -347,7 +350,7 @@ public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, Change
int size = metaIdByChange.size();
if (size > 0) {
changeCount.addAndGet(size);
- int slices = 1 + size / PROJECT_SLICE_MAX_REFS;
+ int slices = 1 + (size - 1) / PROJECT_SLICE_MAX_REFS;
if (slices > 1) {
verboseWriter.println(
"Submitting " + name + " for indexing in " + slices + " slices");
@@ -360,12 +363,9 @@ public class AllChangesIndexer extends SiteIndexer<Change.Id, ChangeData, Change
ProjectSlice projectSlice = ProjectSlice.create(name, slice, slices, metaIdByChange);
ListenableFuture<?> future =
executor.submit(
- reindexProject(
+ reindexProjectSlice(
indexerFactory.create(executor, index),
- name,
- slice,
- slices,
- projectSlice.metaIdByChange(),
+ projectSlice,
doneTask,
failedTask));
String description = "project " + name + " (" + slice + "/" + slices + ")";
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index d3f6268b09..7057ff7351 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -18,13 +18,6 @@ import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.index.FieldDef.exact;
-import static com.google.gerrit.index.FieldDef.fullText;
-import static com.google.gerrit.index.FieldDef.intRange;
-import static com.google.gerrit.index.FieldDef.integer;
-import static com.google.gerrit.index.FieldDef.prefix;
-import static com.google.gerrit.index.FieldDef.storedOnly;
-import static com.google.gerrit.index.FieldDef.timestamp;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining;
@@ -44,6 +37,7 @@ import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.Files;
import com.google.common.primitives.Longs;
+import com.google.common.reflect.TypeToken;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
@@ -61,15 +55,16 @@ import com.google.gerrit.entities.converter.ChangeProtoConverter;
import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
import com.google.gerrit.entities.converter.ProtoConverter;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.RefState;
import com.google.gerrit.index.SchemaFieldDefs;
import com.google.gerrit.index.SchemaUtil;
import com.google.gerrit.json.OutputFormat;
-import com.google.gerrit.proto.Protos;
+import com.google.gerrit.proto.Entities;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.cache.proto.Cache;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -127,69 +122,118 @@ public class ChangeField {
// TODO: Rename LEGACY_ID to NUMERIC_ID
/** Legacy change ID. */
- public static final FieldDef<ChangeData, String> LEGACY_ID_STR =
- exact("legacy_id_str").stored().build(cd -> String.valueOf(cd.getVirtualId().get()));
+ public static final IndexedField<ChangeData, String> NUMERIC_ID_STR_FIELD =
+ IndexedField.<ChangeData>stringBuilder("NumericIdStr")
+ .stored()
+ .required()
+ // The numeric change id is integer in string form
+ .size(10)
+ .build(cd -> String.valueOf(cd.getVirtualId().get()));
+
+ public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
+ NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
/** Newer style Change-Id key. */
- public static final FieldDef<ChangeData, String> ID =
- prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get()));
+ public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
+ IndexedField.<ChangeData>stringBuilder("ChangeId")
+ .required()
+ // The new style key is in form Isha1
+ .size(41)
+ .build(changeGetter(c -> c.getKey().get()));
+
+ public static final IndexedField<ChangeData, String>.SearchSpec CHANGE_ID_SPEC =
+ CHANGE_ID_FIELD.prefix(ChangeQueryBuilder.FIELD_CHANGE_ID);
/** Change status string, in the same format as {@code status:}. */
- public static final FieldDef<ChangeData, String> STATUS =
- exact(ChangeQueryBuilder.FIELD_STATUS)
+ public static final IndexedField<ChangeData, String> STATUS_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Status")
+ .required()
+ .size(20)
.build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
+ public static final IndexedField<ChangeData, String>.SearchSpec STATUS_SPEC =
+ STATUS_FIELD.exact(ChangeQueryBuilder.FIELD_STATUS);
+
/** Project containing the change. */
- public static final FieldDef<ChangeData, String> PROJECT =
- exact(ChangeQueryBuilder.FIELD_PROJECT)
+ public static final IndexedField<ChangeData, String> PROJECT_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Project")
+ .required()
.stored()
+ .size(200)
.build(changeGetter(c -> c.getProject().get()));
+ public static final IndexedField<ChangeData, String>.SearchSpec PROJECT_SPEC =
+ PROJECT_FIELD.exact(ChangeQueryBuilder.FIELD_PROJECT);
+
/** Project containing the change, as a prefix field. */
- public static final FieldDef<ChangeData, String> PROJECTS =
- prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get()));
+ public static final IndexedField<ChangeData, String>.SearchSpec PROJECTS_SPEC =
+ PROJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PROJECTS);
/** Reference (aka branch) the change will submit onto. */
- public static final FieldDef<ChangeData, String> REF =
- exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().branch()));
+ public static final IndexedField<ChangeData, String> REF_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Ref")
+ .required()
+ .size(300)
+ .build(changeGetter(c -> c.getDest().branch()));
+
+ public static final IndexedField<ChangeData, String>.SearchSpec REF_SPEC =
+ REF_FIELD.exact(ChangeQueryBuilder.FIELD_REF);
/** Topic, a short annotation on the branch. */
- public static final FieldDef<ChangeData, String> EXACT_TOPIC =
- exact("topic4").build(ChangeField::getTopic);
+ public static final IndexedField<ChangeData, String> TOPIC_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Topic").size(500).build(ChangeField::getTopic);
+
+ public static final IndexedField<ChangeData, String>.SearchSpec EXACT_TOPIC =
+ TOPIC_FIELD.exact("topic4");
/** Topic, a short annotation on the branch. */
- public static final FieldDef<ChangeData, String> FUZZY_TOPIC =
- fullText("topic5").build(ChangeField::getTopic);
+ public static final IndexedField<ChangeData, String>.SearchSpec FUZZY_TOPIC =
+ TOPIC_FIELD.fullText("topic5");
/** Topic, a short annotation on the branch. */
- public static final FieldDef<ChangeData, String> PREFIX_TOPIC =
- prefix("topic6").build(ChangeField::getTopic);
+ public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_TOPIC =
+ TOPIC_FIELD.prefix("topic6");
+
+ /** {@link com.google.gerrit.entities.SubmissionId} assigned by MergeOp. */
+ public static final IndexedField<ChangeData, String> SUBMISSIONID_FIELD =
+ IndexedField.<ChangeData>stringBuilder("SubmissionId")
+ .size(500)
+ .build(changeGetter(Change::getSubmissionId));
- /** Submission id assigned by MergeOp. */
- public static final FieldDef<ChangeData, String> SUBMISSIONID =
- exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId));
+ public static final IndexedField<ChangeData, String>.SearchSpec SUBMISSIONID_SPEC =
+ SUBMISSIONID_FIELD.exact(ChangeQueryBuilder.FIELD_SUBMISSIONID);
/** Last update time since January 1, 1970. */
// TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
- public static final FieldDef<ChangeData, Timestamp> UPDATED =
- timestamp("updated2")
+ public static final IndexedField<ChangeData, Timestamp> UPDATED_FIELD =
+ IndexedField.<ChangeData>timestampBuilder("LastUpdated")
.stored()
.build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
+ public static final IndexedField<ChangeData, Timestamp>.SearchSpec UPDATED_SPEC =
+ UPDATED_FIELD.timestamp("updated2");
+
/** When this change was merged, time since January 1, 1970. */
// TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
- public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
- timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
+ public static final IndexedField<ChangeData, Timestamp> MERGED_ON_FIELD =
+ IndexedField.<ChangeData>timestampBuilder("MergedOn")
.stored()
.build(
cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
(cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
+ public static final IndexedField<ChangeData, Timestamp>.SearchSpec MERGED_ON_SPEC =
+ MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
+
/** List of full file paths modified in the current patch set. */
- public static final FieldDef<ChangeData, Iterable<String>> PATH =
- // Named for backwards compatibility.
- exact(ChangeQueryBuilder.FIELD_FILE)
- .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+ public static final IndexedField<ChangeData, Iterable<String>> PATH_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("ModifiedFile")
+ .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PATH_SPEC =
+ PATH_FIELD
+ // Named for backwards compatibility.
+ .exact(ChangeQueryBuilder.FIELD_FILE);
public static Set<String> getFileParts(ChangeData cd) {
List<String> paths = cd.currentFilePaths();
@@ -205,24 +249,27 @@ public class ChangeField {
}
/** Hashtags tied to a change */
- public static final FieldDef<ChangeData, Iterable<String>> HASHTAG =
- exact(ChangeQueryBuilder.FIELD_HASHTAG)
- .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<String>> HASHTAG_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Hashtag")
+ .size(200)
+ .build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec HASHTAG_SPEC =
+ HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
/** Hashtags as fulltext field for in-string search. */
- public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
- fullText("hashtag2")
- .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FUZZY_HASHTAG =
+ HASHTAG_FIELD.fullText("hashtag2");
/** Hashtags as prefix field for in-string search. */
- public static final FieldDef<ChangeData, Iterable<String>> PREFIX_HASHTAG =
- prefix("hashtag3")
- .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PREFIX_HASHTAG =
+ HASHTAG_FIELD.prefix("hashtag3");
/** Hashtags with original case. */
- public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
- storedOnly("_hashtag")
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE_FIELD =
+ IndexedField.<ChangeData>iterableByteArrayBuilder("HashtagCaseAware")
+ .stored()
+ .build(
cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
(cd, field) ->
cd.setHashtags(
@@ -230,13 +277,24 @@ public class ChangeField {
.map(f -> new String(f, UTF_8))
.collect(toImmutableSet())));
+ public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+ HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
+
/** Components of each file path modified in the current patch set. */
- public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
- exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts);
+ public static final IndexedField<ChangeData, Iterable<String>> FILE_PART_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("FilePart").build(ChangeField::getFileParts);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FILE_PART_SPEC =
+ FILE_PART_FIELD.exact(ChangeQueryBuilder.FIELD_FILEPART);
/** File extensions of each file modified in the current patch set. */
- public static final FieldDef<ChangeData, Iterable<String>> EXTENSION =
- exact(ChangeQueryBuilder.FIELD_EXTENSION).buildRepeatable(ChangeField::getExtensions);
+ public static final IndexedField<ChangeData, Iterable<String>> EXTENSION_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Extension")
+ .size(100)
+ .build(ChangeField::getExtensions);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXTENSION_SPEC =
+ EXTENSION_FIELD.exact(ChangeQueryBuilder.FIELD_EXTENSION);
public static Set<String> getExtensions(ChangeData cd) {
return extensions(cd).collect(toSet());
@@ -246,8 +304,12 @@ public class ChangeField {
* File extensions of each file modified in the current patch set as a sorted list. The purpose of
* this field is to allow matching changes that only touch files with certain file extensions.
*/
- public static final FieldDef<ChangeData, String> ONLY_EXTENSIONS =
- exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS).build(ChangeField::getAllExtensionsAsList);
+ public static final IndexedField<ChangeData, String> ONLY_EXTENSIONS_FIELD =
+ IndexedField.<ChangeData>stringBuilder("OnlyExtensions")
+ .build(ChangeField::getAllExtensionsAsList);
+
+ public static final IndexedField<ChangeData, String>.SearchSpec ONLY_EXTENSIONS_SPEC =
+ ONLY_EXTENSIONS_FIELD.exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS);
public static String getAllExtensionsAsList(ChangeData cd) {
return extensions(cd).distinct().sorted().collect(joining(","));
@@ -271,8 +333,11 @@ public class ChangeField {
}
/** Footers from the commit message of the current patch set. */
- public static final FieldDef<ChangeData, Iterable<String>> FOOTER =
- exact(ChangeQueryBuilder.FIELD_FOOTER).buildRepeatable(ChangeField::getFooters);
+ public static final IndexedField<ChangeData, Iterable<String>> FOOTER_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Footer").build(ChangeField::getFooters);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_SPEC =
+ FOOTER_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER);
public static Set<String> getFooters(ChangeData cd) {
return cd.commitFooters().stream()
@@ -281,16 +346,23 @@ public class ChangeField {
}
/** Footers from the commit message of the current patch set. */
- public static final FieldDef<ChangeData, Iterable<String>> FOOTER_NAME =
- exact(ChangeQueryBuilder.FIELD_FOOTER_NAME).buildRepeatable(ChangeField::getFootersNames);
+ public static final IndexedField<ChangeData, Iterable<String>> FOOTER_NAME_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("FooterName")
+ .build(ChangeField::getFootersNames);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec FOOTER_NAME =
+ FOOTER_NAME_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER_NAME);
public static Set<String> getFootersNames(ChangeData cd) {
return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
}
/** Folders that are touched by the current patch set. */
- public static final FieldDef<ChangeData, Iterable<String>> DIRECTORY =
- exact(ChangeQueryBuilder.FIELD_DIRECTORY).buildRepeatable(ChangeField::getDirectories);
+ public static final IndexedField<ChangeData, Iterable<String>> DIRECTORY_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("DirField").build(ChangeField::getDirectories);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec DIRECTORY_SPEC =
+ DIRECTORY_FIELD.exact(ChangeQueryBuilder.FIELD_DIRECTORY);
public static Set<String> getDirectories(ChangeData cd) {
List<String> paths = cd.currentFilePaths();
@@ -325,31 +397,47 @@ public class ChangeField {
}
/** Owner/creator of the change. */
- public static final FieldDef<ChangeData, Integer> OWNER =
- integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
+ public static final IndexedField<ChangeData, Integer> OWNER_FIELD =
+ IndexedField.<ChangeData>integerBuilder("Owner")
+ .required()
+ .build(changeGetter(c -> c.getOwner().get()));
+
+ public static final IndexedField<ChangeData, Integer>.SearchSpec OWNER_SPEC =
+ OWNER_FIELD.integer(ChangeQueryBuilder.FIELD_OWNER);
/** Uploader of the latest patch set. */
- public static final FieldDef<ChangeData, Integer> UPLOADER =
- integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+ public static final IndexedField<ChangeData, Integer> UPLOADER_FIELD =
+ IndexedField.<ChangeData>integerBuilder("Uploader")
+ .required()
+ .build(cd -> cd.currentPatchSet().uploader().get());
+
+ public static final IndexedField<ChangeData, Integer>.SearchSpec UPLOADER_SPEC =
+ UPLOADER_FIELD.integer(ChangeQueryBuilder.FIELD_UPLOADER);
/** References the source change number that this change was cherry-picked from. */
- public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
- integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
+ public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_CHANGE_FIELD =
+ IndexedField.<ChangeData>integerBuilder("CherryPickOfChange")
.build(
cd ->
cd.change().getCherryPickOf() != null
? cd.change().getCherryPickOf().changeId().get()
: null);
+ public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_CHANGE =
+ CHERRY_PICK_OF_CHANGE_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE);
+
/** References the source change patch-set that this change was cherry-picked from. */
- public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET =
- integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET)
+ public static final IndexedField<ChangeData, Integer> CHERRY_PICK_OF_PATCHSET_FIELD =
+ IndexedField.<ChangeData>integerBuilder("CherryPickOfPatchset")
.build(
cd ->
cd.change().getCherryPickOf() != null
? cd.change().getCherryPickOf().get()
: null);
+ public static final IndexedField<ChangeData, Integer>.SearchSpec CHERRY_PICK_OF_PATCHSET =
+ CHERRY_PICK_OF_PATCHSET_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET);
+
/** This class decouples the internal and API types from storage. */
private static class StoredAttentionSetEntry {
final long timestampMillis;
@@ -374,25 +462,35 @@ public class ChangeField {
* Users included in the attention set of the change. This omits timestamp, reason and possible
* future fields.
*
- * @see #ATTENTION_SET_FULL
+ * @see #ATTENTION_SET_FULL_SPEC
*/
- public static final FieldDef<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS =
- integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
- .buildRepeatable(ChangeField::getAttentionSetUserIds);
+ public static final IndexedField<ChangeData, Iterable<Integer>> ATTENTION_SET_USERS_FIELD =
+ IndexedField.<ChangeData>iterableIntegerBuilder("AttentionSetUsers")
+ .build(ChangeField::getAttentionSetUserIds);
+
+ public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec ATTENTION_SET_USERS =
+ ATTENTION_SET_USERS_FIELD.integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS);
/** Number of changes that contain attention set. */
- public static final FieldDef<ChangeData, Integer> ATTENTION_SET_USERS_COUNT =
- intRange(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT)
+ public static final IndexedField<ChangeData, Integer> ATTENTION_SET_USERS_COUNT_FIELD =
+ IndexedField.<ChangeData>integerBuilder("AttentionSetUsersCount")
+ .stored()
.build(cd -> additionsOnly(cd.attentionSet()).size());
+ public static final IndexedField<ChangeData, Integer>.SearchSpec ATTENTION_SET_USERS_COUNT =
+ ATTENTION_SET_USERS_COUNT_FIELD.integerRange(
+ ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT);
+
/**
* The full attention set data including timestamp, reason and possible future fields.
*
* @see #ATTENTION_SET_USERS
*/
- public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
- storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL_FIELD =
+ IndexedField.<ChangeData>iterableByteArrayBuilder("AttentionSetFull")
+ .stored()
+ .required()
+ .build(
ChangeField::storedAttentionSet,
(cd, value) ->
parseAttentionSet(
@@ -401,61 +499,91 @@ public class ChangeField {
.collect(toImmutableSet()),
cd));
+ public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+ ATTENTION_SET_FULL_SPEC =
+ ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
+
/** The user assigned to the change. */
- public static final FieldDef<ChangeData, Integer> ASSIGNEE =
- integer(ChangeQueryBuilder.FIELD_ASSIGNEE)
- .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE));
+ // The getter always returns NO_ASSIGNEE, since assignee field is deprecated.
+ @Deprecated
+ public static final IndexedField<ChangeData, Integer> ASSIGNEE_FIELD =
+ IndexedField.<ChangeData>integerBuilder("Assignee").build(changeGetter(c -> NO_ASSIGNEE));
+
+ @Deprecated
+ public static final IndexedField<ChangeData, Integer>.SearchSpec ASSIGNEE_SPEC =
+ ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
/** Reviewer(s) associated with the change. */
- public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
- exact("reviewer2")
+ public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Reviewer")
.stored()
- .buildRepeatable(
+ .build(
cd -> getReviewerFieldValues(cd.reviewers()),
(cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_SPEC =
+ REVIEWER_FIELD.exact("reviewer2");
+
/** Reviewer(s) associated with the change that do not have a gerrit account. */
- public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
- exact("reviewer_by_email")
+ public static final IndexedField<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("ReviewerByEmail")
.stored()
- .buildRepeatable(
+ .build(
cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
(cd, field) ->
cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec REVIEWER_BY_EMAIL =
+ REVIEWER_BY_EMAIL_FIELD.exact("reviewer_by_email");
+
/** Reviewer(s) modified during change's current WIP phase. */
- public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
- exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
+ public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("PendingReviewer")
.stored()
- .buildRepeatable(
+ .build(
cd -> getReviewerFieldValues(cd.pendingReviewers()),
(cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec PENDING_REVIEWER_SPEC =
+ PENDING_REVIEWER_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER);
+
/** Reviewer(s) by email modified during change's current WIP phase. */
- public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
- exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
+ public static final IndexedField<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("PendingReviewerByEmail")
.stored()
- .buildRepeatable(
+ .build(
cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
(cd, field) ->
cd.setPendingReviewersByEmail(
parseReviewerByEmailFieldValues(cd.getId(), field)));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+ PENDING_REVIEWER_BY_EMAIL =
+ PENDING_REVIEWER_BY_EMAIL_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL);
+
/** References a change that this change reverts. */
- public static final FieldDef<ChangeData, Integer> REVERT_OF =
- integer(ChangeQueryBuilder.FIELD_REVERTOF)
+ public static final IndexedField<ChangeData, Integer> REVERT_OF_FIELD =
+ IndexedField.<ChangeData>integerBuilder("RevertOf")
.build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
- public static final FieldDef<ChangeData, String> IS_PURE_REVERT =
- fullText(ChangeQueryBuilder.FIELD_PURE_REVERT)
+ public static final IndexedField<ChangeData, Integer>.SearchSpec REVERT_OF =
+ REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
+
+ public static final IndexedField<ChangeData, String> IS_PURE_REVERT_FIELD =
+ IndexedField.<ChangeData>stringBuilder("IsPureRevert")
+ .size(1)
.build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
+ public static final IndexedField<ChangeData, String>.SearchSpec IS_PURE_REVERT_SPEC =
+ IS_PURE_REVERT_FIELD.fullText(ChangeQueryBuilder.FIELD_PURE_REVERT);
+
/**
* Determines if a change is submittable based on {@link
* com.google.gerrit.entities.SubmitRequirement}s.
*/
- public static final FieldDef<ChangeData, String> IS_SUBMITTABLE =
- exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE)
+ public static final IndexedField<ChangeData, String> IS_SUBMITTABLE_FIELD =
+ IndexedField.<ChangeData>stringBuilder("IsSubmittable")
+ .size(1)
.build(
cd ->
// All submit requirements should be fulfilled
@@ -464,6 +592,9 @@ public class ChangeField {
? "1"
: "0");
+ public static final IndexedField<ChangeData, String>.SearchSpec IS_SUBMITTABLE_SPEC =
+ IS_SUBMITTABLE_FIELD.exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE);
+
@VisibleForTesting
static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -639,25 +770,37 @@ public class ChangeField {
}
/** Commit ID of any patch set on the change, using prefix match. */
- public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
- prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
+ public static final IndexedField<ChangeData, Iterable<String>> COMMIT_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("CommitId")
+ .size(40)
+ .required()
+ .build(ChangeField::getRevisions);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMIT_SPEC =
+ COMMIT_FIELD.prefix(ChangeQueryBuilder.FIELD_COMMIT);
/** Commit ID of any patch set on the change, using exact match. */
- public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMIT =
- exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions);
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMIT_SPEC =
+ COMMIT_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT);
private static ImmutableSet<String> getRevisions(ChangeData cd) {
return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
}
/** Tracking id extracted from a footer. */
- public static final FieldDef<ChangeData, Iterable<String>> TR =
- exact(ChangeQueryBuilder.FIELD_TR)
- .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+ public static final IndexedField<ChangeData, Iterable<String>> TR_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("TrackingFooter")
+ .build(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec TR_SPEC =
+ TR_FIELD.exact(ChangeQueryBuilder.FIELD_TR);
/** List of labels on the current patch set including change owner votes. */
- public static final FieldDef<ChangeData, Iterable<String>> LABEL =
- exact("label2").buildRepeatable(cd -> getLabels(cd));
+ public static final IndexedField<ChangeData, Iterable<String>> LABEL_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Label").required().build(cd -> getLabels(cd));
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec LABEL_SPEC =
+ LABEL_FIELD.exact("label2");
private static Iterable<String> getLabels(ChangeData cd) {
Set<String> allApprovals = new HashSet<>();
@@ -800,41 +943,90 @@ public class ChangeField {
* The exact email address, or any part of the author name or email address, in the current patch
* set.
*/
- public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
- fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
+ public static final IndexedField<ChangeData, Iterable<String>> AUTHOR_PARTS_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("AuthorParts")
+ .required()
+ .description(
+ "The exact email address, or any part of the author name or email address, in the current patch set.")
+ .build(ChangeField::getAuthorParts);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec AUTHOR_PARTS_SPEC =
+ AUTHOR_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_AUTHOR);
/** The exact name, email address and NameEmail of the author. */
- public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
- exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
- .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+ public static final IndexedField<ChangeData, Iterable<String>> EXACT_AUTHOR_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("ExactAuthor")
+ .required()
+ .description("The exact name, email address and NameEmail of the author.")
+ .build(ChangeField::getAuthorNameAndEmail);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_AUTHOR_SPEC =
+ EXACT_AUTHOR_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR);
/**
* The exact email address, or any part of the committer name or email address, in the current
* patch set.
*/
- public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
- fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
+ public static final IndexedField<ChangeData, Iterable<String>> COMMITTER_PARTS_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("CommitterParts")
+ .description(
+ "The exact email address, or any part of the committer name or email address, in the current patch set.")
+ .required()
+ .build(ChangeField::getCommitterParts);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMITTER_PARTS_SPEC =
+ COMMITTER_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMITTER);
/** The exact name, email address, and NameEmail of the committer. */
- public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
- exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
- .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+ public static final IndexedField<ChangeData, Iterable<String>> EXACT_COMMITTER_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("ExactCommiter")
+ .required()
+ .description("The exact name, email address, and NameEmail of the committer.")
+ .build(ChangeField::getCommitterNameAndEmail);
+
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec EXACT_COMMITTER_SPEC =
+ EXACT_COMMITTER_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER);
/** Serialized change object, used for pre-populating results. */
- public static final FieldDef<ChangeData, byte[]> CHANGE =
- storedOnly("_change")
+ private static final TypeToken<Entities.Change> CHANGE_TYPE_TOKEN =
+ new TypeToken<>() {
+ private static final long serialVersionUID = 1L;
+ };
+
+ public static final IndexedField<ChangeData, Entities.Change> CHANGE_FIELD =
+ IndexedField.<ChangeData, Entities.Change>builder("Change", CHANGE_TYPE_TOKEN)
+ .stored()
+ .required()
+ .protoConverter(Optional.of(ChangeProtoConverter.INSTANCE))
.build(
- changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
- (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
+ changeGetter(change -> entityToProto(ChangeProtoConverter.INSTANCE, change)),
+ (cd, value) ->
+ cd.setChange(decodeProtoToEntity(value, ChangeProtoConverter.INSTANCE)));
+
+ public static final IndexedField<ChangeData, Entities.Change>.SearchSpec CHANGE_SPEC =
+ CHANGE_FIELD.storedOnly("_change");
/** Serialized approvals for the current patch set, used for pre-populating results. */
- public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
- storedOnly("_approval")
- .buildRepeatable(
- cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+ private static final TypeToken<Iterable<Entities.PatchSetApproval>> APPROVAL_TYPE_TOKEN =
+ new TypeToken<>() {
+ private static final long serialVersionUID = 1L;
+ };
+
+ public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>> APPROVAL_FIELD =
+ IndexedField.<ChangeData, Iterable<Entities.PatchSetApproval>>builder(
+ "Approval", APPROVAL_TYPE_TOKEN)
+ .stored()
+ .required()
+ .protoConverter(Optional.of(PatchSetApprovalProtoConverter.INSTANCE))
+ .build(
+ cd ->
+ entitiesToProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
(cd, field) ->
cd.setCurrentApprovals(
- decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
+ decodeProtosToEntities(field, PatchSetApprovalProtoConverter.INSTANCE)));
+
+ public static final IndexedField<ChangeData, Iterable<Entities.PatchSetApproval>>.SearchSpec
+ APPROVAL_SPEC = APPROVAL_FIELD.storedOnly("_approval");
public static String formatLabel(String label, int value) {
return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
@@ -850,7 +1042,7 @@ public class ChangeField {
public static String formatLabel(
String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
- return label.toLowerCase()
+ return label.toLowerCase(Locale.US)
+ (value >= 0 ? "+" : "")
+ value
+ (accountId != null ? "," + formatAccount(accountId) : "")
@@ -863,7 +1055,7 @@ public class ChangeField {
public static String formatLabel(
String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
- return label.toLowerCase()
+ return label.toLowerCase(Locale.US)
+ "="
+ value
+ (accountId != null ? "," + formatAccount(accountId) : "")
@@ -880,18 +1072,41 @@ public class ChangeField {
}
/** Commit message of the current patch set. */
- public static final FieldDef<ChangeData, String> COMMIT_MESSAGE =
- fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage);
-
- /** Commit message of the current patch set. */
- public static final FieldDef<ChangeData, String> COMMIT_MESSAGE_EXACT =
- exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT)
+ public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_FIELD =
+ IndexedField.<ChangeData>stringBuilder("CommitMessage")
+ .required()
+ .build(ChangeData::commitMessage);
+
+ public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE =
+ COMMIT_MESSAGE_FIELD.fullText(ChangeQueryBuilder.FIELD_MESSAGE);
+
+ /** Commit message of the current patch set, used to exactly match the commit message */
+ public static final IndexedField<ChangeData, String> COMMIT_MESSAGE_EXACT_FIELD =
+ IndexedField.<ChangeData>stringBuilder("CommitMessageExact")
+ .required()
+ .description(
+ "Same as CommitMessage, but truncated, since supporting such large tokens may be problematic for indexes.")
.build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
+ public static final IndexedField<ChangeData, String>.SearchSpec COMMIT_MESSAGE_EXACT =
+ COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
+
+ /** Subject of the current patch set (aka first line of the commit message). */
+ public static final IndexedField<ChangeData, String> SUBJECT_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Subject")
+ .required()
+ .build(changeGetter(Change::getSubject));
+
+ public static final IndexedField<ChangeData, String>.SearchSpec SUBJECT_SPEC =
+ SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
+
+ public static final IndexedField<ChangeData, String>.SearchSpec PREFIX_SUBJECT_SPEC =
+ SUBJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+
/** Summary or inline comment. */
- public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
- fullText(ChangeQueryBuilder.FIELD_COMMENT)
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<String>> COMMENT_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Comment")
+ .build(
cd ->
Stream.concat(
cd.publishedComments().stream().map(c -> c.message),
@@ -902,22 +1117,35 @@ public class ChangeField {
cd.messages().stream().map(ChangeMessage::getMessage))
.collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec COMMENT_SPEC =
+ COMMENT_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMENT);
+
/** Number of unresolved comment threads of the change, including robot comments. */
- public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
- intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
+ public static final IndexedField<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT_FIELD =
+ IndexedField.<ChangeData>integerBuilder("UnresolvedCommentCount")
+ .stored()
.build(
ChangeData::unresolvedCommentCount,
(cd, field) -> cd.setUnresolvedCommentCount(field));
+ public static final IndexedField<ChangeData, Integer>.SearchSpec UNRESOLVED_COMMENT_COUNT_SPEC =
+ UNRESOLVED_COMMENT_COUNT_FIELD.integerRange(
+ ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT);
+
/** Total number of published inline comments of the change, including robot comments. */
- public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
- intRange("total_comments")
+ public static final IndexedField<ChangeData, Integer> TOTAL_COMMENT_COUNT_FIELD =
+ IndexedField.<ChangeData>integerBuilder("TotalCommentCount")
+ .stored()
.build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
+ public static final IndexedField<ChangeData, Integer>.SearchSpec TOTAL_COMMENT_COUNT_SPEC =
+ TOTAL_COMMENT_COUNT_FIELD.integerRange("total_comments");
+
/** Whether the change is mergeable. */
- public static final FieldDef<ChangeData, String> MERGEABLE =
- exact(ChangeQueryBuilder.FIELD_MERGEABLE)
+ public static final IndexedField<ChangeData, String> MERGEABLE_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Mergeable")
.stored()
+ .size(1)
.build(
cd -> {
Boolean m = cd.isMergeable();
@@ -928,10 +1156,14 @@ public class ChangeField {
},
(cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
+ public static final IndexedField<ChangeData, String>.SearchSpec MERGEABLE_SPEC =
+ MERGEABLE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGEABLE);
+
/** Whether the change is a merge commit. */
- public static final FieldDef<ChangeData, String> MERGE =
- exact(ChangeQueryBuilder.FIELD_MERGE)
+ public static final IndexedField<ChangeData, String> MERGE_FIELD =
+ IndexedField.<ChangeData>stringBuilder("Merge")
.stored()
+ .size(1)
.build(
cd -> {
Boolean m = cd.isMerge();
@@ -941,15 +1173,23 @@ public class ChangeField {
return m ? "1" : "0";
});
+ public static final IndexedField<ChangeData, String>.SearchSpec MERGE_SPEC =
+ MERGE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGE);
+
/** Whether the change is a cherry pick of another change. */
- public static final FieldDef<ChangeData, String> CHERRY_PICK =
- exact(ChangeQueryBuilder.FIELD_CHERRYPICK)
+ public static final IndexedField<ChangeData, String> CHERRY_PICK_FIELD =
+ IndexedField.<ChangeData>stringBuilder("CherryPick")
.stored()
+ .size(1)
.build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
+ public static final IndexedField<ChangeData, String>.SearchSpec CHERRY_PICK_SPEC =
+ CHERRY_PICK_FIELD.exact(ChangeQueryBuilder.FIELD_CHERRYPICK);
+
/** The number of inserted lines in this change. */
- public static final FieldDef<ChangeData, Integer> ADDED =
- intRange(ChangeQueryBuilder.FIELD_ADDED)
+ public static final IndexedField<ChangeData, Integer> ADDED_LINES_FIELD =
+ IndexedField.<ChangeData>integerBuilder("AddedLines")
+ .stored()
.build(
cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
(cd, field) -> {
@@ -958,9 +1198,13 @@ public class ChangeField {
}
});
+ public static final IndexedField<ChangeData, Integer>.SearchSpec ADDED_LINES_SPEC =
+ ADDED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_ADDED);
+
/** The number of deleted lines in this change. */
- public static final FieldDef<ChangeData, Integer> DELETED =
- intRange(ChangeQueryBuilder.FIELD_DELETED)
+ public static final IndexedField<ChangeData, Integer> DELETED_LINES_FIELD =
+ IndexedField.<ChangeData>integerBuilder("DeletedLines")
+ .stored()
.build(
cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
(cd, field) -> {
@@ -969,28 +1213,49 @@ public class ChangeField {
}
});
+ public static final IndexedField<ChangeData, Integer>.SearchSpec DELETED_LINES_SPEC =
+ DELETED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELETED);
+
/** The total number of modified lines in this change. */
- public static final FieldDef<ChangeData, Integer> DELTA =
- intRange(ChangeQueryBuilder.FIELD_DELTA)
+ public static final IndexedField<ChangeData, Integer> DELTA_LINES_FIELD =
+ IndexedField.<ChangeData>integerBuilder("DeltaLines")
+ .stored()
.build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
+ public static final IndexedField<ChangeData, Integer>.SearchSpec DELTA_LINES_SPEC =
+ DELTA_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_DELTA);
+
/** Determines if this change is private. */
- public static final FieldDef<ChangeData, String> PRIVATE =
- exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+ public static final IndexedField<ChangeData, String> PRIVATE_FIELD =
+ IndexedField.<ChangeData>stringBuilder("IsPrivate")
+ .size(1)
+ .build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+ public static final IndexedField<ChangeData, String>.SearchSpec PRIVATE_SPEC =
+ PRIVATE_FIELD.exact(ChangeQueryBuilder.FIELD_PRIVATE);
/** Determines if this change is work in progress. */
- public static final FieldDef<ChangeData, String> WIP =
- exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+ public static final IndexedField<ChangeData, String> WIP_FIELD =
+ IndexedField.<ChangeData>stringBuilder("WIP")
+ .size(1)
+ .build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
+ public static final IndexedField<ChangeData, String>.SearchSpec WIP_SPEC =
+ WIP_FIELD.exact(ChangeQueryBuilder.FIELD_WIP);
/** Determines if this change has started review. */
- public static final FieldDef<ChangeData, String> STARTED =
- exact(ChangeQueryBuilder.FIELD_STARTED)
+ public static final IndexedField<ChangeData, String> STARTED_FIELD =
+ IndexedField.<ChangeData>stringBuilder("ReviewStarted")
+ .size(1)
.build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
+ public static final IndexedField<ChangeData, String>.SearchSpec STARTED_SPEC =
+ STARTED_FIELD.exact(ChangeQueryBuilder.FIELD_STARTED);
+
/** Users who have commented on this change. */
- public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
- integer(ChangeQueryBuilder.FIELD_COMMENTBY)
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<Integer>> COMMENTBY_FIELD =
+ IndexedField.<ChangeData>iterableIntegerBuilder("CommentBy")
+ .build(
cd ->
Stream.concat(
cd.messages().stream().map(ChangeMessage::getAuthor),
@@ -999,11 +1264,14 @@ public class ChangeField {
.map(Account.Id::get)
.collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec COMMENTBY_SPEC =
+ COMMENTBY_FIELD.integer(ChangeQueryBuilder.FIELD_COMMENTBY);
+
/** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
- public static final FieldDef<ChangeData, Iterable<String>> STAR =
- exact(ChangeQueryBuilder.FIELD_STAR)
+ public static final IndexedField<ChangeData, Iterable<String>> STAR_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Star")
.stored()
- .buildRepeatable(
+ .build(
cd ->
Iterables.transform(
cd.stars().entries(),
@@ -1015,33 +1283,61 @@ public class ChangeField {
.map(f -> StarredChangesUtil.StarField.parse(f))
.collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec STAR_SPEC =
+ STAR_FIELD.exact(ChangeQueryBuilder.FIELD_STAR);
+
/** Users that have starred the change with any label. */
- public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
- integer(ChangeQueryBuilder.FIELD_STARBY)
- .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+ public static final IndexedField<ChangeData, Iterable<Integer>> STARBY_FIELD =
+ IndexedField.<ChangeData>iterableIntegerBuilder("StarBy")
+ .build(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get));
+
+ public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec STARBY_SPEC =
+ STARBY_FIELD.integer(ChangeQueryBuilder.FIELD_STARBY);
/** Opaque group identifiers for this change's patch sets. */
- public static final FieldDef<ChangeData, Iterable<String>> GROUP =
- exact(ChangeQueryBuilder.FIELD_GROUP)
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<String>> GROUP_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("Group")
+ .build(
cd -> cd.patchSets().stream().flatMap(ps -> ps.groups().stream()).collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec GROUP_SPEC =
+ GROUP_FIELD.exact(ChangeQueryBuilder.FIELD_GROUP);
+
/** Serialized patch set object, used for pre-populating results. */
- public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
- storedOnly("_patch_set")
- .buildRepeatable(
- cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
- (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
+ private static final TypeToken<Iterable<Entities.PatchSet>> PATCH_SET_TYPE_TOKEN =
+ new TypeToken<>() {
+ private static final long serialVersionUID = 1L;
+ };
+
+ public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>> PATCH_SET_FIELD =
+ IndexedField.<ChangeData, Iterable<Entities.PatchSet>>builder(
+ "PatchSet", PATCH_SET_TYPE_TOKEN)
+ .stored()
+ .required()
+ .protoConverter(Optional.of(PatchSetProtoConverter.INSTANCE))
+ .build(
+ cd -> entitiesToProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+ (cd, value) ->
+ cd.setPatchSets(decodeProtosToEntities(value, PatchSetProtoConverter.INSTANCE)));
+
+ public static final IndexedField<ChangeData, Iterable<Entities.PatchSet>>.SearchSpec
+ PATCH_SET_SPEC = PATCH_SET_FIELD.storedOnly("_patch_set");
/** Users who have edits on this change. */
- public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
- integer(ChangeQueryBuilder.FIELD_EDITBY)
- .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<Integer>> EDITBY_FIELD =
+ IndexedField.<ChangeData>iterableIntegerBuilder("EditBy")
+ .build(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+ public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec EDITBY_SPEC =
+ EDITBY_FIELD.integer(ChangeQueryBuilder.FIELD_EDITBY);
/** Users who have draft comments on this change. */
- public static final FieldDef<ChangeData, Iterable<Integer>> DRAFTBY =
- integer(ChangeQueryBuilder.FIELD_DRAFTBY)
- .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+ public static final IndexedField<ChangeData, Iterable<Integer>> DRAFTBY_FIELD =
+ IndexedField.<ChangeData>iterableIntegerBuilder("DraftBy")
+ .build(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet()));
+
+ public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec DRAFTBY_SPEC =
+ DRAFTBY_FIELD.integer(ChangeQueryBuilder.FIELD_DRAFTBY);
public static final Integer NOT_REVIEWED = -1;
@@ -1055,10 +1351,10 @@ public class ChangeField {
* <p>If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is
* emitted.
*/
- public static final FieldDef<ChangeData, Iterable<Integer>> REVIEWEDBY =
- integer(ChangeQueryBuilder.FIELD_REVIEWEDBY)
+ public static final IndexedField<ChangeData, Iterable<Integer>> REVIEWEDBY_FIELD =
+ IndexedField.<ChangeData>iterableIntegerBuilder("ReviewedBy")
.stored()
- .buildRepeatable(
+ .build(
cd -> {
Set<Account.Id> reviewedBy = cd.reviewedBy();
if (reviewedBy.isEmpty()) {
@@ -1072,6 +1368,9 @@ public class ChangeField {
.map(Account::id)
.collect(toImmutableSet())));
+ public static final IndexedField<ChangeData, Iterable<Integer>>.SearchSpec REVIEWEDBY_SPEC =
+ REVIEWEDBY_FIELD.integer(ChangeQueryBuilder.FIELD_REVIEWEDBY);
+
public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
@@ -1079,9 +1378,9 @@ public class ChangeField {
SubmitRuleOptions.builder().build();
/** All submit rules results in the form of "$ruleName,$status". */
- public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT =
- exact("submit_rule_result")
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("SubmitRuleResult")
+ .build(
cd -> {
List<String> result = new ArrayList<>();
List<SubmitRecord> submitRecords = cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT);
@@ -1091,6 +1390,9 @@ public class ChangeField {
return result;
});
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec
+ SUBMIT_RULE_RESULT_SPEC = SUBMIT_RULE_RESULT_FIELD.exact("submit_rule_result");
+
/**
* JSON type for storing SubmitRecords.
*
@@ -1177,12 +1479,17 @@ public class ChangeField {
}
}
- public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RECORD =
- exact("submit_record").buildRepeatable(ChangeField::formatSubmitRecordValues);
+ public static final IndexedField<ChangeData, Iterable<String>> SUBMIT_RECORD_FIELD =
+ IndexedField.<ChangeData>iterableStringBuilder("SubmitRecord")
+ .build(ChangeField::formatSubmitRecordValues);
- public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
- storedOnly("full_submit_record_strict")
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<String>>.SearchSpec SUBMIT_RECORD_SPEC =
+ SUBMIT_RECORD_FIELD.exact("submit_record");
+
+ public static final IndexedField<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT_FIELD =
+ IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordStrict")
+ .stored()
+ .build(
cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
(cd, field) ->
parseSubmitRecords(
@@ -1192,17 +1499,27 @@ public class ChangeField {
SUBMIT_RULE_OPTIONS_STRICT,
cd));
- public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
- storedOnly("full_submit_record_lenient")
- .buildRepeatable(
- cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
- (cd, field) ->
- parseSubmitRecords(
- StreamSupport.stream(field.spliterator(), false)
- .map(f -> new String(f, UTF_8))
- .collect(toSet()),
- SUBMIT_RULE_OPTIONS_LENIENT,
- cd));
+ public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+ STORED_SUBMIT_RECORD_STRICT_SPEC =
+ STORED_SUBMIT_RECORD_STRICT_FIELD.storedOnly("full_submit_record_strict");
+
+ public static final IndexedField<ChangeData, Iterable<byte[]>>
+ STORED_SUBMIT_RECORD_LENIENT_FIELD =
+ IndexedField.<ChangeData>iterableByteArrayBuilder("FullSubmitRecordLenient")
+ .stored()
+ .build(
+ cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+ (cd, field) ->
+ parseSubmitRecords(
+ StreamSupport.stream(field.spliterator(), false)
+ .map(f -> new String(f, UTF_8))
+ .collect(toSet()),
+ SUBMIT_RULE_OPTIONS_LENIENT,
+ cd));
+
+ public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec
+ STORED_SUBMIT_RECORD_LENIENT_SPEC =
+ STORED_SUBMIT_RECORD_LENIENT_FIELD.storedOnly("full_submit_record_lenient");
public static void parseSubmitRecords(
Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -1250,7 +1567,7 @@ public class ChangeField {
continue;
}
for (SubmitRecord.Label label : rec.labels) {
- String sl = label.status.toString() + ',' + label.label.toLowerCase();
+ String sl = label.status.toString() + ',' + label.label.toLowerCase(Locale.US);
result.add(sl);
String slc = sl + ',';
if (label.appliedBy != null) {
@@ -1279,50 +1596,63 @@ public class ChangeField {
result.add(
SubmitRecord.Label.Status.OK.name()
+ ","
- + srResult.submitRequirement().name().toLowerCase());
+ + srResult.submitRequirement().name().toLowerCase(Locale.US));
result.add(
SubmitRecord.Label.Status.MAY.name()
+ ","
- + srResult.submitRequirement().name().toLowerCase());
+ + srResult.submitRequirement().name().toLowerCase(Locale.US));
break;
case UNSATISFIED:
result.add(
SubmitRecord.Label.Status.NEED.name()
+ ","
- + srResult.submitRequirement().name().toLowerCase());
+ + srResult.submitRequirement().name().toLowerCase(Locale.US));
result.add(
SubmitRecord.Label.Status.REJECT.name()
+ ","
- + srResult.submitRequirement().name().toLowerCase());
+ + srResult.submitRequirement().name().toLowerCase(Locale.US));
break;
case NOT_APPLICABLE:
case ERROR:
result.add(
SubmitRecord.Label.Status.IMPOSSIBLE.name()
+ ","
- + srResult.submitRequirement().name().toLowerCase());
+ + srResult.submitRequirement().name().toLowerCase(Locale.US));
}
}
return result;
}
/** Serialized submit requirements, used for pre-populating results. */
- public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
- storedOnly("full_submit_requirements")
- .buildRepeatable(
- cd ->
- toProtos(
- SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
- (cd, field) -> parseSubmitRequirements(field, cd));
-
- private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
+ private static final TypeToken<Iterable<Cache.SubmitRequirementResultProto>>
+ STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN =
+ new TypeToken<>() {
+ private static final long serialVersionUID = 1L;
+ };
+
+ public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+ STORED_SUBMIT_REQUIREMENTS_FIELD =
+ IndexedField.<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>builder(
+ "StoredSubmitRequirements", STORED_SUBMIT_REQUIREMENTS_TYPE_TOKEN)
+ .stored()
+ .required()
+ .protoConverter(Optional.of(SubmitRequirementProtoConverter.INSTANCE))
+ .build(
+ cd ->
+ entitiesToProtos(
+ SubmitRequirementProtoConverter.INSTANCE,
+ cd.submitRequirements().values()),
+ (cd, value) -> parseSubmitRequirements(value, cd));
+
+ public static final IndexedField<ChangeData, Iterable<Cache.SubmitRequirementResultProto>>
+ .SearchSpec
+ STORED_SUBMIT_REQUIREMENTS_SPEC =
+ STORED_SUBMIT_REQUIREMENTS_FIELD.storedOnly("full_submit_requirements");
+
+ private static void parseSubmitRequirements(
+ Iterable<Cache.SubmitRequirementResultProto> values, ChangeData out) {
out.setSubmitRequirements(
- StreamSupport.stream(values.spliterator(), false)
- .map(
- f ->
- SubmitRequirementProtoConverter.INSTANCE.fromProto(
- Protos.parseUnchecked(
- SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+ decodeProtosToEntities(values, SubmitRequirementProtoConverter.INSTANCE).stream()
.filter(sr -> !sr.isLegacy())
.collect(
ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
@@ -1333,9 +1663,10 @@ public class ChangeField {
*
* <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
*/
- public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
- storedOnly("ref_state")
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_FIELD =
+ IndexedField.<ChangeData>iterableByteArrayBuilder("RefState")
+ .stored()
+ .build(
cd -> {
List<byte[]> result = new ArrayList<>();
cd.getRefStates()
@@ -1345,15 +1676,19 @@ public class ChangeField {
},
(cd, field) -> cd.setRefStates(RefState.parseStates(field)));
+ public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_SPEC =
+ REF_STATE_FIELD.storedOnly("ref_state");
+
/**
* All ref wildcard patterns that were used in the course of indexing this document.
*
* <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link
* RefStatePattern} for the pattern format.
*/
- public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
- storedOnly("ref_state_pattern")
- .buildRepeatable(
+ public static final IndexedField<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN_FIELD =
+ IndexedField.<ChangeData>iterableByteArrayBuilder("RefStatePattern")
+ .stored()
+ .build(
cd -> {
Change.Id id = cd.getId();
Project.NameKey project = cd.change().getProject();
@@ -1372,6 +1707,10 @@ public class ChangeField {
},
(cd, field) -> cd.setRefStatePatterns(field));
+ public static final IndexedField<ChangeData, Iterable<byte[]>>.SearchSpec REF_STATE_PATTERN_SPEC =
+ REF_STATE_PATTERN_FIELD.storedOnly("ref_state_pattern");
+
+ @Nullable
private static String getTopic(ChangeData cd) {
Change c = cd.change();
if (c == null) {
@@ -1380,24 +1719,28 @@ public class ChangeField {
return firstNonNull(c.getTopic(), "");
}
- private static <T> List<byte[]> toProtos(ProtoConverter<?, T> converter, Collection<T> objects) {
- return objects.stream().map(object -> toProto(converter, object)).collect(toImmutableList());
+ private static <V extends MessageLite, T> V entityToProto(
+ ProtoConverter<V, T> converter, T object) {
+ return converter.toProto(object);
}
- private static <T> byte[] toProto(ProtoConverter<?, T> converter, T object) {
- return Protos.toByteArray(converter.toProto(object));
+ private static <V extends MessageLite, T> List<V> entitiesToProtos(
+ ProtoConverter<V, T> converter, Collection<T> objects) {
+ return objects.stream()
+ .map(object -> entityToProto(converter, object))
+ .collect(toImmutableList());
}
- private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
+ private static <V extends MessageLite, T> List<T> decodeProtosToEntities(
+ Iterable<V> raw, ProtoConverter<V, T> converter) {
return StreamSupport.stream(raw.spliterator(), false)
- .map(bytes -> parseProtoFrom(bytes, converter))
+ .map(proto -> decodeProtoToEntity(proto, converter))
.collect(toImmutableList());
}
- private static <P extends MessageLite, T> T parseProtoFrom(
- byte[] bytes, ProtoConverter<P, T> converter) {
- P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
- return converter.fromProto(message);
+ private static <V extends MessageLite, T> T decodeProtoToEntity(
+ V proto, ProtoConverter<V, T> converter) {
+ return converter.fromProto(proto);
}
private static <T> SchemaFieldDefs.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 6fc2665030..74e9af1dcb 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -20,6 +20,7 @@ import com.google.gerrit.index.IndexDefinition;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangePredicates;
+import java.util.function.Function;
/**
* Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -32,4 +33,6 @@ public interface ChangeIndex extends Index<Change.Id, ChangeData> {
default Predicate<ChangeData> keyPredicate(Change.Id id) {
return ChangePredicates.idStr(id);
}
+
+ Function<ChangeData, Change.Id> ENTITY_TO_KEY = ChangeData::getId;
}
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index 05fb7802fc..bb4b24c0ee 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -188,6 +188,7 @@ public class ChangeIndexRewriter implements IndexRewriter<ChangeData> {
* @throws QueryParseException if the underlying index implementation does not support this
* predicate.
*/
+ @Nullable
private Predicate<ChangeData> rewriteImpl(
Predicate<ChangeData> in, ChangeIndex index, QueryOptions opts, MutableInteger leafTerms)
throws QueryParseException {
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index a30e4a60a7..517809a0df 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -46,6 +46,7 @@ import com.google.inject.assistedinject.AssistedInject;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
@@ -299,9 +300,9 @@ public class ChangeIndexer {
* @param id change to delete.
* @return future for the deleting task, the result of the future is always {@code null}
*/
- public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
+ public ListenableFuture<ChangeData> deleteAsync(Project.NameKey project, Change.Id id) {
fireChangeScheduledForDeletionFromIndexEvent(id.get());
- return submit(new DeleteTask(id));
+ return submit(new DeleteTask(id, Optional.of(project)));
}
/**
@@ -314,8 +315,12 @@ public class ChangeIndexer {
doDelete(id);
}
+ private void doDelete(Project.NameKey project, Change.Id id) {
+ new DeleteTask(id, Optional.of(project)).call();
+ }
+
private void doDelete(Change.Id id) {
- new DeleteTask(id).call();
+ new DeleteTask(id, Optional.empty()).call();
}
/**
@@ -424,6 +429,7 @@ public class ChangeIndexer {
return future;
}
+ @Nullable
@Override
public ChangeData callImpl() throws Exception {
// Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
@@ -439,7 +445,7 @@ public class ChangeIndexer {
doIndex(changeData);
return changeData;
} catch (NoSuchChangeException e) {
- doDelete(id);
+ doDelete(project, id);
}
return null;
}
@@ -472,11 +478,14 @@ public class ChangeIndexer {
// Not AbstractIndexTask as it doesn't need a request context.
private class DeleteTask implements Callable<ChangeData> {
private final Change.Id id;
+ private final Optional<Project.NameKey> project;
- private DeleteTask(Change.Id id) {
+ private DeleteTask(Change.Id id, Optional<Project.NameKey> project) {
this.id = id;
+ this.project = project;
}
+ @Nullable
@Override
public ChangeData call() {
logger.atFine().log("Delete change %d from index.", id.get());
@@ -491,7 +500,12 @@ public class ChangeIndexer {
.changeId(id.get())
.indexVersion(i.getSchema().getVersion())
.build())) {
- i.delete(id);
+ // Some index implementation require ProjectKey to build a database key
+ // If delete(K) method is used, this will require changeId -> projectKey lookup (index
+ // query), which is expensive.
+ // Use changeData with ProjectKey and deleteByValue(V) method, if possible
+ project.ifPresentOrElse(
+ p -> i.deleteByValue(changeDataFactory.create(p, id)), () -> i.delete(id));
} catch (RuntimeException e) {
throw new StorageException(
String.format(
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 6116f5ab60..e74ce8f4e3 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -16,6 +16,8 @@ package com.google.gerrit.server.index.change;
import static com.google.gerrit.index.SchemaUtil.schema;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.Schema;
import com.google.gerrit.index.SchemaDefinitions;
import com.google.gerrit.server.query.change.ChangeData;
@@ -27,84 +29,155 @@ import com.google.gerrit.server.query.change.ChangeData;
* com.google.gerrit.index.IndexUpgradeValidator}.
*/
public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
- /** Added new field {@link ChangeField#IS_SUBMITTABLE} based on submit requirements. */
+ /** Added new field {@link ChangeField#IS_SUBMITTABLE_SPEC} based on submit requirements. */
@Deprecated
static final Schema<ChangeData> V74 =
schema(
/* version= */ 74,
- ChangeField.ADDED,
- ChangeField.APPROVAL,
- ChangeField.ASSIGNEE,
- ChangeField.ATTENTION_SET_FULL,
- ChangeField.ATTENTION_SET_USERS,
- ChangeField.ATTENTION_SET_USERS_COUNT,
- ChangeField.AUTHOR,
- ChangeField.CHANGE,
- ChangeField.CHERRY_PICK,
- ChangeField.CHERRY_PICK_OF_CHANGE,
- ChangeField.CHERRY_PICK_OF_PATCHSET,
- ChangeField.COMMENT,
- ChangeField.COMMENTBY,
- ChangeField.COMMIT,
- ChangeField.COMMIT_MESSAGE,
- ChangeField.COMMITTER,
- ChangeField.DELETED,
- ChangeField.DELTA,
- ChangeField.DIRECTORY,
- ChangeField.DRAFTBY,
- ChangeField.EDITBY,
- ChangeField.EXACT_AUTHOR,
- ChangeField.EXACT_COMMIT,
- ChangeField.EXACT_COMMITTER,
- ChangeField.EXACT_TOPIC,
- ChangeField.EXTENSION,
- ChangeField.FILE_PART,
- ChangeField.FOOTER,
- ChangeField.FUZZY_HASHTAG,
- ChangeField.FUZZY_TOPIC,
- ChangeField.GROUP,
- ChangeField.HASHTAG,
- ChangeField.HASHTAG_CASE_AWARE,
- ChangeField.ID,
- ChangeField.IS_PURE_REVERT,
- ChangeField.IS_SUBMITTABLE,
- ChangeField.LABEL,
- ChangeField.LEGACY_ID_STR,
- ChangeField.MERGE,
- ChangeField.MERGEABLE,
- ChangeField.MERGED_ON,
- ChangeField.ONLY_EXTENSIONS,
- ChangeField.OWNER,
- ChangeField.PATCH_SET,
- ChangeField.PATH,
- ChangeField.PENDING_REVIEWER,
- ChangeField.PENDING_REVIEWER_BY_EMAIL,
- ChangeField.PRIVATE,
- ChangeField.PROJECT,
- ChangeField.PROJECTS,
- ChangeField.REF,
- ChangeField.REF_STATE,
- ChangeField.REF_STATE_PATTERN,
- ChangeField.REVERT_OF,
- ChangeField.REVIEWEDBY,
- ChangeField.REVIEWER,
- ChangeField.REVIEWER_BY_EMAIL,
- ChangeField.STAR,
- ChangeField.STARBY,
- ChangeField.STARTED,
- ChangeField.STATUS,
- ChangeField.STORED_SUBMIT_RECORD_LENIENT,
- ChangeField.STORED_SUBMIT_RECORD_STRICT,
- ChangeField.STORED_SUBMIT_REQUIREMENTS,
- ChangeField.SUBMISSIONID,
- ChangeField.SUBMIT_RECORD,
- ChangeField.SUBMIT_RULE_RESULT,
- ChangeField.TOTAL_COMMENT_COUNT,
- ChangeField.TR,
- ChangeField.UNRESOLVED_COMMENT_COUNT,
- ChangeField.UPDATED,
- ChangeField.UPLOADER,
- ChangeField.WIP);
+ ImmutableList.<IndexedField<ChangeData, ?>>of(
+ ChangeField.ADDED_LINES_FIELD,
+ ChangeField.APPROVAL_FIELD,
+ ChangeField.ASSIGNEE_FIELD,
+ ChangeField.ATTENTION_SET_FULL_FIELD,
+ ChangeField.ATTENTION_SET_USERS_COUNT_FIELD,
+ ChangeField.ATTENTION_SET_USERS_FIELD,
+ ChangeField.AUTHOR_PARTS_FIELD,
+ ChangeField.CHANGE_FIELD,
+ ChangeField.CHANGE_ID_FIELD,
+ ChangeField.CHERRY_PICK_FIELD,
+ ChangeField.CHERRY_PICK_OF_CHANGE_FIELD,
+ ChangeField.CHERRY_PICK_OF_PATCHSET_FIELD,
+ ChangeField.COMMENTBY_FIELD,
+ ChangeField.COMMENT_FIELD,
+ ChangeField.COMMITTER_PARTS_FIELD,
+ ChangeField.COMMIT_FIELD,
+ ChangeField.COMMIT_MESSAGE_FIELD,
+ ChangeField.DELETED_LINES_FIELD,
+ ChangeField.DELTA_LINES_FIELD,
+ ChangeField.DIRECTORY_FIELD,
+ ChangeField.DRAFTBY_FIELD,
+ ChangeField.EDITBY_FIELD,
+ ChangeField.EXACT_AUTHOR_FIELD,
+ ChangeField.EXACT_COMMITTER_FIELD,
+ ChangeField.EXTENSION_FIELD,
+ ChangeField.FILE_PART_FIELD,
+ ChangeField.FOOTER_FIELD,
+ ChangeField.GROUP_FIELD,
+ ChangeField.HASHTAG_CASE_AWARE_FIELD,
+ ChangeField.HASHTAG_FIELD,
+ ChangeField.IS_PURE_REVERT_FIELD,
+ ChangeField.IS_SUBMITTABLE_FIELD,
+ ChangeField.LABEL_FIELD,
+ ChangeField.MERGEABLE_FIELD,
+ ChangeField.MERGED_ON_FIELD,
+ ChangeField.MERGE_FIELD,
+ ChangeField.NUMERIC_ID_STR_FIELD,
+ ChangeField.ONLY_EXTENSIONS_FIELD,
+ ChangeField.OWNER_FIELD,
+ ChangeField.PATCH_SET_FIELD,
+ ChangeField.PATH_FIELD,
+ ChangeField.PENDING_REVIEWER_BY_EMAIL_FIELD,
+ ChangeField.PENDING_REVIEWER_FIELD,
+ ChangeField.PRIVATE_FIELD,
+ ChangeField.PROJECT_FIELD,
+ ChangeField.REF_FIELD,
+ ChangeField.REF_STATE_FIELD,
+ ChangeField.REF_STATE_PATTERN_FIELD,
+ ChangeField.REVERT_OF_FIELD,
+ ChangeField.REVIEWEDBY_FIELD,
+ ChangeField.REVIEWER_BY_EMAIL_FIELD,
+ ChangeField.REVIEWER_FIELD,
+ ChangeField.STARBY_FIELD,
+ ChangeField.STARTED_FIELD,
+ ChangeField.STAR_FIELD,
+ ChangeField.STATUS_FIELD,
+ ChangeField.STORED_SUBMIT_RECORD_LENIENT_FIELD,
+ ChangeField.STORED_SUBMIT_RECORD_STRICT_FIELD,
+ ChangeField.STORED_SUBMIT_REQUIREMENTS_FIELD,
+ ChangeField.SUBMISSIONID_FIELD,
+ ChangeField.SUBMIT_RECORD_FIELD,
+ ChangeField.SUBMIT_RULE_RESULT_FIELD,
+ ChangeField.TOPIC_FIELD,
+ ChangeField.TOTAL_COMMENT_COUNT_FIELD,
+ ChangeField.TR_FIELD,
+ ChangeField.UNRESOLVED_COMMENT_COUNT_FIELD,
+ ChangeField.UPDATED_FIELD,
+ ChangeField.UPLOADER_FIELD,
+ ChangeField.WIP_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.ADDED_LINES_SPEC,
+ ChangeField.APPROVAL_SPEC,
+ ChangeField.ASSIGNEE_SPEC,
+ ChangeField.ATTENTION_SET_FULL_SPEC,
+ ChangeField.ATTENTION_SET_USERS,
+ ChangeField.ATTENTION_SET_USERS_COUNT,
+ ChangeField.AUTHOR_PARTS_SPEC,
+ ChangeField.CHANGE_ID_SPEC,
+ ChangeField.CHANGE_SPEC,
+ ChangeField.CHERRY_PICK_OF_CHANGE,
+ ChangeField.CHERRY_PICK_OF_PATCHSET,
+ ChangeField.CHERRY_PICK_SPEC,
+ ChangeField.COMMENTBY_SPEC,
+ ChangeField.COMMENT_SPEC,
+ ChangeField.COMMITTER_PARTS_SPEC,
+ ChangeField.COMMIT_MESSAGE,
+ ChangeField.COMMIT_SPEC,
+ ChangeField.DELETED_LINES_SPEC,
+ ChangeField.DELTA_LINES_SPEC,
+ ChangeField.DIRECTORY_SPEC,
+ ChangeField.DRAFTBY_SPEC,
+ ChangeField.EDITBY_SPEC,
+ ChangeField.EXACT_AUTHOR_SPEC,
+ ChangeField.EXACT_COMMITTER_SPEC,
+ ChangeField.EXACT_COMMIT_SPEC,
+ ChangeField.EXACT_TOPIC,
+ ChangeField.EXTENSION_SPEC,
+ ChangeField.FILE_PART_SPEC,
+ ChangeField.FOOTER_SPEC,
+ ChangeField.FUZZY_HASHTAG,
+ ChangeField.FUZZY_TOPIC,
+ ChangeField.GROUP_SPEC,
+ ChangeField.HASHTAG_CASE_AWARE_SPEC,
+ ChangeField.HASHTAG_SPEC,
+ ChangeField.IS_PURE_REVERT_SPEC,
+ ChangeField.IS_SUBMITTABLE_SPEC,
+ ChangeField.LABEL_SPEC,
+ ChangeField.MERGEABLE_SPEC,
+ ChangeField.MERGED_ON_SPEC,
+ ChangeField.MERGE_SPEC,
+ ChangeField.NUMERIC_ID_STR_SPEC,
+ ChangeField.ONLY_EXTENSIONS_SPEC,
+ ChangeField.OWNER_SPEC,
+ ChangeField.PATCH_SET_SPEC,
+ ChangeField.PATH_SPEC,
+ ChangeField.PENDING_REVIEWER_BY_EMAIL,
+ ChangeField.PENDING_REVIEWER_SPEC,
+ ChangeField.PRIVATE_SPEC,
+ ChangeField.PROJECTS_SPEC,
+ ChangeField.PROJECT_SPEC,
+ ChangeField.REF_SPEC,
+ ChangeField.REF_STATE_PATTERN_SPEC,
+ ChangeField.REF_STATE_SPEC,
+ ChangeField.REVERT_OF,
+ ChangeField.REVIEWEDBY_SPEC,
+ ChangeField.REVIEWER_BY_EMAIL,
+ ChangeField.REVIEWER_SPEC,
+ ChangeField.STARBY_SPEC,
+ ChangeField.STARTED_SPEC,
+ ChangeField.STAR_SPEC,
+ ChangeField.STATUS_SPEC,
+ ChangeField.STORED_SUBMIT_RECORD_LENIENT_SPEC,
+ ChangeField.STORED_SUBMIT_RECORD_STRICT_SPEC,
+ ChangeField.STORED_SUBMIT_REQUIREMENTS_SPEC,
+ ChangeField.SUBMISSIONID_SPEC,
+ ChangeField.SUBMIT_RECORD_SPEC,
+ ChangeField.SUBMIT_RULE_RESULT_SPEC,
+ ChangeField.TOTAL_COMMENT_COUNT_SPEC,
+ ChangeField.TR_SPEC,
+ ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC,
+ ChangeField.UPDATED_SPEC,
+ ChangeField.UPLOADER_SPEC,
+ ChangeField.WIP_SPEC));
/**
* Added new field {@link ChangeField#PREFIX_HASHTAG} and {@link ChangeField#PREFIX_TOPIC} to
@@ -114,29 +187,66 @@ public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
static final Schema<ChangeData> V75 =
new Schema.Builder<ChangeData>()
.add(V74)
- .add(ChangeField.PREFIX_HASHTAG)
- .add(ChangeField.PREFIX_TOPIC)
+ .addSearchSpecs(ChangeField.PREFIX_HASHTAG)
+ .addSearchSpecs(ChangeField.PREFIX_TOPIC)
.build();
/** Added new field {@link ChangeField#FOOTER_NAME}. */
@Deprecated
static final Schema<ChangeData> V76 =
- new Schema.Builder<ChangeData>().add(V75).add(ChangeField.FOOTER_NAME).build();
+ new Schema.Builder<ChangeData>()
+ .add(V75)
+ .addIndexedFields(ChangeField.FOOTER_NAME_FIELD)
+ .addSearchSpecs(ChangeField.FOOTER_NAME)
+ .build();
/** Added new field {@link ChangeField#COMMIT_MESSAGE_EXACT}. */
@Deprecated
static final Schema<ChangeData> V77 =
- new Schema.Builder<ChangeData>().add(V76).add(ChangeField.COMMIT_MESSAGE_EXACT).build();
+ new Schema.Builder<ChangeData>()
+ .add(V76)
+ .addIndexedFields(ChangeField.COMMIT_MESSAGE_EXACT_FIELD)
+ .addSearchSpecs(ChangeField.COMMIT_MESSAGE_EXACT)
+ .build();
// Upgrade Lucene to 7.x requires reindexing.
@Deprecated static final Schema<ChangeData> V78 = schema(V77);
/** Remove draft and star fields. */
+ @Deprecated
static final Schema<ChangeData> V79 =
new Schema.Builder<ChangeData>()
.add(V78)
- .remove(ChangeField.DRAFTBY, ChangeField.STAR, ChangeField.STARBY)
+ .remove(ChangeField.STAR_SPEC, ChangeField.STARBY_SPEC, ChangeField.DRAFTBY_SPEC)
+ .remove(ChangeField.STAR_FIELD, ChangeField.STARBY_FIELD, ChangeField.DRAFTBY_FIELD)
+ .build();
+
+ /** Add subject field. */
+ @Deprecated
+ static final Schema<ChangeData> V80 =
+ new Schema.Builder<ChangeData>()
+ .add(V79)
+ .addIndexedFields(ChangeField.SUBJECT_FIELD)
+ .addSearchSpecs(ChangeField.SUBJECT_SPEC)
.build();
+
+ /** Add prefixsubject field. */
+ @Deprecated
+ static final Schema<ChangeData> V81 =
+ new Schema.Builder<ChangeData>()
+ .add(V80)
+ .addSearchSpecs(ChangeField.PREFIX_SUBJECT_SPEC)
+ .build();
+
+ /** Remove assignee field. */
+ @SuppressWarnings("deprecation")
+ static final Schema<ChangeData> V82 =
+ new Schema.Builder<ChangeData>()
+ .add(V81)
+ .remove(ChangeField.ASSIGNEE_SPEC)
+ .remove(ChangeField.ASSIGNEE_FIELD)
+ .build();
+
/**
* Name of the change index to be used when contacting index backends or loading configurations.
*/
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index 339d7bb272..8f5e36e837 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -15,8 +15,9 @@
package com.google.gerrit.server.index.change;
import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
-import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
@@ -68,10 +69,13 @@ public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
int pageSizeMultiplier,
int limit,
Set<String> fields) {
- // Always include project since it is needed to load the change from NoteDb.
- if (!fields.contains(CHANGE.getName()) && !fields.contains(PROJECT.getName())) {
+ // Always include project and change id since both are needed to load the change from NoteDb.
+ if (!fields.contains(CHANGE_SPEC.getName())
+ && !(fields.contains(PROJECT_SPEC.getName())
+ && fields.contains(NUMERIC_ID_STR_SPEC.getName()))) {
fields = new HashSet<>(fields);
- fields.add(PROJECT.getName());
+ fields.add(PROJECT_SPEC.getName());
+ fields.add(NUMERIC_ID_STR_SPEC.getName());
}
return QueryOptions.create(config, start, pageSize, pageSizeMultiplier, limit, fields);
}
@@ -176,6 +180,6 @@ public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
@Override
public boolean hasChange() {
- return index.getSchema().hasField(ChangeField.CHANGE);
+ return index.getSchema().hasField(ChangeField.CHANGE_SPEC);
}
}
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index ad5cc2b7b1..eb4af01c5d 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -56,9 +56,9 @@ public class StalenessChecker {
public static final ImmutableSet<String> FIELDS =
ImmutableSet.of(
- ChangeField.CHANGE.getName(),
- ChangeField.REF_STATE.getName(),
- ChangeField.REF_STATE_PATTERN.getName());
+ ChangeField.CHANGE_SPEC.getName(),
+ ChangeField.REF_STATE_SPEC.getName(),
+ ChangeField.REF_STATE_PATTERN_SPEC.getName());
private final ChangeIndexCollection indexes;
private final GitRepositoryManager repoManager;
@@ -82,8 +82,8 @@ public class StalenessChecker {
return StalenessCheckResult
.notStale(); // No index; caller couldn't do anything if it is stale.
}
- if (!i.getSchema().hasField(ChangeField.REF_STATE)
- || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN)) {
+ if (!i.getSchema().hasField(ChangeField.REF_STATE_SPEC)
+ || !i.getSchema().hasField(ChangeField.REF_STATE_PATTERN_SPEC)) {
return StalenessCheckResult.notStale(); // Index version not new enough for this check.
}
diff --git a/java/com/google/gerrit/server/index/group/GroupIndex.java b/java/com/google/gerrit/server/index/group/GroupIndex.java
index 28c0384686..f6a9224481 100644
--- a/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -20,6 +20,7 @@ import com.google.gerrit.index.Index;
import com.google.gerrit.index.IndexDefinition;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.query.group.GroupPredicates;
+import java.util.function.Function;
/**
* Index for internal Gerrit groups. This class is mainly used for typing the generic parent class
@@ -33,4 +34,6 @@ public interface GroupIndex extends Index<AccountGroup.UUID, InternalGroup> {
default Predicate<InternalGroup> keyPredicate(AccountGroup.UUID uuid) {
return GroupPredicates.uuid(uuid);
}
+
+ Function<InternalGroup, AccountGroup.UUID> ENTITY_TO_KEY = (g) -> g.getGroupUUID();
}
diff --git a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 26f9e9608e..f0f351038e 100644
--- a/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -33,7 +33,6 @@ public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
static final Schema<InternalGroup> V5 =
schema(
/* version= */ 5,
- ImmutableList.of(),
ImmutableList.of(
GroupField.CREATED_ON_FIELD,
GroupField.DESCRIPTION_FIELD,
diff --git a/java/com/google/gerrit/server/index/project/StalenessChecker.java b/java/com/google/gerrit/server/index/project/StalenessChecker.java
index 9c44c003dc..b2e24e46f4 100644
--- a/java/com/google/gerrit/server/index/project/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/project/StalenessChecker.java
@@ -40,7 +40,7 @@ import java.util.Optional;
*/
public class StalenessChecker {
private static final ImmutableSet<String> FIELDS =
- ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+ ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE_SPEC.getName());
private final ProjectCache projectCache;
private final ProjectIndexCollection indexes;
@@ -74,7 +74,7 @@ public class StalenessChecker {
}
SetMultimap<Project.NameKey, RefState> indexedRefStates =
- RefState.parseStates(result.get().getValue(ProjectField.REF_STATE));
+ RefState.parseStates(result.get().getValue(ProjectField.REF_STATE_SPEC));
SetMultimap<Project.NameKey, RefState> currentRefStates =
MultimapBuilder.hashKeys().hashSetValues().build();
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD
index fd0c4f10eb..6b9ecdfaf1 100644
--- a/java/com/google/gerrit/server/ioutil/BUILD
+++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -5,6 +5,7 @@ java_library(
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//lib:automaton",
"//lib:guava",
diff --git a/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 296cf22c5f..b43655a10d 100644
--- a/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -31,6 +31,7 @@ package com.google.gerrit.server.ioutil;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.CodedEnum;
import java.io.EOFException;
import java.io.IOException;
@@ -129,6 +130,7 @@ public class BasicSerialization {
}
/** Read a UTF-8 string, prefixed by its byte length in a varint. */
+ @Nullable
public static String readString(InputStream input) throws IOException {
final byte[] bin = readBytes(input);
if (bin.length == 0) {
diff --git a/java/com/google/gerrit/server/ioutil/HostPlatform.java b/java/com/google/gerrit/server/ioutil/HostPlatform.java
index e27d17c491..b912c52ad2 100644
--- a/java/com/google/gerrit/server/ioutil/HostPlatform.java
+++ b/java/com/google/gerrit/server/ioutil/HostPlatform.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.ioutil;
import java.security.AccessController;
import java.security.PrivilegedAction;
+import java.util.Locale;
public final class HostPlatform {
private static final boolean win32 = compute("windows");
@@ -34,7 +35,7 @@ public final class HostPlatform {
final String osDotName =
AccessController.doPrivileged(
(PrivilegedAction<String>) () -> System.getProperty("os.name"));
- return osDotName != null && osDotName.toLowerCase().contains(platform);
+ return osDotName != null && osDotName.toLowerCase(Locale.US).contains(platform);
}
private HostPlatform() {}
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index c659b5ffdb..50f26bb317 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -31,7 +31,6 @@ import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
import com.google.gerrit.server.mail.send.RestoredSender;
import com.google.gerrit.server.mail.send.RevertedSender;
-import com.google.gerrit.server.mail.send.SetAssigneeSender;
public class EmailModule extends FactoryModule {
@Override
@@ -50,7 +49,6 @@ public class EmailModule extends FactoryModule {
factory(ReplacePatchSetSender.Factory.class);
factory(RestoredSender.Factory.class);
factory(RevertedSender.Factory.class);
- factory(SetAssigneeSender.Factory.class);
factory(AddToAttentionSetSender.Factory.class);
factory(RemoveFromAttentionSetSender.Factory.class);
}
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index 15b61d0180..c411af5ce1 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -38,7 +38,6 @@ public class EmailSettings {
public final Encryption encryption;
public final long fetchInterval; // in milliseconds
public final boolean sendNewPatchsetEmails;
- public final boolean isAttentionSetEnabled;
@Inject
EmailSettings(@GerritServerConfig Config cfg) {
@@ -61,6 +60,5 @@ public class EmailSettings {
TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
TimeUnit.MILLISECONDS);
sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
- isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", true);
}
}
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index e362c4bcdd..93da9972f3 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.mail.receive;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.util.stream.Collectors.toList;
import com.google.common.base.Strings;
@@ -69,6 +70,7 @@ import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.gerrit.server.util.time.TimeUtil;
@@ -85,7 +87,13 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
-/** A service that can attach the comments from a {@link MailMessage} to a change. */
+/**
+ * Users can post comments on gerrit changes by replying directly to gerrit emails. This service
+ * parses the {@link MailMessage} sent by users and attaches the comments to a change.
+ *
+ * <p>This functionality can be configured or disabled by host. See {@link
+ * com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule}
+ */
@Singleton
public class MailProcessor {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -313,9 +321,11 @@ public class MailProcessor {
}
Op o = new Op(PatchSet.id(cd.getId(), metadata.patchSet), parsedComments, message.id());
- BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
- batchUpdate.addOp(cd.getId(), o);
- batchUpdate.execute();
+ try (RefUpdateContext updCtx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ BatchUpdate batchUpdate = buf.create(project, ctx.getUser(), TimeUtil.now());
+ batchUpdate.addOp(cd.getId(), o);
+ batchUpdate.execute();
+ }
}
}
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 23e1cc34df..a308168001 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -131,27 +131,20 @@ public abstract class MailReceiver implements LifecycleListener {
if (async) {
@SuppressWarnings("unused")
Future<?> possiblyIgnoredError =
- workQueue
- .getDefaultQueue()
- .submit(
- () -> {
- try {
- mailProcessor.process(m);
- requestDeletion(m.id());
- } catch (RestApiException | UpdateException e) {
- logger.atSevere().withCause(e).log(
- "Mail: Can't process message %s . Won't delete.", m.id());
- }
- });
+ workQueue.getDefaultQueue().submit(() -> processMessage(m));
} else {
// Synchronous processing is used only in tests.
- try {
- mailProcessor.process(m);
- requestDeletion(m.id());
- } catch (RestApiException | UpdateException e) {
- logger.atSevere().withCause(e).log("Mail: Can't process messages. Won't delete.");
- }
+ processMessage(m);
}
}
}
+
+ private void processMessage(MailMessage m) {
+ try {
+ mailProcessor.process(m);
+ requestDeletion(m.id());
+ } catch (RestApiException | UpdateException e) {
+ logger.atSevere().withCause(e).log("Mail: Can't process message %s . Won't delete.", m.id());
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/mail/send/AddKeySender.java b/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 652766ae69..73a46a488d 100644
--- a/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.mail.send;
import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.server.IdentifiedUser;
@@ -67,7 +68,7 @@ public class AddKeySender extends OutgoingEmail {
super.init();
setHeader("Subject", String.format("[Gerrit Code Review] New %s Keys Added", getKeyType()));
setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
- add(RecipientType.TO, user.getAccountId());
+ addByAccountId(RecipientType.TO, user.getAccountId());
}
@Override
@@ -111,10 +112,12 @@ public class AddKeySender extends OutgoingEmail {
return "Unknown";
}
+ @Nullable
private String getSshKey() {
return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
}
+ @Nullable
private String getGpgKeys() {
if (gpgKeys != null) {
return Joiner.on("\n").join(gpgKeys);
diff --git a/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
new file mode 100644
index 0000000000..acba4eafec
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/BranchEmailUtils.java
@@ -0,0 +1,98 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.mail.MailHeader;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/** Contains utils for email notification related to the events on project+branch. */
+class BranchEmailUtils {
+
+ /** Set a reasonable list id so that filters can be used to sort messages. */
+ static void setListIdHeader(OutgoingEmail email, BranchNameKey branch) {
+ email.setHeader(
+ "List-Id",
+ "<gerrit-" + branch.project().get().replace('/', '-') + "." + email.getGerritHost() + ">");
+ if (email.getSettingsUrl() != null) {
+ email.setHeader("List-Unsubscribe", "<" + email.getSettingsUrl() + ">");
+ }
+ }
+
+ /** Add branch information to soy template params. */
+ static void addBranchData(OutgoingEmail email, EmailArguments args, BranchNameKey branch) {
+ Map<String, Object> soyContext = email.getSoyContext();
+ Map<String, Object> soyContextEmailData = email.getSoyContextEmailData();
+
+ String projectName = branch.project().get();
+ soyContext.put("projectName", projectName);
+ // shortProjectName is the project name with the path abbreviated.
+ soyContext.put("shortProjectName", getShortProjectName(projectName));
+
+ // instanceAndProjectName is the instance's name followed by the abbreviated project path
+ soyContext.put(
+ "instanceAndProjectName",
+ getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
+ soyContext.put("addInstanceNameInSubject", args.addInstanceNameInSubject);
+
+ soyContextEmailData.put("sshHost", getSshHost(email.getGerritHost(), args.sshAddresses));
+
+ Map<String, String> branchData = new HashMap<>();
+ branchData.put("shortName", branch.shortName());
+ soyContext.put("branch", branchData);
+
+ email.addFooter(MailHeader.PROJECT.withDelimiter() + branch.project().get());
+ email.addFooter(MailHeader.BRANCH.withDelimiter() + branch.shortName());
+ }
+
+ @Nullable
+ private static String getSshHost(String gerritHost, List<String> sshAddresses) {
+ String host = Iterables.getFirst(sshAddresses, null);
+ if (host == null) {
+ return null;
+ }
+ if (host.startsWith("*:")) {
+ return gerritHost + host.substring(1);
+ }
+ return host;
+ }
+
+ /** Shortens project/repo name to only show part after the last '/'. */
+ static String getShortProjectName(String projectName) {
+ int lastIndexSlash = projectName.lastIndexOf('/');
+ if (lastIndexSlash == 0) {
+ return projectName.substring(1); // Remove the first slash
+ }
+ if (lastIndexSlash == -1) { // No slash in the project name
+ return projectName;
+ }
+
+ return "..." + projectName.substring(lastIndexSlash + 1);
+ }
+
+ /** Returns a project/repo name that includes instance as prefix. */
+ static String getInstanceAndProjectName(String instanceName, String projectName) {
+ if (instanceName == null || instanceName.isEmpty()) {
+ return getShortProjectName(projectName);
+ }
+ // Extract the project name (everything after the last slash) and prepends it with gerrit's
+ // instance name
+ return instanceName + "/" + projectName.substring(projectName.lastIndexOf('/') + 1);
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 8be5548227..62471acde8 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -24,7 +24,9 @@ import com.google.common.collect.ListMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeSizeBucket;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
@@ -42,6 +44,7 @@ import com.google.gerrit.mail.MailHeader;
import com.google.gerrit.server.StarredChangesUtil;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
+import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.DiffNotAvailableException;
import com.google.gerrit.server.patch.DiffOptions;
@@ -77,7 +80,7 @@ import org.eclipse.jgit.util.RawParseUtils;
import org.eclipse.jgit.util.TemporaryBuffer;
/** Sends an email to one or more interested parties. */
-public abstract class ChangeEmail extends NotificationEmail {
+public abstract class ChangeEmail extends OutgoingEmail {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -99,19 +102,25 @@ public abstract class ChangeEmail extends NotificationEmail {
protected PatchSetInfo patchSetInfo;
protected String changeMessage;
protected Instant timestamp;
+ protected BranchNameKey branch;
protected ProjectState projectState;
- protected Set<Account.Id> authors;
- protected boolean emailOnlyAuthors;
+ private Set<Account.Id> authors;
+ private boolean emailOnlyAuthors;
protected boolean emailOnlyAttentionSetIfEnabled;
+ // Watchers ignore attention set rules.
+ protected Set<Account.Id> watcherAccounts = new HashSet<>();
+ // Watcher can only be an email if it's specified in notify section of ProjectConfig.
+ protected Set<Address> watcherEmails = new HashSet<>();
protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
- super(args, messageClass, changeData.change().getDest());
+ super(args, messageClass);
this.changeData = changeData;
change = changeData.change();
emailOnlyAuthors = false;
emailOnlyAttentionSetIfEnabled = true;
currentAttentionSet = getAttentionSet();
+ branch = changeData.change().getDest();
}
@Override
@@ -192,7 +201,6 @@ public abstract class ChangeEmail extends NotificationEmail {
}
}
}
- authors = getAuthors();
try {
stars = changeData.stars();
@@ -201,6 +209,7 @@ public abstract class ChangeEmail extends NotificationEmail {
}
super.init();
+ BranchEmailUtils.setListIdHeader(this, branch);
if (timestamp != null) {
setHeader(FieldName.DATE, timestamp);
}
@@ -211,13 +220,13 @@ public abstract class ChangeEmail extends NotificationEmail {
setChangeUrlHeader();
setCommitIdHeader();
- if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+ if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
+ || notify.handling().equals(NotifyHandling.ALL)) {
try {
- addByEmail(
- RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
- addByEmail(
- RecipientType.CC,
- changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+ changeData.reviewersByEmail().byState(ReviewerStateInternal.CC).stream()
+ .forEach(address -> addByEmail(RecipientType.CC, address));
+ changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER).stream()
+ .forEach(address -> addByEmail(RecipientType.CC, address));
} catch (StorageException e) {
throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
}
@@ -242,7 +251,9 @@ public abstract class ChangeEmail extends NotificationEmail {
}
private int getInsertionsCount() {
- return listModifiedFiles().values().stream()
+ return listModifiedFiles().entrySet().stream()
+ .filter(e -> !Patch.COMMIT_MSG.equals(e.getKey()))
+ .map(Map.Entry::getValue)
.map(FileDiffOutput::insertions)
.reduce(0, Integer::sum);
}
@@ -323,8 +334,8 @@ public abstract class ChangeEmail extends NotificationEmail {
+ "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
+ "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
+ "\n",
- modifiedFiles.size() - 1, //
- getInsertionsCount(), //
+ modifiedFiles.size() - 1, // -1 to account for the commit message
+ getInsertionsCount(),
getDeletionsCount()));
detail.append("\n");
}
@@ -373,9 +384,9 @@ public abstract class ChangeEmail extends NotificationEmail {
}
/** TO or CC all vested parties (change owner, patch set uploader, author). */
- protected void rcptToAuthors(RecipientType rt) {
- for (Account.Id id : authors) {
- add(rt, id);
+ protected void addAuthors(RecipientType rt) {
+ for (Account.Id id : getAuthors()) {
+ addByAccountId(rt, id);
}
}
@@ -387,13 +398,45 @@ public abstract class ChangeEmail extends NotificationEmail {
for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
- super.add(RecipientType.BCC, e.getKey());
+ super.addByAccountId(RecipientType.BCC, e.getKey());
}
}
}
- @Override
- protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+ /** Include users and groups that want notification of events. */
+ protected void includeWatchers(NotifyType type) {
+ includeWatchers(type, true);
+ }
+
+ /** Include users and groups that want notification of events. */
+ protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
+ try {
+ Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
+ addWatchers(RecipientType.TO, matching.to);
+ addWatchers(RecipientType.CC, matching.cc);
+ addWatchers(RecipientType.BCC, matching.bcc);
+ } catch (StorageException err) {
+ // Just don't CC everyone. Better to send a partial message to those
+ // we already have queued up then to fail deliver entirely to people
+ // who have a lower interest in the change.
+ logger.atWarning().withCause(err).log("Cannot BCC watchers for %s", type);
+ }
+ }
+
+ /** Add users or email addresses to the TO, CC, or BCC list. */
+ private void addWatchers(RecipientType type, WatcherList watcherList) {
+ watcherAccounts.addAll(watcherList.accounts);
+ for (Account.Id user : watcherList.accounts) {
+ addByAccountId(type, user);
+ }
+
+ watcherEmails.addAll(watcherList.emails);
+ for (Address addr : watcherList.emails) {
+ addByEmail(type, addr);
+ }
+ }
+
+ private final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
if (!NotifyHandling.ALL.equals(notify.handling())) {
return new Watchers();
}
@@ -411,7 +454,7 @@ public abstract class ChangeEmail extends NotificationEmail {
try {
for (Account.Id id : changeData.reviewers().all()) {
- add(RecipientType.CC, id);
+ addByAccountId(RecipientType.CC, id);
}
} catch (StorageException err) {
logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
@@ -427,7 +470,7 @@ public abstract class ChangeEmail extends NotificationEmail {
try {
for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
- add(RecipientType.CC, id);
+ addByAccountId(RecipientType.CC, id);
}
} catch (StorageException err) {
logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
@@ -435,43 +478,54 @@ public abstract class ChangeEmail extends NotificationEmail {
}
@Override
- protected void add(RecipientType rt, Account.Id to) {
- addRecipient(rt, to, /* isWatcher= */ false);
- }
+ protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+ if (!projectState.statePermitsRead()) {
+ return false;
+ }
+ if (emailOnlyAuthors) {
+ return false;
+ }
- /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
- @Override
- protected void addWatcher(RecipientType rt, Account.Id to) {
- addRecipient(rt, to, /* isWatcher= */ true);
+ // If the email is a watcher email, skip permission check. An email can only be a watcher if
+ // it is specified in notify section of ProjectConfig, so we trust that the recipient is
+ // allowed.
+ if (watcherEmails.contains(addr)) {
+ return true;
+ }
+ return args.permissionBackend
+ .user(args.anonymousUser.get())
+ .change(changeData)
+ .test(ChangePermission.READ);
}
- private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
- if (!isWatcher) {
+ @Override
+ protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
+ if (!projectState.statePermitsRead()) {
+ return false;
+ }
+ if (emailOnlyAuthors && !getAuthors().contains(to)) {
+ return false;
+ }
+ // Watchers ignore AttentionSet rules.
+ if (!watcherAccounts.contains(to)) {
Optional<AccountState> accountState = args.accountCache.get(to);
if (emailOnlyAttentionSetIfEnabled
&& accountState.isPresent()
&& accountState.get().generalPreferences().getEmailStrategy()
== EmailStrategy.ATTENTION_SET_ONLY
&& !currentAttentionSet.contains(to)) {
- return;
+ return false;
}
}
- if (emailOnlyAuthors && !authors.contains(to)) {
- return;
- }
- super.add(rt, to);
- }
- @Override
- protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
- if (!projectState.statePermitsRead()) {
- return false;
- }
return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
}
- /** Find all users who are authors of any part of this change. */
+ /** Lazily finds all users who are authors of any part of this change. */
protected Set<Account.Id> getAuthors() {
+ if (this.authors != null) {
+ return this.authors;
+ }
Set<Account.Id> authors = new HashSet<>();
switch (notify.handling()) {
@@ -497,12 +551,13 @@ public abstract class ChangeEmail extends NotificationEmail {
break;
}
- return authors;
+ return this.authors = authors;
}
@Override
protected void setupSoyContext() {
super.setupSoyContext();
+ BranchEmailUtils.addBranchData(this, args, branch);
soyContext.put("changeId", change.getKey().get());
soyContext.put("coverLetter", getCoverLetter());
@@ -546,9 +601,6 @@ public abstract class ChangeEmail extends NotificationEmail {
footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
- if (change.getAssignee() != null) {
- footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
- }
for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
}
@@ -558,8 +610,7 @@ public abstract class ChangeEmail extends NotificationEmail {
for (Account.Id attentionUser : currentAttentionSet) {
footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
}
- // Since this would be user visible, only show it if attention set is enabled
- if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+ if (!currentAttentionSet.isEmpty()) {
// We need names rather than account ids / emails to make it user readable.
soyContext.put(
"attentionSet",
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 3c821ccc32..3711ca2093 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -87,16 +87,19 @@ public class CommentSender extends ReplyToChangeSender {
public List<Comment> comments = new ArrayList<>();
/** Returns a web link to a comment for a change. */
+ @Nullable
public String getCommentLink(String uuid) {
return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
}
/** Returns a web link to the comment tab view of a change. */
+ @Nullable
public String getCommentsTabLink() {
return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
}
/** Returns a web link to the findings tab view of a change. */
+ @Nullable
public String getFindingsTabLink() {
return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
}
@@ -169,10 +172,11 @@ public class CommentSender extends ReplyToChangeSender {
protected void init() throws EmailException {
super.init();
- if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
+ if (notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)
+ || notify.handling().equals(NotifyHandling.ALL)) {
ccAllApprovals();
}
- if (notify.handling().compareTo(NotifyHandling.ALL) >= 0) {
+ if (notify.handling().equals(NotifyHandling.ALL)) {
bccStarredBy();
includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
}
@@ -505,6 +509,7 @@ public class CommentSender extends ReplyToChangeSender {
return false;
}
+ @Nullable
private Repository getRepository() {
try {
return args.server.openRepository(projectState.getNameKey());
diff --git a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
index d6d306cb2e..22c26b1064 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteKeySender.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.mail.send;
import com.google.common.base.Joiner;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.server.IdentifiedUser;
@@ -70,7 +71,7 @@ public class DeleteKeySender extends OutgoingEmail {
super.init();
setHeader("Subject", String.format("[Gerrit Code Review] %s Keys Deleted", getKeyType()));
setMessageId(messageIdGenerator.fromAccountUpdate(user.getAccountId()));
- add(RecipientType.TO, user.getAccountId());
+ addByAccountId(RecipientType.TO, user.getAccountId());
}
@Override
@@ -109,10 +110,12 @@ public class DeleteKeySender extends OutgoingEmail {
throw new IllegalStateException("key type is not SSH or GPG");
}
+ @Nullable
private String getSshKey() {
return (sshKey != null) ? sshKey.sshPublicKey() + "\n" : null;
}
+ @Nullable
private String getGpgKeyFingerprints() {
if (!gpgKeyFingerprints.isEmpty()) {
return Joiner.on("\n").join(gpgKeyFingerprints);
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index 70676e3ade..52a16acfee 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.mail.send;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
@@ -61,8 +62,8 @@ public class DeleteReviewerSender extends ReplyToChangeSender {
bccStarredBy();
ccExistingReviewers();
includeWatchers(NotifyType.ALL_COMMENTS);
- reviewers.stream().forEach(r -> add(RecipientType.TO, r));
- addByEmail(RecipientType.TO, reviewersByEmail);
+ reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
+ reviewersByEmail.stream().forEach(address -> addByEmail(RecipientType.TO, address));
}
@Override
@@ -73,6 +74,7 @@ public class DeleteReviewerSender extends ReplyToChangeSender {
}
}
+ @Nullable
public List<String> getReviewerNames() {
if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
return null;
diff --git a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
index 045c6a4498..5fb66bbe01 100644
--- a/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
+++ b/java/com/google/gerrit/server/mail/send/HttpPasswordUpdateSender.java
@@ -50,7 +50,7 @@ public class HttpPasswordUpdateSender extends OutgoingEmail {
setMessageId(
messageIdGenerator.fromReasonAccountIdAndTimestamp(
"HTTP_password_change", user.getAccountId(), TimeUtil.now()));
- add(RecipientType.TO, user.getAccountId());
+ addByAccountId(RecipientType.TO, user.getAccountId());
}
@Override
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 2e0eeb3802..0ddb0ad903 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -63,7 +63,7 @@ public class InboundEmailRejectionSender extends OutgoingEmail {
setListIdHeader();
setHeader(FieldName.SUBJECT, "[Gerrit Code Review] Unable to process your email");
- add(RecipientType.TO, to);
+ addByEmail(RecipientType.TO, to);
if (!threadId.isEmpty()) {
setHeader(MailHeader.REFERENCES.fieldName(), threadId);
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
index 8ee8fc2a46..0eaafb81ec 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceLoader.java
@@ -17,6 +17,8 @@ package com.google.gerrit.server.mail.send;
import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.io.CharStreams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.inject.Inject;
@@ -24,13 +26,13 @@ import com.google.inject.ProvisionException;
import com.google.inject.Singleton;
import com.google.template.soy.SoyFileSet;
import com.google.template.soy.jbcsrc.api.SoySauce;
-import com.google.template.soy.shared.SoyAstCache;
import java.io.IOException;
import java.io.Reader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
+import org.eclipse.jgit.util.FileUtils;
/**
* Configures and loads Soy Sauce object for rendering email templates.
@@ -86,60 +88,78 @@ class MailSoySauceLoader {
"RestoredHtml.soy",
"Reverted.soy",
"RevertedHtml.soy",
- "SetAssignee.soy",
- "SetAssigneeHtml.soy",
};
+ private static final SoySauce DEFAULT = getDefault(null).build().compileTemplates();
+
private final SitePaths site;
- private final SoyAstCache cache;
private final PluginSetContext<MailSoyTemplateProvider> templateProviders;
@Inject
- MailSoySauceLoader(
- SitePaths site,
- SoyAstCache cache,
- PluginSetContext<MailSoyTemplateProvider> templateProviders) {
+ MailSoySauceLoader(SitePaths site, PluginSetContext<MailSoyTemplateProvider> templateProviders) {
this.site = site;
- this.cache = cache;
this.templateProviders = templateProviders;
}
public SoySauce load() {
- SoyFileSet.Builder builder = SoyFileSet.builder();
- builder.setSoyAstCache(cache);
- for (String name : TEMPLATES) {
- addTemplate(builder, "com/google/gerrit/server/mail/", name);
+ if (!hasCustomTemplates(site, templateProviders)) {
+ return DEFAULT;
}
+
+ SoyFileSet.Builder builder = getDefault(site);
templateProviders.runEach(
- e -> e.getFileNames().forEach(p -> addTemplate(builder, e.getPath(), p)));
+ e -> e.getFileNames().forEach(p -> addTemplate(builder, site, e.getPath(), p)));
return builder.build().compileTemplates();
}
- private void addTemplate(SoyFileSet.Builder builder, String resourcePath, String name)
+ private static boolean hasCustomTemplates(
+ SitePaths site, PluginSetContext<MailSoyTemplateProvider> templateProviders) {
+ try {
+ if (!templateProviders.isEmpty()) {
+ return true;
+ }
+ return Files.exists(site.mail_dir) && FileUtils.hasFiles(site.mail_dir);
+ } catch (IOException e) {
+ throw new StorageException(e);
+ }
+ }
+
+ private static SoyFileSet.Builder getDefault(@Nullable SitePaths site) {
+ SoyFileSet.Builder builder = SoyFileSet.builder();
+ for (String name : TEMPLATES) {
+ addTemplate(builder, site, "com/google/gerrit/server/mail/", name);
+ }
+ return builder;
+ }
+
+ private static void addTemplate(
+ SoyFileSet.Builder builder, @Nullable SitePaths site, String resourcePath, String name)
throws ProvisionException {
if (!resourcePath.endsWith("/")) {
resourcePath += "/";
}
String logicalPath = resourcePath + name;
- // Load as a file in the mail templates directory if present.
- Path tmpl = site.mail_dir.resolve(name);
- if (Files.isRegularFile(tmpl)) {
- String content;
- // TODO(davido): Consider using JGit's FileSnapshot to cache based on
- // mtime.
- try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
- content = CharStreams.toString(r);
- } catch (IOException err) {
- throw new ProvisionException(
- "Failed to read template file " + tmpl.toAbsolutePath().toString(), err);
+ if (site != null) {
+ // Load as a file in the mail templates directory if present.
+ Path tmpl = site.mail_dir.resolve(name);
+ if (Files.isRegularFile(tmpl)) {
+ String content;
+ // TODO(davido): Consider using JGit's FileSnapshot to cache based on
+ // mtime.
+ try (Reader r = Files.newBufferedReader(tmpl, StandardCharsets.UTF_8)) {
+ content = CharStreams.toString(r);
+ } catch (IOException err) {
+ throw new ProvisionException(
+ "Failed to read template file " + tmpl.toAbsolutePath(), err);
+ }
+ builder.add(content, logicalPath);
+ return;
}
- builder.add(content, logicalPath);
- return;
}
// Otherwise load the template as a resource.
- URL resource = this.getClass().getClassLoader().getResource(logicalPath);
+ URL resource = MailSoySauceLoader.class.getClassLoader().getResource(logicalPath);
checkArgument(resource != null, "resource %s not found.", logicalPath);
builder.add(resource, logicalPath);
}
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 693c66941a..ce2e3dcb70 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -14,8 +14,11 @@
package com.google.gerrit.server.mail.send;
+import static com.google.common.base.Preconditions.checkNotNull;
+
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.Table;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelType;
@@ -26,12 +29,16 @@ import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.server.change.NotifyResolver;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.util.Optional;
/** Send notice about a change successfully merged. */
public class MergedSender extends ReplyToChangeSender {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
public interface Factory {
MergedSender create(
Project.NameKey project, Change.Id changeId, Optional<String> stickyApprovalDiff);
@@ -49,13 +56,28 @@ public class MergedSender extends ReplyToChangeSender {
super(args, "merged", newChangeData(args, project, changeId));
labelTypes = changeData.getLabelTypes();
this.stickyApprovalDiff = stickyApprovalDiff;
+ // We want to send the submit email even if the "send only when in attention set" is enabled.
+ emailOnlyAttentionSetIfEnabled = false;
}
@Override
- protected void init() throws EmailException {
- // We want to send the submit email even if the "send only when in attention set" is enabled.
- emailOnlyAttentionSetIfEnabled = false;
+ public void setNotify(NotifyResolver.Result notify) {
+ checkNotNull(notify);
+ if (!stickyApprovalDiff.isEmpty()) {
+ if (!notify.handling().equals(NotifyHandling.ALL)) {
+ logger.atFine().log(
+ "Requested to notify %s, but for change submission with sticky approval diff,"
+ + " Notify=ALL is enforced.",
+ notify.handling().name());
+ }
+ this.notify = NotifyResolver.Result.create(NotifyHandling.ALL, notify.accounts());
+ } else {
+ this.notify = notify;
+ }
+ }
+ @Override
+ protected void init() throws EmailException {
super.init();
ccAllApprovals();
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index e899fc55c8..aabf7ca58a 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.mail.send;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.exceptions.EmailException;
@@ -74,18 +75,18 @@ public abstract class NewChangeSender extends ChangeEmail {
break;
case ALL:
default:
- extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
- extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
+ extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
+ extraCCByEmail.stream().forEach(cc -> addByEmail(RecipientType.CC, cc));
// $FALL-THROUGH$
case OWNER_REVIEWERS:
- reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
- addByEmail(RecipientType.TO, reviewersByEmail, true);
- removedReviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
- addByEmail(RecipientType.TO, removedByEmailReviewers, true);
+ reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
+ reviewersByEmail.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
+ removedReviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r, true));
+ removedByEmailReviewers.stream().forEach(r -> addByEmail(RecipientType.TO, r, true));
break;
}
- rcptToAuthors(RecipientType.CC);
+ addAuthors(RecipientType.CC);
}
@Override
@@ -96,7 +97,8 @@ public abstract class NewChangeSender extends ChangeEmail {
}
}
- public List<String> getReviewerNames() {
+ @Nullable
+ private List<String> getReviewerNames() {
if (reviewers.isEmpty()) {
return null;
}
@@ -107,7 +109,8 @@ public abstract class NewChangeSender extends ChangeEmail {
return names;
}
- public List<String> getRemovedReviewerNames() {
+ @Nullable
+ private List<String> getRemovedReviewerNames() {
if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
return null;
}
diff --git a/java/com/google/gerrit/server/mail/send/NotificationEmail.java b/java/com/google/gerrit/server/mail/send/NotificationEmail.java
deleted file mode 100644
index 5b209ce609..0000000000
--- a/java/com/google/gerrit/server/mail/send/NotificationEmail.java
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.NotifyConfig.NotifyType;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.mail.MailHeader;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
-import com.google.gerrit.server.mail.send.ProjectWatch.Watchers.WatcherList;
-import java.util.HashMap;
-import java.util.Map;
-
-/** Common class for notifications that are related to a project and branch */
-public abstract class NotificationEmail extends OutgoingEmail {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
- protected BranchNameKey branch;
-
- protected NotificationEmail(EmailArguments args, String messageClass, BranchNameKey branch) {
- super(args, messageClass);
- this.branch = branch;
- }
-
- @Override
- protected void init() throws EmailException {
- super.init();
- setListIdHeader();
- }
-
- private void setListIdHeader() {
- // Set a reasonable list id so that filters can be used to sort messages
- setHeader(
- "List-Id",
- "<gerrit-" + branch.project().get().replace('/', '-') + "." + getGerritHost() + ">");
- if (getSettingsUrl() != null) {
- setHeader("List-Unsubscribe", "<" + getSettingsUrl() + ">");
- }
- }
-
- /** Include users and groups that want notification of events. */
- protected void includeWatchers(NotifyType type) {
- includeWatchers(type, true);
- }
-
- /** Include users and groups that want notification of events. */
- protected void includeWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
- try {
- Watchers matching = getWatchers(type, includeWatchersFromNotifyConfig);
- add(RecipientType.TO, matching.to);
- add(RecipientType.CC, matching.cc);
- add(RecipientType.BCC, matching.bcc);
- } catch (StorageException err) {
- // Just don't CC everyone. Better to send a partial message to those
- // we already have queued up then to fail deliver entirely to people
- // who have a lower interest in the change.
- logger.atWarning().withCause(err).log("Cannot BCC watchers for %s", type);
- }
- }
-
- /** Returns all watchers that are relevant */
- protected abstract Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig);
-
- /** Add users or email addresses to the TO, CC, or BCC list. */
- protected void add(RecipientType type, WatcherList watcherList) {
- for (Account.Id user : watcherList.accounts) {
- addWatcher(type, user);
- }
- for (Address addr : watcherList.emails) {
- add(type, addr);
- }
- }
-
- protected abstract void addWatcher(RecipientType type, Account.Id to);
-
- public String getSshHost() {
- String host = Iterables.getFirst(args.sshAddresses, null);
- if (host == null) {
- return null;
- }
- if (host.startsWith("*:")) {
- return getGerritHost() + host.substring(1);
- }
- return host;
- }
-
- @Override
- protected void setupSoyContext() {
- super.setupSoyContext();
-
- String projectName = branch.project().get();
- soyContext.put("projectName", projectName);
- // shortProjectName is the project name with the path abbreviated.
- soyContext.put("shortProjectName", getShortProjectName(projectName));
-
- // instanceAndProjectName is the instance's name followed by the abbreviated project path
- soyContext.put(
- "instanceAndProjectName",
- getInstanceAndProjectName(args.instanceNameProvider.get(), projectName));
- soyContext.put("addInstanceNameInSubject", args.addInstanceNameInSubject);
-
- soyContextEmailData.put("sshHost", getSshHost());
-
- Map<String, String> branchData = new HashMap<>();
- branchData.put("shortName", branch.shortName());
- soyContext.put("branch", branchData);
-
- footers.add(MailHeader.PROJECT.withDelimiter() + branch.project().get());
- footers.add(MailHeader.BRANCH.withDelimiter() + branch.shortName());
- }
-
- @VisibleForTesting
- protected static String getShortProjectName(String projectName) {
- int lastIndexSlash = projectName.lastIndexOf('/');
- if (lastIndexSlash == 0) {
- return projectName.substring(1); // Remove the first slash
- }
- if (lastIndexSlash == -1) { // No slash in the project name
- return projectName;
- }
-
- return "..." + projectName.substring(lastIndexSlash + 1);
- }
-
- @VisibleForTesting
- protected static String getInstanceAndProjectName(String instanceName, String projectName) {
- if (instanceName == null || instanceName.isEmpty()) {
- return getShortProjectName(projectName);
- }
- // Extract the project name (everything after the last slash) and prepends it with gerrit's
- // instance name
- return instanceName + "/" + projectName.substring(projectName.lastIndexOf('/') + 1);
- }
-}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index bfc1f5bfe3..aba8f621d9 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -44,7 +44,6 @@ import java.net.MalformedURLException;
import java.net.URL;
import java.time.Instant;
import java.util.ArrayList;
-import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
@@ -166,7 +165,7 @@ public abstract class OutgoingEmail {
logger.atFine().log(
"CC email sender %s because the email strategy of this user is %s",
fromUser.get().account().id(), CC_ON_OWN_COMMENTS);
- add(RecipientType.CC, fromId);
+ addByAccountId(RecipientType.CC, fromId);
} else if (isImpersonating) {
// If we are impersonating a user, make sure they receive a CC of
// this message regardless of email strategy, unless email notifications are explicitly
@@ -176,7 +175,7 @@ public abstract class OutgoingEmail {
"CC email sender %s because the email is sent on behalf of and email notifications"
+ " are enabled for this user.",
fromUser.get().account().id());
- add(RecipientType.CC, fromId);
+ addByAccountId(RecipientType.CC, fromId);
} else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
// If they don't want a copy, but we queued one up anyway,
@@ -331,7 +330,7 @@ public abstract class OutgoingEmail {
setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
for (RecipientType recipientType : notify.accounts().keySet()) {
- notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
+ notify.accounts().get(recipientType).stream().forEach(a -> addByAccountId(recipientType, a));
}
setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -380,10 +379,12 @@ public abstract class OutgoingEmail {
return SystemReader.getInstance().getHostname();
}
+ @Nullable
public String getSettingsUrl() {
return args.urlFormatter.get().getSettingsUrl().orElse(null);
}
+ @Nullable
private String getGerritUrl() {
return args.urlFormatter.get().getWebUrl().orElse(null);
}
@@ -471,6 +472,7 @@ public abstract class OutgoingEmail {
* @param accountId user to fetch.
* @return name/email of account, username, or null if unset or the accountId is null.
*/
+ @Nullable
protected String getUserNameEmailFor(@Nullable Account.Id accountId) {
if (accountId == null) {
return null;
@@ -522,51 +524,86 @@ public abstract class OutgoingEmail {
return true;
}
- /** Schedule this message for delivery to the listed address. */
- protected final void addByEmail(RecipientType rt, Collection<Address> list) {
- addByEmail(rt, list, false);
+ /**
+ * Adds a recipient that the email will be sent to.
+ *
+ * @param rt category of recipient (TO, CC, BCC)
+ * @param addr Name and email of the recipient.
+ */
+ public final void addByEmail(RecipientType rt, Address addr) {
+ addByEmail(rt, addr, false);
}
- /** Schedule this message for delivery to the listed address. */
- protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
- for (final Address id : list) {
- add(rt, id, override);
+ /**
+ * Adds a recipient that the email will be sent to.
+ *
+ * @param rt category of recipient (TO, CC, BCC).
+ * @param addr Name and email of the recipient.
+ * @param override if the recipient was added previously and override is false no change is made
+ * regardless of {@code rt}.
+ */
+ public final void addByEmail(RecipientType rt, Address addr, boolean override) {
+ try {
+ if (isRecipientAllowed(addr)) {
+ add(rt, addr, override);
+ }
+ } catch (PermissionBackendException e) {
+ logger.atSevere().withCause(e).log("Error checking permissions for email address: %s", addr);
}
}
- /** Schedule delivery of this message to the given account. */
- protected void add(RecipientType rt, Account.Id to) {
- add(rt, to, false);
+ /**
+ * Returns whether this email is allowed to be sent to the given address
+ *
+ * @param addr email address of recipient.
+ * @throws PermissionBackendException thrown if checking a permission fails due to an error in the
+ * permission backend
+ */
+ protected boolean isRecipientAllowed(Address addr) throws PermissionBackendException {
+ return true;
}
- protected void add(RecipientType rt, Account.Id to, boolean override) {
+ /**
+ * Adds a recipient that the email will be sent to.
+ *
+ * @param rt category of recipient (TO, CC, BCC)
+ * @param to Gerrit Account of the recipient.
+ */
+ protected void addByAccountId(RecipientType rt, Account.Id to) {
+ addByAccountId(rt, to, false);
+ }
+
+ /**
+ * Adds a recipient that the email will be sent to.
+ *
+ * @param rt category of recipient (TO, CC, BCC)
+ * @param to Gerrit Account of the recipient.
+ * @param override if the recipient was added previously and override is false no change is made
+ * regardless of {@code rt}.
+ */
+ protected void addByAccountId(RecipientType rt, Account.Id to, boolean override) {
try {
- if (!rcptTo.contains(to) && isVisibleTo(to)) {
+ if (!rcptTo.contains(to) && isRecipientAllowed(to)) {
rcptTo.add(to);
add(rt, toAddress(to), override);
}
} catch (PermissionBackendException e) {
- logger.atSevere().withCause(e).log("Error reading database for account: %s", to);
+ logger.atSevere().withCause(e).log("Error checking permissions for account: %s", to);
}
}
/**
- * Returns whether this email is visible to the given account
+ * Returns whether this email is allowed to be sent to the given account
*
* @param to account.
* @throws PermissionBackendException thrown if checking a permission fails due to an error in the
* permission backend
*/
- protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
+ protected boolean isRecipientAllowed(Account.Id to) throws PermissionBackendException {
return true;
}
- /** Schedule delivery of this message to the given account. */
- protected final void add(RecipientType rt, Address addr) {
- add(rt, addr, false);
- }
-
- protected final void add(RecipientType rt, Address addr, boolean override) {
+ private final void add(RecipientType rt, Address addr, boolean override) {
if (addr != null && addr.email() != null && addr.email().length() > 0) {
if (!args.validator.isValid(addr.email())) {
logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
@@ -594,6 +631,7 @@ public abstract class OutgoingEmail {
}
}
+ @Nullable
private Address toAddress(Account.Id id) {
Optional<Account> accountState = args.accountCache.get(id).map(AccountState::account);
if (!accountState.isPresent()) {
@@ -623,6 +661,26 @@ public abstract class OutgoingEmail {
soyContext.put("email", soyContextEmailData);
}
+ /** Mutable map of parameters passed into email templates when rendering. */
+ public Map<String, Object> getSoyContext() {
+ return this.soyContext;
+ }
+
+ // TODO: It's not clear why we need this explicit separation. Probably worth
+ // simplifying.
+ /** Mutable content of `email` parameter in the templates. */
+ public Map<String, Object> getSoyContextEmailData() {
+ return this.soyContextEmailData;
+ }
+
+ /**
+ * Add a line to email footer with additional information. Typically, in the form of {@literal
+ * <key>: <value>}.
+ */
+ public void addFooter(String footer) {
+ footers.add(footer);
+ }
+
private String getInstanceName() {
return args.instanceNameProvider.get();
}
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 9299d74a6a..f4c211d814 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.mail.send;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -24,6 +25,7 @@ import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.NotifyConfig;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.CurrentUser;
@@ -35,11 +37,13 @@ import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.GroupBackedUser;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
public class ProjectWatch {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -240,13 +244,13 @@ public class ProjectWatch {
}
private boolean filterMatch(CurrentUser user, String filter) throws QueryParseException {
- ChangeQueryBuilder qb;
+ WatcherChangeQueryBuilder qb;
Predicate<ChangeData> p = null;
if (user == null) {
- qb = args.queryBuilder.get().asUser(args.anonymousUser.get());
+ qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), args.anonymousUser.get());
} else {
- qb = args.queryBuilder.get().asUser(user);
+ qb = WatcherChangeQueryBuilder.asUser(args.queryBuilder.get(), user);
p = qb.isVisible();
}
@@ -260,4 +264,40 @@ public class ProjectWatch {
}
return p == null || p.asMatchable().match(changeData);
}
+
+ private static class WatcherChangeQueryBuilder extends ChangeQueryBuilder {
+ private WatcherChangeQueryBuilder(Arguments args) {
+ super(args);
+ }
+
+ public static WatcherChangeQueryBuilder asUser(ChangeQueryBuilder other, CurrentUser user) {
+ return new WatcherChangeQueryBuilder(other.getArgs().asUser(user));
+ }
+
+ @Override
+ protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
+ if (query.startsWith("refs/")) {
+ return ref(query);
+ }
+
+ // Adapt the capacity of this list when adding more default predicates.
+ List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
+ predicates.add(file(query));
+ try {
+ predicates.add(label(query));
+ } catch (StorageException | IOException | ConfigInvalidException | QueryParseException e) {
+ // Skip.
+ }
+ predicates.add(commit(query));
+ predicates.add(message(query));
+ predicates.add(comment(query));
+ predicates.add(projects(query));
+ predicates.add(ref(query));
+ predicates.add(branch(query));
+ predicates.add(topic(query));
+ // Adapt the capacity of the "predicates" list when adding more default
+ // predicates.
+ return Predicate.or(predicates);
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
index a54a652500..f7bc3363e8 100644
--- a/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/RegisterNewEmailSender.java
@@ -54,7 +54,7 @@ public class RegisterNewEmailSender extends OutgoingEmail {
protected void init() throws EmailException {
super.init();
setHeader("Subject", "[Gerrit Code Review] Email Verification");
- add(RecipientType.TO, Address.create(addr));
+ addByEmail(RecipientType.TO, Address.create(addr));
}
@Override
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 0d32dd587a..188c5d8f6d 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -123,12 +123,12 @@ public class ReplacePatchSetSender extends ReplyToChangeSender {
reviewers.remove(fromId);
}
if (args.settings.sendNewPatchsetEmails) {
- if (notify.handling() == NotifyHandling.ALL
- || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
- reviewers.stream().forEach(r -> add(RecipientType.TO, r));
- extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
+ if (notify.handling().equals(NotifyHandling.ALL)
+ || notify.handling().equals(NotifyHandling.OWNER_REVIEWERS)) {
+ reviewers.stream().forEach(r -> addByAccountId(RecipientType.TO, r));
+ extraCC.stream().forEach(cc -> addByAccountId(RecipientType.CC, cc));
}
- rcptToAuthors(RecipientType.CC);
+ addAuthors(RecipientType.CC);
}
bccStarredBy();
includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
@@ -142,6 +142,7 @@ public class ReplacePatchSetSender extends ReplyToChangeSender {
}
}
+ @Nullable
public ImmutableList<String> getReviewerNames() {
List<String> names = new ArrayList<>();
for (Account.Id id : reviewers) {
diff --git a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
index c765430437..696cd17670 100644
--- a/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplyToChangeSender.java
@@ -38,6 +38,6 @@ public abstract class ReplyToChangeSender extends ChangeEmail {
setHeader("In-Reply-To", threadId);
setHeader("References", threadId);
- rcptToAuthors(RecipientType.TO);
+ addAuthors(RecipientType.TO);
}
}
diff --git a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java b/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
deleted file mode 100644
index 29f4c69fe4..0000000000
--- a/java/com/google/gerrit/server/mail/send/SetAssigneeSender.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.mail.send;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.EmailException;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-/**
- * Sender that informs a user by email that they were set as assignee on a change.
- *
- * <p>In contrast to other change emails this email is not sent to the change authors (owner, patch
- * set uploader, author). This is why this class extends {@link ChangeEmail} directly, instead of
- * extending {@link ReplyToChangeSender}.
- */
-public class SetAssigneeSender extends ChangeEmail {
- public interface Factory {
- SetAssigneeSender create(Project.NameKey project, Change.Id changeId, Account.Id assignee);
- }
-
- private final Account.Id assignee;
-
- @Inject
- public SetAssigneeSender(
- EmailArguments args,
- @Assisted Project.NameKey project,
- @Assisted Change.Id changeId,
- @Assisted Account.Id assignee) {
- super(args, "setassignee", newChangeData(args, project, changeId));
- this.assignee = assignee;
- }
-
- @Override
- protected void init() throws EmailException {
- super.init();
-
- add(RecipientType.TO, assignee);
- }
-
- @Override
- protected void formatChange() throws EmailException {
- appendText(textTemplate("SetAssignee"));
- if (useHtml()) {
- appendHtml(soyHtmlTemplate("SetAssigneeHtml"));
- }
- }
-
- @Override
- protected void setupSoyContext() {
- super.setupSoyContext();
- soyContextEmailData.put("assigneeName", getNameFor(assignee));
- }
-}
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 93f29f6ca6..5ffb5fb576 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -204,6 +204,7 @@ public abstract class AbstractChangeNotes<T> {
return load();
}
+ @Nullable
public ObjectId loadRevision() {
if (loaded) {
return getRevision();
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index e6f16220f6..708d59fb01 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -101,6 +101,7 @@ public abstract class AbstractChangeUpdate {
user);
}
+ @Nullable
private static Account.Id accountId(CurrentUser u) {
checkUserType(u);
return (u instanceof IdentifiedUser) ? u.getAccountId() : null;
@@ -163,6 +164,10 @@ public abstract class AbstractChangeUpdate {
return accountId;
}
+ public Account.Id getRealAccountId() {
+ return realAccountId;
+ }
+
/** Whether no updates have been done. */
public abstract boolean isEmpty();
@@ -206,6 +211,7 @@ public abstract class AbstractChangeUpdate {
* deleted.
* @throws IOException if a lower-level error occurred.
*/
+ @Nullable
final ObjectId apply(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException {
if (isEmpty()) {
return null;
diff --git a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
index 541749466d..0289e17d10 100644
--- a/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AllUsersAsyncUpdate.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.notedb;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
@@ -25,6 +26,7 @@ import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.FanOutExecutor;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.Map;
@@ -90,26 +92,28 @@ public class AllUsersAsyncUpdate {
Future<?> possiblyIgnoredError =
executor.submit(
() -> {
- try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
- allUsersRepo.addUpdatesNoLimits(draftUpdates);
- allUsersRepo.flush();
- BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
- bru.setPushCertificate(pushCert);
- if (refLogMessage != null) {
- bru.setRefLogMessage(refLogMessage, false);
- } else {
- bru.setRefLogMessage(
- firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
- false);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (OpenRepo allUsersRepo = OpenRepo.open(repoManager, allUsersName)) {
+ allUsersRepo.addUpdatesNoLimits(draftUpdates);
+ allUsersRepo.flush();
+ BatchRefUpdate bru = allUsersRepo.repo.getRefDatabase().newBatchUpdate();
+ bru.setPushCertificate(pushCert);
+ if (refLogMessage != null) {
+ bru.setRefLogMessage(refLogMessage, false);
+ } else {
+ bru.setRefLogMessage(
+ firstNonNull(NoteDbUtil.guessRestApiHandler(), "Update NoteDb refs async"),
+ false);
+ }
+ bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
+ bru.setAtomic(true);
+ allUsersRepo.cmds.addTo(bru);
+ bru.setAllowNonFastForwards(true);
+ RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
+ } catch (IOException e) {
+ logger.atSevere().withCause(e).log(
+ "Failed to delete draft comments asynchronously after publishing them");
}
- bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent);
- bru.setAtomic(true);
- allUsersRepo.cmds.addTo(bru);
- bru.setAllowNonFastForwards(true);
- RefUpdateUtil.executeChecked(bru, allUsersRepo.rw);
- } catch (IOException e) {
- logger.atSevere().withCause(e).log(
- "Failed to delete draft comments asynchronously after publishing them");
}
});
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 73161d7e38..0dcf786f51 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkState;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
@@ -187,6 +188,7 @@ public class ChangeDraftUpdate extends AbstractChangeUpdate {
return clonedUpdate;
}
+ @Nullable
private CommitBuilder storeCommentsInNotes(
RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
new file mode 100644
index 0000000000..3be55ea3c0
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteFooters.java
@@ -0,0 +1,44 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/** Footers, that can be set in NoteDb commits. */
+public class ChangeNoteFooters {
+ public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
+ 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_CURRENT = new FooterKey("Current");
+ 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_PATCH_SET = new FooterKey("Patch-set");
+ public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
+ new FooterKey("Patch-set-description");
+ public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
+ public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
+ public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
+ public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
+ public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
+ 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_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/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 178cf9b041..881cd963a8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -15,8 +15,6 @@
package com.google.gerrit.server.notedb;
import com.google.auto.value.AutoValue;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.json.OutputFormat;
@@ -24,46 +22,16 @@ import com.google.gerrit.server.config.GerritServerId;
import com.google.gson.Gson;
import com.google.inject.Inject;
import java.time.Instant;
-import java.util.ArrayList;
-import java.util.List;
import java.util.Optional;
-import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.FooterKey;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.util.RawParseUtils;
public class ChangeNoteUtil {
- public static final FooterKey FOOTER_ATTENTION = new FooterKey("Attention");
- public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
- 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_CURRENT = new FooterKey("Current");
- 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_PATCH_SET = new FooterKey("Patch-set");
- public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
- new FooterKey("Patch-set-description");
- public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
- public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
- public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
- public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
- public static final FooterKey FOOTER_SUBMISSION_ID = new FooterKey("Submission-id");
- 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_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");
-
static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
- private static final String LABEL_VOTE_UUID_SEPARATOR = ", ";
private final ChangeNoteJson changeNoteJson;
private final String serverId;
@@ -252,303 +220,4 @@ public class ChangeNoteUtil {
new AttentionStatusInNoteDb(
stringBuilder.toString(), attentionSetUpdate.operation(), attentionSetUpdate.reason()));
}
-
- /**
- * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link #FOOTER_LABEL} or
- * {@link #FOOTER_COPIED_LABEL}.
- *
- * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
- * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
- * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
- * #footerLine} values.
- */
- @AutoValue
- public abstract static class ParsedPatchSetApproval {
-
- /** The original footer value, that this entity was parsed from. */
- public abstract String footerLine();
-
- public abstract boolean isRemoval();
-
- /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
- public abstract String labelVote();
-
- public abstract Optional<String> uuid();
-
- public abstract Optional<String> accountIdent();
-
- public abstract Optional<String> realAccountIdent();
-
- public abstract Optional<String> tag();
-
- public static Builder builder() {
- return new AutoValue_ChangeNoteUtil_ParsedPatchSetApproval.Builder();
- }
-
- @AutoValue.Builder
- public abstract static class Builder {
-
- abstract Builder footerLine(String labelLine);
-
- abstract Builder isRemoval(boolean isRemoval);
-
- abstract Builder labelVote(String labelVote);
-
- abstract Builder uuid(Optional<String> uuid);
-
- abstract Builder accountIdent(Optional<String> accountIdent);
-
- abstract Builder realAccountIdent(Optional<String> realAccountIdent);
-
- abstract Builder tag(Optional<String> tag);
-
- abstract ParsedPatchSetApproval build();
- }
- }
-
- /**
- * Delegates parsing of {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line to
- * dedicated methods: {@link #parseAddedApproval} and {@link #parseRemovedApproval}
- * correspondingly.
- */
- public static ParsedPatchSetApproval parseApproval(String footerLine)
- throws ConfigInvalidException {
- try {
- return footerLine.startsWith("-")
- ? parseRemovedApproval(footerLine)
- : parseAddedApproval(footerLine);
- } catch (StringIndexOutOfBoundsException ex) {
- throw parseException(FOOTER_LABEL, footerLine, ex);
- }
- }
-
- /**
- * Parses added {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line.
- *
- * <p>Valid added approval footer examples:
- *
- * <ul>
- * <li>Label: &lt;LABEL&gt;=VOTE
- * <li>Label: &lt;LABEL&gt;=VOTE &lt;Gerrit Account&gt;
- * <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt;
- * <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt; &lt;Gerrit Account&gt;
- * </ul>
- *
- * <p>&lt;UUID&gt; is optional, since the approval might have been granted before {@link
- * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
- *
- * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
- * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
- */
- private static ParsedPatchSetApproval parseAddedApproval(String footerLine)
- throws ConfigInvalidException {
- ParsedPatchSetApproval.Builder rawPatchSetApproval =
- ParsedPatchSetApproval.builder().footerLine(footerLine);
- rawPatchSetApproval.isRemoval(false);
- // We need some additional logic to differentiate between labels that have a UUID and those that
- // have a user with a comma. This allows us to separate the following cases (note that the
- // leading `Label: ` has been elided at this point):
- // Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
- // Label: <LABEL>=VOTE <Gerrit, Account>
- int reviewerStartOffset = 0;
- int scoreStart = footerLine.indexOf('=') + 1;
- StringBuilder labelNameScore = new StringBuilder(footerLine.substring(0, scoreStart));
- for (int i = scoreStart; i < footerLine.length(); i++) {
- char currentChar = footerLine.charAt(i);
-
- // If we hit ',' before ' ' we have a UUID
- if (currentChar == ',') {
- labelNameScore.append(footerLine, scoreStart, i);
- int uuidStart = i + LABEL_VOTE_UUID_SEPARATOR.length();
- int uuidEnd = footerLine.indexOf(' ', uuidStart);
- String uuid = footerLine.substring(uuidStart, uuidEnd > 0 ? uuidEnd : footerLine.length());
- checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
- rawPatchSetApproval.uuid(Optional.of(uuid));
- reviewerStartOffset = uuidStart + uuid.length();
- break;
- }
-
- // Otherwise we don't
- if (currentChar == ' ') {
- labelNameScore.append(footerLine, scoreStart, i);
- break;
- }
-
- // If we hit neither we're defensive assign the whole line
- if (i == footerLine.length() - 1) {
- labelNameScore = new StringBuilder(footerLine);
- break;
- }
- }
-
- rawPatchSetApproval.labelVote(labelNameScore.toString());
-
- int reviewerStart = footerLine.indexOf(' ', reviewerStartOffset);
- if (reviewerStart > 0) {
- String ident = footerLine.substring(reviewerStart + 1);
- rawPatchSetApproval.accountIdent(Optional.of(ident));
- }
- return rawPatchSetApproval.build();
- }
-
- /**
- * Parses removed {@link ParsedPatchSetApproval} from {@link #FOOTER_LABEL} line.
- *
- * <p>Valid removed approval footer examples:
- *
- * <ul>
- * <li>-&lt;LABEL&gt;
- * <li>-&lt;LABEL&gt; &lt;Gerrit Account&gt;
- * </ul>
- *
- * <p>&lt;Gerrit Account&gt; is only persisted in cases, when the account, that granted the vote
- * does not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
- */
- private static ParsedPatchSetApproval parseRemovedApproval(String footerLine) {
- ParsedPatchSetApproval.Builder rawPatchSetApproval =
- ParsedPatchSetApproval.builder().footerLine(footerLine);
- rawPatchSetApproval.isRemoval(true);
- int labelStart = 1;
- int reviewerStart = footerLine.indexOf(' ', labelStart);
-
- rawPatchSetApproval.labelVote(
- reviewerStart != -1
- ? footerLine.substring(labelStart, reviewerStart)
- : footerLine.substring(labelStart));
-
- if (reviewerStart > 0) {
- String ident = footerLine.substring(reviewerStart + 1);
- rawPatchSetApproval.accountIdent(Optional.of(ident));
- }
- return rawPatchSetApproval.build();
- }
-
- /**
- * Parses copied {@link ParsedPatchSetApproval} from {@link #FOOTER_COPIED_LABEL} line.
- *
- * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
- * :"<TAG>"
- *
- * <ul>
- * <li>":<"TAG>"" is optional.
- * <li><Gerrit Real Account> is also optional, if it was not set.
- * <li><UUID> is optional, since the approval might have been granted before {@link
- * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
- * <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
- * Account is also optional since by default it's the committer).
- * </ul>
- *
- * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
- *
- * <ul>
- * <li><Gerrit Real Account> is also optional, if it was not set.
- * </ul>
- */
- public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
- throws ConfigInvalidException {
- try {
- ParsedPatchSetApproval.Builder rawPatchSetApproval =
- ParsedPatchSetApproval.builder().footerLine(labelLine);
-
- boolean isRemoval = labelLine.startsWith("-");
- rawPatchSetApproval.isRemoval(isRemoval);
- int labelStart = isRemoval ? 1 : 0;
- int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
- int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart);
-
- checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
-
- // Weird tag that contains uuid delimiter. The uuid is actually not present.
- if (tagStart != -1 && uuidStart > tagStart) {
- uuidStart = -1;
- }
-
- int identitiesStart =
- labelLine.indexOf(
- ' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0);
- checkFooter(
- identitiesStart != -1 && identitiesStart < labelLine.length(),
- FOOTER_COPIED_LABEL,
- labelLine);
-
- String labelVoteStr =
- labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
- rawPatchSetApproval.labelVote(labelVoteStr);
- if (uuidStart != -1) {
- String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
- checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
- rawPatchSetApproval.uuid(Optional.of(uuid));
- }
- // The first account is the accountId, and second (if applicable) is the realAccountId.
- List<String> identities =
- parseIdentities(
- labelLine.substring(
- identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
- checkFooter(identities.size() >= 1, FOOTER_COPIED_LABEL, labelLine);
-
- rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
-
- if (identities.size() > 1) {
- rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
- }
-
- if (tagStart != -1) {
- // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
- // line.length()-1 skips the last ".
- String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
- rawPatchSetApproval.tag(Optional.of(tag));
- }
- return rawPatchSetApproval.build();
- } catch (StringIndexOutOfBoundsException ex) {
- throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
- }
- }
-
- // Return the UUID start index or -1 if no UUID is present
- private static int parseCopiedApprovalUuidStart(String line, int tagStart) {
- int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
-
- // The first part of the condition checks whether the footer has the following format:
- // Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
- // Weird tag that contains uuid delimiter. The uuid is actually not present.
- if ((tagStart != -1 && separatorIndex > tagStart)
- ||
-
- // The second part of the condition allows us to distinguish the following two lines:
- // Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>
- // Label2=+1 User Name (company_name, department) <2@gerrit>
- (line.indexOf(' ') < separatorIndex)) {
- return -1;
- }
- return separatorIndex;
- }
-
- // Splitting on "," breaks for identities containing commas. The below re-implements splitting on
- // "(?<=>),", but it's 3-5x faster, as performance matters here.
- private static List<String> parseIdentities(String line) {
- List<String> idents = Splitter.on(',').splitToList(line);
- List<String> identitiesList = new ArrayList<>();
- for (int i = 0; i < idents.size(); i++) {
- if (i == 0 || idents.get(i - 1).endsWith(">")) {
- identitiesList.add(idents.get(i));
- } else {
- int lastIndex = identitiesList.size() - 1;
- identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents.get(i));
- }
- }
- return identitiesList;
- }
-
- private static void checkFooter(boolean expr, FooterKey footer, String actual)
- throws ConfigInvalidException {
- if (!expr) {
- throw parseException(footer, actual, /*cause=*/ null);
- }
- }
-
- private static ConfigInvalidException parseException(
- FooterKey footer, String actual, Throwable cause) {
- return new ConfigInvalidException(
- String.format("invalid %s: %s", footer.getName(), actual), cause);
- }
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index f4d29e8d3f..c5d2428198 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,7 +16,6 @@ package com.google.gerrit.server.notedb;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
import static java.util.Comparator.comparing;
@@ -30,7 +29,6 @@ import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Ordering;
import com.google.common.flogger.FluentLogger;
@@ -52,7 +50,6 @@ import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.SubmitRequirementResult;
-import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
@@ -427,8 +424,7 @@ public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
public ImmutableSortedMap<PatchSet.Id, PatchSet> getPatchSets() {
if (patchSets == null) {
- ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b =
- ImmutableSortedMap.orderedBy(comparing(PatchSet.Id::get));
+ ImmutableSortedMap.Builder<PatchSet.Id, PatchSet> b = ImmutableSortedMap.naturalOrder();
b.putAll(state.patchSets());
patchSets = b.build();
}
@@ -494,26 +490,6 @@ public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
return state.submitRequirementsResult();
}
- /**
- * Returns an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
- * order of the set is the order in which they were assigned.
- */
- public ImmutableSet<Account.Id> getPastAssignees() {
- return Lists.reverse(state.assigneeUpdates()).stream()
- .map(AssigneeStatusUpdate::currentAssignee)
- .filter(Optional::isPresent)
- .map(Optional::get)
- .collect(toImmutableSet());
- }
-
- /**
- * Returns an ImmutableList of AssigneeStatusUpdate of all the updates to the assignee field to
- * this change. The order of the list is from most recent updates to least recent.
- */
- public ImmutableList<AssigneeStatusUpdate> getAssigneeUpdates() {
- return state.assigneeUpdates();
- }
-
/** Returns an ImmutableSet of all hashtags for this change sorted in alphabetical order. */
public ImmutableSet<String> getHashtags() {
return ImmutableSortedSet.copyOf(state.hashtags());
@@ -694,6 +670,7 @@ public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
return change.getProject();
}
+ @Nullable
@Override
protected ObjectId readRef(Repository repo) throws IOException {
return refs != null ? refs.get(getRefName()).orElse(null) : super.readRef(repo);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 6b22a2da53..b98ecdde22 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -181,8 +181,6 @@ public class ChangeNotesCache {
+ P
+ list(state.reviewerUpdates(), 4 * O + K + K + P)
+ P
- + list(state.assigneeUpdates(), 4 * O + K + K)
- + P
+ set(state.attentionSet(), 4 * O + K + I + str(15))
+ P
+ list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
index 76573f6947..84de569b13 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCommit.java
@@ -15,8 +15,8 @@
package com.google.gerrit.server.notedb;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
@@ -25,6 +25,7 @@ import com.google.gerrit.server.git.InMemoryInserter;
import com.google.gerrit.server.git.InsertedObject;
import java.io.IOException;
import java.util.List;
+import java.util.Locale;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.AnyObjectId;
@@ -125,10 +126,10 @@ public class ChangeNotesCommit extends RevCommit {
List<FooterLine> src = getFooterLines();
footerLines = MultimapBuilder.hashKeys(src.size()).arrayListValues(1).build();
for (FooterLine fl : src) {
- footerLines.put(fl.getKey().toLowerCase(), fl.getValue());
+ footerLines.put(fl.getKey().toLowerCase(Locale.US), fl.getValue());
}
}
- return footerLines.get(key.getName().toLowerCase());
+ return footerLines.get(key.getName().toLowerCase(Locale.US));
}
public boolean isAttentionSetCommitOnly(boolean hasChangeMessage) {
@@ -137,7 +138,7 @@ public class ChangeNotesCommit extends RevCommit {
.keySet()
.equals(
Sets.newHashSet(
- FOOTER_PATCH_SET.getName().toLowerCase(),
- FOOTER_ATTENTION.getName().toLowerCase()));
+ FOOTER_PATCH_SET.getName().toLowerCase(Locale.US),
+ FOOTER_ATTENTION.getName().toLowerCase(Locale.US)));
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
new file mode 100644
index 0000000000..420f0c26ed
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParseApprovalUtil.java
@@ -0,0 +1,334 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.revwalk.FooterKey;
+
+/**
+ * Util to extract {@link com.google.gerrit.entities.PatchSetApproval} from {@link
+ * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+ */
+public class ChangeNotesParseApprovalUtil {
+ private static final String LABEL_VOTE_UUID_SEPARATOR = ", ";
+
+ /**
+ * {@link com.google.gerrit.entities.PatchSetApproval}, parsed from {@link
+ * ChangeNoteFooters#FOOTER_LABEL} or {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}.
+ *
+ * <p>In comparison to {@link com.google.gerrit.entities.PatchSetApproval}, this entity represent
+ * the raw fields, parsed from the NoteDB footer line, without any interpretation of the parsed
+ * values. See {@link #parseApproval} and {@link #parseCopiedApproval} for the valid {@link
+ * #footerLine} values.
+ */
+ @AutoValue
+ public abstract static class ParsedPatchSetApproval {
+
+ /** The original footer value, that this entity was parsed from. */
+ public abstract String footerLine();
+
+ public abstract boolean isRemoval();
+
+ /** Either <LABEL>=VOTE or <LABEL> for {@link #isRemoval}. */
+ public abstract String labelVote();
+
+ public abstract Optional<String> uuid();
+
+ public abstract Optional<String> accountIdent();
+
+ public abstract Optional<String> realAccountIdent();
+
+ public abstract Optional<String> tag();
+
+ public static Builder builder() {
+ return new AutoValue_ChangeNotesParseApprovalUtil_ParsedPatchSetApproval.Builder();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ abstract Builder footerLine(String labelLine);
+
+ abstract Builder isRemoval(boolean isRemoval);
+
+ abstract Builder labelVote(String labelVote);
+
+ abstract Builder uuid(Optional<String> uuid);
+
+ abstract Builder accountIdent(Optional<String> accountIdent);
+
+ abstract Builder realAccountIdent(Optional<String> realAccountIdent);
+
+ abstract Builder tag(Optional<String> tag);
+
+ abstract ParsedPatchSetApproval build();
+ }
+ }
+
+ /**
+ * Delegates parsing of {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL}
+ * line to dedicated methods: {@link #parseAddedApproval} and {@link #parseRemovedApproval}
+ * correspondingly.
+ */
+ public static ParsedPatchSetApproval parseApproval(String footerLine)
+ throws ConfigInvalidException {
+ try {
+ return footerLine.startsWith("-")
+ ? parseRemovedApproval(footerLine)
+ : parseAddedApproval(footerLine);
+ } catch (StringIndexOutOfBoundsException ex) {
+ throw parseException(FOOTER_LABEL, footerLine, ex);
+ }
+ }
+
+ /**
+ * Parses added {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL} line.
+ *
+ * <p>Valid added approval footer examples:
+ *
+ * <ul>
+ * <li>Label: &lt;LABEL&gt;=VOTE
+ * <li>Label: &lt;LABEL&gt;=VOTE &lt;Gerrit Account&gt;
+ * <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt;
+ * <li>Label: &lt;LABEL&gt;=VOTE, &lt;UUID&gt; &lt;Gerrit Account&gt;
+ * </ul>
+ *
+ * <p>&lt;UUID&gt; is optional, since the approval might have been granted before {@link
+ * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+ *
+ * <p><Gerrit Account> is only persisted in cases, when the account, that granted the vote does
+ * not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+ */
+ private static ParsedPatchSetApproval parseAddedApproval(String footerLine)
+ throws ConfigInvalidException {
+ ParsedPatchSetApproval.Builder rawPatchSetApproval =
+ ParsedPatchSetApproval.builder().footerLine(footerLine);
+ rawPatchSetApproval.isRemoval(false);
+ // We need some additional logic to differentiate between labels that have a UUID and those that
+ // have a user with a comma. This allows us to separate the following cases (note that the
+ // leading `Label: ` has been elided at this point):
+ // Label: <LABEL>=VOTE, <UUID> <Gerrit Account>
+ // Label: <LABEL>=VOTE <Gerrit, Account>
+ int reviewerStartOffset = 0;
+ int scoreStart = footerLine.indexOf('=') + 1;
+ StringBuilder labelNameScore = new StringBuilder(footerLine.substring(0, scoreStart));
+ for (int i = scoreStart; i < footerLine.length(); i++) {
+ char currentChar = footerLine.charAt(i);
+
+ // If we hit ',' before ' ' we have a UUID
+ if (currentChar == ',') {
+ labelNameScore.append(footerLine, scoreStart, i);
+ int uuidStart = i + LABEL_VOTE_UUID_SEPARATOR.length();
+ int uuidEnd = footerLine.indexOf(' ', uuidStart);
+ String uuid = footerLine.substring(uuidStart, uuidEnd > 0 ? uuidEnd : footerLine.length());
+ checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_LABEL, footerLine);
+ rawPatchSetApproval.uuid(Optional.of(uuid));
+ reviewerStartOffset = uuidStart + uuid.length();
+ break;
+ }
+
+ // Otherwise we don't
+ if (currentChar == ' ') {
+ labelNameScore.append(footerLine, scoreStart, i);
+ break;
+ }
+
+ // If we hit neither we're defensive assign the whole line
+ if (i == footerLine.length() - 1) {
+ labelNameScore = new StringBuilder(footerLine);
+ break;
+ }
+ }
+
+ rawPatchSetApproval.labelVote(labelNameScore.toString());
+
+ int reviewerStart = footerLine.indexOf(' ', reviewerStartOffset);
+ if (reviewerStart > 0) {
+ String ident = footerLine.substring(reviewerStart + 1);
+ rawPatchSetApproval.accountIdent(Optional.of(ident));
+ }
+ return rawPatchSetApproval.build();
+ }
+
+ /**
+ * Parses removed {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_LABEL} line.
+ *
+ * <p>Valid removed approval footer examples:
+ *
+ * <ul>
+ * <li>-&lt;LABEL&gt;
+ * <li>-&lt;LABEL&gt; &lt;Gerrit Account&gt;
+ * </ul>
+ *
+ * <p>&lt;Gerrit Account&gt; is only persisted in cases, when the account, that granted the vote
+ * does not match the account, that issued {@link ChangeUpdate} (created this NoteDB commit).
+ */
+ private static ParsedPatchSetApproval parseRemovedApproval(String footerLine) {
+ ParsedPatchSetApproval.Builder rawPatchSetApproval =
+ ParsedPatchSetApproval.builder().footerLine(footerLine);
+ rawPatchSetApproval.isRemoval(true);
+ int labelStart = 1;
+ int reviewerStart = footerLine.indexOf(' ', labelStart);
+
+ rawPatchSetApproval.labelVote(
+ reviewerStart != -1
+ ? footerLine.substring(labelStart, reviewerStart)
+ : footerLine.substring(labelStart));
+
+ if (reviewerStart > 0) {
+ String ident = footerLine.substring(reviewerStart + 1);
+ rawPatchSetApproval.accountIdent(Optional.of(ident));
+ }
+ return rawPatchSetApproval.build();
+ }
+
+ /**
+ * Parses copied {@link ParsedPatchSetApproval} from {@link ChangeNoteFooters#FOOTER_COPIED_LABEL}
+ * line.
+ *
+ * <p>Footer example: Copied-Label: <LABEL>=VOTE, <UUID> <Gerrit Account>,<Gerrit Real Account>
+ * :"<TAG>"
+ *
+ * <ul>
+ * <li>":<"TAG>"" is optional.
+ * <li><Gerrit Real Account> is also optional, if it was not set.
+ * <li><UUID> is optional, since the approval might have been granted before {@link
+ * com.google.gerrit.entities.PatchSetApproval.UUID} was introduced.
+ * <li>The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
+ * Account is also optional since by default it's the committer).
+ * </ul>
+ *
+ * <p>Footer example for removal: Copied-Label: -<LABEL> <Gerrit Account>,<Gerrit Real Account>
+ *
+ * <ul>
+ * <li><Gerrit Real Account> is also optional, if it was not set.
+ * </ul>
+ */
+ public static ParsedPatchSetApproval parseCopiedApproval(String labelLine)
+ throws ConfigInvalidException {
+ try {
+ ParsedPatchSetApproval.Builder rawPatchSetApproval =
+ ParsedPatchSetApproval.builder().footerLine(labelLine);
+
+ boolean isRemoval = labelLine.startsWith("-");
+ rawPatchSetApproval.isRemoval(isRemoval);
+ int labelStart = isRemoval ? 1 : 0;
+ int tagStart = isRemoval ? -1 : labelLine.indexOf(":\"");
+ int uuidStart = parseCopiedApprovalUuidStart(labelLine, tagStart);
+
+ checkFooter(!isRemoval || uuidStart == -1, FOOTER_LABEL, labelLine);
+
+ // Weird tag that contains uuid delimiter. The uuid is actually not present.
+ if (tagStart != -1 && uuidStart > tagStart) {
+ uuidStart = -1;
+ }
+
+ int identitiesStart =
+ labelLine.indexOf(
+ ' ', uuidStart != -1 ? uuidStart + LABEL_VOTE_UUID_SEPARATOR.length() : 0);
+ checkFooter(
+ identitiesStart != -1 && identitiesStart < labelLine.length(),
+ FOOTER_COPIED_LABEL,
+ labelLine);
+
+ String labelVoteStr =
+ labelLine.substring(labelStart, uuidStart != -1 ? uuidStart : identitiesStart);
+ rawPatchSetApproval.labelVote(labelVoteStr);
+ if (uuidStart != -1) {
+ String uuid = labelLine.substring(uuidStart + 2, identitiesStart);
+ checkFooter(!Strings.isNullOrEmpty(uuid), FOOTER_COPIED_LABEL, labelLine);
+ rawPatchSetApproval.uuid(Optional.of(uuid));
+ }
+ // The first account is the accountId, and second (if applicable) is the realAccountId.
+ List<String> identities =
+ parseIdentities(
+ labelLine.substring(
+ identitiesStart + 1, tagStart == -1 ? labelLine.length() : tagStart));
+
+ rawPatchSetApproval.accountIdent(Optional.of(identities.get(0)));
+
+ if (identities.size() > 1) {
+ rawPatchSetApproval.realAccountIdent(Optional.of(identities.get(1)));
+ }
+
+ if (tagStart != -1) {
+ // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
+ // line.length()-1 skips the last ".
+ String tag = labelLine.substring(tagStart + 2, labelLine.length() - 1);
+ rawPatchSetApproval.tag(Optional.of(tag));
+ }
+ return rawPatchSetApproval.build();
+ } catch (StringIndexOutOfBoundsException ex) {
+ throw parseException(FOOTER_COPIED_LABEL, labelLine, ex);
+ }
+ }
+
+ // Return the UUID start index or -1 if no UUID is present
+ private static int parseCopiedApprovalUuidStart(String line, int tagStart) {
+ int separatorIndex = line.indexOf(LABEL_VOTE_UUID_SEPARATOR);
+
+ // The first part of the condition checks whether the footer has the following format:
+ // Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
+ // Weird tag that contains uuid delimiter. The uuid is actually not present.
+ if ((tagStart != -1 && separatorIndex > tagStart)
+ ||
+
+ // The second part of the condition allows us to distinguish the following two lines:
+ // Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>
+ // Label2=+1 User Name (company_name, department) <2@gerrit>
+ (line.indexOf(' ') < separatorIndex)) {
+ return -1;
+ }
+ return separatorIndex;
+ }
+
+ // Splitting on "," breaks for identities containing commas. The below re-implements splitting on
+ // "(?<=>),", but it's 3-5x faster, as performance matters here.
+ private static List<String> parseIdentities(String line) {
+ List<String> idents = Splitter.on(',').splitToList(line);
+ List<String> identitiesList = new ArrayList<>();
+ for (int i = 0; i < idents.size(); i++) {
+ if (i == 0 || idents.get(i - 1).endsWith(">")) {
+ identitiesList.add(idents.get(i));
+ } else {
+ int lastIndex = identitiesList.size() - 1;
+ identitiesList.set(lastIndex, identitiesList.get(lastIndex) + "," + idents.get(i));
+ }
+ }
+ return identitiesList;
+ }
+
+ private static void checkFooter(boolean expr, FooterKey footer, String actual)
+ throws ConfigInvalidException {
+ if (!expr) {
+ throw parseException(footer, actual, /*cause=*/ null);
+ }
+ }
+
+ private static ConfigInvalidException parseException(
+ FooterKey footer, String actual, Throwable cause) {
+ return new ConfigInvalidException(
+ String.format("invalid %s: %s", footer.getName(), actual), cause);
+ }
+}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index f964d4eb4d..b6cdfac73a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -15,29 +15,28 @@
package com.google.gerrit.server.notedb;
import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+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_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+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_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+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_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
import static java.util.Comparator.comparing;
import static java.util.Comparator.comparingInt;
@@ -76,12 +75,11 @@ import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.SubmitRecord.Label.Status;
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.metrics.Timer0;
-import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
-import com.google.gerrit.server.notedb.ChangeNoteUtil.ParsedPatchSetApproval;
import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.notedb.ChangeNotesParseApprovalUtil.ParsedPatchSetApproval;
import com.google.gerrit.server.util.LabelVote;
import java.io.IOException;
import java.nio.charset.Charset;
@@ -95,6 +93,7 @@ import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -160,7 +159,6 @@ class ChangeNotesParser {
/** Holds all updates to attention set. */
private final List<AttentionSetUpdate> allAttentionSetUpdates;
- private final List<AssigneeStatusUpdate> assigneeUpdates;
private final List<SubmitRecord> submitRecords;
private final ListMultimap<ObjectId, HumanComment> humanComments;
private final List<SubmitRequirementResult> submitRequirementResults;
@@ -225,7 +223,6 @@ class ChangeNotesParser {
reviewerUpdates = new ArrayList<>();
latestAttentionStatus = new HashMap<>();
allAttentionSetUpdates = new ArrayList<>();
- assigneeUpdates = new ArrayList<>();
submitRecords = Lists.newArrayListWithExpectedSize(1);
allChangeMessages = new ArrayList<>();
humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
@@ -298,7 +295,6 @@ class ChangeNotesParser {
buildReviewerUpdates(),
ImmutableSet.copyOf(latestAttentionStatus.values()),
allAttentionSetUpdates,
- assigneeUpdates,
submitRecords,
buildAllMessages(),
humanComments,
@@ -327,6 +323,7 @@ class ChangeNotesParser {
return result;
}
+ @Nullable
private PatchSet.Id buildCurrentPatchSetId() {
// currentPatchSets are in parse order, i.e. newest first. Pick the first
// patch set that was marked as current, excluding deleted patch sets.
@@ -492,7 +489,6 @@ class ChangeNotesParser {
parseHashtags(commit);
parseAttentionSetUpdates(commit);
- parseAssigneeUpdates(commitTimestamp, commit);
parseSubmission(commit, commitTimestamp);
@@ -511,7 +507,7 @@ class ChangeNotesParser {
ObjectId currRev = parseRevision(commit);
if (currRev != null) {
- parsePatchSet(psId, currRev, accountId, commitTimestamp);
+ parsePatchSet(psId, currRev, accountId, realAccountId, commitTimestamp);
}
parseCurrentPatchSet(psId, commit);
@@ -580,6 +576,7 @@ class ChangeNotesParser {
return parseOneFooter(commit, FOOTER_SUBMISSION_ID);
}
+ @Nullable
private String parseBranch(ChangeNotesCommit commit) throws ConfigInvalidException {
String branch = parseOneFooter(commit, FOOTER_BRANCH);
return branch != null ? RefNames.fullName(branch) : null;
@@ -607,6 +604,7 @@ class ChangeNotesParser {
return parseOneFooter(commit, FOOTER_TOPIC);
}
+ @Nullable
private String parseOneFooter(ChangeNotesCommit commit, FooterKey footerKey)
throws ConfigInvalidException {
List<String> footerLines = commit.getFooterLineValues(footerKey);
@@ -627,6 +625,7 @@ class ChangeNotesParser {
return line;
}
+ @Nullable
private ObjectId parseRevision(ChangeNotesCommit commit) throws ConfigInvalidException {
String sha = parseOneFooter(commit, FOOTER_COMMIT);
if (sha == null) {
@@ -641,7 +640,8 @@ class ChangeNotesParser {
}
}
- private void parsePatchSet(PatchSet.Id psId, ObjectId rev, Account.Id accountId, Instant ts)
+ private void parsePatchSet(
+ 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());
@@ -658,6 +658,7 @@ class ChangeNotesParser {
.id(psId)
.commitId(rev)
.uploader(accountId)
+ .realUploader(realAccountId)
.createdOn(ts);
// Fields not set here:
// * Groups, parsed earlier in parseGroups.
@@ -736,22 +737,6 @@ class ChangeNotesParser {
}
}
- private void parseAssigneeUpdates(Instant ts, ChangeNotesCommit commit)
- throws ConfigInvalidException {
- String assigneeValue = parseOneFooter(commit, FOOTER_ASSIGNEE);
- if (assigneeValue != null) {
- Optional<Account.Id> parsedAssignee;
- if (assigneeValue.equals("")) {
- // Empty footer found, assignee deleted
- parsedAssignee = Optional.empty();
- } else {
- PersonIdent ident = RawParseUtils.parsePersonIdent(assigneeValue);
- parsedAssignee = Optional.ofNullable(parseIdent(ident));
- }
- assigneeUpdates.add(AssigneeStatusUpdate.create(ts, ownerId, parsedAssignee));
- }
- }
-
private void parseTag(ChangeNotesCommit commit) throws ConfigInvalidException {
tag = null;
List<String> tagLines = commit.getFooterLineValues(FOOTER_TAG);
@@ -764,6 +749,7 @@ class ChangeNotesParser {
}
}
+ @Nullable
private Change.Status parseStatus(ChangeNotesCommit commit) throws ConfigInvalidException {
List<String> statusLines = commit.getFooterLineValues(FOOTER_STATUS);
if (statusLines.isEmpty()) {
@@ -772,7 +758,7 @@ class ChangeNotesParser {
throw expectedOneFooter(FOOTER_STATUS, statusLines);
}
Change.Status status =
- Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase()).orNull();
+ Enums.getIfPresent(Change.Status.class, statusLines.get(0).toUpperCase(Locale.US)).orNull();
if (status == null) {
throw invalidFooter(FOOTER_STATUS, statusLines.get(0));
}
@@ -802,6 +788,7 @@ class ChangeNotesParser {
return PatchSet.id(id, psId);
}
+ @Nullable
private PatchSetState parsePatchSetState(ChangeNotesCommit commit) throws ConfigInvalidException {
String psIdLine = parseExactlyOneFooter(commit, FOOTER_PATCH_SET);
int s = psIdLine.indexOf(' ');
@@ -813,7 +800,7 @@ class ChangeNotesParser {
PatchSetState state =
Enums.getIfPresent(
PatchSetState.class,
- withParens.substring(1, withParens.length() - 1).toUpperCase())
+ withParens.substring(1, withParens.length() - 1).toUpperCase(Locale.US))
.orNull();
if (state != null) {
return state;
@@ -938,7 +925,8 @@ class ChangeNotesParser {
/** Parses copied {@link PatchSetApproval}. */
private void parseCopiedApproval(PatchSet.Id psId, Instant ts, String line)
throws ConfigInvalidException {
- ParsedPatchSetApproval parsedPatchSetApproval = ChangeNoteUtil.parseCopiedApproval(line);
+ ParsedPatchSetApproval parsedPatchSetApproval =
+ ChangeNotesParseApprovalUtil.parseCopiedApproval(line);
checkFooter(
parsedPatchSetApproval.accountIdent().isPresent(),
FOOTER_COPIED_LABEL,
@@ -996,7 +984,8 @@ class ChangeNotesParser {
throw parseException("patch set %s requires an identified user as uploader", psId.get());
}
PatchSetApproval.Builder psa;
- ParsedPatchSetApproval parsedPatchSetApproval = ChangeNoteUtil.parseApproval(line);
+ ParsedPatchSetApproval parsedPatchSetApproval =
+ ChangeNotesParseApprovalUtil.parseApproval(line);
if (line.startsWith("-")) {
psa = parseRemoveApproval(psId, accountId, realAccountId, ts, parsedPatchSetApproval);
} else {
@@ -1005,7 +994,7 @@ class ChangeNotesParser {
bufferedApprovals.add(psa);
}
- /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteUtil#FOOTER_LABEL} value. */
+ /** Parses {@link PatchSetApproval} out of the {@link ChangeNoteFooters#FOOTER_LABEL} value. */
private PatchSetApproval.Builder parseAddApproval(
PatchSet.Id psId,
Account.Id committerId,
@@ -1147,6 +1136,7 @@ class ChangeNotesParser {
}
}
+ @Nullable
private Account.Id parseIdent(ChangeNotesCommit commit) throws ConfigInvalidException {
// Check if the author name/email is the same as the committer name/email,
// i.e. was the server ident at the time this commit was made.
@@ -1232,6 +1222,7 @@ class ChangeNotesParser {
throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
}
+ @Nullable
private Change.Id parseRevertOf(ChangeNotesCommit commit) throws ConfigInvalidException {
String footer = parseOneFooter(commit, FOOTER_REVERT_OF);
if (footer == null) {
@@ -1245,7 +1236,7 @@ class ChangeNotesParser {
}
/**
- * Parses {@link ChangeNoteUtil#FOOTER_CHERRY_PICK_OF} of the commit.
+ * Parses {@link ChangeNoteFooters#FOOTER_CHERRY_PICK_OF} of the commit.
*
* @param commit the commit to parse.
* @return {@link Optional} value of the parsed footer or {@code null} if the footer is missing in
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index b0079d7002..1715b438b4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -50,12 +50,10 @@ import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
@@ -68,7 +66,6 @@ import com.google.gson.Gson;
import java.time.Instant;
import java.util.List;
import java.util.Map;
-import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.lib.ObjectId;
@@ -124,7 +121,6 @@ public abstract class ChangeNotesState {
List<ReviewerStatusUpdate> reviewerUpdates,
Set<AttentionSetUpdate> attentionSetUpdates,
List<AttentionSetUpdate> allAttentionSetUpdates,
- List<AssigneeStatusUpdate> assigneeUpdates,
List<SubmitRecord> submitRecords,
List<ChangeMessage> changeMessages,
ListMultimap<ObjectId, HumanComment> publishedComments,
@@ -178,7 +174,6 @@ public abstract class ChangeNotesState {
.reviewerUpdates(reviewerUpdates)
.attentionSet(attentionSetUpdates)
.allAttentionSetUpdates(allAttentionSetUpdates)
- .assigneeUpdates(assigneeUpdates)
.submitRecords(submitRecords)
.changeMessages(changeMessages)
.publishedComments(publishedComments)
@@ -320,8 +315,6 @@ public abstract class ChangeNotesState {
/** Returns all attention set updates. */
abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
- abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
-
abstract ImmutableList<SubmitRecord> submitRecords();
abstract ImmutableList<ChangeMessage> changeMessages();
@@ -369,9 +362,6 @@ public abstract class ChangeNotesState {
change.setTopic(Strings.emptyToNull(c.topic()));
change.setLastUpdatedOn(c.lastUpdatedOn());
change.setSubmissionId(c.submissionId());
- if (!assigneeUpdates().isEmpty()) {
- change.setAssignee(assigneeUpdates().get(0).currentAssignee().orElse(null));
- }
change.setPrivate(c.isPrivate());
change.setWorkInProgress(c.workInProgress());
change.setReviewStarted(c.reviewStarted());
@@ -404,7 +394,6 @@ public abstract class ChangeNotesState {
.reviewerUpdates(ImmutableList.of())
.attentionSet(ImmutableSet.of())
.allAttentionSetUpdates(ImmutableList.of())
- .assigneeUpdates(ImmutableList.of())
.submitRecords(ImmutableList.of())
.changeMessages(ImmutableList.of())
.publishedComments(ImmutableListMultimap.of())
@@ -442,8 +431,6 @@ public abstract class ChangeNotesState {
abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
- abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
-
abstract Builder submitRecords(List<SubmitRecord> submitRecords);
abstract Builder changeMessages(List<ChangeMessage> changeMessages);
@@ -519,7 +506,6 @@ public abstract class ChangeNotesState {
object
.allAttentionSetUpdates()
.forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
- object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
object
.submitRecords()
.forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
@@ -616,17 +602,6 @@ public abstract class ChangeNotesState {
.build();
}
- private static AssigneeStatusUpdateProto toAssigneeStatusUpdateProto(AssigneeStatusUpdate u) {
- AssigneeStatusUpdateProto.Builder builder =
- AssigneeStatusUpdateProto.newBuilder()
- .setTimestampMillis(u.date().toEpochMilli())
- .setUpdatedBy(u.updatedBy().get())
- .setHasCurrentAssignee(u.currentAssignee().isPresent());
-
- u.currentAssignee().ifPresent(assignee -> builder.setCurrentAssignee(assignee.get()));
- return builder.build();
- }
-
@Override
public ChangeNotesState deserialize(byte[] in) {
ChangeNotesStateProto proto = Protos.parseUnchecked(ChangeNotesStateProto.parser(), in);
@@ -659,7 +634,6 @@ public abstract class ChangeNotesState {
.attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
.allAttentionSetUpdates(
toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
- .assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
.submitRecords(
proto.getSubmitRecordList().stream()
.map(r -> GSON.fromJson(r, StoredSubmitRecord.class).toSubmitRecord())
@@ -783,20 +757,5 @@ public abstract class ChangeNotesState {
}
return b.build();
}
-
- private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
- List<AssigneeStatusUpdateProto> protos) {
- ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
- for (AssigneeStatusUpdateProto proto : protos) {
- b.add(
- AssigneeStatusUpdate.create(
- Instant.ofEpochMilli(proto.getTimestampMillis()),
- Account.id(proto.getUpdatedBy()),
- proto.getHasCurrentAssignee()
- ? Optional.of(Account.id(proto.getCurrentAssignee()))
- : Optional.empty()));
- }
- return b.build();
- }
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 62c734bc6c..0a895fb405 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -18,31 +18,31 @@ import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_BRANCH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_BRANCH;
+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_COPIED_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_CURRENT;
+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_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_PRIVATE;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REVERT_OF;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_STATUS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_SUBJECT;
+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_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_WORK_IN_PROGRESS;
import static com.google.gerrit.server.notedb.NoteDbUtil.sanitizeFooter;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.util.Comparator.naturalOrder;
import static java.util.Objects.requireNonNull;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -80,6 +80,7 @@ import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.approval.PatchSetApprovalUuidGenerator;
import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.LabelVote;
import com.google.gerrit.server.validators.ValidationException;
@@ -94,6 +95,7 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -160,7 +162,6 @@ public class ChangeUpdate extends AbstractChangeUpdate {
private String commit;
private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
private boolean ignoreFurtherAttentionSetUpdates;
- private Optional<Account.Id> assignee;
private Set<String> hashtags;
private String changeMessage;
private String tag;
@@ -250,9 +251,11 @@ public class ChangeUpdate extends AbstractChangeUpdate {
}
public ObjectId commit() throws IOException {
- try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
- updateManager.add(this);
- updateManager.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (NoteDbUpdateManager updateManager = updateManagerFactory.create(getProjectName())) {
+ updateManager.add(this);
+ updateManager.execute();
+ }
}
return getResult();
}
@@ -510,15 +513,6 @@ public class ChangeUpdate extends AbstractChangeUpdate {
return attentionSetUpdatesBuilder.build();
}
- public void setAssignee(Account.Id assignee) {
- checkArgument(assignee != null, "use removeAssignee");
- this.assignee = Optional.of(assignee);
- }
-
- public void removeAssignee() {
- this.assignee = Optional.empty();
- }
-
public Map<Account.Id, ReviewerStateInternal> getReviewers() {
return reviewers;
}
@@ -571,6 +565,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
}
/** Returns the tree id for the updated tree */
+ @Nullable
private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
throws ConfigInvalidException, IOException {
if (submitRequirementResults == null && comments.isEmpty() && pushCert == null) {
@@ -747,7 +742,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
}
if (status != null) {
- addFooter(msg, FOOTER_STATUS, status.name().toLowerCase());
+ addFooter(msg, FOOTER_STATUS, status.name().toLowerCase(Locale.US));
if (status.equals(Change.Status.ABANDONED)) {
clearAttentionSet("Change was abandoned");
}
@@ -764,15 +759,6 @@ public class ChangeUpdate extends AbstractChangeUpdate {
addFooter(msg, FOOTER_COMMIT, commit);
}
- if (assignee != null) {
- if (assignee.isPresent()) {
- addFooter(msg, FOOTER_ASSIGNEE);
- noteUtil.appendAccountIdIdentString(msg, assignee.get()).append('\n');
- } else {
- addFooter(msg, FOOTER_ASSIGNEE).append('\n');
- }
- }
-
Joiner comma = Joiner.on(',');
if (hashtags != null) {
addFooter(msg, FOOTER_HASHTAGS, comma.join(hashtags));
@@ -1100,7 +1086,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
// remove users that are currently being removed from the attention set.
.filter(
a ->
- plannedAttentionSetUpdates.getOrDefault(a, /*defaultValue= */ null) == null
+ plannedAttentionSetUpdates.getOrDefault(a, /* defaultValue= */ null) == null
|| plannedAttentionSetUpdates.get(a).operation().equals(Operation.REMOVE))
// remove users that are still active on the change.
.filter(a -> !isActiveOnChange(currentReviewers, a))
@@ -1144,7 +1130,7 @@ public class ChangeUpdate extends AbstractChangeUpdate {
private void addPatchSetFooter(StringBuilder sb, PatchSet.Id ps) {
addFooter(sb, FOOTER_PATCH_SET).append(ps.get());
if (psState != null) {
- sb.append(" (").append(psState.name().toLowerCase()).append(')');
+ sb.append(" (").append(psState.name().toLowerCase(Locale.US)).append(')');
}
sb.append('\n');
}
@@ -1172,7 +1158,6 @@ public class ChangeUpdate extends AbstractChangeUpdate {
&& status == null
&& submissionId == null
&& submitRecords == null
- && assignee == null
&& hashtags == null
&& topic == null
&& commit == null
diff --git a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
index 2f47107889..e74af5b784 100644
--- a/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
+++ b/java/com/google/gerrit/server/notedb/CommentTimestampAdapter.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.notedb;
import static java.time.format.DateTimeFormatter.ISO_INSTANT;
+import com.google.common.annotations.VisibleForTesting;
import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
@@ -27,7 +28,7 @@ import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.FormatStyle;
-import java.time.temporal.TemporalAccessor;
+import java.util.Locale;
/**
* Adapter that reads/writes {@link Timestamp}s as ISO 8601 instant in UTC.
@@ -49,6 +50,16 @@ class CommentTimestampAdapter extends TypeAdapter<Timestamp> {
private static final DateTimeFormatter FALLBACK =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM);
+ /**
+ * Fixed format to parse date/time in the "Feb 7, 2017 2:20:30 AM" format
+ *
+ * <p>Some old comments (created in Jan-Feb 2017) can be stored in legacy format, which can't be
+ * parsed with {@link #FALLBACK} formatter if the system/default locale has been changed. We will
+ * try to parse with a fixed format if {@link #FALLBACK} doesn't work.
+ */
+ private static final DateTimeFormatter FIXED_FORMAT_FALLBACK =
+ DateTimeFormatter.ofPattern("MMM d, yyyy h:mm:ss a").withLocale(Locale.US);
+
@Override
public void write(JsonWriter out, Timestamp ts) throws IOException {
Timestamp truncated = new Timestamp(ts.getTime() / 1000 * 1000);
@@ -58,12 +69,26 @@ class CommentTimestampAdapter extends TypeAdapter<Timestamp> {
@Override
public Timestamp read(JsonReader in) throws IOException {
String str = in.nextString();
- TemporalAccessor ta;
try {
- ta = ISO_INSTANT.parse(str);
+ return Timestamp.from(Instant.from(ISO_INSTANT.parse(str)));
} catch (DateTimeParseException e) {
- ta = LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault());
+ try {
+ return parseDateTimeWithDefaultLocaleFormat(str);
+ } catch (DateTimeParseException e2) {
+ return parseDateTimeWithFixedFormat(str);
+ }
}
- return Timestamp.from(Instant.from(ta));
+ }
+
+ public static Timestamp parseDateTimeWithDefaultLocaleFormat(String str) {
+ return Timestamp.from(
+ Instant.from(LocalDateTime.from(FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
+ }
+
+ @VisibleForTesting
+ public static Timestamp parseDateTimeWithFixedFormat(String str) {
+ return Timestamp.from(
+ Instant.from(
+ LocalDateTime.from(FIXED_FORMAT_FALLBACK.parse(str)).atZone(ZoneId.systemDefault())));
}
}
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
index 84776cfda8..270fc3262a 100644
--- a/java/com/google/gerrit/server/notedb/CommitRewriter.java
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -15,12 +15,12 @@ package com.google.gerrit.server.notedb;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
-import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+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.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -49,6 +49,7 @@ import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
import com.google.gerrit.server.notedb.ChangeNoteUtil.CommitMessageRange;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.gson.Gson;
import com.google.inject.Inject;
@@ -89,6 +90,7 @@ import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterKey;
import org.eclipse.jgit.revwalk.FooterLine;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevSort;
@@ -110,6 +112,10 @@ import org.eclipse.jgit.util.RawParseUtils;
@UsedAt(UsedAt.Project.GOOGLE)
@Singleton
public class CommitRewriter {
+ // Reading and Writing assignee footer no longer supported. We keep the definition here to be able
+ // to rewrite older commit messages.
+ public static final FooterKey FOOTER_ASSIGNEE = new FooterKey("Assignee");
+
/** Options to run {@link #backfillProject}. */
public static class RunOptions implements Serializable {
private static final long serialVersionUID = 1L;
@@ -340,13 +346,15 @@ public class CommitRewriter {
if (refsUpdate == null) {
return;
}
- if (!refsUpdate.batchRefUpdate().getCommands().isEmpty()) {
- if (!options.dryRun) {
- refsUpdate.inserter().flush();
- RefUpdateUtil.executeChecked(refsUpdate.batchRefUpdate(), refsUpdate.revWalk());
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ if (!refsUpdate.batchRefUpdate().getCommands().isEmpty()) {
+ if (!options.dryRun) {
+ refsUpdate.inserter().flush();
+ RefUpdateUtil.executeChecked(refsUpdate.batchRefUpdate(), refsUpdate.revWalk());
+ }
}
+ refsUpdate.close();
}
- refsUpdate.close();
}
/**
@@ -368,7 +376,9 @@ public class CommitRewriter {
}
}
accounts.addAll(changeNotes.getAllPastReviewers());
- accounts.addAll(changeNotes.getPastAssignees());
+ // Change Notes class can no longer read or write assignees, we skip assignee accounts at
+ // verifyCommit stage.
+ // accounts.addAll(changeNotes.getPastAssignees());
changeNotes
.getAttentionSetUpdates()
.forEach(attentionSetUpdate -> accounts.add(attentionSetUpdate.account()));
@@ -896,7 +906,7 @@ public class CommitRewriter {
commitMessageRange.get().subjectEnd());
Optional<String> fixedChangeMessage = Optional.empty();
String originalChangeMessage = null;
- if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
+ if (commitMessageRange.get().hasChangeMessage()) {
originalChangeMessage =
RawParseUtils.decode(
enc,
diff --git a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
index c8d93f83f2..3f3ede14d4 100644
--- a/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
+++ b/java/com/google/gerrit/server/notedb/DeleteZombieCommentsRefs.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.notedb;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_DRAFT_COMMENTS;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
import com.google.auto.value.AutoValue;
@@ -36,6 +37,7 @@ import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
@@ -402,17 +404,19 @@ public class DeleteZombieCommentsRefs {
private void deleteBatchZombieRefs(Repository allUsersRepo, List<Ref> refsBatch)
throws IOException {
- List<ReceiveCommand> deleteCommands =
- refsBatch.stream()
- .map(
- zombieRef ->
- new ReceiveCommand(
- zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
- .collect(toImmutableList());
- BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
- bru.setAtomic(true);
- bru.addCommand(deleteCommands);
- RefUpdateUtil.executeChecked(bru, allUsersRepo);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ List<ReceiveCommand> deleteCommands =
+ refsBatch.stream()
+ .map(
+ zombieRef ->
+ new ReceiveCommand(
+ zombieRef.getObjectId(), ObjectId.zeroId(), zombieRef.getName()))
+ .collect(toImmutableList());
+ BatchRefUpdate bru = allUsersRepo.getRefDatabase().newBatchUpdate();
+ bru.setAtomic(true);
+ bru.addCommand(deleteCommands);
+ RefUpdateUtil.executeChecked(bru, allUsersRepo);
+ }
}
private List<Ref> filterZombieRefs(Repository allUsersRepo, List<Ref> allDraftRefs)
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 5d8f57f49e..bdfe3782af 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -141,6 +141,7 @@ public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
return args.allUsers;
}
+ @Nullable
@VisibleForTesting
NoteMap getNoteMap() {
return revisionNoteMap != null ? revisionNoteMap.noteMap : null;
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index ad1f4c5155..0939ada10d 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -365,6 +365,7 @@ public class NoteDbUpdateManager implements AutoCloseable {
cu -> cu.getAttentionSetUpdates().stream()));
}
+ @Nullable
private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
throws IOException {
if (or == null || or.cmds.isEmpty()) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUtil.java b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
index 0c0238df65..5fc9244ea6 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUtil.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUtil.java
@@ -18,6 +18,7 @@ import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -117,6 +118,7 @@ public class NoteDbUtil {
* Returns the name of the REST API handler that is in the stack trace of the caller of this
* method.
*/
+ @Nullable
static String guessRestApiHandler() {
StackTraceElement[] trace = Thread.currentThread().getStackTrace();
int i = findRestApiServlet(trace);
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java
index d743921362..9aaac19149 100644
--- a/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.notedb;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.gerrit.entities.RefNames.REFS;
import static com.google.gerrit.entities.RefNames.REFS_SEQUENCES;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.REPO_SEQ;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -39,6 +40,7 @@ import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.git.RefUpdateUtil;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -265,29 +267,31 @@ public class RepoSequence {
* @param count the number of sequence numbers which should be retrieved
*/
private void acquire(int count) {
- try (Repository repo = repoManager.openRepository(projectName);
- RevWalk rw = new RevWalk(repo)) {
- logger.atFine().log("acquire %d ids on %s in %s", count, refName, projectName);
- Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
- afterReadRef.run();
- ObjectId oldId;
- int next;
- if (!blob.isPresent()) {
- oldId = ObjectId.zeroId();
- next = seed.get();
- } else {
- oldId = blob.get().id();
- next = blob.get().value();
+ try (RefUpdateContext ctx = RefUpdateContext.open(REPO_SEQ)) {
+ try (Repository repo = repoManager.openRepository(projectName);
+ RevWalk rw = new RevWalk(repo)) {
+ logger.atFine().log("acquire %d ids on %s in %s", count, refName, projectName);
+ Optional<IntBlob> blob = IntBlob.parse(repo, refName, rw);
+ afterReadRef.run();
+ ObjectId oldId;
+ int next;
+ if (!blob.isPresent()) {
+ oldId = ObjectId.zeroId();
+ next = seed.get();
+ } else {
+ oldId = blob.get().id();
+ next = blob.get().value();
+ }
+ next = Math.max(floor, next);
+ RefUpdate refUpdate =
+ IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
+ RefUpdateUtil.checkResult(refUpdate);
+ counter = next;
+ limit = counter + count;
+ acquireCount++;
+ } catch (IOException e) {
+ throw new StorageException(e);
}
- next = Math.max(floor, next);
- RefUpdate refUpdate =
- IntBlob.tryStore(repo, rw, projectName, refName, oldId, next + count, gitRefUpdated);
- RefUpdateUtil.checkResult(refUpdate);
- counter = next;
- limit = counter + count;
- acquireCount++;
- } catch (IOException e) {
- throw new StorageException(e);
}
}
diff --git a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
index 7f067f569b..e1e63054de 100644
--- a/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
+++ b/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -19,6 +19,7 @@ import static com.google.gerrit.entities.RefNames.robotCommentsRef;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
@@ -100,6 +101,7 @@ public class RobotCommentUpdate extends AbstractChangeUpdate {
put.add(c);
}
+ @Nullable
private CommitBuilder storeCommentsInNotes(
RevWalk rw, ObjectInserter ins, ObjectId curr, CommitBuilder cb)
throws ConfigInvalidException, IOException {
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 1f4720d0cf..0818f2360d 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -164,10 +164,14 @@ public class AutoMerger {
public Optional<ReceiveCommand> createAutoMergeCommitIfNecessary(
RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit maybeMergeCommit)
throws IOException {
- if (maybeMergeCommit.getParentCount() != 2 || !save) {
+ if (maybeMergeCommit.getParentCount() != 2) {
logger.atFine().log("AutoMerge not required");
return Optional.empty();
}
+ if (!save) {
+ logger.atFine().log("Saving AutoMerge is disabled");
+ return Optional.empty();
+ }
String automergeRef = RefNames.refsCacheAutomerge(maybeMergeCommit.name());
logger.atFine().log("AutoMerge ref=%s, mergeCommit=%s", automergeRef, maybeMergeCommit.name());
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index 56a01b92fc..a264793756 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -26,13 +26,11 @@ import com.google.inject.Singleton;
import java.io.IOException;
import java.util.Optional;
import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevWalk;
/** A utility class for computing the base commit / parent for a specific patchset commit. */
@@ -51,7 +49,8 @@ class BaseCommitUtil {
this.repoManager = repoManager;
}
- RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
+ @Nullable
+ RevCommit getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
throws IOException {
try (Repository repo = repoManager.openRepository(project);
ObjectInserter ins = newInserter(repo);
@@ -89,9 +88,12 @@ class BaseCommitUtil {
* commitId} has a single parent, it will be returned.
* @param commitId 20 bytes commitId SHA-1 hash.
* @return Returns the parent commit of the commit represented by the commitId parameter. Note
- * that auto-merge is not supported for commits having more than two parents.
+ * that auto-merge is not supported for commits having more than two parents. If the commit
+ * has no parents (initial commit) or more than 2 parents {@code null} is returned as the
+ * parent commit.
*/
- RevObject getParentCommit(
+ @Nullable
+ RevCommit getParentCommit(
Repository repo,
ObjectInserter ins,
RevWalk rw,
@@ -101,7 +103,7 @@ class BaseCommitUtil {
RevCommit current = rw.parseCommit(commitId);
switch (current.getParentCount()) {
case 0:
- return rw.parseAny(emptyTree(ins));
+ return null;
case 1:
return current.getParent(0);
default:
@@ -145,10 +147,4 @@ class BaseCommitUtil {
private ObjectInserter newInserter(Repository repo) {
return saveAutomerge ? repo.newObjectInserter() : new InMemoryInserter(repo);
}
-
- private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
- ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
- ins.flush();
- return id;
- }
}
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index b57ab60d25..4d0bcc8358 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -55,6 +55,7 @@ import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.io.DisabledOutputStream;
@@ -488,7 +489,16 @@ public class DiffOperationsImpl implements DiffOperations {
DiffParameters.Builder result =
DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
if (parent > 0) {
- result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
+ RevCommit baseCommit = baseCommitUtil.getBaseCommit(project, newCommit, parent);
+ if (baseCommit == null) {
+ // The specified parent doesn't exist or is not supported, fall back to comparing against
+ // the root.
+ result.baseCommit(ObjectId.zeroId());
+ result.comparisonType(ComparisonType.againstRoot());
+ return result.build();
+ }
+
+ result.baseCommit(baseCommit);
result.comparisonType(ComparisonType.againstParent(parent));
return result.build();
}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
index 70a3208c8b..115830ea6a 100644
--- a/java/com/google/gerrit/server/patch/DiffUtil.java
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -15,19 +15,29 @@
package com.google.gerrit.server.patch;
+import com.google.common.base.Strings;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
import java.io.IOException;
+import java.io.OutputStream;
import java.util.Comparator;
import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
/**
* A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
@@ -116,6 +126,81 @@ public class DiffUtil {
return 0;
}
+ /**
+ * Get formatted diff between the given commits, either for a single path if specified, or for the
+ * full trees.
+ *
+ * @param repo to get the diff from
+ * @param baseCommit to compare with
+ * @param childCommit to compare
+ * @param path to narrow the diff to
+ * @param out to append the diff to
+ * @throws IOException if the diff couldn't be written
+ */
+ public static void getFormattedDiff(
+ Repository repo,
+ RevCommit baseCommit,
+ RevCommit childCommit,
+ @Nullable String path,
+ OutputStream out)
+ throws IOException {
+ getFormattedDiff(repo, null, baseCommit.getTree(), childCommit.getTree(), path, out);
+ }
+
+ public static void getFormattedDiff(
+ Repository repo,
+ @Nullable ObjectReader reader,
+ RevTree baseTree,
+ RevTree childTree,
+ @Nullable String path,
+ OutputStream out)
+ throws IOException {
+ try (DiffFormatter fmt = new DiffFormatter(out)) {
+ fmt.setRepository(repo);
+ if (reader != null) {
+ fmt.setReader(reader, repo.getConfig());
+ }
+ if (path != null) {
+ fmt.setPathFilter(PathFilter.create(path));
+ }
+ fmt.format(baseTree, childTree);
+ fmt.flush();
+ }
+ }
+
+ public static String cleanPatch(final String patch) {
+ String res = removePatchHeader(patch);
+ return res
+ // Remove "index NN..NN" lines
+ .replaceAll("(?m)^index.*", "")
+ // Remove hunk-headers lines
+ .replaceAll("(?m)^@@ .*", "")
+ // Remove empty lines
+ .replaceAll("\n+", "\n")
+ // Trim
+ .trim();
+ }
+
+ public static String removePatchHeader(final String patch) {
+ String res = patch.trim();
+ if (!res.startsWith("diff --") && res.contains("\ndiff --")) {
+ return res.substring(patch.indexOf("\ndiff --"), patch.length() - 1);
+ }
+ return res;
+ }
+
+ public static Optional<String> getPatchHeader(final String patch) {
+ if (patch.startsWith("diff --")) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(
+ Strings.emptyToNull(patch.trim().substring(0, patch.indexOf("\ndiff --git"))));
+ }
+
+ public static String cleanPatch(BinaryResult bin) throws IOException {
+ return cleanPatch(bin.asString());
+ }
+
private static boolean isRootOrMergeCommit(RevCommit commit) {
return commit.getParentCount() != 1;
}
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
index 2c98f1a0b1..d0b7ac6c5c 100644
--- a/java/com/google/gerrit/server/patch/FilePathAdapter.java
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.patch;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Patch.ChangeType;
import java.util.Optional;
@@ -30,6 +31,7 @@ public class FilePathAdapter {
/**
* Converts the old file path of the new diff cache output to the old diff cache representation.
*/
+ @Nullable
public static String getOldPath(Optional<String> oldName, ChangeType changeType) {
switch (changeType) {
case DELETED:
diff --git a/java/com/google/gerrit/server/patch/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
index d6afa88de8..3eb50d3f17 100644
--- a/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -40,7 +40,7 @@ import org.eclipse.jgit.diff.Edit;
import org.eclipse.jgit.diff.MyersDiff;
import org.eclipse.jgit.lib.Config;
-class IntraLineLoader implements Callable<IntraLineDiff> {
+public class IntraLineLoader implements Callable<IntraLineDiff> {
static final FluentLogger logger = FluentLogger.forEnclosingClass();
interface Factory {
@@ -105,7 +105,7 @@ class IntraLineLoader implements Callable<IntraLineDiff> {
}
}
- static IntraLineDiff compute(
+ public static IntraLineDiff compute(
Text aText,
Text bText,
ImmutableList<Edit> immutableEdits,
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 33300e311b..8dff5364d2 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -19,6 +19,7 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.PatchScript;
import com.google.gerrit.common.data.PatchScript.DisplayMethod;
import com.google.gerrit.entities.FixReplacement;
@@ -121,7 +122,7 @@ class PatchScriptBuilder {
if (a.mode == FileMode.MISSING) {
throw new ResourceNotFoundException(String.format("File %s not found", fileName));
}
- FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements);
+ FixCalculator.FixResult fixResult = FixCalculator.calculateFix(a.src, fixReplacements, true);
PatchSide b =
new PatchSide(
null,
@@ -209,6 +210,7 @@ class PatchScriptBuilder {
}
}
+ @Nullable
private static String oldName(PatchFileChange entry) {
switch (entry.getChangeType()) {
case ADDED:
@@ -224,6 +226,7 @@ class PatchScriptBuilder {
}
}
+ @Nullable
private static String newName(PatchFileChange entry) {
switch (entry.getChangeType()) {
case DELETED:
@@ -412,6 +415,7 @@ class PatchScriptBuilder {
treeId, path, id, mode, srcContent, src, mimeType, displayMethod, fileMode);
}
+ @Nullable
private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException {
if (path == null || within == null) {
return null;
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 3e4e72dcbf..d1bda5cb6d 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -97,7 +97,7 @@ public class FileDiffCacheImpl implements FileDiffCache {
persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
.maximumWeight(10 << 20)
.weigher(FileDiffWeigher.class)
- .version(8)
+ .version(9)
.keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
.valueSerializer(FileDiffOutput.Serializer.INSTANCE)
.loader(FileDiffLoader.class);
@@ -443,6 +443,8 @@ public class FileDiffCacheImpl implements FileDiffCache {
.patchType(mainGitDiff.patchType())
.oldPath(mainGitDiff.oldPath())
.newPath(mainGitDiff.newPath())
+ .oldMode(mainGitDiff.oldMode())
+ .newMode(mainGitDiff.newMode())
.headerLines(FileHeaderUtil.getHeaderLines(mainGitDiff.fileHeader()))
.edits(asTaggedEdits(mainGitDiff.edits(), rebaseEdits))
.size(newSize)
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index 31fe77ac6e..9286f47939 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -17,9 +17,12 @@ package com.google.gerrit.server.patch.filediff;
import static com.google.gerrit.server.patch.DiffUtil.stringSize;
import com.google.auto.value.AutoValue;
+import com.google.common.base.Converter;
+import com.google.common.base.Enums;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
import com.google.gerrit.entities.Patch.PatchType;
import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.cache.proto.Cache.FileDiffOutputProto;
@@ -61,6 +64,18 @@ public abstract class FileDiffOutput implements Serializable {
*/
public abstract Optional<String> newPath();
+ /**
+ * The file mode of the old file at the old git tree diff identified by {@link #oldCommitId()}
+ * ()}.
+ */
+ public abstract Optional<Patch.FileMode> oldMode();
+
+ /**
+ * The file mode of the new file at the new git tree diff identified by {@link #newCommitId()}
+ * ()}.
+ */
+ public abstract Optional<Patch.FileMode> newMode();
+
/** The change type of the underlying file, e.g. added, deleted, renamed, etc... */
public abstract Patch.ChangeType changeType();
@@ -201,6 +216,10 @@ public abstract class FileDiffOutput implements Serializable {
public abstract Builder newPath(Optional<String> value);
+ public abstract Builder oldMode(Optional<Patch.FileMode> oldMode);
+
+ public abstract Builder newMode(Optional<Patch.FileMode> newMode);
+
public abstract Builder changeType(ChangeType value);
public abstract Builder patchType(Optional<PatchType> value);
@@ -221,6 +240,9 @@ public abstract class FileDiffOutput implements Serializable {
public enum Serializer implements CacheSerializer<FileDiffOutput> {
INSTANCE;
+ private static final Converter<String, FileMode> FILE_MODE_CONVERTER =
+ Enums.stringConverter(Patch.FileMode.class);
+
private static final FieldDescriptor OLD_PATH_DESCRIPTOR =
FileDiffOutputProto.getDescriptor().findFieldByNumber(1);
@@ -233,6 +255,12 @@ public abstract class FileDiffOutput implements Serializable {
private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
FileDiffOutputProto.getDescriptor().findFieldByNumber(12);
+ private static final FieldDescriptor OLD_MODE_DESCRIPTOR =
+ FileDiffOutputProto.getDescriptor().findFieldByNumber(13);
+
+ private static final FieldDescriptor NEW_MODE_DESCRIPTOR =
+ FileDiffOutputProto.getDescriptor().findFieldByNumber(14);
+
@Override
public byte[] serialize(FileDiffOutput fileDiff) {
ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -277,6 +305,13 @@ public abstract class FileDiffOutput implements Serializable {
builder.setNegative(fileDiff.negative().get());
}
+ if (fileDiff.oldMode().isPresent()) {
+ builder.setOldMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.oldMode().get()));
+ }
+ if (fileDiff.newMode().isPresent()) {
+ builder.setNewMode(FILE_MODE_CONVERTER.reverse().convert(fileDiff.newMode().get()));
+ }
+
return Protos.toByteArray(builder.build());
}
@@ -318,6 +353,12 @@ public abstract class FileDiffOutput implements Serializable {
if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
builder.negative(Optional.of(proto.getNegative()));
}
+ if (proto.hasField(OLD_MODE_DESCRIPTOR)) {
+ builder.oldMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getOldMode())));
+ }
+ if (proto.hasField(NEW_MODE_DESCRIPTOR)) {
+ builder.newMode(Optional.of(FILE_MODE_CONVERTER.convert(proto.getNewMode())));
+ }
return builder.build();
}
}
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 0cfaa669a3..72dc4348c5 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -28,6 +28,7 @@ import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -66,6 +67,7 @@ import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.diff.HistogramDiff;
import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.AbbreviatedObjectId;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
@@ -76,6 +78,7 @@ import org.eclipse.jgit.util.io.DisabledOutputStream;
/** Implementation of the {@link GitFileDiffCache} */
@Singleton
public class GitFileDiffCacheImpl implements GitFileDiffCache {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final String GIT_DIFF = "git_file_diff";
public static Module module() {
@@ -340,8 +343,7 @@ public class GitFileDiffCacheImpl implements GitFileDiffCache {
throws IOException {
if (!key.useTimeout()) {
try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
- FileHeader fileHeader = formatter.get().toFileHeader(diffEntry);
- return GitFileDiff.create(diffEntry, fileHeader);
+ return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
}
}
// This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
@@ -353,7 +355,7 @@ public class GitFileDiffCacheImpl implements GitFileDiffCache {
diffExecutor.submit(
() -> {
try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
- return GitFileDiff.create(diffEntry, formatter.get().toFileHeader(diffEntry));
+ return GitFileDiff.create(diffEntry, getFileHeader(formatter, diffEntry));
}
});
try {
@@ -385,6 +387,46 @@ public class GitFileDiffCacheImpl implements GitFileDiffCache {
? diffEntry.getOldPath()
: diffEntry.getNewPath();
}
+
+ private FileHeader getFileHeader(
+ CloseablePool<DiffFormatter>.Handle formatter, DiffEntry diffEntry) throws IOException {
+ logger.atFine().log("getting file header for %s", formatDiffEntryForLogging(diffEntry));
+ try {
+ return formatter.get().toFileHeader(diffEntry);
+ } catch (MissingObjectException e) {
+ throw new IOException(
+ String.format("Failed to get file header for %s", formatDiffEntryForLogging(diffEntry)),
+ e);
+ }
+ }
+
+ private String formatDiffEntryForLogging(DiffEntry diffEntry) {
+ StringBuilder buf = new StringBuilder();
+ buf.append("DiffEntry[");
+ buf.append(diffEntry.getChangeType());
+ buf.append(" ");
+ switch (diffEntry.getChangeType()) {
+ case ADD:
+ buf.append(String.format("%s (%s)", diffEntry.getNewPath(), diffEntry.getNewId().name()));
+ break;
+ case COPY:
+ case RENAME:
+ buf.append(
+ String.format(
+ "%s (%s) -> %s (%s)",
+ diffEntry.getOldPath(),
+ diffEntry.getOldId().name(),
+ diffEntry.getNewPath(),
+ diffEntry.getNewId().name()));
+ break;
+ case DELETE:
+ case MODIFY:
+ buf.append(String.format("%s (%s)", diffEntry.getOldPath(), diffEntry.getOldId().name()));
+ break;
+ }
+ buf.append("]");
+ return buf.toString();
+ }
}
/**
diff --git a/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
new file mode 100644
index 0000000000..622f0cf624
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/AbstractLabelPermission.java
@@ -0,0 +1,155 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
+import static java.util.Objects.requireNonNull;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Abstract permission representing a label. */
+public abstract class AbstractLabelPermission implements ChangePermissionOrLabel {
+ public enum ForUser {
+ SELF,
+ ON_BEHALF_OF
+ }
+
+ protected final ForUser forUser;
+ protected final String name;
+
+ /**
+ * Construct a reference to an abstract label permission.
+ *
+ * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+ * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+ */
+ public AbstractLabelPermission(ForUser forUser, String name) {
+ this.forUser = requireNonNull(forUser, "ForUser");
+ this.name = LabelType.checkName(name);
+ }
+
+ /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+ public ForUser forUser() {
+ return forUser;
+ }
+
+ /** Returns name of the label, e.g. {@code "Code-Review"}. */
+ public String label() {
+ return name;
+ }
+
+ protected abstract String permissionPrefix();
+
+ protected String permissionName() {
+ if (forUser == ON_BEHALF_OF) {
+ return permissionPrefix() + "As";
+ }
+ return permissionPrefix();
+ }
+
+ @Override
+ public final String describeForException() {
+ if (forUser == ON_BEHALF_OF) {
+ return permissionPrefix() + " on behalf of " + name;
+ }
+ return permissionPrefix() + " " + name;
+ }
+
+ @Override
+ public final int hashCode() {
+ return (permissionPrefix() + name).hashCode();
+ }
+
+ @Override
+ @SuppressWarnings("EqualsGetClass")
+ public final boolean equals(Object other) {
+ if (this.getClass().isAssignableFrom(other.getClass())) {
+ AbstractLabelPermission b = (AbstractLabelPermission) other;
+ return forUser == b.forUser && name.equals(b.name);
+ }
+ return false;
+ }
+
+ @Override
+ public final String toString() {
+ return permissionName() + "[" + name + ']';
+ }
+
+ /** A {@link AbstractLabelPermission} at a specific value. */
+ public abstract static class WithValue implements ChangePermissionOrLabel {
+ private final ForUser forUser;
+ private final LabelVote label;
+
+ /**
+ * Construct a reference to an abstract label permission at a specific value.
+ *
+ * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+ * @param label label name and vote.
+ */
+ public WithValue(ForUser forUser, LabelVote label) {
+ this.forUser = requireNonNull(forUser, "ForUser");
+ this.label = requireNonNull(label, "LabelVote");
+ }
+
+ /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+ public ForUser forUser() {
+ return forUser;
+ }
+
+ /** Returns name of the label, e.g. {@code "Code-Review"}. */
+ public String label() {
+ return label.label();
+ }
+
+ /** Returns specific value of the label, e.g. 1 or 2. */
+ public short value() {
+ return label.value();
+ }
+
+ public abstract String permissionName();
+
+ @Override
+ public final String describeForException() {
+ if (forUser == ON_BEHALF_OF) {
+ return permissionName() + " on behalf of " + label.formatWithEquals();
+ }
+ return permissionName() + " " + label.formatWithEquals();
+ }
+
+ @Override
+ public final int hashCode() {
+ return (permissionName() + label).hashCode();
+ }
+
+ @Override
+ @SuppressWarnings("EqualsGetClass")
+ public final boolean equals(Object other) {
+ if (this.getClass().isAssignableFrom(other.getClass())) {
+ AbstractLabelPermission.WithValue b = (AbstractLabelPermission.WithValue) other;
+ return forUser == b.forUser && label.equals(b.label);
+ }
+ return false;
+ }
+
+ @Override
+ public final String toString() {
+ if (forUser == ON_BEHALF_OF) {
+ return permissionName() + "As[" + label.format() + ']';
+ }
+ return permissionName() + "[" + label.format() + ']';
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index cadb21c304..db15da801d 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -14,8 +14,8 @@
package com.google.gerrit.server.permissions;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
import static com.google.gerrit.server.permissions.DefaultPermissionMappings.labelPermissionName;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
@@ -84,6 +84,19 @@ class ChangeControl {
&& refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
}
+ /**
+ * Can this user rebase this change on behalf of the uploader?
+ *
+ * <p>This only checks the permissions of the rebaser (aka the impersonating user).
+ *
+ * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+ * user) to have permissions to create the new patch set. These permissions need to be checked
+ * separately.
+ */
+ private boolean canRebaseOnBehalfOfUploader() {
+ return (isOwner() || refControl.canSubmit(isOwner()) || refControl.canRebase());
+ }
+
/** Can this user restore this change? */
private boolean canRestore() {
// Anyone who can abandon the change can restore it, as long as they can create changes.
@@ -120,16 +133,6 @@ class ChangeControl {
return false;
}
- /** Is this user assigned to this change? */
- private boolean isAssignee() {
- Account.Id currentAssignee = getChange().getAssignee();
- if (currentAssignee != null && getUser().isIdentifiedUser()) {
- Account.Id id = getUser().getAccountId();
- return id.equals(currentAssignee);
- }
- return false;
- }
-
/** Is this user a reviewer for the change? */
private boolean isReviewer(ChangeData cd) {
if (getUser().isIdentifiedUser()) {
@@ -171,13 +174,6 @@ class ChangeControl {
return false;
}
- private boolean canEditAssignee() {
- return isOwner()
- || getProjectControl().isOwner()
- || refControl.canPerform(Permission.EDIT_ASSIGNEE)
- || isAssignee();
- }
-
/** Can this user edit the hashtag name? */
private boolean canEditHashtags() {
return isOwner() // owner (aka creator) of the change can edit hashtags
@@ -216,7 +212,10 @@ class ChangeControl {
public void check(ChangePermissionOrLabel perm)
throws AuthException, PermissionBackendException {
if (!can(perm)) {
- throw new AuthException(perm.describeForException() + " not permitted");
+ throw new AuthException(
+ perm.describeForException()
+ + " not permitted"
+ + perm.hintForException().map(hint -> " (" + hint + ")").orElse(""));
}
}
@@ -240,10 +239,10 @@ class ChangeControl {
private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
if (perm instanceof ChangePermission) {
return can((ChangePermission) perm);
- } else if (perm instanceof LabelPermission) {
- return can((LabelPermission) perm);
- } else if (perm instanceof LabelPermission.WithValue) {
- return can((LabelPermission.WithValue) perm);
+ } else if (perm instanceof AbstractLabelPermission) {
+ return can((AbstractLabelPermission) perm);
+ } else if (perm instanceof AbstractLabelPermission.WithValue) {
+ return can((AbstractLabelPermission.WithValue) perm);
}
throw new PermissionBackendException(perm + " unsupported");
}
@@ -259,8 +258,6 @@ class ChangeControl {
return getProjectControl().isAdmin() || refControl.canDeleteChanges(isOwner());
case ADD_PATCH_SET:
return canAddPatchSet();
- case EDIT_ASSIGNEE:
- return canEditAssignee();
case EDIT_DESCRIPTION:
return canEditDescription();
case EDIT_HASHTAGS:
@@ -269,6 +266,8 @@ class ChangeControl {
return canEditTopicName();
case REBASE:
return canRebase();
+ case REBASE_ON_BEHALF_OF_UPLOADER:
+ return canRebaseOnBehalfOfUploader();
case RESTORE:
return canRestore();
case REVERT:
@@ -288,11 +287,11 @@ class ChangeControl {
throw new PermissionBackendException(perm + " unsupported");
}
- private boolean can(LabelPermission perm) {
+ private boolean can(AbstractLabelPermission perm) {
return !label(labelPermissionName(perm)).isEmpty();
}
- private boolean can(LabelPermission.WithValue perm) {
+ private boolean can(AbstractLabelPermission.WithValue perm) {
PermissionRange r = label(labelPermissionName(perm));
if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
return false;
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java
index 63b03787b2..7741adac62 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermission.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -16,7 +16,9 @@ package com.google.gerrit.server.permissions;
import static java.util.Objects.requireNonNull;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
public enum ChangePermission implements ChangePermissionOrLabel {
READ,
@@ -35,7 +37,6 @@ public enum ChangePermission implements ChangePermissionOrLabel {
* change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
*/
ABANDON,
- EDIT_ASSIGNEE,
EDIT_DESCRIPTION,
EDIT_HASHTAGS,
EDIT_TOPIC_NAME,
@@ -53,24 +54,53 @@ public enum ChangePermission implements ChangePermissionOrLabel {
* <p>Before checking this permission, the caller should first verify the current patch set of the
* change is not locked by calling {@code PatchSetUtil.isPatchSetLocked}.
*/
- REBASE,
+ REBASE(
+ /* description= */ null,
+ /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase"
+ + " if they have the 'Push' permission"),
+ /**
+ * Permission that is required for a user to rebase a change on behalf of the uploader.
+ *
+ * <p>This only covers the permissions of the rebaser (aka the impersonating user).
+ *
+ * <p>In addition rebase on behalf of the uploader requires the uploader (aka the impersonated
+ * user) to have permissions to create the new patch set. These permissions need to be checked
+ * separately.
+ */
+ REBASE_ON_BEHALF_OF_UPLOADER(
+ /* description= */ null,
+ /* hint= */ "change owners and users with the 'Submit' or 'Rebase' permission can rebase on"
+ + " behalf of the uploader"),
REVERT,
SUBMIT,
SUBMIT_AS("submit on behalf of other users"),
TOGGLE_WORK_IN_PROGRESS_STATE;
private final String description;
+ private final String hint;
ChangePermission() {
this.description = null;
+ this.hint = null;
}
ChangePermission(String description) {
this.description = requireNonNull(description);
+ this.hint = null;
+ }
+
+ ChangePermission(@Nullable String description, String hint) {
+ this.description = description;
+ this.hint = requireNonNull(hint);
}
@Override
public String describeForException() {
return description != null ? description : GerritPermission.describeEnumValue(this);
}
+
+ @Override
+ public Optional<String> hintForException() {
+ return Optional.ofNullable(hint);
+ }
}
diff --git a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
index 2824efd57b..9254158036 100644
--- a/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
+++ b/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
@@ -15,6 +15,17 @@
package com.google.gerrit.server.permissions;
import com.google.gerrit.extensions.api.access.GerritPermission;
+import java.util.Optional;
-/** A {@link ChangePermission} or a {@link LabelPermission}. */
-public interface ChangePermissionOrLabel extends GerritPermission {}
+/** A {@link ChangePermission} or a {@link AbstractLabelPermission}. */
+public interface ChangePermissionOrLabel extends GerritPermission {
+ /**
+ * A hint that explains under which conditions this permission is permitted.
+ *
+ * <p>This is useful for permissions that are not directly assigned but are indirectly permitted
+ * by the user having other permissions or being the change owner.
+ */
+ default Optional<String> hintForException() {
+ return Optional.empty();
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index bf4d05aee4..a4ee052440 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -195,12 +195,17 @@ public class DefaultPermissionBackend extends PermissionBackend {
case MODIFY_ACCOUNT:
case READ_AS:
case STREAM_EVENTS:
+ case VIEW_ACCESS:
case VIEW_ALL_ACCOUNTS:
case VIEW_CONNECTIONS:
case VIEW_PLUGINS:
- case VIEW_ACCESS:
return has(globalPermissionName(perm)) || isAdmin();
+ case VIEW_SECONDARY_EMAILS:
+ return has(globalPermissionName(perm))
+ || has(globalPermissionName(GlobalPermission.MODIFY_ACCOUNT))
+ || isAdmin();
+
case ACCESS_DATABASE:
case RUN_AS:
return has(globalPermissionName(perm));
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 9d69d9bad6..958de1b293 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -24,7 +24,7 @@ import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
import com.google.gerrit.extensions.api.access.PluginPermission;
import com.google.gerrit.extensions.api.access.PluginProjectPermission;
-import com.google.gerrit.server.permissions.LabelPermission.ForUser;
+import com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
@@ -61,6 +61,7 @@ public class DefaultPermissionMappings {
.put(GlobalPermission.VIEW_CONNECTIONS, GlobalCapability.VIEW_CONNECTIONS)
.put(GlobalPermission.VIEW_PLUGINS, GlobalCapability.VIEW_PLUGINS)
.put(GlobalPermission.VIEW_QUEUE, GlobalCapability.VIEW_QUEUE)
+ .put(GlobalPermission.VIEW_SECONDARY_EMAILS, GlobalCapability.VIEW_SECONDARY_EMAILS)
.build();
static {
@@ -90,7 +91,6 @@ public class DefaultPermissionMappings {
ImmutableBiMap.<ChangePermission, String>builder()
.put(ChangePermission.READ, Permission.READ)
.put(ChangePermission.ABANDON, Permission.ABANDON)
- .put(ChangePermission.EDIT_ASSIGNEE, Permission.EDIT_ASSIGNEE)
.put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS)
.put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME)
.put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER)
@@ -160,19 +160,29 @@ public class DefaultPermissionMappings {
return Optional.ofNullable(CHANGE_PERMISSIONS.inverse().get(permissionName));
}
- public static String labelPermissionName(LabelPermission labelPermission) {
- if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
- return Permission.forLabelAs(labelPermission.label());
+ public static String labelPermissionName(AbstractLabelPermission labelPermission) {
+ if (labelPermission instanceof LabelPermission) {
+ if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+ return Permission.forLabelAs(labelPermission.label());
+ }
+ return Permission.forLabel(labelPermission.label());
+ } else if (labelPermission instanceof LabelRemovalPermission) {
+ return Permission.forRemoveLabel(labelPermission.label());
}
- return Permission.forLabel(labelPermission.label());
+ throw new IllegalStateException("invalid AbstractLabelPermission subtype");
}
// TODO(dborowitz): Can these share a common superinterface?
- public static String labelPermissionName(LabelPermission.WithValue labelPermission) {
- if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
- return Permission.forLabelAs(labelPermission.label());
+ public static String labelPermissionName(AbstractLabelPermission.WithValue labelPermission) {
+ if (labelPermission instanceof LabelPermission.WithValue) {
+ if (labelPermission.forUser() == ForUser.ON_BEHALF_OF) {
+ return Permission.forLabelAs(labelPermission.label());
+ }
+ return Permission.forLabel(labelPermission.label());
+ } else if (labelPermission instanceof LabelRemovalPermission.WithValue) {
+ return Permission.forRemoveLabel(labelPermission.label());
}
- return Permission.forLabel(labelPermission.label());
+ throw new IllegalStateException("invalid AbstractLabelPermission.WithValue subtype");
}
private DefaultPermissionMappings() {}
diff --git a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
index a23228f284..640ea9a6b8 100644
--- a/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
+++ b/java/com/google/gerrit/server/permissions/GitVisibleChangeFilter.java
@@ -14,8 +14,6 @@
package com.google.gerrit.server.permissions;
-import static com.google.common.collect.ImmutableMap.toImmutableMap;
-
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
@@ -24,9 +22,9 @@ import com.google.gerrit.entities.Project;
import com.google.gerrit.server.git.ChangesByProjectCache;
import com.google.gerrit.server.query.change.ChangeData;
import java.io.IOException;
+import java.util.HashMap;
import java.util.Objects;
import java.util.Set;
-import java.util.function.Function;
import java.util.stream.Stream;
import org.eclipse.jgit.lib.Repository;
@@ -64,16 +62,18 @@ public class GitVisibleChangeFilter {
ImmutableSet<Change.Id> changes) {
Stream<ChangeData> changeDatas = Stream.empty();
if (changes.size() < CHANGE_LIMIT_FOR_DIRECT_FILTERING) {
+ logger.atFine().log("Loading changes one by one for project %s", projectName);
changeDatas = loadChangeDatasOneByOne(changes, changeDataFactory, projectName);
} else {
+ logger.atFine().log("Loading changes from ChangesByProjectCache for project %s", projectName);
try {
changeDatas = changesByProjectCache.streamChangeDatas(projectName, repository);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Unable to streamChangeDatas for %s", projectName);
}
}
-
- return changeDatas
+ HashMap<Change.Id, ChangeData> result = new HashMap<>();
+ changeDatas
.filter(cd -> changes.contains(cd.getId()))
.filter(
cd -> {
@@ -87,7 +87,16 @@ public class GitVisibleChangeFilter {
return false;
}
})
- .collect(toImmutableMap(ChangeData::getId, Function.identity()));
+ .forEach(
+ cd -> {
+ if (result.containsKey(cd.getId())) {
+ logger.atWarning().log(
+ "Duplicate change datas for the repo %s: [%s, %s]",
+ projectName, cd, result.get(cd.getId()));
+ }
+ result.put(cd.getId(), cd);
+ });
+ return ImmutableMap.copyOf(result);
}
/** Get a stream of changes by loading them individually. */
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index c0b44e5fb7..342997899e 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -58,7 +58,8 @@ public enum GlobalPermission implements GlobalOrPluginPermission {
VIEW_CACHES,
VIEW_CONNECTIONS,
VIEW_PLUGINS,
- VIEW_QUEUE;
+ VIEW_QUEUE,
+ VIEW_SECONDARY_EMAILS;
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index c266caa437..4652364dfb 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -14,24 +14,14 @@
package com.google.gerrit.server.permissions;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
-import static java.util.Objects.requireNonNull;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.server.util.LabelVote;
/** Permission representing a label. */
-public class LabelPermission implements ChangePermissionOrLabel {
- public enum ForUser {
- SELF,
- ON_BEHALF_OF;
- }
-
- private final ForUser forUser;
- private final String name;
-
+public class LabelPermission extends AbstractLabelPermission {
/**
* Construct a reference to a label permission.
*
@@ -67,55 +57,16 @@ public class LabelPermission implements ChangePermissionOrLabel {
* @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
*/
public LabelPermission(ForUser forUser, String name) {
- this.forUser = requireNonNull(forUser, "ForUser");
- this.name = LabelType.checkName(name);
- }
-
- /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
- public ForUser forUser() {
- return forUser;
- }
-
- /** Returns name of the label, e.g. {@code "Code-Review"}. */
- public String label() {
- return name;
- }
-
- @Override
- public String describeForException() {
- if (forUser == ON_BEHALF_OF) {
- return "label on behalf of " + name;
- }
- return "label " + name;
+ super(forUser, name);
}
@Override
- public int hashCode() {
- return name.hashCode();
- }
-
- @Override
- public boolean equals(Object other) {
- if (other instanceof LabelPermission) {
- LabelPermission b = (LabelPermission) other;
- return forUser == b.forUser && name.equals(b.name);
- }
- return false;
- }
-
- @Override
- public String toString() {
- if (forUser == ON_BEHALF_OF) {
- return "LabelAs[" + name + ']';
- }
- return "Label[" + name + ']';
+ public String permissionPrefix() {
+ return "label";
}
/** A {@link LabelPermission} at a specific value. */
- public static class WithValue implements ChangePermissionOrLabel {
- private final ForUser forUser;
- private final LabelVote label;
-
+ public static class WithValue extends AbstractLabelPermission.WithValue {
/**
* Construct a reference to a label at a specific value.
*
@@ -195,53 +146,12 @@ public class LabelPermission implements ChangePermissionOrLabel {
* @param label label name and vote.
*/
public WithValue(ForUser forUser, LabelVote label) {
- this.forUser = requireNonNull(forUser, "ForUser");
- this.label = requireNonNull(label, "LabelVote");
- }
-
- /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
- public ForUser forUser() {
- return forUser;
- }
-
- /** Returns name of the label, e.g. {@code "Code-Review"}. */
- public String label() {
- return label.label();
- }
-
- /** Returns specific value of the label, e.g. 1 or 2. */
- public short value() {
- return label.value();
- }
-
- @Override
- public String describeForException() {
- if (forUser == ON_BEHALF_OF) {
- return "label on behalf of " + label.formatWithEquals();
- }
- return "label " + label.formatWithEquals();
- }
-
- @Override
- public int hashCode() {
- return label.hashCode();
- }
-
- @Override
- public boolean equals(Object other) {
- if (other instanceof WithValue) {
- WithValue b = (WithValue) other;
- return forUser == b.forUser && label.equals(b.label);
- }
- return false;
+ super(forUser, label);
}
@Override
- public String toString() {
- if (forUser == ON_BEHALF_OF) {
- return "LabelAs[" + label.format() + ']';
- }
- return "Label[" + label.format() + ']';
+ public String permissionName() {
+ return "label";
}
}
}
diff --git a/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
new file mode 100644
index 0000000000..2553601953
--- /dev/null
+++ b/java/com/google/gerrit/server/permissions/LabelRemovalPermission.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.permissions;
+
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.SELF;
+
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.util.LabelVote;
+
+/** Permission representing a label removal. */
+public class LabelRemovalPermission extends AbstractLabelPermission {
+ /**
+ * Construct a reference to a label removal permission.
+ *
+ * @param type type description of the label.
+ */
+ public LabelRemovalPermission(LabelType type) {
+ this(type.getName());
+ }
+
+ /**
+ * Construct a reference to a label removal permission.
+ *
+ * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+ */
+ public LabelRemovalPermission(String name) {
+ super(SELF, name);
+ }
+
+ @Override
+ public String permissionPrefix() {
+ return "removeLabel";
+ }
+
+ /** A {@link LabelRemovalPermission} at a specific value. */
+ public static class WithValue extends AbstractLabelPermission.WithValue {
+ /**
+ * Construct a reference to a label removal at a specific value.
+ *
+ * @param type description of the label.
+ * @param value numeric score assigned to the label.
+ */
+ public WithValue(LabelType type, LabelValue value) {
+ this(type.getName(), value.getValue());
+ }
+
+ /**
+ * Construct a reference to a label removal at a specific value.
+ *
+ * @param type description of the label.
+ * @param value numeric score assigned to the label.
+ */
+ public WithValue(LabelType type, short value) {
+ this(type.getName(), value);
+ }
+
+ /**
+ * Construct a reference to a label removal at a specific value.
+ *
+ * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+ * @param value numeric score assigned to the label.
+ */
+ public WithValue(String name, short value) {
+ this(LabelVote.create(name, value));
+ }
+
+ /**
+ * Construct a reference to a label removal at a specific value.
+ *
+ * @param label label name and vote.
+ */
+ public WithValue(LabelVote label) {
+ super(SELF, label);
+ }
+
+ @Override
+ public String permissionName() {
+ return "removeLabel";
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 8c731f6597..ac9ac98c42 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -474,6 +474,18 @@ public abstract class PermissionBackend {
}
/**
+ * Test which values of a label the user may be able to remove.
+ *
+ * @param label definition of the label to test values of.
+ * @return set containing values the user may be able to use; may be empty if none.
+ * @throws PermissionBackendException if failure consulting backend configuration.
+ */
+ public Set<LabelRemovalPermission.WithValue> testRemoval(LabelType label)
+ throws PermissionBackendException {
+ return test(removalValuesOf(requireNonNull(label, "LabelType")));
+ }
+
+ /**
* Test which values of a group of labels the user may be able to set.
*
* @param types definition of the labels to test values of.
@@ -486,10 +498,29 @@ public abstract class PermissionBackend {
return test(types.stream().flatMap(t -> valuesOf(t).stream()).collect(toSet()));
}
+ /**
+ * Test which values of a group of labels the user may be able to remove.
+ *
+ * @param types definition of the labels to test values of.
+ * @return set containing values the user may be able to use; may be empty if none.
+ * @throws PermissionBackendException if failure consulting backend configuration.
+ */
+ public Set<LabelRemovalPermission.WithValue> testLabelRemovals(Collection<LabelType> types)
+ throws PermissionBackendException {
+ requireNonNull(types, "LabelType");
+ return test(types.stream().flatMap(t -> removalValuesOf(t).stream()).collect(toSet()));
+ }
+
private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
return label.getValues().stream()
.map(v -> new LabelPermission.WithValue(label, v))
.collect(toSet());
}
+
+ private static Set<LabelRemovalPermission.WithValue> removalValuesOf(LabelType label) {
+ return label.getValues().stream()
+ .map(v -> new LabelRemovalPermission.WithValue(label, v))
+ .collect(toSet());
+ }
}
}
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index 7f2e62b615..fab894e108 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.entities.RefNames.REFS_TAGS;
import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
import com.google.common.collect.Sets;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BranchNameKey;
@@ -269,6 +270,7 @@ class ProjectControl {
return false;
}
+ @Nullable
private Boolean canPerform(String permissionName, AccessSection section, Permission permission) {
for (PermissionRule rule : permission.getRules()) {
if (rule.isBlock() || rule.isDeny() || !match(rule)) {
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 1a3741bfc0..7f9692bcf0 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.PermissionRange;
@@ -177,6 +178,7 @@ class RefControl {
}
/** The range of permitted values associated with a label permission. */
+ @Nullable
PermissionRange getRange(String permission, boolean isChangeOwner) {
if (Permission.hasRange(permission)) {
return toRange(permission, isChangeOwner);
diff --git a/java/com/google/gerrit/server/plugins/JarScanner.java b/java/com/google/gerrit/server/plugins/JarScanner.java
index e119bf1fd5..122e3f4e2f 100644
--- a/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -24,6 +24,7 @@ import com.google.common.collect.ListMultimap;
import com.google.common.collect.Maps;
import com.google.common.collect.MultimapBuilder;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
@@ -212,6 +213,7 @@ public class JarScanner implements PluginContentScanner, AutoCloseable {
this.superName = superName;
}
+ @Nullable
@Override
public AnnotationVisitor visitAnnotation(String desc, boolean visible) {
if (!visible) {
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 8d17d8598f..326363665e 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -27,6 +27,7 @@ import com.google.common.collect.Maps;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.events.LifecycleListener;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.systemstatus.ServerInformation;
@@ -713,6 +714,7 @@ public class PluginLoader implements LifecycleListener {
return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
}
+ @Nullable
public String getGerritPluginName(Path srcPath) {
String fileName = srcPath.getFileName().toString();
if (isUiPlugin(fileName)) {
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 320b618f85..af948b0999 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -110,6 +110,7 @@ public class ServerPlugin extends Plugin {
}
}
+ @Nullable
@SuppressWarnings("unchecked")
protected static Class<? extends Module> load(@Nullable String name, ClassLoader pluginLoader)
throws ClassNotFoundException {
diff --git a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
index 60dff84471..e91f7b7f50 100644
--- a/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
+++ b/java/com/google/gerrit/server/plugins/ServerPluginInfoModule.java
@@ -72,13 +72,15 @@ class ServerPluginInfoModule extends AbstractModule {
if (!ready) {
synchronized (dataDir) {
if (!ready) {
- try {
- Files.createDirectories(dataDir);
- } catch (IOException e) {
- throw new ProvisionException(
- String.format(
- "Cannot create %s for plugin %s", dataDir.toAbsolutePath(), plugin.getName()),
- e);
+ if (!Files.isDirectory(dataDir)) {
+ try {
+ Files.createDirectories(dataDir);
+ } catch (IOException e) {
+ throw new ProvisionException(
+ String.format(
+ "Cannot create %s for plugin %s", dataDir.toAbsolutePath(), plugin.getName()),
+ e);
+ }
}
ready = true;
}
diff --git a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
index ae9828aaa8..9ccbf90ff4 100644
--- a/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
+++ b/java/com/google/gerrit/server/project/BooleanProjectConfigTransformations.java
@@ -71,6 +71,11 @@ public class BooleanProjectConfigTransformations {
.put(
BooleanProjectConfig.WORK_IN_PROGRESS_BY_DEFAULT,
new Mapper(i -> i.workInProgressByDefault, (i, v) -> i.workInProgressByDefault = v))
+ .put(
+ BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+ new Mapper(
+ i -> i.skipAddingAuthorAndCommitterAsReviewers,
+ (i, v) -> i.skipAddingAuthorAndCommitterAsReviewers = v))
.build();
static {
diff --git a/java/com/google/gerrit/server/project/CommentLinkProvider.java b/java/com/google/gerrit/server/project/CommentLinkProvider.java
index c2ac68a7ae..88f045e1d8 100644
--- a/java/com/google/gerrit/server/project/CommentLinkProvider.java
+++ b/java/com/google/gerrit/server/project/CommentLinkProvider.java
@@ -48,7 +48,7 @@ public class CommentLinkProvider implements Provider<List<CommentLinkInfo>>, Ger
ImmutableList.builderWithExpectedSize(subsections.size());
for (String name : subsections) {
try {
- StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name, true);
+ StoredCommentLinkInfo cl = ProjectConfig.buildCommentLink(cfg, name);
if (cl.getOverrideOnly()) {
logger.atWarning().log("commentlink %s empty except for \"enabled\"", name);
continue;
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index c1b7b86987..8da0510cf8 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.project;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -48,7 +49,7 @@ public class CreateProjectArgs {
newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
enableSignedPush = InheritableBoolean.INHERIT;
requireSignedPush = InheritableBoolean.INHERIT;
- submitType = SubmitType.MERGE_IF_NECESSARY;
+ submitType = SubmitType.INHERIT;
rejectEmptyCommit = InheritableBoolean.INHERIT;
}
@@ -56,6 +57,7 @@ public class CreateProjectArgs {
return projectName;
}
+ @Nullable
public String getProjectName() {
return projectName != null ? projectName.get() : null;
}
diff --git a/java/com/google/gerrit/server/project/DeleteVoteControl.java b/java/com/google/gerrit/server/project/DeleteVoteControl.java
new file mode 100644
index 0000000000..3f3f88ab0f
--- /dev/null
+++ b/java/com/google/gerrit/server/project/DeleteVoteControl.java
@@ -0,0 +1,81 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import java.util.Set;
+
+public class DeleteVoteControl {
+ private final PermissionBackend permissionBackend;
+ private final ChangeData.Factory changeDataFactory;
+
+ @Inject
+ public DeleteVoteControl(
+ PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
+ this.permissionBackend = permissionBackend;
+ this.changeDataFactory = changeDataFactory;
+ }
+
+ public boolean testDeleteVotePermissions(
+ CurrentUser user, ChangeNotes notes, PatchSetApproval approval, LabelType labelType)
+ throws PermissionBackendException {
+ return testDeleteVotePermissions(user, changeDataFactory.create(notes), approval, labelType);
+ }
+
+ public boolean testDeleteVotePermissions(
+ CurrentUser user, ChangeData cd, PatchSetApproval approval, LabelType labelType)
+ throws PermissionBackendException {
+ if (canRemoveReviewerWithoutRemoveLabelPermission(
+ cd.change(), user, approval.accountId(), approval.value())) {
+ return true;
+ }
+ // Test if the user is allowed to remove vote of the given label type and value.
+ Set<LabelRemovalPermission.WithValue> allowed =
+ permissionBackend.user(user).change(cd).testRemoval(labelType);
+ return allowed.contains(new LabelRemovalPermission.WithValue(labelType, approval.value()));
+ }
+
+ private boolean canRemoveReviewerWithoutRemoveLabelPermission(
+ Change change, CurrentUser user, Account.Id reviewer, int value)
+ throws PermissionBackendException {
+ if (user.isIdentifiedUser()) {
+ Account.Id aId = user.getAccountId();
+ if (aId.equals(reviewer)) {
+ return true; // A user can always remove their own votes.
+ } else if (aId.equals(change.getOwner()) && 0 <= value) {
+ return true; // The change owner may remove any zero or positive score.
+ }
+ }
+
+ // Users with the remove reviewer permission, the branch owner, project
+ // owner and site admin can remove anyone
+ PermissionBackend.WithUser withUser = permissionBackend.user(user);
+ PermissionBackend.ForProject forProject = withUser.project(change.getProject());
+ return forProject.ref(change.getDest().branch()).test(RefPermission.WRITE_CONFIG)
+ || withUser.test(GlobalPermission.ADMINISTRATE_SERVER);
+ }
+}
diff --git a/java/com/google/gerrit/server/project/GroupList.java b/java/com/google/gerrit/server/project/GroupList.java
index 98dc44a97a..1b0ba97c40 100644
--- a/java/com/google/gerrit/server/project/GroupList.java
+++ b/java/com/google/gerrit/server/project/GroupList.java
@@ -126,6 +126,7 @@ public class GroupList extends TabFile {
byUUID.put(uuid, reference);
}
+ @Nullable
public String asText() {
if (byUUID.isEmpty()) {
return null;
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 235eb34b4a..f46c2b1394 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.project;
import static java.util.stream.Collectors.toMap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.Project;
@@ -39,8 +40,9 @@ public class LabelDefinitionJson {
return label;
}
+ @Nullable
private static Boolean toBoolean(boolean v) {
- return v ? v : null;
+ return v ? Boolean.TRUE : null;
}
private LabelDefinitionJson() {}
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 67c031e2f0..6498d1b767 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -24,6 +24,7 @@ import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.common.hash.Hashing;
import com.google.common.util.concurrent.Futures;
@@ -54,6 +55,7 @@ import com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSeri
import com.google.gerrit.server.config.AllProjectsConfigProvider;
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.git.GitRepositoryManager;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
@@ -67,6 +69,7 @@ import com.google.inject.name.Named;
import com.google.protobuf.ByteString;
import java.io.IOException;
import java.time.Duration;
+import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
@@ -75,6 +78,7 @@ import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
@@ -158,6 +162,7 @@ public class ProjectCacheImpl implements ProjectCache {
};
}
+ private final Config config;
private final AllProjectsName allProjectsName;
private final AllUsersName allUsersName;
private final LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
@@ -169,13 +174,15 @@ public class ProjectCacheImpl implements ProjectCache {
@Inject
ProjectCacheImpl(
- final AllProjectsName allProjectsName,
- final AllUsersName allUsersName,
+ @GerritServerConfig Config config,
+ AllProjectsName allProjectsName,
+ AllUsersName allUsersName,
@Named(CACHE_NAME) LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache,
@Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
Provider<ProjectIndexer> indexer,
MetricMaker metricMaker,
ProjectState.Factory projectStateFactory) {
+ this.config = config;
this.allProjectsName = allProjectsName;
this.allUsersName = allUsersName;
this.inMemoryProjectCache = inMemoryProjectCache;
@@ -293,14 +300,21 @@ public class ProjectCacheImpl implements ProjectCache {
@Override
public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
- return all().stream()
- .map(n -> inMemoryProjectCache.getIfPresent(n))
- .filter(Objects::nonNull)
- .flatMap(p -> p.getAllGroupUUIDs().stream())
- // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
- // against them just in case there is a bug or corner case.
- .filter(id -> id != null && id.get() != null)
- .collect(toSet());
+ Set<AccountGroup.UUID> relevantGroupUuids =
+ Streams.concat(
+ Arrays.stream(
+ config.getStringList("groups", /* subsection= */ null, "relevantGroup"))
+ .map(AccountGroup::uuid),
+ all().stream()
+ .map(n -> inMemoryProjectCache.getIfPresent(n))
+ .filter(Objects::nonNull)
+ .flatMap(p -> p.getAllGroupUUIDs().stream())
+ // getAllGroupUUIDs shouldn't really return null UUIDs, but harden
+ // against them just in case there is a bug or corner case.
+ .filter(id -> id != null && id.get() != null))
+ .collect(toSet());
+ logger.atFine().log("relevant group UUIDs: %s", relevantGroupUuids);
+ return relevantGroupUuids;
}
}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 47b0a5399b..6c8087e0b0 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -14,7 +14,6 @@
package com.google.gerrit.server.project;
-import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.Permission.isPermission;
@@ -104,6 +103,8 @@ import org.eclipse.jgit.revwalk.RevWalk;
public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ public static final String RULES_PL_FILE = "rules.pl";
+
public static final String COMMENTLINK = "commentlink";
public static final String LABEL = "label";
public static final String KEY_LABEL_DESCRIPTION = "description";
@@ -131,7 +132,6 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
KEY_SR_OVERRIDE_IN_CHILD_PROJECTS);
public static final String KEY_MATCH = "match";
- private static final String KEY_HTML = "html";
public static final String KEY_LINK = "link";
public static final String KEY_PREFIX = "prefix";
public static final String KEY_SUFFIX = "suffix";
@@ -318,7 +318,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
return builder.build();
}
- public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
+ public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name)
throws IllegalArgumentException {
String match = cfg.getString(COMMENTLINK, name, KEY_MATCH);
if (match != null) {
@@ -335,9 +335,6 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
String linkSuffix = cfg.getString(COMMENTLINK, name, KEY_SUFFIX);
String linkText = cfg.getString(COMMENTLINK, name, KEY_TEXT);
- String html = cfg.getString(COMMENTLINK, name, KEY_HTML);
- boolean hasHtml = !Strings.isNullOrEmpty(html);
-
String rawEnabled = cfg.getString(COMMENTLINK, name, KEY_ENABLED);
Boolean enabled;
if (rawEnabled != null) {
@@ -345,12 +342,8 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
} else {
enabled = null;
}
- checkArgument(allowRaw || !hasHtml, "Raw html replacement not allowed");
- if (Strings.isNullOrEmpty(match)
- && Strings.isNullOrEmpty(link)
- && !hasHtml
- && enabled != null) {
+ if (Strings.isNullOrEmpty(match) && Strings.isNullOrEmpty(link) && enabled != null) {
if (enabled) {
return StoredCommentLinkInfo.enabled(name);
}
@@ -362,7 +355,6 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
.setPrefix(linkPrefix)
.setSuffix(linkSuffix)
.setText(linkText)
- .setHtml(html)
.setEnabled(enabled)
.setOverrideOnly(false)
.build();
@@ -667,7 +659,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
}
readGroupList();
- rulesId = getObjectId("rules.pl");
+ rulesId = getObjectId(RULES_PL_FILE);
Config rc = readConfig(PROJECT_CONFIG, baseConfig);
Project.Builder p = Project.builder(projectName);
p.setDescription(Strings.nullToEmpty(rc.getString(PROJECT, null, KEY_DESCRIPTION)));
@@ -728,7 +720,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
extensionPanelSections = new LinkedHashMap<>();
for (String name : rc.getSubsections(EXTENSION_PANELS)) {
- String lower = name.toLowerCase();
+ String lower = name.toLowerCase(Locale.US);
if (lowerNames.containsKey(lower)) {
error(
String.format(
@@ -977,7 +969,8 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
Map<String, String> lowerNames = new HashMap<>();
submitRequirementSections = new LinkedHashMap<>();
for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
- String lower = name.toLowerCase();
+ checkDuplicateSrDefinition(rc, name);
+ String lower = name.toLowerCase(Locale.US);
if (lowerNames.containsKey(lower)) {
error(
String.format(
@@ -1034,6 +1027,40 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
}
}
+ private void checkDuplicateSrDefinition(Config rc, String srName) {
+ if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_DESCRIPTION).length > 1) {
+ error(
+ String.format(
+ "Multiple definitions of %s for submit requirement '%s'",
+ KEY_SR_DESCRIPTION, srName));
+ }
+ if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_APPLICABILITY_EXPRESSION).length > 1) {
+ error(
+ String.format(
+ "Multiple definitions of %s for submit requirement '%s'",
+ KEY_SR_APPLICABILITY_EXPRESSION, srName));
+ }
+ if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_SUBMITTABILITY_EXPRESSION).length > 1) {
+ error(
+ String.format(
+ "Multiple definitions of %s for submit requirement '%s'",
+ KEY_SR_SUBMITTABILITY_EXPRESSION, srName));
+ }
+ if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_OVERRIDE_EXPRESSION).length > 1) {
+ error(
+ String.format(
+ "Multiple definitions of %s for submit requirement '%s'",
+ KEY_SR_OVERRIDE_EXPRESSION, srName));
+ }
+ if (rc.getStringList(SUBMIT_REQUIREMENT, srName, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS).length
+ > 1) {
+ error(
+ String.format(
+ "Multiple definitions of %s for submit requirement '%s'",
+ KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, srName));
+ }
+ }
+
/**
* Report unsupported submit requirement parameters as errors.
*
@@ -1075,7 +1102,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
labelSections = new LinkedHashMap<>();
for (String name : rc.getSubsections(LABEL)) {
- String lower = name.toLowerCase();
+ String lower = name.toLowerCase(Locale.US);
if (lowerNames.containsKey(lower)) {
error(String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
}
@@ -1118,9 +1145,11 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
error(
String.format(
"Invalid %s for label \"%s\". Valid names are: %s",
- KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
+ KEY_FUNCTION,
+ name,
+ Joiner.on(", ").join(LabelFunction.ALL_NON_DEPRECATED.keySet())));
}
- label.setFunction(function.orElse(null));
+ function.ifPresent(label::setFunction);
label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
if (!values.isEmpty()) {
@@ -1168,6 +1197,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
return false;
}
+ @Nullable
private List<String> getStringListOrNull(
Config rc, String section, String subSection, String name) {
String[] ac = rc.getStringList(section, subSection, name);
@@ -1179,7 +1209,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
commentLinkSections = new LinkedHashMap<>(subsections.size());
for (String name : subsections) {
try {
- commentLinkSections.put(name, buildCommentLink(rc, name, false));
+ commentLinkSections.put(name, buildCommentLink(rc, name));
} catch (PatternSyntaxException e) {
error(
String.format(
@@ -1269,7 +1299,8 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
parsedConfig.fromText(cfg);
projectLevelConfigs.put(pathInfo.path, parsedConfig);
} catch (ConfigInvalidException e) {
- logger.atWarning().withCause(e).log("Unable to parse config");
+ logger.atWarning().withCause(e).log(
+ "Unable to parse config for project %s", projectName.get());
}
}
}
@@ -1337,6 +1368,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
return true;
}
+ @Nullable
public static String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
if (value == null) {
return null;
@@ -1380,9 +1412,9 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
unsetSection(rc, COMMENTLINK);
if (commentLinkSections != null) {
for (StoredCommentLinkInfo cm : commentLinkSections.values()) {
- rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
- if (!Strings.isNullOrEmpty(cm.getHtml())) {
- rc.setString(COMMENTLINK, cm.getName(), KEY_HTML, cm.getHtml());
+ // Match and Link can be empty if the commentlink is override only.
+ if (!Strings.isNullOrEmpty(cm.getMatch())) {
+ rc.setString(COMMENTLINK, cm.getName(), KEY_MATCH, cm.getMatch());
}
if (!Strings.isNullOrEmpty(cm.getLink())) {
rc.setString(COMMENTLINK, cm.getName(), KEY_LINK, cm.getLink());
@@ -1498,7 +1530,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
if (capability != null) {
Set<String> have = new HashSet<>();
for (Permission permission : sort(capability.getPermissions())) {
- have.add(permission.getName().toLowerCase());
+ have.add(permission.getName().toLowerCase(Locale.US));
boolean needRange = GlobalCapability.hasRange(permission.getName());
List<String> rules = new ArrayList<>();
@@ -1512,7 +1544,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
rc.setStringList(CAPABILITY, null, permission.getName(), rules);
}
for (String varName : rc.getNames(CAPABILITY)) {
- if (!have.contains(varName.toLowerCase())) {
+ if (!have.contains(varName.toLowerCase(Locale.US))) {
rc.unset(CAPABILITY, null, varName);
}
}
@@ -1543,7 +1575,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
Set<String> have = new HashSet<>();
for (Permission permission : sort(as.getPermissions())) {
- have.add(permission.getName().toLowerCase());
+ have.add(permission.getName().toLowerCase(Locale.US));
boolean needRange = Permission.hasRange(permission.getName());
List<String> rules = new ArrayList<>();
@@ -1559,7 +1591,7 @@ public class ProjectConfig extends VersionedMetaData implements ValidationError.
for (String varName : rc.getNames(ACCESS, refName)) {
if (isCoreOrPluginPermission(convertLegacyPermission(varName))
- && !have.contains(varName.toLowerCase())) {
+ && !have.contains(varName.toLowerCase(Locale.US))) {
rc.unset(ACCESS, refName, varName);
}
}
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index f1c161d50b..485d9268f0 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.project;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
@@ -43,6 +44,7 @@ import com.google.gerrit.server.git.GitRepositoryManager.Status;
import com.google.gerrit.server.git.RepositoryExistsException;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
@@ -105,36 +107,38 @@ public class ProjectCreator {
public ProjectState createProject(CreateProjectArgs args)
throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
- final Project.NameKey nameKey = args.getProject();
- try {
- final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
- Status status = repoManager.getRepositoryStatus(nameKey);
- if (!status.equals(Status.NON_EXISTENT)) {
- throw new RepositoryExistsException(nameKey, "Repository status: " + status);
- }
- try (Repository repo = repoManager.createRepository(nameKey)) {
- RefUpdate u = repo.updateRef(Constants.HEAD);
- u.disableRefLog();
- u.link(head);
+ try (RefUpdateContext ctx = RefUpdateContext.open(INIT_REPO)) {
+ final Project.NameKey nameKey = args.getProject();
+ try {
+ final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
+ Status status = repoManager.getRepositoryStatus(nameKey);
+ if (!status.equals(Status.NON_EXISTENT)) {
+ throw new RepositoryExistsException(nameKey, "Repository status: " + status);
+ }
+ try (Repository repo = repoManager.createRepository(nameKey)) {
+ RefUpdate u = repo.updateRef(Constants.HEAD);
+ u.disableRefLog();
+ u.link(head);
- createProjectConfig(args);
+ createProjectConfig(args);
- if (!args.permissionsOnly && args.createEmptyCommit) {
- createEmptyCommits(repo, nameKey, args.branch);
- }
+ if (!args.permissionsOnly && args.createEmptyCommit) {
+ createEmptyCommits(repo, nameKey, args.branch);
+ }
- fire(nameKey, head);
+ fire(nameKey, head);
- return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
+ return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
+ }
+ } catch (RepositoryExistsException e) {
+ throw new ResourceConflictException(
+ "Cannot create "
+ + nameKey.get()
+ + " because the name is already occupied by another project.",
+ e);
+ } catch (RepositoryNotFoundException badName) {
+ throw new BadRequestException("invalid project name: " + nameKey, badName);
}
- } catch (RepositoryExistsException e) {
- throw new ResourceConflictException(
- "Cannot create "
- + nameKey.get()
- + " because the name is already occupied by another project.",
- e);
- } catch (RepositoryNotFoundException badName) {
- throw new BadRequestException("invalid project name: " + nameKey, badName);
}
}
diff --git a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index ccb5651403..929399a2ba 100644
--- a/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -18,6 +18,7 @@ import com.google.common.base.Joiner;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.config.AllProjectsName;
import java.util.Iterator;
@@ -63,6 +64,7 @@ class ProjectHierarchyIterator implements Iterator<ProjectState> {
return n;
}
+ @Nullable
private ProjectState computeNext(ProjectState n) {
Project.NameKey parentName = n.getProject().getParent();
if (parentName != null && visit(parentName)) {
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index b350f3c7e0..a3f8009dba 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -56,6 +56,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -380,7 +381,7 @@ public class ProjectState {
Map<String, SubmitRequirement> requirements = new LinkedHashMap<>();
for (ProjectState s : treeInOrder()) {
for (SubmitRequirement requirement : s.getConfig().getSubmitRequirementSections().values()) {
- String lowerName = requirement.name().toLowerCase();
+ String lowerName = requirement.name().toLowerCase(Locale.US);
SubmitRequirement old = requirements.get(lowerName);
if (old == null || old.allowOverrideInChildProjects()) {
requirements.put(lowerName, requirement);
@@ -395,7 +396,7 @@ public class ProjectState {
Map<String, LabelType> types = new LinkedHashMap<>();
for (ProjectState s : treeInOrder()) {
for (LabelType type : s.getConfig().getLabelSections().values()) {
- String lower = type.getName().toLowerCase();
+ String lower = type.getName().toLowerCase(Locale.US);
LabelType old = types.get(lower);
if (old == null || old.isCanOverride()) {
types.put(lower, type);
@@ -449,11 +450,11 @@ public class ProjectState {
public List<CommentLinkInfo> getCommentLinks() {
Map<String, CommentLinkInfo> cls = new LinkedHashMap<>();
for (CommentLinkInfo cl : commentLinks) {
- cls.put(cl.name.toLowerCase(), cl);
+ cls.put(cl.name.toLowerCase(Locale.US), cl);
}
for (ProjectState s : treeInOrder()) {
for (StoredCommentLinkInfo cl : s.getConfig().getCommentLinkSections().values()) {
- String name = cl.getName().toLowerCase();
+ String name = cl.getName().toLowerCase(Locale.US);
if (cl.getOverrideOnly()) {
CommentLinkInfo parent = cls.get(name);
if (parent == null) {
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index ab4bb70538..9463b39965 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -264,7 +264,7 @@ public class ProjectsConsistencyChecker {
.changeIndexQuery(
"projectsConsistencyCheckerQueryChanges",
q ->
- q.setRequestedFields(ChangeField.CHANGE, ChangeField.PATCH_SET)
+ q.setRequestedFields(ChangeField.CHANGE_SPEC, ChangeField.PATCH_SET_SPEC)
.query(and(basePredicate, or(predicates))))
.call();
diff --git a/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
new file mode 100644
index 0000000000..5683fe7974
--- /dev/null
+++ b/java/com/google/gerrit/server/project/PrologRulesWarningValidator.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.RefNames;
+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.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * A validator than emits a warning for newly added prolog rules file via git push. Modification and
+ * deletion are allowed so that clients can get rid of prolog rules.
+ */
+@Singleton
+public class PrologRulesWarningValidator implements CommitValidationListener {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final DiffOperations diffOperations;
+
+ @Inject
+ public PrologRulesWarningValidator(DiffOperations diffOperations) {
+ this.diffOperations = diffOperations;
+ }
+
+ @Override
+ public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+ throws CommitValidationException {
+ try {
+ if (receiveEvent.refName.equals(RefNames.REFS_CONFIG)
+ && isFileAdded(receiveEvent, RULES_PL_FILE)) {
+ return ImmutableList.of(
+ new CommitValidationMessage(
+ "Uploading a new 'rules.pl' file is discouraged."
+ + " Please consider adding submit-requirements instead.",
+ ValidationMessage.Type.WARNING));
+ }
+ } catch (DiffNotAvailableException e) {
+ logger.atWarning().withCause(e).log("Failed to retrieve the file diff.");
+ }
+ return ImmutableList.of();
+ }
+
+ private boolean isFileAdded(CommitReceivedEvent receiveEvent, String fileName)
+ throws DiffNotAvailableException {
+ List<Map.Entry<String, FileDiffOutput>> matchingEntries =
+ diffOperations
+ .listModifiedFilesAgainstParent(
+ receiveEvent.project.getNameKey(),
+ receiveEvent.commit,
+ /* parentNum=*/ 0,
+ DiffOptions.DEFAULTS)
+ .entrySet().stream()
+ .filter(e -> fileName.equals(e.getKey()))
+ .collect(Collectors.toList());
+ if (matchingEntries.size() != 1) {
+ return false;
+ }
+ return matchingEntries.iterator().next().getValue().changeType().equals(ChangeType.ADDED);
+ }
+}
diff --git a/java/com/google/gerrit/server/project/RefUtil.java b/java/com/google/gerrit/server/project/RefUtil.java
index e86ad41d99..07f7ba5cf0 100644
--- a/java/com/google/gerrit/server/project/RefUtil.java
+++ b/java/com/google/gerrit/server/project/RefUtil.java
@@ -23,6 +23,7 @@ import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import java.io.IOException;
import java.util.Collections;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RevisionSyntaxException;
@@ -49,6 +50,9 @@ public class RefUtil {
} catch (RevisionSyntaxException e) {
throw new UnprocessableEntityException(
String.format("base revision \"%s\" is invalid", baseRevision), e);
+ } catch (AmbiguousObjectException e) {
+ throw new UnprocessableEntityException(
+ String.format("base revision \"%s\" is ambiguous", baseRevision), e);
}
}
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 1bc309c715..3fda87a1d9 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -32,10 +32,12 @@ import com.google.inject.Singleton;
@Singleton
public class RemoveReviewerControl {
private final PermissionBackend permissionBackend;
+ private final ChangeData.Factory changeDataFactory;
@Inject
- RemoveReviewerControl(PermissionBackend permissionBackend) {
+ RemoveReviewerControl(PermissionBackend permissionBackend, ChangeData.Factory changeDataFactory) {
this.permissionBackend = permissionBackend;
+ this.changeDataFactory = changeDataFactory;
}
/**
@@ -64,6 +66,20 @@ public class RemoveReviewerControl {
/** Returns true if the user is allowed to remove this reviewer. */
public boolean testRemoveReviewer(
+ ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
+ throws PermissionBackendException {
+ return testRemoveReviewer(notes, currentUser, approval.accountId(), approval.value());
+ }
+
+ /** Returns true if the user is allowed to remove this reviewer. */
+ public boolean testRemoveReviewer(
+ ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int value)
+ throws PermissionBackendException {
+ return testRemoveReviewer(changeDataFactory.create(notes), currentUser, reviewer, value);
+ }
+
+ /** Returns true if the user is allowed to remove this reviewer. */
+ public boolean testRemoveReviewer(
ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
throws PermissionBackendException {
if (canRemoveReviewerWithoutPermissionCheck(
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 3d7175fa61..eaebab2d83 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.project;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.CurrentUser;
@@ -25,6 +26,7 @@ import com.google.gerrit.server.CurrentUser;
* of which sections are relevant to any given input reference.
*/
public class SectionMatcher extends RefPatternMatcher {
+ @Nullable
static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
String ref = section.getName();
if (AccessSection.isValidRefSectionName(ref)) {
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
index d749fd3b52..0991f201de 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import com.google.common.collect.ImmutableMap;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.entities.SubmitRequirementExpressionResult;
@@ -35,6 +36,7 @@ import com.google.inject.Inject;
import com.google.inject.Module;
import com.google.inject.Provider;
import com.google.inject.Scopes;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
@@ -42,6 +44,7 @@ import java.util.stream.Stream;
/** Evaluates submit requirements for different change data. */
public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
private final ProjectCache projectCache;
@@ -111,6 +114,29 @@ public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvalua
: Optional.empty();
}
+ if (applicabilityResult.isPresent()) {
+ logger.atFine().log(
+ "Applicability expression result for SR name '%s':"
+ + " passing atoms: %s, failing atoms: %s",
+ sr.name(),
+ applicabilityResult.get().passingAtoms(),
+ applicabilityResult.get().failingAtoms());
+ }
+ if (submittabilityResult.isPresent()) {
+ logger.atFine().log(
+ "Submittability expression result for SR name '%s':"
+ + " passing atoms: %s, failing atoms: %s",
+ sr.name(),
+ submittabilityResult.get().passingAtoms(),
+ submittabilityResult.get().failingAtoms());
+ }
+ if (overrideResult.isPresent()) {
+ logger.atFine().log(
+ "Override expression result for SR name '%s':"
+ + " passing atoms: %s, failing atoms: %s",
+ sr.name(), overrideResult.get().passingAtoms(), overrideResult.get().failingAtoms());
+ }
+
return SubmitRequirementResult.builder()
.legacy(Optional.of(false))
.submitRequirement(sr)
@@ -130,6 +156,8 @@ public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvalua
PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
return SubmitRequirementExpressionResult.create(expression, predicateResult);
} catch (QueryParseException | SubmitRequirementEvaluationException e) {
+ logger.atWarning().withCause(e).log(
+ "Failed to evaluate submit requirement expression: %s", expression.expressionString());
return SubmitRequirementExpressionResult.error(expression, e.getMessage());
}
}
@@ -180,7 +208,8 @@ public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvalua
return globalSubmitRequirements.stream()
.collect(
toImmutableMap(
- globalRequirement -> globalRequirement.name().toLowerCase(), Function.identity()));
+ globalRequirement -> globalRequirement.name().toLowerCase(Locale.US),
+ Function.identity()));
}
/** Evaluate the predicate recursively using change data. */
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
index e54e5afc37..403e52631e 100644
--- a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -28,6 +28,7 @@ import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.util.HashMap;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
@@ -142,10 +143,12 @@ public class SubmitRequirementsUtil {
// (projectConfigRequirements should not contain legacy entries)
// TODO(ghareeb): remove the filter statement
.filter(entry -> !entry.getValue().isLegacy())
- .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
+ .collect(
+ Collectors.toMap(
+ sr -> sr.getKey().name().toLowerCase(Locale.US), sr -> sr.getValue()));
for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
legacyRequirements.entrySet()) {
- String srName = legacy.getKey().name().toLowerCase();
+ String srName = legacy.getKey().name().toLowerCase(Locale.US);
SubmitRequirementResult projectConfigResult = requirementsByName.get(srName);
SubmitRequirementResult legacyResult = legacy.getValue();
// If there's no project config requirement with the same name as the legacy requirement
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 433abe6e71..fa75542e18 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -14,6 +14,9 @@
package com.google.gerrit.server.query.account;
+import static com.google.gerrit.server.index.account.AccountField.USERNAME_SPEC;
+
+import com.google.common.base.Ascii;
import com.google.common.collect.Lists;
import com.google.common.primitives.Ints;
import com.google.gerrit.entities.Account;
@@ -59,7 +62,9 @@ public class AccountPredicates {
}
}
}
- preds.add(username(query));
+ if (schema.hasField(USERNAME_SPEC)) {
+ preds.add(username(query));
+ }
// Adapt the capacity of the "predicates" list when adding more default
// predicates.
return Predicate.or(preds);
@@ -76,14 +81,14 @@ public class AccountPredicates {
public static Predicate<AccountState> emailIncludingSecondaryEmails(String email) {
return new AccountPredicate(
- AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
+ AccountField.EMAIL_SPEC, AccountQueryBuilder.FIELD_EMAIL, Ascii.toLowerCase(email));
}
public static Predicate<AccountState> preferredEmail(String email) {
return new AccountPredicate(
AccountField.PREFERRED_EMAIL_LOWER_CASE_SPEC,
AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
- email.toLowerCase());
+ Ascii.toLowerCase(email));
}
public static Predicate<AccountState> preferredEmailExact(String email) {
@@ -95,14 +100,14 @@ public class AccountPredicates {
public static Predicate<AccountState> equalsNameIncludingSecondaryEmails(String name) {
return new AccountPredicate(
- AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
+ AccountField.NAME_PART_SPEC, AccountQueryBuilder.FIELD_NAME, Ascii.toLowerCase(name));
}
public static Predicate<AccountState> equalsName(String name) {
return new AccountPredicate(
AccountField.NAME_PART_NO_SECONDARY_EMAIL_SPEC,
AccountQueryBuilder.FIELD_NAME,
- name.toLowerCase());
+ Ascii.toLowerCase(name));
}
public static Predicate<AccountState> externalIdIncludingSecondaryEmails(String externalId) {
@@ -123,7 +128,7 @@ public class AccountPredicates {
public static Predicate<AccountState> username(String username) {
return new AccountPredicate(
- AccountField.USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
+ USERNAME_SPEC, AccountQueryBuilder.FIELD_USERNAME, Ascii.toLowerCase(username));
}
public static Predicate<AccountState> watchedProject(Project.NameKey project) {
diff --git a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index ed950c8f0f..2f4a923db8 100644
--- a/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -14,9 +14,12 @@
package com.google.gerrit.server.query.account;
import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.exceptions.NotSignedInException;
import com.google.gerrit.exceptions.StorageException;
@@ -37,6 +40,8 @@ import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.account.AccountPredicates.AccountPredicate;
+import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.ProvisionException;
@@ -61,6 +66,7 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
public static class Arguments {
final ChangeFinder changeFinder;
+ final ChangeData.Factory changeDataFactory;
final PermissionBackend permissionBackend;
private final Provider<CurrentUser> self;
@@ -71,9 +77,11 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
Provider<CurrentUser> self,
AccountIndexCollection indexes,
ChangeFinder changeFinder,
+ ChangeData.Factory changeDataFactory,
PermissionBackend permissionBackend) {
this.self = self;
this.indexes = indexes;
+ this.changeDataFactory = changeDataFactory;
this.changeFinder = changeFinder;
this.permissionBackend = permissionBackend;
}
@@ -98,6 +106,7 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
}
}
+ @Nullable
Schema<AccountState> schema() {
Index<?, AccountState> index = indexes != null ? indexes.getSearchIndex() : null;
return index != null ? index.getSchema() : null;
@@ -119,7 +128,17 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
if (!changeNotes.isPresent()) {
throw error(String.format("change %s not found", change));
}
-
+ if (changeNotes.get().getChange().isPrivate()) {
+ Account.Id caller = self();
+ ChangeData cd = args.changeDataFactory.create(changeNotes.get());
+ Account.Id owner = cd.change().getOwner();
+ ImmutableSet<Account.Id> reviewersAndCC = cd.reviewers().all();
+ if (!(caller.equals(owner) || reviewersAndCC.contains(caller))) {
+ throw error(String.format("change %s not found", change));
+ }
+ return orAccountPredicate(
+ ImmutableList.<Account.Id>builder().add(owner).addAll(reviewersAndCC).build());
+ }
if (!args.permissionBackend
.user(args.getUser())
.change(changeNotes.get())
@@ -132,7 +151,7 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
@Operator
public Predicate<AccountState> email(String email)
throws PermissionBackendException, QueryParseException {
- if (canSeeSecondaryEmails()) {
+ if (canViewSecondaryEmails()) {
return AccountPredicates.emailIncludingSecondaryEmails(email);
}
@@ -140,7 +159,7 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
return AccountPredicates.preferredEmail(email);
}
- throw new QueryParseException("'email' operator is not supported by account index version");
+ throw new QueryParseException("'email' operator is not supported on this gerrit host");
}
@Operator
@@ -166,7 +185,7 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
@Operator
public Predicate<AccountState> name(String name)
throws PermissionBackendException, QueryParseException {
- if (canSeeSecondaryEmails()) {
+ if (canViewSecondaryEmails()) {
return AccountPredicates.equalsNameIncludingSecondaryEmails(name);
}
@@ -191,7 +210,7 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
@Override
protected Predicate<AccountState> defaultField(String query) {
Predicate<AccountState> defaultPredicate =
- AccountPredicates.defaultPredicate(args.schema(), checkedCanSeeSecondaryEmails(), query);
+ AccountPredicates.defaultPredicate(args.schema(), checkedCanViewSecondaryEmails(), query);
if (query.startsWith("cansee:")) {
try {
return cansee(query.substring(7));
@@ -214,13 +233,13 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
return args.getIdentifiedUser().getAccountId();
}
- private boolean canSeeSecondaryEmails() throws PermissionBackendException, QueryParseException {
- return args.permissionBackend.user(args.getUser()).test(GlobalPermission.MODIFY_ACCOUNT);
+ private boolean canViewSecondaryEmails() throws PermissionBackendException, QueryParseException {
+ return args.permissionBackend.user(args.getUser()).test(GlobalPermission.VIEW_SECONDARY_EMAILS);
}
- private boolean checkedCanSeeSecondaryEmails() {
+ private boolean checkedCanViewSecondaryEmails() {
try {
- return canSeeSecondaryEmails();
+ return canViewSecondaryEmails();
} catch (PermissionBackendException e) {
logger.atSevere().withCause(e).log("Permission check failed");
return false;
@@ -229,4 +248,14 @@ public class AccountQueryBuilder extends QueryBuilder<AccountState, AccountQuery
return false;
}
}
+
+ /** Creates an OR predicate of the account IDs of the {@code accounts} parameter. */
+ private Predicate<AccountState> orAccountPredicate(ImmutableList<Account.Id> accounts) {
+ Predicate<AccountState> result =
+ AccountPredicate.or(AccountPredicates.id(args.schema(), accounts.get(0)));
+ for (int i = 1; i < accounts.size(); i += 1) {
+ result = AccountPredicate.or(result, AccountPredicates.id(args.schema(), accounts.get(i)));
+ }
+ return result;
+ }
}
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 98a12d56d9..fa1758a8a1 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Multimap;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Project;
import com.google.gerrit.index.IndexConfig;
@@ -71,6 +72,7 @@ public class InternalAccountQuery extends InternalQuery<AccountState, InternalAc
return query(AccountPredicates.externalIdIncludingSecondaryEmails(externalId.toString()));
}
+ @Nullable
@UsedAt(UsedAt.Project.COLLABNET)
public AccountState oneByExternalId(ExternalId.Key externalId) {
List<AccountState> accountStates = byExternalId(externalId);
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
index 11749cc77f..ed876c1a24 100644
--- a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -28,6 +28,7 @@ import com.google.gerrit.server.account.GroupControl;
import com.google.gerrit.server.group.GroupResolver;
import com.google.inject.Inject;
import java.util.Arrays;
+import java.util.Locale;
import java.util.Optional;
public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
@@ -113,7 +114,7 @@ public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, Approval
private static <T extends Enum<T>> Optional<T> parseEnumValue(Class<T> clazz, String value) {
return Optional.ofNullable(
- Enums.getIfPresent(clazz, value.toUpperCase().replace('-', '_')).orNull());
+ Enums.getIfPresent(clazz, value.toUpperCase(Locale.US).replace('-', '_')).orNull());
}
private <T extends Enum<T>> String formatEnumValues(Class<T> clazz) {
diff --git a/java/com/google/gerrit/server/query/change/AddedPredicate.java b/java/com/google/gerrit/server/query/change/AddedPredicate.java
index 1f526c5dad..698884c3ff 100644
--- a/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -19,11 +19,11 @@ import com.google.gerrit.server.index.change.ChangeField;
public class AddedPredicate extends IntegerRangeChangePredicate {
public AddedPredicate(String value) throws QueryParseException {
- super(ChangeField.ADDED, value);
+ super(ChangeField.ADDED_LINES_SPEC, value);
}
@Override
protected Integer getValueInt(ChangeData changeData) {
- return ChangeField.ADDED.get(changeData);
+ return ChangeField.ADDED_LINES_SPEC.get(changeData);
}
}
diff --git a/java/com/google/gerrit/server/query/change/AfterPredicate.java b/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 251498996f..d3e3477687 100644
--- a/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.QueryParseException;
import java.sql.Timestamp;
import java.time.Instant;
@@ -26,7 +26,7 @@ import java.time.Instant;
public class AfterPredicate extends TimestampRangeChangePredicate {
protected final Instant cut;
- public AfterPredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+ public AfterPredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
throws QueryParseException {
super(def, name, value);
cut = parse(value);
diff --git a/java/com/google/gerrit/server/query/change/AgePredicate.java b/java/com/google/gerrit/server/query/change/AgePredicate.java
index c1138bd4c5..8a9ba2e575 100644
--- a/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -27,7 +27,7 @@ public class AgePredicate extends TimestampRangeChangePredicate {
protected final Instant cut;
public AgePredicate(String value) {
- super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
+ super(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.FIELD_AGE, value);
long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
long ms = MILLISECONDS.convert(s, SECONDS);
diff --git a/java/com/google/gerrit/server/query/change/BeforePredicate.java b/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 5d682fb018..e9ddbffa3c 100644
--- a/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.QueryParseException;
import java.sql.Timestamp;
import java.time.Instant;
@@ -26,7 +26,7 @@ import java.time.Instant;
public class BeforePredicate extends TimestampRangeChangePredicate {
protected final Instant cut;
- public BeforePredicate(FieldDef<ChangeData, Timestamp> def, String name, String value)
+ public BeforePredicate(SchemaField<ChangeData, Timestamp> def, String name, String value)
throws QueryParseException {
super(def, name, value);
cut = parse(value);
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 6ca3accc5d..d6df7e0895 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -14,10 +14,10 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
public class BooleanPredicate extends ChangeIndexPredicate {
- public BooleanPredicate(FieldDef<ChangeData, String> field) {
+ public BooleanPredicate(SchemaField<ChangeData, String> field) {
super(field, "1");
}
diff --git a/java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java b/java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java
new file mode 100644
index 0000000000..eb35b14704
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/BranchSetIndexPredicate.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** A Predicate to match any number of BranchNameKeys with O(1) efficiency */
+public class BranchSetIndexPredicate extends OrPredicate<ChangeData> {
+ private final String name;
+ private final Set<BranchNameKey> branches;
+
+ public BranchSetIndexPredicate(String name, Set<BranchNameKey> branches) throws StorageException {
+ super(getPredicates(branches));
+ this.name = name;
+ this.branches = branches;
+ }
+
+ @Override
+ public boolean match(ChangeData changeData) {
+ Change change = changeData.change();
+ if (change == null) {
+ return false;
+ }
+
+ return branches.contains(change.getDest());
+ }
+
+ @Override
+ public String toString() {
+ return "BranchSetIndexPredicate[" + name + "]" + super.toString();
+ }
+
+ private static List<Predicate<ChangeData>> getPredicates(Set<BranchNameKey> branches) {
+ return branches.stream()
+ .map(
+ branchNameKey ->
+ Predicate.and(
+ ChangePredicates.project(branchNameKey.project()),
+ ChangePredicates.ref(branchNameKey.branch())))
+ .collect(Collectors.toList());
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 9a32a0344c..f982235284 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -343,6 +343,7 @@ public class ChangeData {
.id(PatchSet.id(id, currentPatchSetId))
.commitId(commitId)
.uploader(Account.id(1000))
+ .realUploader(Account.id(1000))
.createdOn(TimeUtil.now())
.build();
return cd;
@@ -407,7 +408,7 @@ public class ChangeData {
* Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
* change and a given user.
*/
- private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
+ private Table<Account.Id, PatchSet.Id, Ref> editRefsByUser;
private Set<Account.Id> reviewedBy;
/**
@@ -729,6 +730,7 @@ public class ChangeData {
return notes;
}
+ @Nullable
public PatchSet currentPatchSet() {
if (currentPatchSet == null) {
Change c = change();
@@ -773,6 +775,7 @@ public class ChangeData {
currentApprovals = approvals;
}
+ @Nullable
public String commitMessage() {
if (commitMessage == null) {
if (!loadCommitData()) {
@@ -796,6 +799,7 @@ public class ChangeData {
return trackingFooters.extract(commitFooters());
}
+ @Nullable
public PersonIdent getAuthor() {
if (author == null) {
if (!loadCommitData()) {
@@ -805,6 +809,7 @@ public class ChangeData {
return author;
}
+ @Nullable
public PersonIdent getCommitter() {
if (committer == null) {
if (!loadCommitData()) {
@@ -900,6 +905,7 @@ public class ChangeData {
}
/** Returns patch with the given ID, or null if it does not exist. */
+ @Nullable
public PatchSet patchSet(PatchSet.Id psId) {
if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
return currentPatchSet;
@@ -1047,6 +1053,7 @@ public class ChangeData {
return robotComments;
}
+ @Nullable
public Integer unresolvedCommentCount() {
if (unresolvedCommentCount == null) {
if (!lazyload()) {
@@ -1069,6 +1076,7 @@ public class ChangeData {
this.unresolvedCommentCount = count;
}
+ @Nullable
public Integer totalCommentCount() {
if (totalCommentCount == null) {
if (!lazyload()) {
@@ -1114,7 +1122,7 @@ public class ChangeData {
* submit requirements are evaluated online.
*
* <p>For changes loaded from the index, the value will be set from index field {@link
- * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
+ * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS_FIELD}.
*/
public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
if (submitRequirements == null) {
@@ -1248,8 +1256,8 @@ public class ChangeData {
return editRefs().rowKeySet();
}
- public Table<Account.Id, PatchSet.Id, ObjectId> editRefs() {
- if (editsByUser == null) {
+ public Table<Account.Id, PatchSet.Id, Ref> editRefs() {
+ if (editRefsByUser == null) {
if (!lazyload()) {
return HashBasedTable.create();
}
@@ -1257,7 +1265,7 @@ public class ChangeData {
if (c == null) {
return HashBasedTable.create();
}
- editsByUser = HashBasedTable.create();
+ editRefsByUser = HashBasedTable.create();
Change.Id id = requireNonNull(change.getId());
try (Repository repo = repoManager.openRepository(project())) {
for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
@@ -1268,7 +1276,7 @@ public class ChangeData {
if (id.equals(ps.changeId())) {
Account.Id accountId = Account.Id.fromRef(ref.getName());
if (accountId != null) {
- editsByUser.put(accountId, ps, ref.getObjectId());
+ editRefsByUser.put(accountId, ps, ref);
}
}
}
@@ -1276,7 +1284,7 @@ public class ChangeData {
throw new StorageException(e);
}
}
- return editsByUser;
+ return editRefsByUser;
}
public Set<Account.Id> draftsByUser() {
@@ -1424,13 +1432,13 @@ public class ChangeData {
}
ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
- for (Table.Cell<Account.Id, PatchSet.Id, ObjectId> edit : editRefs().cellSet()) {
+ for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
result.put(
project,
RefState.create(
RefNames.refsEdit(
edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
- edit.getValue()));
+ edit.getValue().getObjectId()));
}
// TODO: instantiating the notes is too much. We don't want to parse NoteDb, we just want the
@@ -1460,21 +1468,6 @@ public class ChangeData {
.forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
}
}
- if (editsByUser == null) {
- // Recover edit refs as well. Edits are represented as refs in the repository.
- // ChangeData exposes #editsByUser which just provides a Set of Account.Ids of users who
- // have edits on this change. Recovering this list from RefStates makes it available even
- // on ChangeData instances retrieved from the index.
- editsByUser = HashBasedTable.create();
- if (refStates.containsKey(project())) {
- refStates.get(project()).stream()
- .filter(r -> RefNames.isRefsEdit(r.ref()))
- .forEach(
- r ->
- editsByUser.put(
- Account.Id.fromRef(r.ref()), PatchSet.Id.fromEditRef(r.ref()), r.id()));
- }
- }
}
public ImmutableList<byte[]> getRefStatePatterns() {
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
index 6540d80820..e39b3e26d0 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexCardinalPredicate.java
@@ -14,20 +14,20 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.HasCardinality;
public class ChangeIndexCardinalPredicate extends ChangeIndexPredicate implements HasCardinality {
protected final int cardinality;
protected ChangeIndexCardinalPredicate(
- FieldDef<ChangeData, ?> def, String value, int cardinality) {
+ SchemaField<ChangeData, ?> def, String value, int cardinality) {
super(def, value);
this.cardinality = cardinality;
}
protected ChangeIndexCardinalPredicate(
- FieldDef<ChangeData, ?> def, String name, String value, int cardinality) {
+ SchemaField<ChangeData, ?> def, String name, String value, int cardinality) {
super(def, name, value);
this.cardinality = cardinality;
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
index d86d3668e3..c69f021e5e 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPostFilterPredicate.java
@@ -14,18 +14,19 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
/**
* Predicate that is mapped to a field in the change index, with additional filtering done in the
* {@code match} method.
*/
public abstract class ChangeIndexPostFilterPredicate extends ChangeIndexPredicate {
- protected ChangeIndexPostFilterPredicate(FieldDef<ChangeData, ?> def, String value) {
+ protected ChangeIndexPostFilterPredicate(SchemaField<ChangeData, ?> def, String value) {
super(def, value);
}
- protected ChangeIndexPostFilterPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+ protected ChangeIndexPostFilterPredicate(
+ SchemaField<ChangeData, ?> def, String name, String value) {
super(def, name, value);
}
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index ccd4109b28..a897a8d159 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.IndexPredicate;
import com.google.gerrit.index.query.Predicate;
@@ -32,11 +32,11 @@ public class ChangeIndexPredicate extends IndexPredicate<ChangeData> {
return ChangeStatusPredicate.NONE;
}
- protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String value) {
+ protected ChangeIndexPredicate(SchemaField<ChangeData, ?> def, String value) {
super(def, value);
}
- protected ChangeIndexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+ protected ChangeIndexPredicate(SchemaField<ChangeData, ?> def, String name, String value) {
super(def, name, value);
}
}
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 5f9abc3b71..528d0ce2d9 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -46,14 +46,6 @@ public class ChangePredicates {
}
/**
- * Returns a predicate that matches changes that are assigned to the provided {@link
- * com.google.gerrit.entities.Account.Id}.
- */
- public static Predicate<ChangeData> assignee(Account.Id id) {
- return new ChangeIndexPredicate(ChangeField.ASSIGNEE, id.toString());
- }
-
- /**
* Returns a predicate that matches changes that are a revert of the provided {@link
* com.google.gerrit.entities.Change.Id}.
*/
@@ -66,7 +58,7 @@ public class ChangePredicates {
* com.google.gerrit.entities.Account.Id}.
*/
public static Predicate<ChangeData> commentBy(Account.Id id) {
- return new ChangeIndexPredicate(ChangeField.COMMENTBY, id.toString());
+ return new ChangeIndexPredicate(ChangeField.COMMENTBY_SPEC, id.toString());
}
/**
@@ -74,7 +66,7 @@ public class ChangePredicates {
* com.google.gerrit.entities.Account.Id} has a pending change edit.
*/
public static Predicate<ChangeData> editBy(Account.Id id) {
- return new ChangeIndexPredicate(ChangeField.EDITBY, id.toString());
+ return new ChangeIndexPredicate(ChangeField.EDITBY_SPEC, id.toString());
}
/**
@@ -95,10 +87,9 @@ public class ChangePredicates {
* Returns a predicate that matches changes where the provided {@link
* com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
*/
- public static Predicate<ChangeData> starBy(
- StarredChangesUtil starredChangesUtil, Account.Id id, String label) {
+ public static Predicate<ChangeData> starBy(StarredChangesUtil starredChangesUtil, Account.Id id) {
Set<Predicate<ChangeData>> starredChanges =
- starredChangesUtil.byAccountId(id, label).stream()
+ starredChangesUtil.byAccountId(id).stream()
.map(ChangePredicates::idStr)
.collect(toImmutableSet());
return starredChanges.isEmpty() ? ChangeIndexPredicate.none() : Predicate.or(starredChanges);
@@ -111,7 +102,7 @@ public class ChangePredicates {
public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
for (Account.Id id : ids) {
- predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY, id.toString()));
+ predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY_SPEC, id.toString()));
}
return Predicate.or(predicates);
}
@@ -119,7 +110,7 @@ public class ChangePredicates {
/** Returns a predicate that matches changes that were not yet reviewed. */
public static Predicate<ChangeData> unreviewed() {
return Predicate.not(
- new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
+ new ChangeIndexPredicate(ChangeField.REVIEWEDBY_SPEC, ChangeField.NOT_REVIEWED.toString()));
}
/**
@@ -127,8 +118,12 @@ public class ChangePredicates {
* com.google.gerrit.entities.Change.Id}.
*/
public static Predicate<ChangeData> idStr(Change.Id id) {
+ return idStr(id.toString());
+ }
+
+ public static Predicate<ChangeData> idStr(String id) {
return new ChangeIndexCardinalPredicate(
- ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
+ ChangeField.NUMERIC_ID_STR_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id, 1);
}
/**
@@ -136,7 +131,7 @@ public class ChangePredicates {
* com.google.gerrit.entities.Account.Id}.
*/
public static Predicate<ChangeData> owner(Account.Id id) {
- return new ChangeIndexCardinalPredicate(ChangeField.OWNER, id.toString(), 5000);
+ return new ChangeIndexCardinalPredicate(ChangeField.OWNER_SPEC, id.toString(), 5000);
}
/**
@@ -144,7 +139,7 @@ public class ChangePredicates {
* provided {@link com.google.gerrit.entities.Account.Id}.
*/
public static Predicate<ChangeData> uploader(Account.Id id) {
- return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+ return new ChangeIndexPredicate(ChangeField.UPLOADER_SPEC, id.toString());
}
/**
@@ -170,12 +165,12 @@ public class ChangePredicates {
* com.google.gerrit.entities.Project.NameKey}.
*/
public static Predicate<ChangeData> project(Project.NameKey id) {
- return new ChangeIndexCardinalPredicate(ChangeField.PROJECT, id.get(), 1_000_000);
+ return new ChangeIndexCardinalPredicate(ChangeField.PROJECT_SPEC, id.get(), 1_000_000);
}
/** Returns a predicate that matches changes targeted at the provided {@code refName}. */
public static Predicate<ChangeData> ref(String refName) {
- return new ChangeIndexCardinalPredicate(ChangeField.REF, refName, 10_000);
+ return new ChangeIndexCardinalPredicate(ChangeField.REF_SPEC, refName, 10_000);
}
/** Returns a predicate that matches changes in the provided {@code topic}. */
@@ -195,26 +190,26 @@ public class ChangePredicates {
/** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
public static Predicate<ChangeData> submissionId(String changeSet) {
- return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
+ return new ChangeIndexPredicate(ChangeField.SUBMISSIONID_SPEC, changeSet);
}
/** Returns a predicate that matches changes that modified the provided {@code path}. */
public static Predicate<ChangeData> path(String path) {
- return new ChangeIndexPredicate(ChangeField.PATH, path);
+ return new ChangeIndexPredicate(ChangeField.PATH_SPEC, path);
}
/** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
public static Predicate<ChangeData> hashtag(String hashtag) {
// Use toLowerCase without locale to match behavior in ChangeField.
return new ChangeIndexPredicate(
- ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+ ChangeField.HASHTAG_SPEC, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
}
/** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
// Use toLowerCase without locale to match behavior in ChangeField.
return new ChangeIndexPredicate(
- ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+ ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
}
/**
@@ -223,16 +218,16 @@ public class ChangePredicates {
public static Predicate<ChangeData> prefixHashtag(String hashtag) {
// Use toLowerCase without locale to match behavior in ChangeField.
return new ChangeIndexPredicate(
- ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+ ChangeField.PREFIX_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase(Locale.US));
}
/** Returns a predicate that matches changes that modified the provided {@code file}. */
public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
Predicate<ChangeData> eqPath = path(file);
- if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
+ if (!args.getSchema().hasField(ChangeField.FILE_PART_SPEC)) {
return eqPath;
}
- return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART, file));
+ return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART_SPEC, file));
}
/**
@@ -247,7 +242,7 @@ public class ChangePredicates {
if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
footer = footer.substring(0, indexEquals) + ": " + footer.substring(indexEquals + 1);
}
- return new ChangeIndexPredicate(ChangeField.FOOTER, footer.toLowerCase(Locale.US));
+ return new ChangeIndexPredicate(ChangeField.FOOTER_SPEC, footer.toLowerCase(Locale.US));
}
/**
@@ -263,22 +258,23 @@ public class ChangePredicates {
*/
public static Predicate<ChangeData> directory(String directory) {
return new ChangeIndexPredicate(
- ChangeField.DIRECTORY, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
+ ChangeField.DIRECTORY_SPEC, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
}
/** Returns a predicate that matches changes with the provided {@code trackingId}. */
public static Predicate<ChangeData> trackingId(String trackingId) {
- return new ChangeIndexCardinalPredicate(ChangeField.TR, trackingId, 5);
+ return new ChangeIndexCardinalPredicate(ChangeField.TR_SPEC, trackingId, 5);
}
/** Returns a predicate that matches changes authored by the provided {@code exactAuthor}. */
public static Predicate<ChangeData> exactAuthor(String exactAuthor) {
- return new ChangeIndexPredicate(ChangeField.EXACT_AUTHOR, exactAuthor.toLowerCase(Locale.US));
+ return new ChangeIndexPredicate(
+ ChangeField.EXACT_AUTHOR_SPEC, exactAuthor.toLowerCase(Locale.US));
}
/** Returns a predicate that matches changes authored by the provided {@code author}. */
public static Predicate<ChangeData> author(String author) {
- return new ChangeIndexPredicate(ChangeField.AUTHOR, author);
+ return new ChangeIndexPredicate(ChangeField.AUTHOR_PARTS_SPEC, author);
}
/**
@@ -287,7 +283,7 @@ public class ChangePredicates {
*/
public static Predicate<ChangeData> exactCommitter(String exactCommitter) {
return new ChangeIndexPredicate(
- ChangeField.EXACT_COMMITTER, exactCommitter.toLowerCase(Locale.US));
+ ChangeField.EXACT_COMMITTER_SPEC, exactCommitter.toLowerCase(Locale.US));
}
/**
@@ -295,12 +291,13 @@ public class ChangePredicates {
* committer}.
*/
public static Predicate<ChangeData> committer(String comitter) {
- return new ChangeIndexPredicate(ChangeField.COMMITTER, comitter.toLowerCase(Locale.US));
+ return new ChangeIndexPredicate(
+ ChangeField.COMMITTER_PARTS_SPEC, comitter.toLowerCase(Locale.US));
}
/** Returns a predicate that matches changes whose ID starts with the provided {@code id}. */
public static Predicate<ChangeData> idPrefix(String id) {
- return new ChangeIndexCardinalPredicate(ChangeField.ID, id, 5);
+ return new ChangeIndexCardinalPredicate(ChangeField.CHANGE_ID_SPEC, id, 5);
}
/**
@@ -308,7 +305,7 @@ public class ChangePredicates {
* its name.
*/
public static Predicate<ChangeData> projectPrefix(String prefix) {
- return new ChangeIndexPredicate(ChangeField.PROJECTS, prefix);
+ return new ChangeIndexPredicate(ChangeField.PROJECTS_SPEC, prefix);
}
/**
@@ -317,9 +314,9 @@ public class ChangePredicates {
*/
public static Predicate<ChangeData> commitPrefix(String commitId) {
if (commitId.length() == ObjectIds.STR_LEN) {
- return new ChangeIndexCardinalPredicate(ChangeField.EXACT_COMMIT, commitId, 5);
+ return new ChangeIndexCardinalPredicate(ChangeField.EXACT_COMMIT_SPEC, commitId, 5);
}
- return new ChangeIndexCardinalPredicate(ChangeField.COMMIT, commitId, 5);
+ return new ChangeIndexCardinalPredicate(ChangeField.COMMIT_SPEC, commitId, 5);
}
/**
@@ -330,12 +327,20 @@ public class ChangePredicates {
return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
}
+ public static Predicate<ChangeData> subject(String subject) {
+ return new ChangeIndexPredicate(ChangeField.SUBJECT_SPEC, subject);
+ }
+
+ public static Predicate<ChangeData> prefixSubject(String subject) {
+ return new ChangeIndexPredicate(ChangeField.PREFIX_SUBJECT_SPEC, subject);
+ }
+
/**
* Returns a predicate that matches changes where the provided {@code comment} appears in any
* comment on any patch set of the change. Uses full-text search semantics.
*/
public static Predicate<ChangeData> comment(String comment) {
- return new ChangeIndexPredicate(ChangeField.COMMENT, comment);
+ return new ChangeIndexPredicate(ChangeField.COMMENT_SPEC, comment);
}
/**
@@ -346,7 +351,7 @@ public class ChangePredicates {
* in the form of 'gerrit~$rule_name'.
*/
public static Predicate<ChangeData> submitRuleStatus(String value) {
- return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
+ return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT_SPEC, value);
}
/**
@@ -354,7 +359,7 @@ public class ChangePredicates {
* to "1", or non-pure reverts if {@code value} is "0".
*/
public static Predicate<ChangeData> pureRevert(String value) {
- return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT, value);
+ return new ChangeIndexPredicate(ChangeField.IS_PURE_REVERT_SPEC, value);
}
/**
@@ -365,6 +370,6 @@ public class ChangePredicates {
* com.google.gerrit.entities.SubmitRequirement}s.
*/
public static Predicate<ChangeData> isSubmittable(String value) {
- return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE, value);
+ return new ChangeIndexPredicate(ChangeField.IS_SUBMITTABLE_SPEC, value);
}
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 4c548e04de..816936b220 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -24,11 +24,13 @@ import static java.util.stream.Collectors.toSet;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Enums;
import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
@@ -42,9 +44,9 @@ import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.NotSignedInException;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.SchemaUtil;
import com.google.gerrit.index.query.LimitPredicate;
import com.google.gerrit.index.query.Predicate;
@@ -63,6 +65,7 @@ import com.google.gerrit.server.account.DestinationList;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupBackends;
import com.google.gerrit.server.account.GroupMembers;
+import com.google.gerrit.server.account.QueryList;
import com.google.gerrit.server.account.VersionedAccountDestinations;
import com.google.gerrit.server.account.VersionedAccountQueries;
import com.google.gerrit.server.change.ChangeTriplet;
@@ -99,6 +102,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
@@ -151,7 +155,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
public static final String FIELD_ATTENTION_SET_USERS = "attentionusers";
public static final String FIELD_ATTENTION_SET_USERS_COUNT = "attentionuserscount";
public static final String FIELD_ATTENTION_SET_FULL = "attentionfull";
- public static final String FIELD_ASSIGNEE = "assignee";
+ @Deprecated public static final String FIELD_ASSIGNEE = "assignee";
public static final String FIELD_AUTHOR = "author";
public static final String FIELD_EXACTAUTHOR = "exactauthor";
@@ -184,6 +188,8 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
public static final String FIELD_MERGEABLE = "mergeable2";
public static final String FIELD_MERGED_ON = "mergedon";
public static final String FIELD_MESSAGE = "message";
+ public static final String FIELD_SUBJECT = "subject";
+ public static final String FIELD_PREFIX_SUBJECT = "prefixsubject";
public static final String FIELD_MESSAGE_EXACT = "messageexact";
public static final String FIELD_OWNER = "owner";
public static final String FIELD_OWNERIN = "ownerin";
@@ -221,9 +227,12 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
public static final String ARG_ID_GROUP = "group";
public static final String ARG_ID_OWNER = "owner";
public static final String ARG_ID_NON_UPLOADER = "non_uploader";
+ public static final String ARG_ID_NON_CONTRIBUTOR = "non_contributor";
public static final String ARG_COUNT = "count";
public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
+ public static final Account.Id NON_CONTRIBUTOR_ACCOUNT_ID = Account.id(-2);
+ public static final Account.Id NON_EXISTING_ACCOUNT_ID = Account.id(-1000);
public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
public static final String OPERATOR_MERGED_AFTER = "mergedafter";
@@ -407,7 +416,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
this.submitRules = submitRules;
}
- Arguments asUser(CurrentUser otherUser) {
+ public Arguments asUser(CurrentUser otherUser) {
return new Arguments(
queryProvider,
rewriter,
@@ -475,21 +484,23 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
}
}
+ @Nullable
Schema<ChangeData> getSchema() {
return index != null ? index.getSchema() : null;
}
}
- private final Arguments args;
+ protected final Arguments args;
protected Map<String, String> hasOperandAliases = Collections.emptyMap();
- private Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+ private final Map<Account.Id, DestinationList> destinationListByAccount = new HashMap<>();
+ private final Map<Account.Id, QueryList> queryListByAccount = new HashMap<>();
private static final Splitter RULE_SPLITTER = Splitter.on("=");
private static final Splitter PLUGIN_SPLITTER = Splitter.on("_");
- private static final Splitter LABEL_SPLITTER = Splitter.on(",");
+ protected static final Splitter LABEL_SPLITTER = Splitter.on(",");
@Inject
- ChangeQueryBuilder(Arguments args) {
+ protected ChangeQueryBuilder(Arguments args) {
this(mydef, args);
}
@@ -513,6 +524,10 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return new ChangeQueryBuilder(builderDef, args.asUser(user));
}
+ public Arguments getArgs() {
+ return args;
+ }
+
@Operator
public Predicate<ChangeData> age(String value) {
return new AgePredicate(value);
@@ -520,7 +535,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> before(String value) throws QueryParseException {
- return new BeforePredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_BEFORE, value);
+ return new BeforePredicate(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.OPERATOR_BEFORE, value);
}
@Operator
@@ -530,7 +545,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> after(String value) throws QueryParseException {
- return new AfterPredicate(ChangeField.UPDATED, ChangeQueryBuilder.OPERATOR_AFTER, value);
+ return new AfterPredicate(ChangeField.UPDATED_SPEC, ChangeQueryBuilder.OPERATOR_AFTER, value);
}
@Operator
@@ -540,16 +555,16 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> mergedBefore(String value) throws QueryParseException {
- checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_BEFORE);
+ checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_BEFORE);
return new BeforePredicate(
- ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
+ ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_BEFORE, value);
}
@Operator
public Predicate<ChangeData> mergedAfter(String value) throws QueryParseException {
- checkFieldAvailable(ChangeField.MERGED_ON, OPERATOR_MERGED_AFTER);
+ checkOperatorAvailable(ChangeField.MERGED_ON_SPEC, OPERATOR_MERGED_AFTER);
return new AfterPredicate(
- ChangeField.MERGED_ON, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
+ ChangeField.MERGED_ON_SPEC, ChangeQueryBuilder.OPERATOR_MERGED_AFTER, value);
}
@Operator
@@ -630,7 +645,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
}
if ("attention".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
+ checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "has:attention");
return new IsAttentionPredicate();
}
@@ -673,13 +688,14 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
}
if ("uploader".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.UPLOADER, "is:uploader");
+ checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "is:uploader");
return ChangePredicates.uploader(self());
}
if ("reviewer".equalsIgnoreCase(value)) {
return Predicate.and(
- Predicate.not(new BooleanPredicate(ChangeField.WIP)), ReviewerPredicate.reviewer(self()));
+ Predicate.not(new BooleanPredicate(ChangeField.WIP_SPEC)),
+ ReviewerPredicate.reviewer(self()));
}
if ("cc".equalsIgnoreCase(value)) {
@@ -688,40 +704,33 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
if ("mergeable".equalsIgnoreCase(value)) {
if (!args.indexMergeable) {
- throw new QueryParseException("'is:mergeable' operator is not supported by server");
+ throw new QueryParseException(
+ "'is:mergeable' operator is not supported on this gerrit host");
}
- return new BooleanPredicate(ChangeField.MERGEABLE);
+ return new BooleanPredicate(ChangeField.MERGEABLE_SPEC);
}
if ("merge".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.MERGE, "is:merge");
- return new BooleanPredicate(ChangeField.MERGE);
+ checkOperatorAvailable(ChangeField.MERGE_SPEC, "is:merge");
+ return new BooleanPredicate(ChangeField.MERGE_SPEC);
}
if ("private".equalsIgnoreCase(value)) {
- return new BooleanPredicate(ChangeField.PRIVATE);
+ return new BooleanPredicate(ChangeField.PRIVATE_SPEC);
}
if ("attention".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
+ checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "is:attention");
return new IsAttentionPredicate();
}
- if ("assigned".equalsIgnoreCase(value)) {
- return Predicate.not(ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE)));
- }
-
- if ("unassigned".equalsIgnoreCase(value)) {
- return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
- }
-
if ("pure-revert".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.IS_PURE_REVERT, "is:pure-revert");
+ checkOperatorAvailable(ChangeField.IS_PURE_REVERT_SPEC, "is:pure-revert");
return ChangePredicates.pureRevert("1");
}
if ("submittable".equalsIgnoreCase(value)) {
- if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE)) {
+ if (!args.index.getSchema().hasField(ChangeField.IS_SUBMITTABLE_SPEC)) {
// SubmittablePredicate will match if *any* of the submit records are OK,
// but we need to check that they're *all* OK, so check that none of the
// submit records match any of the negative cases. To avoid checking yet
@@ -732,22 +741,22 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
Predicate.not(new SubmittablePredicate(SubmitRecord.Status.NOT_READY)),
Predicate.not(new SubmittablePredicate(SubmitRecord.Status.RULE_ERROR)));
}
- checkFieldAvailable(ChangeField.IS_SUBMITTABLE, "is:submittable");
+ checkOperatorAvailable(ChangeField.IS_SUBMITTABLE_SPEC, "is:submittable");
return new IsSubmittablePredicate();
}
if ("started".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.STARTED, "is:started");
- return new BooleanPredicate(ChangeField.STARTED);
+ checkOperatorAvailable(ChangeField.STARTED_SPEC, "is:started");
+ return new BooleanPredicate(ChangeField.STARTED_SPEC);
}
if ("wip".equalsIgnoreCase(value)) {
- return new BooleanPredicate(ChangeField.WIP);
+ return new BooleanPredicate(ChangeField.WIP_SPEC);
}
if ("cherrypick".equalsIgnoreCase(value)) {
- checkFieldAvailable(ChangeField.CHERRY_PICK, "is:cherrypick");
- return new BooleanPredicate(ChangeField.CHERRY_PICK);
+ checkOperatorAvailable(ChangeField.CHERRY_PICK_SPEC, "is:cherrypick");
+ return new BooleanPredicate(ChangeField.CHERRY_PICK_SPEC);
}
// for plugins the value will be operandName_pluginName
@@ -769,7 +778,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> conflicts(String value) throws QueryParseException {
if (!args.conflictsPredicateEnabled) {
- throw new QueryParseException("'conflicts:' operator is not supported by server");
+ throw new QueryParseException("'conflicts:' operator is not supported on this gerrit host");
}
List<Change> changes = parseChange(value);
List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
@@ -881,7 +890,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return ChangePredicates.hashtag(hashtag);
}
- checkFieldAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
+ checkOperatorAvailable(ChangeField.FUZZY_HASHTAG, "inhashtag");
return ChangePredicates.fuzzyHashtag(hashtag);
}
@@ -891,7 +900,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return ChangePredicates.hashtag(hashtag);
}
- checkFieldAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
+ checkOperatorAvailable(ChangeField.PREFIX_HASHTAG, "prefixhashtag");
return ChangePredicates.prefixHashtag(hashtag);
}
@@ -917,7 +926,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return ChangePredicates.exactTopic(name);
}
- checkFieldAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
+ checkOperatorAvailable(ChangeField.PREFIX_TOPIC, "prefixtopic");
return ChangePredicates.prefixTopic(name);
}
@@ -983,7 +992,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> hasfooter(String footerName) throws QueryParseException {
- checkFieldAvailable(ChangeField.FOOTER_NAME, "hasfooter");
+ checkOperatorAvailable(ChangeField.FOOTER_NAME, "hasfooter");
return ChangePredicates.hasFooter(footerName);
}
@@ -1039,8 +1048,10 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
accounts = Collections.singleton(OWNER_ACCOUNT_ID);
} else if (value.equals(ARG_ID_NON_UPLOADER)) {
accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+ } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+ accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
} else {
- accounts = parseAccount(value);
+ accounts = parseAccountIgnoreVisibility(value);
}
} else if (key.equalsIgnoreCase(ARG_ID_GROUP)) {
group = parseGroup(value).getUUID();
@@ -1068,15 +1079,17 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
if (accounts != null || group != null) {
throw new QueryParseException("more than one user/group specified (" + value + ")");
}
- try {
- if (value.equals(ARG_ID_OWNER)) {
- accounts = Collections.singleton(OWNER_ACCOUNT_ID);
- } else if (value.equals(ARG_ID_NON_UPLOADER)) {
- accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
- } else {
- accounts = parseAccount(value);
- }
- } catch (QueryParseException qpex) {
+ if (value.equals(ARG_ID_OWNER)) {
+ accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+ } else if (value.equals(ARG_ID_NON_UPLOADER)) {
+ accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
+ } else if (value.equals(ARG_ID_NON_CONTRIBUTOR)) {
+ accounts = Collections.singleton(NON_CONTRIBUTOR_ACCOUNT_ID);
+ } else {
+ accounts = parseAccountIgnoreVisibility(value);
+ }
+
+ if (accounts.contains(NON_EXISTING_ACCOUNT_ID)) {
// If it doesn't match an account, see if it matches a group
// (accounts get precedence)
try {
@@ -1096,7 +1109,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
// submit record status, interpret as a submit record query.
int eq = name.indexOf('=');
if (eq > 0) {
- String statusName = name.substring(eq + 1).toUpperCase();
+ String statusName = name.substring(eq + 1).toUpperCase(Locale.US);
if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
SubmitRecord.Label.Status status =
Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
@@ -1107,9 +1120,16 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
}
}
+ validateLabelArgs(accounts);
return new LabelPredicate(args, name, accounts, group, count, countOp);
}
+ protected void validateLabelArgs(Set<Account.Id> accounts) throws QueryParseException {
+ if (accounts != null && accounts.contains(NON_CONTRIBUTOR_ACCOUNT_ID)) {
+ throw new QueryParseException("non_contributor arg is not allowed in change queries");
+ }
+ }
+
/** Assert that keys {@code k1} and {@code k2} do not exist in {@code labelArgs} together. */
private void assertDisjunctive(PredicateArgs labelArgs, String k1, String k2)
throws QueryParseException {
@@ -1134,15 +1154,29 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> message(String text) throws QueryParseException {
if (text.startsWith("^")) {
- checkFieldAvailable(ChangeField.COMMIT_MESSAGE_EXACT, "messageexact");
+ checkFieldAvailable(
+ ChangeField.COMMIT_MESSAGE_EXACT,
+ "'message' operator with regular expression is not supported on this gerrit host");
return new RegexMessagePredicate(text);
}
return ChangePredicates.message(text);
}
+ @Operator
+ public Predicate<ChangeData> subject(String value) throws QueryParseException {
+ checkOperatorAvailable(ChangeField.SUBJECT_SPEC, ChangeQueryBuilder.FIELD_SUBJECT);
+ return ChangePredicates.subject(value);
+ }
+
+ @Operator
+ public Predicate<ChangeData> prefixsubject(String value) throws QueryParseException {
+ checkOperatorAvailable(
+ ChangeField.PREFIX_SUBJECT_SPEC, ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
+ return ChangePredicates.prefixSubject(value);
+ }
+
private Predicate<ChangeData> starredBySelf() throws QueryParseException {
- return ChangePredicates.starBy(
- args.starredChangesUtil, self(), StarredChangesUtil.DEFAULT_LABEL);
+ return ChangePredicates.starBy(args.starredChangesUtil, self());
}
private Predicate<ChangeData> draftBySelf() throws QueryParseException {
@@ -1201,7 +1235,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> owner(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- return owner(parseAccount(who, (AccountState s) -> true));
+ return owner(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
}
private Predicate<ChangeData> owner(Set<Account.Id> who) {
@@ -1214,7 +1248,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
private Predicate<ChangeData> ownerDefaultField(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- Set<Account.Id> accounts = parseAccount(who);
+ Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
return Predicate.any();
}
@@ -1224,8 +1258,8 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> uploader(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- checkFieldAvailable(ChangeField.UPLOADER, "uploader");
- return uploader(parseAccount(who, (AccountState s) -> true));
+ checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploader");
+ return uploader(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
}
private Predicate<ChangeData> uploader(Set<Account.Id> who) {
@@ -1239,8 +1273,8 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> attention(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- checkFieldAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
- return attention(parseAccount(who, (AccountState s) -> true));
+ checkOperatorAvailable(ChangeField.ATTENTION_SET_USERS, "attention");
+ return attention(parseAccountIgnoreVisibility(who, (AccountState s) -> true));
}
private Predicate<ChangeData> attention(Set<Account.Id> who) {
@@ -1248,20 +1282,6 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
}
@Operator
- public Predicate<ChangeData> assignee(String who)
- throws QueryParseException, IOException, ConfigInvalidException {
- return assignee(parseAccount(who, (AccountState s) -> true));
- }
-
- private Predicate<ChangeData> assignee(Set<Account.Id> who) {
- List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
- for (Account.Id id : who) {
- p.add(ChangePredicates.assignee(id));
- }
- return Predicate.or(p);
- }
-
- @Operator
public Predicate<ChangeData> ownerin(String group) throws QueryParseException, IOException {
GroupReference g = parseGroup(group);
AccountGroup.UUID groupId = g.getUUID();
@@ -1279,7 +1299,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
- checkFieldAvailable(ChangeField.UPLOADER, "uploaderin");
+ checkOperatorAvailable(ChangeField.UPLOADER_SPEC, "uploaderin");
GroupReference g = parseGroup(group);
AccountGroup.UUID groupId = g.getUUID();
@@ -1319,7 +1339,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
if (Objects.equals(byState, Predicate.<ChangeData>any())) {
return Predicate.any();
}
- return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
+ return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP_SPEC)), byState);
}
@Operator
@@ -1376,7 +1396,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> commentby(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- return commentby(parseAccount(who));
+ return commentby(parseAccountIgnoreVisibility(who));
}
private Predicate<ChangeData> commentby(Set<Account.Id> who) {
@@ -1390,7 +1410,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> from(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- Set<Account.Id> ownerIds = parseAccount(who);
+ Set<Account.Id> ownerIds = parseAccountIgnoreVisibility(who);
return Predicate.or(owner(ownerIds), commentby(ownerIds));
}
@@ -1401,16 +1421,16 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
String name = null;
Account.Id account = null;
- try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
- // [name=]<name>
- if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
- name = inputArgs.keyValue.get(ARG_ID_NAME).value();
- } else if (inputArgs.positional.size() == 1) {
- name = Iterables.getOnlyElement(inputArgs.positional);
- } else if (inputArgs.positional.size() > 1) {
- throw new QueryParseException("Error parsing named query: " + value);
- }
+ // [name=]<name>
+ if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+ name = inputArgs.keyValue.get(ARG_ID_NAME).value();
+ } else if (inputArgs.positional.size() == 1) {
+ name = Iterables.getOnlyElement(inputArgs.positional);
+ } else if (inputArgs.positional.size() > 1) {
+ throw new QueryParseException("Error parsing named query: " + value);
+ }
+ try {
// [,user=<user>]
if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
@@ -1424,9 +1444,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
account = self();
}
- VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
- q.load(args.allUsersName, git);
- String query = q.getQueryList().getQuery(name);
+ String query = getQueryList(account).getQuery(name);
if (query != null) {
return parse(query);
}
@@ -1439,10 +1457,27 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
throw new QueryParseException("Unknown named query: " + name);
}
+ protected QueryList getQueryList(Account.Id account) throws ConfigInvalidException, IOException {
+ QueryList ql = queryListByAccount.get(account);
+ if (ql == null) {
+ ql = loadQueryList(account);
+ queryListByAccount.put(account, ql);
+ }
+ return ql;
+ }
+
+ protected QueryList loadQueryList(Account.Id account) throws ConfigInvalidException, IOException {
+ VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
+ try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
+ q.load(args.allUsersName, git);
+ }
+ return q.getQueryList();
+ }
+
@Operator
public Predicate<ChangeData> reviewedby(String who)
throws QueryParseException, IOException, ConfigInvalidException {
- return ChangePredicates.reviewedBy(parseAccount(who));
+ return ChangePredicates.reviewedBy(parseAccountIgnoreVisibility(who));
}
@Operator
@@ -1452,16 +1487,16 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
String name = null;
Account.Id account = null;
- try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
- // [name=]<name>
- if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
- name = inputArgs.keyValue.get(ARG_ID_NAME).value();
- } else if (inputArgs.positional.size() == 1) {
- name = Iterables.getOnlyElement(inputArgs.positional);
- } else if (inputArgs.positional.size() > 1) {
- throw new QueryParseException("Error parsing named destination: " + value);
- }
+ // [name=]<name>
+ if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+ name = inputArgs.keyValue.get(ARG_ID_NAME).value();
+ } else if (inputArgs.positional.size() == 1) {
+ name = Iterables.getOnlyElement(inputArgs.positional);
+ } else if (inputArgs.positional.size() > 1) {
+ throw new QueryParseException("Error parsing named destination: " + value);
+ }
+ try {
// [,user=<user>]
if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER).value());
@@ -1475,9 +1510,9 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
account = self();
}
- Set<BranchNameKey> destinations = getDestinationList(git, account).getDestinations(name);
+ Set<BranchNameKey> destinations = getDestinationList(account).getDestinations(name);
if (destinations != null && !destinations.isEmpty()) {
- return new DestinationPredicate(destinations, value);
+ return new BranchSetIndexPredicate(FIELD_DESTINATION + ":" + value, destinations);
}
} catch (RepositoryNotFoundException e) {
throw new QueryParseException(
@@ -1488,24 +1523,31 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
throw new QueryParseException("Unknown named destination: " + name);
}
- protected DestinationList getDestinationList(Repository git, Account.Id account)
+ protected DestinationList getDestinationList(Account.Id account)
throws ConfigInvalidException, RepositoryNotFoundException, IOException {
DestinationList dl = destinationListByAccount.get(account);
if (dl == null) {
- dl = loadDestinationList(git, account);
+ dl = loadDestinationList(account);
destinationListByAccount.put(account, dl);
}
return dl;
}
- protected DestinationList loadDestinationList(Repository git, Account.Id account)
+ protected DestinationList loadDestinationList(Account.Id account)
throws ConfigInvalidException, RepositoryNotFoundException, IOException {
VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
- d.load(args.allUsersName, git);
+ try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
+ d.load(args.allUsersName, git);
+ }
return d.getDestinationList();
}
@Operator
+ public Predicate<ChangeData> a(String who) throws QueryParseException {
+ return author(who);
+ }
+
+ @Operator
public Predicate<ChangeData> author(String who) throws QueryParseException {
return getAuthorOrCommitterPredicate(
who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
@@ -1537,8 +1579,8 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
@Operator
public Predicate<ChangeData> cherryPickOf(String value) throws QueryParseException {
- checkFieldAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
- checkFieldAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
+ checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_CHANGE, "cherryPickOf");
+ checkOperatorAvailable(ChangeField.CHERRY_PICK_OF_PATCHSET, "cherryPickOf");
if (Ints.tryParse(value) != null) {
return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
}
@@ -1610,11 +1652,16 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return Predicate.or(predicates);
}
- protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator)
+ private void checkOperatorAvailable(SchemaField<ChangeData, ?> field, String operator)
+ throws QueryParseException {
+ checkFieldAvailable(
+ field, String.format("'%s' operator is not supported on this gerrit host", operator));
+ }
+
+ protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String errorMessage)
throws QueryParseException {
if (!args.index.getSchema().hasField(field)) {
- throw new QueryParseException(
- String.format("'%s' operator is not supported by change index version", operator));
+ throw new QueryParseException(errorMessage);
}
}
@@ -1665,7 +1712,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
private Set<Account.Id> parseAccount(String who)
throws QueryParseException, IOException, ConfigInvalidException {
try {
- return args.accountResolver.resolve(who).asNonEmptyIdSet();
+ return args.accountResolver.resolveAsUser(args.getUser(), who).asNonEmptyIdSet();
} catch (UnresolvableAccountException e) {
if (e.isSelf()) {
throw new QueryRequiresAuthException(e.getMessage(), e);
@@ -1674,16 +1721,42 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
}
}
- private Set<Account.Id> parseAccount(
+ private Set<Account.Id> parseAccountIgnoreVisibility(String who)
+ throws QueryRequiresAuthException, IOException, ConfigInvalidException {
+ try {
+ return args.accountResolver
+ .resolveAsUserIgnoreVisibility(args.getUser(), who)
+ .asNonEmptyIdSet();
+ } catch (UnresolvableAccountException e) {
+ if (e.isSelf()) {
+ throw new QueryRequiresAuthException(e.getMessage(), e);
+ }
+ return ImmutableSet.of(NON_EXISTING_ACCOUNT_ID);
+ }
+ }
+
+ private Set<Account.Id> parseAccountIgnoreVisibility(
String who, java.util.function.Predicate<AccountState> activityFilter)
- throws QueryParseException, IOException, ConfigInvalidException {
+ throws QueryRequiresAuthException, IOException, ConfigInvalidException {
try {
- return args.accountResolver.resolve(who, activityFilter).asNonEmptyIdSet();
+ return args.accountResolver
+ .resolveAsUserIgnoreVisibility(args.getUser(), who, activityFilter)
+ .asNonEmptyIdSet();
} catch (UnresolvableAccountException e) {
+ // Thrown if no account was found.
+
+ // Users can always see their own account. This means if self was being resolved and there was
+ // no match the user wasn't logged in and the request was done anonymously.
if (e.isSelf()) {
throw new QueryRequiresAuthException(e.getMessage(), e);
}
- throw new QueryParseException(e.getMessage(), e);
+
+ // If no account is found, we don't want to fail with an error as this would allow users to
+ // probe the existence of accounts (error -> account doesn't exist, empty result -> account
+ // exists but didn't take part in any visible changes). Hence, we return a special account ID
+ // (NON_EXISTING_ACCOUNT_ID) that doesn't match any account so the query can be normally
+ // executed
+ return ImmutableSet.of(NON_EXISTING_ACCOUNT_ID);
}
}
@@ -1724,7 +1797,8 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
return value;
}
- private Account.Id self() throws QueryParseException {
+ /** Returns {@link com.google.gerrit.entities.Account.Id} of the identified calling user. */
+ public Account.Id self() throws QueryParseException {
return args.getIdentifiedUser().getAccountId();
}
@@ -1739,7 +1813,7 @@ public class ChangeQueryBuilder extends QueryBuilder<ChangeData, ChangeQueryBuil
Predicate<ChangeData> reviewerPredicate = null;
try {
- Set<Account.Id> accounts = parseAccount(who);
+ Set<Account.Id> accounts = parseAccountIgnoreVisibility(who);
if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
reviewerPredicate =
Predicate.or(
diff --git a/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
index 24b8b7afc0..d1c487eb37 100644
--- a/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -14,17 +14,17 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.Matchable;
import com.google.gerrit.index.query.RegexPredicate;
public abstract class ChangeRegexPredicate extends RegexPredicate<ChangeData>
implements Matchable<ChangeData> {
- protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String value) {
+ protected ChangeRegexPredicate(SchemaField<ChangeData, ?> def, String value) {
super(def, value);
}
- protected ChangeRegexPredicate(FieldDef<ChangeData, ?> def, String name, String value) {
+ protected ChangeRegexPredicate(SchemaField<ChangeData, ?> def, String name, String value) {
super(def, name, value);
}
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 5840db4d21..d949ea8414 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -26,6 +26,7 @@ import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.index.change.ChangeField;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
@@ -74,11 +75,11 @@ public final class ChangeStatusPredicate extends ChangeIndexPredicate implements
}
public static String canonicalize(Change.Status status) {
- return status.name().toLowerCase();
+ return status.name().toLowerCase(Locale.US);
}
public static Predicate<ChangeData> parse(String value) throws QueryParseException {
- String lower = value.toLowerCase();
+ String lower = value.toLowerCase(Locale.US);
NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
if (!head.isEmpty()) {
// Assume no statuses share a common prefix so we can only walk one entry.
@@ -105,7 +106,7 @@ public final class ChangeStatusPredicate extends ChangeIndexPredicate implements
@Nullable private final Change.Status status;
private ChangeStatusPredicate(@Nullable Change.Status status) {
- super(ChangeField.STATUS, status != null ? canonicalize(status) : INVALID_STATUS);
+ super(ChangeField.STATUS_SPEC, status != null ? canonicalize(status) : INVALID_STATUS);
this.status = status;
}
diff --git a/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index d4bdc67788..40e4c6e7e8 100644
--- a/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -19,11 +19,11 @@ import com.google.gerrit.server.index.change.ChangeField;
public class DeletedPredicate extends IntegerRangeChangePredicate {
public DeletedPredicate(String value) throws QueryParseException {
- super(ChangeField.DELETED, value);
+ super(ChangeField.DELETED_LINES_SPEC, value);
}
@Override
protected Integer getValueInt(ChangeData changeData) {
- return ChangeField.DELETED.get(changeData);
+ return ChangeField.DELETED_LINES_SPEC.get(changeData);
}
}
diff --git a/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index 821ec944a1..e9eaa32da1 100644
--- a/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -19,11 +19,11 @@ import com.google.gerrit.server.index.change.ChangeField;
public class DeltaPredicate extends IntegerRangeChangePredicate {
public DeltaPredicate(String value) throws QueryParseException {
- super(ChangeField.DELTA, value);
+ super(ChangeField.DELTA_LINES_SPEC, value);
}
@Override
protected Integer getValueInt(ChangeData changeData) {
- return ChangeField.DELTA.get(changeData);
+ return ChangeField.DELTA_LINES_SPEC.get(changeData);
}
}
diff --git a/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/java/com/google/gerrit/server/query/change/DestinationPredicate.java
deleted file mode 100644
index 3c3d70f872..0000000000
--- a/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.index.query.PostFilterPredicate;
-import java.util.Set;
-
-public class DestinationPredicate extends PostFilterPredicate<ChangeData> {
- protected Set<BranchNameKey> destinations;
-
- public DestinationPredicate(Set<BranchNameKey> destinations, String value) {
- super(ChangeQueryBuilder.FIELD_DESTINATION, value);
- this.destinations = destinations;
- }
-
- @Override
- public boolean match(ChangeData object) {
- Change change = object.change();
- if (change == null) {
- return false;
- }
- return destinations.contains(change.getDest());
- }
-
- @Override
- public int getCost() {
- return 1;
- }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
index f572063ef8..ffd4497394 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicates.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.query.change;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -24,6 +25,8 @@ import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.index.query.PostFilterPredicate;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.index.change.ChangeField;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -31,9 +34,14 @@ import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData.StorageConstraint;
+import java.io.IOException;
+import java.util.List;
import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
public class EqualsLabelPredicates {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
public static class PostFilterEqualsLabelPredicate extends PostFilterPredicate<ChangeData> {
private final Matcher matcher;
@@ -68,7 +76,7 @@ public class EqualsLabelPredicates {
int expVal,
Account.Id account,
@Nullable Integer count) {
- super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account, count));
+ super(ChangeField.LABEL_SPEC, ChangeField.formatLabel(label, expVal, account, count));
this.matcher = new Matcher(args, label, expVal, account, count);
}
@@ -84,6 +92,7 @@ public class EqualsLabelPredicates {
}
private static class Matcher {
+ protected final AccountResolver accountResolver;
protected final ProjectCache projectCache;
protected final PermissionBackend permissionBackend;
protected final IdentifiedUser.GenericFactory userFactory;
@@ -114,6 +123,7 @@ public class EqualsLabelPredicates {
Account.Id account,
@Nullable Integer count) {
this.permissionBackend = args.permissionBackend;
+ this.accountResolver = args.accountResolver;
this.projectCache = args.projectCache;
this.userFactory = args.userFactory;
this.group = args.group;
@@ -192,6 +202,14 @@ public class EqualsLabelPredicates {
&& cd.currentPatchSet().uploader().equals(approver)) {
return false;
}
+
+ if (account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID)) {
+ if ((cd.currentPatchSet().uploader().equals(approver)
+ || matchAccount(cd.getCommitter().getEmailAddress(), approver)
+ || matchAccount(cd.getAuthor().getEmailAddress(), approver))) {
+ return false;
+ }
+ }
}
IdentifiedUser reviewer = userFactory.create(approver);
@@ -213,12 +231,28 @@ public class EqualsLabelPredicates {
}
}
+ /**
+ * Returns true if the {@code email} parameter belongs to the account identified by the {@code
+ * accountId} parameter.
+ */
+ private boolean matchAccount(String email, Account.Id accountId) {
+ try {
+ List<AccountState> accountsList = accountResolver.resolve(email).asList();
+ return accountsList.stream().anyMatch(c -> c.account().id().equals(accountId));
+ } catch (ConfigInvalidException | IOException e) {
+ logger.atWarning().withCause(e).log("Failed to resolve account %s", email);
+ }
+ return false;
+ }
+
private boolean isMagicUser() {
return account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
- || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID);
+ || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+ || account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID);
}
}
+ @Nullable
public static LabelType type(LabelTypes types, String toFind) {
if (types.byLabel(toFind).isPresent()) {
return types.byLabel(toFind).get();
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
index c16bc83bda..830df98b79 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -29,7 +29,7 @@ public class FileExtensionListPredicate extends ChangeIndexPredicate {
}
FileExtensionListPredicate(String value) {
- super(ChangeField.ONLY_EXTENSIONS, clean(value));
+ super(ChangeField.ONLY_EXTENSIONS_SPEC, clean(value));
}
@Override
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index 39715cf2cd..d15c5dc1d9 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -26,7 +26,7 @@ public class FileExtensionPredicate extends ChangeIndexPredicate {
}
FileExtensionPredicate(String value) {
- super(ChangeField.EXTENSION, clean(value));
+ super(ChangeField.EXTENSION_SPEC, clean(value));
}
@Override
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index f470cf9ea6..c4aba0d97b 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -20,7 +20,7 @@ import java.util.List;
public class GroupPredicate extends ChangeIndexPredicate {
public GroupPredicate(String group) {
- super(ChangeField.GROUP, group);
+ super(ChangeField.GROUP_SPEC, group);
}
@Override
diff --git a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
index 312c04eb82..b6059f7b86 100644
--- a/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.IntegerRangePredicate;
import com.google.gerrit.index.query.Matchable;
import com.google.gerrit.index.query.QueryParseException;
@@ -22,7 +22,7 @@ import com.google.gerrit.index.query.QueryParseException;
public abstract class IntegerRangeChangePredicate extends IntegerRangePredicate<ChangeData>
implements Matchable<ChangeData> {
- protected IntegerRangeChangePredicate(FieldDef<ChangeData, Integer> type, String value)
+ protected IntegerRangeChangePredicate(SchemaField<ChangeData, Integer> type, String value)
throws QueryParseException {
super(type, value);
}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 99c1ca13cf..62c070c203 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -26,6 +26,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
@@ -103,6 +104,7 @@ public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChang
return query(ChangePredicates.idStr(id));
}
+ @UsedAt(UsedAt.Project.GOOGLE)
public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
for (Change.Id id : ids) {
@@ -115,15 +117,6 @@ public class InternalChangeQuery extends InternalQuery<ChangeData, InternalChang
return query(byBranchKeyPred(branch, key));
}
- public List<ChangeData> byBranchKeyOpen(Project.NameKey project, String branch, Change.Key key) {
- return query(and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open()));
- }
-
- public static Predicate<ChangeData> byBranchKeyOpenPred(
- Project.NameKey project, String branch, Change.Key key) {
- return and(byBranchKeyPred(BranchNameKey.create(project, branch), key), open());
- }
-
private static Predicate<ChangeData> byBranchKeyPred(BranchNameKey branch, Change.Key key) {
return and(ref(branch), project(branch.project()), change(key));
}
diff --git a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
index 17de132e9f..aeee744a58 100644
--- a/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsSubmittablePredicate.java
@@ -20,7 +20,7 @@ import com.google.gerrit.server.index.change.ChangeField;
public class IsSubmittablePredicate extends BooleanPredicate {
public IsSubmittablePredicate() {
- super(ChangeField.IS_SUBMITTABLE);
+ super(ChangeField.IS_SUBMITTABLE_SPEC);
}
/**
@@ -53,11 +53,11 @@ public class IsSubmittablePredicate extends BooleanPredicate {
public static Predicate<ChangeData> rewrite(Predicate<ChangeData> in) {
if (in instanceof IsSubmittablePredicate) {
return Predicate.and(
- new BooleanPredicate(ChangeField.IS_SUBMITTABLE), ChangeStatusPredicate.open());
+ new BooleanPredicate(ChangeField.IS_SUBMITTABLE_SPEC), ChangeStatusPredicate.open());
}
if (in instanceof NotPredicate && in.getChild(0) instanceof IsSubmittablePredicate) {
return Predicate.or(
- Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE)),
+ Predicate.not(new BooleanPredicate(ChangeField.IS_SUBMITTABLE_SPEC)),
ChangeStatusPredicate.closed());
}
return in;
diff --git a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 27309afbcd..ffa29bafcb 100644
--- a/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -23,11 +23,11 @@ public class IsUnresolvedPredicate extends IntegerRangeChangePredicate {
}
public IsUnresolvedPredicate(String value) throws QueryParseException {
- super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
+ super(ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC, value);
}
@Override
protected Integer getValueInt(ChangeData changeData) {
- return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData);
+ return ChangeField.UNRESOLVED_COMMENT_COUNT_SPEC.get(changeData);
}
}
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2afaada7e6..5a38958053 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -23,6 +23,7 @@ import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.index.query.RangeUtil;
import com.google.gerrit.index.query.RangeUtil.Range;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.project.ProjectCache;
@@ -37,6 +38,7 @@ public class LabelPredicate extends OrPredicate<ChangeData> {
protected static final int MAX_COUNT = 5; // inclusive
protected static class Args {
+ protected final AccountResolver accountResolver;
protected final ProjectCache projectCache;
protected final PermissionBackend permissionBackend;
protected final IdentifiedUser.GenericFactory userFactory;
@@ -48,6 +50,7 @@ public class LabelPredicate extends OrPredicate<ChangeData> {
protected final GroupBackend groupBackend;
protected Args(
+ AccountResolver accountResolver,
ProjectCache projectCache,
PermissionBackend permissionBackend,
IdentifiedUser.GenericFactory userFactory,
@@ -57,6 +60,7 @@ public class LabelPredicate extends OrPredicate<ChangeData> {
@Nullable Integer count,
@Nullable PredicateArgs.Operator countOp,
GroupBackend groupBackend) {
+ this.accountResolver = accountResolver;
this.projectCache = projectCache;
this.permissionBackend = permissionBackend;
this.userFactory = userFactory;
@@ -93,6 +97,7 @@ public class LabelPredicate extends OrPredicate<ChangeData> {
super(
predicates(
new Args(
+ a.accountResolver,
a.projectCache,
a.permissionBackend,
a.userFactory,
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
index c9c8c45e1d..9ee4852288 100644
--- a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.query.change;
import static com.google.gerrit.server.query.change.EqualsLabelPredicates.type;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
@@ -30,6 +31,8 @@ import java.util.List;
import java.util.Optional;
public class MagicLabelPredicates {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
public static class PostFilterMagicLabelPredicate extends PostFilterPredicate<ChangeData> {
private static class PostFilterMatcher extends Matcher {
public PostFilterMatcher(
@@ -62,6 +65,14 @@ public class MagicLabelPredicates {
public int getCost() {
return 2;
}
+
+ public String getLabel() {
+ return matcher.getLabel();
+ }
+
+ public boolean ignoresUploaderApprovals() {
+ return matcher.ignoresUploaderApprovals();
+ }
}
public static class IndexMagicLabelPredicate extends ChangeIndexPredicate {
@@ -94,7 +105,7 @@ public class MagicLabelPredicates {
Account.Id account,
@Nullable Integer count) {
super(
- ChangeField.LABEL,
+ ChangeField.LABEL_SPEC,
ChangeField.formatLabel(
magicLabelVote.label(), magicLabelVote.value().name(), account, count));
this.matcher = new IndexMatcher(args, magicLabelVote, account, count);
@@ -104,6 +115,14 @@ public class MagicLabelPredicates {
public boolean match(ChangeData changeData) {
return matcher.match(changeData);
}
+
+ public String getLabel() {
+ return matcher.getLabel();
+ }
+
+ public boolean ignoresUploaderApprovals() {
+ return matcher.ignoresUploaderApprovals();
+ }
}
private abstract static class Matcher {
@@ -156,6 +175,16 @@ public class MagicLabelPredicates {
throw new IllegalStateException("Unsupported magic label value: " + magicLabelVote.value());
}
+ public String getLabel() {
+ return magicLabelVote.label();
+ }
+
+ public boolean ignoresUploaderApprovals() {
+ logger.atFine().log("account = %d", account.get());
+ return account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+ || account.equals(ChangeQueryBuilder.NON_CONTRIBUTOR_ACCOUNT_ID);
+ }
+
private boolean matchAny(ChangeData changeData, LabelType labelType) {
List<Predicate<ChangeData>> predicates = new ArrayList<>();
for (LabelValue labelValue : labelType.getValues()) {
diff --git a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
index 1787c76f25..315785c2ca 100644
--- a/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexDirectoryPredicate.java
@@ -22,7 +22,7 @@ public class RegexDirectoryPredicate extends ChangeRegexPredicate {
protected final RunAutomaton pattern;
public RegexDirectoryPredicate(String re) {
- super(ChangeField.DIRECTORY, re);
+ super(ChangeField.DIRECTORY_SPEC, re);
if (re.startsWith("^")) {
re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
index 24efa6ad0f..f62780a973 100644
--- a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import static com.google.gerrit.server.index.change.ChangeField.HASHTAG;
+import static com.google.gerrit.server.index.change.ChangeField.HASHTAG_SPEC;
import dk.brics.automaton.RegExp;
import dk.brics.automaton.RunAutomaton;
@@ -23,7 +23,7 @@ public class RegexHashtagPredicate extends ChangeRegexPredicate {
protected final RunAutomaton pattern;
public RegexHashtagPredicate(String re) {
- super(HASHTAG, re);
+ super(HASHTAG_SPEC, re);
if (re.startsWith("^")) {
re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 4c3c04c198..9368047058 100644
--- a/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -19,7 +19,7 @@ import com.google.gerrit.server.ioutil.RegexListSearcher;
public class RegexPathPredicate extends ChangeRegexPredicate {
public RegexPathPredicate(String re) {
- super(ChangeField.PATH, re);
+ super(ChangeField.PATH_SPEC, re);
}
@Override
diff --git a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index bbdfc663f0..a51dcc4e39 100644
--- a/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -24,7 +24,7 @@ public class RegexProjectPredicate extends ChangeRegexPredicate {
protected final RunAutomaton pattern;
public RegexProjectPredicate(String re) {
- super(ChangeField.PROJECT, re);
+ super(ChangeField.PROJECT_SPEC, re);
if (re.startsWith("^")) {
re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index b2dba726b4..cc556ba019 100644
--- a/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -24,7 +24,7 @@ public class RegexRefPredicate extends ChangeRegexPredicate {
protected final RunAutomaton pattern;
public RegexRefPredicate(String re) throws QueryParseException {
- super(ChangeField.REF, re);
+ super(ChangeField.REF_SPEC, re);
if (re.startsWith("^")) {
re = re.substring(1);
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 57f5213392..b355afbd6a 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -40,7 +40,7 @@ public class ReviewerPredicate extends ChangeIndexPredicate implements HasCardin
protected final Account.Id id;
private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
- super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
+ super(ChangeField.REVIEWER_SPEC, ChangeField.getReviewerFieldValue(state, id));
this.state = state;
this.id = id;
}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index ecddbb6ec9..243712dc2b 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -20,12 +20,13 @@ import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.index.change.ChangeField;
+import java.util.Locale;
import java.util.Set;
public class SubmitRecordPredicate extends ChangeIndexPredicate {
public static Predicate<ChangeData> create(
String label, SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
- String lowerLabel = label.toLowerCase();
+ String lowerLabel = label.toLowerCase(Locale.US);
if (accounts == null || accounts.isEmpty()) {
return new SubmitRecordPredicate(status.name() + ',' + lowerLabel);
}
@@ -36,7 +37,7 @@ public class SubmitRecordPredicate extends ChangeIndexPredicate {
}
private SubmitRecordPredicate(String value) {
- super(ChangeField.SUBMIT_RECORD, value);
+ super(ChangeField.SUBMIT_RECORD_SPEC, value);
}
@Override
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
index 2580a1bdda..cb92ddd67b 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -14,13 +14,24 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.common.base.Splitter;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
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.query.FileEditsPredicate;
-import com.google.gerrit.server.query.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.ConstantPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.DistinctVotersPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.FileEditsPredicate.FileEditsArgs;
+import com.google.gerrit.server.submitrequirement.predicate.HasSubmoduleUpdatePredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexAuthorEmailPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexCommitterEmailPredicate;
+import com.google.gerrit.server.submitrequirement.predicate.RegexUploaderEmailPredicateFactory;
import com.google.inject.Inject;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@@ -37,6 +48,7 @@ public class SubmitRequirementChangeQueryBuilder extends ChangeQueryBuilder {
new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
private final DistinctVotersPredicate.Factory distinctVotersPredicateFactory;
+ private final HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory;
/**
* Regular expression for the {@link #file(String)} operator. Field value is of the form:
@@ -48,20 +60,28 @@ public class SubmitRequirementChangeQueryBuilder extends ChangeQueryBuilder {
private static final Pattern FILE_EDITS_PATTERN =
Pattern.compile("'((?:(?:\\\\')|(?:[^']))*)',withDiffContaining='((?:(?:\\\\')|(?:[^']))*)'");
+ public static final String SUBMODULE_UPDATE_HAS_ARG = "submodule-update";
+ private static final Splitter SUBMODULE_UPDATE_SPLITTER = Splitter.on(",");
+
private final FileEditsPredicate.Factory fileEditsPredicateFactory;
+ private final RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory;
@Inject
SubmitRequirementChangeQueryBuilder(
Arguments args,
DistinctVotersPredicate.Factory distinctVotersPredicateFactory,
- FileEditsPredicate.Factory fileEditsPredicateFactory) {
+ FileEditsPredicate.Factory fileEditsPredicateFactory,
+ HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory,
+ RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory) {
super(def, args);
this.distinctVotersPredicateFactory = distinctVotersPredicateFactory;
this.fileEditsPredicateFactory = fileEditsPredicateFactory;
+ this.hasSubmoduleUpdateFactory = hasSubmoduleUpdateFactory;
+ this.regexUploaderEmailPredicateFactory = regexUploaderEmailPredicateFactory;
}
@Override
- protected void checkFieldAvailable(FieldDef<ChangeData, ?> field, String operator) {
+ protected void checkFieldAvailable(SchemaField<ChangeData, ?> field, String operator) {
// Submit requirements don't rely on the index, so they can be used regardless of index schema
// version.
}
@@ -79,12 +99,53 @@ public class SubmitRequirementChangeQueryBuilder extends ChangeQueryBuilder {
return super.is(value);
}
+ @Override
+ public Predicate<ChangeData> has(String value) throws QueryParseException {
+ if (value.toLowerCase(Locale.US).startsWith(SUBMODULE_UPDATE_HAS_ARG)) {
+ List<String> args = SUBMODULE_UPDATE_SPLITTER.splitToList(value);
+ if (args.size() > 2) {
+ throw error(
+ String.format(
+ "wrong number of arguments for the has:%s operator", SUBMODULE_UPDATE_HAS_ARG));
+ } else if (args.size() == 2) {
+ List<String> baseValue = Splitter.on("=").splitToList(args.get(1));
+ if (baseValue.size() != 2) {
+ throw error("unexpected base value format");
+ }
+ if (!baseValue.get(0).toLowerCase(Locale.US).equals("base")) {
+ throw error("unexpected base value format");
+ }
+ try {
+ int base = Integer.parseInt(baseValue.get(1));
+ return hasSubmoduleUpdateFactory.create(base);
+ } catch (NumberFormatException e) {
+ throw error(
+ String.format(
+ "failed to parse the parent number %s: %s", baseValue.get(1), e.getMessage()));
+ }
+ } else {
+ return hasSubmoduleUpdateFactory.create(0);
+ }
+ }
+ return super.has(value);
+ }
+
@Operator
public Predicate<ChangeData> authoremail(String who) throws QueryParseException {
return new RegexAuthorEmailPredicate(who);
}
@Operator
+ public Predicate<ChangeData> committerEmail(String who) throws QueryParseException {
+ return new RegexCommitterEmailPredicate(who);
+ }
+
+ @Operator
+ public Predicate<ChangeData> uploaderEmail(String who) throws QueryParseException {
+ return regexUploaderEmailPredicateFactory.create(who);
+ }
+
+ @Operator
public Predicate<ChangeData> distinctvoters(String value) throws QueryParseException {
return distinctVotersPredicateFactory.create(value);
}
@@ -120,6 +181,9 @@ public class SubmitRequirementChangeQueryBuilder extends ChangeQueryBuilder {
return fileEditsPredicateFactory.create(FileEditsArgs.create(filePattern, contentPattern));
}
+ @Override
+ protected void validateLabelArgs(Set<Account.Id> accountIds) throws QueryParseException {}
+
private static void validateRegularExpression(String pattern, String errorMessage)
throws QueryParseException {
try {
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 060a92e7d6..e543ac3e07 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -21,7 +21,7 @@ public class SubmittablePredicate extends ChangeIndexPredicate {
protected final SubmitRecord.Status status;
public SubmittablePredicate(SubmitRecord.Status status) {
- super(ChangeField.SUBMIT_RECORD, status.name());
+ super(ChangeField.SUBMIT_RECORD_SPEC, status.name());
this.status = status;
}
diff --git a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
index abbd0c9926..0b2d32df05 100644
--- a/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
+++ b/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaFieldDefs.SchemaField;
import com.google.gerrit.index.query.Matchable;
import com.google.gerrit.index.query.TimestampRangePredicate;
import java.sql.Timestamp;
@@ -22,7 +22,7 @@ import java.sql.Timestamp;
public abstract class TimestampRangeChangePredicate extends TimestampRangePredicate<ChangeData>
implements Matchable<ChangeData> {
protected TimestampRangeChangePredicate(
- FieldDef<ChangeData, Timestamp> def, String name, String value) {
+ SchemaField<ChangeData, Timestamp> def, String name, String value) {
super(def, name, value);
}
diff --git a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
index b9b58b8d91..078acd4b9c 100644
--- a/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
+++ b/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -17,7 +17,9 @@ package com.google.gerrit.server.query.group;
import static com.google.common.collect.ImmutableList.toImmutableList;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
+import com.google.common.collect.Maps;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -27,8 +29,12 @@ import com.google.gerrit.index.query.InternalQuery;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.index.group.GroupIndexCollection;
import com.google.inject.Inject;
+import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
/**
* Query wrapper for the group index.
@@ -57,8 +63,29 @@ public class InternalGroupQuery extends InternalQuery<InternalGroup, InternalGro
return query(GroupPredicates.member(memberId));
}
- public List<InternalGroup> bySubgroup(AccountGroup.UUID subgroupId) {
- return query(GroupPredicates.subgroup(subgroupId));
+ /**
+ * Get all immediate parents of the provided {@code subgroupIds}.
+ *
+ * @return map pointing from children to list of its immediate parents
+ */
+ public Map<AccountGroup.UUID, ImmutableSet<AccountGroup.UUID>> bySubgroups(
+ ImmutableSet<AccountGroup.UUID> subgroupIds) {
+ List<Predicate<InternalGroup>> predicates =
+ subgroupIds.stream().map(e -> GroupPredicates.subgroup(e)).collect(Collectors.toList());
+ List<InternalGroup> groups = query(Predicate.or(predicates));
+
+ Map<AccountGroup.UUID, Set<AccountGroup.UUID>> parentsByChild =
+ Maps.newHashMapWithExpectedSize(groups.size());
+ subgroupIds.stream().forEach(c -> parentsByChild.put(c, new HashSet<>()));
+ for (InternalGroup parent : groups) {
+ for (AccountGroup.UUID child : parent.getSubgroups()) {
+ if (subgroupIds.contains(child)) {
+ parentsByChild.get(child).add(parent.getGroupUUID());
+ }
+ }
+ }
+ return parentsByChild.entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> ImmutableSet.copyOf(e.getValue())));
}
private Optional<InternalGroup> getOnlyGroup(
diff --git a/java/com/google/gerrit/server/query/project/ProjectPredicates.java b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
index 8b4048f6d5..a7b0743f7e 100644
--- a/java/com/google/gerrit/server/query/project/ProjectPredicates.java
+++ b/java/com/google/gerrit/server/query/project/ProjectPredicates.java
@@ -25,23 +25,23 @@ import java.util.Locale;
/** Utility class to create predicates for project index queries. */
public class ProjectPredicates {
public static Predicate<ProjectData> name(Project.NameKey nameKey) {
- return new ProjectPredicate(ProjectField.NAME, nameKey.get());
+ return new ProjectPredicate(ProjectField.NAME_SPEC, nameKey.get());
}
public static Predicate<ProjectData> parent(Project.NameKey parentNameKey) {
- return new ProjectPredicate(ProjectField.PARENT_NAME, parentNameKey.get());
+ return new ProjectPredicate(ProjectField.PARENT_NAME_SPEC, parentNameKey.get());
}
public static Predicate<ProjectData> inname(String name) {
- return new ProjectPredicate(ProjectField.NAME_PART, name.toLowerCase(Locale.US));
+ return new ProjectPredicate(ProjectField.NAME_PART_SPEC, name.toLowerCase(Locale.US));
}
public static Predicate<ProjectData> description(String description) {
- return new ProjectPredicate(ProjectField.DESCRIPTION, description);
+ return new ProjectPredicate(ProjectField.DESCRIPTION_SPEC, description);
}
public static Predicate<ProjectData> state(ProjectState state) {
- return new ProjectPredicate(ProjectField.STATE, state.name());
+ return new ProjectPredicate(ProjectField.STATE_SPEC, state.name());
}
private ProjectPredicates() {}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
index d23454644e..616468ed79 100644
--- a/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilder.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -14,93 +14,21 @@
package com.google.gerrit.server.query.project;
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.ProjectState;
import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.query.LimitPredicate;
import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryBuilder;
import com.google.gerrit.index.query.QueryParseException;
-import com.google.inject.Inject;
import java.util.List;
-/** Parses a query string meant to be applied to project objects. */
-public class ProjectQueryBuilder extends QueryBuilder<ProjectData, ProjectQueryBuilder> {
- public static final String FIELD_LIMIT = "limit";
-
- private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilder> mydef =
- new QueryBuilder.Definition<>(ProjectQueryBuilder.class);
-
- @Inject
- ProjectQueryBuilder() {
- super(mydef, null);
- }
-
- @Operator
- public Predicate<ProjectData> name(String name) {
- return ProjectPredicates.name(Project.nameKey(name));
- }
-
- @Operator
- public Predicate<ProjectData> parent(String parentName) {
- return ProjectPredicates.parent(Project.nameKey(parentName));
- }
-
- @Operator
- public Predicate<ProjectData> inname(String namePart) {
- if (namePart.isEmpty()) {
- return name(namePart);
- }
- return ProjectPredicates.inname(namePart);
- }
-
- @Operator
- public Predicate<ProjectData> description(String description) throws QueryParseException {
- if (Strings.isNullOrEmpty(description)) {
- throw error("description operator requires a value");
- }
-
- return ProjectPredicates.description(description);
- }
-
- @Operator
- public Predicate<ProjectData> state(String state) throws QueryParseException {
- if (Strings.isNullOrEmpty(state)) {
- throw error("state operator requires a value");
- }
- ProjectState parsedState;
- try {
- parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase());
- } catch (IllegalArgumentException e) {
- throw error("state operator must be either 'active' or 'read-only'", e);
- }
- if (parsedState == ProjectState.HIDDEN) {
- throw error("state operator must be either 'active' or 'read-only'");
- }
- return ProjectPredicates.state(parsedState);
- }
-
- @Override
- protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
- // Adapt the capacity of this list when adding more default predicates.
- List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
- preds.add(name(query));
- preds.add(inname(query));
- if (!Strings.isNullOrEmpty(query)) {
- preds.add(description(query));
- }
- return Predicate.or(preds);
- }
-
- @Operator
- public Predicate<ProjectData> limit(String query) throws QueryParseException {
- Integer limit = Ints.tryParse(query);
- if (limit == null) {
- throw error("Invalid limit: " + query);
- }
- return new LimitPredicate<>(FIELD_LIMIT, limit);
- }
+/**
+ * Provides methods required for parsing projects queries.
+ *
+ * <p>Internally (at google), this interface has a different implementation, comparing to upstream.
+ */
+public interface ProjectQueryBuilder {
+ String FIELD_LIMIT = "limit";
+
+ /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(String)}. */
+ Predicate<ProjectData> parse(String query) throws QueryParseException;
+ /** See {@link com.google.gerrit.index.query.QueryBuilder#parse(List)}. */
+ List<Predicate<ProjectData>> parse(List<String> queries) throws QueryParseException;
}
diff --git a/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
new file mode 100644
index 0000000000..599683e6ac
--- /dev/null
+++ b/java/com/google/gerrit/server/query/project/ProjectQueryBuilderImpl.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.project;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Lists;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Locale;
+
+/** Parses a query string meant to be applied to project objects. */
+public class ProjectQueryBuilderImpl extends QueryBuilder<ProjectData, ProjectQueryBuilderImpl>
+ implements ProjectQueryBuilder {
+ private static final QueryBuilder.Definition<ProjectData, ProjectQueryBuilderImpl> mydef =
+ new QueryBuilder.Definition<>(ProjectQueryBuilderImpl.class);
+
+ @Inject
+ ProjectQueryBuilderImpl() {
+ super(mydef, null);
+ }
+
+ @Operator
+ public Predicate<ProjectData> name(String name) {
+ return ProjectPredicates.name(Project.nameKey(name));
+ }
+
+ @Operator
+ public Predicate<ProjectData> parent(String parentName) {
+ return ProjectPredicates.parent(Project.nameKey(parentName));
+ }
+
+ @Operator
+ public Predicate<ProjectData> inname(String namePart) {
+ if (namePart.isEmpty()) {
+ return name(namePart);
+ }
+ return ProjectPredicates.inname(namePart);
+ }
+
+ @Operator
+ public Predicate<ProjectData> description(String description) throws QueryParseException {
+ if (Strings.isNullOrEmpty(description)) {
+ throw error("description operator requires a value");
+ }
+
+ return ProjectPredicates.description(description);
+ }
+
+ @Operator
+ public Predicate<ProjectData> state(String state) throws QueryParseException {
+ if (Strings.isNullOrEmpty(state)) {
+ throw error("state operator requires a value");
+ }
+ ProjectState parsedState;
+ try {
+ parsedState = ProjectState.valueOf(state.replace('-', '_').toUpperCase(Locale.US));
+ } catch (IllegalArgumentException e) {
+ throw error("state operator must be either 'active' or 'read-only'", e);
+ }
+ if (parsedState == ProjectState.HIDDEN) {
+ throw error("state operator must be either 'active' or 'read-only'");
+ }
+ return ProjectPredicates.state(parsedState);
+ }
+
+ @Override
+ protected Predicate<ProjectData> defaultField(String query) throws QueryParseException {
+ // Adapt the capacity of this list when adding more default predicates.
+ List<Predicate<ProjectData>> preds = Lists.newArrayListWithCapacity(3);
+ preds.add(name(query));
+ preds.add(inname(query));
+ if (!Strings.isNullOrEmpty(query)) {
+ preds.add(description(query));
+ }
+ return Predicate.or(preds);
+ }
+
+ @Operator
+ public Predicate<ProjectData> limit(String query) throws QueryParseException {
+ Integer limit = Ints.tryParse(query);
+ if (limit == null) {
+ throw error("Invalid limit: " + query);
+ }
+ return new LimitPredicate<>(FIELD_LIMIT, limit);
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 62da2f24de..dd0ec78d97 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -34,6 +34,7 @@ java_library(
"//lib/auto:auto-factory",
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
+ "//lib/commons:codec",
"//lib/commons:compress",
"//lib/commons:lang3",
"//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index e35ffdbda8..4b161439a9 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -11,91 +11,33 @@
// WITHOUT 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.account;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
-import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangePredicates;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.restapi.change.CommentJson;
-import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
@Singleton
public class DeleteDraftComments
implements RestModifyView<AccountResource, DeleteDraftCommentsInput> {
-
private final Provider<CurrentUser> userProvider;
- private final BatchUpdate.Factory batchUpdateFactory;
- private final ChangeQueryBuilder queryBuilder;
- private final Provider<InternalChangeQuery> queryProvider;
- private final ChangeData.Factory changeDataFactory;
- private final ChangeJson.Factory changeJsonFactory;
- private final Provider<CommentJson> commentJsonProvider;
- private final CommentsUtil commentsUtil;
- private final PatchSetUtil psUtil;
+ private final DeleteDraftCommentsUtil deleteDraftCommentsUtil;
@Inject
DeleteDraftComments(
- Provider<CurrentUser> userProvider,
- BatchUpdate.Factory batchUpdateFactory,
- ChangeQueryBuilder queryBuilder,
- Provider<InternalChangeQuery> queryProvider,
- ChangeData.Factory changeDataFactory,
- ChangeJson.Factory changeJsonFactory,
- Provider<CommentJson> commentJsonProvider,
- CommentsUtil commentsUtil,
- PatchSetUtil psUtil) {
+ Provider<CurrentUser> userProvider, DeleteDraftCommentsUtil deleteDraftCommentsUtil) {
this.userProvider = userProvider;
- this.batchUpdateFactory = batchUpdateFactory;
- this.queryBuilder = queryBuilder;
- this.queryProvider = queryProvider;
- this.changeDataFactory = changeDataFactory;
- this.changeJsonFactory = changeJsonFactory;
- this.commentJsonProvider = commentJsonProvider;
- this.commentsUtil = commentsUtil;
- this.psUtil = psUtil;
+ this.deleteDraftCommentsUtil = deleteDraftCommentsUtil;
}
@Override
@@ -114,82 +56,6 @@ public class DeleteDraftComments
// hasSameAccountId check.)
throw new AuthException("Cannot delete drafts of other user");
}
-
- HumanCommentFormatter humanCommentFormatter =
- commentJsonProvider.get().newHumanCommentFormatter();
- Account.Id accountId = rsrc.getUser().getAccountId();
- Instant now = TimeUtil.now();
- Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
- List<Op> ops = new ArrayList<>();
- for (ChangeData cd :
- queryProvider
- .get()
- // Don't attempt to mutate any changes the user can't currently see.
- .enforceVisibility(true)
- .query(predicate(accountId, input))) {
- BatchUpdate update =
- updates.computeIfAbsent(
- cd.project(), p -> batchUpdateFactory.create(p, rsrc.getUser(), now));
- Op op = new Op(humanCommentFormatter, accountId);
- update.addOp(cd.getId(), op);
- ops.add(op);
- }
-
- // Currently there's no way to let some updates succeed even if others fail. Even if there were,
- // all updates from this operation only happen in All-Users and thus are fully atomic, so
- // allowing partial failure would have little value.
- BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
-
- return Response.ok(
- ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
- }
-
- private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
- throws BadRequestException {
- Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
- if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
- return hasDraft;
- }
- try {
- return Predicate.and(hasDraft, queryBuilder.parse(input.query));
- } catch (QueryParseException e) {
- throw new BadRequestException("Invalid query: " + e.getMessage(), e);
- }
- }
-
- private class Op implements BatchUpdateOp {
- private final HumanCommentFormatter humanCommentFormatter;
- private final Account.Id accountId;
- private DeletedDraftCommentInfo result;
-
- Op(HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
- this.humanCommentFormatter = humanCommentFormatter;
- this.accountId = accountId;
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
- ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
- boolean dirty = false;
- for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
- dirty = true;
- PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
- commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
- commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
- comments.add(humanCommentFormatter.format(c));
- }
- if (dirty) {
- result = new DeletedDraftCommentInfo();
- result.change =
- changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
- result.deleted = comments.build();
- }
- return dirty;
- }
-
- @Nullable
- DeletedDraftCommentInfo getResult() {
- return result;
- }
+ return Response.ok(deleteDraftCommentsUtil.deleteDraftComments(rsrc.getUser(), input.query));
}
}
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
new file mode 100644
index 0000000000..9e0259284a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftCommentsUtil.java
@@ -0,0 +1,169 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.restapi.account;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.CommentJson;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+@Singleton
+public class DeleteDraftCommentsUtil {
+ private final BatchUpdate.Factory batchUpdateFactory;
+ private final ChangeQueryBuilder queryBuilder;
+ private final Provider<InternalChangeQuery> queryProvider;
+ private final ChangeData.Factory changeDataFactory;
+ private final ChangeJson.Factory changeJsonFactory;
+ private final Provider<CommentJson> commentJsonProvider;
+ private final CommentsUtil commentsUtil;
+ private final PatchSetUtil psUtil;
+
+ @Inject
+ public DeleteDraftCommentsUtil(
+ BatchUpdate.Factory batchUpdateFactory,
+ ChangeQueryBuilder queryBuilder,
+ Provider<InternalChangeQuery> queryProvider,
+ ChangeData.Factory changeDataFactory,
+ ChangeJson.Factory changeJsonFactory,
+ Provider<CommentJson> commentJsonProvider,
+ CommentsUtil commentsUtil,
+ PatchSetUtil psUtil) {
+ this.batchUpdateFactory = batchUpdateFactory;
+ this.queryBuilder = queryBuilder;
+ this.queryProvider = queryProvider;
+ this.changeDataFactory = changeDataFactory;
+ this.changeJsonFactory = changeJsonFactory;
+ this.commentJsonProvider = commentJsonProvider;
+ this.commentsUtil = commentsUtil;
+ this.psUtil = psUtil;
+ }
+
+ public ImmutableList<DeletedDraftCommentInfo> deleteDraftComments(
+ IdentifiedUser user, String query) throws RestApiException, UpdateException {
+ CommentJson.HumanCommentFormatter humanCommentFormatter =
+ commentJsonProvider.get().newHumanCommentFormatter();
+ Account.Id accountId = user.getAccountId();
+ Instant now = TimeUtil.now();
+ Map<Project.NameKey, BatchUpdate> updates = new LinkedHashMap<>();
+ List<Op> ops = new ArrayList<>();
+ for (ChangeData cd :
+ queryProvider
+ .get()
+ // Don't attempt to mutate any changes the user can't currently see.
+ .enforceVisibility(true)
+ .query(predicate(accountId, query))) {
+ BatchUpdate update =
+ updates.computeIfAbsent(cd.project(), p -> batchUpdateFactory.create(p, user, now));
+ Op op = new Op(humanCommentFormatter, accountId);
+ update.addOp(cd.getId(), op);
+ ops.add(op);
+ }
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ // Currently there's no way to let some updates succeed even if others fail. Even if there
+ // were,
+ // all updates from this operation only happen in All-Users and thus are fully atomic, so
+ // allowing partial failure would have little value.
+ BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
+ }
+ return ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList());
+ }
+
+ private Predicate<ChangeData> predicate(Account.Id accountId, String query)
+ throws BadRequestException {
+ Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(commentsUtil, accountId);
+ if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(query)).isEmpty()) {
+ return hasDraft;
+ }
+ try {
+ return Predicate.and(hasDraft, queryBuilder.parse(query));
+ } catch (QueryParseException e) {
+ throw new BadRequestException("Invalid query: " + e.getMessage(), e);
+ }
+ }
+
+ private class Op implements BatchUpdateOp {
+ private final CommentJson.HumanCommentFormatter humanCommentFormatter;
+ private final Account.Id accountId;
+ private DeletedDraftCommentInfo result;
+
+ Op(CommentJson.HumanCommentFormatter humanCommentFormatter, Account.Id accountId) {
+ this.humanCommentFormatter = humanCommentFormatter;
+ this.accountId = accountId;
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
+ ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
+ boolean dirty = false;
+ for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
+ dirty = true;
+ PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
+ commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+ commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
+ comments.add(humanCommentFormatter.format(c));
+ }
+ if (dirty) {
+ result = new DeletedDraftCommentInfo();
+ result.change =
+ changeJsonFactory.noOptions().format(changeDataFactory.create(ctx.getNotes()));
+ result.deleted = comments.build();
+ }
+ return dirty;
+ }
+
+ @Nullable
+ DeletedDraftCommentInfo getResult() {
+ return result;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index 6ab2c4418a..c45694eb68 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -45,6 +45,7 @@ import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import org.kohsuke.args4j.Option;
@@ -124,7 +125,7 @@ public class GetCapabilities implements RestReadView<AccountResource> {
}
private boolean want(String name) {
- return query == null || query.contains(name.toLowerCase());
+ return query == null || query.contains(name.toLowerCase(Locale.US));
}
private void addRanges(Map<String, Object> have, AccountLimits limits) {
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java
index 4d70eb9cde..a5185326e6 100644
--- a/java/com/google/gerrit/server/restapi/account/GetEmails.java
+++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -52,7 +52,7 @@ public class GetEmails implements RestReadView<AccountResource> {
public Response<List<EmailInfo>> apply(AccountResource rsrc)
throws AuthException, PermissionBackendException {
if (!self.get().hasSameAccountId(rsrc.getUser())) {
- permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+ permissionBackend.currentUser().check(GlobalPermission.VIEW_SECONDARY_EMAILS);
}
return Response.ok(
rsrc.getUser().getEmailAddresses().stream()
diff --git a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
index a3c48b9c53..d7a5da1188 100644
--- a/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/GetExternalIds.java
@@ -18,6 +18,7 @@ import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USE
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.common.AccountExternalIdInfo;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -92,7 +93,8 @@ public class GetExternalIds implements RestReadView<AccountResource> {
return Response.ok(result);
}
+ @Nullable
private static Boolean toBoolean(boolean v) {
- return v ? v : null;
+ return v ? Boolean.TRUE : null;
}
}
diff --git a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
index 8d65aacfa7..d8ad3cf580 100644
--- a/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/GetWatchedProjects.java
@@ -19,6 +19,7 @@ import static java.util.stream.Collectors.toList;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
import com.google.gerrit.extensions.client.ProjectWatchInfo;
@@ -93,6 +94,7 @@ public class GetWatchedProjects implements RestReadView<AccountResource> {
return pwi;
}
+ @Nullable
private static Boolean toBoolean(boolean value) {
return value ? true : null;
}
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 30534b5add..9fc0c42a95 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -173,7 +173,7 @@ public class QueryAccounts implements RestReadView<TopLevelResource> {
}
boolean modifyAccountCapabilityChecked = false;
if (options.contains(ListAccountsOption.ALL_EMAILS)) {
- permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT);
+ permissionBackend.currentUser().check(GlobalPermission.VIEW_SECONDARY_EMAILS);
modifyAccountCapabilityChecked = true;
fillOptions.add(FillOptions.EMAIL);
fillOptions.add(FillOptions.SECONDARY_EMAILS);
@@ -185,7 +185,7 @@ public class QueryAccounts implements RestReadView<TopLevelResource> {
if (modifyAccountCapabilityChecked) {
fillOptions.add(FillOptions.SECONDARY_EMAILS);
} else {
- if (permissionBackend.currentUser().test(GlobalPermission.MODIFY_ACCOUNT)) {
+ if (permissionBackend.currentUser().test(GlobalPermission.VIEW_SECONDARY_EMAILS)) {
fillOptions.add(FillOptions.SECONDARY_EMAILS);
}
}
@@ -240,7 +240,7 @@ public class QueryAccounts implements RestReadView<TopLevelResource> {
if (suggest) {
return Response.ok(ImmutableList.of());
}
- throw new BadRequestException(e.getMessage());
+ throw new BadRequestException(e.getMessage(), e);
}
}
}
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 12abf3dbc1..173f24b6f7 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -131,10 +131,7 @@ public class StarredChanges
try {
starredChangesUtil.star(
- self.get().getAccountId(),
- change.getProject(),
- change.getId(),
- StarredChangesUtil.Operation.ADD);
+ self.get().getAccountId(), change.getId(), StarredChangesUtil.Operation.ADD);
} catch (MutuallyExclusiveLabelsException e) {
throw new ResourceConflictException(e.getMessage());
} catch (IllegalLabelException e) {
@@ -182,10 +179,7 @@ public class StarredChanges
throw new AuthException("not allowed remove starred change");
}
starredChangesUtil.star(
- self.get().getAccountId(),
- rsrc.getChange().getProject(),
- rsrc.getChange().getId(),
- StarredChangesUtil.Operation.REMOVE);
+ self.get().getAccountId(), rsrc.getChange().getId(), StarredChangesUtil.Operation.REMOVE);
return Response.none();
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index 8dd0e786bf..36080a471a 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.AbandonInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -36,6 +38,7 @@ import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -126,16 +129,18 @@ public class Abandon
AccountState accountState = user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null;
AbandonOp op = abandonOpFactory.create(accountState, msgTxt);
ChangeData changeData = changeDataFactory.create(notes.getProjectName(), notes.getChangeId());
- try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
- u.setNotify(notify);
- u.addOp(notes.getChangeId(), op);
- u.addOp(
- notes.getChangeId(),
- storeSubmitRequirementsOpFactory.create(
- changeData.submitRequirements().values(), changeData));
- u.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u = updateFactory.create(notes.getProjectName(), user, TimeUtil.now())) {
+ u.setNotify(notify);
+ u.addOp(notes.getChangeId(), op);
+ u.addOp(
+ notes.getChangeId(),
+ storeSubmitRequirementsOpFactory.create(
+ changeData.submitRequirements().values(), changeData));
+ u.execute();
+ }
+ return op.getChange();
}
- return op.getChange();
}
@Override
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index 03d383f96e..155e66fd3e 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -32,6 +34,7 @@ import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -87,17 +90,18 @@ public class AddToAttentionSet
.test(ChangePermission.READ)) {
throw new AuthException("read not permitted for " + attentionUserId);
}
-
- try (BatchUpdate bu =
- updateFactory.create(
- changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
- AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
- bu.addOp(changeResource.getId(), op);
- NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
- NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
- bu.setNotify(notifyResult);
- bu.execute();
- return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(
+ changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.now())) {
+ AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
+ bu.addOp(changeResource.getId(), op);
+ NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+ NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+ bu.setNotify(notifyResult);
+ bu.execute();
+ return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
index e3ab135032..763212d67c 100644
--- a/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
+++ b/java/com/google/gerrit/server/restapi/change/AllowedFormats.java
@@ -22,6 +22,7 @@ import com.google.gerrit.server.change.ArchiveFormatInternal;
import com.google.gerrit.server.config.DownloadConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
+import java.util.Locale;
import java.util.Set;
@Singleton
@@ -36,7 +37,7 @@ public class AllowedFormats {
for (String ext : format.getSuffixes()) {
exts.put(ext, format);
}
- exts.put(format.name().toLowerCase(), format);
+ exts.put(format.name().toLowerCase(Locale.US), format);
}
extensions = exts.build();
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
new file mode 100644
index 0000000000..58cd0100dd
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -0,0 +1,235 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.PreconditionFailedException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class ApplyPatch implements RestModifyView<ChangeResource, ApplyPatchPatchSetInput> {
+ private final ChangeJson.Factory jsonFactory;
+ private final ContributorAgreementsChecker contributorAgreements;
+ private final Provider<IdentifiedUser> user;
+ private final GitRepositoryManager gitManager;
+ private final BatchUpdate.Factory batchUpdateFactory;
+ private final PatchSetInserter.Factory patchSetInserterFactory;
+ private final Provider<InternalChangeQuery> queryProvider;
+ private final ZoneId serverZoneId;
+
+ @Inject
+ ApplyPatch(
+ ChangeJson.Factory jsonFactory,
+ ContributorAgreementsChecker contributorAgreements,
+ Provider<IdentifiedUser> user,
+ GitRepositoryManager gitManager,
+ BatchUpdate.Factory batchUpdateFactory,
+ PatchSetInserter.Factory patchSetInserterFactory,
+ Provider<InternalChangeQuery> queryProvider,
+ @GerritPersonIdent PersonIdent myIdent) {
+ this.jsonFactory = jsonFactory;
+ this.contributorAgreements = contributorAgreements;
+ this.user = user;
+ this.gitManager = gitManager;
+ this.batchUpdateFactory = batchUpdateFactory;
+ this.patchSetInserterFactory = patchSetInserterFactory;
+ this.queryProvider = queryProvider;
+ this.serverZoneId = myIdent.getZoneId();
+ }
+
+ @Override
+ public Response<ChangeInfo> apply(ChangeResource rsrc, ApplyPatchPatchSetInput input)
+ throws IOException, UpdateException, RestApiException, PermissionBackendException,
+ ConfigInvalidException, NoSuchProjectException, InvalidChangeOperationException {
+ NameKey project = rsrc.getProject();
+ contributorAgreements.check(project, rsrc.getUser());
+ BranchNameKey destBranch = rsrc.getChange().getDest();
+
+ try (Repository repo = gitManager.openRepository(project);
+ // This inserter and revwalk *must* be passed to any BatchUpdates
+ // created later on, to ensure the applied commit is flushed
+ // before patch sets are updated.
+ ObjectInserter oi = repo.newObjectInserter();
+ ObjectReader reader = oi.newReader();
+ CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
+ Ref destRef = repo.getRefDatabase().exactRef(destBranch.branch());
+ if (destRef == null) {
+ throw new ResourceNotFoundException(
+ String.format("Branch %s does not exist.", destBranch.branch()));
+ }
+ ChangeData destChange = rsrc.getChangeData();
+ if (destChange == null) {
+ throw new PreconditionFailedException(
+ "patch:apply cannot be called without a destination change.");
+ }
+
+ if (destChange.change().isClosed()) {
+ throw new PreconditionFailedException(
+ String.format(
+ "patch:apply with Change-Id %s could not update the existing change %d "
+ + "in destination branch %s of project %s, because the change was closed (%s)",
+ destChange.getId(),
+ destChange.getId().get(),
+ destBranch.branch(),
+ destBranch.project(),
+ destChange.change().getStatus().name()));
+ }
+
+ RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId());
+
+ RevCommit baseCommit;
+ if (!Strings.isNullOrEmpty(input.base)) {
+ baseCommit =
+ CommitUtil.getBaseCommit(
+ project.get(), queryProvider.get(), revWalk, destRef, input.base);
+ } else {
+ if (latestPatchset.getParentCount() != 1) {
+ throw new BadRequestException(
+ String.format(
+ "Cannot parse base commit for a change with none or multiple parents. Change ID: %s.",
+ destChange.getId()));
+ }
+ baseCommit = revWalk.parseCommit(latestPatchset.getParent(0));
+ }
+ PatchApplier.Result applyResult =
+ ApplyPatchUtil.applyPatch(repo, oi, input.patch, baseCommit);
+ ObjectId treeId = applyResult.getTreeId();
+
+ Instant now = TimeUtil.now();
+ PersonIdent committerIdent = user.get().newCommitterIdent(now, serverZoneId);
+ PersonIdent authorIdent =
+ input.author == null
+ ? committerIdent
+ : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
+ List<FooterLine> footerLines = latestPatchset.getFooterLines();
+ String messageWithNoFooters =
+ !Strings.isNullOrEmpty(input.commitMessage)
+ ? input.commitMessage
+ : removeFooters(latestPatchset.getFullMessage(), footerLines);
+ String commitMessage =
+ ApplyPatchUtil.buildCommitMessage(
+ messageWithNoFooters,
+ footerLines,
+ input.patch.patch,
+ ApplyPatchUtil.getResultPatch(repo, reader, baseCommit, revWalk.lookupTree(treeId)),
+ applyResult.getErrors());
+
+ ObjectId appliedCommit =
+ CommitUtil.createCommitWithTree(
+ oi, authorIdent, committerIdent, baseCommit, commitMessage, treeId);
+ CodeReviewCommit commit = revWalk.parseCommit(appliedCommit);
+ oi.flush();
+
+ Change resultChange;
+ try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+ bu.setRepository(repo, revWalk, oi);
+ resultChange =
+ insertPatchSet(bu, repo, patchSetInserterFactory, destChange.notes(), commit);
+ } catch (NoSuchChangeException | RepositoryNotFoundException e) {
+ throw new ResourceConflictException(e.getMessage());
+ }
+ List<ListChangesOption> opts = input.responseFormatOptions;
+ if (opts == null) {
+ opts = ImmutableList.of();
+ }
+ ChangeInfo changeInfo = jsonFactory.create(opts).format(resultChange);
+ return Response.ok(changeInfo);
+ }
+ }
+
+ private static Change insertPatchSet(
+ BatchUpdate bu,
+ Repository git,
+ PatchSetInserter.Factory patchSetInserterFactory,
+ ChangeNotes destNotes,
+ CodeReviewCommit commit)
+ throws IOException, UpdateException, RestApiException {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ Change destChange = destNotes.getChange();
+ PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+ PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, commit);
+ inserter.setMessage(buildMessageForPatchSet(psId));
+ bu.addOp(destChange.getId(), inserter);
+ bu.execute();
+ return inserter.getChange();
+ }
+ }
+
+ private static String buildMessageForPatchSet(PatchSet.Id psId) {
+ return new StringBuilder(String.format("Uploaded patch set %s.", psId.get())).toString();
+ }
+
+ private String removeFooters(String originalMessage, List<FooterLine> footerLines) {
+ if (footerLines.isEmpty()) {
+ return originalMessage;
+ }
+ return originalMessage.substring(0, originalMessage.indexOf(footerLines.get(0).getKey()));
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
new file mode 100644
index 0000000000..a5df0f839d
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ApplyPatchUtil.java
@@ -0,0 +1,194 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import org.apache.commons.codec.binary.Base64;
+import org.apache.commons.lang3.StringUtils;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.patch.Patch;
+import org.eclipse.jgit.patch.PatchApplier;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevTree;
+
+/** Utility for applying a patch. */
+public final class ApplyPatchUtil {
+
+ /**
+ * Applies the given patch on top of the merge tip, using the given object inserter.
+ *
+ * @param repo to apply the patch in
+ * @param oi to operate with
+ * @param input the patch for applying
+ * @param mergeTip the tip to apply the patch on
+ * @return the tree ID with the applied patch
+ * @throws IOException if unable to create the jgit PatchApplier object
+ * @throws RestApiException for any other failure
+ */
+ public static PatchApplier.Result applyPatch(
+ Repository repo, ObjectInserter oi, ApplyPatchInput input, RevCommit mergeTip)
+ throws IOException, RestApiException {
+ checkNotNull(mergeTip);
+ RevTree tip = mergeTip.getTree();
+ Patch patch = new Patch();
+ try (InputStream patchStream =
+ new ByteArrayInputStream(decodeIfNecessary(input.patch).getBytes(StandardCharsets.UTF_8))) {
+ patch.parse(patchStream);
+ if (!patch.getErrors().isEmpty()) {
+ throw new BadRequestException(
+ "Invalid patch format. Got the following errors:\n"
+ + patch.getErrors().stream()
+ .map(Objects::toString)
+ .collect(Collectors.joining("\n"))
+ + "\nFor the patch:\n"
+ + input.patch);
+ }
+ }
+ try {
+ PatchApplier applier = new PatchApplier(repo, tip, oi);
+ PatchApplier.Result applyResult = applier.applyPatch(patch);
+ return applyResult;
+ } catch (IOException e) {
+ throw RestApiException.wrap("Cannot apply patch: " + input.patch, e);
+ }
+ }
+
+ /**
+ * Build commit message for commits with applied patch.
+ *
+ * <p>Message structure:
+ *
+ * <ol>
+ * <li>Provided {@code message}.
+ * <li>In case of errors while applying the patch - a warning message which includes the errors;
+ * as well as the original patch's header if available, or the full original patch
+ * otherwise.
+ * <li>If there are no explicit errors, but the result change's patch is not the same as the
+ * original patch - a warning message which includes the diff; as well as the original
+ * patch's header if available, or the full original patch otherwise.
+ * <li>The provided {@code footerLines}, if any.
+ * </ol>
+ *
+ * @param message the first message piece, excluding footers
+ * @param footerLines footer lines to append to the message
+ * @param originalPatch to compare the result patch to
+ * @param resultPatch to validate accuracy for
+ * @return the commit message
+ * @throws BadRequestException if the commit message cannot be sanitized
+ */
+ public static String buildCommitMessage(
+ String message,
+ List<FooterLine> footerLines,
+ String originalPatch,
+ String resultPatch,
+ List<PatchApplier.Result.Error> errors)
+ throws BadRequestException {
+ StringBuilder res = new StringBuilder(message.trim());
+
+ boolean appendOriginalPatch = false;
+ String decodedOriginalPatch = decodeIfNecessary(originalPatch);
+ if (!errors.isEmpty()) {
+ res.append(
+ "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+ + "\nPLEASE REVIEW CAREFULLY.\nErrors:\n"
+ + errors.stream().map(Objects::toString).collect(Collectors.joining("\n")));
+ appendOriginalPatch = true;
+ } else {
+ // Only surface the diff if no explicit errors occurred.
+ Optional<String> patchDiff = verifyAppliedPatch(decodedOriginalPatch, resultPatch);
+ if (!patchDiff.isEmpty()) {
+ res.append(
+ "\n\nNOTE FOR REVIEWERS - original patch and result patch are not identical."
+ + "\nPLEASE REVIEW CAREFULLY.\nDiffs between the patches:\n "
+ + patchDiff.get());
+ appendOriginalPatch = true;
+ }
+ }
+
+ if (appendOriginalPatch) {
+ Optional<String> originalPatchHeader = DiffUtil.getPatchHeader(decodedOriginalPatch);
+ String patchDescription =
+ (originalPatchHeader.isEmpty() ? decodedOriginalPatch : originalPatchHeader.get()).trim();
+ res.append(
+ "\n\nOriginal patch:\n "
+ + patchDescription.substring(0, Math.min(patchDescription.length(), 1024)));
+ }
+
+ if (!footerLines.isEmpty()) {
+ res.append('\n');
+ }
+ for (FooterLine footer : footerLines) {
+ res.append("\n" + footer.toString());
+ }
+ return CommitMessageUtil.checkAndSanitizeCommitMessage(res.toString());
+ }
+
+ /**
+ * Fetch the patch of the result tree.
+ *
+ * @param repo in which the patch was applied
+ * @param reader for the repo objects, including {@code resultTree}
+ * @param baseCommit to generate patch against
+ * @param resultTree to generate the patch for
+ * @return the result patch
+ * @throws IOException if the result patch cannot be written
+ */
+ public static String getResultPatch(
+ Repository repo, ObjectReader reader, RevCommit baseCommit, RevTree resultTree)
+ throws IOException {
+ try (OutputStream resultPatchStream = new ByteArrayOutputStream()) {
+ DiffUtil.getFormattedDiff(
+ repo, reader, baseCommit.getTree(), resultTree, null, resultPatchStream);
+ return resultPatchStream.toString();
+ }
+ }
+
+ private static Optional<String> verifyAppliedPatch(String originalPatch, String resultPatch) {
+ String cleanOriginalPatch = DiffUtil.cleanPatch(originalPatch);
+ String cleanResultPatch = DiffUtil.cleanPatch(resultPatch);
+ if (cleanOriginalPatch.equals(cleanResultPatch)) {
+ return Optional.empty();
+ }
+ return Optional.of(StringUtils.difference(cleanOriginalPatch, cleanResultPatch));
+ }
+
+ private static String decodeIfNecessary(String patch) {
+ if (Base64.isBase64(patch)) {
+ return new String(org.eclipse.jgit.util.Base64.decode(patch), StandardCharsets.UTF_8);
+ }
+ return patch;
+ }
+
+ private ApplyPatchUtil() {}
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 19cfd6a011..6fd75deac5 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -283,6 +283,7 @@ public class ChangeEdits implements ChildCollection<ChangeResource, ChangeEditRe
/** Put handler that is activated when PUT request is called on collection element. */
@Singleton
public static class Put implements RestModifyView<ChangeEditResource, FileContentInput> {
+
private static final Pattern BINARY_DATA_PATTERN =
Pattern.compile("data:([\\w/.-]*);([\\w]+),(.*)");
private static final String BASE64 = "base64";
@@ -340,8 +341,17 @@ public class ChangeEdits implements ChildCollection<ChangeResource, ChangeEditRe
throw new ResourceConflictException("Invalid path: " + path);
}
+ if (fileContentInput.fileMode != null) {
+ if ((fileContentInput.fileMode != 100644) && (fileContentInput.fileMode != 100755)) {
+ throw new BadRequestException(
+ "file_mode ("
+ + fileContentInput.fileMode
+ + ") was invalid: supported values are 0, 644, or 755.");
+ }
+ }
try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
- editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
+ editModifier.modifyFile(
+ repository, rsrc.getNotes(), path, newContent, fileContentInput.fileMode);
} catch (InvalidChangeOperationException e) {
throw new ResourceConflictException(e.getMessage());
}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index 718759acfc..33e634205c 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -42,7 +42,6 @@ import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.change.RebaseChangeOp;
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.change.ReviewerResource;
-import com.google.gerrit.server.change.SetAssigneeOp;
import com.google.gerrit.server.change.SetCherryPickOp;
import com.google.gerrit.server.change.SetHashtagsOp;
import com.google.gerrit.server.change.SetPrivateOp;
@@ -91,10 +90,6 @@ public class ChangeRestApiModule extends RestApiModule {
delete(ATTENTION_SET_ENTRY_KIND).to(RemoveFromAttentionSet.class);
post(ATTENTION_SET_ENTRY_KIND, "delete").to(RemoveFromAttentionSet.class);
postOnCollection(ATTENTION_SET_ENTRY_KIND).to(AddToAttentionSet.class);
- get(CHANGE_KIND, "assignee").to(GetAssignee.class);
- get(CHANGE_KIND, "past_assignees").to(GetPastAssignees.class);
- put(CHANGE_KIND, "assignee").to(PutAssignee.class);
- delete(CHANGE_KIND, "assignee").to(DeleteAssignee.class);
get(CHANGE_KIND, "hashtags").to(GetHashtags.class);
get(CHANGE_KIND, "comments").to(ListChangeComments.class);
get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
@@ -113,6 +108,7 @@ public class ChangeRestApiModule extends RestApiModule {
post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
get(CHANGE_KIND, "submitted_together").to(SubmittedTogether.class);
post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
+ post(CHANGE_KIND, "rebase:chain").to(RebaseChain.class);
post(CHANGE_KIND, "index").to(Index.class);
post(CHANGE_KIND, "move").to(Move.class);
post(CHANGE_KIND, "private").to(PostPrivate.class);
@@ -122,6 +118,7 @@ public class ChangeRestApiModule extends RestApiModule {
post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
put(CHANGE_KIND, "message").to(PutMessage.class);
post(CHANGE_KIND, "check.submit_requirement").to(CheckSubmitRequirement.class);
+ post(CHANGE_KIND, "patch:apply").to(ApplyPatch.class);
get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
child(CHANGE_KIND, "reviewers").to(Reviewers.class);
@@ -218,7 +215,6 @@ public class ChangeRestApiModule extends RestApiModule {
factory(PreviewFix.Factory.class);
factory(RebaseChangeOp.Factory.class);
factory(ReviewerResource.Factory.class);
- factory(SetAssigneeOp.Factory.class);
factory(SetCherryPickOp.Factory.class);
factory(SetHashtagsOp.Factory.class);
factory(SetTopicOp.Factory.class);
diff --git a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
index a0c5b16871..9715a5d6b8 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangesCollection.java
@@ -39,6 +39,7 @@ import com.google.inject.Singleton;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
@Singleton
public class ChangesCollection implements RestCollection<TopLevelResource, ChangeResource> {
@@ -49,6 +50,7 @@ public class ChangesCollection implements RestCollection<TopLevelResource, Chang
private final ChangeResource.Factory changeResourceFactory;
private final PermissionBackend permissionBackend;
private final ProjectCache projectCache;
+ private final ChangeNotes.Factory changeNotesFactory;
@Inject
public ChangesCollection(
@@ -58,7 +60,9 @@ public class ChangesCollection implements RestCollection<TopLevelResource, Chang
ChangeFinder changeFinder,
ChangeResource.Factory changeResourceFactory,
PermissionBackend permissionBackend,
- ProjectCache projectCache) {
+ ProjectCache projectCache,
+ ChangeNotes.Factory changeNotesFactory) {
+ this.changeNotesFactory = changeNotesFactory;
this.user = user;
this.queryFactory = queryFactory;
this.views = views;
@@ -78,6 +82,11 @@ public class ChangesCollection implements RestCollection<TopLevelResource, Chang
return views;
}
+ /**
+ * Parses {@link ChangeResource} from {@link com.google.gerrit.entities.Change.Id}
+ *
+ * <p>Reads the change from index, since project is unknown.
+ */
@Override
public ChangeResource parse(TopLevelResource root, IdString id)
throws RestApiException, PermissionBackendException, IOException {
@@ -96,6 +105,29 @@ public class ChangesCollection implements RestCollection<TopLevelResource, Chang
return changeResourceFactory.create(change, user.get());
}
+ /**
+ * Parses {@link ChangeResource} from {@link com.google.gerrit.entities.Change.Id} in {@code
+ * project} at {@code metaRevId}
+ *
+ * <p>Read change from ChangeNotesCache, so the method can be used upon creation, when the change
+ * might not be yet available in the index.
+ */
+ public ChangeResource parse(Project.NameKey project, Change.Id id, ObjectId metaRevId)
+ throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException {
+ checkProjectStatePermitsRead(project);
+ ChangeNotes change = changeNotesFactory.createChecked(project, id, metaRevId);
+ if (!canRead(change)) {
+ throw new ResourceNotFoundException(toIdString(id));
+ }
+
+ return changeResourceFactory.create(change, user.get());
+ }
+
+ /**
+ * Parses {@link ChangeResource} from {@link com.google.gerrit.entities.Change.Id}
+ *
+ * <p>Reads the change from index, since project is unknown.
+ */
public ChangeResource parse(Change.Id id)
throws ResourceConflictException, ResourceNotFoundException, PermissionBackendException {
List<ChangeNotes> notes = changeFinder.find(id);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 66f8be74cf..1bfb6bd623 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -16,10 +16,10 @@ package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
@@ -31,9 +31,7 @@ import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MergeConflictException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
@@ -44,8 +42,10 @@ import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.change.ResetCherryPickOp;
import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.git.CommitUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.GroupCollector;
import com.google.gerrit.server.git.MergeUtil;
@@ -63,6 +63,7 @@ import com.google.gerrit.server.submit.IntegrationConflictException;
import com.google.gerrit.server.submit.MergeIdenticalTreeException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -73,11 +74,8 @@ import java.time.Instant;
import java.time.ZoneId;
import java.util.HashSet;
import java.util.List;
-import java.util.Map;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.InvalidObjectIdException;
-import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
@@ -85,7 +83,6 @@ import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.ChangeIdUtil;
@Singleton
@@ -267,7 +264,9 @@ public class CherryPickChange {
String.format("Branch %s does not exist.", dest.branch()));
}
- RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
+ RevCommit baseCommit =
+ CommitUtil.getBaseCommit(
+ project.get(), queryProvider.get(), revWalk, destRef, input.base);
CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
@@ -334,105 +333,55 @@ public class CherryPickChange {
} catch (MergeIdenticalTreeException | MergeConflictException e) {
throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
}
-
- try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
- bu.setRepository(git, revWalk, oi);
- bu.setNotify(resolveNotify(input));
- Change.Id changeId;
- String newTopic = null;
- if (input.topic != null) {
- newTopic = Strings.emptyToNull(input.topic.trim());
- }
- if (newTopic == null
- && sourceChange != null
- && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
- newTopic = sourceChange.getTopic() + "-" + dest.shortName();
- }
- if (destChange != null) {
- // The change key exists on the destination branch. The cherry pick
- // will be added as a new patch set.
- changeId =
- insertPatchSet(
- bu,
- git,
- destChange.notes(),
- cherryPickCommit,
- sourceChange,
- newTopic,
- input,
- workInProgress);
- } else {
- // Change key not found on destination branch. We can create a new
- // change.
- changeId =
- createNewChange(
- bu,
- cherryPickCommit,
- dest.branch(),
- newTopic,
- project,
- sourceChange,
- sourceCommit,
- input,
- revertedChange,
- idForNewChange,
- workInProgress);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu = batchUpdateFactory.create(project, identifiedUser, timestamp)) {
+ bu.setRepository(git, revWalk, oi);
+ bu.setNotify(resolveNotify(input));
+ Change.Id changeId;
+ String newTopic = null;
+ if (input.topic != null) {
+ newTopic = Strings.emptyToNull(input.topic.trim());
+ }
+ if (newTopic == null
+ && sourceChange != null
+ && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+ newTopic = sourceChange.getTopic() + "-" + dest.shortName();
+ }
+ if (destChange != null) {
+ // The change key exists on the destination branch. The cherry pick
+ // will be added as a new patch set.
+ changeId =
+ insertPatchSet(
+ bu,
+ git,
+ destChange.notes(),
+ cherryPickCommit,
+ sourceChange,
+ newTopic,
+ input,
+ workInProgress);
+ } else {
+ // Change key not found on destination branch. We can create a new
+ // change.
+ changeId =
+ createNewChange(
+ bu,
+ cherryPickCommit,
+ dest.branch(),
+ newTopic,
+ project,
+ sourceChange,
+ sourceCommit,
+ input,
+ revertedChange,
+ idForNewChange,
+ workInProgress);
+ }
+ bu.execute();
+ return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
}
- bu.execute();
- return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
- }
- }
- }
-
- private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
- throws RestApiException, IOException {
- RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
- // The tip commit of the destination ref is the default base for the newly created change.
- if (Strings.isNullOrEmpty(base)) {
- return destRefTip;
- }
-
- ObjectId baseObjectId;
- try {
- baseObjectId = ObjectId.fromString(base);
- } catch (InvalidObjectIdException e) {
- throw new BadRequestException(
- String.format("Base %s doesn't represent a valid SHA-1", base), e);
- }
-
- RevCommit baseCommit;
- try {
- baseCommit = revWalk.parseCommit(baseObjectId);
- } catch (MissingObjectException e) {
- throw new UnprocessableEntityException(
- String.format("Base %s doesn't exist", baseObjectId.name()), e);
- }
-
- InternalChangeQuery changeQuery = queryProvider.get();
- changeQuery.enforceVisibility(true);
- List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), base);
-
- if (changeDatas.isEmpty()) {
- if (revWalk.isMergedInto(baseCommit, destRefTip)) {
- // The base commit is a merged commit with no change associated.
- return baseCommit;
}
- throw new UnprocessableEntityException(
- String.format("Commit %s does not exist on branch %s", base, destRef.getName()));
- } else if (changeDatas.size() != 1) {
- throw new ResourceConflictException("Multiple changes found for commit " + base);
- }
-
- Change change = changeDatas.get(0).change();
- if (!change.isAbandoned()) {
- // The base commit is a valid change revision.
- return baseCommit;
}
-
- throw new ResourceConflictException(
- String.format(
- "Change %s with commit %s is %s",
- change.getChangeId(), base, ChangeUtil.status(change)));
}
private Change.Id insertPatchSet(
@@ -456,7 +405,8 @@ public class CherryPickChange {
if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
inserter.setWorkInProgress(false);
}
- inserter.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+ inserter.setValidationOptions(
+ ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
bu.addOp(destChange.getId(), inserter);
PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
// If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -507,7 +457,8 @@ public class CherryPickChange {
(sourceChange != null && sourceChange.isWorkInProgress())
|| !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
}
- ins.setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions));
+ ins.setValidationOptions(
+ ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
ins.setMessage(
@@ -529,7 +480,7 @@ public class CherryPickChange {
reviewers.remove(user.get().getAccountId());
Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
ccs.remove(user.get().getAccountId());
- ins.setReviewersAndCcs(reviewers, ccs);
+ ins.setReviewersAndCcsIgnoreVisibility(reviewers, ccs);
}
// If there is a base, and the base is not merged, the groups will be overridden by the base's
// groups.
@@ -553,20 +504,6 @@ public class CherryPickChange {
return changeId;
}
- private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
- @Nullable Map<String, String> validationOptions) {
- if (validationOptions == null) {
- return ImmutableListMultimap.of();
- }
-
- ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
- ImmutableListMultimap.builder();
- validationOptions
- .entrySet()
- .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
- return validationOptionsBuilder.build();
- }
-
private NotifyResolver.Result resolveNotify(CherryPickInput input)
throws BadRequestException, ConfigInvalidException, IOException {
return notifyResolver.resolve(
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 81b6fb314e..8ebe71ff15 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -263,6 +263,7 @@ public class CommentJson {
return rci;
}
+ @Nullable
private List<FixSuggestionInfo> toFixSuggestionInfos(
@Nullable List<FixSuggestion> fixSuggestions) {
if (fixSuggestions == null || fixSuggestions.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 760d99d692..a1bb987c0f 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -15,10 +15,12 @@
package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
@@ -34,6 +36,7 @@ import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
import com.google.gerrit.extensions.api.accounts.AccountInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeInput;
@@ -61,6 +64,7 @@ import com.google.gerrit.server.config.AnonymousCowardName;
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.CommitUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.MergeUtil;
import com.google.gerrit.server.git.MergeUtilFactory;
@@ -79,6 +83,7 @@ import com.google.gerrit.server.restapi.project.CommitsCollection;
import com.google.gerrit.server.restapi.project.ProjectsCollection;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -94,7 +99,6 @@ import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.NoMergeBaseException;
-import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
@@ -104,6 +108,7 @@ import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.TreeFormatter;
+import org.eclipse.jgit.patch.PatchApplier;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.ChangeIdUtil;
@@ -293,6 +298,10 @@ public class CreateChange
}
}
+ if (input.merge != null && input.patch != null) {
+ throw new BadRequestException("Only one of `merge` and `patch` arguments can be set.");
+ }
+
if (input.author != null
&& (Strings.isNullOrEmpty(input.author.email)
|| Strings.isNullOrEmpty(input.author.name))) {
@@ -325,92 +334,137 @@ public class CreateChange
BatchUpdate.Factory updateFactory)
throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
UpdateException {
- logger.atFine().log(
- "Creating new change for target branch %s in project %s"
- + " (new branch = %s, base change = %s, base commit = %s)",
- input.branch, projectState.getName(), input.newBranch, input.baseChange, input.baseCommit);
-
- try (Repository git = gitManager.openRepository(projectState.getNameKey());
- ObjectInserter oi = git.newObjectInserter();
- ObjectReader reader = oi.newReader();
- CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(reader)) {
- PatchSet basePatchSet = null;
- List<String> groups = Collections.emptyList();
-
- if (input.baseChange != null) {
- ChangeNotes baseChange = getBaseChange(input.baseChange);
- basePatchSet = psUtil.current(baseChange);
- groups = basePatchSet.groups();
- logger.atFine().log("base patch set = %s (groups = %s)", basePatchSet.id(), groups);
- }
-
- ObjectId parentCommit =
- getParentCommit(
- git, rw, input.branch, input.newBranch, basePatchSet, input.baseCommit, input.merge);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
logger.atFine().log(
- "parent commit = %s", parentCommit != null ? parentCommit.name() : "NULL");
-
- RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
-
- Instant now = TimeUtil.now();
-
- PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
- PersonIdent author =
- input.author == null
- ? committer
- : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
-
- String commitMessage = getCommitMessage(input.subject, me);
-
- CodeReviewCommit c;
- if (input.merge != null) {
- // create a merge commit
- c =
- newMergeCommit(
- git, oi, rw, projectState, mergeTip, input.merge, author, committer, commitMessage);
- if (!c.getFilesWithGitConflicts().isEmpty()) {
- logger.atFine().log(
- "merge commit has conflicts in the following files: %s",
- c.getFilesWithGitConflicts());
+ "Creating new change for target branch %s in project %s"
+ + " (new branch = %s, base change = %s, base commit = %s)",
+ input.branch,
+ projectState.getName(),
+ input.newBranch,
+ input.baseChange,
+ input.baseCommit);
+
+ try (Repository git = gitManager.openRepository(projectState.getNameKey());
+ ObjectInserter oi = git.newObjectInserter();
+ ObjectReader reader = oi.newReader();
+ CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(reader)) {
+ PatchSet basePatchSet = null;
+ List<String> groups = Collections.emptyList();
+
+ if (input.baseChange != null) {
+ ChangeNotes baseChange = getBaseChange(input.baseChange);
+ basePatchSet = psUtil.current(baseChange);
+ groups = basePatchSet.groups();
+ logger.atFine().log("base patch set = %s (groups = %s)", basePatchSet.id(), groups);
+ }
+
+ ObjectId parentCommit =
+ getParentCommit(
+ git,
+ rw,
+ input.branch,
+ input.newBranch,
+ basePatchSet,
+ input.baseCommit,
+ input.merge);
+ logger.atFine().log(
+ "parent commit = %s", parentCommit != null ? parentCommit.name() : "NULL");
+
+ RevCommit mergeTip = parentCommit == null ? null : rw.parseCommit(parentCommit);
+
+ Instant now = TimeUtil.now();
+
+ PersonIdent committer = me.newCommitterIdent(now, serverZoneId);
+ PersonIdent author =
+ input.author == null
+ ? committer
+ : new PersonIdent(input.author.name, input.author.email, now, serverZoneId);
+
+ String commitMessage = getCommitMessage(input.subject, me);
+
+ CodeReviewCommit c;
+ if (input.merge != null) {
+ // create a merge commit
+ c =
+ newMergeCommit(
+ git,
+ oi,
+ rw,
+ projectState,
+ mergeTip,
+ input.merge,
+ author,
+ committer,
+ commitMessage);
+ if (!c.getFilesWithGitConflicts().isEmpty()) {
+ logger.atFine().log(
+ "merge commit has conflicts in the following files: %s",
+ c.getFilesWithGitConflicts());
+ }
+ } else if (input.patch != null) {
+ // create a commit with the given patch.
+ if (mergeTip == null) {
+ throw new BadRequestException("Cannot apply patch on top of an empty tree.");
+ }
+ PatchApplier.Result applyResult =
+ ApplyPatchUtil.applyPatch(git, oi, input.patch, mergeTip);
+ ObjectId treeId = applyResult.getTreeId();
+ String appliedPatchCommitMessage =
+ getCommitMessage(
+ ApplyPatchUtil.buildCommitMessage(
+ input.subject,
+ ImmutableList.of(),
+ input.patch.patch,
+ ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
+ applyResult.getErrors()),
+ me);
+ c =
+ rw.parseCommit(
+ CommitUtil.createCommitWithTree(
+ oi, author, committer, mergeTip, appliedPatchCommitMessage, treeId));
+ } else {
+ // create an empty commit.
+ c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
+ }
+ // Flush inserter so that commit becomes visible to validators
+ oi.flush();
+
+ Change.Id changeId = Change.id(seq.nextChangeId());
+ ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
+ ins.setMessage(messageForNewChange(ins.getPatchSetId(), c));
+ ins.setTopic(input.topic);
+ ins.setPrivate(input.isPrivate);
+ ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
+ ins.setGroups(groups);
+
+ if (input.validationOptions != null) {
+ ImmutableListMultimap.Builder<String, String> validationOptions =
+ ImmutableListMultimap.builder();
+ input
+ .validationOptions
+ .entrySet()
+ .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+ ins.setValidationOptions(validationOptions.build());
}
- } else {
- // create an empty commit
- c = newCommit(oi, rw, author, committer, mergeTip, commitMessage);
- }
- // Flush inserter so that commit becomes visible to validators
- oi.flush();
-
- Change.Id changeId = Change.id(seq.nextChangeId());
- ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
- ins.setMessage(messageForNewChange(ins.getPatchSetId(), c));
- ins.setTopic(input.topic);
- ins.setPrivate(input.isPrivate);
- ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
- ins.setGroups(groups);
-
- if (input.validationOptions != null) {
- ImmutableListMultimap.Builder<String, String> validationOptions =
- ImmutableListMultimap.builder();
- input
- .validationOptions
- .entrySet()
- .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
- ins.setValidationOptions(validationOptions.build());
- }
- try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
- bu.setRepository(git, rw, oi);
- bu.setNotify(
- notifyResolver.resolve(
- firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
- bu.insertChange(ins);
- bu.execute();
+ try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
+ bu.setRepository(git, rw, oi);
+ bu.setNotify(
+ notifyResolver.resolve(
+ firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
+ bu.insertChange(ins);
+ bu.execute();
+ }
+ List<ListChangesOption> opts = input.responseFormatOptions;
+ if (opts == null) {
+ opts = ImmutableList.of();
+ }
+ ChangeInfo changeInfo = jsonFactory.create(opts).format(ins.getChange());
+ changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
+ return changeInfo;
+ } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
+ throw new BadRequestException(e.getMessage());
}
- ChangeInfo changeInfo = jsonFactory.noOptions().format(ins.getChange());
- changeInfo.containsGitConflicts = !c.getFilesWithGitConflicts().isEmpty() ? true : null;
- return changeInfo;
- } catch (InvalidMergeStrategyException | MergeWithConflictsNotSupportedException e) {
- throw new BadRequestException(e.getMessage());
}
}
@@ -526,7 +580,7 @@ public class CreateChange
return commitMessage;
}
- private static CodeReviewCommit newCommit(
+ private static CodeReviewCommit createEmptyCommit(
ObjectInserter oi,
CodeReviewRevWalk rw,
PersonIdent authorIdent,
@@ -535,17 +589,14 @@ public class CreateChange
String commitMessage)
throws IOException {
logger.atFine().log("Creating empty commit");
- CommitBuilder commit = new CommitBuilder();
- if (mergeTip == null) {
- commit.setTreeId(emptyTreeId(oi));
- } else {
- commit.setTreeId(mergeTip.getTree().getId());
- commit.setParentId(mergeTip);
- }
- commit.setAuthor(authorIdent);
- commit.setCommitter(committerIdent);
- commit.setMessage(commitMessage);
- return rw.parseCommit(insert(oi, commit));
+ ObjectId treeID = mergeTip == null ? emptyTreeId(oi) : mergeTip.getTree().getId();
+ return rw.parseCommit(
+ CommitUtil.createCommitWithTree(
+ oi, authorIdent, committerIdent, mergeTip, commitMessage, treeID));
+ }
+
+ private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
+ return inserter.insert(new TreeFormatter());
}
private CodeReviewCommit newMergeCommit(
@@ -615,14 +666,4 @@ public class CreateChange
return stringBuilder.toString();
}
-
- private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit) throws IOException {
- ObjectId id = inserter.insert(commit);
- inserter.flush();
- return id;
- }
-
- private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
- return inserter.insert(new TreeFormatter());
- }
}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index 9e9cf6a82a..cd0025f7f6 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
import com.google.gerrit.entities.HumanComment;
@@ -36,6 +37,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -81,13 +83,15 @@ public class CreateDraftComment implements RestModifyView<RevisionResource, Draf
throw new BadRequestException(
String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
}
-
- try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- Op op = new Op(rsrc.getPatchSet().id(), in);
- bu.addOp(rsrc.getChange().getId(), op);
- bu.execute();
- return Response.created(
- commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ Op op = new Op(rsrc.getPatchSet().id(), in);
+ bu.addOp(rsrc.getChange().getId(), op);
+ bu.execute();
+ return Response.created(
+ commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 4b66cdc3a6..51094b7b90 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
@@ -63,6 +64,7 @@ import com.google.gerrit.server.restapi.project.CommitsCollection;
import com.google.gerrit.server.submit.MergeIdenticalTreeException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -200,18 +202,20 @@ public class CreateMergePatchSet implements RestModifyView<ChangeResource, Merge
PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
PatchSetInserter psInserter =
patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
- try (BatchUpdate bu = updateFactory.create(project, me, now)) {
- bu.setRepository(git, rw, oi);
- bu.setNotify(NotifyResolver.Result.none());
- psInserter
- .setMessage(messageForChange(nextPsId, newCommit))
- .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
- .setCheckAddPatchSetPermission(false);
- if (groups != null) {
- psInserter.setGroups(groups);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu = updateFactory.create(project, me, now)) {
+ bu.setRepository(git, rw, oi);
+ bu.setNotify(NotifyResolver.Result.none());
+ psInserter
+ .setMessage(messageForChange(nextPsId, newCommit))
+ .setWorkInProgress(!newCommit.getFilesWithGitConflicts().isEmpty())
+ .setCheckAddPatchSetPermission(false);
+ if (groups != null) {
+ psInserter.setGroups(groups);
+ }
+ bu.addOp(rsrc.getId(), psInserter);
+ bu.execute();
}
- bu.addOp(rsrc.getId(), psInserter);
- bu.execute();
}
ChangeJson json = jsonFactory.create(ListChangesOption.CURRENT_REVISION);
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
deleted file mode 100644
index d81821003d..0000000000
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.extensions.events.AssigneeChanged;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.AccountTemplateUtil;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
- private final BatchUpdate.Factory updateFactory;
- private final ChangeMessagesUtil cmUtil;
- private final AssigneeChanged assigneeChanged;
- private final IdentifiedUser.GenericFactory userFactory;
- private final AccountLoader.Factory accountLoaderFactory;
-
- @Inject
- DeleteAssignee(
- BatchUpdate.Factory updateFactory,
- ChangeMessagesUtil cmUtil,
- AssigneeChanged assigneeChanged,
- IdentifiedUser.GenericFactory userFactory,
- AccountLoader.Factory accountLoaderFactory) {
- this.updateFactory = updateFactory;
- this.cmUtil = cmUtil;
- this.assigneeChanged = assigneeChanged;
- this.userFactory = userFactory;
- this.accountLoaderFactory = accountLoaderFactory;
- }
-
- @Override
- public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
- throws RestApiException, UpdateException, PermissionBackendException {
- rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
-
- try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- Op op = new Op();
- bu.addOp(rsrc.getChange().getId(), op);
- bu.execute();
- Account.Id deletedAssignee = op.getDeletedAssignee();
- return deletedAssignee == null
- ? Response.none()
- : Response.ok(accountLoaderFactory.create(true).fillOne(deletedAssignee));
- }
- }
-
- private class Op implements BatchUpdateOp {
- private Change change;
- private AccountState deletedAssignee;
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws RestApiException {
- change = ctx.getChange();
- ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
- Account.Id currentAssigneeId = change.getAssignee();
- if (currentAssigneeId == null) {
- return false;
- }
- IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
- deletedAssignee = deletedAssigneeUser.state();
- update.removeAssignee();
- addMessage(ctx, deletedAssigneeUser);
- return true;
- }
-
- public Account.Id getDeletedAssignee() {
- return deletedAssignee != null ? deletedAssignee.account().id() : null;
- }
-
- private void addMessage(ChangeContext ctx, IdentifiedUser deletedAssignee) {
- cmUtil.setChangeMessage(
- ctx,
- "Assignee deleted: "
- + AccountTemplateUtil.getAccountTemplate(deletedAssignee.getAccountId()),
- ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
- }
-
- @Override
- public void postUpdate(PostUpdateContext ctx) {
- assigneeChanged.fire(
- ctx.getChangeData(change), ctx.getAccount(), deletedAssignee, ctx.getWhen());
- }
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChange.java b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
index 8298abb3fa..9153703b3b 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChange.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChange.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.common.Input;
@@ -30,6 +31,7 @@ import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -53,11 +55,13 @@ public class DeleteChange
throw new MethodNotAllowedException("delete not permitted");
}
rsrc.permissions().check(ChangePermission.DELETE);
-
- try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- Change.Id id = rsrc.getChange().getId();
- bu.addOp(id, opFactory.create(id));
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ Change.Id id = rsrc.getChange().getId();
+ bu.addOp(id, opFactory.create(id));
+ bu.execute();
+ }
}
return Response.none();
}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 588d56e59a..ca6bfad5d0 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.util.Objects.requireNonNull;
import com.google.common.base.Strings;
@@ -41,6 +42,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -88,9 +90,11 @@ public class DeleteChangeMessage
createNewChangeMessage(user.asIdentifiedUser().getAccountId(), input.reason);
DeleteChangeMessageOp deleteChangeMessageOp =
new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
- try (BatchUpdate batchUpdate =
- updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
- batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate batchUpdate =
+ updateFactory.create(resource.getChangeResource().getProject(), user, TimeUtil.now())) {
+ batchUpdate.addOp(resource.getChangeId(), deleteChangeMessageOp).execute();
+ }
}
ChangeMessageInfo updatedMessageInfo =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteComment.java b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
index 2056664570..13975827f9 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteComment.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.common.base.Strings;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
@@ -35,6 +37,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -83,9 +86,13 @@ public class DeleteComment implements RestModifyView<HumanCommentResource, Delet
String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
- try (BatchUpdate batchUpdate =
- updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
- batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate batchUpdate =
+ updateFactory.create(rsrc.getRevisionResource().getProject(), user, TimeUtil.now())) {
+ batchUpdate
+ .addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp)
+ .execute();
+ }
}
ChangeNotes updatedNotes =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 7d28a3940b..f55e9c701a 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
@@ -30,6 +32,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -53,11 +56,13 @@ public class DeleteDraftComment implements RestModifyView<DraftCommentResource,
@Override
public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
throws RestApiException, UpdateException {
- try (BatchUpdate bu =
- updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
- Op op = new Op(rsrc.getComment().key);
- bu.addOp(rsrc.getChange().getId(), op);
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+ Op op = new Op(rsrc.getComment().key);
+ bu.addOp(rsrc.getChange().getId(), op);
+ bu.execute();
+ }
}
return Response.none();
}
diff --git a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
index 08725b51cb..5c63bd7ae6 100644
--- a/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/DeletePrivate.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.common.InputWithMessage;
@@ -30,6 +31,7 @@ import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -62,8 +64,11 @@ public class DeletePrivate implements RestModifyView<ChangeResource, InputWithMe
}
SetPrivateOp op = setPrivateOpFactory.create(false, input);
- try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- u.addOp(rsrc.getId(), op).execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ u.addOp(rsrc.getId(), op).execute();
+ }
}
return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index 7a409e8488..cbc3b5ef87 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -27,6 +29,7 @@ import com.google.gerrit.server.change.ReviewerResource;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -53,21 +56,22 @@ public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteRe
if (input == null) {
input = new DeleteReviewerInput();
}
-
- try (BatchUpdate bu =
- updateFactory.create(
- rsrc.getChangeResource().getProject(),
- rsrc.getChangeResource().getUser(),
- TimeUtil.now())) {
- bu.setNotify(getNotify(rsrc.getChange(), input));
- BatchUpdateOp op;
- if (rsrc.isByEmail()) {
- op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
- } else {
- op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(
+ rsrc.getChangeResource().getProject(),
+ rsrc.getChangeResource().getUser(),
+ TimeUtil.now())) {
+ bu.setNotify(getNotify(rsrc.getChange(), input));
+ BatchUpdateOp op;
+ if (rsrc.isByEmail()) {
+ op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
+ } else {
+ op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+ }
+ bu.addOp(rsrc.getChange().getId(), op);
+ bu.execute();
}
- bu.addOp(rsrc.getChange().getId(), op);
- bu.execute();
}
return Response.none();
}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 9fa31601af..b3d7fa26aa 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
@@ -32,6 +33,7 @@ import com.google.gerrit.server.change.ReviewerResource;
import com.google.gerrit.server.change.VoteResource;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -80,34 +82,37 @@ public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput>
if (r.getRevisionResource() != null && !r.getRevisionResource().isCurrent()) {
throw new MethodNotAllowedException("Cannot delete vote on non-current patch set");
}
-
- try (BatchUpdate bu =
- updateFactory.create(
- change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
- bu.setNotify(
- notifyResolver.resolve(
- firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
- bu.addOp(
- change.getId(),
- deleteVoteOpFactory.create(
- r.getChange().getProject(),
- r.getReviewerUser().state(),
- rsrc.getLabel(),
- input,
- true));
- if (!input.ignoreAutomaticAttentionSetRules
- && !r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(
+ change.getProject(), r.getChangeResource().getUser(), TimeUtil.now())) {
+ bu.setNotify(
+ notifyResolver.resolve(
+ firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails));
bu.addOp(
change.getId(),
- attentionSetOpFactory.create(
- r.getReviewerUser().getAccountId(),
- /* reason= */ "Their vote was deleted",
- /* notify= */ false));
- }
- if (input.ignoreAutomaticAttentionSetRules) {
- bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+ deleteVoteOpFactory.create(
+ r.getChange().getProject(),
+ r.getReviewerUser().state(),
+ rsrc.getLabel(),
+ input,
+ true));
+ if (!input.ignoreAutomaticAttentionSetRules
+ && !r.getReviewerUser()
+ .getAccountId()
+ .equals(currentUserProvider.get().getAccountId())) {
+ bu.addOp(
+ change.getId(),
+ attentionSetOpFactory.create(
+ r.getReviewerUser().getAccountId(),
+ /* reason= */ "Their vote was deleted",
+ /* notify= */ false));
+ }
+ if (input.ignoreAutomaticAttentionSetRules) {
+ bu.addOp(change.getId(), new AttentionSetUnchangedOp());
+ }
+ bu.execute();
}
- bu.execute();
}
return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
index 432f0da9f3..3ac4d225f4 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVoteOp.java
@@ -20,6 +20,7 @@ import static java.util.Objects.requireNonNull;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
@@ -37,7 +38,9 @@ import com.google.gerrit.server.extensions.events.VoteDeleted;
import com.google.gerrit.server.mail.send.DeleteVoteSender;
import com.google.gerrit.server.mail.send.MessageIdGenerator;
import com.google.gerrit.server.mail.send.ReplyToChangeSender;
+import com.google.gerrit.server.permissions.LabelRemovalPermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DeleteVoteControl;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.RemoveReviewerControl;
import com.google.gerrit.server.update.BatchUpdateOp;
@@ -75,6 +78,7 @@ public class DeleteVoteOp implements BatchUpdateOp {
private final VoteDeleted voteDeleted;
private final DeleteVoteSender.Factory deleteVoteSenderFactory;
+ private final DeleteVoteControl deleteVoteControl;
private final RemoveReviewerControl removeReviewerControl;
private final MessageIdGenerator messageIdGenerator;
@@ -96,8 +100,9 @@ public class DeleteVoteOp implements BatchUpdateOp {
ChangeMessagesUtil cmUtil,
VoteDeleted voteDeleted,
DeleteVoteSender.Factory deleteVoteSenderFactory,
- RemoveReviewerControl removeReviewerControl,
+ DeleteVoteControl deleteVoteControl,
MessageIdGenerator messageIdGenerator,
+ RemoveReviewerControl removeReviewerControl,
@Assisted Project.NameKey projectName,
@Assisted AccountState reviewerToDeleteVoteFor,
@Assisted String label,
@@ -109,6 +114,7 @@ public class DeleteVoteOp implements BatchUpdateOp {
this.cmUtil = cmUtil;
this.voteDeleted = voteDeleted;
this.deleteVoteSenderFactory = deleteVoteSenderFactory;
+ this.deleteVoteControl = deleteVoteControl;
this.removeReviewerControl = removeReviewerControl;
this.messageIdGenerator = messageIdGenerator;
@@ -143,19 +149,16 @@ public class DeleteVoteOp implements BatchUpdateOp {
newApprovals.put(a.label(), a.value());
continue;
} else if (enforcePermissions) {
- // For regular users, check if they are allowed to remove the vote.
- try {
- removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
- } catch (AuthException e) {
- throw new AuthException("delete vote not permitted", e);
- }
+ checkPermissions(ctx, labelTypes.byLabel(a.labelId()).get(), a);
}
// Set the approval to 0 if vote is being removed.
newApprovals.put(a.label(), (short) 0);
- found = true;
-
- // Set old value, as required by VoteDeleted.
- oldApprovals.put(a.label(), a.value());
+ // If the value is 0, we treat it as already deleted, so no additional actions is required
+ if (a.value() != 0) {
+ found = true;
+ // Set old value, as required by VoteDeleted.
+ oldApprovals.put(a.label(), a.value());
+ }
break;
}
if (!found) {
@@ -185,18 +188,16 @@ public class DeleteVoteOp implements BatchUpdateOp {
CurrentUser user = ctx.getUser();
try {
NotifyResolver.Result notify = ctx.getNotify(change.getId());
- if (notify.shouldNotify()) {
- ReplyToChangeSender emailSender =
- deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
- if (user.isIdentifiedUser()) {
- emailSender.setFrom(user.getAccountId());
- }
- emailSender.setChangeMessage(mailMessage, ctx.getWhen());
- emailSender.setNotify(notify);
- emailSender.setMessageId(
- messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
- emailSender.send();
+ ReplyToChangeSender emailSender =
+ deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
+ if (user.isIdentifiedUser()) {
+ emailSender.setFrom(user.getAccountId());
}
+ emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+ emailSender.setNotify(notify);
+ emailSender.setMessageId(
+ messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+ emailSender.send();
} catch (Exception e) {
logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
}
@@ -211,4 +212,21 @@ public class DeleteVoteOp implements BatchUpdateOp {
user.isIdentifiedUser() ? user.asIdentifiedUser().state() : null,
ctx.getWhen());
}
+
+ private void checkPermissions(ChangeContext ctx, LabelType labelType, PatchSetApproval approval)
+ throws PermissionBackendException, AuthException {
+ boolean permitted =
+ removeReviewerControl.testRemoveReviewer(ctx.getNotes(), ctx.getUser(), approval)
+ || deleteVoteControl.testDeleteVotePermissions(
+ ctx.getUser(), ctx.getNotes(), approval, labelType);
+ if (!permitted) {
+ throw new AuthException(
+ "Delete vote not permitted.",
+ new AuthException(
+ "Both "
+ + new LabelRemovalPermission.WithValue(labelType, approval.value())
+ .describeForException()
+ + " and remove-reviewer are not permitted"));
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index e89cf6c1a6..96e5645d4e 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -370,6 +370,7 @@ public class Files implements ChildCollection<RevisionResource, FileResource> {
: Iterables.getFirst(fileDiffList.values(), null).oldCommitId();
}
+ @Nullable
private ObjectId getNewId(Map<String, FileDiffOutput> fileDiffList) {
return fileDiffList.isEmpty()
? null
diff --git a/java/com/google/gerrit/server/restapi/change/GetAssignee.java b/java/com/google/gerrit/server/restapi/change/GetAssignee.java
deleted file mode 100644
index a5820bfa8e..0000000000
--- a/java/com/google/gerrit/server/restapi/change/GetAssignee.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Optional;
-
-@Singleton
-public class GetAssignee implements RestReadView<ChangeResource> {
- private final AccountLoader.Factory accountLoaderFactory;
-
- @Inject
- GetAssignee(AccountLoader.Factory accountLoaderFactory) {
- this.accountLoaderFactory = accountLoaderFactory;
- }
-
- @Override
- public Response<AccountInfo> apply(ChangeResource rsrc) throws PermissionBackendException {
- Optional<Account.Id> assignee = Optional.ofNullable(rsrc.getChange().getAssignee());
- if (assignee.isPresent()) {
- return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.get()));
- }
- return Response.none();
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index a81171a5b3..d126d8a4a9 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -144,6 +144,7 @@ public class GetChange
cds, this, Streams.stream(pdiFactories.entries()));
}
+ @Nullable
private ObjectId verifyMetaId(Change change, @Nullable ObjectId id) throws RestApiException {
if (id == null) {
return null;
diff --git a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java b/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
deleted file mode 100644
index c1c9a34950..0000000000
--- a/java/com/google/gerrit/server/restapi/change/GetPastAssignees.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-@Singleton
-public class GetPastAssignees implements RestReadView<ChangeResource> {
- private final AccountLoader.Factory accountLoaderFactory;
-
- @Inject
- GetPastAssignees(AccountLoader.Factory accountLoaderFactory) {
- this.accountLoaderFactory = accountLoaderFactory;
- }
-
- @Override
- public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws PermissionBackendException {
-
- Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
- if (pastAssignees == null) {
- return Response.ok(Collections.emptyList());
- }
-
- AccountLoader accountLoader = accountLoaderFactory.create(true);
- List<AccountInfo> infos = pastAssignees.stream().map(accountLoader::get).collect(toList());
- accountLoader.fill();
- return Response.ok(infos);
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/GetPatch.java b/java/com/google/gerrit/server/restapi/change/GetPatch.java
index dea4dc4adc..d8946a75ea 100644
--- a/java/com/google/gerrit/server/restapi/change/GetPatch.java
+++ b/java/com/google/gerrit/server/restapi/change/GetPatch.java
@@ -24,6 +24,7 @@ import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestReadView;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffUtil;
import com.google.inject.Inject;
import java.io.IOException;
import java.io.OutputStream;
@@ -32,12 +33,10 @@ import java.util.Calendar;
import java.util.Locale;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
-import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.filter.PathFilter;
import org.kohsuke.args4j.Option;
public class GetPatch implements RestReadView<RevisionResource> {
@@ -98,14 +97,7 @@ public class GetPatch implements RestReadView<RevisionResource> {
if (path == null) {
out.write(formatEmailHeader(commit).getBytes(UTF_8));
}
- try (DiffFormatter fmt = new DiffFormatter(out)) {
- fmt.setRepository(repo);
- if (path != null) {
- fmt.setPathFilter(PathFilter.create(path));
- }
- fmt.format(base.getTree(), commit.getTree());
- fmt.flush();
- }
+ DiffUtil.getFormattedDiff(repo, base, commit, path, out);
}
@Override
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 8aa2554be3..9797bda579 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -29,7 +29,9 @@ import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestReadView;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.change.MergeabilityCache;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.MergeUtilFactory;
import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -46,6 +48,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
@@ -59,6 +62,7 @@ public class Mergeable implements RestReadView<RevisionResource> {
usage = "test mergeability for other branches too")
private boolean otherBranches;
+ private final Config cfg;
private final GitRepositoryManager gitManager;
private final ProjectCache projectCache;
private final MergeUtilFactory mergeUtilFactory;
@@ -69,6 +73,7 @@ public class Mergeable implements RestReadView<RevisionResource> {
@Inject
Mergeable(
+ @GerritServerConfig Config cfg,
GitRepositoryManager gitManager,
ProjectCache projectCache,
MergeUtilFactory mergeUtilFactory,
@@ -76,6 +81,7 @@ public class Mergeable implements RestReadView<RevisionResource> {
ChangeIndexer indexer,
MergeabilityCache cache,
SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
+ this.cfg = cfg;
this.gitManager = gitManager;
this.projectCache = projectCache;
this.mergeUtilFactory = mergeUtilFactory;
@@ -175,7 +181,8 @@ public class Mergeable implements RestReadView<RevisionResource> {
boolean mergeable = cache.get(commit, ref, type, strategy, change.getDest(), git);
// TODO(dborowitz): Include something else in the change ETag that it's possible to bump here,
// such as cache or secondary index update time.
- if (!Objects.equals(mergeable, old)) {
+ if (!Objects.equals(mergeable, old)
+ && MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex()) {
@SuppressWarnings("unused")
Future<?> possiblyIgnoredError = indexer.indexAsync(change.getProject(), change.getId());
}
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index f4f05005e2..2b0de12ee9 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -19,6 +19,7 @@ import static com.google.gerrit.server.permissions.ChangePermission.ABANDON;
import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
import com.google.gerrit.common.Nullable;
@@ -59,6 +60,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -156,9 +158,11 @@ public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction
projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
Op op = new Op(input);
- try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
- u.addOp(change.getId(), op);
- u.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) {
+ u.addOp(change.getId(), op);
+ u.execute();
+ }
}
return Response.ok(json.noOptions().format(op.getChange()));
}
@@ -212,7 +216,8 @@ public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction
}
Change.Key changeKey = change.getKey();
- if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) {
+ if (!asChanges(queryProvider.get().setLimit(1).byBranchKey(newDestKey, changeKey))
+ .isEmpty()) {
throw new ResourceConflictException(
"Destination "
+ newDestKey.shortName()
diff --git a/java/com/google/gerrit/server/restapi/change/OnPostReview.java b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
index b179d02404..4999f1897b 100644
--- a/java/com/google/gerrit/server/restapi/change/OnPostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/OnPostReview.java
@@ -18,6 +18,7 @@ import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.annotations.ExtensionPoint;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.notedb.ChangeNotes;
+import java.time.Instant;
import java.util.Map;
import java.util.Optional;
@@ -28,6 +29,7 @@ public interface OnPostReview {
* Allows implementors to return a message that should be included into the change message that is
* posted on post review.
*
+ * @param when the timestamp at which the review is posted
* @param user the user that posts the review
* @param changeNotes the change on which post review is performed
* @param patchSet the patch set on which post review is performed
@@ -37,6 +39,7 @@ public interface OnPostReview {
* {@link Optional#empty()} if the change message should not be extended
*/
default Optional<String> getChangeMessageAddOn(
+ Instant when,
IdentifiedUser user,
ChangeNotes changeNotes,
PatchSet patchSet,
diff --git a/java/com/google/gerrit/server/restapi/change/PostHashtags.java b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
index bcaa14577d..a503edad7b 100644
--- a/java/com/google/gerrit/server/restapi/change/PostHashtags.java
+++ b/java/com/google/gerrit/server/restapi/change/PostHashtags.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.common.collect.ImmutableSortedSet;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
import com.google.gerrit.extensions.restapi.Response;
@@ -26,6 +28,7 @@ import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -46,13 +49,14 @@ public class PostHashtags
public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
throws RestApiException, UpdateException, PermissionBackendException {
req.permissions().check(ChangePermission.EDIT_HASHTAGS);
-
- try (BatchUpdate bu =
- updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
- SetHashtagsOp op = hashtagsFactory.create(input);
- bu.addOp(req.getId(), op);
- bu.execute();
- return Response.ok(op.getUpdatedHashtags());
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+ SetHashtagsOp op = hashtagsFactory.create(input);
+ bu.addOp(req.getId(), op);
+ bu.execute();
+ return Response.ok(op.getUpdatedHashtags());
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/PostPrivate.java b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
index 45d7250d9a..56b81b8771 100644
--- a/java/com/google/gerrit/server/restapi/change/PostPrivate.java
+++ b/java/com/google/gerrit/server/restapi/change/PostPrivate.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.common.InputWithMessage;
@@ -33,6 +34,7 @@ import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -74,8 +76,11 @@ public class PostPrivate
}
SetPrivateOp op = setPrivateOpFactory.create(true, input);
- try (BatchUpdate u = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- u.addOp(rsrc.getId(), op).execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ u.addOp(rsrc.getId(), op).execute();
+ }
}
return Response.created();
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 7a6ac0d07a..99406376db 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -17,8 +17,9 @@ package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.AbstractLabelPermission.ForUser.ON_BEHALF_OF;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toList;
@@ -104,6 +105,7 @@ import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -156,7 +158,6 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
private final BatchUpdate.Factory updateFactory;
private final PostReviewOp.Factory postReviewOpFactory;
- private final PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory;
private final ChangeResource.Factory changeResourceFactory;
private final ChangeData.Factory changeDataFactory;
private final AccountCache accountCache;
@@ -180,7 +181,6 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
PostReview(
BatchUpdate.Factory updateFactory,
PostReviewOp.Factory postReviewOpFactory,
- PostReviewCopyApprovalsOpFactory postReviewCopyApprovalsOpFactory,
ChangeResource.Factory changeResourceFactory,
ChangeData.Factory changeDataFactory,
AccountCache accountCache,
@@ -200,7 +200,6 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
ReviewerAdded reviewerAdded) {
this.updateFactory = updateFactory;
this.postReviewOpFactory = postReviewOpFactory;
- this.postReviewCopyApprovalsOpFactory = postReviewCopyApprovalsOpFactory;
this.changeResourceFactory = changeResourceFactory;
this.changeDataFactory = changeDataFactory;
this.accountCache = accountCache;
@@ -305,93 +304,93 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
// Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
-
- try (BatchUpdate bu =
- updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
- bu.setNotify(notify);
-
- Account account = revision.getUser().asIdentifiedUser().getAccount();
- boolean ccOrReviewer = false;
- if (input.labels != null && !input.labels.isEmpty()) {
- ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
- if (ccOrReviewer) {
- logger.atFine().log("calling user is cc/reviewer on the change due to voting on a label");
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
+ bu.setNotify(notify);
+
+ Account account = revision.getUser().asIdentifiedUser().getAccount();
+ boolean ccOrReviewer = false;
+ if (input.labels != null && !input.labels.isEmpty()) {
+ ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
+ if (ccOrReviewer) {
+ logger.atFine().log(
+ "calling user is cc/reviewer on the change due to voting on a label");
+ }
}
- }
- if (!ccOrReviewer) {
- // Check if user was already CCed or reviewing prior to this review.
- ReviewerSet currentReviewers =
- approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
- ccOrReviewer = currentReviewers.all().contains(account.id());
- if (ccOrReviewer) {
- logger.atFine().log("calling user is already cc/reviewer on the change");
+ if (!ccOrReviewer) {
+ // Check if user was already CCed or reviewing prior to this review.
+ ReviewerSet currentReviewers =
+ approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
+ ccOrReviewer = currentReviewers.all().contains(account.id());
+ if (ccOrReviewer) {
+ logger.atFine().log("calling user is already cc/reviewer on the change");
+ }
}
- }
- // Apply reviewer changes first. Revision emails should be sent to the
- // updated set of reviewers. Also keep track of whether the user added
- // themselves as a reviewer or to the CC list.
- logger.atFine().log("adding reviewer additions");
- for (ReviewerModification reviewerResult : reviewerResults) {
- reviewerResult.op.suppressEmail(); // Send a single batch email below.
- reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
- bu.addOp(revision.getChange().getId(), reviewerResult.op);
- if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
- logger.atFine().log("calling user is explicitly added as reviewer or CC");
- ccOrReviewer = true;
+ // Apply reviewer changes first. Revision emails should be sent to the
+ // updated set of reviewers. Also keep track of whether the user added
+ // themselves as a reviewer or to the CC list.
+ logger.atFine().log("adding reviewer additions");
+ for (ReviewerModification reviewerResult : reviewerResults) {
+ reviewerResult.op.suppressEmail(); // Send a single batch email below.
+ reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
+ bu.addOp(revision.getChange().getId(), reviewerResult.op);
+ if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
+ logger.atFine().log("calling user is explicitly added as reviewer or CC");
+ ccOrReviewer = true;
+ }
}
- }
- if (!ccOrReviewer) {
- // User posting this review isn't currently in the reviewer or CC list,
- // isn't being explicitly added, and isn't voting on any label.
- // Automatically CC them on this change so they receive replies.
- logger.atFine().log("CCing calling user");
- ReviewerModification selfAddition =
- reviewerModifier.ccCurrentUser(revision.getUser(), revision);
- selfAddition.op.suppressEmail();
- selfAddition.op.suppressEvent();
- bu.addOp(revision.getChange().getId(), selfAddition.op);
- }
-
- // Add WorkInProgressOp if requested.
- if ((input.ready || input.workInProgress)
- && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
- if (input.ready && input.workInProgress) {
- output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
- return Response.withStatusCode(SC_BAD_REQUEST, output);
+ if (!ccOrReviewer) {
+ // User posting this review isn't currently in the reviewer or CC list,
+ // isn't being explicitly added, and isn't voting on any label.
+ // Automatically CC them on this change so they receive replies.
+ logger.atFine().log("CCing calling user");
+ ReviewerModification selfAddition =
+ reviewerModifier.ccCurrentUser(revision.getUser(), revision);
+ selfAddition.op.suppressEmail();
+ selfAddition.op.suppressEvent();
+ bu.addOp(revision.getChange().getId(), selfAddition.op);
}
- revision
- .getChangeResource()
- .permissions()
- .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
-
- if (input.ready) {
- output.ready = true;
+ // Add WorkInProgressOp if requested.
+ if ((input.ready || input.workInProgress)
+ && didWorkInProgressChange(revision.getChange().isWorkInProgress(), input)) {
+ if (input.ready && input.workInProgress) {
+ output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+ return Response.withStatusCode(SC_BAD_REQUEST, output);
+ }
+
+ revision
+ .getChangeResource()
+ .permissions()
+ .check(ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE);
+
+ if (input.ready) {
+ output.ready = true;
+ }
+
+ logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
+ WorkInProgressOp wipOp =
+ workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
+ wipOp.suppressEmail();
+ bu.addOp(revision.getChange().getId(), wipOp);
}
- logger.atFine().log("setting work-in-progress to %s", input.workInProgress);
- WorkInProgressOp wipOp =
- workInProgressOpFactory.create(input.workInProgress, new WorkInProgressOp.Input());
- wipOp.suppressEmail();
- bu.addOp(revision.getChange().getId(), wipOp);
+ // Add the review ops.
+ logger.atFine().log("posting review");
+ PostReviewOp postReviewOp =
+ postReviewOpFactory.create(
+ projectState, revision.getPatchSet().id(), input, revision.getAccountId());
+ bu.addOp(revision.getChange().getId(), postReviewOp);
+
+ // Adjust the attention set based on the input
+ replyAttentionSetUpdates.updateAttentionSet(
+ bu, revision.getNotes(), input, revision.getUser());
+ bu.execute();
}
-
- // Add the review ops.
- logger.atFine().log("posting review");
- PostReviewOp postReviewOp =
- postReviewOpFactory.create(projectState, revision.getPatchSet().id(), input);
- bu.addOp(revision.getChange().getId(), postReviewOp);
- bu.addOp(
- revision.getChange().getId(),
- postReviewCopyApprovalsOpFactory.create(revision.getPatchSet().id()));
-
- // Adjust the attention set based on the input
- replyAttentionSetUpdates.updateAttentionSet(
- bu, revision.getNotes(), input, revision.getUser());
- bu.execute();
}
// Re-read change to take into account results of the update.
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
deleted file mode 100644
index 88d2d7b7f4..0000000000
--- a/java/com/google/gerrit/server/restapi/change/PostReviewCopyApprovalsOp.java
+++ /dev/null
@@ -1,236 +0,0 @@
-// Copyright (C) 2022 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.auto.factory.AutoFactory;
-import com.google.auto.factory.Provided;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableTable;
-import com.google.common.collect.Table.Cell;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.approval.ApprovalCopier;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import java.io.IOException;
-import java.util.Optional;
-
-/**
- * Batch update operation that copy approvals that have been newly applied on outdated patch sets to
- * the follow-up patch sets if they are copyable and no non-copied approvals prevent the copying.
- *
- * <p>Must be invoked after the batch update operation which applied new approvals on outdated patch
- * sets (e.g. after {@link PostReviewOp}.
- */
-@AutoFactory
-public class PostReviewCopyApprovalsOp implements BatchUpdateOp {
- private final ApprovalCopier approvalCopier;
- private final PatchSetUtil patchSetUtil;
- private final PatchSet.Id patchSetId;
-
- private ChangeContext ctx;
- private ImmutableList<PatchSet.Id> followUpPatchSets;
-
- PostReviewCopyApprovalsOp(
- @Provided ApprovalCopier approvalCopier,
- @Provided PatchSetUtil patchSetUtil,
- PatchSet.Id patchSetId) {
- this.approvalCopier = approvalCopier;
- this.patchSetUtil = patchSetUtil;
- this.patchSetId = patchSetId;
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws IOException {
- if (ctx.getNotes().getCurrentPatchSet().id().equals(patchSetId)) {
- // the updated patch set is the current patch, there a no follow-up patch set to which new
- // approvals could be copied
- return false;
- }
-
- init(ctx);
-
- boolean dirty = false;
- ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
- ctx.getUpdate(patchSetId).getApprovals();
- for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
- String label = cell.getRowKey();
- Account.Id approverId = cell.getColumnKey();
- PatchSetApproval.Key psaKey =
- PatchSetApproval.key(patchSetId, approverId, LabelId.create(label));
-
- if (isRemoval(cell)) {
- if (removeCopies(psaKey)) {
- dirty = true;
- }
- continue;
- }
-
- PatchSet patchSet = patchSetUtil.get(ctx.getNotes(), patchSetId);
- PatchSetApproval psaOrig = cell.getValue().get();
-
- // Target patch sets to which the approval is copyable.
- ImmutableList<PatchSet.Id> targetPatchSets =
- approvalCopier.forApproval(
- ctx.getNotes(),
- patchSet,
- psaKey.accountId(),
- psaKey.labelId().get(),
- psaOrig.value());
-
- // Iterate over all follow-up patch sets, in patch set order.
- for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
- if (hasOverrideOf(followUpPatchSetId, psaKey)) {
- // a non-copied approval exists that overrides any copied approval
- // -> do not copy the approval to this patch set nor to any follow-up patch sets
- break;
- }
-
- if (targetPatchSets.contains(followUpPatchSetId)) {
- // The approval is copyable to the new patch set.
-
- if (hasCopyOfWithValue(followUpPatchSetId, psaKey, psaOrig.value())) {
- // a copy approval with the exact value already exists
- continue;
- }
-
- // add/update the copied approval on the target patch set
- PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
- ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
- dirty = true;
- } else {
- // The approval is not copyable to the new patch set.
-
- if (hasCopyOf(followUpPatchSetId, psaKey)) {
- // a copy approval exists and should be removed
- removeCopy(followUpPatchSetId, psaKey);
- dirty = true;
- }
- }
- }
- }
-
- return dirty;
- }
-
- private void init(ChangeContext ctx) {
- this.ctx = ctx;
-
- // compute follow-up patch sets (sorted by patch set ID)
- this.followUpPatchSets =
- ctx.getNotes().getPatchSets().keySet().stream()
- .filter(psId -> psId.get() > patchSetId.get())
- .collect(toImmutableList());
- }
-
- /**
- * Whether the given cell entry from the approval table represents the removal of an approval.
- *
- * @param cell cell entry from the approval table
- * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
- * otherwise {@code false}
- */
- private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
- return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
- }
-
- /**
- * Removes copies of the given approval from all follow-up patch sets.
- *
- * @param psaKey the key of the patch set approval for which copies should be removed from all
- * follow-up patch sets
- * @return whether any copy approval has been removed
- */
- private boolean removeCopies(PatchSetApproval.Key psaKey) {
- boolean dirty = false;
- for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
- if (hasCopyOf(followUpPatchSet, psaKey)) {
- removeCopy(followUpPatchSet, psaKey);
- } else {
- // Do not remove copy from this follow-up patch sets and also not from any further follow-up
- // patch sets (if the further follow-up patch sets have copies they are copies of a
- // non-copied approval on this follow-up patch set and hence those should not be removed).
- break;
- }
- }
- return dirty;
- }
-
- /**
- * Removes the copy approval with the given key from the given patch set.
- *
- * @param patchSet patch set from which the copy approval with the given key should be removed
- * @param psaKey the key of the patch set approval for which copies should be removed from the
- * given patch set
- */
- private void removeCopy(PatchSet.Id patchSet, PatchSetApproval.Key psaKey) {
- ctx.getUpdate(patchSet)
- .removeCopiedApprovalFor(
- ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
- ? ctx.getIdentifiedUser().getRealUser().getAccountId()
- : null,
- psaKey.accountId(),
- psaKey.labelId().get());
- }
-
- /**
- * Whether the given patch set has a copy approval with the given key.
- *
- * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
- * approval with the given key
- * @param psaKey the key of the patch set approval
- */
- private boolean hasCopyOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
- return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
- .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
- }
-
- /**
- * Whether the given patch set has a copy approval with the given key and value.
- *
- * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
- * approval with the given key and value
- * @param psaKey the key of the patch set approval
- */
- private boolean hasCopyOfWithValue(
- PatchSet.Id patchSetId, PatchSetApproval.Key psaKey, short value) {
- return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
- .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey) && psa.value() == value);
- }
-
- /**
- * Whether the given patch set has a normal approval with the given key that overrides copy
- * approvals with that key.
- *
- * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
- * approval with the given key that overrides copy approvals with that key
- * @param psaKey the key of the patch set approval
- */
- private boolean hasOverrideOf(PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
- return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
- .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
- }
-
- private boolean areAccountAndLabelTheSame(
- PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
- return psaKey1.accountId().equals(psaKey2.accountId())
- && psaKey1.labelId().equals(psaKey2.labelId());
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
index 9274f5246b..a8f8adf009 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewOp.java
@@ -18,15 +18,22 @@ import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
+import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SortedSetMultimap;
import com.google.common.collect.Streams;
+import com.google.common.collect.Table.Cell;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.entities.FixSuggestion;
@@ -55,9 +62,9 @@ import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.PublishCommentUtil;
+import com.google.gerrit.server.approval.ApprovalCopier;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.EmailReviewComments;
-import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.logging.Metadata;
@@ -90,12 +97,89 @@ import org.eclipse.jgit.lib.Config;
public class PostReviewOp implements BatchUpdateOp {
interface Factory {
- PostReviewOp create(ProjectState projectState, PatchSet.Id psId, ReviewInput in);
+ PostReviewOp create(
+ ProjectState projectState, PatchSet.Id psId, ReviewInput in, Account.Id reviewerId);
+ }
+
+ /**
+ * Update of a copied label that has been performed on a follow-up patch set after a vote has been
+ * applied on an outdated patch set (follow-up patch sets = all patch sets that are newer than the
+ * outdated patch set on which the user voted).
+ */
+ @AutoValue
+ abstract static class CopiedLabelUpdate {
+ /**
+ * Type of the update that has been performed for a copied vote on a follow-up patch set.
+ *
+ * <p>Whether the copied vote has been added
+ *
+ * <ul>
+ * <li>added to
+ * <li>updated on
+ * <li>removed from
+ * </ul>
+ *
+ * a follow-up patch set.
+ */
+ enum Type {
+ /** A copied vote was added. No copied vote existed for this label yet. */
+ ADDED,
+
+ /** An existing copied vote has been updated. */
+ UPDATED,
+
+ /** An existing copied vote has been removed. */
+ REMOVED;
+ }
+
+ /** The ID of the (follow-up) patch set on which the copied label update has been performed. */
+ abstract PatchSet.Id patchSetId();
+
+ /**
+ * The old copied label vote that has been updated or that has been removed.
+ *
+ * <p>Not set if {@link #type()} is {@link Type#ADDED}.
+ */
+ abstract Optional<LabelVote> oldLabelVote();
+
+ /**
+ * The type of the update that has been performed for the copied vote on the (follow-up) patch
+ * set.
+ */
+ abstract Type type();
+
+ /** Returns a string with the patch set number and if present the old label vote. */
+ private String formatPatchSetWithOldLabelVote() {
+ StringBuilder b = new StringBuilder();
+ b.append(patchSetId().get());
+ if (oldLabelVote().isPresent()) {
+ b.append(" (was ").append(oldLabelVote().get().format()).append(")");
+ }
+ return b.toString();
+ }
+
+ private static CopiedLabelUpdate added(PatchSet.Id patchSetId) {
+ return create(patchSetId, Optional.empty(), Type.ADDED);
+ }
+
+ private static CopiedLabelUpdate updated(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+ return create(patchSetId, Optional.of(oldLabelVote), Type.UPDATED);
+ }
+
+ private static CopiedLabelUpdate removed(PatchSet.Id patchSetId, LabelVote oldLabelVote) {
+ return create(patchSetId, Optional.of(oldLabelVote), Type.REMOVED);
+ }
+
+ private static CopiedLabelUpdate create(
+ PatchSet.Id patchSetId, Optional<LabelVote> oldLabelVote, Type type) {
+ return new AutoValue_PostReviewOp_CopiedLabelUpdate(patchSetId, oldLabelVote, type);
+ }
}
@VisibleForTesting
public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+ private final ApprovalCopier approvalCopier;
private final ApprovalsUtil approvalsUtil;
private final ChangeMessagesUtil cmUtil;
private final CommentsUtil commentsUtil;
@@ -109,6 +193,7 @@ public class PostReviewOp implements BatchUpdateOp {
private final ProjectState projectState;
private final PatchSet.Id psId;
private final ReviewInput in;
+ private final Account.Id reviewerId;
private final boolean publishPatchSetLevelComment;
private IdentifiedUser user;
@@ -117,12 +202,15 @@ public class PostReviewOp implements BatchUpdateOp {
private String mailMessage;
private List<Comment> comments = new ArrayList<>();
private List<LabelVote> labelDelta = new ArrayList<>();
+ private SortedSetMultimap<LabelVote, CopiedLabelUpdate> labelUpdatesOnFollowUpPatchSets =
+ MultimapBuilder.hashKeys().treeSetValues(comparing(CopiedLabelUpdate::patchSetId)).build();
private Map<String, Short> approvals = new HashMap<>();
private Map<String, Short> oldApprovals = new HashMap<>();
@Inject
PostReviewOp(
@GerritServerConfig Config gerritConfig,
+ ApprovalCopier approvalCopier,
ApprovalsUtil approvalsUtil,
ChangeMessagesUtil cmUtil,
CommentsUtil commentsUtil,
@@ -134,7 +222,9 @@ public class PostReviewOp implements BatchUpdateOp {
PluginSetContext<OnPostReview> onPostReviews,
@Assisted ProjectState projectState,
@Assisted PatchSet.Id psId,
- @Assisted ReviewInput in) {
+ @Assisted ReviewInput in,
+ @Assisted Account.Id reviewerId) {
+ this.approvalCopier = approvalCopier;
this.approvalsUtil = approvalsUtil;
this.publishCommentUtil = publishCommentUtil;
this.psUtil = psUtil;
@@ -150,6 +240,7 @@ public class PostReviewOp implements BatchUpdateOp {
this.projectState = projectState;
this.psId = psId;
this.in = in;
+ this.reviewerId = reviewerId;
}
@Override
@@ -171,6 +262,9 @@ public class PostReviewOp implements BatchUpdateOp {
try (TraceContext.TraceTimer ignored = newTimer("updateLabels")) {
dirty |= updateLabels(projectState, ctx);
}
+ try (TraceContext.TraceTimer ignored = newTimer("updateCopiedApprovals")) {
+ dirty |= updateCopiedApprovalsOnFollowUpPatchSets(ctx);
+ }
try (TraceContext.TraceTimer ignored = newTimer("insertMessage")) {
dirty |= insertMessage(ctx);
}
@@ -182,12 +276,9 @@ public class PostReviewOp implements BatchUpdateOp {
if (mailMessage == null) {
return;
}
- NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
- if (notify.shouldNotify()) {
- email
- .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
- .sendAsync();
- }
+ email
+ .create(ctx, ps, notes.getMetaId(), mailMessage, comments, in.message, labelDelta)
+ .sendAsync();
String comment = mailMessage;
if (publishPatchSetLevelComment) {
// TODO(davido): Remove this workaround when patch set level comments are exposed in comment
@@ -558,10 +649,11 @@ public class PostReviewOp implements BatchUpdateOp {
del.add(c);
update.putApproval(normName, (short) 0);
}
- // Only allow voting again if the vote is copied over from a past patch-set, or the
- // values are different.
+ // Only allow voting again the values are different, if the real account differs or if the
+ // vote is copied over from a past patch-set.
} else if (c != null
&& (c.value() != ent.getValue()
+ || !c.realAccountId().equals(reviewerId)
|| (inLabels.containsKey(c.label()) && isApprovalCopiedOver(c, ctx.getNotes())))) {
PatchSetApproval.Builder b =
c.toBuilder()
@@ -705,12 +797,226 @@ public class PostReviewOp implements BatchUpdateOp {
return current;
}
+ /**
+ * Copies approvals that have been newly applied on outdated patch sets to the follow-up patch
+ * sets if they are copyable and no non-copied approvals prevent the copying.
+ *
+ * <p>Must be invoked after the new approvals on outdated patch sets have been applied (e.g. after
+ * {@link #updateLabels(ProjectState, ChangeContext)}.
+ *
+ * @param ctx the change context
+ * @return {@code true} if an update was done, otherwise {@code false}
+ */
+ private boolean updateCopiedApprovalsOnFollowUpPatchSets(ChangeContext ctx) throws IOException {
+ if (ctx.getNotes().getCurrentPatchSet().id().equals(psId)) {
+ // the updated patch set is the current patch, there a no follow-up patch set to which new
+ // approvals could be copied
+ return false;
+ }
+
+ // compute follow-up patch sets (sorted by patch set ID)
+ ImmutableList<PatchSet.Id> followUpPatchSets =
+ ctx.getNotes().getPatchSets().keySet().stream()
+ .filter(patchSetId -> patchSetId.get() > psId.get())
+ .collect(toImmutableList());
+
+ boolean dirty = false;
+ ImmutableTable<String, Account.Id, Optional<PatchSetApproval>> newApprovals =
+ ctx.getUpdate(psId).getApprovals();
+ for (Cell<String, Account.Id, Optional<PatchSetApproval>> cell : newApprovals.cellSet()) {
+ PatchSetApproval psaOrig = cell.getValue().get();
+
+ if (isRemoval(cell)) {
+ if (removeCopies(ctx, followUpPatchSets, psaOrig)) {
+ dirty = true;
+ }
+ continue;
+ }
+
+ PatchSet patchSet = psUtil.get(ctx.getNotes(), psId);
+
+ // Target patch sets to which the approval is copyable.
+ ImmutableList<PatchSet.Id> targetPatchSets =
+ approvalCopier.forApproval(
+ ctx.getNotes(), patchSet, psaOrig.accountId(), psaOrig.label(), psaOrig.value());
+
+ // Iterate over all follow-up patch sets, in patch set order.
+ for (PatchSet.Id followUpPatchSetId : followUpPatchSets) {
+ if (hasOverrideOf(ctx, followUpPatchSetId, psaOrig.key())) {
+ // a non-copied approval exists that overrides any copied approval
+ // -> do not copy the approval to this patch set nor to any follow-up patch sets
+ break;
+ }
+
+ if (targetPatchSets.contains(followUpPatchSetId)) {
+ // The approval is copyable to the new patch set.
+
+ if (hasCopyOfWithValue(ctx, followUpPatchSetId, psaOrig)) {
+ // a copy approval with the exact value already exists
+ continue;
+ }
+
+ // add/update the copied approval on the target patch set
+ Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+ PatchSetApproval copiedPatchSetApproval = psaOrig.copyWithPatchSet(followUpPatchSetId);
+ ctx.getUpdate(followUpPatchSetId).putCopiedApproval(copiedPatchSetApproval);
+ labelUpdatesOnFollowUpPatchSets.put(
+ LabelVote.createFrom(psaOrig),
+ copiedPsa.isPresent()
+ ? CopiedLabelUpdate.updated(
+ followUpPatchSetId, LabelVote.createFrom(copiedPsa.get()))
+ : CopiedLabelUpdate.added(followUpPatchSetId));
+ dirty = true;
+ } else {
+ // The approval is not copyable to the new patch set.
+ Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSetId, psaOrig.key());
+ if (copiedPsa.isPresent()) {
+ // a copy approval exists and should be removed
+ removeCopy(ctx, psaOrig, copiedPsa.get());
+ dirty = true;
+ }
+ }
+ }
+ }
+
+ return dirty;
+ }
+
+ /**
+ * Whether the given cell entry from the approval table represents the removal of an approval.
+ *
+ * @param cell cell entry from the approval table
+ * @return {@code true} if the approval is not set or the approval has {@code 0} as the value,
+ * otherwise {@code false}
+ */
+ private boolean isRemoval(Cell<String, Account.Id, Optional<PatchSetApproval>> cell) {
+ return cell.getValue().isEmpty() || cell.getValue().get().value() == 0;
+ }
+
+ /**
+ * Removes copies of the given approval from all follow-up patch sets.
+ *
+ * @param ctx the change context
+ * @param followUpPatchSets the follow-up patch sets of the patch set on which the review is
+ * posted
+ * @param psaOrig the original patch set approval for which copies should be removed from all
+ * follow-up patch sets
+ * @return whether any copy approval has been removed
+ */
+ private boolean removeCopies(
+ ChangeContext ctx, ImmutableList<PatchSet.Id> followUpPatchSets, PatchSetApproval psaOrig) {
+ boolean dirty = false;
+ for (PatchSet.Id followUpPatchSet : followUpPatchSets) {
+ Optional<PatchSetApproval> copiedPsa = getCopyOf(ctx, followUpPatchSet, psaOrig.key());
+ if (copiedPsa.isPresent()) {
+ removeCopy(ctx, psaOrig, copiedPsa.get());
+ } else {
+ // Do not remove copy from this follow-up patch sets and also not from any further follow-up
+ // patch sets (if the further follow-up patch sets have copies they are copies of a
+ // non-copied approval on this follow-up patch set and hence those should not be removed).
+ break;
+ }
+ }
+ return dirty;
+ }
+
+ /**
+ * Removes the copy approval with the given key from the given patch set.
+ *
+ * @param ctx the change context
+ * @param psaOrig the original patch set approval for which copies should be removed from the
+ * given patch set
+ * @param copiedPsa the copied patch set approval that should be removed
+ */
+ private void removeCopy(ChangeContext ctx, PatchSetApproval psaOrig, PatchSetApproval copiedPsa) {
+ ctx.getUpdate(copiedPsa.patchSetId())
+ .removeCopiedApprovalFor(
+ ctx.getIdentifiedUser().getRealUser().isIdentifiedUser()
+ ? ctx.getIdentifiedUser().getRealUser().getAccountId()
+ : null,
+ copiedPsa.accountId(),
+ copiedPsa.labelId().get());
+ labelUpdatesOnFollowUpPatchSets.put(
+ LabelVote.createFrom(psaOrig),
+ CopiedLabelUpdate.removed(copiedPsa.patchSetId(), LabelVote.createFrom(copiedPsa)));
+ }
+
+ /**
+ * Retrieves the copy of the given approval from the given patch set if it exists.
+ *
+ * @param ctx the change context
+ * @param patchSetId the ID of the patch from which it the copied approval should be returned
+ * @param psaKey the key of the patch set approval for which the copied approval should be
+ * returned
+ * @return the copy of the given approval from the given patch set if it exists
+ */
+ private Optional<PatchSetApproval> getCopyOf(
+ ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+ return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+ .filter(psa -> areAccountAndLabelTheSame(psa.key(), psaKey))
+ .findAny();
+ }
+
+ /**
+ * Whether the given patch set has a copy approval with the given key and value.
+ *
+ * @param ctx the change context
+ * @param patchSetId the ID of the patch for which it should be checked whether it has a copy
+ * approval with the given key and value
+ * @param psaOrig the original patch set approval
+ */
+ private boolean hasCopyOfWithValue(
+ ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval psaOrig) {
+ return ctx.getNotes().getApprovals().onlyCopied().get(patchSetId).stream()
+ .anyMatch(
+ psa ->
+ areAccountAndLabelTheSame(psa.key(), psaOrig.key())
+ && psa.value() == psaOrig.value());
+ }
+
+ /**
+ * Whether the given patch set has a normal approval with the given key that overrides copy
+ * approvals with that key.
+ *
+ * @param ctx the change context
+ * @param patchSetId the ID of the patch for which it should be checked whether it has a normal
+ * approval with the given key that overrides copy approvals with that key
+ * @param psaKey the key of the patch set approval
+ */
+ private boolean hasOverrideOf(
+ ChangeContext ctx, PatchSet.Id patchSetId, PatchSetApproval.Key psaKey) {
+ return ctx.getNotes().getApprovals().onlyNonCopied().get(patchSetId).stream()
+ .anyMatch(psa -> areAccountAndLabelTheSame(psa.key(), psaKey));
+ }
+
+ private boolean areAccountAndLabelTheSame(
+ PatchSetApproval.Key psaKey1, PatchSetApproval.Key psaKey2) {
+ return psaKey1.accountId().equals(psaKey2.accountId())
+ && psaKey1.labelId().equals(psaKey2.labelId());
+ }
+
private boolean insertMessage(ChangeContext ctx) {
String msg = Strings.nullToEmpty(in.message).trim();
StringBuilder buf = new StringBuilder();
- for (LabelVote d : labelDelta) {
- buf.append(" ").append(d.format());
+ for (String formattedLabelVote :
+ labelDelta.stream().map(LabelVote::format).sorted().collect(toImmutableList())) {
+ buf.append(" ").append(formattedLabelVote);
+ }
+ if (!labelUpdatesOnFollowUpPatchSets.isEmpty()) {
+ buf.append("\n\nCopied votes on follow-up patch sets have been updated:");
+ for (Map.Entry<LabelVote, Collection<CopiedLabelUpdate>> e :
+ labelUpdatesOnFollowUpPatchSets.asMap().entrySet().stream()
+ .sorted(Map.Entry.comparingByKey(comparing(LabelVote::label)))
+ .collect(toImmutableList())) {
+ Optional<String> copyCondition =
+ projectState
+ .getLabelTypes(ctx.getNotes())
+ .byLabel(e.getKey().label())
+ .map(LabelType::getCopyCondition)
+ .map(Optional::get);
+ buf.append(formatVotesCopiedToFollowUpPatchSets(e.getKey(), e.getValue(), copyCondition));
+ }
}
if (comments.size() == 1) {
buf.append("\n\n(1 comment)");
@@ -729,7 +1035,8 @@ public class PostReviewOp implements BatchUpdateOp {
onPostReviews.runEach(
onPostReview ->
onPostReview
- .getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
+ .getChangeMessageAddOn(
+ ctx.getWhen(), user, ctx.getNotes(), ps, oldApprovals, approvals)
.ifPresent(
pluginMessage ->
pluginMessages.add(
@@ -748,6 +1055,88 @@ public class PostReviewOp implements BatchUpdateOp {
return true;
}
+ /**
+ * Given a label vote that has been applied on an outdated patch set, this method formats the
+ * updates to the copied labels on the follow-up patch sets that have been performed for that
+ * label vote.
+ *
+ * <p>If label votes have been copied to follow-up patch sets the formatted message is
+ * "<label-vote> has been copied to patch sets: 3, 4 (copy condition: "<copy-condition>").".
+ *
+ * <p>If existing copied votes on follow-up patch sets have been updated, the old copied votes are
+ * included into the message: "<label-vote> has been copied to patch sets: 3 (was
+ * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+ *
+ * <p>If existing copied votes on follow-up patch sets have been removed (because the new vote is
+ * not copyable) the message is: "Copied <label> vote has been removed from patch set 3 (was
+ * <old-label-vote>), 4 (was <old-label-vote>) (copy condition: "<copy-condition>").".
+ *
+ * <p>If copied votes have been both added/updated and removed, 2 messages are returned.
+ *
+ * <p>Each returned message is formatted as a list item (prefixed with '* ').
+ *
+ * <p>Passing atoms in copy conditions are not highlighted. This is because the passing atoms can
+ * be different for different follow-up patch sets (e.g. 'changekind:TRIVIAL_REBASE OR
+ * changekind:NO_CODE_CHANGE' can have 'changekind:TRIVIAL_REBASE' passing for one follow-up patch
+ * set and 'changekind:NO_CODE_CHANGE' passing for another follow-up patch set). Including the
+ * copy condition once per follow-up patch set with differently highlighted passing atoms would
+ * make the message unreadable. Hence we don't highlight passing atoms here.
+ *
+ * @param labelVote the label vote that has been applied on an outdated patch set
+ * @param followUpPatchSetUpdates updates to copied votes on follow-up patch sets that have been
+ * done by copying the label vote on the outdated patch set to follow-up patch sets
+ * @param copyCondition the copy condition of the label for which a vote was applied on an
+ * outdated patch set
+ * @return formatted string to be included into a change message
+ */
+ private String formatVotesCopiedToFollowUpPatchSets(
+ LabelVote labelVote,
+ Collection<CopiedLabelUpdate> followUpPatchSetUpdates,
+ Optional<String> copyCondition) {
+ StringBuilder b = new StringBuilder();
+
+ // Add line for added/updated copied approvals.
+ ImmutableList<CopiedLabelUpdate> additionsAndUpdates =
+ followUpPatchSetUpdates.stream()
+ .filter(
+ copiedLabelUpdate ->
+ copiedLabelUpdate.type() == CopiedLabelUpdate.Type.ADDED
+ || copiedLabelUpdate.type() == CopiedLabelUpdate.Type.UPDATED)
+ .collect(toImmutableList());
+ if (!additionsAndUpdates.isEmpty()) {
+ b.append("\n* ");
+ b.append(labelVote.format());
+ b.append(" has been copied to patch set ");
+ b.append(
+ additionsAndUpdates.stream()
+ .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+ .collect(joining(", ")));
+ copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+ b.append(".");
+ }
+
+ // Add line for removed copied approvals.
+ ImmutableList<CopiedLabelUpdate> removals =
+ followUpPatchSetUpdates.stream()
+ .filter(copiedLabelUpdate -> copiedLabelUpdate.type() == CopiedLabelUpdate.Type.REMOVED)
+ .collect(toImmutableList());
+ if (!removals.isEmpty()) {
+ b.append("\n* Copied ");
+ b.append(labelVote.label());
+ b.append(" vote has been removed from patch set ");
+ b.append(
+ removals.stream()
+ .map(CopiedLabelUpdate::formatPatchSetWithOldLabelVote)
+ .collect(joining(", ")));
+ b.append(" since the new ");
+ b.append(labelVote.value() != 0 ? labelVote.format() : labelVote.formatWithEquals());
+ b.append(" vote is not copyable");
+ copyCondition.ifPresent(cc -> b.append(" (copy condition: \"" + cc + "\")"));
+ b.append(".");
+ }
+ return b.toString();
+ }
+
private void addLabelDelta(String name, short value) {
labelDelta.add(LabelVote.create(name, value));
}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index 9bc80a4cf0..e46f9e466d 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.ReviewerInput;
@@ -31,6 +33,7 @@ import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -70,11 +73,14 @@ public class PostReviewers
if (modification.op == null) {
return Response.ok(modification.result);
}
- try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- bu.setNotify(resolveNotify(rsrc, input));
- Change.Id id = rsrc.getChange().getId();
- bu.addOp(id, modification.op);
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ bu.setNotify(resolveNotify(rsrc, input));
+ Change.Id id = rsrc.getChange().getId();
+ bu.addOp(id, modification.op);
+ bu.execute();
+ }
}
// Re-read change to take into account results of the update.
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
deleted file mode 100644
index d41620e6ba..0000000000
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.ReviewerInput;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.approval.ApprovalsUtil;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ReviewerModifier;
-import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
-import com.google.gerrit.server.change.SetAssigneeOp;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-@Singleton
-public class PutAssignee
- implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
-
- private final BatchUpdate.Factory updateFactory;
- private final AccountResolver accountResolver;
- private final SetAssigneeOp.Factory assigneeFactory;
- private final ReviewerModifier reviewerModifier;
- private final AccountLoader.Factory accountLoaderFactory;
- private final PermissionBackend permissionBackend;
- private final ApprovalsUtil approvalsUtil;
-
- @Inject
- PutAssignee(
- BatchUpdate.Factory updateFactory,
- AccountResolver accountResolver,
- SetAssigneeOp.Factory assigneeFactory,
- ReviewerModifier reviewerModifier,
- AccountLoader.Factory accountLoaderFactory,
- PermissionBackend permissionBackend,
- ApprovalsUtil approvalsUtil) {
- this.updateFactory = updateFactory;
- this.accountResolver = accountResolver;
- this.assigneeFactory = assigneeFactory;
- this.reviewerModifier = reviewerModifier;
- this.accountLoaderFactory = accountLoaderFactory;
- this.permissionBackend = permissionBackend;
- this.approvalsUtil = approvalsUtil;
- }
-
- @Override
- public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
- throws RestApiException, UpdateException, IOException, PermissionBackendException,
- ConfigInvalidException {
- rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
-
- input.assignee = Strings.nullToEmpty(input.assignee).trim();
- if (input.assignee.isEmpty()) {
- throw new BadRequestException("missing assignee field");
- }
-
- IdentifiedUser assignee = accountResolver.resolve(input.assignee).asUniqueUser();
- try {
- permissionBackend
- .absentUser(assignee.getAccountId())
- .change(rsrc.getNotes())
- .check(ChangePermission.READ);
- } catch (AuthException e) {
- throw new AuthException("read not permitted for " + input.assignee, e);
- }
-
- try (BatchUpdate bu =
- updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
- SetAssigneeOp op = assigneeFactory.create(assignee);
- bu.addOp(rsrc.getId(), op);
-
- ReviewerSet currentReviewers = approvalsUtil.getReviewers(rsrc.getNotes());
- if (!currentReviewers.all().contains(assignee.getAccountId())) {
- ReviewerModification reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
- reviewersAddition.op.suppressEmail();
- bu.addOp(rsrc.getId(), reviewersAddition.op);
- }
-
- bu.execute();
- return Response.ok(accountLoaderFactory.create(true).fillOne(assignee.getAccountId()));
- }
- }
-
- private ReviewerModification addAssigneeAsCC(ChangeResource rsrc, String assignee)
- throws IOException, PermissionBackendException, ConfigInvalidException {
- ReviewerInput reviewerInput = new ReviewerInput();
- reviewerInput.reviewer = assignee;
- reviewerInput.state = ReviewerState.CC;
- reviewerInput.confirmed = true;
- reviewerInput.notify = NotifyHandling.NONE;
- return reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
- }
-
- @Override
- public UiAction.Description getDescription(ChangeResource rsrc) {
- return new UiAction.Description()
- .setLabel("Edit Assignee")
- .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_ASSIGNEE));
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index 5b5bc15c5c..0d633db6b7 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.common.base.Strings;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.common.DescriptionInput;
@@ -31,6 +33,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -56,10 +59,12 @@ public class PutDescription
rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
Op op = new Op(input != null ? input : new DescriptionInput(), rsrc.getPatchSet().id());
- try (BatchUpdate u =
- updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
- u.addOp(rsrc.getChange().getId(), op);
- u.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u =
+ updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+ u.addOp(rsrc.getChange().getId(), op);
+ u.execute();
+ }
}
return Strings.isNullOrEmpty(op.newDescription)
? Response.none()
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index 64110873e2..681e1b1d25 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
@@ -36,6 +37,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -86,13 +88,15 @@ public class PutDraftComment implements RestModifyView<DraftCommentResource, Dra
throw new BadRequestException(
String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
}
- try (BatchUpdate bu =
- updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
- Op op = new Op(rsrc.getComment().key, in);
- bu.addOp(rsrc.getChange().getId(), op);
- bu.execute();
- return Response.ok(
- commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+ Op op = new Op(rsrc.getComment().key, in);
+ bu.addOp(rsrc.getChange().getId(), op);
+ bu.execute();
+ return Response.ok(
+ commentJson.get().setFillAccounts(false).newHumanCommentFormatter().format(op.comment));
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index f898dcacfb..41710a68ac 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -15,11 +15,13 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -33,6 +35,7 @@ import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
@@ -41,6 +44,7 @@ import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -70,6 +74,7 @@ public class PutMessage implements RestModifyView<ChangeResource, CommitMessageI
private final PatchSetUtil psUtil;
private final NotifyResolver notifyResolver;
private final ProjectCache projectCache;
+ private final DynamicItem<UrlFormatter> urlFormatter;
@Inject
PutMessage(
@@ -81,7 +86,8 @@ public class PutMessage implements RestModifyView<ChangeResource, CommitMessageI
@GerritPersonIdent PersonIdent gerritIdent,
PatchSetUtil psUtil,
NotifyResolver notifyResolver,
- ProjectCache projectCache) {
+ ProjectCache projectCache,
+ DynamicItem<UrlFormatter> urlFormatter) {
this.updateFactory = updateFactory;
this.repositoryManager = repositoryManager;
this.userProvider = userProvider;
@@ -91,6 +97,7 @@ public class PutMessage implements RestModifyView<ChangeResource, CommitMessageI
this.psUtil = psUtil;
this.notifyResolver = notifyResolver;
this.projectCache = projectCache;
+ this.urlFormatter = urlFormatter;
}
@Override
@@ -114,7 +121,8 @@ public class PutMessage implements RestModifyView<ChangeResource, CommitMessageI
.orElseThrow(illegalState(resource.getProject()))
.is(BooleanProjectConfig.REQUIRE_CHANGE_ID),
resource.getChange().getKey().get(),
- sanitizedCommitMessage);
+ sanitizedCommitMessage,
+ urlFormatter.get());
try (Repository repository = repositoryManager.openRepository(resource.getProject());
RevWalk revWalk = new RevWalk(repository);
@@ -127,21 +135,24 @@ public class PutMessage implements RestModifyView<ChangeResource, CommitMessageI
}
Instant ts = TimeUtil.now();
- try (BatchUpdate bu =
- updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
- // Ensure that BatchUpdate will update the same repo
- bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
-
- PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
- ObjectId newCommit =
- createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
- PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
- inserter.setMessage(
- String.format("Patch Set %s: Commit message was updated.", psId.getId()));
- inserter.setDescription("Edit commit message");
- bu.setNotify(resolveNotify(input, resource));
- bu.addOp(resource.getChange().getId(), inserter);
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(resource.getChange().getProject(), userProvider.get(), ts)) {
+ // Ensure that BatchUpdate will update the same repo
+ bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
+
+ PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.id());
+ ObjectId newCommit =
+ createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
+ PatchSetInserter inserter =
+ psInserterFactory.create(resource.getNotes(), psId, newCommit);
+ inserter.setMessage(
+ String.format("Patch Set %s: Commit message was updated.", psId.getId()));
+ inserter.setDescription("Edit commit message");
+ bu.setNotify(resolveNotify(input, resource));
+ bu.addOp(resource.getChange().getId(), inserter);
+ bu.execute();
+ }
}
}
return Response.ok("ok");
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index c9b436e1a9..b1e5d5a58a 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.common.base.Strings;
import com.google.gerrit.extensions.api.changes.TopicInput;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -28,6 +30,7 @@ import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -62,10 +65,12 @@ public class PutTopic
}
SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
- try (BatchUpdate u =
- updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
- u.addOp(req.getId(), op);
- u.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u =
+ updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.now())) {
+ u.addOp(req.getId(), op);
+ u.execute();
+ }
}
if (Strings.isNullOrEmpty(sanitizedInput.topic)) {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 5e30dae495..167f784ff6 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -15,53 +15,43 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
-import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.RebaseInput;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.RebaseChangeOp;
import com.google.gerrit.server.change.RebaseUtil;
-import com.google.gerrit.server.change.RebaseUtil.Base;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
@Singleton
@@ -72,156 +62,88 @@ public class Rebase
private final BatchUpdate.Factory updateFactory;
private final GitRepositoryManager repoManager;
- private final RebaseChangeOp.Factory rebaseFactory;
private final RebaseUtil rebaseUtil;
private final ChangeJson.Factory json;
private final PermissionBackend permissionBackend;
private final ProjectCache projectCache;
private final PatchSetUtil patchSetUtil;
+ private final RebaseMetrics rebaseMetrics;
@Inject
public Rebase(
BatchUpdate.Factory updateFactory,
GitRepositoryManager repoManager,
- RebaseChangeOp.Factory rebaseFactory,
RebaseUtil rebaseUtil,
ChangeJson.Factory json,
PermissionBackend permissionBackend,
ProjectCache projectCache,
- PatchSetUtil patchSetUtil) {
+ PatchSetUtil patchSetUtil,
+ RebaseMetrics rebaseMetrics) {
this.updateFactory = updateFactory;
this.repoManager = repoManager;
- this.rebaseFactory = rebaseFactory;
this.rebaseUtil = rebaseUtil;
this.json = json;
this.permissionBackend = permissionBackend;
this.projectCache = projectCache;
this.patchSetUtil = patchSetUtil;
+ this.rebaseMetrics = rebaseMetrics;
}
@Override
public Response<ChangeInfo> apply(RevisionResource rsrc, RebaseInput input)
throws UpdateException, RestApiException, IOException, PermissionBackendException {
- // Not allowed to rebase if the current patch set is locked.
- patchSetUtil.checkPatchSetNotLocked(rsrc.getNotes());
- rsrc.permissions().check(ChangePermission.REBASE);
+ if (input.onBehalfOfUploader && !rsrc.getPatchSet().uploader().equals(rsrc.getAccountId())) {
+ rsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+ rsrc = rebaseUtil.onBehalfOf(rsrc, input);
+ } else {
+ input.onBehalfOfUploader = false;
+ rsrc.permissions().check(ChangePermission.REBASE);
+ }
+
projectCache
.get(rsrc.getProject())
.orElseThrow(illegalState(rsrc.getProject()))
.checkStatePermitsWrite();
Change change = rsrc.getChange();
- try (Repository repo = repoManager.openRepository(change.getProject());
- ObjectInserter oi = repo.newObjectInserter();
- ObjectReader reader = oi.newReader();
- RevWalk rw = CodeReviewCommit.newRevWalk(reader);
- BatchUpdate bu =
- updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
- if (!change.isNew()) {
- throw new ResourceConflictException("change is " + ChangeUtil.status(change));
- } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
- throw new ResourceConflictException(
- "cannot rebase merge commits or commit with no ancestor");
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (Repository repo = repoManager.openRepository(change.getProject());
+ ObjectInserter oi = repo.newObjectInserter();
+ ObjectReader reader = oi.newReader();
+ RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+ BatchUpdate bu =
+ updateFactory.create(change.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ rebaseUtil.verifyRebasePreconditions(rw, rsrc.getNotes(), rsrc.getPatchSet());
+
+ RebaseChangeOp rebaseOp =
+ rebaseUtil.getRebaseOp(
+ rsrc,
+ input,
+ rebaseUtil.parseOrFindBaseRevision(repo, rw, permissionBackend, rsrc, input, true));
+
+ // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
+ bu.setNotify(NotifyResolver.Result.none());
+ bu.setRepository(repo, rw, oi);
+ bu.addOp(change.getId(), rebaseOp);
+ bu.execute();
+
+ rebaseMetrics.countRebase(input.onBehalfOfUploader, input.allowConflicts);
+
+ ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
+ changeInfo.containsGitConflicts =
+ !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+ return Response.ok(changeInfo);
}
- RebaseChangeOp rebaseOp =
- rebaseFactory
- .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
- .setForceContentMerge(true)
- .setAllowConflicts(input.allowConflicts)
- .setValidationOptions(getValidateOptionsAsMultimap(input.validationOptions))
- .setFireRevisionCreated(true);
- // TODO(dborowitz): Why no notification? This seems wrong; dig up blame.
- bu.setNotify(NotifyResolver.Result.none());
- bu.setRepository(repo, rw, oi);
- bu.addOp(change.getId(), rebaseOp);
- bu.execute();
-
- ChangeInfo changeInfo = json.create(OPTIONS).format(change.getProject(), change.getId());
- changeInfo.containsGitConflicts =
- !rebaseOp.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
- return Response.ok(changeInfo);
}
}
- private ObjectId findBaseRev(
- Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
- throws RestApiException, IOException, NoSuchChangeException, AuthException,
- PermissionBackendException {
- BranchNameKey destRefKey = rsrc.getChange().getDest();
- if (input == null || input.base == null) {
- return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
- }
-
- Change change = rsrc.getChange();
- String str = input.base.trim();
- if (str.equals("")) {
- // Remove existing dependency to other patch set.
- Ref destRef = repo.exactRef(destRefKey.branch());
- if (destRef == null) {
- throw new ResourceConflictException(
- "can't rebase onto tip of branch " + destRefKey.branch() + "; branch doesn't exist");
- }
- return destRef.getObjectId();
- }
-
- Base base;
- try {
- base = rebaseUtil.parseBase(rsrc, str);
- if (base == null) {
- throw new ResourceConflictException(
- "base revision is missing from the destination branch: " + str);
- }
- } catch (NoSuchChangeException e) {
- throw new UnprocessableEntityException(
- String.format("Base change not found: %s", input.base), e);
- }
-
- PatchSet.Id baseId = base.patchSet().id();
- if (change.getId().equals(baseId.changeId())) {
- throw new ResourceConflictException("cannot rebase change onto itself");
- }
-
- permissionBackend.user(rsrc.getUser()).change(base.notes()).check(ChangePermission.READ);
-
- Change baseChange = base.notes().getChange();
- if (!baseChange.getProject().equals(change.getProject())) {
- throw new ResourceConflictException(
- "base change is in wrong project: " + baseChange.getProject());
- } else if (!baseChange.getDest().equals(change.getDest())) {
- throw new ResourceConflictException(
- "base change is targeting wrong branch: " + baseChange.getDest());
- } else if (baseChange.isAbandoned()) {
- throw new ResourceConflictException("base change is abandoned: " + baseChange.getKey());
- } else if (isMergedInto(rw, rsrc.getPatchSet(), base.patchSet())) {
- throw new ResourceConflictException(
- "base change "
- + baseChange.getKey()
- + " is a descendant of the current change - recursion not allowed");
- }
- return base.patchSet().commitId();
- }
-
- private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
- ObjectId baseId = base.commitId();
- ObjectId tipId = tip.commitId();
- return rw.isMergedInto(rw.parseCommit(baseId), rw.parseCommit(tipId));
- }
-
- private boolean hasOneParent(RevWalk rw, PatchSet ps) throws IOException {
- // Prevent rebase of exotic changes (merge commit, no ancestor).
- RevCommit c = rw.parseCommit(ps.commitId());
- return c.getParentCount() == 1;
- }
-
@Override
public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
UiAction.Description description =
new UiAction.Description()
.setLabel("Rebase")
- .setTitle(
- "Rebase onto tip of branch or parent change. Makes you the uploader of this "
- + "change which can affect validity of approvals.")
+ .setTitle("Rebase onto tip of branch or parent change.")
.setVisible(false);
Change change = rsrc.getChange();
@@ -241,29 +163,23 @@ public class Rebase
boolean enabled = false;
try (Repository repo = repoManager.openRepository(change.getDest().project());
RevWalk rw = new RevWalk(repo)) {
- if (hasOneParent(rw, rsrc.getPatchSet())) {
+ if (RebaseUtil.hasOneParent(rw, rsrc.getPatchSet())) {
enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
}
}
- if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
- return description.setVisible(true).setEnabled(enabled);
- }
- return description;
- }
-
- private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
- @Nullable Map<String, String> validationOptions) {
- if (validationOptions == null) {
- return ImmutableListMultimap.of();
+ boolean canRebase = rsrc.permissions().testOrFalse(ChangePermission.REBASE);
+ boolean canRebaseOnBehalfOfUploader =
+ rsrc.permissions().testOrFalse(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+ if (canRebase || canRebaseOnBehalfOfUploader) {
+ return description
+ .setOption("rebase", canRebase)
+ .setOption("rebase_on_behalf_of_uploader", canRebaseOnBehalfOfUploader)
+ .setEnabled(enabled)
+ .setVisible(true);
}
- ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
- ImmutableListMultimap.builder();
- validationOptions
- .entrySet()
- .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
- return validationOptionsBuilder.build();
+ return description;
}
public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseChain.java b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
new file mode 100644
index 0000000000..343fb72922
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseChain.java
@@ -0,0 +1,334 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
+import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.RebaseUtil;
+import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Rest API for rebasing an ancestry chain of changes. */
+@Singleton
+public class RebaseChain
+ implements RestModifyView<ChangeResource, RebaseInput>, UiAction<ChangeResource> {
+ private static final ImmutableSet<ListChangesOption> OPTIONS =
+ Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
+
+ private final GitRepositoryManager repoManager;
+ private final RebaseUtil rebaseUtil;
+ private final GetRelatedChangesUtil getRelatedChangesUtil;
+ private final ChangeResource.Factory changeResourceFactory;
+ private final ChangeData.Factory changeDataFactory;
+ private final PermissionBackend permissionBackend;
+ private final BatchUpdate.Factory updateFactory;
+ private final ChangeNotes.Factory notesFactory;
+ private final ProjectCache projectCache;
+ private final PatchSetUtil patchSetUtil;
+ private final ChangeJson.Factory json;
+ private final RebaseMetrics rebaseMetrics;
+
+ @Inject
+ RebaseChain(
+ GitRepositoryManager repoManager,
+ RebaseUtil rebaseUtil,
+ GetRelatedChangesUtil getRelatedChangesUtil,
+ ChangeResource.Factory changeResourceFactory,
+ ChangeData.Factory changeDataFactory,
+ PermissionBackend permissionBackend,
+ BatchUpdate.Factory updateFactory,
+ ChangeNotes.Factory notesFactory,
+ ProjectCache projectCache,
+ PatchSetUtil patchSetUtil,
+ ChangeJson.Factory json,
+ RebaseMetrics rebaseMetrics) {
+ this.repoManager = repoManager;
+ this.getRelatedChangesUtil = getRelatedChangesUtil;
+ this.changeDataFactory = changeDataFactory;
+ this.rebaseUtil = rebaseUtil;
+ this.changeResourceFactory = changeResourceFactory;
+ this.permissionBackend = permissionBackend;
+ this.updateFactory = updateFactory;
+ this.notesFactory = notesFactory;
+ this.projectCache = projectCache;
+ this.patchSetUtil = patchSetUtil;
+ this.json = json;
+ this.rebaseMetrics = rebaseMetrics;
+ }
+
+ @Override
+ public Response<RebaseChainInfo> apply(ChangeResource tipRsrc, RebaseInput input)
+ throws IOException, PermissionBackendException, RestApiException, UpdateException {
+ if (input.onBehalfOfUploader) {
+ tipRsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+ if (input.allowConflicts) {
+ throw new BadRequestException(
+ "allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+ }
+ } else {
+ tipRsrc.permissions().check(ChangePermission.REBASE);
+ }
+
+ Project.NameKey project = tipRsrc.getProject();
+ projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite();
+
+ CurrentUser user = tipRsrc.getUser();
+
+ boolean anyRebaseOnBehalfOfUploader = false;
+ List<Change.Id> upToDateAncestors = new ArrayList<>();
+ Map<Change.Id, RebaseChangeOp> rebaseOps = new LinkedHashMap<>();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (Repository repo = repoManager.openRepository(project);
+ ObjectInserter oi = repo.newObjectInserter();
+ ObjectReader reader = oi.newReader();
+ RevWalk rw = CodeReviewCommit.newRevWalk(reader);
+ BatchUpdate bu = updateFactory.create(project, user, TimeUtil.now())) {
+ List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+
+ boolean ancestorsAreUpToDate = true;
+ for (int i = 0; i < chain.size(); i++) {
+ ChangeData changeData = chain.get(i).data();
+ PatchSet ps = patchSetUtil.current(changeData.notes());
+ if (ps == null) {
+ throw new IllegalStateException(
+ "current revision is missing for change " + changeData.getId());
+ }
+
+ RevisionResource revRsrc =
+ new RevisionResource(changeResourceFactory.create(changeData, user), ps);
+ if (input.onBehalfOfUploader
+ && !revRsrc.getPatchSet().uploader().equals(revRsrc.getAccountId())) {
+ revRsrc = rebaseUtil.onBehalfOf(revRsrc, input);
+ revRsrc.permissions().check(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER);
+ anyRebaseOnBehalfOfUploader = true;
+ } else {
+ revRsrc.permissions().check(ChangePermission.REBASE);
+ }
+ rebaseUtil.verifyRebasePreconditions(rw, changeData.notes(), ps);
+
+ boolean isUpToDate = false;
+ RebaseChangeOp rebaseOp = null;
+ if (i == 0) {
+ ObjectId desiredBase =
+ rebaseUtil.parseOrFindBaseRevision(
+ repo, rw, permissionBackend, revRsrc, input, false);
+ if (currentBase(rw, ps).equals(desiredBase)) {
+ isUpToDate = true;
+ } else {
+ rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, desiredBase);
+ }
+ } else {
+ if (ancestorsAreUpToDate) {
+ ObjectId latestCommittedBase =
+ PatchSetUtil.getCurrentCommittedRevCommit(
+ project, rw, notesFactory, chain.get(i - 1).id());
+ isUpToDate = currentBase(rw, ps).equals(latestCommittedBase);
+ }
+ if (!isUpToDate) {
+ rebaseOp = rebaseUtil.getRebaseOp(revRsrc, input, chain.get(i - 1).id());
+ }
+ }
+
+ if (isUpToDate) {
+ upToDateAncestors.add(changeData.getId());
+ continue;
+ }
+ ancestorsAreUpToDate = false;
+ bu.addOp(revRsrc.getChange().getId(), revRsrc.getUser(), rebaseOp);
+ rebaseOps.put(revRsrc.getChange().getId(), rebaseOp);
+ }
+
+ if (ancestorsAreUpToDate) {
+ throw new ResourceConflictException("The whole chain is already up to date.");
+ }
+
+ bu.setNotify(NotifyResolver.Result.none());
+ bu.setRepository(repo, rw, oi);
+ bu.execute();
+ }
+ }
+
+ rebaseMetrics.countRebaseChain(anyRebaseOnBehalfOfUploader, input.allowConflicts);
+
+ RebaseChainInfo res = new RebaseChainInfo();
+ res.rebasedChanges = new ArrayList<>();
+ ChangeJson changeJson = json.create(OPTIONS);
+ for (Change.Id c : upToDateAncestors) {
+ res.rebasedChanges.add(changeJson.format(project, c));
+ }
+ for (Map.Entry<Change.Id, RebaseChangeOp> e : rebaseOps.entrySet()) {
+ Change.Id id = e.getKey();
+ RebaseChangeOp op = e.getValue();
+ ChangeInfo changeInfo = changeJson.format(project, id);
+ changeInfo.containsGitConflicts =
+ !op.getRebasedCommit().getFilesWithGitConflicts().isEmpty() ? true : null;
+ res.rebasedChanges.add(changeInfo);
+ }
+ if (res.rebasedChanges.stream()
+ .anyMatch(i -> i.containsGitConflicts != null && i.containsGitConflicts)) {
+ res.containsGitConflicts = true;
+ }
+ return Response.ok(res);
+ }
+
+ @Override
+ public Description getDescription(ChangeResource tipRsrc) throws Exception {
+ UiAction.Description description =
+ new UiAction.Description()
+ .setLabel("Rebase Chain")
+ .setTitle(
+ "Rebase the ancestry chain onto the tip of the target branch. Makes you the "
+ + "uploader of the changes which can affect validity of approvals.")
+ .setVisible(false);
+
+ Change tip = tipRsrc.getChange();
+ if (!tip.isNew()) {
+ return description;
+ }
+ if (!projectCache
+ .get(tipRsrc.getProject())
+ .orElseThrow(illegalState(tipRsrc.getProject()))
+ .statePermitsWrite()) {
+ return description;
+ }
+
+ if (patchSetUtil.isPatchSetLocked(tipRsrc.getNotes())) {
+ return description;
+ }
+
+ boolean visible = true;
+ boolean enabled;
+ try (Repository repo = repoManager.openRepository(tipRsrc.getProject());
+ RevWalk rw = new RevWalk(repo)) {
+ List<PatchSetData> chain = getChainForCurrentPatchSet(tipRsrc);
+ if (chain.size() <= 1) {
+ return description;
+ }
+ PatchSetData oldestAncestor = chain.get(0);
+ enabled =
+ rebaseUtil.canRebase(
+ oldestAncestor.patchSet(), oldestAncestor.data().change().getDest(), repo, rw);
+
+ ImmutableList<RevisionResource> chainAsRevisionResources =
+ chain.stream()
+ .map(
+ ps ->
+ new RevisionResource(
+ changeResourceFactory.create(ps.data(), tipRsrc.getUser()),
+ ps.patchSet()))
+ .collect(toImmutableList());
+
+ boolean canRebase =
+ chainAsRevisionResources.stream()
+ .allMatch(psRsrc -> psRsrc.permissions().testOrFalse(ChangePermission.REBASE));
+ boolean canRebaseOnBehalfOfUploader =
+ chainAsRevisionResources.stream()
+ .allMatch(
+ psRsrc ->
+ psRsrc
+ .permissions()
+ .testOrFalse(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER));
+
+ if (!canRebase && !canRebaseOnBehalfOfUploader) {
+ visible = false;
+ } else {
+ for (RevisionResource psRsrc : chainAsRevisionResources) {
+ if (patchSetUtil.isPatchSetLocked(psRsrc.getNotes())
+ || !RebaseUtil.hasOneParent(rw, psRsrc.getPatchSet())) {
+ enabled = false;
+ break;
+ }
+ }
+ }
+
+ return description
+ .setVisible(visible)
+ .setOption("rebase", canRebase)
+ .setOption("rebase_on_behalf_of_uploader", canRebaseOnBehalfOfUploader)
+ .setEnabled(enabled);
+ }
+ }
+
+ private ObjectId currentBase(RevWalk rw, PatchSet ps) throws IOException {
+ return rw.parseCommit(ps.commitId()).getParent(0);
+ }
+
+ private List<PatchSetData> getChainForCurrentPatchSet(ChangeResource rsrc)
+ throws PermissionBackendException, IOException {
+ List<PatchSetData> ancestors =
+ Lists.reverse(
+ getRelatedChangesUtil.getAncestors(
+ changeDataFactory.create(rsrc.getNotes()),
+ patchSetUtil.current(rsrc.getNotes()),
+ true));
+ int eldestOpenAncestor = 0;
+ for (PatchSetData ps : ancestors) {
+ if (ps.data().change().isMerged()) {
+ eldestOpenAncestor++;
+ }
+ }
+ return ancestors.subList(eldestOpenAncestor, ancestors.size());
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java b/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java
new file mode 100644
index 0000000000..d6577eab00
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/RebaseMetrics.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.restapi.change;
+
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Metrics for the rebase REST endpoints ({@link Rebase} and {@link RebaseChain}). */
+@Singleton
+public class RebaseMetrics {
+ private final Counter3<Boolean, Boolean, Boolean> countRebases;
+
+ @Inject
+ public RebaseMetrics(MetricMaker metricMaker) {
+ this.countRebases =
+ metricMaker.newCounter(
+ "change/count_rebases",
+ new Description("Total number of rebases").setRate(),
+ Field.ofBoolean("on_behalf_of_uploader", (metadataBuilder, isOnBehalfOfUploader) -> {})
+ .description("Whether the rebase was done on behalf of the uploader.")
+ .build(),
+ Field.ofBoolean("rebase_chain", (metadataBuilder, isRebaseChain) -> {})
+ .description("Whether a chain was rebased.")
+ .build(),
+ Field.ofBoolean("allow_conflicts", (metadataBuilder, allow_conflicts) -> {})
+ .description("Whether the rebase was done with allowing conflicts.")
+ .build());
+ }
+
+ public void countRebase(boolean isOnBehalfOfUploader, boolean allowConflicts) {
+ countRebase(isOnBehalfOfUploader, /* isRebaseChain= */ false, allowConflicts);
+ }
+
+ public void countRebaseChain(boolean isOnBehalfOfUploader, boolean allowConflicts) {
+ countRebase(isOnBehalfOfUploader, /* isRebaseChain= */ true, allowConflicts);
+ }
+
+ private void countRebase(
+ boolean isOnBehalfOfUploader, boolean isRebaseChain, boolean allowConflicts) {
+ countRebases.increment(
+ /* field1= */ isOnBehalfOfUploader,
+ /* field2= */ isRebaseChain,
+ /* field3= */ allowConflicts);
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index bd3e8ec976..d761fa7e42 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.change;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+
import com.google.common.base.Strings;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -30,6 +32,7 @@ import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -79,16 +82,18 @@ public class RemoveFromAttentionSet
}
}
ChangeResource changeResource = attentionResource.getChangeResource();
- try (BatchUpdate bu =
- updateFactory.create(
- changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
- RemoveFromAttentionSetOp op =
- opFactory.create(attentionResource.getAccountId(), input.reason, true);
- bu.addOp(changeResource.getId(), op);
- NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
- NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
- bu.setNotify(notifyResult);
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(
+ changeResource.getProject(), changeResource.getUser(), TimeUtil.now())) {
+ RemoveFromAttentionSetOp op =
+ opFactory.create(attentionResource.getAccountId(), input.reason, true);
+ bu.addOp(changeResource.getId(), op);
+ NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+ NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+ bu.setNotify(notifyResult);
+ bu.execute();
+ }
}
return Response.none();
}
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index 3d9d5889c3..d21bc9a58f 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -199,8 +199,9 @@ public class ReplyAttentionSetUpdates {
}
return;
}
- // The rest of the conditions only apply if the change is ready for review.
- if (!readyForReview) {
+ // The rest of the conditions only apply if the change is ready for review and reply is not
+ // posted by a bot.
+ if (!readyForReview || serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
return;
}
@@ -259,10 +260,13 @@ public class ReplyAttentionSetUpdates {
/**
* Bots don't process automatic rules, the only attention set change they do is this rule: Add
- * owner and uploader when a bot votes negatively.
+ * owner and uploader when a bot votes negatively, but only if the change is open.
*/
private void botsWithNegativeLabelsAddOwnerAndUploader(
BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+ if (changeNotes.getChange().isClosed()) {
+ return;
+ }
if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
Account.Id owner = changeNotes.getChange().getOwner();
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 19d0677e01..6ac9c21184 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
@@ -47,6 +48,7 @@ import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -99,11 +101,13 @@ public class Restore
.checkStatePermitsWrite();
Op op = new Op(input);
- try (BatchUpdate u =
- updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
- u.addOp(rsrc.getId(), op).execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate u =
+ updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.now())) {
+ u.addOp(rsrc.getId(), op).execute();
+ }
+ return Response.ok(json.noOptions().format(op.change));
}
- return Response.ok(json.noOptions().format(op.change));
}
private class Op implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index 4e5027b75a..691fc7588a 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -19,6 +19,7 @@ import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
import static com.google.gerrit.server.permissions.ChangePermission.REVERT;
import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.util.Objects.requireNonNull;
import com.google.common.base.Strings;
@@ -42,7 +43,6 @@ import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.PatchSetUtil;
@@ -53,11 +53,8 @@ import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.change.WalkSorter;
import com.google.gerrit.server.change.WalkSorter.PatchSetData;
-import com.google.gerrit.server.extensions.events.ChangeReverted;
import com.google.gerrit.server.git.CommitUtil;
import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.mail.send.RevertedSender;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.permissions.ChangePermission;
@@ -73,8 +70,8 @@ import com.google.gerrit.server.restapi.change.CherryPickChange.Result;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.PostUpdateContext;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.CommitMessageUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -88,6 +85,7 @@ import java.util.Collection;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -115,17 +113,13 @@ public class RevertSubmission
private final ChangeJson.Factory json;
private final GitRepositoryManager repoManager;
private final WalkSorter sorter;
- private final ChangeMessagesUtil cmUtil;
private final CommitUtil commitUtil;
private final ChangeNotes.Factory changeNotesFactory;
- private final ChangeReverted changeReverted;
- private final RevertedSender.Factory revertedSenderFactory;
private final Sequences seq;
private final NotifyResolver notifyResolver;
private final BatchUpdate.Factory updateFactory;
private final ChangeResource.Factory changeResourceFactory;
private final GetRelated getRelated;
- private final MessageIdGenerator messageIdGenerator;
private CherryPickInput cherryPickInput;
private List<ChangeInfo> results;
@@ -145,17 +139,13 @@ public class RevertSubmission
ChangeJson.Factory json,
GitRepositoryManager repoManager,
WalkSorter sorter,
- ChangeMessagesUtil cmUtil,
CommitUtil commitUtil,
ChangeNotes.Factory changeNotesFactory,
- ChangeReverted changeReverted,
- RevertedSender.Factory revertedSenderFactory,
Sequences seq,
NotifyResolver notifyResolver,
BatchUpdate.Factory updateFactory,
ChangeResource.Factory changeResourceFactory,
- GetRelated getRelated,
- MessageIdGenerator messageIdGenerator) {
+ GetRelated getRelated) {
this.queryProvider = queryProvider;
this.user = user;
this.permissionBackend = permissionBackend;
@@ -166,17 +156,13 @@ public class RevertSubmission
this.json = json;
this.repoManager = repoManager;
this.sorter = sorter;
- this.cmUtil = cmUtil;
this.commitUtil = commitUtil;
this.changeNotesFactory = changeNotesFactory;
- this.changeReverted = changeReverted;
- this.revertedSenderFactory = revertedSenderFactory;
this.seq = seq;
this.notifyResolver = notifyResolver;
this.updateFactory = updateFactory;
this.changeResourceFactory = changeResourceFactory;
this.getRelated = getRelated;
- this.messageIdGenerator = messageIdGenerator;
results = new ArrayList<>();
cherryPickInput = null;
}
@@ -211,7 +197,8 @@ public class RevertSubmission
}
if (topic == null) {
return String.format(
- "revert-%s-%s", submissionId, RandomStringUtils.randomAlphabetic(10).toUpperCase());
+ "revert-%s-%s",
+ submissionId, RandomStringUtils.randomAlphabetic(10).toUpperCase(Locale.US));
}
return topic;
}
@@ -253,13 +240,11 @@ public class RevertSubmission
cherryPickInput = createCherryPickInput(revertInput);
Instant timestamp = TimeUtil.now();
+ String initialMessage = revertInput.message;
for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) {
cherryPickInput.base = null;
Project.NameKey project = projectAndBranch.project();
cherryPickInput.destination = projectAndBranch.branch();
- if (revertInput.workInProgress) {
- cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.OWNER);
- }
Collection<ChangeData> changesInProjectAndBranch =
changesPerProjectAndBranch.get(projectAndBranch);
@@ -273,6 +258,7 @@ public class RevertSubmission
.collect(Collectors.toSet());
revertAllChangesInProjectAndBranch(
+ initialMessage,
revertInput,
project,
sortedChangesInProjectAndBranch,
@@ -285,7 +271,9 @@ public class RevertSubmission
return revertSubmissionInfo;
}
+ // Warning: reuses and modifies revertInput.message.
private void revertAllChangesInProjectAndBranch(
+ String initialMessage,
RevertInput revertInput,
Project.NameKey project,
Iterator<PatchSetData> sortedChangesInProjectAndBranch,
@@ -293,8 +281,6 @@ public class RevertSubmission
Instant timestamp)
throws IOException, RestApiException, UpdateException, ConfigInvalidException,
PermissionBackendException {
-
- String initialMessage = revertInput.message;
while (sortedChangesInProjectAndBranch.hasNext()) {
ChangeNotes changeNotes = sortedChangesInProjectAndBranch.next().data().notes();
if (cherryPickInput.base == null) {
@@ -302,6 +288,7 @@ public class RevertSubmission
cherryPickInput.base = getBase(changeNotes, commitIdsInProjectAndBranch).name();
}
+ // Set revert message for the current revert change.
revertInput.message = getMessage(initialMessage, changeNotes);
if (cherryPickInput.base.equals(changeNotes.getCurrentPatchSet().commitId().getName())) {
// This is the code in case this is the first revert of this project + branch, and the
@@ -323,25 +310,26 @@ public class RevertSubmission
cherryPickInput.message = revertInput.message;
ObjectId generatedChangeId = CommitMessageUtil.generateChangeId();
Change.Id cherryPickRevertChangeId = Change.id(seq.nextChangeId());
- try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
- bu.setNotify(
- notifyResolver.resolve(
- firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
- cherryPickInput.notifyDetails));
- bu.addOp(
- changeNotes.getChange().getId(),
- new CreateCherryPickOp(
- revCommitId,
- generatedChangeId,
- cherryPickRevertChangeId,
- timestamp,
- revertInput.workInProgress));
- bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
- bu.addOp(
- cherryPickRevertChangeId,
- new NotifyOp(changeNotes.getChange(), cherryPickRevertChangeId));
-
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) {
+ bu.setNotify(
+ notifyResolver.resolve(
+ firstNonNull(cherryPickInput.notify, NotifyHandling.ALL),
+ cherryPickInput.notifyDetails));
+ bu.addOp(
+ changeNotes.getChange().getId(),
+ new CreateCherryPickOp(
+ revCommitId,
+ generatedChangeId,
+ cherryPickRevertChangeId,
+ timestamp,
+ revertInput.workInProgress));
+ if (!revertInput.workInProgress) {
+ commitUtil.addChangeRevertedNotificationOps(
+ bu, changeNotes.getChangeId(), cherryPickRevertChangeId, generatedChangeId.name());
+ }
+ bu.execute();
+ }
}
}
@@ -366,6 +354,9 @@ public class RevertSubmission
// change is created for the cherry-picked commit. Notifications are sent only for this change,
// but not for the intermediately created revert commit.
cherryPickInput.notify = revertInput.notify;
+ if (revertInput.workInProgress) {
+ cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.NONE);
+ }
cherryPickInput.notifyDetails = revertInput.notifyDetails;
cherryPickInput.parent = 1;
cherryPickInput.keepReviewers = true;
@@ -598,55 +589,4 @@ public class RevertSubmission
return true;
}
}
-
- private class NotifyOp implements BatchUpdateOp {
- private final Change change;
- private final Change.Id revertChangeId;
-
- NotifyOp(Change change, Change.Id revertChangeId) {
- this.change = change;
- this.revertChangeId = revertChangeId;
- }
-
- @Override
- public void postUpdate(PostUpdateContext ctx) throws Exception {
- changeReverted.fire(
- ctx.getChangeData(change),
- ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertChangeId)),
- ctx.getWhen());
- try {
- RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
- emailSender.setFrom(ctx.getAccountId());
- emailSender.setNotify(ctx.getNotify(change.getId()));
- emailSender.setMessageId(
- messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
- emailSender.send();
- } catch (Exception err) {
- logger.atSevere().withCause(err).log(
- "Cannot send email for revert change %s", change.getId());
- }
- }
- }
-
- /**
- * create a message that describes the revert if the cherry-pick is successful, and point the
- * revert of the change towards the cherry-pick. The cherry-pick is the updated change that acts
- * as "revert-of" the original change.
- */
- private class PostRevertedMessageOp implements BatchUpdateOp {
- private final ObjectId computedChangeId;
-
- PostRevertedMessageOp(ObjectId computedChangeId) {
- this.computedChangeId = computedChangeId;
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws Exception {
- cmUtil.setChangeMessage(
- ctx,
- "Created a revert of this change as I" + computedChangeId.getName(),
- ChangeMessagesUtil.TAG_REVERT);
- return true;
- }
- }
}
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index d8d51d49d0..07e54ceb48 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -208,7 +208,7 @@ public class ReviewerRecommender {
queryProvider
.get()
.setLimit(numberOfRelevantChanges)
- .setRequestedFields(ChangeField.REVIEWER)
+ .setRequestedFields(ChangeField.REVIEWER_SPEC)
.query(changeQueryBuilder.owner("self"));
Map<Account.Id, MutableDouble> suggestions = new LinkedHashMap<>();
// Put those candidates at the bottom of the list
diff --git a/java/com/google/gerrit/server/restapi/change/Revisions.java b/java/com/google/gerrit/server/restapi/change/Revisions.java
index bdc68163cb..8ab8a198c6 100644
--- a/java/com/google/gerrit/server/restapi/change/Revisions.java
+++ b/java/com/google/gerrit/server/restapi/change/Revisions.java
@@ -157,6 +157,7 @@ public class Revisions implements ChildCollection<ChangeResource, RevisionResour
.id(PatchSet.id(change.getId(), 0))
.commitId(editCommit)
.uploader(change.getUser().getAccountId())
+ .realUploader(change.getUser().getAccountId())
.createdOn(editCommit.getCommitterIdent().getWhenAsInstant())
.build();
if (commitId == null || editCommit.equals(commitId)) {
diff --git a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
index 9f019b6b19..7d3fe983f8 100644
--- a/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
+++ b/java/com/google/gerrit/server/restapi/change/SetReadyForReview.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -29,10 +30,12 @@ import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.WorkInProgressOp;
import com.google.gerrit.server.change.WorkInProgressOp.Input;
+import com.google.gerrit.server.git.CommitUtil;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -42,11 +45,16 @@ public class SetReadyForReview
implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
private final BatchUpdate.Factory updateFactory;
private final WorkInProgressOp.Factory opFactory;
+ private final CommitUtil commitUtil;
@Inject
- SetReadyForReview(BatchUpdate.Factory updateFactory, WorkInProgressOp.Factory opFactory) {
+ SetReadyForReview(
+ BatchUpdate.Factory updateFactory,
+ WorkInProgressOp.Factory opFactory,
+ CommitUtil commitUtil) {
this.updateFactory = updateFactory;
this.opFactory = opFactory;
+ this.commitUtil = commitUtil;
}
@Override
@@ -62,12 +70,18 @@ public class SetReadyForReview
if (!change.isWorkInProgress()) {
throw new ResourceConflictException("change is not work in progress");
}
-
- try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
- bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
- bu.execute();
- return Response.ok();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.ALL)));
+ bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
+ if (change.getRevertOf() != null) {
+ commitUtil.addChangeRevertedNotificationOps(
+ bu, change.getRevertOf(), change.getId(), change.getKey().get());
+ }
+ bu.execute();
+ return Response.ok();
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
index 0ad5180851..306aeea98f 100644
--- a/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
+++ b/java/com/google/gerrit/server/restapi/change/SetWorkInProgress.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.restapi.change;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -33,6 +34,7 @@ import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -62,12 +64,14 @@ public class SetWorkInProgress
if (change.isWorkInProgress()) {
throw new ResourceConflictException("change is already work in progress");
}
-
- try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
- bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
- bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
- bu.execute();
- return Response.ok();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (BatchUpdate bu =
+ updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) {
+ bu.setNotify(NotifyResolver.Result.create(firstNonNull(input.notify, NotifyHandling.NONE)));
+ bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
+ bu.execute();
+ return Response.ok();
+ }
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index 560f4e02f7..b1f1da5f6d 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -43,11 +43,11 @@ import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.BranchUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.ProjectUtil;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeResource;
@@ -198,7 +198,7 @@ public class Submit
Change change = rsrc.getChange();
if (!change.isNew()) {
throw new ResourceConflictException("change is " + ChangeUtil.status(change));
- } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
+ } else if (!BranchUtil.branchExists(repoManager, change.getDest())) {
throw new ResourceConflictException(
String.format("destination branch \"%s\" not found.", change.getDest().branch()));
} else if (!rsrc.getPatchSet().id().equals(change.currentPatchSetId())) {
@@ -234,6 +234,7 @@ public class Submit
* @param user the user who is checking to submit
* @return a reason why any of the changes is not submittable or null
*/
+ @Nullable
private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
try {
if (cs.furtherHiddenChanges()) {
@@ -297,6 +298,7 @@ public class Submit
return null;
}
+ @Nullable
@Override
public UiAction.Description getDescription(RevisionResource resource)
throws IOException, PermissionBackendException {
@@ -372,6 +374,7 @@ public class Submit
.setEnabled(Boolean.TRUE.equals(enabled));
}
+ @Nullable
public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
Set<ChangeData> mergeabilityMap = new HashSet<>();
Set<ObjectId> outDatedPatchsets = new HashSet<>();
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index a046100542..63f2239407 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -56,8 +56,6 @@ import com.google.gerrit.server.config.GerritInstanceId;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.documentation.QueryDocumentationExecutor;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndexCollection;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.plugincontext.PluginItemContext;
import com.google.gerrit.server.plugincontext.PluginMapContext;
@@ -94,7 +92,6 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
private final QueryDocumentationExecutor docSearcher;
private final ProjectCache projectCache;
private final AgreementJson agreementJson;
- private final ChangeIndexCollection indexes;
private final SitePaths sitePaths;
private final @Nullable @GerritInstanceId String instanceId;
@@ -118,7 +115,6 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
QueryDocumentationExecutor docSearcher,
ProjectCache projectCache,
AgreementJson agreementJson,
- ChangeIndexCollection indexes,
SitePaths sitePaths,
@Nullable @GerritInstanceId String instanceId) {
this.config = config;
@@ -139,7 +135,6 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
this.docSearcher = docSearcher;
this.projectCache = projectCache;
this.agreementJson = agreementJson;
- this.indexes = indexes;
this.sitePaths = sitePaths;
this.instanceId = instanceId;
}
@@ -224,11 +219,6 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
private ChangeConfigInfo getChangeInfo() {
ChangeConfigInfo info = new ChangeConfigInfo();
info.allowBlame = toBoolean(config.getBoolean("change", "allowBlame", true));
- boolean hasAssigneeInIndex =
- indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
- info.showAssigneeInChangesTable =
- toBoolean(
- config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
info.updateDelay =
(int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
@@ -236,10 +226,7 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
toBoolean(this.config.getBoolean("change", null, "disablePrivateChanges", false));
info.mergeabilityComputationBehavior =
MergeabilityComputationBehavior.fromConfig(config).name();
- info.enableAttentionSet =
- toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", true));
- info.enableAssignee =
- toBoolean(this.config.getBoolean("change", null, "enableAssignee", false));
+ info.enableRobotComments = toBoolean(config.getBoolean("change", "enableRobotComments", true));
info.conflictsPredicateEnabled =
toBoolean(config.getBoolean("change", "conflictsPredicateEnabled", true));
return info;
@@ -310,6 +297,7 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
return info;
}
+ @Nullable
private String getDocUrl() {
String docUrl = config.getString("gerrit", null, "docUrl");
if (Strings.isNullOrEmpty(docUrl)) {
@@ -332,15 +320,13 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
return info;
}
- private static final String DEFAULT_THEME = "/static/" + SitePaths.THEME_FILENAME;
private static final String DEFAULT_THEME_JS = "/static/" + SitePaths.THEME_JS_FILENAME;
+ @Nullable
private String getDefaultTheme() {
if (config.getString("theme", null, "enableDefault") == null) {
// If not explicitly enabled or disabled, check for the existence of the theme file.
- return Files.exists(sitePaths.site_theme_js)
- ? DEFAULT_THEME_JS
- : Files.exists(sitePaths.site_theme) ? DEFAULT_THEME : null;
+ return Files.exists(sitePaths.site_theme_js) ? DEFAULT_THEME_JS : null;
}
if (config.getBoolean("theme", null, "enableDefault", true)) {
// Return non-null theme path without checking for file existence. Even if the file doesn't
@@ -351,6 +337,7 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
return null;
}
+ @Nullable
private SshdInfo getSshdInfo() {
String[] addr = config.getStringList("sshd", null, "listenAddress");
if (addr.length == 1 && isOff(addr[0])) {
@@ -387,7 +374,8 @@ public class GetServerInfo implements RestReadView<ConfigResource> {
return Arrays.asList(config.getStringList("dashboard", null, "submitRequirementColumns"));
}
+ @Nullable
private static Boolean toBoolean(boolean v) {
- return v ? v : null;
+ return v ? Boolean.TRUE : null;
}
}
diff --git a/java/com/google/gerrit/server/restapi/config/GetSummary.java b/java/com/google/gerrit/server/restapi/config/GetSummary.java
index d0a1498bfb..34cf550430 100644
--- a/java/com/google/gerrit/server/restapi/config/GetSummary.java
+++ b/java/com/google/gerrit/server/restapi/config/GetSummary.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.config;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.extensions.annotations.RequiresCapability;
import com.google.gerrit.extensions.restapi.Response;
@@ -90,14 +91,22 @@ public class GetSummary implements RestReadView<ConfigResource> {
private TaskSummaryInfo getTaskSummary() {
Collection<Task<?>> pending = workQueue.getTasks();
int tasksTotal = pending.size();
+ int tasksStopping = 0;
int tasksRunning = 0;
+ int tasksStarting = 0;
int tasksReady = 0;
int tasksSleeping = 0;
for (Task<?> task : pending) {
switch (task.getState()) {
+ case STOPPING:
+ tasksStopping++;
+ break;
case RUNNING:
tasksRunning++;
break;
+ case STARTING:
+ tasksStarting++;
+ break;
case READY:
tasksReady++;
break;
@@ -113,7 +122,9 @@ public class GetSummary implements RestReadView<ConfigResource> {
TaskSummaryInfo taskSummary = new TaskSummaryInfo();
taskSummary.total = toInteger(tasksTotal);
+ taskSummary.stopping = toInteger(tasksStopping);
taskSummary.running = toInteger(tasksRunning);
+ taskSummary.starting = toInteger(tasksStarting);
taskSummary.ready = toInteger(tasksReady);
taskSummary.sleeping = toInteger(tasksSleeping);
return taskSummary;
@@ -211,6 +222,7 @@ public class GetSummary implements RestReadView<ConfigResource> {
return jvmSummary;
}
+ @Nullable
private static Integer toInteger(int i) {
return i != 0 ? i : null;
}
@@ -247,7 +259,9 @@ public class GetSummary implements RestReadView<ConfigResource> {
public static class TaskSummaryInfo {
public Integer total;
+ public Integer stopping;
public Integer running;
+ public Integer starting;
public Integer ready;
public Integer sleeping;
}
diff --git a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
index 9ce7ffd8b0..c8f2ed6dc7 100644
--- a/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
+++ b/java/com/google/gerrit/server/restapi/config/ReloadConfig.java
@@ -33,6 +33,7 @@ import com.google.inject.Inject;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
@@ -59,7 +60,8 @@ public class ReloadConfig implements RestModifyView<ConfigResource, Input> {
updates.asMap().entrySet().stream()
.collect(
Collectors.toMap(
- e -> e.getKey().name().toLowerCase(), e -> toEntryInfos(e.getValue()))));
+ e -> e.getKey().name().toLowerCase(Locale.US),
+ e -> toEntryInfos(e.getValue()))));
}
private static List<ConfigUpdateEntryInfo> toEntryInfos(
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index e61793193d..9d36aaaa97 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.restapi.group;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
@@ -181,6 +182,7 @@ public class CreateGroup
return Response.created(json.format(new InternalGroupDescription(createGroup(args))));
}
+ @Nullable
private AccountGroup.UUID owner(GroupInput input) throws UnprocessableEntityException {
if (input.ownerId != null) {
GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index b94e44df50..4d9a1e9315 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -21,6 +21,7 @@ import static java.util.stream.Collectors.toList;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
@@ -408,6 +409,7 @@ public class ListGroups implements RestReadView<TopLevelResource> {
}
}
+ @Nullable
private Pattern getRegexPattern() {
return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
}
diff --git a/java/com/google/gerrit/server/restapi/project/BanCommit.java b/java/com/google/gerrit/server/restapi/project/BanCommit.java
index eb5473d871..2dd7bd8c81 100644
--- a/java/com/google/gerrit/server/restapi/project/BanCommit.java
+++ b/java/com/google/gerrit/server/restapi/project/BanCommit.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.project;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.api.projects.BanCommitInput;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -64,6 +65,7 @@ public class BanCommit implements RestModifyView<ProjectResource, BanCommitInput
return Response.ok(r);
}
+ @Nullable
private static List<String> transformCommits(List<ObjectId> commits) {
if (commits == null || commits.isEmpty()) {
return null;
diff --git a/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
index 904a16f645..192e624e3b 100644
--- a/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
+++ b/java/com/google/gerrit/server/restapi/project/ConfigInfoCreator.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.restapi.project;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
@@ -125,6 +126,7 @@ public class ConfigInfoCreator {
return info;
}
+ @Nullable
private static Map<String, Map<String, ConfigParameterInfo>> getPluginConfig(
ProjectState project,
DynamicMap<ProjectConfigEntry> pluginConfigEntries,
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index a949ff2684..458ae4d9ba 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.project;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
@@ -46,6 +47,7 @@ import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -151,24 +153,26 @@ public class CreateAccessChange implements RestModifyView<ProjectResource, Proje
md.setInsertChangeId(true);
Change.Id changeId = Change.id(seq.nextChangeId());
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ RevCommit commit =
+ config.commitToNewRef(
+ md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
- RevCommit commit =
- config.commitToNewRef(md, PatchSet.id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
-
- if (commit.name().equals(oldCommitSha1)) {
- throw new BadRequestException("no change");
- }
+ if (commit.name().equals(oldCommitSha1)) {
+ throw new BadRequestException("no change");
+ }
- try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
- ObjectReader objReader = objInserter.newReader();
- RevWalk rw = new RevWalk(objReader);
- BatchUpdate bu =
- updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
- bu.setRepository(md.getRepository(), rw, objInserter);
- ChangeInserter ins = newInserter(changeId, commit);
- bu.insertChange(ins);
- bu.execute();
- return Response.created(jsonFactory.noOptions().format(ins.getChange()));
+ try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+ ObjectReader objReader = objInserter.newReader();
+ RevWalk rw = new RevWalk(objReader);
+ BatchUpdate bu =
+ updateFactory.create(rsrc.getNameKey(), rsrc.getUser(), TimeUtil.now())) {
+ bu.setRepository(md.getRepository(), rw, objInserter);
+ ChangeInserter ins = newInserter(changeId, commit);
+ bu.insertChange(ins);
+ bu.execute();
+ return Response.created(jsonFactory.noOptions().format(ins.getChange()));
+ }
}
} catch (InvalidNameException e) {
throw new BadRequestException(e.toString());
diff --git a/java/com/google/gerrit/server/restapi/project/CreateBranch.java b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
index c39b1f40f4..412559bf5a 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateBranch.java
@@ -15,10 +15,9 @@
package com.google.gerrit.server.restapi.project;
import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchInfo;
@@ -32,6 +31,7 @@ import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ValidationOptionsUtil;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -43,12 +43,12 @@ import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.project.RefUtil;
import com.google.gerrit.server.project.RefValidationHelper;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.MagicBranch;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.util.Map;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
@@ -89,130 +89,132 @@ public class CreateBranch
throws BadRequestException, AuthException, ResourceConflictException,
UnprocessableEntityException, IOException, PermissionBackendException,
NoSuchProjectException {
- String ref = id.get();
- if (input == null) {
- input = new BranchInput();
- }
- if (input.ref != null && !ref.equals(input.ref)) {
- throw new BadRequestException("ref must match URL");
- }
- if (input.revision != null) {
- input.revision = input.revision.trim();
- }
- if (Strings.isNullOrEmpty(input.revision)) {
- input.revision = Constants.HEAD;
- }
- while (ref.startsWith("/")) {
- ref = ref.substring(1);
- }
- ref = RefNames.fullName(ref);
- if (!Repository.isValidRefName(ref)) {
- throw new BadRequestException("invalid branch name \"" + ref + "\"");
- }
- if (MagicBranch.isMagicBranch(ref)) {
- throw new BadRequestException(
- "not allowed to create branches under \""
- + MagicBranch.getMagicRefNamePrefix(ref)
- + "\"");
- }
- if (!isBranchAllowed(ref)) {
- throw new BadRequestException(
- "Cannot create a branch with name \""
- + ref
- + "\". Not allowed to create branches under Gerrit internal or tags refs.");
- }
-
- BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
- try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
- ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
- RevWalk rw = RefUtil.verifyConnected(repo, revid);
- RevObject object = rw.parseAny(revid);
-
- if (ref.startsWith(Constants.R_HEADS)) {
- // Ensure that what we start the branch from is a commit. If we
- // were given a tag, dereference to the commit instead.
- //
- object = rw.parseCommit(object);
+ try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+ String ref = id.get();
+ if (input == null) {
+ input = new BranchInput();
+ }
+ if (input.ref != null && !ref.equals(input.ref)) {
+ throw new BadRequestException("ref must match URL");
+ }
+ if (input.revision != null) {
+ input.revision = input.revision.trim();
+ }
+ if (Strings.isNullOrEmpty(input.revision)) {
+ input.revision = Constants.HEAD;
+ }
+ while (ref.startsWith("/")) {
+ ref = ref.substring(1);
+ }
+ ref = RefNames.fullName(ref);
+ if (!Repository.isValidRefName(ref)) {
+ throw new BadRequestException("invalid branch name \"" + ref + "\"");
+ }
+ if (MagicBranch.isMagicBranch(ref)) {
+ throw new BadRequestException(
+ "not allowed to create branches under \""
+ + MagicBranch.getMagicRefNamePrefix(ref)
+ + "\"");
+ }
+ if (!isBranchAllowed(ref)) {
+ throw new BadRequestException(
+ "Cannot create a branch with name \""
+ + ref
+ + "\". Not allowed to create branches under Gerrit internal or tags refs.");
}
- Ref sourceRef = repo.exactRef(input.revision);
- if (sourceRef == null) {
- createRefControl.checkCreateRef(identifiedUser, repo, name, object, /* forPush= */ false);
- } else {
- if (sourceRef.isSymbolic()) {
- sourceRef = sourceRef.getTarget();
+ BranchNameKey name = BranchNameKey.create(rsrc.getNameKey(), ref);
+ try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
+ ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
+ RevWalk rw = RefUtil.verifyConnected(repo, revid);
+ RevObject object = rw.parseAny(revid);
+
+ if (ref.startsWith(Constants.R_HEADS)) {
+ // Ensure that what we start the branch from is a commit. If we
+ // were given a tag, dereference to the commit instead.
+ //
+ object = rw.parseCommit(object);
}
- createRefControl.checkCreateRef(
- identifiedUser,
- repo,
- name,
- object,
- /* forPush= */ false,
- BranchNameKey.create(rsrc.getNameKey(), sourceRef.getName()));
- }
- RefUpdate u = repo.updateRef(ref);
- u.setExpectedOldObjectId(ObjectId.zeroId());
- u.setNewObjectId(object.copy());
- u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
- u.setRefLogMessage("created via REST from " + input.revision, false);
- refCreationValidator.validateRefOperation(
- rsrc.getName(),
- identifiedUser.get(),
- u,
- getValidateOptionsAsMultimap(input.validationOptions));
- RefUpdate.Result result = u.update(rw);
- switch (result) {
- case FAST_FORWARD:
- case NEW:
- case NO_CHANGE:
- referenceUpdated.fire(
- name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
- break;
- case LOCK_FAILURE:
- if (repo.getRefDatabase().exactRef(ref) != null) {
- throw new ResourceConflictException("branch \"" + ref + "\" already exists");
+ Ref sourceRef = repo.exactRef(input.revision);
+ if (sourceRef == null) {
+ createRefControl.checkCreateRef(identifiedUser, repo, name, object, /* forPush= */ false);
+ } else {
+ if (sourceRef.isSymbolic()) {
+ sourceRef = sourceRef.getTarget();
}
- String refPrefix = RefUtil.getRefPrefix(ref);
- while (!Constants.R_HEADS.equals(refPrefix)) {
- if (repo.getRefDatabase().exactRef(refPrefix) != null) {
- throw new ResourceConflictException(
- "Cannot create branch \""
- + ref
- + "\" since it conflicts with branch \""
- + refPrefix
- + "\".");
+ createRefControl.checkCreateRef(
+ identifiedUser,
+ repo,
+ name,
+ object,
+ /* forPush= */ false,
+ BranchNameKey.create(rsrc.getNameKey(), sourceRef.getName()));
+ }
+
+ RefUpdate u = repo.updateRef(ref);
+ u.setExpectedOldObjectId(ObjectId.zeroId());
+ u.setNewObjectId(object.copy());
+ u.setRefLogIdent(identifiedUser.get().newRefLogIdent());
+ u.setRefLogMessage("created via REST from " + input.revision, false);
+ refCreationValidator.validateRefOperation(
+ rsrc.getName(),
+ identifiedUser.get(),
+ u,
+ ValidationOptionsUtil.getValidateOptionsAsMultimap(input.validationOptions));
+ RefUpdate.Result result = u.update(rw);
+ switch (result) {
+ case FAST_FORWARD:
+ case NEW:
+ case NO_CHANGE:
+ referenceUpdated.fire(
+ name.project(), u, ReceiveCommand.Type.CREATE, identifiedUser.get().state());
+ break;
+ case LOCK_FAILURE:
+ if (repo.getRefDatabase().exactRef(ref) != null) {
+ throw new ResourceConflictException("branch \"" + ref + "\" already exists");
}
- refPrefix = RefUtil.getRefPrefix(refPrefix);
- }
- throw new LockFailureException(String.format("Failed to create %s", ref), u);
- case FORCED:
- case IO_FAILURE:
- case NOT_ATTEMPTED:
- case REJECTED:
- case REJECTED_CURRENT_BRANCH:
- case RENAMED:
- case REJECTED_MISSING_OBJECT:
- case REJECTED_OTHER_REASON:
- default:
- throw new IOException(String.format("Failed to create %s: %s", ref, result.name()));
- }
+ String refPrefix = RefUtil.getRefPrefix(ref);
+ while (!Constants.R_HEADS.equals(refPrefix)) {
+ if (repo.getRefDatabase().exactRef(refPrefix) != null) {
+ throw new ResourceConflictException(
+ "Cannot create branch \""
+ + ref
+ + "\" since it conflicts with branch \""
+ + refPrefix
+ + "\".");
+ }
+ refPrefix = RefUtil.getRefPrefix(refPrefix);
+ }
+ throw new LockFailureException(String.format("Failed to create %s", ref), u);
+ case FORCED:
+ case IO_FAILURE:
+ case NOT_ATTEMPTED:
+ case REJECTED:
+ case REJECTED_CURRENT_BRANCH:
+ case RENAMED:
+ case REJECTED_MISSING_OBJECT:
+ case REJECTED_OTHER_REASON:
+ default:
+ throw new IOException(String.format("Failed to create %s: %s", ref, result.name()));
+ }
- BranchInfo info = new BranchInfo();
- info.ref = ref;
- info.revision = revid.getName();
+ BranchInfo info = new BranchInfo();
+ info.ref = ref;
+ info.revision = revid.getName();
- if (isConfigRef(name.branch())) {
- // Never allow to delete the meta config branch.
- info.canDelete = null;
- } else {
- info.canDelete =
- permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
- && rsrc.getProjectState().statePermitsWrite()
- ? true
- : null;
+ if (isConfigRef(name.branch())) {
+ // Never allow to delete the meta config branch.
+ info.canDelete = null;
+ } else {
+ info.canDelete =
+ permissionBackend.currentUser().ref(name).testOrFalse(RefPermission.DELETE)
+ && rsrc.getProjectState().statePermitsWrite()
+ ? true
+ : null;
+ }
+ return Response.created(info);
}
- return Response.created(info);
}
}
@@ -220,18 +222,4 @@ public class CreateBranch
private boolean isBranchAllowed(String branch) {
return !RefNames.isGerritRef(branch) && !branch.startsWith(RefNames.REFS_TAGS);
}
-
- private static ImmutableListMultimap<String, String> getValidateOptionsAsMultimap(
- @Nullable Map<String, String> validationOptions) {
- if (validationOptions == null) {
- return ImmutableListMultimap.of();
- }
-
- ImmutableListMultimap.Builder<String, String> validationOptionsBuilder =
- ImmutableListMultimap.builder();
- validationOptions
- .entrySet()
- .forEach(e -> validationOptionsBuilder.put(e.getKey(), e.getValue()));
- return validationOptionsBuilder.build();
- }
}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateChange.java b/java/com/google/gerrit/server/restapi/project/CreateChange.java
index 59efd06397..e30759d645 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateChange.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.project;
import com.google.common.base.Strings;
+import com.google.gerrit.entities.ProjectUtil;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeInput;
@@ -59,7 +60,8 @@ public class CreateChange implements RestModifyView<ProjectResource, ChangeInput
throw new AuthException("Authentication required");
}
- if (!Strings.isNullOrEmpty(input.project) && !rsrc.getName().equals(input.project)) {
+ if (!Strings.isNullOrEmpty(input.project)
+ && !rsrc.getName().equals(ProjectUtil.sanitizeProjectName(input.project))) {
throw new BadRequestException("project must match URL");
}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 82033465e6..cfdadd9fac 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -21,6 +21,7 @@ import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.ProjectUtil;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.annotations.RequiresCapability;
import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -35,7 +36,6 @@ import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.ProjectUtil;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerConfig;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateTag.java b/java/com/google/gerrit/server/restapi/project/CreateTag.java
index 63734bbe90..b12ceef894 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateTag.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateTag.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.project;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.R_TAGS;
import com.google.common.base.Strings;
@@ -28,6 +29,7 @@ import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.server.WebLinks;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
@@ -39,6 +41,7 @@ import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectResource;
import com.google.gerrit.server.project.RefUtil;
import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -46,6 +49,7 @@ import java.io.IOException;
import java.time.ZoneId;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.TagCommand;
+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
import org.eclipse.jgit.api.errors.GitAPIException;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectId;
@@ -97,64 +101,71 @@ public class CreateTag implements RestCollectionCreateView<ProjectResource, TagR
ref = RefUtil.normalizeTagRef(ref);
PermissionBackend.ForRef perm =
permissionBackend.currentUser().project(resource.getNameKey()).ref(ref);
+ try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+ try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
+ ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
+ RevWalk rw = RefUtil.verifyConnected(repo, revid);
+ // Reachability through tags does not influence a commit's visibility, so no need to check
+ // for
+ // visibility.
+ RevObject object = rw.parseAny(revid);
+ rw.reset();
+ boolean isAnnotated = Strings.emptyToNull(input.message) != null;
+ boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
+ if (isSigned) {
+ throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
+ } else if (isAnnotated) {
+ if (!check(perm, RefPermission.CREATE_TAG)) {
+ throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+ }
- try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
- ObjectId revid = RefUtil.parseBaseRevision(repo, input.revision);
- RevWalk rw = RefUtil.verifyConnected(repo, revid);
- // Reachability through tags does not influence a commit's visibility, so no need to check for
- // visibility.
- RevObject object = rw.parseAny(revid);
- rw.reset();
- boolean isAnnotated = Strings.emptyToNull(input.message) != null;
- boolean isSigned = isAnnotated && input.message.contains("-----BEGIN PGP SIGNATURE-----\n");
- if (isSigned) {
- throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
- } else if (isAnnotated) {
- if (!check(perm, RefPermission.CREATE_TAG)) {
- throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
+ } else {
+ perm.check(RefPermission.CREATE);
+ }
+ if (repo.getRefDatabase().exactRef(ref) != null) {
+ throw new ResourceConflictException("tag \"" + ref + "\" already exists");
}
- } else {
- perm.check(RefPermission.CREATE);
- }
- if (repo.getRefDatabase().exactRef(ref) != null) {
- throw new ResourceConflictException("tag \"" + ref + "\" already exists");
- }
-
- try (Git git = new Git(repo)) {
- TagCommand tag =
- git.tag()
- .setObjectId(object)
- .setName(ref.substring(R_TAGS.length()))
- .setAnnotated(isAnnotated)
- .setSigned(isSigned);
+ try (Git git = new Git(repo)) {
+ TagCommand tag =
+ git.tag()
+ .setObjectId(object)
+ .setName(ref.substring(R_TAGS.length()))
+ .setAnnotated(isAnnotated)
+ .setSigned(isSigned);
- if (isAnnotated) {
- tag.setMessage(input.message)
- .setTagger(
- resource
- .getUser()
- .asIdentifiedUser()
- .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
- }
+ if (isAnnotated) {
+ tag.setMessage(input.message)
+ .setTagger(
+ resource
+ .getUser()
+ .asIdentifiedUser()
+ .newCommitterIdent(TimeUtil.now(), ZoneId.systemDefault()));
+ }
- Ref result = tag.call();
- tagCache.updateFastForward(
- resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
- referenceUpdated.fire(
- resource.getNameKey(),
- ref,
- ObjectId.zeroId(),
- result.getObjectId(),
- resource.getUser().asIdentifiedUser().state());
- try (RevWalk w = new RevWalk(repo)) {
- return Response.created(
- ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
+ try {
+ Ref result = tag.call();
+ tagCache.updateFastForward(
+ resource.getNameKey(), ref, ObjectId.zeroId(), result.getObjectId());
+ referenceUpdated.fire(
+ resource.getNameKey(),
+ ref,
+ ObjectId.zeroId(),
+ result.getObjectId(),
+ resource.getUser().asIdentifiedUser().state());
+ try (RevWalk w = new RevWalk(repo)) {
+ return Response.created(
+ ListTags.createTagInfo(perm, result, w, resource.getProjectState(), links));
+ }
+ } catch (ConcurrentRefUpdateException e) {
+ LockFailureException.throwIfLockFailure(e);
+ throw e;
+ }
}
+ } catch (GitAPIException e) {
+ logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
+ throw new IOException(e);
}
- } catch (GitAPIException e) {
- logger.atSevere().withCause(e).log("Cannot create tag \"%s\"", ref);
- throw new IOException(e);
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
index ca48109065..455358a1ab 100644
--- a/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/DashboardsCollection.java
@@ -225,6 +225,7 @@ public class DashboardsCollection implements ChildCollection<ProjectResource, Da
return info;
}
+ @Nullable
private static String replace(String project, String input) {
return input == null ? input : input.replace("${project}", project);
}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
index 6248a61cd9..227a01b03e 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranch.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.restapi.project;
import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import com.google.gerrit.entities.RefNames;
@@ -27,6 +28,7 @@ import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.BranchResource;
import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -58,8 +60,9 @@ public class DeleteBranch implements RestModifyView<BranchResource, Input> {
if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
}
-
- deleteRef.deleteSingleRef(rsrc.getProjectState(), rsrc.getRef(), R_HEADS);
+ try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+ deleteRef.deleteSingleRef(rsrc.getProjectState(), rsrc.getRef(), R_HEADS);
+ }
return Response.none();
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
index ca5962e1b5..a1b5f81e90 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteBranches.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.project;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import com.google.common.collect.ImmutableSet;
@@ -26,6 +27,7 @@ import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -52,9 +54,10 @@ public class DeleteBranches implements RestModifyView<ProjectResource, DeleteBra
// Never allow to delete the meta config branch.
throw new MethodNotAllowedException("not allowed to delete branch " + RefNames.REFS_CONFIG);
}
-
- deleteRef.deleteMultipleRefs(
- project.getProjectState(), ImmutableSet.copyOf(input.branches), R_HEADS);
+ try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+ deleteRef.deleteMultipleRefs(
+ project.getProjectState(), ImmutableSet.copyOf(input.branches), R_HEADS);
+ }
return Response.none();
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 1e351f88fc..388946edc0 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.restapi.project;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.entities.RefNames.isConfigRef;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
import static java.lang.String.format;
import static org.eclipse.jgit.lib.Constants.R_REFS;
import static org.eclipse.jgit.lib.Constants.R_TAGS;
@@ -41,6 +42,7 @@ import com.google.gerrit.server.permissions.RefPermission;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.project.RefValidationHelper;
import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -102,59 +104,61 @@ public class DeleteRef {
*/
public void deleteSingleRef(ProjectState projectState, String ref, @Nullable String prefix)
throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
- if (prefix != null && !ref.startsWith(R_REFS)) {
- ref = prefix + ref;
- }
+ try (RefUpdateContext ctx = RefUpdateContext.open(BRANCH_MODIFICATION)) {
+ if (prefix != null && !ref.startsWith(R_REFS)) {
+ ref = prefix + ref;
+ }
- projectState.checkStatePermitsWrite();
- permissionBackend
- .currentUser()
- .project(projectState.getNameKey())
- .ref(ref)
- .check(RefPermission.DELETE);
+ projectState.checkStatePermitsWrite();
+ permissionBackend
+ .currentUser()
+ .project(projectState.getNameKey())
+ .ref(ref)
+ .check(RefPermission.DELETE);
- try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
- Ref refObj = repository.exactRef(ref);
- if (refObj == null) {
- throw new ResourceConflictException(String.format("ref %s doesn't exist", ref));
- }
- RefUpdate u = repository.updateRef(ref);
- u.setExpectedOldObjectId(refObj.getObjectId());
- u.setNewObjectId(ObjectId.zeroId());
- u.setForceUpdate(true);
- refDeletionValidator.validateRefOperation(
- projectState.getName(),
- identifiedUser.get(),
- u,
- /* pushOptions */ ImmutableListMultimap.of());
- RefUpdate.Result result = u.delete();
+ try (Repository repository = repoManager.openRepository(projectState.getNameKey())) {
+ Ref refObj = repository.exactRef(ref);
+ if (refObj == null) {
+ throw new ResourceConflictException(String.format("ref %s doesn't exist", ref));
+ }
+ RefUpdate u = repository.updateRef(ref);
+ u.setExpectedOldObjectId(refObj.getObjectId());
+ u.setNewObjectId(ObjectId.zeroId());
+ u.setForceUpdate(true);
+ refDeletionValidator.validateRefOperation(
+ projectState.getName(),
+ identifiedUser.get(),
+ u,
+ /* pushOptions */ ImmutableListMultimap.of());
+ RefUpdate.Result result = u.delete();
- switch (result) {
- case NEW:
- case NO_CHANGE:
- case FAST_FORWARD:
- case FORCED:
- referenceUpdated.fire(
- projectState.getNameKey(),
- u,
- ReceiveCommand.Type.DELETE,
- identifiedUser.get().state());
- break;
+ switch (result) {
+ case NEW:
+ case NO_CHANGE:
+ case FAST_FORWARD:
+ case FORCED:
+ referenceUpdated.fire(
+ projectState.getNameKey(),
+ u,
+ ReceiveCommand.Type.DELETE,
+ identifiedUser.get().state());
+ break;
- case REJECTED_CURRENT_BRANCH:
- logger.atFine().log("Cannot delete current branch %s: %s", ref, result.name());
- throw new ResourceConflictException("cannot delete current branch");
+ case REJECTED_CURRENT_BRANCH:
+ logger.atFine().log("Cannot delete current branch %s: %s", ref, result.name());
+ throw new ResourceConflictException("cannot delete current branch");
- 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()));
+ 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()));
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTag.java b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
index 8d0a3d3490..e22c90f042 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTag.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTag.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.restapi.project;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
+
import com.google.common.base.Preconditions;
import com.google.gerrit.extensions.common.Input;
import com.google.gerrit.extensions.restapi.Response;
@@ -22,6 +24,7 @@ import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.RefUtil;
import com.google.gerrit.server.project.TagResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -44,7 +47,9 @@ public class DeleteTag implements RestModifyView<TagResource, Input> {
Preconditions.checkState(tag.startsWith(Constants.R_TAGS));
- deleteRef.deleteSingleRef(resource.getProjectState(), tag);
+ try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+ deleteRef.deleteSingleRef(resource.getProjectState(), tag);
+ }
return Response.none();
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteTags.java b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
index 7ac3aff48b..a015d2b65e 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteTags.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteTags.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.restapi.project;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
import static org.eclipse.jgit.lib.Constants.R_TAGS;
import com.google.common.collect.ImmutableSet;
@@ -24,6 +25,7 @@ import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -43,12 +45,14 @@ public class DeleteTags implements RestModifyView<ProjectResource, DeleteTagsInp
if (input == null || input.tags == null || input.tags.isEmpty()) {
throw new BadRequestException("tags must be specified");
}
-
- // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
- // rather than refs/tags/refs/heads/bla.
- // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go through.
- deleteRef.deleteMultipleRefs(
- project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
+ try (RefUpdateContext ctx = RefUpdateContext.open(TAG_MODIFICATION)) {
+ // If input.tags = ["refs/heads/bla"], this will actually delete the 'ref/heads/bla' branch,
+ // rather than refs/tags/refs/heads/bla.
+ // Since this is checked against DELETE permissions for refs/heads/bla, we'll let it go
+ // through.
+ deleteRef.deleteMultipleRefs(
+ project.getProjectState(), ImmutableSet.copyOf(input.tags), R_TAGS);
+ }
return Response.none();
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index 651e7f078f..e1a3c0c991 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -26,6 +26,7 @@ import static java.util.stream.Collectors.toMap;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
@@ -340,6 +341,7 @@ public class GetAccess implements RestReadView<ProjectResource> {
return accessSectionInfo;
}
+ @Nullable
private static Boolean toBoolean(boolean value) {
return value ? true : null;
}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index 6174798f26..f9602bcbdf 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -18,6 +18,7 @@ import com.google.common.collect.ListMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.ProjectUtil;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -32,7 +33,6 @@ import com.google.gerrit.extensions.restapi.TopLevelResource;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.ProjectUtil;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.ProjectPermission;
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index d4077c8879..d4b30c2219 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -313,7 +313,7 @@ public class PutConfig implements RestModifyView<ProjectResource, ConfigInput> {
cfg.setString(COMMENTLINK, name, KEY_TEXT, value.text);
}
cfg.setBoolean(COMMENTLINK, name, KEY_ENABLED, value.enabled == null || value.enabled);
- projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name, false));
+ projectConfig.addCommentLinkSection(ProjectConfig.buildCommentLink(cfg, name));
} else {
// Delete the commentlink section
projectConfig.removeCommentLinkSection(name);
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 23d60fece1..6957275ec3 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -19,6 +19,9 @@ import com.google.common.collect.Iterables;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
import com.google.gerrit.extensions.api.access.ProjectAccessInput;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -39,6 +42,7 @@ import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.util.List;
+import java.util.Map;
import org.eclipse.jgit.errors.ConfigInvalidException;
@Singleton
@@ -80,6 +84,8 @@ public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessI
throws Exception {
MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+ validateInput(input);
+
ProjectConfig config;
List<AccessSection> removals =
@@ -137,4 +143,65 @@ public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessI
return Response.ok(getAccess.apply(rsrc.getNameKey()));
}
+
+ private static void validateInput(ProjectAccessInput input) throws BadRequestException {
+ if (input.add != null) {
+ for (Map.Entry<String, AccessSectionInfo> accessSectionEntry : input.add.entrySet()) {
+ validateAccessSection(accessSectionEntry.getKey(), accessSectionEntry.getValue());
+ }
+ }
+ }
+
+ private static void validateAccessSection(String ref, AccessSectionInfo accessSectionInfo)
+ throws BadRequestException {
+ if (accessSectionInfo != null) {
+ for (Map.Entry<String, PermissionInfo> permissionEntry :
+ accessSectionInfo.permissions.entrySet()) {
+ validatePermission(ref, permissionEntry.getKey(), permissionEntry.getValue());
+ }
+ }
+ }
+
+ private static void validatePermission(
+ String ref, String permission, PermissionInfo permissionInfo) throws BadRequestException {
+ if (permissionInfo != null) {
+ for (Map.Entry<String, PermissionRuleInfo> permissionRuleEntry :
+ permissionInfo.rules.entrySet()) {
+ validatePermissionRule(
+ ref, permission, permissionRuleEntry.getKey(), permissionRuleEntry.getValue());
+ }
+ }
+ }
+
+ private static void validatePermissionRule(
+ String ref, String permission, String groupId, PermissionRuleInfo permissionRuleInfo)
+ throws BadRequestException {
+ if (permissionRuleInfo != null) {
+ if (permissionRuleInfo.min != null || permissionRuleInfo.max != null) {
+ if (permissionRuleInfo.min == null) {
+ throw new BadRequestException(
+ String.format(
+ "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+ + " ..%d (min is required if max is set)",
+ permission, groupId, ref, permissionRuleInfo.max));
+ }
+
+ if (permissionRuleInfo.max == null) {
+ throw new BadRequestException(
+ String.format(
+ "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+ + " %d.. (max is required if min is set)",
+ permission, groupId, ref, permissionRuleInfo.min));
+ }
+
+ if (permissionRuleInfo.min > permissionRuleInfo.max) {
+ throw new BadRequestException(
+ String.format(
+ "Invalid range for permission rule that assigns %s to group %s on ref %s:"
+ + " %d..%d (min must be <= max)",
+ permission, groupId, ref, permissionRuleInfo.min, permissionRuleInfo.max));
+ }
+ }
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index cab5b458ce..bfcbffcd13 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -51,6 +51,7 @@ import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
/**
* Evaluates a submit-like Prolog rule found in the rules.pl file of the current project and filters
@@ -381,7 +382,7 @@ public class PrologRuleEvaluator {
String typeName = typeTerm.name();
try {
- return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase()));
+ return SubmitTypeRecord.OK(SubmitType.valueOf(typeName.toUpperCase(Locale.US)));
} catch (IllegalArgumentException e) {
return typeError(
"Submit type rule "
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index 773c75e692..167b84e764 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.rules;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
import static com.googlecode.prolog_cafe.lang.PrologMachineCopy.save;
import com.google.common.base.Joiner;
@@ -184,13 +185,14 @@ public class RulesCache {
// Dynamically consult the rules into the machine's internal database.
//
String rules = read(project, rulesId);
- PrologMachineCopy pmc = consultRules("rules.pl", new StringReader(rules));
+ PrologMachineCopy pmc = consultRules(RULES_PL_FILE, new StringReader(rules));
if (pmc == null) {
throw new CompileException("Cannot consult rules of " + project);
}
return pmc;
}
+ @Nullable
private PrologMachineCopy consultRules(String name, Reader rules) throws CompileException {
BufferingPrologControl ctl = newEmptyMachine(systemLoader);
PushbackReader in = new PushbackReader(rules, Prolog.PUSHBACK_SIZE);
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 9907b1cc40..dc83d4a4c2 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.schema.AclUtil.grant;
import static com.google.gerrit.server.schema.AclUtil.rule;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
import com.google.gerrit.common.Version;
import com.google.gerrit.common.data.GlobalCapability;
@@ -39,6 +40,7 @@ import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.notedb.RepoSequence;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -84,18 +86,20 @@ public class AllProjectsCreator {
}
public void create(AllProjectsInput input) throws IOException, ConfigInvalidException {
- try (Repository git = repositoryManager.openRepository(allProjectsName)) {
- initAllProjects(git, input);
- } catch (RepositoryNotFoundException notFound) {
- // A repository may be missing if this project existed only to store
- // inheritable permissions. For example 'All-Projects'.
- try (Repository git = repositoryManager.createRepository(allProjectsName)) {
+ try (RefUpdateContext updCtx = RefUpdateContext.open(INIT_REPO)) {
+ try (Repository git = repositoryManager.openRepository(allProjectsName)) {
initAllProjects(git, input);
- RefUpdate u = git.updateRef(Constants.HEAD);
- u.link(RefNames.REFS_CONFIG);
- } catch (RepositoryNotFoundException err) {
- String name = allProjectsName.get();
- throw new IOException("Cannot create repository " + name, err);
+ } catch (RepositoryNotFoundException notFound) {
+ // A repository may be missing if this project existed only to store
+ // inheritable permissions. For example 'All-Projects'.
+ try (Repository git = repositoryManager.createRepository(allProjectsName)) {
+ initAllProjects(git, input);
+ RefUpdate u = git.updateRef(Constants.HEAD);
+ u.link(RefNames.REFS_CONFIG);
+ } catch (RepositoryNotFoundException err) {
+ String name = allProjectsName.get();
+ throw new IOException("Cannot create repository " + name, err);
+ }
}
}
}
@@ -149,19 +153,16 @@ public class AllProjectsCreator {
config.upsertAccessSection(
AccessSection.HEADS,
- heads -> {
- initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config);
- });
+ heads -> initDefaultAclsForRegisteredUsers(heads, codeReviewLabel, config));
config.upsertAccessSection(
AccessSection.GLOBAL_CAPABILITIES,
- capabilities -> {
- input
- .serviceUsersGroup()
- .ifPresent(
- batchUsersGroup ->
- initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
- });
+ capabilities ->
+ input
+ .serviceUsersGroup()
+ .ifPresent(
+ batchUsersGroup ->
+ initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup)));
input
.administratorsGroup()
@@ -171,16 +172,10 @@ public class AllProjectsCreator {
private void initDefaultAclsForRegisteredUsers(
AccessSection.Builder heads, LabelType codeReviewLabel, ProjectConfig config) {
config.upsertAccessSection(
- "refs/for/*",
- refsFor -> {
- grant(config, refsFor, Permission.ADD_PATCH_SET, registered);
- });
+ "refs/for/*", refsFor -> grant(config, refsFor, Permission.ADD_PATCH_SET, registered));
config.upsertAccessSection(
- "refs/meta/version",
- version -> {
- grant(config, version, Permission.READ, anonymous);
- });
+ "refs/meta/version", version -> grant(config, version, Permission.READ, anonymous));
grant(config, heads, codeReviewLabel, -1, 1, registered);
grant(config, heads, Permission.FORGE_AUTHOR, registered);
@@ -208,15 +203,11 @@ public class AllProjectsCreator {
ProjectConfig config, LabelType codeReviewLabel, GroupReference adminsGroup) {
config.upsertAccessSection(
AccessSection.GLOBAL_CAPABILITIES,
- capabilities -> {
- grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup);
- });
+ capabilities ->
+ grant(config, capabilities, GlobalCapability.ADMINISTRATE_SERVER, adminsGroup));
config.upsertAccessSection(
- AccessSection.ALL,
- all -> {
- grant(config, all, Permission.READ, adminsGroup);
- });
+ AccessSection.ALL, all -> grant(config, all, Permission.READ, adminsGroup));
config.upsertAccessSection(
AccessSection.HEADS,
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index f2fe7f6663..63fbaf9faf 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.schema.AclUtil.grant;
import static com.google.gerrit.server.schema.AllProjectsInput.getDefaultCodeReviewLabel;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
@@ -35,6 +36,7 @@ import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.RefPattern;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -90,16 +92,18 @@ public class AllUsersCreator {
}
public void create() throws IOException, ConfigInvalidException {
- try (Repository git = mgr.openRepository(allUsersName)) {
- initAllUsers(git);
- } catch (RepositoryNotFoundException notFound) {
- try (Repository git = mgr.createRepository(allUsersName)) {
+ try (RefUpdateContext ctx = RefUpdateContext.open(INIT_REPO)) {
+ try (Repository git = mgr.openRepository(allUsersName)) {
initAllUsers(git);
- RefUpdate u = git.updateRef(Constants.HEAD);
- u.link(RefNames.REFS_CONFIG);
- } catch (RepositoryNotFoundException err) {
- String name = allUsersName.get();
- throw new IOException("Cannot create repository " + name, err);
+ } catch (RepositoryNotFoundException notFound) {
+ try (Repository git = mgr.createRepository(allUsersName)) {
+ initAllUsers(git);
+ RefUpdate u = git.updateRef(Constants.HEAD);
+ u.link(RefNames.REFS_CONFIG);
+ } catch (RepositoryNotFoundException err) {
+ String name = allUsersName.get();
+ throw new IOException("Cannot create repository " + name, err);
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
new file mode 100644
index 0000000000..2ca79342ed
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -0,0 +1,469 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.project.ProjectLevelConfig;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.inject.Inject;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * A class with logic for migrating existing label functions to submit requirements and resetting
+ * the label functions to {@link LabelFunction#NO_BLOCK}.
+ *
+ * <p>Important note: Callers should do this migration only if this gerrit installation has no
+ * Prolog submit rules (i.e. no rules.pl file in refs/meta/config). Otherwise, the newly created
+ * submit requirements might not behave as intended.
+ *
+ * <p>The conversion is done as follows:
+ *
+ * <ul>
+ * <li>MaxWithBlock is translated to submittableIf = label:$lbl=MAX AND -label:$lbl=MIN
+ * <li>MaxNoBlock is translated to submittableIf = label:$lbl=MAX
+ * <li>AnyWithBlock is translated to submittableIf = -label:$lbl=MIN
+ * <li>NoBlock/NoOp are translated to applicableIf = is:false (not applicable)
+ * <li>PatchSetLock labels are left as is
+ * </ul>
+ *
+ * If the label has {@link LabelType#isIgnoreSelfApproval()}, the max vote is appended with the
+ * 'user=non_uploader' argument.
+ *
+ * <p>For labels that were skipped, i.e. had only one "zero" predefined value, the migrator creates
+ * a non-applicable submit-requirement for them. This is done so that if a parent project had a
+ * submit-requirement with the same name, then it's not inherited by this project.
+ *
+ * <p>If there is an existing label and there exists a "submit requirement" with the same name, the
+ * migrator checks if the submit-requirement to be created matches the one in project.config. If
+ * they don't match, a warning message is printed, otherwise nothing happens. In either cases, the
+ * existing submit-requirement is not altered.
+ */
+public class MigrateLabelFunctionsToSubmitRequirement {
+ public static final String COMMIT_MSG = "Migrate label functions to submit requirements";
+ private final GitRepositoryManager repoManager;
+ private final PersonIdent serverUser;
+
+ public enum Status {
+ /**
+ * The migrator updated the project config and created new submit requirements and/or did reset
+ * label functions.
+ */
+ MIGRATED,
+
+ /** The project had prolog rules, and the migration was skipped. */
+ HAS_PROLOG,
+
+ /**
+ * The project was migrated with a previous run of this class. The migration for this run was
+ * skipped.
+ */
+ PREVIOUSLY_MIGRATED,
+
+ /**
+ * Migration was run for the project but did not update the project.config because it was
+ * up-to-date.
+ */
+ NO_CHANGE
+ }
+
+ @Inject
+ public MigrateLabelFunctionsToSubmitRequirement(
+ GitRepositoryManager repoManager, @GerritPersonIdent PersonIdent serverUser) {
+ this.repoManager = repoManager;
+ this.serverUser = serverUser;
+ }
+
+ /**
+ * For each label function, create a corresponding submit-requirement and set the label function
+ * to NO_BLOCK. Blocking label functions are substituted with blocking submit-requirements.
+ * Non-blocking label functions are substituted with non-applicable submit requirements, allowing
+ * the label vote to be surfaced as a trigger vote (optional label).
+ *
+ * @return {@link Status} reflecting the status of the migration.
+ */
+ public Status executeMigration(Project.NameKey project, UpdateUI ui)
+ throws IOException, ConfigInvalidException {
+ if (hasPrologRules(project)) {
+ ui.message(String.format("Skipping project %s because it has prolog rules", project));
+ return Status.HAS_PROLOG;
+ }
+ ProjectLevelConfig.Bare projectConfig =
+ new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
+ boolean migrationPerformed = false;
+ try (Repository repo = repoManager.openRepository(project);
+ MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, project, repo)) {
+ if (hasMigrationAlreadyRun(repo)) {
+ ui.message(
+ String.format(
+ "Skipping migrating label functions to submit requirements for project '%s'"
+ + " because it has been previously migrated",
+ project));
+ return Status.PREVIOUSLY_MIGRATED;
+ }
+ projectConfig.load(project, repo);
+ Config cfg = projectConfig.getConfig();
+ Map<String, LabelAttributes> labelTypes = getLabelTypes(cfg);
+ Map<String, SubmitRequirement> existingSubmitRequirements = loadSubmitRequirements(cfg);
+ boolean updated = false;
+ for (Map.Entry<String, LabelAttributes> lt : labelTypes.entrySet()) {
+ String labelName = lt.getKey();
+ LabelAttributes attributes = lt.getValue();
+ if (attributes.function().equals("PatchSetLock")) {
+ // PATCH_SET_LOCK functions should be left as is
+ continue;
+ }
+ // If the function is other than "NoBlock" we want to reset the label function regardless
+ // of whether there exists a "submit requirement".
+ if (!attributes.function().equals("NoBlock")) {
+ updated = true;
+ writeLabelFunction(cfg, labelName, "NoBlock");
+ }
+ Optional<SubmitRequirement> sr = createSrFromLabelDef(labelName, attributes);
+ if (!sr.isPresent()) {
+ continue;
+ }
+ // Make the operation idempotent by skipping creating the submit-requirement if one was
+ // already created or previously existed.
+ if (existingSubmitRequirements.containsKey(labelName.toLowerCase(Locale.ROOT))) {
+ if (!sr.get()
+ .equals(existingSubmitRequirements.get(labelName.toLowerCase(Locale.ROOT)))) {
+ ui.message(
+ String.format(
+ "Warning: Skipping creating a submit requirement for label '%s'. An existing "
+ + "submit requirement is already present but its definition is not "
+ + "identical to the existing label definition.",
+ labelName));
+ }
+ continue;
+ }
+ updated = true;
+ ui.message(
+ String.format(
+ "Project %s: Creating a submit requirement for label %s", project, labelName));
+ writeSubmitRequirement(cfg, sr.get());
+ }
+ if (updated) {
+ commit(projectConfig, md);
+ migrationPerformed = true;
+ }
+ }
+ return migrationPerformed ? Status.MIGRATED : Status.NO_CHANGE;
+ }
+
+ /**
+ * Returns a Map containing label names as string in keys along with some of its attributes (that
+ * we need in the migration) like canOverride, ignoreSelfApproval and function in the value.
+ */
+ private Map<String, LabelAttributes> getLabelTypes(Config cfg) {
+ Map<String, LabelAttributes> labelTypes = new HashMap<>();
+ for (String labelName : cfg.getSubsections(ProjectConfig.LABEL)) {
+ String function = cfg.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
+ boolean canOverride =
+ cfg.getBoolean(
+ ProjectConfig.LABEL,
+ labelName,
+ ProjectConfig.KEY_CAN_OVERRIDE,
+ /* defaultValue= */ true);
+ boolean ignoreSelfApproval =
+ cfg.getBoolean(
+ ProjectConfig.LABEL,
+ labelName,
+ ProjectConfig.KEY_IGNORE_SELF_APPROVAL,
+ /* defaultValue= */ false);
+ ImmutableList<String> values =
+ ImmutableList.<String>builder()
+ .addAll(
+ Arrays.asList(
+ cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_VALUE)))
+ .build();
+ ImmutableList<String> refPatterns =
+ ImmutableList.<String>builder()
+ .addAll(
+ Arrays.asList(
+ cfg.getStringList(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_BRANCH)))
+ .build();
+ LabelAttributes attributes =
+ LabelAttributes.create(
+ function == null ? "MaxWithBlock" : function,
+ canOverride,
+ ignoreSelfApproval,
+ values,
+ refPatterns);
+ labelTypes.put(labelName, attributes);
+ }
+ return labelTypes;
+ }
+
+ private void writeSubmitRequirement(Config cfg, SubmitRequirement sr) {
+ if (sr.description().isPresent()) {
+ cfg.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ sr.name(),
+ ProjectConfig.KEY_SR_DESCRIPTION,
+ sr.description().get());
+ }
+ if (sr.applicabilityExpression().isPresent()) {
+ cfg.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ sr.name(),
+ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+ sr.applicabilityExpression().get().expressionString());
+ }
+ cfg.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ sr.name(),
+ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+ sr.submittabilityExpression().expressionString());
+ if (sr.overrideExpression().isPresent()) {
+ cfg.setString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ sr.name(),
+ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+ sr.overrideExpression().get().expressionString());
+ }
+ cfg.setBoolean(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ sr.name(),
+ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+ sr.allowOverrideInChildProjects());
+ }
+
+ private void writeLabelFunction(Config cfg, String labelName, String function) {
+ cfg.setString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION, function);
+ }
+
+ private void commit(ProjectLevelConfig.Bare projectConfig, MetaDataUpdate md) throws IOException {
+ md.getCommitBuilder().setAuthor(serverUser);
+ md.getCommitBuilder().setCommitter(serverUser);
+ md.setMessage(COMMIT_MSG);
+ projectConfig.commit(md);
+ }
+
+ private static Optional<SubmitRequirement> createSrFromLabelDef(
+ String labelName, LabelAttributes attributes) {
+ if (isLabelSkipped(attributes.values())) {
+ return Optional.of(createNonApplicableSr(labelName, attributes.canOverride()));
+ } else if (isBlockingOrRequiredLabel(attributes.function())) {
+ return Optional.of(createBlockingOrRequiredSr(labelName, attributes));
+ }
+ return Optional.empty();
+ }
+
+ private static SubmitRequirement createNonApplicableSr(String labelName, boolean canOverride) {
+ return SubmitRequirement.builder()
+ .setName(labelName)
+ .setApplicabilityExpression(SubmitRequirementExpression.of("is:false"))
+ .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true"))
+ .setAllowOverrideInChildProjects(canOverride)
+ .build();
+ }
+
+ /**
+ * Create a "submit requirement" that is only satisfied if the label is voted with the max votes
+ * and/or not voted by the min vote, according to the label attributes.
+ */
+ private static SubmitRequirement createBlockingOrRequiredSr(
+ String labelName, LabelAttributes attributes) {
+ SubmitRequirement.Builder builder =
+ SubmitRequirement.builder()
+ .setName(labelName)
+ .setAllowOverrideInChildProjects(attributes.canOverride());
+ String maxPart =
+ String.format("label:%s=MAX", labelName)
+ + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : "");
+ switch (attributes.function()) {
+ case "MaxWithBlock":
+ builder.setSubmittabilityExpression(
+ SubmitRequirementExpression.create(
+ String.format("%s AND -label:%s=MIN", maxPart, labelName)));
+ break;
+ case "AnyWithBlock":
+ builder.setSubmittabilityExpression(
+ SubmitRequirementExpression.create(String.format("-label:%s=MIN", labelName)));
+ break;
+ case "MaxNoBlock":
+ builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart));
+ break;
+ default:
+ break;
+ }
+ if (!attributes.refPatterns().isEmpty()) {
+ builder.setApplicabilityExpression(
+ SubmitRequirementExpression.of(
+ String.join(
+ " OR ",
+ attributes.refPatterns().stream()
+ .map(b -> "branch:\\\"" + b + "\\\"")
+ .collect(Collectors.toList()))));
+ }
+ return builder.build();
+ }
+
+ private static boolean isBlockingOrRequiredLabel(String function) {
+ return function.equals("AnyWithBlock")
+ || function.equals("MaxWithBlock")
+ || function.equals("MaxNoBlock");
+ }
+
+ /**
+ * Returns true if the label definition was skipped in the project, i.e. it had only one defined
+ * value: zero.
+ */
+ private static boolean isLabelSkipped(List<String> values) {
+ return values.isEmpty() || (values.size() == 1 && values.get(0).startsWith("0"));
+ }
+
+ public boolean anyProjectHasProlog(Collection<Project.NameKey> allProjects) throws IOException {
+ for (Project.NameKey p : allProjects) {
+ if (hasPrologRules(p)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private boolean hasPrologRules(Project.NameKey project) throws IOException {
+ try (Repository repo = repoManager.openRepository(project);
+ RevWalk rw = new RevWalk(repo);
+ ObjectReader reader = rw.getObjectReader()) {
+ Ref refsConfig = repo.exactRef(RefNames.REFS_CONFIG);
+ if (refsConfig == null) {
+ // Project does not have a refs/meta/config and no rules.pl consequently.
+ return false;
+ }
+ RevCommit commit = repo.parseCommit(refsConfig.getObjectId());
+ try (TreeWalk tw = TreeWalk.forPath(reader, RULES_PL_FILE, commit.getTree())) {
+ if (tw != null) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ /**
+ * Returns a map containing submit requirement names in lower name as keys, with {@link
+ * com.google.gerrit.entities.SubmitRequirement} as value.
+ */
+ private Map<String, SubmitRequirement> loadSubmitRequirements(Config rc) {
+ Map<String, SubmitRequirement> allRequirements = new LinkedHashMap<>();
+ for (String name : rc.getSubsections(ProjectConfig.SUBMIT_REQUIREMENT)) {
+ String description =
+ rc.getString(ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_DESCRIPTION);
+ String applicabilityExpr =
+ rc.getString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ name,
+ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION);
+ String submittabilityExpr =
+ rc.getString(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ name,
+ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION);
+ String overrideExpr =
+ rc.getString(
+ ProjectConfig.SUBMIT_REQUIREMENT, name, ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION);
+ boolean canInherit =
+ rc.getBoolean(
+ ProjectConfig.SUBMIT_REQUIREMENT,
+ name,
+ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+ false);
+ SubmitRequirement submitRequirement =
+ SubmitRequirement.builder()
+ .setName(name)
+ .setDescription(Optional.ofNullable(description))
+ .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+ .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+ .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+ .setAllowOverrideInChildProjects(canInherit)
+ .build();
+ allRequirements.put(name.toLowerCase(Locale.ROOT), submitRequirement);
+ }
+ return allRequirements;
+ }
+
+ private static boolean hasMigrationAlreadyRun(Repository repo) throws IOException {
+ try (RevWalk revWalk = new RevWalk(repo)) {
+ Ref refsMetaConfig = repo.exactRef(RefNames.REFS_CONFIG);
+ if (refsMetaConfig == null) {
+ return false;
+ }
+ revWalk.markStart(revWalk.parseCommit(refsMetaConfig.getObjectId()));
+ RevCommit commit;
+ while ((commit = revWalk.next()) != null) {
+ if (COMMIT_MSG.equals(commit.getShortMessage())) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ @AutoValue
+ abstract static class LabelAttributes {
+ abstract String function();
+
+ abstract boolean canOverride();
+
+ abstract boolean ignoreSelfApproval();
+
+ abstract ImmutableList<String> values();
+
+ abstract ImmutableList<String> refPatterns();
+
+ static LabelAttributes create(
+ String function,
+ boolean canOverride,
+ boolean ignoreSelfApproval,
+ ImmutableList<String> values,
+ ImmutableList<String> refPatterns) {
+ return new AutoValue_MigrateLabelFunctionsToSubmitRequirement_LabelAttributes(
+ function, canOverride, ignoreSelfApproval, values, refPatterns);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
index 0e22af925f..57ec7efcdf 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.schema;
import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
@@ -26,6 +27,7 @@ import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.stream.IntStream;
@@ -87,15 +89,16 @@ public class NoteDbSchemaUpdater {
// seeded refs/meta/version during AllProjectsCreator, so it won't hit this block.
checkNoteDbConfigFor216();
}
-
- for (int nextVersion : requiredUpgrades(currentVersion, schemaVersions.keySet())) {
- try {
- ui.message(String.format("Migrating data to schema %d ...", nextVersion));
- NoteDbSchemaVersions.get(schemaVersions, nextVersion).upgrade(args, ui);
- versionManager.increment(nextVersion - 1);
- } catch (Exception e) {
- throw new StorageException(
- String.format("Failed to upgrade to schema version %d", nextVersion), e);
+ try (RefUpdateContext ctx = RefUpdateContext.open(OFFLINE_OPERATION)) {
+ for (int nextVersion : requiredUpgrades(currentVersion, schemaVersions.keySet())) {
+ try {
+ ui.message(String.format("Migrating data to schema %d ...", nextVersion));
+ NoteDbSchemaVersions.get(schemaVersions, nextVersion).upgrade(args, ui);
+ versionManager.increment(nextVersion - 1);
+ } catch (Exception e) {
+ throw new StorageException(
+ String.format("Failed to upgrade to schema version %d", nextVersion), e);
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index 26ae4a8c21..38e45abe23 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -38,6 +38,8 @@ import com.google.gerrit.server.group.db.InternalGroupCreation;
import com.google.gerrit.server.index.group.GroupIndex;
import com.google.gerrit.server.index.group.GroupIndexCollection;
import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
import com.google.inject.Inject;
import java.io.IOException;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -91,31 +93,33 @@ public class SchemaCreatorImpl implements SchemaCreator {
@Override
public void create() throws IOException, ConfigInvalidException {
- GroupReference admins = createGroupReference("Administrators");
- GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
-
- AllProjectsInput allProjectsInput =
- AllProjectsInput.builder()
- .administratorsGroup(admins)
- .serviceUsersGroup(serviceUsers)
- .build();
- allProjectsCreator.create(allProjectsInput);
- // We have to create the All-Users repository before we can use it to store the groups in it.
- allUsersCreator.setAdministrators(admins).create();
-
- // Don't rely on injection to construct Sequences, as the default GitReferenceUpdated has a
- // thick dependency stack which may not all be available at schema creation time.
- Sequences seqs =
- new Sequences(
- config,
- repoManager,
- GitReferenceUpdated.DISABLED,
- allProjectsName,
- allUsersName,
- metricMaker);
- try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
- createAdminsGroup(seqs, allUsersRepo, admins);
- createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
+ try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.INIT_REPO)) {
+ GroupReference admins = createGroupReference("Administrators");
+ GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
+
+ AllProjectsInput allProjectsInput =
+ AllProjectsInput.builder()
+ .administratorsGroup(admins)
+ .serviceUsersGroup(serviceUsers)
+ .build();
+ allProjectsCreator.create(allProjectsInput);
+ // We have to create the All-Users repository before we can use it to store the groups in it.
+ allUsersCreator.setAdministrators(admins).create();
+
+ // Don't rely on injection to construct Sequences, as the default GitReferenceUpdated has a
+ // thick dependency stack which may not all be available at schema creation time.
+ Sequences seqs =
+ new Sequences(
+ config,
+ repoManager,
+ GitReferenceUpdated.DISABLED,
+ allProjectsName,
+ allUsersName,
+ metricMaker);
+ try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+ createAdminsGroup(seqs, allUsersRepo, admins);
+ createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
+ }
}
}
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index 436c57bfe0..a7e950695b 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.schema;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
+
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
@@ -29,6 +31,7 @@ import com.google.gerrit.server.group.db.GroupDelta;
import com.google.gerrit.server.group.db.GroupNameNotes;
import com.google.gerrit.server.index.group.GroupIndex;
import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import java.io.IOException;
import java.util.Optional;
import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -84,17 +87,19 @@ public class Schema_184 implements NoteDbSchemaVersion {
GroupConfig groupConfig,
GroupNameNotes groupNameNotes)
throws IOException {
- BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
- try (MetaDataUpdate metaDataUpdate =
- createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
- groupConfig.commit(metaDataUpdate);
- }
- // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
- try (MetaDataUpdate metaDataUpdate =
- createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
- groupNameNotes.commit(metaDataUpdate);
+ try (RefUpdateContext ctx = RefUpdateContext.open(OFFLINE_OPERATION)) {
+ BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+ try (MetaDataUpdate metaDataUpdate =
+ createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+ groupConfig.commit(metaDataUpdate);
+ }
+ // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+ try (MetaDataUpdate metaDataUpdate =
+ createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+ groupNameNotes.commit(metaDataUpdate);
+ }
+ RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
}
- RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
}
private MetaDataUpdate createMetaDataUpdate(
diff --git a/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
index 02ff159b56..37e7278bfe 100644
--- a/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
+++ b/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.securestore;
import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.server.config.SitePaths;
import com.google.inject.Inject;
import com.google.inject.ProvisionException;
@@ -54,6 +55,7 @@ public class DefaultSecureStore extends SecureStore {
return sec.getStringList(section, subsection, name);
}
+ @Nullable
@Override
public synchronized String[] getListForPlugin(
String pluginName, String section, String subsection, String name) {
diff --git a/java/com/google/gerrit/server/securestore/SecureStore.java b/java/com/google/gerrit/server/securestore/SecureStore.java
index b53e38cf25..855c97899e 100644
--- a/java/com/google/gerrit/server/securestore/SecureStore.java
+++ b/java/com/google/gerrit/server/securestore/SecureStore.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.securestore;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import java.util.List;
/**
@@ -53,6 +54,7 @@ public abstract class SecureStore {
*
* @return decrypted String value or {@code null} if not found
*/
+ @Nullable
public final String get(String section, String subsection, String name) {
String[] values = getList(section, subsection, name);
if (values != null && values.length > 0) {
@@ -67,6 +69,7 @@ public abstract class SecureStore {
*
* @return decrypted String value or {@code null} if not found
*/
+ @Nullable
public final String getForPlugin(
String pluginName, String section, String subsection, String name) {
String[] values = getListForPlugin(pluginName, section, subsection, name);
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index b21834706b..0471b67fdd 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -19,6 +19,7 @@ import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICA
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetInfo;
@@ -142,6 +143,7 @@ public class CherryPick extends SubmitStrategy {
patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
}
+ @Nullable
@Override
public PatchSet updateChangeImpl(ChangeContext ctx) throws NoSuchChangeException, IOException {
if (newCommit == null && toMerge.getStatusCode() == SKIPPED_IDENTICAL_TREE) {
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 3d38f6c3d9..7aa3716f2f 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.submit;
+import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
@@ -93,7 +94,10 @@ class EmailMerge implements Runnable, RequestContext {
RequestContext old = requestContext.setContext(this);
try {
MergedSender emailSender =
- mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
+ mergedSenderFactory.create(
+ project,
+ change.getId(),
+ Optional.ofNullable(Strings.emptyToNull(stickyApprovalDiff)));
if (submitter != null) {
emailSender.setFrom(submitter.getAccountId());
}
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java
new file mode 100644
index 0000000000..4c1192583f
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -0,0 +1,164 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submit;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.MagicLabelPredicates;
+import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Metrics are recorded when a change is merged (aka submitted). */
+public class MergeMetrics {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder;
+
+ // TODO: This metric is for measuring the impact of allowing users to rebase changes on behalf of
+ // the uploader. Once this feature has been rolled out and its impact as been measured, we may
+ // remove this metric.
+ private final Counter0 countChangesThatWereSubmittedWithRebaserApproval;
+
+ @Inject
+ public MergeMetrics(
+ Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder,
+ MetricMaker metricMaker) {
+ this.submitRequirementChangequeryBuilder = submitRequirementChangequeryBuilder;
+
+ this.countChangesThatWereSubmittedWithRebaserApproval =
+ metricMaker.newCounter(
+ "change/submitted_with_rebaser_approval",
+ new Description(
+ "Number of rebased changes that were submitted with a Code-Review approval of"
+ + " the rebaser that would not have been submittable if the rebase was not"
+ + " done on behalf of the uploader.")
+ .setRate());
+ }
+
+ public void countChangesThatWereSubmittedWithRebaserApproval(ChangeData cd) {
+ if (isRebaseOnBehalfOfUploader(cd)
+ && hasCodeReviewApprovalOfRealUploader(cd)
+ && ignoresCodeReviewApprovalsOfUploader(cd)) {
+ // 1. The patch set that is being submitted was created by rebasing on behalf of the uploader.
+ // The uploader of the patch set is the original uploader on whom's behalf the rebase was
+ // done. The real uploader is the user that did the rebase on behalf of the uploader (e.g. by
+ // clicking on the rebase button).
+ //
+ // 2. The change has Code-Review approvals of the real uploader (aka the rebaser).
+ //
+ // 3. Code-Review approvals of the uploader are ignored.
+ //
+ // If instead of a rebase on behalf of the uploader a normal rebase would have been done the
+ // rebaser would have been the uploader of the patch set. In this case the Code-Review
+ // approval of the rebaser would not have counted since Code-Review approvals of the uploader
+ // are ignored.
+ //
+ // In this case we assume that the change would not be submittable if a normal rebase had been
+ // done. This is not always correct (e.g. if there are approvals of multiple reviewers) but
+ // it's good enough for the metric.
+ countChangesThatWereSubmittedWithRebaserApproval.increment();
+ }
+ }
+
+ private boolean isRebaseOnBehalfOfUploader(ChangeData cd) {
+ // If the uploader differs from the real uploader the upload of the patch set has been
+ // impersonated. Impersonating the uploader is only allowed on rebase by rebasing on behalf of
+ // the uploader. Hence if the current patch set has different accounts as uploader and real
+ // uploader we can assume that it was created by rebase on behalf of the uploader.
+ boolean isRebaseOnBehalfOfUploader =
+ !cd.currentPatchSet().uploader().equals(cd.currentPatchSet().realUploader());
+ logger.atFine().log("isRebaseOnBehalfOfUploader = %s", isRebaseOnBehalfOfUploader);
+ return isRebaseOnBehalfOfUploader;
+ }
+
+ private boolean hasCodeReviewApprovalOfRealUploader(ChangeData cd) {
+ boolean hasCodeReviewApprovalOfRealUploader =
+ cd.currentApprovals().stream()
+ .anyMatch(psa -> psa.accountId().equals(cd.currentPatchSet().realUploader()));
+ logger.atFine().log(
+ "hasCodeReviewApprovalOfRealUploader = %s", hasCodeReviewApprovalOfRealUploader);
+ return hasCodeReviewApprovalOfRealUploader;
+ }
+
+ private boolean ignoresCodeReviewApprovalsOfUploader(ChangeData cd) {
+ for (SubmitRequirement submitRequirement : cd.submitRequirements().keySet()) {
+ try {
+ Predicate<ChangeData> predicate =
+ submitRequirementChangequeryBuilder
+ .get()
+ .parse(submitRequirement.submittabilityExpression().expressionString());
+ boolean ignoresCodeReviewApprovalsOfUploader =
+ ignoresCodeReviewApprovalsOfUploader(predicate);
+ logger.atFine().log(
+ "ignoresCodeReviewApprovalsOfUploader = %s", ignoresCodeReviewApprovalsOfUploader);
+ if (ignoresCodeReviewApprovalsOfUploader) {
+ return true;
+ }
+ } catch (QueryParseException e) {
+ logger.atFine().log(
+ "Failed to parse submit requirement expression %s: %s",
+ submitRequirement.submittabilityExpression().expressionString(), e.getMessage());
+ // ignore and inspect the next submit requirement
+ }
+ }
+ return false;
+ }
+
+ private boolean ignoresCodeReviewApprovalsOfUploader(Predicate<ChangeData> predicate) {
+ logger.atFine().log(
+ "predicate = (%s) %s (child count = %d)",
+ predicate.getClass().getName(), predicate, predicate.getChildCount());
+ if (predicate.getChildCount() == 0) {
+ // Submit requirements may require a Code-Review approval but ignore approvals by the
+ // uploader. This is done by using a label predicate with 'user=non_uploader' or
+ // 'user=non_contributor', e.g. 'label:Code-Review=+2,user=non_uploader'. After the submit
+ // requirement expression has been parsed these label predicates are represented by
+ // MagicLabelPredicate in the predicate tree. Hence to know whether Code-Review approvals of
+ // the uploader are ignored, we must check if there is any MagicLabelPredicate for the
+ // Code-Review label that ignores approvals of the uploader (aka has user set to non_uploader
+ // or non_contributor).
+ if (predicate instanceof MagicLabelPredicates.PostFilterMagicLabelPredicate) {
+ MagicLabelPredicates.PostFilterMagicLabelPredicate magicLabelPredicate =
+ (MagicLabelPredicates.PostFilterMagicLabelPredicate) predicate;
+ if (magicLabelPredicate.getLabel().equalsIgnoreCase("Code-Review")
+ && magicLabelPredicate.ignoresUploaderApprovals()) {
+ return true;
+ }
+ } else if (predicate instanceof MagicLabelPredicates.IndexMagicLabelPredicate) {
+ MagicLabelPredicates.IndexMagicLabelPredicate magicLabelPredicate =
+ (MagicLabelPredicates.IndexMagicLabelPredicate) predicate;
+ if (magicLabelPredicate.getLabel().equalsIgnoreCase("Code-Review")
+ && magicLabelPredicate.ignoresUploaderApprovals()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ for (Predicate<ChangeData> childPredicate : predicate.getChildren()) {
+ if (ignoresCodeReviewApprovalsOfUploader(childPredicate)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index 27eb0a43ea..d2996140eb 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.submit;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toSet;
@@ -84,6 +85,7 @@ import com.google.gerrit.server.update.SubmissionExecutor;
import com.google.gerrit.server.update.SubmissionListener;
import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -244,6 +246,7 @@ public class MergeOp implements AutoCloseable {
private final RetryHelper retryHelper;
private final ChangeData.Factory changeDataFactory;
private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
+ private final MergeMetrics mergeMetrics;
// Changes that were updated by this MergeOp.
private final Map<Change.Id, Change> updatedChanges;
@@ -278,7 +281,8 @@ public class MergeOp implements AutoCloseable {
TopicMetrics topicMetrics,
RetryHelper retryHelper,
ChangeData.Factory changeDataFactory,
- StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
+ StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory,
+ MergeMetrics mergeMetrics) {
this.cmUtil = cmUtil;
this.batchUpdateFactory = batchUpdateFactory;
this.internalUserFactory = internalUserFactory;
@@ -296,6 +300,7 @@ public class MergeOp implements AutoCloseable {
this.changeDataFactory = changeDataFactory;
this.updatedChanges = new HashMap<>();
this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
+ this.mergeMetrics = mergeMetrics;
}
@Override
@@ -374,6 +379,7 @@ public class MergeOp implements AutoCloseable {
commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
} else {
checkSubmitRequirements(cd);
+ mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cd);
}
} catch (ResourceConflictException e) {
commitStatus.problem(cd.getId(), e.getMessage());
@@ -535,9 +541,6 @@ public class MergeOp implements AutoCloseable {
// Multiply the timeout by the number of projects we're actually attempting to
// submit. Times 2 to retry more persistently, to increase success rate.
.defaultTimeoutMultiplier(filteredNoteDbChangeSet.projects().size() * 2)
- // By default, we only retry lock failures. Here it's better to also retry unexpected
- // runtime exceptions.
- .retryOn(t -> t instanceof RuntimeException)
.call();
submissionExecutor.afterExecutions(orm);
@@ -608,95 +611,98 @@ public class MergeOp implements AutoCloseable {
private void integrateIntoHistory(
ChangeSet cs, SubmissionExecutor submissionExecutor, boolean checkSubmitRules)
throws RestApiException, UpdateException {
- checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
- logger.atFine().log("Beginning merge attempt on %s", cs);
- Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
+ try (RefUpdateContext ctx = RefUpdateContext.open(MERGE_CHANGE)) {
+ checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
+ logger.atFine().log("Beginning merge attempt on %s", cs);
+ Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
- ListMultimap<BranchNameKey, ChangeData> cbb;
- try {
- cbb = cs.changesByBranch();
- } catch (StorageException e) {
- throw new StorageException("Error reading changes to submit", e);
- }
- Set<BranchNameKey> branches = cbb.keySet();
+ ListMultimap<BranchNameKey, ChangeData> cbb;
+ try {
+ cbb = cs.changesByBranch();
+ } catch (StorageException e) {
+ throw new StorageException("Error reading changes to submit", e);
+ }
+ Set<BranchNameKey> branches = cbb.keySet();
- for (BranchNameKey branch : branches) {
- OpenRepo or = openRepo(branch.project());
- if (or != null) {
- toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
+ for (BranchNameKey branch : branches) {
+ OpenRepo or = openRepo(branch.project());
+ if (or != null) {
+ toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
+ }
}
- }
- // Done checks that don't involve running submit strategies.
- commitStatus.maybeFailVerbose();
+ // Done checks that don't involve running submit strategies.
+ commitStatus.maybeFailVerbose();
- try {
- SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
- SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
- UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
- List<SubmitStrategy> strategies =
- getSubmitStrategies(
- toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
- this.projects = updateOrderCalculator.getProjectsInOrder();
- List<BatchUpdate> batchUpdates =
- orm.batchUpdates(
- projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
- // Group batch updates by project
- Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
- batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
- for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
- Project.NameKey project = entry.getValue().project();
- Change.Id changeId = entry.getKey();
- ChangeData cd = entry.getValue();
- batchUpdatesByProject
- .get(project)
- .addOp(
- changeId,
- storeSubmitRequirementsOpFactory.create(
- cd.submitRequirementsIncludingLegacy().values(), cd));
- }
try {
- submissionExecutor.setAdditionalBatchUpdateListeners(
- ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
- submissionExecutor.execute(batchUpdates);
- } finally {
- // If the BatchUpdate fails it can be that merging some of the changes was actually
- // successful. This is why we must to collect the updated changes also when an
- // exception was thrown.
- strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
-
- // Do not leave executed BatchUpdates in the OpenRepos
- if (!dryrun) {
- orm.resetUpdates(ImmutableSet.copyOf(this.projects));
+ SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
+ SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
+ UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
+ List<SubmitStrategy> strategies =
+ getSubmitStrategies(
+ toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
+ this.projects = updateOrderCalculator.getProjectsInOrder();
+ List<BatchUpdate> batchUpdates =
+ orm.batchUpdates(
+ projects, /* refLogMessage= */ checkSubmitRules ? "merged" : "forced-merge");
+ // Group batch updates by project
+ Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
+ batchUpdates.stream()
+ .collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
+ for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
+ Project.NameKey project = entry.getValue().project();
+ Change.Id changeId = entry.getKey();
+ ChangeData cd = entry.getValue();
+ batchUpdatesByProject
+ .get(project)
+ .addOp(
+ changeId,
+ storeSubmitRequirementsOpFactory.create(
+ cd.submitRequirementsIncludingLegacy().values(), cd));
+ }
+ try {
+ submissionExecutor.setAdditionalBatchUpdateListeners(
+ ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
+ submissionExecutor.execute(batchUpdates);
+ } finally {
+ // If the BatchUpdate fails it can be that merging some of the changes was actually
+ // successful. This is why we must to collect the updated changes also when an
+ // exception was thrown.
+ strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
+
+ // Do not leave executed BatchUpdates in the OpenRepos
+ if (!dryrun) {
+ orm.resetUpdates(ImmutableSet.copyOf(this.projects));
+ }
+ }
+ } catch (NoSuchProjectException e) {
+ throw new ResourceNotFoundException(e.getMessage());
+ } catch (IOException e) {
+ throw new StorageException(e);
+ } catch (SubmoduleConflictException e) {
+ throw new IntegrationConflictException(e.getMessage(), e);
+ } catch (UpdateException e) {
+ if (e.getCause() instanceof LockFailureException) {
+ // Lock failures are a special case: RetryHelper depends on this specific causal chain in
+ // order to trigger a retry. The downside of throwing here is we will not get the nicer
+ // error message constructed below, in the case where this is the final attempt and the
+ // operation is not retried further. This is not a huge downside, and is hopefully so rare
+ // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
+ throw e;
}
- }
- } catch (NoSuchProjectException e) {
- throw new ResourceNotFoundException(e.getMessage());
- } catch (IOException e) {
- throw new StorageException(e);
- } catch (SubmoduleConflictException e) {
- throw new IntegrationConflictException(e.getMessage(), e);
- } catch (UpdateException e) {
- if (e.getCause() instanceof LockFailureException) {
- // Lock failures are a special case: RetryHelper depends on this specific causal chain in
- // order to trigger a retry. The downside of throwing here is we will not get the nicer
- // error message constructed below, in the case where this is the final attempt and the
- // operation is not retried further. This is not a huge downside, and is hopefully so rare
- // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
- throw e;
- }
- // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
- // thrown by some legacy SubmitStrategyOp code that intended the error
- // message to be user-visible. Copy the message from the wrapped
- // exception.
- //
- // If you happen across one of these, the correct fix is to convert the
- // inner IntegrationConflictException to a ResourceConflictException.
- if (e.getCause() instanceof IntegrationConflictException) {
- throw (IntegrationConflictException) e.getCause();
+ // BatchUpdate may have inadvertently wrapped an IntegrationConflictException
+ // thrown by some legacy SubmitStrategyOp code that intended the error
+ // message to be user-visible. Copy the message from the wrapped
+ // exception.
+ //
+ // If you happen across one of these, the correct fix is to convert the
+ // inner IntegrationConflictException to a ResourceConflictException.
+ if (e.getCause() instanceof IntegrationConflictException) {
+ throw (IntegrationConflictException) e.getCause();
+ }
+ throw new MergeUpdateException(genericMergeError(cs), e);
}
- throw new MergeUpdateException(genericMergeError(cs), e);
}
}
@@ -929,11 +935,13 @@ public class MergeOp implements AutoCloseable {
}
}
+ @Nullable
private SubmitType getSubmitType(ChangeData cd) {
SubmitTypeRecord str = cd.submitTypeRecord();
return str.isOk() ? str.type : null;
}
+ @Nullable
private OpenRepo openRepo(Project.NameKey project) {
try {
return orm.getRepo(project);
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index cfb2f885e3..5f58a744fe 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -20,6 +20,7 @@ import static com.google.gerrit.server.submit.CommitMergeStatus.EMPTY_COMMIT;
import static com.google.gerrit.server.submit.CommitMergeStatus.SKIPPED_IDENTICAL_TREE;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.exceptions.StorageException;
@@ -238,6 +239,7 @@ public class RebaseSubmitStrategy extends SubmitStrategy {
acceptMergeTip(args.mergeTip);
}
+ @Nullable
@Override
public PatchSet updateChangeImpl(ChangeContext ctx)
throws NoSuchChangeException, ResourceConflictException, IOException, BadRequestException {
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index f638078979..bdda3fc5dd 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -21,6 +21,7 @@ import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.SubmissionId;
@@ -76,6 +77,8 @@ import org.eclipse.jgit.revwalk.RevFlag;
* merged.
*/
public abstract class SubmitStrategy {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
public static Module module() {
return new FactoryModule() {
@Override
@@ -275,6 +278,7 @@ public abstract class SubmitStrategy {
Change.Id id = c.change().getId();
bu.addOp(id, args.setPrivateOpFactory.create(false, null));
ImplicitIntegrateOp implicitIntegrateOp = new ImplicitIntegrateOp(args, c);
+ logger.atFine().log("Add implicit integrate op: %s", implicitIntegrateOp);
bu.addOp(id, implicitIntegrateOp);
maybeAddTestHelperOp(bu, id);
this.submitStrategyOps.add(implicitIntegrateOp);
@@ -282,6 +286,7 @@ public abstract class SubmitStrategy {
// Then ops for explicitly merged changes
for (SubmitStrategyOp op : ops) {
+ logger.atFine().log("Add explicit integrate op: %s", op);
bu.addOp(op.getId(), args.setPrivateOpFactory.create(false, null));
bu.addOp(op.getId(), op);
maybeAddTestHelperOp(bu, op.getId());
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index d06940cae3..96dc326737 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -21,7 +21,9 @@ import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
+import com.google.common.base.MoreObjects;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
@@ -126,7 +128,8 @@ abstract class SubmitStrategyOp implements BatchUpdateOp {
logger.atFine().log("No merge tip, no update to perform");
return;
}
- logger.atFine().log("Moved tip from %s to %s", tipBefore, tipAfter);
+ logger.atFine().log(
+ "Moved tip from %s to %s (branch = %s)", tipBefore, tipAfter, getDest().branch());
checkProjectConfig(ctx, tipAfter);
@@ -158,6 +161,7 @@ abstract class SubmitStrategyOp implements BatchUpdateOp {
}
}
+ @Nullable
private CodeReviewCommit getAlreadyMergedCommit(RepoContext ctx) throws IOException {
CodeReviewCommit tip = args.mergeTip.getInitialTip();
if (tip == null) {
@@ -565,4 +569,14 @@ abstract class SubmitStrategyOp implements BatchUpdateOp {
e);
}
}
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("commit", getCommit().name())
+ .add("changeId", getId())
+ .add("dest", getDest().branch())
+ .add("project", getProject())
+ .toString();
+ }
}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
index 37df66b051..1fd3ad61db 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleCommits.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -18,6 +18,7 @@ import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.SubmoduleSubscription;
import com.google.gerrit.exceptions.StorageException;
@@ -212,6 +213,7 @@ class SubmoduleCommits {
return newCommit;
}
+ @Nullable
private RevCommit updateSubmodule(
DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
throws SubmoduleConflictException, IOException {
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index ba736fa149..cebb5e3c0e 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.submit;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.UPDATE_SUPERPROJECT;
+
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.BranchNameKey;
@@ -25,6 +27,7 @@ import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -103,10 +106,12 @@ public class SubmoduleOp {
}
}
}
- BatchUpdate.execute(
- orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
- ImmutableList.of(),
- dryrun);
+ try (RefUpdateContext ctx = RefUpdateContext.open(UPDATE_SUPERPROJECT)) {
+ BatchUpdate.execute(
+ orm.batchUpdates(superProjects, /* refLogMessage= */ "merged"),
+ ImmutableList.of(),
+ dryrun);
+ }
} catch (UpdateException | IOException | NoSuchProjectException e) {
throw new StorageException("Cannot update gitlinks", e);
}
diff --git a/java/com/google/gerrit/server/query/change/ConstantPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
index f0a85fe996..c493fa4348 100644
--- a/java/com/google/gerrit/server/query/change/ConstantPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/ConstantPredicate.java
@@ -12,8 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
import com.google.inject.Singleton;
/**
diff --git a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
index 5a51f5db6a..e3929899ae 100644
--- a/java/com/google/gerrit/server/query/change/DistinctVotersPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/DistinctVotersPredicate.java
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
@@ -22,6 +22,8 @@ import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.util.Optional;
diff --git a/java/com/google/gerrit/server/query/FileEditsPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
index 7058765a6d..515dc4a7bb 100644
--- a/java/com/google/gerrit/server/query/FileEditsPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/FileEditsPredicate.java
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.server.query;
+package com.google.gerrit.server.submitrequirement.predicate;
import com.google.auto.value.AutoValue;
import com.google.common.collect.Iterables;
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
new file mode 100644
index 0000000000..1774628f41
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/HasSubmoduleUpdatePredicate.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import static com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder.SUBMODULE_UPDATE_HAS_ARG;
+
+import com.google.gerrit.entities.Patch.FileMode;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.DiffOptions;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Submit requirement predicate that returns true if the diff of the latest patchset against the
+ * parent number identified by {@link #base} has a submodule modified file, that is, a .gitmodules
+ * or a git link file.
+ */
+public class HasSubmoduleUpdatePredicate extends SubmitRequirementPredicate {
+ private static final String GIT_MODULES_FILE = ".gitmodules";
+
+ private final DiffOperations diffOperations;
+ private final GitRepositoryManager repoManager;
+ private final int base;
+
+ public interface Factory {
+ HasSubmoduleUpdatePredicate create(int base);
+ }
+
+ @Inject
+ HasSubmoduleUpdatePredicate(
+ DiffOperations diffOperations, GitRepositoryManager repoManager, @Assisted int base) {
+ super("has", SUBMODULE_UPDATE_HAS_ARG);
+ this.diffOperations = diffOperations;
+ this.repoManager = repoManager;
+ this.base = base;
+ }
+
+ @Override
+ public boolean match(ChangeData cd) {
+ try {
+ try (Repository repo = repoManager.openRepository(cd.project());
+ RevWalk rw = new RevWalk(repo)) {
+ RevCommit revCommit = rw.parseCommit(cd.currentPatchSet().commitId());
+ if (base > revCommit.getParentCount()) {
+ return false;
+ }
+ }
+ Map<String, FileDiffOutput> diffList =
+ diffOperations.listModifiedFilesAgainstParent(
+ cd.project(), cd.currentPatchSet().commitId(), base, DiffOptions.DEFAULTS);
+ return diffList.values().stream().anyMatch(HasSubmoduleUpdatePredicate::isGitLink);
+ } catch (DiffNotAvailableException e) {
+ throw new StorageException(
+ String.format(
+ "Failed to evaluate the diff for commit %s against parent number %d",
+ cd.currentPatchSet().commitId(), base),
+ e);
+ } catch (IOException e) {
+ throw new StorageException(
+ String.format("Failed to open repo for project %s", cd.project()), e);
+ }
+ }
+
+ /**
+ * Return true if the modified file is a {@link #GIT_MODULES_FILE} or a git link regardless of if
+ * the modification type is add, remove or modify.
+ */
+ private static boolean isGitLink(FileDiffOutput fileDiffOutput) {
+ Optional<String> oldPath = fileDiffOutput.oldPath();
+ Optional<String> newPath = fileDiffOutput.newPath();
+ Optional<FileMode> oldMode = fileDiffOutput.oldMode();
+ Optional<FileMode> newMode = fileDiffOutput.newMode();
+
+ return (oldPath.isPresent() && oldPath.get().equals(GIT_MODULES_FILE))
+ || (newPath.isPresent() && newPath.get().equals(GIT_MODULES_FILE))
+ || (oldMode.isPresent() && oldMode.get().equals(FileMode.GITLINK))
+ || (newMode.isPresent() && newMode.get().equals(FileMode.GITLINK));
+ }
+
+ @Override
+ public int getCost() {
+ return 1;
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
index 22891bc980..eb7f666037 100644
--- a/java/com/google/gerrit/server/query/change/RegexAuthorEmailPredicate.java
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexAuthorEmailPredicate.java
@@ -12,9 +12,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.server.query.change;
+package com.google.gerrit.server.submitrequirement.predicate;
import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
import dk.brics.automaton.RegExp;
import dk.brics.automaton.RunAutomaton;
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java
new file mode 100644
index 0000000000..f991d311a0
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexCommitterEmailPredicate.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+/**
+ * A submit requirement predicate that matches with changes having the committer email's address
+ * matching a specific regular expression pattern.
+ */
+public class RegexCommitterEmailPredicate extends SubmitRequirementPredicate {
+ protected final RunAutomaton committerEmailPattern;
+
+ public RegexCommitterEmailPredicate(String pattern) throws QueryParseException {
+ super("committeremail", pattern);
+
+ if (pattern.startsWith("^")) {
+ pattern = pattern.substring(1);
+ }
+
+ if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+ pattern = pattern.substring(0, pattern.length() - 1);
+ }
+
+ try {
+ this.committerEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+ } catch (IllegalArgumentException e) {
+ throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+ }
+ }
+
+ @Override
+ public boolean match(ChangeData cd) {
+ return committerEmailPattern.run(cd.getCommitter().getEmailAddress());
+ }
+
+ @Override
+ public int getCost() {
+ return 1;
+ }
+}
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java
new file mode 100644
index 0000000000..95665468a2
--- /dev/null
+++ b/java/com/google/gerrit/server/submitrequirement/predicate/RegexUploaderEmailPredicate.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.submitrequirement.predicate;
+
+import com.google.auto.factory.AutoFactory;
+import com.google.auto.factory.Provided;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementPredicate;
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+import java.util.Optional;
+
+/**
+ * A submit requirement predicate that matches with changes having the uploader's email address
+ * matching a specific regular expression pattern.
+ */
+@AutoFactory
+public class RegexUploaderEmailPredicate extends SubmitRequirementPredicate {
+ protected final RunAutomaton uploaderEmailPattern;
+ private final AccountCache accountCache;
+
+ public RegexUploaderEmailPredicate(@Provided AccountCache accountCache, String pattern)
+ throws QueryParseException {
+ super("uploaderemail", pattern);
+ this.accountCache = accountCache;
+
+ if (pattern.startsWith("^")) {
+ pattern = pattern.substring(1);
+ }
+
+ if (pattern.endsWith("$") && !pattern.endsWith("\\$")) {
+ pattern = pattern.substring(0, pattern.length() - 1);
+ }
+
+ try {
+ this.uploaderEmailPattern = new RunAutomaton(new RegExp(pattern).toAutomaton());
+ } catch (IllegalArgumentException e) {
+ throw new QueryParseException(String.format("invalid regular expression: %s", pattern), e);
+ }
+ }
+
+ @Override
+ public boolean match(ChangeData cd) {
+ Optional<AccountState> accountState = accountCache.get(cd.currentPatchSet().uploader());
+ if (!accountState.isPresent()) {
+ return false;
+ }
+ String email = accountState.get().account().preferredEmail();
+ return email == null ? false : uploaderEmailPattern.run(email);
+ }
+
+ @Override
+ public int getCost() {
+ return 1;
+ }
+}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index a17d015531..92505138a4 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.update;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
import static com.google.common.flogger.LazyArgs.lazy;
import static java.util.Comparator.comparing;
@@ -23,6 +24,8 @@ import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Throwables;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
@@ -34,6 +37,7 @@ import com.google.common.collect.Multiset;
import com.google.common.flogger.FluentLogger;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.BranchNameKey;
@@ -50,8 +54,12 @@ import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.RefLogIdentityProvider;
+import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.change.NotifyResolver;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
import com.google.gerrit.server.extensions.events.AttentionSetObserver;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
@@ -243,6 +251,12 @@ public class BatchUpdate implements AutoCloseable {
}
class ContextImpl implements Context {
+ private final CurrentUser contextUser;
+
+ ContextImpl(@Nullable CurrentUser contextUser) {
+ this.contextUser = contextUser != null ? contextUser : user;
+ }
+
@Override
public RepoView getRepoView() throws IOException {
return BatchUpdate.this.getRepoView();
@@ -270,7 +284,7 @@ public class BatchUpdate implements AutoCloseable {
@Override
public CurrentUser getUser() {
- return user;
+ return contextUser;
}
@Override
@@ -281,6 +295,10 @@ public class BatchUpdate implements AutoCloseable {
}
private class RepoContextImpl extends ContextImpl implements RepoContext {
+ RepoContextImpl(@Nullable CurrentUser contextUser) {
+ super(contextUser);
+ }
+
@Override
public ObjectInserter getInserter() throws IOException {
return getRepoView().getInserterWrapper();
@@ -296,21 +314,22 @@ public class BatchUpdate implements AutoCloseable {
private final ChangeNotes notes;
/**
- * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
- * PatchSet.Id only for convenience.
+ * Updates where the caller allowed us to combine potentially multiple adjustments into a single
+ * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
+ * patch set.
*/
private final Map<PatchSet.Id, ChangeUpdate> defaultUpdates;
/**
- * Updates where the caller allowed us to combine potentially multiple adjustments into a single
- * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
- * patch set.
+ * Updates where the caller instructed us to create one NoteDb commit per update. Keyed by
+ * PatchSet.Id only for convenience.
*/
private final ListMultimap<PatchSet.Id, ChangeUpdate> distinctUpdates;
private boolean deleted;
- ChangeContextImpl(ChangeNotes notes) {
+ ChangeContextImpl(@Nullable CurrentUser contextUser, ChangeNotes notes) {
+ super(contextUser);
this.notes = requireNonNull(notes);
defaultUpdates = new TreeMap<>(comparing(PatchSet.Id::get));
distinctUpdates = ArrayListMultimap.create();
@@ -334,7 +353,7 @@ public class BatchUpdate implements AutoCloseable {
}
private ChangeUpdate getNewChangeUpdate(PatchSet.Id psId) {
- ChangeUpdate u = changeUpdateFactory.create(notes, user, when);
+ ChangeUpdate u = changeUpdateFactory.create(notes, getUser(), getWhen());
if (newChanges.containsKey(notes.getChangeId())) {
u.setAllowWriteToNewRef(true);
}
@@ -356,7 +375,9 @@ public class BatchUpdate implements AutoCloseable {
private class PostUpdateContextImpl extends ContextImpl implements PostUpdateContext {
private final Map<Change.Id, ChangeData> changeDatas;
- PostUpdateContextImpl(Map<Change.Id, ChangeData> changeDatas) {
+ PostUpdateContextImpl(
+ @Nullable CurrentUser contextUser, Map<Change.Id, ChangeData> changeDatas) {
+ super(contextUser);
this.changeDatas = changeDatas;
}
@@ -374,29 +395,37 @@ public class BatchUpdate implements AutoCloseable {
/** Per-change result status from {@link #executeChangeOps}. */
private enum ChangeResult {
+ /** Change was not modified by any of the batch update ops. */
SKIPPED,
+
+ /** Change was inserted or updated. */
UPSERTED,
+
+ /** Change was deleted. */
DELETED
}
private final GitRepositoryManager repoManager;
+ private final AccountCache accountCache;
private final ChangeData.Factory changeDataFactory;
private final ChangeNotes.Factory changeNotesFactory;
private final ChangeUpdate.Factory changeUpdateFactory;
private final NoteDbUpdateManager.Factory updateManagerFactory;
private final ChangeIndexer indexer;
private final GitReferenceUpdated gitRefUpdated;
+ private final RefLogIdentityProvider refLogIdentityProvider;
private final Project.NameKey project;
private final CurrentUser user;
private final Instant when;
private final ZoneId zoneId;
- private final ListMultimap<Change.Id, BatchUpdateOp> ops =
+ private final ListMultimap<Change.Id, OpData<BatchUpdateOp>> ops =
MultimapBuilder.linkedHashKeys().arrayListValues().build();
private final Map<Change.Id, Change> newChanges = new HashMap<>();
- private final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
+ private final List<OpData<RepoOnlyOp>> repoOnlyOps = new ArrayList<>();
private final Map<Change.Id, NotifyHandling> perChangeNotifyHandling = new HashMap<>();
+ private final ExperimentFeatures experimentFeatures;
private RepoView repoView;
private BatchRefUpdate batchRefUpdate;
@@ -414,27 +443,33 @@ public class BatchUpdate implements AutoCloseable {
BatchUpdate(
GitRepositoryManager repoManager,
@GerritPersonIdent PersonIdent serverIdent,
+ AccountCache accountCache,
ChangeData.Factory changeDataFactory,
ChangeNotes.Factory changeNotesFactory,
ChangeUpdate.Factory changeUpdateFactory,
NoteDbUpdateManager.Factory updateManagerFactory,
ChangeIndexer indexer,
GitReferenceUpdated gitRefUpdated,
+ RefLogIdentityProvider refLogIdentityProvider,
AttentionSetObserver attentionSetObserver,
+ ExperimentFeatures experimentFeatures,
@Assisted Project.NameKey project,
@Assisted CurrentUser user,
@Assisted Instant when) {
this.repoManager = repoManager;
+ this.accountCache = accountCache;
this.changeDataFactory = changeDataFactory;
this.changeNotesFactory = changeNotesFactory;
this.changeUpdateFactory = changeUpdateFactory;
this.updateManagerFactory = updateManagerFactory;
this.indexer = indexer;
this.gitRefUpdated = gitRefUpdated;
+ this.refLogIdentityProvider = refLogIdentityProvider;
+ this.attentionSetObserver = attentionSetObserver;
+ this.experimentFeatures = experimentFeatures;
this.project = project;
this.user = user;
this.when = when;
- this.attentionSetObserver = attentionSetObserver;
zoneId = serverIdent.getZoneId();
}
@@ -544,48 +579,77 @@ public class BatchUpdate implements AutoCloseable {
toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
}
+ /**
+ * Adds a {@link BatchUpdate} for a change.
+ *
+ * <p>The op is executed by the user for which the {@link BatchUpdate} has been created.
+ */
+ @CanIgnoreReturnValue
public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
requireNonNull(op);
- ops.put(id, op);
+ ops.put(id, OpData.create(op, user));
+ return this;
+ }
+
+ /** Adds a {@link BatchUpdate} for a change that should be executed by the given context user. */
+ @CanIgnoreReturnValue
+ public BatchUpdate addOp(Change.Id id, CurrentUser contextUser, BatchUpdateOp op) {
+ checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
+ requireNonNull(op);
+ ops.put(id, OpData.create(op, contextUser));
return this;
}
+ /**
+ * Adds a {@link RepoOnlyOp}.
+ *
+ * <p>The op is executed by the user for which the {@link BatchUpdate} has been created.
+ */
+ @CanIgnoreReturnValue
public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
- repoOnlyOps.add(op);
+ repoOnlyOps.add(OpData.create(op, user));
+ return this;
+ }
+
+ /** Adds a {@link RepoOnlyOp} that should be executed by the given context user. */
+ @CanIgnoreReturnValue
+ public BatchUpdate addRepoOnlyOp(CurrentUser contextUser, RepoOnlyOp op) {
+ checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
+ repoOnlyOps.add(OpData.create(op, contextUser));
return this;
}
+ @CanIgnoreReturnValue
public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
- Context ctx = new ContextImpl();
+ Context ctx = new ContextImpl(user);
Change c = op.createChange(ctx);
checkArgument(
!newChanges.containsKey(c.getId()), "only one op allowed to create change %s", c.getId());
newChanges.put(c.getId(), c);
- ops.get(c.getId()).add(0, op);
+ ops.get(c.getId()).add(0, OpData.create(op, user));
return this;
}
private void executeUpdateRepo() throws UpdateException, RestApiException {
try {
logDebug("Executing updateRepo on %d ops", ops.size());
- RepoContextImpl ctx = new RepoContextImpl();
- for (Map.Entry<Change.Id, BatchUpdateOp> op : ops.entries()) {
+ for (Map.Entry<Change.Id, OpData<BatchUpdateOp>> e : ops.entries()) {
+ BatchUpdateOp op = e.getValue().op();
+ RepoContextImpl ctx = new RepoContextImpl(e.getValue().user());
try (TraceContext.TraceTimer ignored =
TraceContext.newTimer(
op.getClass().getSimpleName() + "#updateRepo",
- Metadata.builder()
- .projectName(project.get())
- .changeId(op.getKey().get())
- .build())) {
- op.getValue().updateRepo(ctx);
+ Metadata.builder().projectName(project.get()).changeId(e.getKey().get()).build())) {
+ op.updateRepo(ctx);
}
}
logDebug("Executing updateRepo on %d RepoOnlyOps", repoOnlyOps.size());
- for (RepoOnlyOp op : repoOnlyOps) {
- op.updateRepo(ctx);
+ for (OpData<RepoOnlyOp> opData : repoOnlyOps) {
+ RepoContextImpl ctx = new RepoContextImpl(opData.user());
+ opData.op().updateRepo(ctx);
}
if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
@@ -594,7 +658,7 @@ public class BatchUpdate implements AutoCloseable {
// first update's executeRefUpdates has finished, hence after first repo's refs have been
// updated, which is too late.
onSubmitValidators.validate(
- project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+ project, getRepoView().getRevWalk().getObjectReader(), repoView.getCommands());
}
} catch (Exception e) {
Throwables.throwIfInstanceOf(e, RestApiException.class);
@@ -602,18 +666,25 @@ public class BatchUpdate implements AutoCloseable {
}
}
+ private boolean indexAsync() {
+ return experimentFeatures.isFeatureEnabled(
+ ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_DO_NOT_AWAIT_CHANGE_INDEXING);
+ }
+
private void fireRefChangeEvent() {
if (batchRefUpdate != null) {
gitRefUpdated.fire(project, batchRefUpdate, getAccount().orElse(null));
}
}
- private void fireAttentionSetUpdateEvents(PostUpdateContext ctx) {
+ private void fireAttentionSetUpdateEvents(Map<Change.Id, ChangeData> changeDatas) {
for (ProjectChangeKey key : attentionSetUpdates.keySet()) {
- ChangeData change = ctx.getChangeData(key.projectName(), key.changeId());
- AccountState account = getAccount().orElse(null);
+ ChangeData change =
+ changeDatas.computeIfAbsent(
+ key.changeId(), id -> changeDataFactory.create(key.projectName(), key.changeId()));
for (AttentionSetUpdate update : attentionSetUpdates.get(key)) {
- attentionSetObserver.fire(change, account, update, ctx.getWhen());
+ attentionSetObserver.fire(
+ change, accountCache.getEvenIfMissing(update.account()), update, when);
}
}
}
@@ -622,11 +693,13 @@ public class BatchUpdate implements AutoCloseable {
private final NoteDbUpdateManager manager;
private final boolean dryrun;
private final Map<Change.Id, ChangeResult> results;
+ private final boolean indexAsync;
- ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+ ChangesHandle(NoteDbUpdateManager manager, boolean dryrun, boolean indexAsync) {
this.manager = manager;
this.dryrun = dryrun;
results = new HashMap<>();
+ this.indexAsync = indexAsync;
}
@Override
@@ -669,7 +742,7 @@ public class BatchUpdate implements AutoCloseable {
indexFutures.add(indexer.indexAsync(project, id));
break;
case DELETED:
- indexFutures.add(indexer.deleteAsync(id));
+ indexFutures.add(indexer.deleteAsync(project, id));
break;
case SKIPPED:
break;
@@ -677,6 +750,17 @@ public class BatchUpdate implements AutoCloseable {
throw new IllegalStateException("unexpected result: " + e.getValue());
}
}
+ if (indexAsync) {
+ // We want to index asynchronously. However, the callers will await all
+ // index futures. This allows us to - even in synchronous case -
+ // parallelize indexing changes.
+ // Returning immediate futures for newly-created change data objects
+ // while letting the actual futures go will make actual indexing
+ // asynchronous.
+ return results.keySet().stream()
+ .map(cId -> Futures.immediateFuture(changeDataFactory.create(project, cId)))
+ .collect(toImmutableList());
+ }
return indexFutures.build();
}
}
@@ -698,37 +782,50 @@ public class BatchUpdate implements AutoCloseable {
.setBatchUpdateListeners(batchUpdateListeners)
.setChangeRepo(
repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
- dryrun);
- if (user.isIdentifiedUser()) {
- handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, zoneId));
- }
+ dryrun,
+ indexAsync());
+ getRefLogIdent().ifPresent(handle.manager::setRefLogIdent);
handle.manager.setRefLogMessage(refLogMessage);
handle.manager.setPushCertificate(pushCert);
- for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+ for (Map.Entry<Change.Id, Collection<OpData<BatchUpdateOp>>> e : ops.asMap().entrySet()) {
Change.Id id = e.getKey();
- ChangeContextImpl ctx = newChangeContext(id);
boolean dirty = false;
+ boolean deleted = false;
+ List<ChangeUpdate> changeUpdates = new ArrayList<>();
+ ChangeContextImpl ctx = null;
logDebug(
"Applying %d ops for change %s: %s",
e.getValue().size(),
id,
lazy(() -> e.getValue().stream().map(op -> op.getClass().getName()).collect(toSet())));
- for (BatchUpdateOp op : e.getValue()) {
+ for (OpData<BatchUpdateOp> opData : e.getValue()) {
+ if (ctx == null) {
+ ctx = newChangeContext(opData.user(), id);
+ } else if (!ctx.getUser().equals(opData.user())) {
+ ctx.defaultUpdates.values().forEach(changeUpdates::add);
+ ctx.distinctUpdates.values().forEach(changeUpdates::add);
+ ctx = newChangeContext(opData.user(), id);
+ }
try (TraceContext.TraceTimer ignored =
TraceContext.newTimer(
- op.getClass().getSimpleName() + "#updateChange",
+ opData.getClass().getSimpleName() + "#updateChange",
Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
- dirty |= op.updateChange(ctx);
+ dirty |= opData.op().updateChange(ctx);
+ deleted |= ctx.deleted;
}
}
+ if (ctx != null) {
+ ctx.defaultUpdates.values().forEach(changeUpdates::add);
+ ctx.distinctUpdates.values().forEach(changeUpdates::add);
+ }
+
if (!dirty) {
logDebug("No ops reported dirty, short-circuiting");
handle.setResult(id, ChangeResult.SKIPPED);
continue;
}
- ctx.defaultUpdates.values().forEach(handle.manager::add);
- ctx.distinctUpdates.values().forEach(handle.manager::add);
- if (ctx.deleted) {
+ changeUpdates.forEach(handle.manager::add);
+ if (deleted) {
logDebug("Change %s was deleted", id);
handle.manager.deleteChange(id);
handle.setResult(id, ChangeResult.DELETED);
@@ -739,7 +836,48 @@ public class BatchUpdate implements AutoCloseable {
return handle;
}
- private ChangeContextImpl newChangeContext(Change.Id id) {
+ /**
+ * Creates the ref log identity that should be used for the ref updates that are done by this
+ * {@code BatchUpdate}.
+ *
+ * <p>The ref log identity is created for the users for which operations should be executed. If
+ * all operations are executed by the same user the ref log identity is created for that user. If
+ * operations are executed for multiple users a shared reflog identity is created.
+ */
+ @VisibleForTesting
+ Optional<PersonIdent> getRefLogIdent() {
+ if (ops.isEmpty()) {
+ return Optional.empty();
+ }
+
+ // If all updates are done by identified users, create a shared ref log identity.
+ if (ops.values().stream()
+ .map(OpData::user)
+ .allMatch(currentUser -> currentUser.isIdentifiedUser())) {
+ return Optional.of(
+ refLogIdentityProvider.newRefLogIdent(
+ ops.values().stream()
+ .map(OpData::user)
+ .map(CurrentUser::asIdentifiedUser)
+ .collect(toImmutableList()),
+ when,
+ zoneId));
+ }
+
+ // Fail if some but not all updates are done by identified users. At the moment we do not
+ // support batching updates of identified users and non-identified users (e.g. updates done on
+ // behalf of the server).
+ checkState(
+ ops.values().stream()
+ .map(OpData::user)
+ .noneMatch(currentUser -> currentUser.isIdentifiedUser()),
+ "batching updates of identified users and non-identified users is not supported");
+
+ // As fallback the server identity will be used as the ref log identity.
+ return Optional.empty();
+ }
+
+ private ChangeContextImpl newChangeContext(@Nullable CurrentUser contextUser, Change.Id id) {
logDebug("Opening change %s for update", id);
Change c = newChanges.get(id);
boolean isNew = c != null;
@@ -752,27 +890,30 @@ public class BatchUpdate implements AutoCloseable {
logDebug("Change %s is new", id);
}
ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
- return new ChangeContextImpl(notes);
+ return new ChangeContextImpl(contextUser, notes);
}
private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
- PostUpdateContextImpl ctx = new PostUpdateContextImpl(changeDatas);
- for (BatchUpdateOp op : ops.values()) {
+ for (OpData<BatchUpdateOp> opData : ops.values()) {
+ PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
try (TraceContext.TraceTimer ignored =
- TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
- op.postUpdate(ctx);
+ TraceContext.newTimer(
+ opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+ opData.op().postUpdate(ctx);
}
}
- for (RepoOnlyOp op : repoOnlyOps) {
+ for (OpData<RepoOnlyOp> opData : repoOnlyOps) {
+ PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas);
try (TraceContext.TraceTimer ignored =
- TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
- op.postUpdate(ctx);
+ TraceContext.newTimer(
+ opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
+ opData.op().postUpdate(ctx);
}
}
try (TraceContext.TraceTimer ignored =
TraceContext.newTimer("fireAttentionSetUpdates#postUpdate", Metadata.empty())) {
- fireAttentionSetUpdateEvents(ctx);
+ fireAttentionSetUpdateEvents(changeDatas);
}
}
@@ -803,4 +944,18 @@ public class BatchUpdate implements AutoCloseable {
logger.atFine().log(msg, arg1, arg2, arg3);
}
}
+
+ /** Data needed to execute a {@link RepoOnlyOp} or a {@link BatchUpdateOp}. */
+ @AutoValue
+ abstract static class OpData<T extends RepoOnlyOp> {
+ /** Op that should be executed. */
+ abstract T op();
+
+ /** User that should be used to execute the {@link #op}. */
+ abstract CurrentUser user();
+
+ static <T extends RepoOnlyOp> OpData<T> create(T op, CurrentUser user) {
+ return new AutoValue_BatchUpdate_OpData<>(op, user);
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/update/context/RefUpdateContext.java b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
new file mode 100644
index 0000000000..56d536adcc
--- /dev/null
+++ b/java/com/google/gerrit/server/update/context/RefUpdateContext.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update.context;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+/**
+ * Passes additional information about an operation to the {@code BatchRefUpdate#execute} method.
+ *
+ * <p>To pass the additional information {@link RefUpdateContext}, wraps a code into an open
+ * RefUpdateContext, e.g.:
+ *
+ * <pre>{@code
+ * try(RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.CHANGE_MODIFICATION)) {
+ * ...
+ * // some code which modifies a ref using BatchRefUpdate.execute method
+ * }
+ * }</pre>
+ *
+ * When the {@code BatchRefUpdate#execute} method is executed, it can get all opened contexts and
+ * use it for an additional actions, e.g. it can put it in the reflog.
+ *
+ * <p>The information provided by this class is used internally in google.
+ *
+ * <p>The InMemoryRepositoryManager file makes some validation to ensure that RefUpdateContext is
+ * used correctly within the code (see thee validateRefUpdateContext method).
+ *
+ * <p>The class includes only operations from open-source gerrit and can be extended (see {@code
+ * TestActionRefUpdateContext} for example how to extend it).
+ */
+public class RefUpdateContext implements AutoCloseable {
+ private static final ThreadLocal<Deque<RefUpdateContext>> current = new ThreadLocal<>();
+
+ /**
+ * List of possible ref-update types.
+ *
+ * <p>Items in this enum are not fine-grained; different actions are shared the same type (e.g.
+ * {@link #CHANGE_MODIFICATION} includes posting comments, change edits and attention set update).
+ *
+ * <p>It is expected, that each type of operation should include only specific ref(s); check the
+ * validateRefUpdateContext in InMemoryRepositoryManager for relation between RefUpdateType and
+ * ref name.
+ */
+ public enum RefUpdateType {
+ /**
+ * Indicates that the context is implemented as a descendant of the {@link RefUpdateContext} .
+ *
+ * <p>The {@link #getUpdateType()} returns this type for all descendant of {@link
+ * RefUpdateContext}. This type is never returned if the context is exactly {@link
+ * RefUpdateContext}.
+ */
+ OTHER,
+ /**
+ * A ref is updated as a part of change-related operation.
+ *
+ * <p>This covers multiple different cases - creating and uploading changes and patchsets,
+ * comments operations, change edits, etc...
+ */
+ CHANGE_MODIFICATION,
+ /** A ref is updated during merge-change operation. */
+ MERGE_CHANGE,
+ /** A ref is updated as a part of a repo sequence operation. */
+ REPO_SEQ,
+ /** A ref is updated as a part of a repo initialization. */
+ INIT_REPO,
+ /** A ref is udpated as a part of gpg keys modification. */
+ GPG_KEYS_MODIFICATION,
+ /** A ref is updated as a part of group(s) update */
+ GROUPS_UPDATE,
+ /** A ref is updated as a part of account(s) update. */
+ ACCOUNTS_UPDATE,
+ /** A ref is updated as a part of direct push. */
+ DIRECT_PUSH,
+ /** A ref is updated as a part of explicit branch or ref update operation. */
+ BRANCH_MODIFICATION,
+ /** A ref is updated as a part of explicit tag update operation. */
+ TAG_MODIFICATION,
+ /**
+ * A tag is updated as a part of an offline operation.
+ *
+ * <p>Offline operation - an operation which is executed separately from the gerrit server and
+ * can't be triggered by any gerrit API. E.g. schema update.
+ */
+ OFFLINE_OPERATION,
+ /** A tag is updated as a part of an update-superproject flow. */
+ UPDATE_SUPERPROJECT,
+ /** A ref is updated as a part of explicit HEAD update operation. */
+ HEAD_MODIFICATION,
+ /** A ref is updated as a part of versioned meta data change. */
+ VERSIONED_META_DATA_CHANGE,
+ /** A ref is updated as a part of commit-ban operation. */
+ BAN_COMMIT,
+ /**
+ * A ref is updated inside a plugin.
+ *
+ * <p>If a plugin updates one of a special refs - it must also open a nested context.
+ */
+ PLUGIN,
+ /** A ref is updated as a part of auto-close-changes. */
+ AUTO_CLOSE_CHANGES
+ }
+
+ /** Opens a provided context. */
+ protected static <T extends RefUpdateContext> T open(T ctx) {
+ getCurrent().addLast(ctx);
+ return ctx;
+ }
+
+ /** Opens a context of a give type. */
+ public static RefUpdateContext open(RefUpdateType updateType) {
+ checkArgument(updateType != RefUpdateType.OTHER, "The OTHER type is for internal use only.");
+ return open(new RefUpdateContext(updateType));
+ }
+
+ /** Returns the list of opened contexts; the first element is the outermost context. */
+ public static ImmutableList<RefUpdateContext> getOpenedContexts() {
+ return ImmutableList.copyOf(getCurrent());
+ }
+
+ /** Checks if there is an open context of the given type. */
+ public static boolean hasOpen(RefUpdateType type) {
+ return getCurrent().stream().anyMatch(ctx -> ctx.getUpdateType() == type);
+ }
+
+ private final RefUpdateType updateType;
+
+ private RefUpdateContext(RefUpdateType updateType) {
+ this.updateType = updateType;
+ }
+
+ protected RefUpdateContext() {
+ this(RefUpdateType.OTHER);
+ }
+
+ protected static final Deque<RefUpdateContext> getCurrent() {
+ Deque<RefUpdateContext> result = current.get();
+ if (result == null) {
+ result = new ArrayDeque<>();
+ current.set(result);
+ }
+ return result;
+ }
+
+ /**
+ * Returns the type of {@link RefUpdateContext}.
+ *
+ * <p>For descendants, always return {@link RefUpdateType#OTHER}
+ */
+ public final RefUpdateType getUpdateType() {
+ return updateType;
+ }
+
+ /** Closes the current context. */
+ @Override
+ public void close() {
+ Deque<RefUpdateContext> openedContexts = getCurrent();
+ checkState(
+ openedContexts.peekLast() == this, "The current context is different from this context.");
+ openedContexts.removeLast();
+ }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 1b36139f79..948b6e3d3b 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -18,7 +18,6 @@ import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.config.SendEmailExecutor;
import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
@@ -86,7 +85,7 @@ public class AttentionSetEmail {
this.asyncSender =
new AsyncSender(
requestContext,
- ctx.getIdentifiedUser(),
+ ctx.getUser(),
sender,
messageId,
ctx.getNotify(change.getId()),
@@ -108,7 +107,7 @@ public class AttentionSetEmail {
*/
private static class AsyncSender implements Runnable, RequestContext {
private final ThreadLocalRequestContext requestContext;
- private final IdentifiedUser user;
+ private final CurrentUser user;
private final AttentionSetSender sender;
private final MessageIdGenerator.MessageId messageId;
private final NotifyResolver.Result notify;
@@ -118,7 +117,7 @@ public class AttentionSetEmail {
AsyncSender(
ThreadLocalRequestContext requestContext,
- IdentifiedUser user,
+ CurrentUser user,
AttentionSetSender sender,
MessageIdGenerator.MessageId messageId,
NotifyResolver.Result notify,
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index 038fe2c092..fbcf3ce929 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -19,6 +19,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PatchSetApproval;
/** A single vote on a label, consisting of a label name and a value. */
@AutoValue
@@ -68,6 +69,10 @@ public abstract class LabelVote {
return new AutoValue_LabelVote(LabelType.checkNameInternal(label), value);
}
+ public static LabelVote createFrom(PatchSetApproval psa) {
+ return create(psa.label(), psa.value());
+ }
+
public abstract String label();
public abstract short value();
diff --git a/java/com/google/gerrit/server/util/MagicBranch.java b/java/com/google/gerrit/server/util/MagicBranch.java
index 924c2887c6..a5ce108713 100644
--- a/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/java/com/google/gerrit/server/util/MagicBranch.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.util;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.Capable;
import com.google.gerrit.entities.Project;
import java.io.IOException;
@@ -38,6 +39,7 @@ public final class MagicBranch {
}
/** Returns the ref name prefix for a magic branch, {@code null} if the branch is not magic */
+ @Nullable
public static String getMagicRefNamePrefix(String refName) {
if (refName.startsWith(NEW_CHANGE)) {
return NEW_CHANGE;
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index bbc6bf0916..83a230d226 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -5,6 +5,7 @@ java_library(
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//lib:jgit",
"//lib/flogger:api",
diff --git a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
index 97132a32ae..201a9b7cd6 100644
--- a/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
+++ b/java/com/google/gerrit/server/util/git/SubmoduleSectionParser.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.util.git;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmoduleSubscription;
@@ -65,6 +66,7 @@ public class SubmoduleSectionParser {
return parsedSubscriptions;
}
+ @Nullable
private SubmoduleSubscription parse(String id) {
final String url = config.getString("submodule", id, "url");
final String path = config.getString("submodule", id, "path");
diff --git a/java/com/google/gerrit/server/validators/AssigneeValidationListener.java b/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
deleted file mode 100644
index 514125f0cd..0000000000
--- a/java/com/google/gerrit/server/validators/AssigneeValidationListener.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.validators;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-
-/** Listener to provide validation of assignees. */
-@ExtensionPoint
-public interface AssigneeValidationListener {
- /**
- * Invoked by Gerrit before the assignee of a change is modified.
- *
- * @param change the change on which the assignee is changed
- * @param assignee the new assignee. Null if removed
- * @throws ValidationException if validation fails
- */
- void validateAssignee(Change change, Account assignee) throws ValidationException;
-}
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 547aff32fe..a77ada4f8d 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -544,6 +544,7 @@ public abstract class BaseCommand implements Command {
}
@Override
+ @Nullable
public String getRemoteName() {
return null;
}
diff --git a/java/com/google/gerrit/sshd/Commands.java b/java/com/google/gerrit/sshd/Commands.java
index b6d3401955..5d641a0dfa 100644
--- a/java/com/google/gerrit/sshd/Commands.java
+++ b/java/com/google/gerrit/sshd/Commands.java
@@ -15,6 +15,7 @@
package com.google.gerrit.sshd;
import com.google.auto.value.AutoAnnotation;
+import com.google.gerrit.common.Nullable;
import com.google.inject.Key;
import java.lang.annotation.Annotation;
import org.apache.sshd.server.command.Command;
@@ -78,6 +79,7 @@ public class Commands {
return false;
}
+ @Nullable
static CommandName parentOf(CommandName name) {
if (name instanceof NestedCommandNameImpl) {
return ((NestedCommandNameImpl) name).parent;
diff --git a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index 6997d9625b..401d31e629 100644
--- a/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -22,6 +22,7 @@ import com.google.common.base.Splitter;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.BaseEncoding;
import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PeerDaemonUser;
import com.google.gerrit.server.account.AccountSshKey;
@@ -169,6 +170,7 @@ class DatabasePubKeyAuth implements PublickeyAuthenticator {
return p.keys;
}
+ @Nullable
private SshKeyCacheEntry find(Iterable<SshKeyCacheEntry> keyList, PublicKey suppliedKey) {
for (SshKeyCacheEntry k : keyList) {
if (k.match(suppliedKey)) {
diff --git a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
index 5b6d8f9771..7adcd24095 100644
--- a/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
+++ b/java/com/google/gerrit/sshd/SshAutoRegisterModuleGenerator.java
@@ -19,6 +19,7 @@ import static com.google.gerrit.server.plugins.AutoRegisterUtil.calculateBindAnn
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.ListMultimap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.annotations.Export;
import com.google.gerrit.server.plugins.InvalidPluginException;
import com.google.gerrit.server.plugins.ModuleGenerator;
@@ -84,6 +85,7 @@ class SshAutoRegisterModuleGenerator extends AbstractModule implements ModuleGen
listeners.put(tl, clazz);
}
+ @Nullable
@Override
public Module create() throws InvalidPluginException {
checkState(command != null, "pluginName must be provided");
diff --git a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 55ecdfeecf..f807b19c16 100644
--- a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -15,6 +15,7 @@
package com.google.gerrit.sshd;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.plugins.Plugin;
@@ -61,6 +62,7 @@ class SshPluginStarterCallback implements StartPluginListener, ReloadPluginListe
}
}
+ @Nullable
private Provider<Command> load(Plugin plugin) {
if (plugin.getSshInjector() != null) {
Key<Command> key = Commands.key(plugin.getName());
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 4da55e22f5..2e292036a9 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -17,6 +17,7 @@ package com.google.gerrit.sshd.commands;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -87,6 +88,7 @@ final class CreateAccountCommand extends SshCommand {
}
}
+ @Nullable
private String readSshKey() throws IOException {
if (sshKey == null) {
return null;
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index e0805c0391..143b060e36 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -56,6 +56,7 @@ import java.lang.reflect.AnnotatedElement;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
+import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -358,7 +359,7 @@ public class ReviewCommand extends SshCommand {
}
private static String asOptionName(LabelType type) {
- return "--" + type.getName().toLowerCase();
+ return "--" + type.getName().toLowerCase(Locale.US);
}
private static Option newApproveOption(LabelType type, String usage) {
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 4254e5b3cf..00361add83 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -134,7 +134,9 @@ final class ShowQueue extends SshCommand {
switch (task.state) {
case DONE:
case CANCELLED:
+ case STARTING:
case RUNNING:
+ case STOPPING:
case READY:
start = format(task.state);
break;
@@ -204,8 +206,12 @@ final class ShowQueue extends SshCommand {
return "....... done";
case CANCELLED:
return "..... killed";
+ case STOPPING:
+ return "... stopping";
case RUNNING:
return "";
+ case STARTING:
+ return "starting ...";
case READY:
return "waiting ....";
case SLEEPING:
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index e5234fe27e..81a6443dad 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -5,7 +5,10 @@ java_library(
testonly = True,
srcs = glob(
["**/*.java"],
- exclude = ["AssertableExecutorService.java"],
+ exclude = [
+ "AssertableExecutorService.java",
+ "TestActionRefUpdateContext.java",
+ ],
),
visibility = ["//visibility:public"],
exports = [
@@ -40,6 +43,7 @@ java_library(
"//java/com/google/gerrit/server/restapi",
"//java/com/google/gerrit/server/schema",
"//java/com/google/gerrit/server/util/time",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:guava",
"//lib:h2",
"//lib:jgit",
@@ -47,6 +51,7 @@ java_library(
"//lib:junit",
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
+ "//lib/errorprone:annotations",
"//lib/flogger:api",
"//lib/guice",
"//lib/guice:guice-servlet",
@@ -66,3 +71,14 @@ java_library(
"//lib/truth",
],
)
+
+java_library(
+ name = "test-ref-update-context",
+ testonly = True,
+ srcs = ["TestActionRefUpdateContext.java"],
+ visibility = ["//visibility:public"],
+ deps = [
+ "//java/com/google/gerrit/server",
+ "//lib/errorprone:annotations",
+ ],
+)
diff --git a/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
new file mode 100644
index 0000000000..1533aebec4
--- /dev/null
+++ b/java/com/google/gerrit/testing/FakeAccountPatchReviewStore.java
@@ -0,0 +1,141 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.change.AccountPatchReviewStore;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * An implementation of the {@link AccountPatchReviewStore} that's only used in tests. This
+ * implementation stores reviewed files in memory.
+ */
+@Singleton
+public class FakeAccountPatchReviewStore implements AccountPatchReviewStore, LifecycleListener {
+
+ private final Set<Entity> store = new HashSet<>();
+
+ @Override
+ public void start() {}
+
+ @Override
+ public void stop() {}
+
+ public static class FakeAccountPatchReviewStoreModule extends LifecycleModule {
+ @Override
+ protected void configure() {
+ DynamicItem.bind(binder(), AccountPatchReviewStore.class)
+ .to(FakeAccountPatchReviewStore.class);
+ listener().to(FakeAccountPatchReviewStore.class);
+ }
+ }
+
+ @AutoValue
+ abstract static class Entity {
+ abstract PatchSet.Id psId();
+
+ abstract Account.Id accountId();
+
+ abstract String path();
+
+ static Entity create(PatchSet.Id psId, Account.Id accountId, String path) {
+ return new AutoValue_FakeAccountPatchReviewStore_Entity(psId, accountId, path);
+ }
+ }
+
+ @Override
+ @CanIgnoreReturnValue
+ public boolean markReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
+ synchronized (store) {
+ Entity entity = Entity.create(psId, accountId, path);
+ return store.add(entity);
+ }
+ }
+
+ @Override
+ public void markReviewed(PatchSet.Id psId, Account.Id accountId, Collection<String> paths) {
+ paths.forEach(path -> markReviewed(psId, accountId, path));
+ }
+
+ @Override
+ public void clearReviewed(PatchSet.Id psId, Account.Id accountId, String path) {
+ synchronized (store) {
+ store.remove(Entity.create(psId, accountId, path));
+ }
+ }
+
+ @Override
+ public void clearReviewed(PatchSet.Id psId) {
+ synchronized (store) {
+ List<Entity> toRemove = new ArrayList<>();
+ for (Entity entity : store) {
+ if (entity.psId().equals(psId)) {
+ toRemove.add(entity);
+ }
+ }
+ store.removeAll(toRemove);
+ }
+ }
+
+ @Override
+ public void clearReviewed(Change.Id changeId) {
+ synchronized (store) {
+ List<Entity> toRemove = new ArrayList<>();
+ for (Entity entity : store) {
+ if (entity.psId().changeId().equals(changeId)) {
+ toRemove.add(entity);
+ }
+ }
+ store.removeAll(toRemove);
+ }
+ }
+
+ @Override
+ public Optional<PatchSetWithReviewedFiles> findReviewed(PatchSet.Id psId, Account.Id accountId) {
+ synchronized (store) {
+ int matchedPsNumber = -1;
+ Optional<PatchSetWithReviewedFiles> result = Optional.empty();
+ for (Entity entity : store) {
+ if (entity.accountId() != accountId || !entity.psId().changeId().equals(psId.changeId())) {
+ continue;
+ }
+ int entityPsNumber = Integer.parseInt(entity.psId().getId());
+ if (entityPsNumber <= psId.get() && entityPsNumber > matchedPsNumber) {
+ matchedPsNumber = entityPsNumber;
+ result =
+ Optional.of(
+ PatchSetWithReviewedFiles.create(
+ PatchSet.id(psId.changeId(), matchedPsNumber),
+ ImmutableSet.of(entity.path())));
+ }
+ }
+ return result;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/testing/GerritTestName.java b/java/com/google/gerrit/testing/GerritTestName.java
index d287837430..14493b6013 100644
--- a/java/com/google/gerrit/testing/GerritTestName.java
+++ b/java/com/google/gerrit/testing/GerritTestName.java
@@ -15,6 +15,7 @@
package com.google.gerrit.testing;
import com.google.common.base.CharMatcher;
+import java.util.Locale;
import org.junit.BeforeClass;
import org.junit.rules.TestName;
import org.junit.rules.TestRule;
@@ -30,7 +31,7 @@ public class GerritTestName implements TestRule {
}
public String getSanitizedMethodName() {
- String name = delegate.getMethodName().toLowerCase();
+ String name = delegate.getMethodName().toLowerCase(Locale.US);
name =
CharMatcher.inRange('a', 'z')
.or(CharMatcher.inRange('A', 'Z'))
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index d18571b290..00020302a6 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -38,6 +38,7 @@ import com.google.gerrit.index.project.ProjectSchemaDefinitions;
import com.google.gerrit.metrics.DisabledMetricMaker;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.server.CacheRefreshExecutor;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
import com.google.gerrit.server.FanOutExecutor;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.GerritPersonIdentProvider;
@@ -46,6 +47,7 @@ import com.google.gerrit.server.PluginUser;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.api.GerritApiModule;
import com.google.gerrit.server.api.PluginApiModule;
+import com.google.gerrit.server.api.projects.ProjectQueryBuilderModule;
import com.google.gerrit.server.audit.AuditModule;
import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -84,6 +86,7 @@ import com.google.gerrit.server.git.GarbageCollection;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.PerThreadRequestScope;
import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
import com.google.gerrit.server.group.testing.TestGroupBackend;
import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
import com.google.gerrit.server.index.account.AllAccountsIndexer;
@@ -192,6 +195,8 @@ public class InMemoryModule extends FactoryModule {
AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
install(new AuthModule(authConfig));
install(new GerritApiModule());
+ install(new ProjectQueryBuilderModule());
+ install(new DefaultRefLogIdentityProvider.Module());
factory(PluginUser.Factory.class);
install(new PluginApiModule());
install(new DefaultPermissionBackendModule());
@@ -200,6 +205,7 @@ public class InMemoryModule extends FactoryModule {
install(new AuditModule());
install(new SubscriptionGraphModule());
install(new SuperprojectUpdateSubmissionListenerModule());
+ install(new WorkQueueModule());
bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 2051ae3bb2..8c87405a37 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -14,20 +14,57 @@
package com.google.gerrit.testing;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BAN_COMMIT;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.BRANCH_MODIFICATION;
+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 static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GPG_KEYS_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.HEAD_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.MERGE_CHANGE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.OFFLINE_OPERATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.PLUGIN;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.REPO_SEQ;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.TAG_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.VERSIONED_META_DATA_CHANGE;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.gpg.PublicKeyStore;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
import com.google.inject.Inject;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
import java.util.Map;
+import java.util.Map.Entry;
import java.util.NavigableSet;
+import java.util.Optional;
+import java.util.function.Predicate;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.dfs.DfsObjDatabase;
+import org.eclipse.jgit.internal.storage.dfs.DfsReftableBatchRefUpdate;
import org.eclipse.jgit.internal.storage.dfs.DfsRepository;
import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
/** Repository manager that uses in-memory repositories. */
public class InMemoryRepositoryManager implements GitRepositoryManager {
@@ -56,6 +93,140 @@ public class InMemoryRepositoryManager implements GitRepositoryManager {
setPerformsAtomicTransactions(true);
}
+ /** Validates that a given ref is updated within the expected context. */
+ private static class RefUpdateContextValidator {
+ /**
+ * A configured singleton for ref context validation.
+ *
+ * <p>Each ref must match no more than 1 special ref from the list below. If ref is not
+ * matched to any special ref predicate, then it is checked against the standard rules - check
+ * the code of the {@link #validateRefUpdateContext} for details.
+ */
+ public static final RefUpdateContextValidator INSTANCE =
+ new RefUpdateContextValidator()
+ .addSpecialRef(RefNames::isSequenceRef, REPO_SEQ)
+ .addSpecialRef(RefNames.HEAD::equals, HEAD_MODIFICATION)
+ .addSpecialRef(RefNames::isRefsChanges, CHANGE_MODIFICATION, MERGE_CHANGE)
+ .addSpecialRef(RefNames::isAutoMergeRef, CHANGE_MODIFICATION)
+ .addSpecialRef(RefNames::isRefsEdit, CHANGE_MODIFICATION, MERGE_CHANGE)
+ .addSpecialRef(RefNames::isTagRef, TAG_MODIFICATION)
+ .addSpecialRef(RefNames::isRejectCommitsRef, BAN_COMMIT)
+ .addSpecialRef(
+ name -> RefNames.isRefsUsers(name) && !RefNames.isRefsEdit(name),
+ VERSIONED_META_DATA_CHANGE,
+ ACCOUNTS_UPDATE,
+ MERGE_CHANGE)
+ .addSpecialRef(
+ RefNames::isConfigRef,
+ VERSIONED_META_DATA_CHANGE,
+ BRANCH_MODIFICATION,
+ MERGE_CHANGE)
+ .addSpecialRef(RefNames::isExternalIdRef, VERSIONED_META_DATA_CHANGE, ACCOUNTS_UPDATE)
+ .addSpecialRef(PublicKeyStore.REFS_GPG_KEYS::equals, GPG_KEYS_MODIFICATION)
+ .addSpecialRef(RefNames::isRefsDraftsComments, CHANGE_MODIFICATION)
+ .addSpecialRef(RefNames::isRefsStarredChanges, CHANGE_MODIFICATION)
+ // A user can create a change for updating a group and then merge it.
+ // The GroupsIT#pushToGroupBranchForReviewForNonAllUsersRepoAndSubmit test verifies
+ // this scenario.
+ .addSpecialRef(RefNames::isGroupRef, GROUPS_UPDATE, MERGE_CHANGE);
+
+ private List<Entry<Predicate<String>, ImmutableList<RefUpdateType>>> specialRefs =
+ new ArrayList<>();
+
+ private RefUpdateContextValidator() {}
+
+ public void validateRefUpdateContext(ReceiveCommand cmd) {
+ String refName = cmd.getRefName();
+
+ if (RefUpdateContextCollector.enabled()) {
+ RefUpdateContextCollector.register(refName, RefUpdateContext.getOpenedContexts());
+ }
+ if (TestActionRefUpdateContext.isOpen()
+ || RefUpdateContext.hasOpen(OFFLINE_OPERATION)
+ || RefUpdateContext.hasOpen(INIT_REPO)
+ || RefUpdateContext.hasOpen(DIRECT_PUSH)) {
+ // The action can touch any refs in these contexts.
+ return;
+ }
+
+ Optional<ImmutableList<RefUpdateType>> allowedRefUpdateTypes =
+ RefUpdateContextValidator.INSTANCE.getAllowedRefUpdateTypes(refName);
+
+ if (allowedRefUpdateTypes.isPresent()) {
+ checkState(
+ allowedRefUpdateTypes.get().stream().anyMatch(RefUpdateContext::hasOpen)
+ || isTestRepoCall(),
+ "Special ref '%s' is updated outside of the expected operation. Wrap code in the correct RefUpdateContext or fix allowed update types",
+ refName);
+ return;
+ }
+ // It is not one of the special ref - update is possible only within specific contexts.
+ checkState(
+ RefUpdateContext.hasOpen(MERGE_CHANGE)
+ || RefUpdateContext.hasOpen(RefUpdateType.BRANCH_MODIFICATION)
+ || RefUpdateContext.hasOpen(RefUpdateType.UPDATE_SUPERPROJECT)
+ // Plugin can update any ref
+ || RefUpdateContext.hasOpen(PLUGIN)
+ || isTestRepoCall(),
+ "Ordinary ref '%s' is updated outside of the expected operation. Wrap code in the correct RefUpdateContext or add the ref as a special ref.",
+ refName);
+ }
+
+ private RefUpdateContextValidator addSpecialRef(
+ Predicate<String> refNamePredicate, RefUpdateType... validRefUpdateTypes) {
+ specialRefs.add(
+ new SimpleImmutableEntry<>(
+ refNamePredicate, ImmutableList.copyOf(validRefUpdateTypes)));
+ return this;
+ }
+
+ private Optional<ImmutableList<RefUpdateType>> getAllowedRefUpdateTypes(String refName) {
+ List<ImmutableList<RefUpdateType>> allowedTypes =
+ specialRefs.stream()
+ .filter(entry -> entry.getKey().test(refName))
+ .map(Entry::getValue)
+ .collect(toList());
+ checkState(
+ allowedTypes.size() <= 1,
+ "refName matches more than 1 predicate. Please fix the specialRefs list, so each reference has no more than one match.");
+ if (allowedTypes.size() == 0) {
+ return Optional.empty();
+ }
+ return Optional.of(allowedTypes.get(0));
+ }
+
+ /**
+ * Returns true if a ref is updated using one of the method in {@link
+ * org.eclipse.jgit.junit.TestRepository}.
+ *
+ * <p>The {@link org.eclipse.jgit.junit.TestRepository} used only in tests and allows to
+ * change refs directly. Wrapping each usage in a test context requires a lot of modification,
+ * so instead we allow any ref updates, which are made using through this class.
+ */
+ private boolean isTestRepoCall() {
+ return Arrays.stream(Thread.currentThread().getStackTrace())
+ .anyMatch(elem -> elem.getClassName().equals("org.eclipse.jgit.junit.TestRepository"));
+ }
+ }
+
+ @Override
+ protected MemRefDatabase createRefDatabase() {
+ return new MemRefDatabase() {
+ @Override
+ public BatchRefUpdate newBatchUpdate() {
+ DfsObjDatabase odb = getRepository().getObjectDatabase();
+ return new DfsReftableBatchRefUpdate(this, odb) {
+ @Override
+ public void execute(RevWalk rw, ProgressMonitor pm, List<String> options) {
+ getCommands().stream()
+ .forEach(RefUpdateContextValidator.INSTANCE::validateRefUpdateContext);
+ super.execute(rw, pm, options);
+ }
+ };
+ }
+ };
+ }
+
@Override
public Description getDescription() {
return (Description) super.getDescription();
@@ -133,6 +304,6 @@ public class InMemoryRepositoryManager implements GitRepositoryManager {
}
private static String normalize(Project.NameKey name) {
- return name.get().toLowerCase();
+ return name.get().toLowerCase(Locale.US);
}
}
diff --git a/java/com/google/gerrit/testing/IndexVersions.java b/java/com/google/gerrit/testing/IndexVersions.java
index 3810707ce0..2e843fe4dc 100644
--- a/java/com/google/gerrit/testing/IndexVersions.java
+++ b/java/com/google/gerrit/testing/IndexVersions.java
@@ -26,6 +26,7 @@ import com.google.gerrit.index.Schema;
import com.google.gerrit.index.SchemaDefinitions;
import java.util.ArrayList;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import org.eclipse.jgit.lib.Config;
@@ -73,13 +74,14 @@ public class IndexVersions {
* if any of the specified schema versions doesn't exist
*/
public static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef) {
- String envVar = schemaDef.getName().toUpperCase() + "_INDEX_VERSIONS";
+ String envVar = schemaDef.getName().toUpperCase(Locale.US) + "_INDEX_VERSIONS";
String value = System.getenv(envVar);
if (!Strings.isNullOrEmpty(value)) {
return get(schemaDef, "env variable " + envVar, value);
}
- String systemProperty = "gerrit.index." + schemaDef.getName().toLowerCase() + ".versions";
+ String systemProperty =
+ "gerrit.index." + schemaDef.getName().toLowerCase(Locale.US) + ".versions";
value = System.getProperty(systemProperty);
return get(schemaDef, "system property " + systemProperty, value);
}
@@ -138,7 +140,10 @@ public class IndexVersions {
i -> {
Config cfg = baseConfig;
cfg.setInt(
- "index", "lucene", schemaDef.getName().toLowerCase() + "TestVersion", i);
+ "index",
+ "lucene",
+ schemaDef.getName().toLowerCase(Locale.US) + "TestVersion",
+ i);
return cfg;
}));
}
diff --git a/java/com/google/gerrit/testing/RefUpdateContextCollector.java b/java/com/google/gerrit/testing/RefUpdateContextCollector.java
new file mode 100644
index 0000000000..88232d2292
--- /dev/null
+++ b/java/com/google/gerrit/testing/RefUpdateContextCollector.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+/**
+ * Stores information about each updated ref in tests, together with associated RefUpdateContext(s).
+ *
+ * <p>This is a {@link TestRule}, which clears the stored data after each test.
+ *
+ * <p>Usage:
+ *
+ * <pre>{@code
+ * class ...Test {
+ * \@Rule
+ * public RefUpdateContextCollector refContextCollector = new RefUpdateContextCollector();
+ * ...
+ * public void test() {
+ * // some actions
+ * assertThat(refContextCollector.getContextsByRef("refs/heads/main")).contains(...)
+ * }
+ * }
+ * }</pre>
+ */
+public class RefUpdateContextCollector implements TestRule {
+ private static ConcurrentLinkedQueue<Entry<String, ImmutableList<RefUpdateContext>>>
+ touchedRefsWithContexts = null;
+
+ @Override
+ public Statement apply(Statement statement, Description description) {
+ return new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ try {
+ touchedRefsWithContexts = new ConcurrentLinkedQueue<>();
+ statement.evaluate();
+ } finally {
+ touchedRefsWithContexts = null;
+ }
+ }
+ };
+ }
+
+ public static boolean enabled() {
+ return touchedRefsWithContexts != null;
+ }
+
+ public static void register(String refName, ImmutableList<RefUpdateContext> openedContexts) {
+ if (touchedRefsWithContexts == null) {
+ return;
+ }
+ touchedRefsWithContexts.add(new SimpleImmutableEntry<>(refName, openedContexts));
+ }
+
+ public ImmutableList<String> getRefsByUpdateType(RefUpdateType refUpdateType) {
+ return touchedRefsWithContexts.stream()
+ .filter(
+ entry ->
+ entry.getValue().stream()
+ .map(RefUpdateContext::getUpdateType)
+ .anyMatch(refUpdateType::equals))
+ .map(Entry::getKey)
+ .collect(toImmutableList());
+ }
+
+ public void clear() {
+ touchedRefsWithContexts.clear();
+ }
+}
diff --git a/java/com/google/gerrit/testing/SshMode.java b/java/com/google/gerrit/testing/SshMode.java
index 41633bdf1f..60bd5187a8 100644
--- a/java/com/google/gerrit/testing/SshMode.java
+++ b/java/com/google/gerrit/testing/SshMode.java
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.base.Enums;
import com.google.common.base.Strings;
+import java.util.Locale;
/**
* Whether to enable/disable tests using SSH by inspecting the global environment.
@@ -43,7 +44,7 @@ public enum SshMode {
if (Strings.isNullOrEmpty(value)) {
return YES;
}
- value = value.toUpperCase();
+ value = value.toUpperCase(Locale.US);
SshMode mode = Enums.getIfPresent(SshMode.class, value).orNull();
if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
checkArgument(
diff --git a/java/com/google/gerrit/testing/TestActionRefUpdateContext.java b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
new file mode 100644
index 0000000000..23ec9aaa30
--- /dev/null
+++ b/java/com/google/gerrit/testing/TestActionRefUpdateContext.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.testing;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+
+/**
+ * Marks ref updates as a test actions.
+ *
+ * <p>This class should be used in tests only to wrap a portion of test code which directly modifies
+ * references. Usage:
+ *
+ * <pre>{@code
+ * import static ...TestActionRefUpdateContext.openTestRefUpdateContext();
+ *
+ * try(RefUpdateContext ctx=openTestRefUpdateContext()) {
+ * // Some test code, which modifies a reference.
+ * }
+ * }</pre>
+ *
+ * or
+ *
+ * <pre>{@code
+ * import static ...TestActionRefUpdateContext.testRefAction;
+ *
+ * testRefAction(() -> {doSomethingWithRef()});
+ * T result = testRefAction(() -> { return doSomethingWithRef()});
+ * }</pre>
+ */
+public final class TestActionRefUpdateContext extends RefUpdateContext {
+ public static boolean isOpen() {
+ return getCurrent().stream().anyMatch(ctx -> ctx instanceof TestActionRefUpdateContext);
+ }
+
+ public static TestActionRefUpdateContext openTestRefUpdateContext() {
+ return open(new TestActionRefUpdateContext());
+ }
+
+ @CanIgnoreReturnValue
+ public static <V, E extends Exception> V testRefAction(CallableWithException<V, E> c) throws E {
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ return c.call();
+ }
+ }
+
+ public static <E extends Exception> void testRefAction(RunnableWithException<E> c) throws E {
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ c.run();
+ }
+ }
+
+ public interface CallableWithException<V, E extends Exception> {
+ V call() throws E;
+ }
+
+ @FunctionalInterface
+ public interface RunnableWithException<E extends Exception> {
+ void run() throws E;
+ }
+}
diff --git a/java/com/google/gerrit/testing/TestChanges.java b/java/com/google/gerrit/testing/TestChanges.java
index 4a97bc5bc7..5a3c7554fa 100644
--- a/java/com/google/gerrit/testing/TestChanges.java
+++ b/java/com/google/gerrit/testing/TestChanges.java
@@ -32,6 +32,7 @@ import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Injector;
import java.time.ZoneId;
+import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.ObjectId;
@@ -72,20 +73,26 @@ public class TestChanges {
.id(id)
.commitId(ObjectId.fromString(revision))
.uploader(userId)
+ .realUploader(userId)
.createdOn(TimeUtil.now())
.build();
}
public static ChangeUpdate newUpdate(
- Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
+ Injector injector, Change c, Optional<CurrentUser> user, boolean shouldExist)
+ throws Exception {
injector =
injector.createChildInjector(
new FactoryModule() {
@Override
public void configure() {
- bind(CurrentUser.class).toInstance(user);
+ if (user.isPresent()) {
+ // user may be already bound in injector
+ bind(CurrentUser.class).toInstance(user.get());
+ }
}
});
+ CurrentUser currentUser = injector.getProvider(CurrentUser.class).get();
ChangeUpdate update =
injector
.getInstance(ChangeUpdate.Factory.class)
@@ -93,7 +100,7 @@ public class TestChanges {
new ChangeNotes(
injector.getInstance(AbstractChangeNotes.Args.class), c, shouldExist, null)
.load(),
- user,
+ currentUser,
TimeUtil.now(),
Ordering.natural());
@@ -109,7 +116,9 @@ public class TestChanges {
try (Repository repo = repoManager.openRepository(c.getProject());
TestRepository<Repository> tr = new TestRepository<>(repo)) {
PersonIdent ident =
- user.asIdentifiedUser().newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
+ currentUser
+ .asIdentifiedUser()
+ .newCommitterIdent(update.getWhen(), ZoneId.systemDefault());
TestRepository<Repository>.CommitBuilder cb =
tr.commit()
.author(ident)
diff --git a/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
new file mode 100644
index 0000000000..27e4b17e37
--- /dev/null
+++ b/java/com/google/gerrit/util/cli/ApiProtocolBufferGenerator.java
@@ -0,0 +1,176 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.util.cli;
+
+import com.google.common.base.CaseFormat;
+import com.google.common.reflect.ClassPath;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Utility to generate Protocol Buffers (*.proto) files from existing POJO API types.
+ *
+ * <p>Usage:
+ *
+ * <ul>
+ * <li>Print proto representation of all API objects: {@code bazelisk run
+ * java/com/google/gerrit/util/cli:protogen}
+ * </ul>
+ */
+public class ApiProtocolBufferGenerator {
+ private static String NOTICE =
+ "// Copyright (C) 2023 The Android Open Source Project\n"
+ + "//\n"
+ + "// Licensed under the Apache License, Version 2.0 (the \"License\");\n"
+ + "// you may not use this file except in compliance with the License.\n"
+ + "// You may obtain a copy of the License at\n"
+ + "//\n"
+ + "// http://www.apache.org/licenses/LICENSE-2.0\n"
+ + "//\n"
+ + "// Unless required by applicable law or agreed to in writing, software\n"
+ + "// distributed under the License is distributed on an \"AS IS\" BASIS,\n"
+ + "// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n"
+ + "// See the License for the specific language governing permissions and\n"
+ + "// limitations under the License.";
+
+ private static String PACKAGE = "com.google.gerrit.extensions.common";
+
+ public static void main(String[] args) {
+ try {
+ ClassPath.from(ClassLoader.getSystemClassLoader()).getAllClasses().stream()
+ .filter(c -> c.getPackageName().equalsIgnoreCase(PACKAGE))
+ .filter(c -> c.getName().endsWith("Input") || c.getName().endsWith("Info"))
+ .map(clazz -> clazz.load())
+ .forEach(ApiProtocolBufferGenerator::exportSingleClass);
+ } catch (Exception e) {
+ System.err.println(e);
+ }
+ }
+
+ private static void exportSingleClass(Class<?> clazz) {
+ StringBuilder proto = new StringBuilder(NOTICE);
+ proto.append("\n\nsyntax = \"proto3\";");
+ proto.append("\n\npackage gerrit.api;");
+ proto.append("\n\noption java_package = \"" + PACKAGE + "\";");
+
+ int fieldNumber = 1;
+
+ proto.append("\n\n\nmessage " + clazz.getSimpleName() + " {\n");
+
+ for (Field f : clazz.getFields()) {
+ Class<?> type = f.getType();
+
+ if (type.isAssignableFrom(List.class)) {
+ ParameterizedType list = (ParameterizedType) f.getGenericType();
+ Class<?> genericType = (Class<?>) list.getActualTypeArguments()[0];
+ String protoType =
+ protoType(genericType)
+ .orElseThrow(() -> new IllegalStateException("unknown type: " + genericType));
+ proto.append(
+ String.format(
+ "repeated %s %s = %d;\n", protoType, protoName(f.getName()), fieldNumber));
+ } else if (type.isAssignableFrom(Map.class)) {
+ ParameterizedType map = (ParameterizedType) f.getGenericType();
+ Class<?> key = (Class<?>) map.getActualTypeArguments()[0];
+ if (map.getActualTypeArguments()[1] instanceof ParameterizedType) {
+ // TODO: This is list multimap which proto doesn't support. Move to
+ // it's own types.
+ proto.append(
+ "reserved "
+ + fieldNumber
+ + "; // TODO(hiesel): Add support for map<?,repeated <?>>\n");
+ } else {
+ Class<?> value = (Class<?>) map.getActualTypeArguments()[1];
+ String keyProtoType =
+ protoType(key).orElseThrow(() -> new IllegalStateException("unknown type: " + key));
+ String valueProtoType =
+ protoType(value)
+ .orElseThrow(() -> new IllegalStateException("unknown type: " + value));
+ proto.append(
+ String.format(
+ "map<%s,%s> %s = %d;\n",
+ keyProtoType, valueProtoType, protoName(f.getName()), fieldNumber));
+ }
+ } else if (protoType(type).isPresent()) {
+ proto.append(
+ String.format(
+ "%s %s = %d;\n", protoType(type).get(), protoName(f.getName()), fieldNumber));
+ } else {
+ proto.append(
+ "reserved "
+ + fieldNumber
+ + "; // TODO(hiesel): Add support for "
+ + type.getName()
+ + "\n");
+ }
+ fieldNumber++;
+ }
+ proto.append("}");
+
+ System.out.println(proto);
+ }
+
+ private static Optional<String> protoType(Class<?> type) {
+ if (isInt(type)) {
+ return Optional.of("int32");
+ } else if (isLong(type)) {
+ return Optional.of("int64");
+ } else if (isChar(type)) {
+ return Optional.of("string");
+ } else if (isShort(type)) {
+ return Optional.of("int32");
+ } else if (isShort(type)) {
+ return Optional.of("int32");
+ } else if (isBoolean(type)) {
+ return Optional.of("bool");
+ } else if (type.isAssignableFrom(String.class)) {
+ return Optional.of("string");
+ } else if (type.isAssignableFrom(Timestamp.class)) {
+ // See https://gerrit-review.googlesource.com/Documentation/rest-api.html#timestamp
+ return Optional.of("string");
+ } else if (type.getPackageName().startsWith("com.google.gerrit.extensions")) {
+ return Optional.of("gerrit.api." + type.getSimpleName());
+ }
+ return Optional.empty();
+ }
+
+ private static boolean isInt(Class<?> type) {
+ return type.isAssignableFrom(Integer.class) || type.isAssignableFrom(int.class);
+ }
+
+ private static boolean isLong(Class<?> type) {
+ return type.isAssignableFrom(Long.class) || type.isAssignableFrom(long.class);
+ }
+
+ private static boolean isChar(Class<?> type) {
+ return type.isAssignableFrom(Character.class) || type.isAssignableFrom(char.class);
+ }
+
+ private static boolean isShort(Class<?> type) {
+ return type.isAssignableFrom(Short.class) || type.isAssignableFrom(short.class);
+ }
+
+ private static boolean isBoolean(Class<?> type) {
+ return type.isAssignableFrom(Boolean.class) || type.isAssignableFrom(boolean.class);
+ }
+
+ private static String protoName(String name) {
+ return CaseFormat.LOWER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, name);
+ }
+}
diff --git a/java/com/google/gerrit/util/cli/BUILD b/java/com/google/gerrit/util/cli/BUILD
index ebcc67ed70..b464f32d2f 100644
--- a/java/com/google/gerrit/util/cli/BUILD
+++ b/java/com/google/gerrit/util/cli/BUILD
@@ -2,7 +2,10 @@ load("@rules_java//java:defs.bzl", "java_library")
java_library(
name = "cli",
- srcs = glob(["**/*.java"]),
+ srcs = glob(
+ ["**/*.java"],
+ exclude = ["ApiProtocolBufferGenerator.java"],
+ ),
visibility = ["//visibility:public"],
deps = [
"//java/com/google/gerrit/common:annotations",
@@ -14,3 +17,15 @@ java_library(
"//lib/guice:guice-assistedinject",
],
)
+
+# Util to generate *.proto files from *Info and *Input objects
+java_binary(
+ name = "protogen",
+ srcs = ["ApiProtocolBufferGenerator.java"],
+ main_class = "com.google.gerrit.util.cli.ApiProtocolBufferGenerator",
+ deps = [
+ "//java/com/google/gerrit/extensions:api",
+ "//lib:guava",
+ "//lib:protobuf",
+ ],
+)
diff --git a/java/com/google/gerrit/util/cli/CmdLineParser.java b/java/com/google/gerrit/util/cli/CmdLineParser.java
index 7c42797d43..a37c0273ae 100644
--- a/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,6 +44,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.StringWriter;
@@ -567,6 +568,7 @@ public class CmdLineParser {
* and it needed to be exposed.
*/
@SuppressWarnings("rawtypes")
+ @Nullable
public OptionHandler findOptionByName(String name) {
for (OptionHandler h : optionsList) {
if (h.option instanceof NamedOptionDef) {
diff --git a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
index b6e5b74304..33e6692f77 100644
--- a/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
+++ b/javatests/com/google/gerrit/acceptance/ProjectResetterTest.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.only;
import static org.mockito.Mockito.verify;
@@ -32,6 +33,7 @@ import com.google.gerrit.server.config.AllUsersNameProvider;
import com.google.gerrit.server.index.account.AccountIndexer;
import com.google.gerrit.server.index.group.GroupIndexer;
import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.InMemoryRepositoryManager;
import com.google.gerrit.testing.TestTimeUtil;
@@ -286,14 +288,16 @@ public class ProjectResetterTest {
}
private Ref createRef(Repository repo, String ref) throws IOException {
- try (ObjectInserter oi = repo.newObjectInserter();
- RevWalk rw = new RevWalk(repo)) {
- ObjectId emptyCommit = createCommit(repo);
- RefUpdate updateRef = repo.updateRef(ref);
- updateRef.setExpectedOldObjectId(ObjectId.zeroId());
- updateRef.setNewObjectId(emptyCommit);
- assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
- return repo.exactRef(ref);
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (ObjectInserter oi = repo.newObjectInserter();
+ RevWalk rw = new RevWalk(repo)) {
+ ObjectId emptyCommit = createCommit(repo);
+ RefUpdate updateRef = repo.updateRef(ref);
+ updateRef.setExpectedOldObjectId(ObjectId.zeroId());
+ updateRef.setNewObjectId(emptyCommit);
+ assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
+ return repo.exactRef(ref);
+ }
}
}
@@ -302,17 +306,19 @@ public class ProjectResetterTest {
}
private Ref updateRef(Repository repo, Ref ref) throws IOException {
- try (ObjectInserter oi = repo.newObjectInserter();
- RevWalk rw = new RevWalk(repo)) {
- ObjectId emptyCommit = createCommit(repo);
- RefUpdate updateRef = repo.updateRef(ref.getName());
- updateRef.setExpectedOldObjectId(ref.getObjectId());
- updateRef.setNewObjectId(emptyCommit);
- updateRef.setForceUpdate(true);
- assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.FORCED);
- Ref updatedRef = repo.exactRef(ref.getName());
- assertThat(updatedRef.getObjectId()).isNotEqualTo(ref.getObjectId());
- return updatedRef;
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (ObjectInserter oi = repo.newObjectInserter();
+ RevWalk rw = new RevWalk(repo)) {
+ ObjectId emptyCommit = createCommit(repo);
+ RefUpdate updateRef = repo.updateRef(ref.getName());
+ updateRef.setExpectedOldObjectId(ref.getObjectId());
+ updateRef.setNewObjectId(emptyCommit);
+ updateRef.setForceUpdate(true);
+ assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.FORCED);
+ Ref updatedRef = repo.exactRef(ref.getName());
+ assertThat(updatedRef.getObjectId()).isNotEqualTo(ref.getObjectId());
+ return updatedRef;
+ }
}
}
diff --git a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
new file mode 100644
index 0000000000..3464d213c2
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
@@ -0,0 +1,202 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter2;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link TestMetricMaker}. */
+public class TestMetricMakerTest {
+ private TestMetricMaker testMetricMaker = new TestMetricMaker();
+
+ @Before
+ public void setUp() {
+ testMetricMaker.reset();
+ }
+
+ @Test
+ public void counter0() throws Exception {
+ String counterName = "test_counter";
+ Counter0 counter = testMetricMaker.newCounter(counterName, new Description("Test Counter"));
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+
+ counter.increment();
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(1);
+
+ counter.incrementBy(/* value= */ 3);
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(4);
+ }
+
+ @Test
+ public void counter1_booleanField() throws Exception {
+ String counterName = "test_counter";
+ Counter1<Boolean> counter =
+ testMetricMaker.newCounter(
+ counterName,
+ new Description("Test Counter"),
+ Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build());
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+ counter.increment(/* field1= */ true);
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+ counter.incrementBy(/* field1= */ true, /* value= */ 3);
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+
+ counter.increment(/* field1= */ false);
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ false, /* value= */ 4);
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(5);
+
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+ }
+
+ @Test
+ public void counter1_stringField() throws Exception {
+ String counterName = "test_counter";
+ Counter1<String> counter =
+ testMetricMaker.newCounter(
+ counterName,
+ new Description("Test Counter"),
+ Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build());
+ assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+ counter.increment(/* field1= */ "foo");
+ assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(1);
+ assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+ counter.incrementBy(/* field1= */ "foo", /* value= */ 3);
+ assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(0);
+
+ counter.increment(/* field1= */ "bar");
+ assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ "bar", /* value= */ 4);
+ assertThat(testMetricMaker.getCount(counterName, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, "bar")).isEqualTo(5);
+
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+ }
+
+ @Test
+ public void counter2() throws Exception {
+ String counterName = "test_counter";
+ Counter2<Boolean, String> counter =
+ testMetricMaker.newCounter(
+ counterName,
+ new Description("Test Counter"),
+ Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build(),
+ Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build());
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+ counter.increment(/* field1= */ true, /* field2= */ "foo");
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(1);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+ counter.incrementBy(/* field1= */ true, /* field2= */ "foo", /* value= */ 3);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+
+ counter.increment(/* field1= */ false, /* field2= */ "foo");
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* value= */ 4);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(5);
+
+ counter.increment(/* field1= */ true, /* field2= */ "bar");
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, true, "bar")).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ true, /* field2= */ "bar", /* value= */ 5);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, true, "bar")).isEqualTo(6);
+
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+ }
+
+ @Test
+ public void counter3() throws Exception {
+ String counterName = "test_counter";
+ Counter3<Boolean, String, Integer> counter =
+ testMetricMaker.newCounter(
+ counterName,
+ new Description("Test Counter"),
+ Field.ofBoolean("boolean_field", (metadataBuilder, booleanField) -> {}).build(),
+ Field.ofString("string_field", (metadataBuilder, stringField) -> {}).build(),
+ Field.ofInteger("integer_field", (metadataBuilder, stringField) -> {}).build());
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+ counter.increment(/* field1= */ true, /* field2= */ "foo", /* field3= */ 0);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+ counter.incrementBy(/* field1= */ true, /* field2= */ "foo", /* field3= */ 0, /* value= */ 3);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(0);
+
+ counter.increment(/* field1= */ false, /* field2= */ "foo", /* field3= */ 0);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* field3= */ 0, /* value= */ 4);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 0)).isEqualTo(5);
+
+ counter.increment(/* field1= */ true, /* field2= */ "bar", /* field3= */ 0);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, true, "bar", 0)).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ true, /* field2= */ "bar", /* field3= */ 0, /* value= */ 5);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, true, "bar", 0)).isEqualTo(6);
+
+ counter.increment(/* field1= */ false, /* field2= */ "foo", /* field3= */ 1);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 1)).isEqualTo(1);
+
+ counter.incrementBy(/* field1= */ false, /* field2= */ "foo", /* field3= */ 1, /* value= */ 6);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo", 0)).isEqualTo(4);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo", 1)).isEqualTo(7);
+
+ assertThat(testMetricMaker.getCount(counterName)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, true)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, true, "foo")).isEqualTo(0);
+ assertThat(testMetricMaker.getCount(counterName, false, "foo")).isEqualTo(0);
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index c2b779bc70..dd0420008b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -42,6 +42,7 @@ import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static com.google.gerrit.truth.ConfigSubject.assertThat;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
@@ -78,6 +79,7 @@ import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.acceptance.testsuite.request.SshSessionFactory;
import com.google.gerrit.common.Nullable;
@@ -130,10 +132,12 @@ import com.google.gerrit.gpg.testing.TestKey;
import com.google.gerrit.httpd.CacheBasedWebSession;
import com.google.gerrit.server.ExceptionHook;
import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountControl;
import com.google.gerrit.server.account.AccountProperties;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.account.GroupMembership;
import com.google.gerrit.server.account.VersionedAuthorizedKeys;
import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -144,6 +148,7 @@ import com.google.gerrit.server.account.externalids.ExternalIds;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
import com.google.gerrit.server.index.account.AccountIndexer;
import com.google.gerrit.server.index.account.StalenessChecker;
import com.google.gerrit.server.notedb.Sequences;
@@ -175,6 +180,7 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
import javax.servlet.http.HttpServletResponse;
import org.apache.http.HttpResponse;
import org.apache.http.client.ClientProtocolException;
@@ -241,6 +247,7 @@ public class AccountIT extends AbstractDaemonTest {
@Inject private ExternalIdKeyFactory externalIdKeyFactory;
@Inject private ExternalIdFactory externalIdFactory;
@Inject private AuthConfig authConfig;
+ @Inject private AccountControl.Factory accountControlFactory;
@Inject protected Emails emails;
@@ -258,7 +265,7 @@ public class AccountIT extends AbstractDaemonTest {
if (ref != null) {
RefUpdate ru = repo.updateRef(REFS_GPG_KEYS);
ru.setForceUpdate(true);
- assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
}
}
@@ -984,14 +991,15 @@ public class AccountIT extends AbstractDaemonTest {
}
@Test
- public void cannotGetEmailsOfOtherAccountWithoutModifyAccount() throws Exception {
+ public void cannotGetEmailsOfOtherAccountWithoutViewSecondaryEmailsAndWithoutModifyAccount()
+ throws Exception {
String email = "preferred2@example.com";
TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
requestScopeOperations.setApiUser(user.id());
AuthException thrown =
assertThrows(AuthException.class, () -> gApi.accounts().id(foo.id().get()).getEmails());
- assertThat(thrown).hasMessageThat().contains("modify account not permitted");
+ assertThat(thrown).hasMessageThat().contains("view secondary emails not permitted");
}
@Test
@@ -1882,7 +1890,7 @@ public class AccountIT extends AbstractDaemonTest {
// Mark first key as invalid
assertThat(info.get(0).valid).isTrue();
- authorizedKeys.markKeyInvalid(admin.id(), 1);
+ testRefAction(() -> authorizedKeys.markKeyInvalid(admin.id(), 1));
info = gApi.accounts().self().listSshKeys();
assertThat(info).hasSize(2);
assertThat(info.get(0).seq).isEqualTo(1);
@@ -2064,6 +2072,7 @@ public class AccountIT extends AbstractDaemonTest {
return newEmailInput(email, true);
}
+ @Nullable
private String getMetaId(Account.Id accountId) throws IOException {
try (Repository repo = repoManager.openRepository(allUsers);
RevWalk rw = new RevWalk(repo);
@@ -2427,79 +2436,88 @@ public class AccountIT extends AbstractDaemonTest {
// Manually updating the user ref makes the index document stale.
String userRef = RefNames.refsUsers(accountId);
- try (Repository repo = repoManager.openRepository(allUsers);
- ObjectInserter oi = repo.newObjectInserter();
- RevWalk rw = new RevWalk(repo)) {
- RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
-
- PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
- CommitBuilder cb = new CommitBuilder();
- cb.setTreeId(commit.getTree());
- cb.setCommitter(ident);
- cb.setAuthor(ident);
- cb.setMessage(commit.getFullMessage());
- ObjectId emptyCommit = oi.insert(cb);
- oi.flush();
-
- RefUpdate updateRef = repo.updateRef(userRef);
- updateRef.setExpectedOldObjectId(commit.toObjectId());
- updateRef.setNewObjectId(emptyCommit);
- assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
- }
+ testRefAction(
+ () -> {
+ try (Repository repo = repoManager.openRepository(allUsers);
+ ObjectInserter oi = repo.newObjectInserter();
+ RevWalk rw = new RevWalk(repo)) {
+ RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+
+ PersonIdent ident = new PersonIdent(serverIdent.get(), TimeUtil.now());
+ CommitBuilder cb = new CommitBuilder();
+ cb.setTreeId(commit.getTree());
+ cb.setCommitter(ident);
+ cb.setAuthor(ident);
+ cb.setMessage(commit.getFullMessage());
+ ObjectId emptyCommit = oi.insert(cb);
+ oi.flush();
+
+ RefUpdate updateRef = repo.updateRef(userRef);
+ updateRef.setExpectedOldObjectId(commit.toObjectId());
+ updateRef.setNewObjectId(emptyCommit);
+ assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+ }
+ });
assertStaleAccountAndReindex(accountId);
// Manually inserting/updating/deleting an external ID of the user makes the index document
// stale.
try (Repository repo = repoManager.openRepository(allUsers)) {
- ExternalIdNotes extIdNotes =
- ExternalIdNotes.load(
- allUsers,
- repo,
- externalIdFactory,
- authConfig.isUserNameCaseInsensitiveMigrationMode());
-
- ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
- extIdNotes.insert(externalIdFactory.create(key, accountId));
- try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
- extIdNotes.commit(update);
- }
- assertStaleAccountAndReindex(accountId);
-
- extIdNotes =
- ExternalIdNotes.load(
- allUsers,
- repo,
- externalIdFactory,
- authConfig.isUserNameCaseInsensitiveMigrationMode());
- extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
- try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
- extIdNotes.commit(update);
- }
- assertStaleAccountAndReindex(accountId);
-
- extIdNotes =
- ExternalIdNotes.load(
- allUsers,
- repo,
- externalIdFactory,
- authConfig.isUserNameCaseInsensitiveMigrationMode());
- extIdNotes.delete(accountId, key);
- try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
- extIdNotes.commit(update);
- }
+ testRefAction(
+ () -> {
+ ExternalIdNotes extIdNotes =
+ ExternalIdNotes.load(
+ allUsers,
+ repo,
+ externalIdFactory,
+ authConfig.isUserNameCaseInsensitiveMigrationMode());
+
+ ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
+ extIdNotes.insert(externalIdFactory.create(key, accountId));
+ try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+ extIdNotes.commit(update);
+ }
+ assertStaleAccountAndReindex(accountId);
+
+ extIdNotes =
+ ExternalIdNotes.load(
+ allUsers,
+ repo,
+ externalIdFactory,
+ authConfig.isUserNameCaseInsensitiveMigrationMode());
+ extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
+ try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+ extIdNotes.commit(update);
+ }
+ assertStaleAccountAndReindex(accountId);
+
+ extIdNotes =
+ ExternalIdNotes.load(
+ allUsers,
+ repo,
+ externalIdFactory,
+ authConfig.isUserNameCaseInsensitiveMigrationMode());
+ extIdNotes.delete(accountId, key);
+ try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
+ extIdNotes.commit(update);
+ }
+ });
assertStaleAccountAndReindex(accountId);
}
// Manually delete account
- try (Repository repo = repoManager.openRepository(allUsers);
- RevWalk rw = new RevWalk(repo)) {
- RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
- RefUpdate updateRef = repo.updateRef(userRef);
- updateRef.setExpectedOldObjectId(commit.toObjectId());
- updateRef.setNewObjectId(ObjectId.zeroId());
- updateRef.setForceUpdate(true);
- assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
- }
+ testRefAction(
+ () -> {
+ try (Repository repo = repoManager.openRepository(allUsers);
+ RevWalk rw = new RevWalk(repo)) {
+ RevCommit commit = rw.parseCommit(repo.exactRef(userRef).getObjectId());
+ RefUpdate updateRef = repo.updateRef(userRef);
+ updateRef.setExpectedOldObjectId(commit.toObjectId());
+ updateRef.setNewObjectId(ObjectId.zeroId());
+ updateRef.setForceUpdate(true);
+ assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ }
+ });
assertStaleAccountAndReindex(accountId);
}
@@ -2882,8 +2900,12 @@ public class AccountIT extends AbstractDaemonTest {
requestScopeOperations.setApiUser(user.id());
assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("secondary"));
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id("secondary@example.com"));
requestScopeOperations.setApiUser(admin.id());
assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
+ assertThat(gApi.accounts().id("secondary@example.com").get()._accountId)
+ .isEqualTo(foo.id().get());
}
@Test
@@ -3117,6 +3139,139 @@ public class AccountIT extends AbstractDaemonTest {
.isNotEqualTo(updatedUserState.account().metaId());
}
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void accountsCanSeeEachOtherThroughASharedExternalGroupOnlyWhenTheGroupIsMentionedInAcls()
+ throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ // user and user2 cannot see each other because they do not share a Gerrit internal group
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isFalse();
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+ .isFalse();
+
+ // Configure an external group backend that has a single group that contains all users.
+ TestGroupBackend testGroupBackend = createTestGroupBackendWithAllUsersGroup("AllUsers");
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(testGroupBackend)) {
+ // user and user2 cannot see each other although the external AllUsers group contains both
+ // users. That's because this group is not detected as relevant and hence its memberships are
+ // not checked.
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isFalse();
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+ .isFalse();
+
+ // Add ACL for the external group.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ TestProjectUpdate.allowLabel("Code-Review")
+ .range(0, 1)
+ .ref("refs/heads/*")
+ .group(AccountGroup.uuid(TestGroupBackend.PREFIX + "AllUsers"))
+ .build())
+ .update();
+
+ // user and user2 can now see each other because the external AllUsers group that contains
+ // both users is guessed as relevant now that permissions are assigned to this group.
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isTrue();
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+ .isTrue();
+ }
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ @GerritConfig(name = "groups.relevantGroup", value = "testbackend:AllUsers")
+ public void accountsCanSeeEachOtherThroughASharedExternalGroupThatIsConfiguredAsRelevant()
+ throws Exception {
+ TestAccount user2 = accountCreator.user2();
+
+ // user and user2 cannot see each other because they do not share a Gerrit internal group
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isFalse();
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+ .isFalse();
+
+ // Check that the configured relevant group is included into the guessed groups.
+ assertThat(projectCache.guessRelevantGroupUUIDs())
+ .contains(AccountGroup.uuid("testbackend:AllUsers"));
+
+ // Configure an external group backend that has a single group that contains all users.
+ TestGroupBackend testGroupBackend = createTestGroupBackendWithAllUsersGroup("AllUsers");
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(testGroupBackend)) {
+ // user and user2 can see each other since the external AllUsers that contains both users has
+ // been configured as a relevant group.
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isTrue();
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user2.id())).canSee(user.id()))
+ .isTrue();
+ }
+ }
+
+ private TestGroupBackend createTestGroupBackendWithAllUsersGroup(String nameOfAllUsersGroup)
+ throws IOException {
+ TestGroupBackend testGroupBackend = new TestGroupBackend();
+
+ AccountGroup.UUID allUsersGroupUuid =
+ testGroupBackend.create(nameOfAllUsersGroup).getGroupUUID();
+
+ GroupMembership testGroupMembership =
+ new GroupMembership() {
+ @Override
+ public Set<AccountGroup.UUID> intersection(Iterable<AccountGroup.UUID> groupUuids) {
+ return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
+ .filter(this::contains)
+ .collect(toSet());
+ }
+
+ @Override
+ public Set<AccountGroup.UUID> getKnownGroups() {
+ // Typically for external group backends it's too expensive to query all groups that the
+ // user is a member of. Instead limit the group membership check to groups that are
+ // guessed to be relevant.
+ return projectCache.guessRelevantGroupUUIDs().stream()
+ // filter out groups of other group backends and groups of this group backend that
+ // don't exist
+ .filter(
+ uuid -> testGroupBackend.handles(uuid) && testGroupBackend.get(uuid) != null)
+ .collect(toImmutableSet());
+ }
+
+ @Override
+ public boolean containsAnyOf(Iterable<AccountGroup.UUID> groupUuids) {
+ return StreamSupport.stream(groupUuids.spliterator(), /* parallel= */ false)
+ .anyMatch(this::contains);
+ }
+
+ @Override
+ public boolean contains(AccountGroup.UUID groupUuid) {
+ return allUsersGroupUuid.equals(groupUuid);
+ }
+ };
+
+ accounts
+ .allIds()
+ .forEach(accountId -> testGroupBackend.setMembershipsOf(accountId, testGroupMembership));
+
+ return testGroupBackend;
+ }
+
private void assertExternalIds(Account.Id accountId, ImmutableSet<String> extIds)
throws Exception {
assertThat(
@@ -3245,16 +3400,19 @@ public class AccountIT extends AbstractDaemonTest {
}
private Map<String, GpgKeyInfo> addGpgKey(TestAccount account, String armored) throws Exception {
- AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
- try (Registration registration =
- extensionRegistry.newRegistration().add(accountIndexedCounter)) {
- Map<String, GpgKeyInfo> gpgKeys =
- gApi.accounts()
- .id(account.username())
- .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
- accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
- return gpgKeys;
- }
+ return testRefAction(
+ () -> {
+ AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(accountIndexedCounter)) {
+ Map<String, GpgKeyInfo> gpgKeys =
+ gApi.accounts()
+ .id(account.username())
+ .putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+ accountIndexedCounter.assertReindexOf(gApi.accounts().id(account.username()).get());
+ return gpgKeys;
+ }
+ });
}
private Map<String, GpgKeyInfo> addGpgKeyNoReindex(String armored) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index d1258fc3f0..1693411a9b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -16,6 +16,7 @@ package com.google.gerrit.acceptance.api.accounts;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.api.GerritApi;
@@ -32,6 +33,7 @@ import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.index.account.AccountIndexer;
import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.testing.InMemoryTestEnvironment;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -141,16 +143,19 @@ public class AccountIndexerIT {
private void updateAccountWithoutCacheOrIndex(Account.Id accountId, AccountDelta accountDelta)
throws IOException, ConfigInvalidException {
- try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
- MetaDataUpdate md =
- new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo)) {
- PersonIdent ident = serverIdent.get();
- md.getCommitBuilder().setAuthor(ident);
- md.getCommitBuilder().setCommitter(ident);
-
- AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
- accountConfig.setAccountDelta(accountDelta);
- accountConfig.commit(md);
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
+ MetaDataUpdate md =
+ new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo)) {
+ PersonIdent ident = serverIdent.get();
+ md.getCommitBuilder().setAuthor(ident);
+ md.getCommitBuilder().setCommitter(ident);
+
+ AccountConfig accountConfig =
+ new AccountConfig(accountId, allUsersName, allUsersRepo).load();
+ accountConfig.setAccountDelta(accountDelta);
+ accountConfig.commit(md);
+ }
}
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index 7e23f0e030..0d246e32d8 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -18,7 +18,9 @@ import static com.google.common.truth.OptionalSubject.optionals;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GOOGLE_OAUTH;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import static java.util.stream.Collectors.toSet;
import com.google.common.collect.ImmutableSet;
@@ -45,6 +47,7 @@ import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.group.db.GroupsUpdate;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.ssh.SshKeyCache;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.inject.Inject;
import com.google.inject.util.Providers;
import java.util.Optional;
@@ -285,11 +288,13 @@ public class AccountManagerIT extends AbstractDaemonTest {
// Create orphaned SCHEME_GERRIT external ID.
Account.Id accountId = Account.id(seq.nextAccountId());
ExternalId gerritExtId = externalIdFactory.create(gerritExtIdKey, accountId);
- try (Repository allUsersRepo = repoManager.openRepository(allUsers);
- MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
- ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
- extIdNotes.insert(gerritExtId);
- extIdNotes.commit(md);
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+ MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+ ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+ extIdNotes.insert(gerritExtId);
+ extIdNotes.commit(md);
+ }
}
AuthRequest who = authRequestFactory.createForUser(username);
@@ -563,6 +568,108 @@ public class AccountManagerIT extends AbstractDaemonTest {
}
@Test
+ public void errorCreatingOAuthAccountDueToPresentDuplicateUsernameExternalID() throws Exception {
+ String username = "foo";
+ String gerritEmail = "bar@example.com";
+
+ ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
+ AuthRequest whoGerrit = authRequestFactory.createForUser(username);
+ whoGerrit.setEmailAddress(gerritEmail);
+ AuthResult authResultGerrit = accountManager.authenticate(whoGerrit);
+ assertAuthResultForNewAccount(authResultGerrit, gerritExtIdKey);
+
+ // Check that OAuth externalID is not in use.
+ ExternalId.Key externalExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+ assertNoSuchExternalIds(externalExtIdKey);
+
+ String googleOAuthEmail = "baz@example.com";
+ AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+ whoOAuth.setEmailAddress(googleOAuthEmail);
+
+ AccountException thrown =
+ assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
+ assertThat(thrown).hasMessageThat().contains("Cannot assign external ID \"username:foo\" to");
+ }
+
+ @Test
+ public void errorCreatingOAuthAccountDueToDuplicateEmailExternalIDInNonLDAPExternalId()
+ throws Exception {
+ String username = "foo";
+ String gerritEmail = "foo@example.com";
+
+ ExternalId.Key gerritExtIdKey =
+ externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
+ AuthRequest whoGerrit = authRequestFactory.createForExternalUser(username);
+ whoGerrit.setEmailAddress(gerritEmail);
+ AuthResult authResultGerrit = accountManager.authenticate(whoGerrit);
+ assertAuthResultForNewAccount(authResultGerrit, gerritExtIdKey);
+
+ // Check that OAuth externalID is not in use.
+ ExternalId.Key externalExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+ assertNoSuchExternalIds(externalExtIdKey);
+
+ String googleOAuthEmail = "foo@example.com";
+ AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+ whoOAuth.setEmailAddress(googleOAuthEmail);
+
+ AccountException thrown =
+ assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("Email 'foo@example.com' in use by another account");
+ }
+
+ @Test
+ public void errorCreatingOAuthAccountDueToDuplicateUsernameIdentityAlreadyInUse()
+ throws Exception {
+ String username = "foo";
+ String gerritEmail = "foo@example.com";
+
+ ExternalId.Key externalExtIdKey =
+ externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
+ AuthRequest whoExternal = authRequestFactory.createForExternalUser(username);
+ whoExternal.setEmailAddress(gerritEmail);
+ AuthResult authResultGerrit = accountManager.authenticate(whoExternal);
+ assertAuthResultForNewAccount(authResultGerrit, externalExtIdKey);
+
+ // Check that OAuth externalID is not in use.
+ ExternalId.Key OAuthExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+ assertNoSuchExternalIds(OAuthExtIdKey);
+
+ String googleOAuthEmail = "baz@example.com";
+ AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+ whoExternal.setEmailAddress(googleOAuthEmail);
+
+ AccountException thrown =
+ assertThrows(AccountException.class, () -> accountManager.authenticate(whoOAuth));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("Cannot assign external ID \"username:foo\" to account");
+ }
+
+ @Test
+ public void linkOAuthAccountToLDAPAccountWithEmail() throws Exception {
+ String username = "foo";
+ String email = "foo@example.com";
+ ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
+ AuthRequest whoGerrit = authRequestFactory.createForUser(username);
+ whoGerrit.setEmailAddress(email);
+ AuthResult authResultGerrit = accountManager.authenticate(whoGerrit);
+ Account.Id accID = authResultGerrit.getAccountId();
+ assertAuthResultForNewAccount(authResultGerrit, gerritExtIdKey);
+ // Check that OAuth externalID is not in use.
+ ExternalId.Key OAuthExtIdKey = externalIdKeyFactory.create(SCHEME_GOOGLE_OAUTH, username);
+ assertNoSuchExternalIds(OAuthExtIdKey);
+
+ AuthRequest whoOAuth = authRequestFactory.createForOAuthUser(username);
+ whoOAuth.setEmailAddress(email);
+ AuthResult authResultOAuth = accountManager.authenticate(whoOAuth);
+ assertAuthResultForExistingAccount(authResultOAuth, accID, OAuthExtIdKey);
+
+ assertThat(authResultOAuth.getAccountId()).isEqualTo(authResultGerrit.getAccountId());
+ }
+
+ @Test
public void updateExternalIdOnLink() throws Exception {
// Create an account with a SCHEME_GERRIT external ID and no email
String username = "foo";
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
index 3c605e1a01..c4414026ce 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/BUILD
@@ -13,6 +13,7 @@ acceptance_tests(
"//java/com/google/gerrit/git",
"//java/com/google/gerrit/mail",
"//java/com/google/gerrit/server/util/time",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
],
)
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index 52e2121f72..59ba00b57b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -78,7 +78,6 @@ public class GeneralPreferencesIT extends AbstractDaemonTest {
i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
i.disableKeyboardShortcuts = true;
i.expandInlineDiffs ^= true;
- i.highlightAssigneeInChangeTable ^= true;
i.relativeDateInChangeTable ^= true;
i.sizeBarInChangeTable ^= true;
i.legacycidInChangeTable ^= true;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index 80431ee596..b80ff9b1a4 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -46,6 +46,7 @@ import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.testing.TestTimeUtil;
import com.google.inject.Inject;
import java.util.List;
+import java.util.Locale;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.ObjectId;
@@ -65,7 +66,8 @@ public class AbandonIT extends AbstractDaemonTest {
gApi.changes().id(changeId).abandon();
ChangeInfo info = get(changeId, MESSAGES);
assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
- assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
+ assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+ .contains("abandoned");
ResourceConflictException thrown =
assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).abandon());
@@ -82,13 +84,17 @@ public class AbandonIT extends AbstractDaemonTest {
ChangeInfo info = get(a.getChangeId(), MESSAGES);
assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
- assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
- assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+ assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+ .contains("abandoned");
+ assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+ .contains("deadbeef");
info = get(b.getChangeId(), MESSAGES);
assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
- assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("abandoned");
- assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("deadbeef");
+ assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+ .contains("abandoned");
+ assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+ .contains("deadbeef");
}
@Test
@@ -292,7 +298,8 @@ public class AbandonIT extends AbstractDaemonTest {
gApi.changes().id(changeId).restore();
ChangeInfo info = get(changeId, MESSAGES);
assertThat(info.status).isEqualTo(ChangeStatus.NEW);
- assertThat(Iterables.getLast(info.messages).message.toLowerCase()).contains("restored");
+ assertThat(Iterables.getLast(info.messages).message.toLowerCase(Locale.US))
+ .contains("restored");
ResourceConflictException thrown =
assertThrows(ResourceConflictException.class, () -> gApi.changes().id(changeId).restore());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
new file mode 100644
index 0000000000..f31ae9b1e1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -0,0 +1,521 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_FILES;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.patch.DiffUtil.cleanPatch;
+import static com.google.gerrit.server.patch.DiffUtil.removePatchHeader;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchPatchSetInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+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.common.DiffInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.testing.GitPersonSubject;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.Base64;
+import org.junit.Test;
+
+public class ApplyPatchIT extends AbstractDaemonTest {
+
+ private static final String DESTINATION_BRANCH = "destBranch";
+
+ private static final String ADDED_FILE_NAME = "a_new_file.txt";
+ private static final String ADDED_FILE_CONTENT = "First added line\nSecond added line\n";
+ private static final String ADDED_FILE_DIFF =
+ "diff --git a/a_new_file.txt b/a_new_file.txt\n"
+ + "new file mode 100644\n"
+ + "--- /dev/null\n"
+ + "+++ b/a_new_file.txt\n"
+ + "@@ -0,0 +1,2 @@\n"
+ + "+First added line\n"
+ + "+Second added line\n";
+
+ @Inject private ProjectOperations projectOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+
+ @Test
+ public void applyAddedFilePatch_success() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+ ChangeInfo result = applyPatch(in);
+
+ DiffInfo diff = fetchDiffForFile(result, ADDED_FILE_NAME);
+ assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+ }
+
+ private static final String MODIFIED_FILE_NAME = "modified_file.txt";
+ private static final String MODIFIED_FILE_ORIGINAL_CONTENT =
+ "First original line\nSecond original line";
+ private static final String MODIFIED_FILE_NEW_CONTENT = "Modified line\n";
+ private static final String MODIFIED_FILE_DIFF =
+ "diff --git a/modified_file.txt b/modified_file.txt\n"
+ + "--- a/modified_file.txt\n"
+ + "+++ b/modified_file.txt\n"
+ + "@@ -1,2 +1 @@\n"
+ + "-First original line\n"
+ + "-Second original line\n"
+ + "+Modified line\n";
+
+ @Test
+ public void applyModifiedFilePatch_success() throws Exception {
+ initBaseWithFile(MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+ ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+
+ ChangeInfo result = applyPatch(in);
+
+ DiffInfo diff = fetchDiffForFile(result, MODIFIED_FILE_NAME);
+ assertDiffForFullyModifiedFile(
+ diff,
+ result.currentRevision,
+ MODIFIED_FILE_NAME,
+ MODIFIED_FILE_ORIGINAL_CONTENT,
+ MODIFIED_FILE_NEW_CONTENT);
+ }
+
+ @Test
+ public void applyDeletedFilePatch_success() throws Exception {
+ final String deletedFileName = "deleted_file.txt";
+ final String deletedFileOriginalContent = "content to be deleted.\n";
+ final String deletedFileDiff =
+ "diff --git a/deleted_file.txt b/deleted_file.txt\n"
+ + "--- a/deleted_file.txt\n"
+ + "+++ /dev/null\n";
+ initBaseWithFile(deletedFileName, deletedFileOriginalContent);
+ ApplyPatchPatchSetInput in = buildInput(deletedFileDiff);
+
+ ChangeInfo result = applyPatch(in);
+
+ DiffInfo diff = fetchDiffForFile(result, deletedFileName);
+ assertDiffForDeletedFile(diff, deletedFileName, deletedFileOriginalContent);
+ }
+
+ @Test
+ public void applyRenamedFilePatch_success() throws Exception {
+ final String renamedFileOriginalName = "renamed_file_origin.txt";
+ final String renamedFileNewName = "renamed_file_new.txt";
+ final String renamedFileDiff =
+ "diff --git a/renamed_file_origin.txt b/renamed_file_new.txt\n"
+ + "rename from renamed_file_origin.txt\n"
+ + "rename to renamed_file_new.txt\n"
+ + "--- a/renamed_file_origin.txt\n"
+ + "+++ b/renamed_file_new.txt\n"
+ + "@@ -1,2 +1 @@\n"
+ + "-First original line\n"
+ + "-Second original line\n"
+ + "+Modified line\n";
+ initBaseWithFile(renamedFileOriginalName, MODIFIED_FILE_ORIGINAL_CONTENT);
+ ApplyPatchPatchSetInput in = buildInput(renamedFileDiff);
+
+ ChangeInfo result = applyPatch(in);
+
+ DiffInfo originalFileDiff = fetchDiffForFile(result, renamedFileOriginalName);
+ assertDiffForDeletedFile(
+ originalFileDiff, renamedFileOriginalName, MODIFIED_FILE_ORIGINAL_CONTENT);
+ DiffInfo newFileDiff = fetchDiffForFile(result, renamedFileNewName);
+ assertDiffForNewFile(
+ newFileDiff, result.currentRevision, renamedFileNewName, MODIFIED_FILE_NEW_CONTENT);
+ }
+
+ @Test
+ public void applyGerritBasedPatchWithSingleFile_success() throws Exception {
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+ PushOneCommit.Result baseCommit = createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+ baseCommit.assertOkStatus();
+ BinaryResult originalPatch = gApi.changes().id(baseCommit.getChangeId()).current().patch();
+ ApplyPatchPatchSetInput in = buildInput(originalPatch.asString());
+ createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+
+ ChangeInfo result = applyPatch(in);
+
+ BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
+ assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+ }
+
+ @Test
+ public void applyGerritBasedPatchWithMultipleFiles_success() throws Exception {
+ PushOneCommit.Result commonBaseCommit =
+ createChange("File for modification", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+ commonBaseCommit.assertOkStatus();
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+ PushOneCommit.Result commitToPatch =
+ createChange("Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+ amendChange(
+ commitToPatch.getChangeId(), "Modify file", MODIFIED_FILE_NAME, MODIFIED_FILE_NEW_CONTENT);
+ commitToPatch.assertOkStatus();
+ BinaryResult originalPatch = gApi.changes().id(commitToPatch.getChangeId()).current().patch();
+ ApplyPatchPatchSetInput in = buildInput(originalPatch.asString());
+ createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+
+ ChangeInfo result = applyPatch(in);
+
+ BinaryResult resultPatch = gApi.changes().id(result.id).current().patch();
+ assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+ }
+
+ @Test
+ public void applyGerritBasedPatchUsingRest_success() throws Exception {
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+ PushOneCommit.Result destChange = createChange("refs/for/" + DESTINATION_BRANCH);
+ createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+ PushOneCommit.Result baseCommit =
+ createChange(testRepo, "branch", "Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT, "");
+ baseCommit.assertOkStatus();
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+ patchResp.assertOK();
+ String originalPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+ ApplyPatchPatchSetInput in = buildInput(originalPatch);
+
+ RestResponse resp =
+ adminRestSession.post("/changes/" + destChange.getChangeId() + "/patch:apply", in);
+
+ resp.assertOK();
+ BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
+ assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalPatch));
+ }
+
+ @Test
+ public void applyGerritBasedPatchUsingRestWithEncodedPatch_success() throws Exception {
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, DESTINATION_BRANCH), head);
+ PushOneCommit.Result destChange = createChange("refs/for/" + DESTINATION_BRANCH);
+ createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+ PushOneCommit.Result baseCommit =
+ createChange(testRepo, "branch", "Add file", ADDED_FILE_NAME, ADDED_FILE_CONTENT, "");
+ baseCommit.assertOkStatus();
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+ patchResp.assertOK();
+ String originalEncodedPatch = patchResp.getEntityContent();
+ String originalDecodedPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+ ApplyPatchPatchSetInput in = buildInput(originalEncodedPatch);
+
+ RestResponse resp =
+ adminRestSession.post("/changes/" + destChange.getChangeId() + "/patch:apply", in);
+
+ resp.assertOK();
+ BinaryResult resultPatch = gApi.changes().id(destChange.getChangeId()).current().patch();
+ assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(originalDecodedPatch));
+ }
+
+ @Test
+ public void applyPatchWithConflict_appendErrorsToCommitMessage() throws Exception {
+ initBaseWithFile(MODIFIED_FILE_NAME, "Unexpected base content");
+ String patch = ADDED_FILE_DIFF + MODIFIED_FILE_DIFF;
+ ApplyPatchPatchSetInput in = buildInput(patch);
+ in.commitMessage = "subject";
+
+ ChangeInfo result = applyPatch(in);
+
+ assertThat(gApi.changes().id(result.id).current().commit(false).message)
+ .isEqualTo(
+ in.commitMessage
+ + "\n\nNOTE FOR REVIEWERS - errors occurred while applying the patch."
+ + "\nPLEASE REVIEW CAREFULLY.\nErrors:\nError applying patch in "
+ + MODIFIED_FILE_NAME
+ + ", hunk HunkHeader[1,2->1,1]: Hunk cannot be applied\n\nOriginal patch:\n "
+ + removePatchHeader(patch)
+ + "\n\nChange-Id: "
+ + result.changeId
+ + "\n");
+ // Error in MODIFIED_FILE should not affect ADDED_FILE results.
+ DiffInfo diff = fetchDiffForFile(result, ADDED_FILE_NAME);
+ assertDiffForNewFile(diff, result.currentRevision, ADDED_FILE_NAME, ADDED_FILE_CONTENT);
+ }
+
+ @Test
+ public void applyPatchWithoutAddPatchSetPermissions_fails() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .remove(
+ permissionKey(Permission.ADD_PATCH_SET)
+ .ref("refs/for/*")
+ .group(REGISTERED_USERS)
+ .build())
+ .update();
+ PushOneCommit.Result destChange = createChange("dest change", "a file", "with content");
+ // Add-patch is always allowed for the change owner, so we need to use another account.
+ requestScopeOperations.setApiUser(accountCreator.user2().id());
+
+ Throwable error =
+ assertThrows(
+ AuthException.class, () -> gApi.changes().id(destChange.getChangeId()).applyPatch(in));
+
+ assertThat(error).hasMessageThat().contains("patch set");
+ }
+
+ @Test
+ public void applyPatchWithCustomMessage_success() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.commitMessage = "custom commit message";
+
+ ChangeInfo result = applyPatch(in);
+
+ assertThat(gApi.changes().id(result.id).current().commit(false).message)
+ .contains(in.commitMessage);
+ }
+
+ @Test
+ public void applyPatchWithBaseCommit_success() throws Exception {
+ PushOneCommit.Result baseCommit =
+ createChange("base commit", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT);
+ baseCommit.assertOkStatus();
+ PushOneCommit.Result ignoredCommit =
+ createChange("Ignored file modification", MODIFIED_FILE_NAME, "Ignored file modification");
+ ignoredCommit.assertOkStatus();
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF);
+ in.base = baseCommit.getCommit().getName();
+
+ ChangeInfo result = applyPatch(in);
+
+ assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit)
+ .isEqualTo(in.base);
+ }
+
+ @Test
+ public void applyPatchWithDefaultAuthor_success() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+ ChangeInfo result = applyPatch(in);
+
+ GitPerson person = gApi.changes().id(result.id).current().commit(false).author;
+ GitPersonSubject.assertThat(person).email().isEqualTo(admin.email());
+ }
+
+ @Test
+ public void applyPatchWithAuthorOverrideMissingEmail_throwsIllegalArgument() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.author = new AccountInput();
+ in.author.name = "name";
+
+ Throwable error = assertThrows(IllegalArgumentException.class, () -> applyPatch(in));
+
+ assertThat(error).hasMessageThat().contains("E-mail");
+ }
+
+ @Test
+ public void applyPatchWithAuthorOverrideMissingName_throwsIllegalArgument() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.author = new AccountInput();
+ in.author.name = null;
+ in.author.email = "gerritlessjane@invalid";
+
+ Throwable error = assertThrows(IllegalArgumentException.class, () -> applyPatch(in));
+
+ assertThat(error).hasMessageThat().contains("Name");
+ }
+
+ @Test
+ public void applyPatchWithAuthorOverride_success() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.author = new AccountInput();
+ in.author.email = "gerritlessjane@invalid";
+ // This is an email address that doesn't exist as account on the Gerrit server.
+ in.author.name = "Gerritless Jane";
+
+ ChangeInfo result = applyPatch(in);
+
+ RevisionApi rApi = gApi.changes().id(result.id).current();
+ GitPerson author = rApi.commit(false).author;
+ GitPersonSubject.assertThat(author).email().isEqualTo(in.author.email);
+ GitPersonSubject.assertThat(author).name().isEqualTo(in.author.name);
+ GitPerson committer = rApi.commit(false).committer;
+ GitPersonSubject.assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+ }
+
+ @Test
+ public void applyPatchWithAuthorWithoutPermissions_fails() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.author = new AccountInput();
+ in.author.name = "Jane";
+ in.author.email = "jane@invalid";
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ Throwable error = assertThrows(ResourceConflictException.class, () -> applyPatch(in));
+
+ assertThat(error).hasMessageThat().contains("forge author");
+ }
+
+ @Test
+ public void applyPatchWithSelfAsForgedAuthor_success() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.author = new AccountInput();
+ in.author.name = admin.fullName();
+ in.author.email = admin.email();
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ ChangeInfo result = applyPatch(in);
+
+ GitPerson person = gApi.changes().id(result.id).current().commit(false).author;
+ GitPersonSubject.assertThat(person).email().isEqualTo(admin.email());
+ }
+
+ @Test
+ public void applyPatchWithExplicitBase_overrideParentId() throws Exception {
+ PushOneCommit.Result inputParent = createChange("Input parent", "file1", "content");
+ PushOneCommit.Result parent = createChange("Parent Change", "file2", "content");
+ parent.assertOkStatus();
+ PushOneCommit.Result dest = createChange("Destination Change", "file3", "content");
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.base = inputParent.getCommit().name();
+
+ gApi.changes().id(dest.getChangeId()).applyPatch(in);
+
+ ChangeInfo result = get(dest.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
+ assertThat(result.revisions.get(result.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(inputParent.getCommit().name());
+
+ BinaryResult resultPatch = gApi.changes().id(dest.getChangeId()).current().patch();
+ assertThat(cleanPatch(resultPatch)).isEqualTo(cleanPatch(ADDED_FILE_DIFF));
+ }
+
+ @Test
+ public void applyPatchWithNoExplicitBase_overwritesLatestPatch() throws Exception {
+ PushOneCommit.Result dest = createChange("Destination Change", "ps1.txt", "ps1 content");
+ RevCommit originalParentCommit = dest.getCommit().getParent(0);
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+ gApi.changes().id(dest.getChangeId()).applyPatch(in);
+
+ ChangeInfo result = get(dest.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT, CURRENT_FILES);
+ assertThat(result.revisions.get(result.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(originalParentCommit.name());
+ assertThat(result.revisions.get(result.currentRevision).files.keySet())
+ .containsExactly(ADDED_FILE_NAME);
+ assertDiffForNewFile(
+ fetchDiffForFile(result, ADDED_FILE_NAME),
+ result.currentRevision,
+ ADDED_FILE_NAME,
+ ADDED_FILE_CONTENT);
+ }
+
+ @Test
+ public void commitMessage_providedMessage() throws Exception {
+ final String msg = "custom message";
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+ in.commitMessage = msg;
+
+ ChangeInfo result = applyPatch(in);
+
+ ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+ assertThat(info.revisions.get(info.currentRevision).commit.message)
+ .isEqualTo(msg + "\n\nChange-Id: " + result.changeId + "\n");
+ }
+
+ @Test
+ public void commitMessage_defaultMessageAndPatchHeader() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput("Patch header\n" + ADDED_FILE_DIFF);
+
+ ChangeInfo result = applyPatch(in);
+
+ ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+ assertThat(info.revisions.get(info.currentRevision).commit.message)
+ .isEqualTo("Default commit message\n\nChange-Id: " + result.changeId + "\n");
+ }
+
+ @Test
+ public void commitMessage_defaultMessageAndNoPatchHeader() throws Exception {
+ initDestBranch();
+ ApplyPatchPatchSetInput in = buildInput(ADDED_FILE_DIFF);
+
+ ChangeInfo result = applyPatch(in);
+
+ ChangeInfo info = get(result.changeId, CURRENT_REVISION, CURRENT_COMMIT);
+ assertThat(info.revisions.get(info.currentRevision).commit.message)
+ .isEqualTo("Default commit message\n\nChange-Id: " + result.changeId + "\n");
+ }
+
+ private void initDestBranch() throws Exception {
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, ApplyPatchIT.DESTINATION_BRANCH), head);
+ }
+
+ private void initBaseWithFile(String fileName, String fileContent) throws Exception {
+ PushOneCommit.Result baseCommit =
+ createChange("Add original file: " + fileName, fileName, fileContent);
+ baseCommit.assertOkStatus();
+ initDestBranch();
+ }
+
+ private ApplyPatchPatchSetInput buildInput(String patch) {
+ ApplyPatchPatchSetInput in = new ApplyPatchPatchSetInput();
+ in.patch = new ApplyPatchInput();
+ in.patch.patch = patch;
+ return in;
+ }
+
+ private ChangeInfo applyPatch(ApplyPatchPatchSetInput input) throws RestApiException {
+ input.responseFormatOptions = ImmutableList.of(ListChangesOption.CURRENT_REVISION);
+ return gApi.changes()
+ .create(new ChangeInput(project.get(), DESTINATION_BRANCH, "Default commit message"))
+ .applyPatch(input);
+ }
+
+ private DiffInfo fetchDiffForFile(ChangeInfo result, String fileName) throws RestApiException {
+ return gApi.changes().id(result.id).current().file(fileName).diff();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index c9c5c2c1c8..d8bf8efe2b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -28,6 +28,7 @@ import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.a
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
@@ -48,15 +49,16 @@ import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS
import static com.google.gerrit.extensions.client.ReviewerState.CC;
import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
import static com.google.gerrit.server.project.testing.TestLabels.label;
import static com.google.gerrit.server.project.testing.TestLabels.value;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import static com.google.gerrit.truth.CacheStatsSubject.assertThat;
import static com.google.gerrit.truth.CacheStatsSubject.cloneStats;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -101,6 +103,7 @@ import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.LabelType;
@@ -118,9 +121,8 @@ import com.google.gerrit.extensions.api.changes.DraftApi;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.NotifyInfo;
-import com.google.gerrit.extensions.api.changes.RebaseInput;
import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.RevertInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -147,33 +149,28 @@ import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GitPerson;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
import com.google.gerrit.extensions.common.TrackingIdInfo;
import com.google.gerrit.extensions.events.AttentionSetListener;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.query.PostFilterPredicate;
import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeMessages;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.testing.TestChangeETagComputation;
-import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.ChangeMessageModifier;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationListener;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.index.change.ChangeIndex;
import com.google.gerrit.server.index.change.ChangeIndexCollection;
@@ -189,13 +186,13 @@ import com.google.gerrit.server.restapi.change.PostReview;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.FakeEmailSender.Message;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.sql.Timestamp;
import java.text.MessageFormat;
@@ -203,6 +200,7 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
+import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Iterator;
@@ -239,6 +237,7 @@ public class ChangeIT extends AbstractDaemonTest {
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ExtensionRegistry extensionRegistry;
@Inject private IndexOperations.Change changeIndexOperations;
+ @Inject private AccountControl.Factory accountControlFactory;
@Inject
@Named("diff_intraline")
@@ -420,6 +419,31 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ public void setReadyForReviewSendsNotificationsForRevertChange() throws Exception {
+ PushOneCommit.Result r = createChange();
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+ RevertInput in = new RevertInput();
+ in.workInProgress = true;
+ String changeId = gApi.changes().id(r.getChangeId()).revert(in).get().changeId;
+ requestScopeOperations.setApiUser(admin.id());
+
+ gApi.changes().id(changeId).setReadyForReview();
+
+ assertThat(gApi.changes().id(changeId).get().workInProgress).isNull();
+ // expected messages on source change:
+ // 1. Uploaded patch set 1.
+ // 2. Patch Set 1: Code-Review+2
+ // 3. Change has been successfully merged by Administrator
+ // 4. Patch Set 1: Reverted
+ List<ChangeMessageInfo> sourceMessages =
+ new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+ assertThat(sourceMessages).hasSize(4);
+ String expectedMessage = String.format("Created a revert of this change as I%s", changeId);
+ assertThat(sourceMessages.get(3).message).isEqualTo(expectedMessage);
+ }
+
+ @Test
public void hasReviewStarted() throws Exception {
PushOneCommit.Result r = createWorkInProgressChange();
String changeId = r.getChangeId();
@@ -645,6 +669,21 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ public void reviewRemoveInactiveReviewer() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ReviewInput in = ReviewInput.approve().reviewer(user.email());
+ gApi.changes().id(r.getChangeId()).current().review(in);
+
+ accountOperations.account(user.id()).forUpdate().inactive().update();
+ in = ReviewInput.noScore().reviewer(Integer.toString(user.id().get()), REMOVED, false);
+
+ gApi.changes().id(r.getChangeId()).current().review(in);
+ ChangeInfo info = gApi.changes().id(r.getChangeId()).get();
+ assertThat(info.reviewers.get(REVIEWER).stream().map(ai -> ai._accountId).collect(toList()))
+ .containsExactly(admin.id().get());
+ }
+
+ @Test
public void reviewWithWorkInProgressAndReadyReturnsError() throws Exception {
PushOneCommit.Result r = createChange();
ReviewInput in = ReviewInput.noScore();
@@ -752,303 +791,6 @@ public class ChangeIT extends AbstractDaemonTest {
assertThat(thrown).hasMessageThat().contains("Multiple changes found for " + changeId);
}
- @FunctionalInterface
- private interface Rebase {
- void call(String id) throws RestApiException;
- }
-
- @Test
- public void rebaseViaRevisionApi() throws Exception {
- testRebase(id -> gApi.changes().id(id).current().rebase());
- }
-
- @Test
- public void rebaseViaChangeApi() throws Exception {
- testRebase(id -> gApi.changes().id(id).rebase());
- }
-
- private void testRebase(Rebase rebase) throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- // Add an approval whose score should be copied on trivial rebase
- gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
-
- String changeId = r2.getChangeId();
- // Rebase the second change
- rebase.call(changeId);
-
- // Second change should have 2 patch sets and an approval
- ChangeInfo c2 = gApi.changes().id(changeId).get(CURRENT_REVISION, DETAILED_LABELS);
- assertThat(c2.revisions.get(c2.currentRevision)._number).isEqualTo(2);
-
- // ...and the committer and description should be correct
- ChangeInfo info = gApi.changes().id(changeId).get(CURRENT_REVISION, CURRENT_COMMIT);
- GitPerson committer = info.revisions.get(info.currentRevision).commit.committer;
- assertThat(committer.name).isEqualTo(admin.fullName());
- assertThat(committer.email).isEqualTo(admin.email());
- String description = info.revisions.get(info.currentRevision).description;
- assertThat(description).isEqualTo("Rebase");
-
- // ...and the approval was copied
- LabelInfo cr = c2.labels.get(LabelId.CODE_REVIEW);
- assertThat(cr).isNotNull();
- assertThat(cr.all).hasSize(1);
- assertThat(cr.all.get(0).value).isEqualTo(1);
-
- // Rebasing the second change again should fail
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class, () -> gApi.changes().id(changeId).current().rebase());
- assertThat(thrown).hasMessageThat().contains("Change is already up to date");
- }
-
- @Test
- public void rebaseAsUploaderInAttentionSet() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- TestAccount admin2 = accountCreator.admin2();
- requestScopeOperations.setApiUser(admin2.id());
- amendChangeWithUploader(r2, project, admin2);
- gApi.changes()
- .id(r2.getChangeId())
- .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
-
- gApi.changes().id(r2.getChangeId()).rebase();
- }
-
- @Test
- public void rebaseOnChangeNumber() throws Exception {
- String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
- PushOneCommit.Result r1 = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
- RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
- assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
- Change.Id id1 = r1.getChange().getId();
- RebaseInput in = new RebaseInput();
- in.base = id1.toString();
- gApi.changes().id(r2.getChangeId()).rebase(in);
-
- Change.Id id2 = r2.getChange().getId();
- ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
- ri2 = ci2.revisions.get(ci2.currentRevision);
- assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
- List<RelatedChangeAndCommitInfo> related =
- gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
- assertThat(related).hasSize(2);
- assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
- assertThat(related.get(0)._revisionNumber).isEqualTo(2);
- assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
- assertThat(related.get(1)._revisionNumber).isEqualTo(1);
- }
-
- @Test
- public void rebaseOnClosedChange() throws Exception {
- String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
- PushOneCommit.Result r1 = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- ChangeInfo ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
- RevisionInfo ri2 = ci2.revisions.get(ci2.currentRevision);
- assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
-
- // Submit first change.
- Change.Id id1 = r1.getChange().getId();
- gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
- gApi.changes().id(id1.get()).current().submit();
-
- // Rebase second change on first change.
- RebaseInput in = new RebaseInput();
- in.base = id1.toString();
- gApi.changes().id(r2.getChangeId()).rebase(in);
-
- Change.Id id2 = r2.getChange().getId();
- ci2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
- ri2 = ci2.revisions.get(ci2.currentRevision);
- assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
- assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
- }
-
- @Test
- public void rebaseOnNonExistingChange() throws Exception {
- String changeId = createChange().getChangeId();
- RebaseInput in = new RebaseInput();
- in.base = "999999";
- UnprocessableEntityException exception =
- assertThrows(
- UnprocessableEntityException.class, () -> gApi.changes().id(changeId).rebase(in));
- assertThat(exception).hasMessageThat().isEqualTo("Base change not found: " + in.base);
- }
-
- @Test
- public void rebaseFromRelationChainToClosedChange() throws Exception {
- PushOneCommit.Result r1 = createChange();
- testRepo.reset("HEAD~1");
-
- createChange();
- PushOneCommit.Result r3 = createChange();
-
- // Submit first change.
- Change.Id id1 = r1.getChange().getId();
- gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
- gApi.changes().id(id1.get()).current().submit();
-
- // Rebase third change on first change.
- RebaseInput in = new RebaseInput();
- in.base = id1.toString();
- gApi.changes().id(r3.getChangeId()).rebase(in);
-
- Change.Id id3 = r3.getChange().getId();
- ChangeInfo ci3 = get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT);
- RevisionInfo ri3 = ci3.revisions.get(ci3.currentRevision);
- assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
-
- assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
- }
-
- @Test
- public void rebaseNotAllowedWithoutPermission() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- // Rebase the second
- String changeId = r2.getChangeId();
- requestScopeOperations.setApiUser(user.id());
- AuthException thrown =
- assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
- assertThat(thrown).hasMessageThat().contains("rebase not permitted");
- }
-
- @Test
- public void rebaseAllowedWithPermission() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- projectOperations
- .project(project)
- .forUpdate()
- .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
- .update();
-
- // Rebase the second
- String changeId = r2.getChangeId();
- requestScopeOperations.setApiUser(user.id());
- gApi.changes().id(changeId).rebase();
- }
-
- @Test
- public void rebaseNotAllowedWithoutPushPermission() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- projectOperations
- .project(project)
- .forUpdate()
- .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
- .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
- .update();
-
- // Rebase the second
- String changeId = r2.getChangeId();
- requestScopeOperations.setApiUser(user.id());
- AuthException thrown =
- assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
- assertThat(thrown).hasMessageThat().contains("rebase not permitted");
- }
-
- @Test
- public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- projectOperations
- .project(project)
- .forUpdate()
- .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
- .update();
-
- // Rebase the second
- String changeId = r2.getChangeId();
- AuthException thrown =
- assertThrows(AuthException.class, () -> gApi.changes().id(changeId).rebase());
- assertThat(thrown).hasMessageThat().contains("rebase not permitted");
- }
-
- @Test
- public void rebaseWithValidationOptions() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Approve and submit the first change
- RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
- revision.review(ReviewInput.approve());
- revision.submit();
-
- RebaseInput rebaseInput = new RebaseInput();
- rebaseInput.validationOptions = ImmutableMap.of("key", "value");
-
- TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
- try (Registration registration =
- extensionRegistry.newRegistration().add(testCommitValidationListener)) {
- // Rebase the second change
- gApi.changes().id(r2.getChangeId()).current().rebase(rebaseInput);
- assertThat(testCommitValidationListener.receiveEvent.pushOptions)
- .containsExactly("key", "value");
- }
- }
-
@Test
public void deleteNewChangeAsAdmin() throws Exception {
deleteChangeAsUser(admin, admin);
@@ -1381,166 +1123,6 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
- public void rebaseUpToDateChange() throws Exception {
- PushOneCommit.Result r = createChange();
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).rebase());
- assertThat(thrown).hasMessageThat().contains("Change is already up to date");
- }
-
- @Test
- public void rebaseConflict() throws Exception {
- 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",
- "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
- PushOneCommit.Result r2 = push.to("refs/for/master");
- r2.assertOkStatus();
- ResourceConflictException exception =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase());
- assertThat(exception)
- .hasMessageThat()
- .isEqualTo(
- String.format(
- "The change could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n%s",
- PushOneCommit.FILE_NAME));
- }
-
- @Test
- public void rebaseDoesNotAddWorkInProgress() throws Exception {
- PushOneCommit.Result r = createChange();
-
- // create an unrelated change so that we can rebase
- testRepo.reset("HEAD~1");
- PushOneCommit.Result unrelated = createChange();
- gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
- gApi.changes().id(unrelated.getChangeId()).current().submit();
-
- gApi.changes().id(r.getChangeId()).rebase();
-
- // change is still ready for review after rebase
- assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
- }
-
- @Test
- public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
- PushOneCommit.Result r = createChange();
- change(r).setWorkInProgress();
-
- // create an unrelated change so that we can rebase
- testRepo.reset("HEAD~1");
- PushOneCommit.Result unrelated = createChange();
- gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
- gApi.changes().id(unrelated.getChangeId()).current().submit();
-
- gApi.changes().id(r.getChangeId()).rebase();
-
- // change is still work in progress after rebase
- assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
- }
-
- @Test
- public void rebaseConflict_conflictsAllowed() throws Exception {
- String patchSetSubject = "patch set change";
- String patchSetContent = "patch set content";
- String baseSubject = "base change";
- String baseContent = "base content";
-
- PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
- gApi.changes()
- .id(r1.getChangeId())
- .revision(r1.getCommit().name())
- .review(ReviewInput.approve());
- gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
-
- testRepo.reset("HEAD~1");
- PushOneCommit push =
- pushFactory.create(
- admin.newIdent(), testRepo, patchSetSubject, PushOneCommit.FILE_NAME, patchSetContent);
- PushOneCommit.Result r2 = push.to("refs/for/master");
- r2.assertOkStatus();
-
- String changeId = r2.getChangeId();
- RevCommit patchSet = r2.getCommit();
- RevCommit base = r1.getCommit();
-
- TestWorkInProgressStateChangedListener wipStateChangedListener =
- new TestWorkInProgressStateChangedListener();
- try (Registration registration =
- extensionRegistry.newRegistration().add(wipStateChangedListener)) {
- RebaseInput rebaseInput = new RebaseInput();
- rebaseInput.allowConflicts = true;
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
- assertThat(changeInfo.containsGitConflicts).isTrue();
- assertThat(changeInfo.workInProgress).isTrue();
- }
- assertThat(wipStateChangedListener.invoked).isTrue();
- assertThat(wipStateChangedListener.wip).isTrue();
-
- // To get the revisions, we must retrieve the change with more change options.
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(base.name());
-
- // 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(PushOneCommit.FILE_NAME).content();
- ByteArrayOutputStream os = new ByteArrayOutputStream();
- bin.writeTo(os);
- String fileContent = new String(os.toByteArray(), UTF_8);
- String patchSetSha1 = abbreviateName(patchSet, 6);
- String baseSha1 = abbreviateName(base, 6);
- assertThat(fileContent)
- .isEqualTo(
- "<<<<<<< PATCH SET ("
- + patchSetSha1
- + " "
- + patchSetSubject
- + ")\n"
- + patchSetContent
- + "\n"
- + "=======\n"
- + baseContent
- + "\n"
- + ">>>>>>> BASE ("
- + baseSha1
- + " "
- + baseSubject
- + ")\n");
-
- // Verify the message that has been posted on the change.
- List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
- assertThat(messages).hasSize(2);
- assertThat(Iterables.getLast(messages).message)
- .isEqualTo(
- "Patch Set 2: Patch Set 1 was rebased\n\n"
- + "The following files contain Git conflicts:\n"
- + "* "
- + PushOneCommit.FILE_NAME
- + "\n");
- }
-
- @Test
public void attentionSetListener_firesOnChange() throws Exception {
PushOneCommit.Result r1 = createChange();
AttentionSetInput addUser = new AttentionSetInput(user.email(), "some reason");
@@ -1573,109 +1155,6 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
- public void rebaseChangeBase() throws Exception {
- PushOneCommit.Result r1 = createChange();
- PushOneCommit.Result r2 = createChange();
- PushOneCommit.Result r3 = createChange();
- RebaseInput ri = new RebaseInput();
-
- // rebase r3 directly onto master (break dep. towards r2)
- ri.base = "";
- gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).rebase(ri);
- PatchSet ps3 = r3.getPatchSet();
- assertThat(ps3.id().get()).isEqualTo(2);
-
- // rebase r2 onto r3 (referenced by ref)
- ri.base = ps3.id().toRefName();
- gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri);
- PatchSet ps2 = r2.getPatchSet();
- assertThat(ps2.id().get()).isEqualTo(2);
-
- // rebase r1 onto r2 (referenced by commit)
- ri.base = ps2.commitId().name();
- gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri);
- PatchSet ps1 = r1.getPatchSet();
- assertThat(ps1.id().get()).isEqualTo(2);
-
- // rebase r1 onto r3 (referenced by change number)
- ri.base = String.valueOf(r3.getChange().getId().get());
- gApi.changes().id(r1.getChangeId()).revision(ps1.commitId().name()).rebase(ri);
- assertThat(r1.getPatchSetId().get()).isEqualTo(3);
- }
-
- @Test
- public void rebaseChangeBaseRecursion() throws Exception {
- PushOneCommit.Result r1 = createChange();
- PushOneCommit.Result r2 = createChange();
-
- RebaseInput ri = new RebaseInput();
- ri.base = r2.getCommit().name();
- String expectedMessage =
- "base change "
- + r2.getChangeId()
- + " is a descendant of the current change - recursion not allowed";
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).rebase(ri));
- assertThat(thrown).hasMessageThat().contains(expectedMessage);
- }
-
- @Test
- public void rebaseAbandonedChange() throws Exception {
- PushOneCommit.Result r = createChange();
- String changeId = r.getChangeId();
- assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
- gApi.changes().id(changeId).abandon();
- ChangeInfo info = info(changeId);
- assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(changeId).revision(r.getCommit().name()).rebase());
- assertThat(thrown).hasMessageThat().contains("change is abandoned");
- }
-
- @Test
- public void rebaseOntoAbandonedChange() throws Exception {
- // Create two changes both with the same parent
- PushOneCommit.Result r = createChange();
- testRepo.reset("HEAD~1");
- PushOneCommit.Result r2 = createChange();
-
- // Abandon the first change
- String changeId = r.getChangeId();
- assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
- gApi.changes().id(changeId).abandon();
- ChangeInfo info = info(changeId);
- assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
-
- RebaseInput ri = new RebaseInput();
- ri.base = r.getCommit().name();
-
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).rebase(ri));
- assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
- }
-
- @Test
- public void rebaseOntoSelf() throws Exception {
- PushOneCommit.Result r = createChange();
- String changeId = r.getChangeId();
- String commit = r.getCommit().name();
- RebaseInput ri = new RebaseInput();
- ri.base = commit;
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(changeId).revision(commit).rebase(ri));
- assertThat(thrown).hasMessageThat().contains("cannot rebase change onto itself");
- }
-
- @Test
@TestProjectInput(createEmptyCommit = false)
public void changeNoParentToOneParent() throws Exception {
// create initial commit with no parent and push it as change, so that patch
@@ -2668,6 +2147,78 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void removeNonVisibleReviewer() throws Exception {
+ // allow all users to remove reviewers
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ gApi.changes().id(changeId).addReviewer(user.email());
+ AccountInfo reviewerInfo =
+ Iterables.getOnlyElement(
+ gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+ assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+ TestAccount user2 = accountCreator.user2();
+ requestScopeOperations.setApiUser(user2.id());
+
+ // user2 cannot see user
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isFalse();
+
+ gApi.changes().id(changeId).reviewer(user.id().toString()).remove(new DeleteReviewerInput());
+ assertThat(gApi.changes().id(changeId).get().reviewers).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void removeNonVisibleReviewerThroughPostReview() throws Exception {
+ // allow all users to remove reviewers
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ gApi.changes().id(changeId).addReviewer(user.email());
+ AccountInfo reviewerInfo =
+ Iterables.getOnlyElement(
+ gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER));
+ assertThat(reviewerInfo._accountId).isEqualTo(user.id().get());
+
+ TestAccount user2 = accountCreator.user2();
+ requestScopeOperations.setApiUser(user2.id());
+
+ // user2 cannot see user
+ assertThat(
+ accountControlFactory.get(identifiedUserFactory.create(user.id())).canSee(user2.id()))
+ .isFalse();
+
+ ReviewerInput reviewerInput = new ReviewerInput();
+ reviewerInput.reviewer = user.email();
+ reviewerInput.state = ReviewerState.REMOVED;
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.reviewers = ImmutableList.of(reviewerInput);
+ ReviewResult reviewResult = gApi.changes().id(changeId).current().review(reviewInput);
+ assertThat(reviewResult.error).isNull();
+
+ // user is removed as a reviewer, user2 is added as a CC by doing the post review request that
+ // removed user as a reviewer
+ assertThat(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.REVIEWER)).isNull();
+ reviewerInfo =
+ Iterables.getOnlyElement(gApi.changes().id(changeId).get().reviewers.get(ReviewerState.CC));
+ assertThat(reviewerInfo._accountId).isEqualTo(user2.id().get());
+ }
+
+ @Test
public void removeReviewerNotPermitted() throws Exception {
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -2879,7 +2430,68 @@ public class ChangeIT extends AbstractDaemonTest {
.id(r.getChangeId())
.reviewer(admin.id().toString())
.deleteVote(LabelId.CODE_REVIEW));
- assertThat(thrown).hasMessageThat().contains("delete vote not permitted");
+ assertThat(thrown).hasMessageThat().contains("Delete vote not permitted");
+ }
+
+ @Test
+ public void deleteVoteAlwaysPermittedForSelfVotes() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(
+ blockLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ requestScopeOperations.setApiUser(user.id());
+ gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+ gApi.changes()
+ .id(r.getChangeId())
+ .reviewer(user.id().toString())
+ .deleteVote(LabelId.CODE_REVIEW);
+ }
+
+ @Test
+ public void deleteVoteAlwaysPermittedForAdmin() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(
+ blockLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(block(Permission.REMOVE_REVIEWER).ref("refs/heads/*").group(REGISTERED_USERS))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+
+ requestScopeOperations.setApiUser(user.id());
+ gApi.changes().id(changeId).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes()
+ .id(r.getChangeId())
+ .reviewer(user.id().toString())
+ .deleteVote(LabelId.CODE_REVIEW);
}
@Test
@@ -3175,6 +2787,40 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ public void queryChangesDefaultFieldMatchesOwner() throws Exception {
+ // We have to create a new user since changes are not deleted between tests, which means
+ // querying the standard users will lead to dirty results.
+ TestAccount changeOwner = accountCreator.createValid("changeOwner");
+ requestScopeOperations.setApiUser(changeOwner.id());
+ // Creating a change through the API since PushOneCommit changes are always owned by admin().
+ ChangeInput in = new ChangeInput();
+ in.branch = Constants.MASTER;
+ in.subject = "subject";
+ in.project = project.get();
+ ChangeInfo info = gApi.changes().createAsInfo(in);
+ assertThat(info.owner._accountId).isEqualTo(changeOwner.id().get());
+ requestScopeOperations.setApiUser(user.id());
+ List<ChangeInfo> results = query(changeOwner.email());
+ assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(info.changeId);
+ }
+
+ @Test
+ public void queryChangesDefaultFieldMatchesReviewer() throws Exception {
+ requestScopeOperations.setApiUser(admin.id());
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "subject", "a.txt", "a1")
+ .to("refs/for/master");
+ // We have to create a new user since changes are not deleted between tests, which means
+ // querying the standard users will lead to dirty results.
+ TestAccount changeReviewer = accountCreator.createValid("changeReviewer");
+ gApi.changes().id(r.getChangeId()).addReviewer(changeReviewer.email());
+ requestScopeOperations.setApiUser(user.id());
+ List<ChangeInfo> results = query(changeReviewer.email());
+ assertThat(Iterables.getOnlyElement(results).changeId).isEqualTo(r.getChangeId());
+ }
+
+ @Test
public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
PushOneCommit.Result r = createChange();
ReviewerInput in = new ReviewerInput();
@@ -3284,9 +2930,11 @@ public class ChangeIT extends AbstractDaemonTest {
@Test
public void submitToSymref() throws Exception {
// Create symref in the origin repository (testRepo references to a local repository)
- try (Repository repo = repoManager.openRepository(project)) {
- RefUpdate u = repo.updateRef("refs/heads/master_symref");
- assertThat(u.link("refs/heads/master")).isEqualTo(Result.NEW);
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (Repository repo = repoManager.openRepository(project)) {
+ RefUpdate u = repo.updateRef("refs/heads/master_symref");
+ assertThat(u.link("refs/heads/master")).isEqualTo(Result.NEW);
+ }
}
PushOneCommit.Result r = createChange("refs/for/master_symref");
@@ -3414,6 +3062,18 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ public void stableRevisionSort() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+ r1.assertOkStatus();
+ PushOneCommit.Result r2 = amendChange(r1.getChangeId());
+ r2.assertOkStatus();
+
+ ChangeInfo actual = gApi.changes().id(r2.getChangeId()).get(ALL_REVISIONS, CURRENT_REVISION);
+ assertThat(actual.revisions).hasSize(2);
+ assertThat(actual.revisions.values().stream().map(r -> r._number)).isInOrder();
+ }
+
+ @Test
public void defaultSearchDoesNotTouchDatabase() throws Exception {
requestScopeOperations.setApiUser(admin.id());
PushOneCommit.Result r1 = createChange();
@@ -3423,7 +3083,11 @@ public class ChangeIT extends AbstractDaemonTest {
.review(ReviewInput.approve());
gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
- createChange();
+ PushOneCommit.Result change = createChange();
+ // Populate change with a reasonable set of fields. We can't exhaustively
+ // test all possible variations, but can try to cover a reasonable set.
+ approve(change.getChangeId());
+ gApi.changes().id(change.getChangeId()).addReviewer(user.email());
requestScopeOperations.setApiUser(user.id());
try (AutoCloseable ignored = disableNoteDb()) {
@@ -3438,6 +3102,34 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ public void nonLazyloadQueryOptionsDoNotTouchDatabase() throws Exception {
+ requestScopeOperations.setApiUser(admin.id());
+ 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.Result change = createChange();
+ // Populate change with a reasonable set of fields. We can't exhaustively
+ // test all possible variations, but can try to cover a reasonable set.
+ approve(change.getChangeId());
+ gApi.changes().id(change.getChangeId()).addReviewer(user.email());
+
+ requestScopeOperations.setApiUser(user.id());
+ try (AutoCloseable ignored = disableNoteDb()) {
+ assertThat(
+ gApi.changes()
+ .query()
+ .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+ .withOptions(EnumSet.complementOf(EnumSet.copyOf(ChangeJson.REQUIRE_LAZY_LOAD)))
+ .get())
+ .hasSize(2);
+ }
+ }
+
+ @Test
public void votable() throws Exception {
PushOneCommit.Result r = createChange();
String triplet = project.get() + "~master~" + r.getChangeId();
@@ -3702,6 +3394,7 @@ public class ChangeIT extends AbstractDaemonTest {
assertThat(change.status).isEqualTo(ChangeStatus.NEW);
assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+ assertThat(change.removableLabels).isEmpty();
// add new label and assert that it's returned for existing changes
AccountGroup.UUID registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
@@ -3731,6 +3424,9 @@ public class ChangeIT extends AbstractDaemonTest {
.id(r.getChangeId())
.revision(r.getCommit().name())
.review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
+ change = gApi.changes().id(r.getChangeId()).get();
+ assertPermitted(change, LabelId.VERIFIED, -1, 0, 1);
+ assertOnlyRemovableLabel(change, LabelId.VERIFIED, "+1", admin);
try (ProjectConfigUpdate u = updateProject(project)) {
// remove label and assert that it's no longer returned for existing
@@ -3750,6 +3446,7 @@ public class ChangeIT extends AbstractDaemonTest {
change = gApi.changes().id(r.getChangeId()).get();
assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
+ assertThat(change.removableLabels).isEmpty();
// abandon the change and see that the returned labels stay the same
// while all permitted labels disappear.
@@ -3758,6 +3455,7 @@ public class ChangeIT extends AbstractDaemonTest {
assertThat(change.status).isEqualTo(ChangeStatus.ABANDONED);
assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
assertThat(change.permittedLabels).isEmpty();
+ assertThat(change.removableLabels).isEmpty();
}
@Test
@@ -3845,52 +3543,6 @@ public class ChangeIT extends AbstractDaemonTest {
assertPermitted(change, LabelId.CODE_REVIEW, 2);
assertPermitted(change, LabelId.VERIFIED, 0, 1);
assertLabelDescription(change, LabelId.VERIFIED, TestLabels.VERIFIED_LABEL_DESCRIPTION);
-
- // Ignore the new label by Prolog submit rule. Permitted ranges are still going to be
- // returned for the label.
- GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
- testRepo.reset("config");
- PushOneCommit push2 =
- pushFactory.create(
- admin.newIdent(),
- testRepo,
- "Ignore Verified",
- "rules.pl",
- "submit_rule(submit(CR)) :-\n gerrit:max_with_block(-2, 2, 'Code-Review', CR).");
- push2.to(RefNames.REFS_CONFIG);
-
- change = gApi.changes().id(r.getChangeId()).get();
- assertPermitted(change, LabelId.CODE_REVIEW, 2);
- assertPermitted(change, LabelId.VERIFIED, 0, 1);
-
- // add an approval on the new label. The label can still be voted +1 although it is ignored
- // in Prolog. 0 is not permitted because votes cannot be decreased.
- gApi.changes()
- .id(r.getChangeId())
- .revision(r.getCommit().name())
- .review(new ReviewInput().label(verified.getName(), verified.getMax().getValue()));
-
- change = gApi.changes().id(r.getChangeId()).get();
- assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW, LabelId.VERIFIED);
- assertPermitted(change, LabelId.CODE_REVIEW, 2);
- assertPermitted(change, LabelId.VERIFIED, 1);
-
- // remove label and assert that it's no longer returned for existing
- // changes, even if there is an approval for it
- try (ProjectConfigUpdate u = updateProject(project)) {
- u.getConfig().getLabelSections().remove(verified.getName());
- u.save();
- }
- projectOperations
- .project(project)
- .forUpdate()
- .remove(permissionKey(verified.getName()).ref(heads).group(registeredUsers))
- .update();
-
- change = gApi.changes().id(r.getChangeId()).get();
- assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
- assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
- assertPermitted(change, LabelId.CODE_REVIEW, 2);
}
@Test
@@ -4000,61 +3652,85 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
- public void checkLabelsForMergedChangeWithNonAuthorCodeReview() throws Exception {
- // Configure Non-Author-Code-Review
- RevCommit oldHead = projectOperations.project(project).getHead("master");
+ public void uploadingRulesPlIsNotAllowed() throws Exception {
+ projectOperations.project(project).getHead("master");
GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
testRepo.reset("config");
- PushOneCommit push2 =
- pushFactory.create(
+ PushOneCommit.Result pushResult =
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ "Add prolog rules",
+ RULES_PL_FILE,
+ "submit_rule(S) :-\n"
+ + " gerrit:default_submit(X),\n"
+ + " X =.. [submit | Ls],\n"
+ + " add_non_author_approval(Ls, R),\n"
+ + " S =.. [submit | R].\n"
+ + "\n"
+ + "add_non_author_approval(S1, S2) :-\n"
+ + " gerrit:commit_author(A),\n"
+ + " gerrit:commit_label(label('Code-Review', 2), R),\n"
+ + " R \\= A, !,\n"
+ + " S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
+ + "add_non_author_approval(S1,"
+ + " [label('Non-Author-Code-Review', need(_)) | S1]).")
+ .to(RefNames.REFS_CONFIG);
+ pushResult.assertOkStatus();
+ pushResult.assertMessage(
+ String.format(
+ "WARNING: commit %s: Uploading a new 'rules.pl' file is discouraged. "
+ + "Please consider adding submit-requirements instead.",
+ ObjectIds.abbreviateName(pushResult.getCommit())));
+ }
+
+ @Test
+ public void modifyingRulesPlIsAllowed() throws Exception {
+ // Committing the rules.pl change directly to the repository to bypass gerrit validation.
+ modifySubmitRules(
+ "submit_rule(submit(R)) :- \n"
+ + "gerrit:unresolved_comments_count(2), \n"
+ + "!,"
+ + "gerrit:uploader(U), \n"
+ + "R = label('All-Comments-Resolved', ok(U)).\n");
+ GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+ testRepo.reset("config");
+ pushFactory
+ .create(
admin.newIdent(),
testRepo,
- "Configure Non-Author-Code-Review",
- "rules.pl",
- "submit_rule(S) :-\n"
- + " gerrit:default_submit(X),\n"
- + " X =.. [submit | Ls],\n"
- + " add_non_author_approval(Ls, R),\n"
- + " S =.. [submit | R].\n"
- + "\n"
- + "add_non_author_approval(S1, S2) :-\n"
- + " gerrit:commit_author(A),\n"
- + " gerrit:commit_label(label('Code-Review', 2), R),\n"
- + " R \\= A, !,\n"
- + " S2 = [label('Non-Author-Code-Review', ok(R)) | S1].\n"
- + "add_non_author_approval(S1,"
- + " [label('Non-Author-Code-Review', need(_)) | S1]).");
- push2.to(RefNames.REFS_CONFIG);
- testRepo.reset(oldHead);
-
- String heads = RefNames.REFS_HEADS + "*";
-
- // Allow user to approve
- projectOperations
- .project(project)
- .forUpdate()
- .add(
- allowLabel(TestLabels.codeReview().getName())
- .ref(heads)
- .group(REGISTERED_USERS)
- .range(-2, 2))
- .update();
-
- PushOneCommit.Result r = createChange();
-
- requestScopeOperations.setApiUser(user.id());
- gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
-
- requestScopeOperations.setApiUser(admin.id());
- gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+ "Update prolog rules",
+ RULES_PL_FILE,
+ "submit_rule(submit(R)) :- \n"
+ + "gerrit:unresolved_comments_count(0), \n"
+ + "!,"
+ + "gerrit:uploader(U), \n"
+ + "R = label('All-Comments-Resolved', ok(U)).\n")
+ .to(RefNames.REFS_CONFIG)
+ .assertOkStatus();
+ }
- ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
- assertThat(change.status).isEqualTo(MERGED);
- assertThat(change.submissionId).isNotNull();
- assertThat(change.labels.keySet())
- .containsExactly(LabelId.CODE_REVIEW, "Non-Author-Code-Review");
- assertThat(change.permittedLabels.keySet()).containsExactly(LabelId.CODE_REVIEW);
- assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+ @Test
+ public void deletingRulesPlIsAllowed() throws Exception {
+ // Committing the rules.pl change directly to the repository to bypass gerrit validation.
+ modifySubmitRules(
+ "submit_rule(submit(R)) :- \n"
+ + "gerrit:unresolved_comments_count(2), \n"
+ + "!,"
+ + "gerrit:uploader(U), \n"
+ + "R = label('All-Comments-Resolved', ok(U)).\n");
+ GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":config");
+ testRepo.reset("config");
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ /* subject= */ "Remove prolog rules",
+ /* files= */ ImmutableMap.of())
+ .rmFile(RULES_PL_FILE)
+ .to(RefNames.REFS_CONFIG)
+ .assertOkStatus();
}
@Test
@@ -4070,6 +3746,7 @@ public class ChangeIT extends AbstractDaemonTest {
assertThat(change.submissionId).isNotNull();
assertThat(change.labels.keySet()).containsExactly(LabelId.CODE_REVIEW);
assertPermitted(change, LabelId.CODE_REVIEW, 0, 1, 2);
+ assertThat(change.removableLabels).isEmpty();
}
@Test
@@ -4212,43 +3889,6 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
- public void unresolvedCommentsBlocked() throws Exception {
- modifySubmitRules(
- "submit_rule(submit(R)) :- \n"
- + "gerrit:unresolved_comments_count(0), \n"
- + "!,"
- + "gerrit:uploader(U), \n"
- + "R = label('All-Comments-Resolved', ok(U)).\n"
- + "submit_rule(submit(R)) :- \n"
- + "gerrit:unresolved_comments_count(U), \n"
- + "U > 0,"
- + "R = label('All-Comments-Resolved', need(_)). \n\n");
-
- String oldHead = projectOperations.project(project).getHead("master").name();
- PushOneCommit.Result result1 =
- pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
- testRepo.reset(oldHead);
- PushOneCommit.Result result2 =
- pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
-
- addComment(result1, "comment 1", true, false, null);
- addComment(result2, "comment 2", true, true, null);
-
- gApi.changes().id(result1.getChangeId()).current().submit();
-
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(result2.getChangeId()).current().submit());
- assertThat(thrown)
- .hasMessageThat()
- .contains("Failed to submit 1 change due to the following problems");
- assertThat(thrown)
- .hasMessageThat()
- .contains("submit requirement 'All-Comments-Resolved' is unsatisfied");
- }
-
- @Test
public void changeCommitMessage() throws Exception {
// Tests mutating the commit message as both the owner of the change and a regular user with
// addPatchSet permission. Asserts that both cases succeed.
@@ -4287,6 +3927,29 @@ public class ChangeIT extends AbstractDaemonTest {
}
@Test
+ public void changeCommitMessageFromChangeIdToLinkFooter() throws Exception {
+ PushOneCommit.Result r = createChange();
+ r.assertOkStatus();
+ assertThat(getCommitMessage(r.getChangeId()))
+ .isEqualTo("test commit\n\nChange-Id: " + r.getChangeId() + "\n");
+
+ requestScopeOperations.setApiUser(admin.id());
+ String newMessage =
+ "modified commit by "
+ + admin.id()
+ + "\n\nLink: "
+ + canonicalWebUrl.get()
+ + "id/"
+ + r.getChangeId()
+ + "\n";
+ gApi.changes().id(r.getChangeId()).setMessage(newMessage);
+ RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
+ assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
+ assertThat(getCommitMessage(r.getChangeId())).isEqualTo(newMessage);
+ assertThat(rApi.description()).isEqualTo("Edit commit message");
+ }
+
+ @Test
public void changeCommitMessageWithNoChangeIdSucceedsIfChangeIdNotRequired() throws Exception {
ConfigInput configInput = new ConfigInput();
configInput.requireChangeId = InheritableBoolean.FALSE;
@@ -4577,26 +4240,6 @@ public class ChangeIT extends AbstractDaemonTest {
return gApi.changes().id(changeId).current().file("/COMMIT_MSG").content().asString();
}
- private void addComment(
- PushOneCommit.Result r,
- String message,
- boolean omitDuplicateComments,
- Boolean unresolved,
- String inReplyTo)
- throws Exception {
- ReviewInput.CommentInput c = new ReviewInput.CommentInput();
- c.line = 1;
- c.message = message;
- c.path = FILE_NAME;
- c.unresolved = unresolved;
- c.inReplyTo = inReplyTo;
- ReviewInput in = new ReviewInput();
- in.comments = new HashMap<>();
- in.comments.put(c.path, Lists.newArrayList(c));
- in.omitDuplicateComments = omitDuplicateComments;
- gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
- }
-
private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
if (r == null) {
return ImmutableList.of();
@@ -4621,10 +4264,12 @@ public class ChangeIT extends AbstractDaemonTest {
}
private void setChangeStatus(Change.Id id, Change.Status newStatus) throws Exception {
- try (BatchUpdate batchUpdate =
- batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
- batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
- batchUpdate.execute();
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (BatchUpdate batchUpdate =
+ batchUpdateFactory.create(project, atrScope.get().getUser(), TimeUtil.now())) {
+ batchUpdate.addOp(id, new ChangeStatusUpdateOp(newStatus));
+ batchUpdate.execute();
+ }
}
ChangeStatus changeStatus = gApi.changes().id(id.get()).get().status;
@@ -4658,7 +4303,7 @@ public class ChangeIT extends AbstractDaemonTest {
.commit()
.author(admin.newIdent())
.committer(admin.newIdent())
- .add("rules.pl", newContent)
+ .add(RULES_PL_FILE, newContent)
.message("Modify rules.pl")
.create();
}
@@ -4776,8 +4421,12 @@ public class ChangeIT extends AbstractDaemonTest {
ListChangesOption.SKIP_DIFFSTAT);
PushOneCommit.Result change = createChange();
- int number = gApi.changes().id(change.getChangeId()).get()._number;
+ // Populate change with a reasonable set of fields. We can't exhaustively
+ // test all possible variations, but can try to cover a reasonable set.
+ approve(change.getChangeId());
+ gApi.changes().id(change.getChangeId()).addReviewer(user.email());
+ int number = gApi.changes().id(change.getChangeId()).get()._number;
try (AutoCloseable ignored = changeIndexOperations.disableReadsAndWrites()) {
assertThat(gApi.changes().id(project.get(), number).get(options).changeId)
.isEqualTo(change.getChangeId());
@@ -4931,6 +4580,47 @@ public class ChangeIT extends AbstractDaemonTest {
.contains(String.format("%s has removed %s", admin.fullName(), reviewerInput.reviewer));
}
+ @Test
+ public void emailSubjectContainsChangeSizeBucket() throws Exception {
+ testEmailSubjectContainsChangeSizeBucket(0, "NoOp");
+ testEmailSubjectContainsChangeSizeBucket(1, "XS");
+ testEmailSubjectContainsChangeSizeBucket(9, "XS");
+ testEmailSubjectContainsChangeSizeBucket(10, "S");
+ testEmailSubjectContainsChangeSizeBucket(49, "S");
+ testEmailSubjectContainsChangeSizeBucket(50, "M");
+ testEmailSubjectContainsChangeSizeBucket(249, "M");
+ testEmailSubjectContainsChangeSizeBucket(250, "L");
+ testEmailSubjectContainsChangeSizeBucket(999, "L");
+ testEmailSubjectContainsChangeSizeBucket(1000, "XL");
+ }
+
+ private void testEmailSubjectContainsChangeSizeBucket(
+ int numberOfLines, String expectedSizeBucket) throws Exception {
+ String change;
+ if (numberOfLines == 0) {
+ // create empty change
+ ChangeInput in = new ChangeInput();
+ in.branch = Constants.MASTER;
+ in.subject = "Create a change from the API";
+ in.project = project.get();
+ ChangeInfo info = gApi.changes().create(in).get();
+ change = info.changeId;
+ } else {
+ change =
+ createChange(
+ "subject",
+ expectedSizeBucket + "-file-with-" + numberOfLines + "lines.txt",
+ Collections.nCopies(numberOfLines, "line").stream().collect(joining("\n")))
+ .getChangeId();
+ }
+ sender.clear();
+ gApi.changes().id(change).addReviewer(user.email());
+ List<Message> messages = sender.getMessages();
+ assertThat(messages).hasSize(1);
+ assertThat(((StringEmailHeader) messages.get(0).headers().get("Subject")).getString())
+ .contains("[" + expectedSizeBucket + "]");
+ }
+
private PushOneCommit.Result createWorkInProgressChange() throws Exception {
return pushTo("refs/for/master%wip");
}
@@ -4949,19 +4639,6 @@ public class ChangeIT extends AbstractDaemonTest {
void call(String changeId, String reviewer) throws RestApiException;
}
- private static class TestWorkInProgressStateChangedListener
- implements WorkInProgressStateChangedListener {
- boolean invoked;
- Boolean wip;
-
- @Override
- public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
- this.invoked = true;
- this.wip =
- event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
- }
- }
-
public static class TestAttentionSetListenerModule extends AbstractModule {
@Override
public void configure() {
@@ -4986,15 +4663,4 @@ public class ChangeIT extends AbstractDaemonTest {
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/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
index de73c00f82..1790133128 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIdIT.java
@@ -54,6 +54,28 @@ public class ChangeIdIT extends AbstractDaemonTest {
}
@Test
+ public void projectChangeNumberReturnsChangeWhenProjectEndsWithSlash() throws Exception {
+ Project.NameKey p = projectOperations.newProject().create();
+ ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
+
+ ChangeInfo changeInfo = gApi.changes().id(p.get() + "/", ci._number).get();
+
+ assertThat(changeInfo.changeId).isEqualTo(ci.changeId);
+ assertThat(changeInfo.project).isEqualTo(p.get());
+ }
+
+ @Test
+ public void projectChangeNumberReturnsChangeWhenProjectEndsWithDotGit() throws Exception {
+ Project.NameKey p = projectOperations.newProject().create();
+ ChangeInfo ci = gApi.changes().create(new ChangeInput(p.get(), "master", "msg")).get();
+
+ ChangeInfo changeInfo = gApi.changes().id(p.get() + ".git", ci._number).get();
+
+ assertThat(changeInfo.changeId).isEqualTo(ci.changeId);
+ assertThat(changeInfo.project).isEqualTo(p.get());
+ }
+
+ @Test
public void wrongProjectInProjectChangeNumberReturnsNotFound() throws Exception {
ResourceNotFoundException thrown =
assertThrows(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
index f8cf5fda2b..2b1bef0358 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopiedApprovalsInChangeMessageIT.java
@@ -45,12 +45,29 @@ 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.ApprovalsUtil;
+import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.project.testing.TestLabels;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import org.junit.Test;
+/**
+ * Tests to verify that copied/outdated approvals are included into the change message that is
+ * posted on patch set creation. Includes verifying that the copied/outdated approvals in the change
+ * message are correctly formatted.
+ *
+ * <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.
+ */
public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
@Inject private ApprovalsUtil approvalsUtil;
@Inject private ProjectOperations projectOperations;
@@ -98,7 +115,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* 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");
@@ -111,7 +132,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
/* copiedApprovals= */ ImmutableSet.of(),
- /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+ /* 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");
}
@@ -125,7 +150,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* 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");
@@ -141,7 +170,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
/* copiedApprovals= */ ImmutableSet.of(),
- /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+ /* 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");
}
@@ -153,13 +186,17 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ImmutableList.of(
createLabelType(
/* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
- PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+ PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", -2);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+ /* failingAtoms= */ ImmutableSet.of("is:MAX"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
- .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+ .hasValue("Copied Votes:\n* Code-Review-2 (copy condition: \"**is:MIN** OR is:MAX\")\n");
}
@Test
@@ -168,14 +205,21 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
new LabelTypes(
ImmutableList.of(
createLabelType(
- /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
- PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+ /* 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(patchSetApproval));
+ /* outdatedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+ /* failingAtoms= */ ImmutableSet.of("changekind:TRIVIAL_REBASE"))));
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
- .hasValue("Outdated Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+ .hasValue(
+ "Outdated Votes:\n* Code-Review+2 (copy condition:"
+ + " \"changekind:TRIVIAL_REBASE **is:MAX**\")\n");
}
@Test
@@ -189,7 +233,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(),
+ /* failingAtoms= */ ImmutableSet.of())),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
@@ -208,7 +256,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
/* copiedApprovals= */ ImmutableSet.of(),
- /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+ /* 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 (non-parseable copy condition: \"foo bar baz\")\n");
@@ -225,17 +277,22 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
/* labelName= */ "Code-Review",
/* copyCondition= */ String.format(
"is:MIN OR (is:MAX approverin:%s)", groupUuid))));
- PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+ PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
"Copied Votes:\n"
- + "* Code-Review+1 by %s"
- + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + "* Code-Review+2 by %s"
+ + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
}
@@ -254,12 +311,17 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
/* copiedApprovals= */ ImmutableSet.of(),
- /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+ /* outdatedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
"Outdated Votes:\n"
- + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+ + " OR (is:MAX **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
}
@@ -275,10 +337,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
/* labelName= */ "Code-Review",
/* copyCondition= */ String.format(
"is:MIN OR (is:MAX approverin:%s)", groupUuid))));
- PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
+ PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 2);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
// Set 'user' as the current user in the request scope.
@@ -291,8 +358,8 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
.hasValue(
String.format(
"Copied Votes:\n"
- + "* Code-Review+1 by %s"
- + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + "* Code-Review+2 by %s"
+ + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
}
@@ -313,7 +380,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
/* copiedApprovals= */ ImmutableSet.of(),
- /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+ /* outdatedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:MAX"))));
// Set 'user' as the current user in the request scope.
// 'user' cannot see the Administrators group that is used in the copy condition.
@@ -325,7 +396,8 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
.hasValue(
String.format(
"Outdated Votes:\n"
- + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + "* Code-Review+1 by %s (copy condition: \"is:MIN"
+ + " OR (is:MAX **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(admin.id()), groupUuid));
}
@@ -344,7 +416,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval = createPatchSetApproval(admin, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(),
+ /* failingAtoms= */ ImmutableSet.of())),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
@@ -371,7 +447,11 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
/* copiedApprovals= */ ImmutableSet.of(),
- /* outdatedApprovals= */ ImmutableSet.of(patchSetApproval));
+ /* outdatedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval,
+ /* passingAtoms= */ ImmutableSet.of(),
+ /* failingAtoms= */ ImmutableSet.of())));
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
@@ -388,7 +468,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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");
@@ -401,7 +489,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -417,7 +513,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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");
@@ -433,7 +537,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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");
@@ -450,7 +562,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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");
@@ -466,7 +586,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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");
@@ -480,14 +608,22 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ImmutableList.of(
createLabelType(
/* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
- PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
- PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
+ PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 2);
+ PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 2);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
- .hasValue("Copied Votes:\n* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+ .hasValue("Copied Votes:\n* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n");
}
@Test
@@ -498,39 +634,92 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
ImmutableList.of(
createLabelType(
/* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX"),
- createLabelType(
- /* labelName= */ "Verified", /* copyCondition= */ "is:MIN OR is:MAX")));
- PatchSetApproval patchSetApproval1 = createPatchSetApproval(admin, "Code-Review", 1);
+ 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(patchSetApproval1, patchSetApproval2),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of("is:MAX"),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
"Copied Votes:\n"
- + "* Code-Review+1 (copy condition: \"is:MIN OR is:MAX\")\n"
- + "* Verified+1 (copy condition: \"is:MIN OR is:MAX\")\n");
+ + "* Code-Review+2 (copy condition: \"is:MIN OR **is:MAX**\")\n"
+ + "* Verified+1 (copy condition: \"is:MIN OR **is:MAX**\")\n");
}
@Test
- public void formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate()
- throws Exception {
+ public void
+ formatMultipleApprovals_differentValue_withCopyCondition_noUserInPredicate_samePassingAtoms()
+ throws Exception {
LabelTypes labelTypes =
new LabelTypes(
ImmutableList.of(
createLabelType(
- /* labelName= */ "Code-Review", /* copyCondition= */ "is:MIN OR is:MAX")));
+ /* 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(patchSetApproval1, patchSetApproval2),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+ /* failingAtoms= */ ImmutableSet.of()),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of("changekind:REWORK"),
+ /* failingAtoms= */ ImmutableSet.of())),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
"Copied Votes:\n"
- + "* Code-Review+1, Code-Review+2 (copy condition: \"is:MIN OR is:MAX\")\n");
+ + "* Code-Review+1, Code-Review+2 (copy condition: \"**changekind:REWORK**\")\n");
+ }
+
+ @Test
+ 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(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of("is:2"),
+ /* failingAtoms= */ ImmutableSet.of("is:1")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of("is:1"),
+ /* failingAtoms= */ ImmutableSet.of("is:2"))),
+ /* outdatedApprovals= */ ImmutableSet.of());
+ assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
+ .hasValue(
+ "Copied Votes:\n"
+ + "* Code-Review+1 (copy condition: \"**is:1** OR is:2\")\n"
+ + "* Code-Review+2 (copy condition: \"is:1 OR **is:2**\")\n");
}
@Test
@@ -545,7 +734,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -565,7 +762,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -587,7 +792,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -597,8 +810,9 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
}
@Test
- public void formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate()
- throws Exception {
+ public void
+ formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_samePassingAtoms()
+ throws Exception {
String groupUuid =
groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
LabelTypes labelTypes =
@@ -608,24 +822,86 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
/* labelName= */ "Code-Review",
/* copyCondition= */ String.format(
"is:MIN OR (is:MAX approverin:%s)", groupUuid))));
- PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
- PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 1);
+ PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 2);
+ PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Code-Review", 2);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
"Copied Votes:\n"
- + "* Code-Review+1 by %s, %s"
- + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + "* Code-Review+2 by %s, %s"
+ + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(admin.id()),
AccountTemplateUtil.getAccountTemplate(user.id()),
groupUuid));
}
@Test
+ public void
+ formatMultipleApprovals_sameVote_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+ throws Exception {
+ 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(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", registeredUsersGroupUuid)),
+ /* failingAtoms= */ ImmutableSet.of(
+ "is:MIN", String.format("approverin:%s", administratorsGroupUuid))),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* 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))
+ .hasValue(
+ String.format(
+ "Copied Votes:\n"
+ + "* Code-Review+2 by %s"
+ + " (copy condition: \"is:MIN OR (**is:MAX** **approverin:%s**)"
+ + " OR (**is:MAX** **approverin:%s**)\")\n"
+ + "* Code-Review+2 by %s"
+ + " (copy condition: \"is:MIN OR (**is:MAX** approverin:%s)"
+ + " OR (**is:MAX** **approverin:%s**)\")\n",
+ AccountTemplateUtil.getAccountTemplate(admin.id()),
+ administratorsGroupUuid,
+ registeredUsersGroupUuid,
+ AccountTemplateUtil.getAccountTemplate(user.id()),
+ administratorsGroupUuid,
+ registeredUsersGroupUuid));
+ }
+
+ @Test
public void formatMultipleApprovals_differentLabel_withCopyCondition_withUserInPredicate()
throws Exception {
String groupUuid =
@@ -641,18 +917,30 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
/* labelName= */ "Verified",
/* copyCondition= */ String.format(
"is:MIN OR (is:MAX approverin:%s)", groupUuid))));
- PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", 1);
+ PatchSetApproval patchSetApproval1 = createPatchSetApproval(user, "Code-Review", -2);
PatchSetApproval patchSetApproval2 = createPatchSetApproval(admin, "Verified", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of("is:MIN"),
+ /* failingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", groupUuid))),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:MAX", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
"Copied Votes:\n"
- + "* Code-Review+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n"
- + "* Verified+1 by %s (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + "* Code-Review-2 by %s (copy condition: \"**is:MIN**"
+ + " OR (is:MAX approverin:%s)\")\n"
+ + "* Verified+1 by %s (copy condition: \"is:MIN"
+ + " OR (**is:MAX** **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(user.id()),
groupUuid,
AccountTemplateUtil.getAccountTemplate(admin.id()),
@@ -660,8 +948,9 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
}
@Test
- public void formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate()
- throws Exception {
+ public void
+ formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_samePassingAtoms()
+ throws Exception {
String groupUuid =
groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
LabelTypes labelTypes =
@@ -670,21 +959,79 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
createLabelType(
/* labelName= */ "Code-Review",
/* copyCondition= */ String.format(
- "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+ "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(patchSetApproval1, patchSetApproval2),
+ /* copiedApprovals= */ ImmutableSet.of(
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:ANY", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:ANY", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
"Copied Votes:\n"
+ "* Code-Review+1 by %s, Code-Review+2 by %s"
- + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
+ AccountTemplateUtil.getAccountTemplate(user.id()),
+ AccountTemplateUtil.getAccountTemplate(admin.id()),
+ groupUuid));
+ }
+
+ @Test
+ public void
+ formatMultipleApprovals_differentValue_withCopyCondition_withUserInPredicate_differentPassingAtoms()
+ 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(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:2", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN", "is:1")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* 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))
+ .hasValue(
+ String.format(
+ "Copied Votes:\n"
+ + "* Code-Review+1 by %s"
+ + " (copy condition: \"is:MIN OR (**is:1** **approverin:%s**)"
+ + " OR (is:2 **approverin:%s**)\")\n"
+ + "* Code-Review+2 by %s"
+ + " (copy condition: \"is:MIN OR (is:1 **approverin:%s**)"
+ + " OR (**is:2** **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(user.id()),
+ groupUuid,
+ groupUuid,
AccountTemplateUtil.getAccountTemplate(admin.id()),
+ groupUuid,
groupUuid));
}
@@ -692,29 +1039,42 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
public void formatMultipleApprovals_differentAndSameValue_withCopyCondition_withUserInPredicate()
throws Exception {
TestAccount user2 = accountCreator.user2();
- String groupUuid =
- groupCache.get(AccountGroup.nameKey("Administrators")).get().getGroupUUID().get();
+ String groupUuid = SystemGroupBackend.REGISTERED_USERS.get();
LabelTypes labelTypes =
new LabelTypes(
ImmutableList.of(
createLabelType(
/* labelName= */ "Code-Review",
/* copyCondition= */ String.format(
- "is:MIN OR (is:MAX approverin:%s)", groupUuid))));
+ "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(
- patchSetApproval1, patchSetApproval2, patchSetApproval3),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval1,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:ANY", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval2,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:ANY", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN")),
+ ApprovalCopier.Result.PatchSetApprovalData.create(
+ patchSetApproval3,
+ /* passingAtoms= */ ImmutableSet.of(
+ "is:ANY", String.format("approverin:%s", groupUuid)),
+ /* failingAtoms= */ ImmutableSet.of("is:MIN"))),
/* outdatedApprovals= */ ImmutableSet.of());
assertThat(approvalsUtil.formatApprovalCopierResult(approvalCopierResult, labelTypes))
.hasValue(
String.format(
"Copied Votes:\n"
+ "* Code-Review+1 by %s, %s, Code-Review+2 by %s"
- + " (copy condition: \"is:MIN OR (is:MAX approverin:%s)\")\n",
+ + " (copy condition: \"is:MIN OR (**is:ANY** **approverin:%s**)\")\n",
AccountTemplateUtil.getAccountTemplate(user.id()),
AccountTemplateUtil.getAccountTemplate(user2.id()),
AccountTemplateUtil.getAccountTemplate(admin.id()),
@@ -737,7 +1097,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -769,7 +1137,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Verified", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -799,7 +1175,15 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
PatchSetApproval patchSetApproval2 = createPatchSetApproval(user, "Code-Review", 1);
ApprovalCopier.Result approvalCopierResult =
ApprovalCopier.Result.create(
- /* copiedApprovals= */ ImmutableSet.of(patchSetApproval1, patchSetApproval2),
+ /* 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(
@@ -849,7 +1233,7 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
+ "\n"
+ "Copied Votes:\n"
+ "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
- + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+ + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
+ "\n"
+ "Outdated Votes:\n"
+ "* Verified+1\n");
@@ -900,7 +1284,7 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
+ "\n"
+ "Copied Votes:\n"
+ "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
- + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+ + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
+ "\n"
+ "Outdated Votes:\n"
+ "* Verified+1\n");
@@ -946,7 +1330,7 @@ public class CopiedApprovalsInChangeMessageIT extends AbstractDaemonTest {
+ "\n"
+ "Copied Votes:\n"
+ "* Code-Review-2 (copy condition: \"changekind:NO_CHANGE"
- + " OR changekind:TRIVIAL_REBASE OR is:MIN\")\n"
+ + " OR changekind:TRIVIAL_REBASE OR **is:MIN**\")\n"
+ "\n"
+ "Outdated Votes:\n"
+ "* Verified+1\n");
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
index 9d0e10a0cf..a055201d73 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/CopyApprovalsToFollowUpPatchSetsIT.java
@@ -25,6 +25,7 @@ import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS
import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
import static com.google.gerrit.server.project.testing.TestLabels.value;
+import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
@@ -38,6 +39,7 @@ import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.project.testing.TestLabels;
import com.google.inject.Inject;
@@ -47,8 +49,8 @@ import org.junit.Before;
import org.junit.Test;
/**
- * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewCopyApprovalsOp}
- * copies approvals to follow-up patch sets if possible.
+ * Test to verify that {@link com.google.gerrit.server.restapi.change.PostReviewOp} copies approvals
+ * to follow-up patch sets if possible.
*/
public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
@Inject private ProjectOperations projectOperations;
@@ -106,9 +108,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
r.assertOkStatus();
PatchSet patchSet2 = r.getChange().currentPatchSet();
- // Vote on the first patch set.
+ // Vote on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 2, 1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified+1");
vote(user, changeId, patchSet1.number(), -2, -1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-2 Verified-1");
// Verify that no votes have been copied to the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -128,8 +132,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void newApprovals_copied_noCurrentVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -139,9 +143,23 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
r.assertOkStatus();
PatchSet patchSet2 = r.getChange().currentPatchSet();
- // Vote on the first patch set.
+ // Vote on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 2, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+2 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+ + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), -2, -1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review-2 Verified-1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review-2 has been copied to patch set 2 (copy condition: \"is:ANY\").\n"
+ + "* Verified-1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
// Verify that the votes have been copied to the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -161,8 +179,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void newApprovals_notCopied_currentVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -176,9 +194,13 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
vote(admin, changeId, patchSet2.number(), 2, 1);
vote(user, changeId, patchSet2.number(), -2, -1);
- // Vote on the first patch set.
+ // Vote on the first patch set and verify change message.
vote(admin, changeId, patchSet1.number(), 1, -1);
+ assertLastChangeMessage(
+ r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
vote(user, changeId, patchSet1.number(), -1, 1);
+ assertLastChangeMessage(
+ r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
// Verify that the votes have not been copied to the current patch set (since a current vote
// already exists).
@@ -200,8 +222,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void newApprovals_notCopied_currentDeletedVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -219,9 +241,13 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
deleteCurrentVotes(admin, changeId);
deleteCurrentVotes(user, changeId);
- // Vote on the first patch set.
+ // Vote on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 1, -1);
+ assertLastChangeMessage(
+ r.getChangeId(), String.format("Patch Set 1: Code-Review+1 Verified-1"));
vote(user, changeId, patchSet1.number(), -1, 1);
+ assertLastChangeMessage(
+ r.getChangeId(), String.format("Patch Set 1: Code-Review-1 Verified+1"));
// Verify that the votes have not been copied to the current patch set (since a deletion vote
// already exists on the current patch set).
@@ -254,9 +280,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
r.assertOkStatus();
PatchSet patchSet2 = r.getChange().currentPatchSet();
- // Update the votes on the first patch set.
+ // Update the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 2, -1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+2 Verified-1");
vote(user, changeId, patchSet1.number(), -1, 1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
// Verify that no votes have been copied to the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -278,7 +306,7 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
public void updatedApprovals_notCopied_copyingNotEnabled_unsetsCopiedApprovals()
throws Exception {
updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -297,9 +325,30 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, 1, 1);
assertCurrentVotes(c, user, 2, 1);
- // Update the votes on the first patch set with votes that are not copied
+ // Update the votes on the first patch set with votes that are not copied and verify the change
+ // messages.
vote(admin, changeId, patchSet1.number(), -1, -1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review-1 Verified-1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+ + " since the new Code-Review-1 vote is not copyable"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+ + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
vote(user, changeId, patchSet1.number(), -2, -1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review-2 Verified-1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+ + " since the new Code-Review-2 vote is not copyable"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+ + " since the new Verified-1 vote is not copyable (copy condition: \"is:MAX\")."));
// Verify that the copied votes on the current patch set have been unset.
c = detailedChange(changeId);
@@ -320,7 +369,7 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
@Test
public void updatedApprovals_copied_noCurrentVote() throws Exception {
updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:max"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:MAX"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -339,9 +388,26 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, 0, 0);
assertCurrentVotes(c, user, 0, 0);
- // Update the votes on the first patch set with votes that are copied.
+ // Update the votes on the first patch set with votes that are copied and verify the change
+ // messages.
vote(admin, changeId, patchSet1.number(), 2, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+2 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+2 has been copied to patch set 2"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
vote(user, changeId, patchSet1.number(), 1, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+1 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+1 has been copied to patch set 2"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:MAX\")."));
// Verify that the votes have been copied to the current patch set.
c = detailedChange(changeId);
@@ -361,8 +427,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void updatedApprovals_notCopied_currentVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -380,9 +446,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
vote(admin, changeId, patchSet2.number(), 2, 1);
vote(user, changeId, patchSet2.number(), -2, -1);
- // Update the votes on the first patch set.
+ // Update the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), -1, 1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
vote(user, changeId, patchSet1.number(), 1, -1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
// Verify that the votes have not been copied to the current patch set (since a current vote
// already exists).
@@ -404,8 +472,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void updatedApprovals_notCopied_currentDeletedVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -427,9 +495,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
deleteCurrentVotes(admin, changeId);
deleteCurrentVotes(user, changeId);
- // Update the votes on the first patch set.
+ // Update the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), -1, 1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review-1 Verified+1");
vote(user, changeId, patchSet1.number(), 1, -1);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: Code-Review+1 Verified-1");
// Verify that the votes have not been copied to the current patch set (since a deletion vote
// already exists on the current patch set).
@@ -451,8 +521,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void updatedApprovals_copied_currentCopiedVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -471,9 +541,28 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, -2, -1);
assertCurrentVotes(c, user, 2, 1);
- // Update the votes on the first patch set with votes that are copied.
+ // Update the votes on the first patch set with votes that are copied and verify the change
+ // messages.
vote(admin, changeId, patchSet1.number(), 2, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+2 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+2 has been copied to patch set 2 (was Code-Review-2)"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Verified+1 has been copied to patch set 2 (was Verified-1)"
+ + " (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), -2, -1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review-2 Verified-1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review-2 has been copied to patch set 2 (was Code-Review+2)"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Verified-1 has been copied to patch set 2 (was Verified+1)"
+ + " (copy condition: \"is:ANY\")."));
// Verify that the votes have been copied to the current patch set.
c = detailedChange(changeId);
@@ -509,9 +598,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
vote(admin, changeId, patchSet2.number(), -2, -1);
vote(user, changeId, patchSet2.number(), 2, 1);
- // Delete the votes on the first patch set.
+ // Delete the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
// Verify that the vote deletions have not been copied to the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -551,9 +642,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, 0, 0);
assertCurrentVotes(c, user, 0, 0);
- // Delete the votes on the first patch set.
+ // Delete the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
// Verify that there are still no votes on the current patch set.
c = detailedChange(changeId);
@@ -573,8 +666,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void deletedApprovals_notCopied_currentVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -592,9 +685,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
vote(admin, changeId, patchSet2.number(), 2, 1);
vote(user, changeId, patchSet2.number(), -2, -1);
- // Delete the votes on the first patch set.
+ // Delete the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
// Verify that the vote deletions have not been copied to the current patch set (since a current
// vote already exists).
@@ -616,8 +711,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void deletedApprovals_notCopied_currentDeletedVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -639,9 +734,11 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
deleteCurrentVotes(admin, changeId);
deleteCurrentVotes(user, changeId);
- // Delete the votes on the first patch set.
+ // Delete the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(r.getChangeId(), "Patch Set 1: -Code-Review -Verified");
// Verify that there are still no votes on the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -662,8 +759,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void deletedApprovals_copied_currentCopiedVote() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -682,9 +779,29 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, -2, -1);
assertCurrentVotes(c, user, 2, 1);
- // Delete the votes on the first patch set.
+ // Delete the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: -Code-Review -Verified\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review-2)"
+ + " since the new Code-Review=0 vote is not copyable"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+ + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: -Code-Review -Verified\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+ + " since the new Code-Review=0 vote is not copyable"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+ + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
// Verify that the vote deletions have been copied to the current patch set.
c = detailedChange(changeId);
@@ -701,8 +818,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
/** Tests that new approvals on an outdated patch set are copied to all follow-up patch sets. */
@Test
public void copyNewApprovalAcrossMultipleFollowUpPatchSets() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -720,9 +837,27 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
r.assertOkStatus();
PatchSet patchSet4 = r.getChange().currentPatchSet();
- // Vote on the first patch set.
+ // Vote on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 2, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+2 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+2 has been copied to patch set 2, 3, 4"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Verified+1 has been copied to patch set 2, 3, 4"
+ + " (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), -2, -1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review-2 Verified-1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review-2 has been copied to patch set 2, 3, 4"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Verified-1 has been copied to patch set 2, 3, 4"
+ + " (copy condition: \"is:ANY\")."));
// Verify that votes have been copied to the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -748,8 +883,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
public void
copyNewApprovalAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -777,9 +912,25 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, 0, -1);
assertCurrentVotes(c, user, 0, -1);
- // Vote on the first patch set with copyable votes.
+ // Vote on the first patch set with copyable votes and verify the change messages.
vote(admin, changeId, patchSet1.number(), 2, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+2 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+2 has been copied to patch set 2 "
+ + "(copy condition: \"is:1 OR is:2\").\n"
+ + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), 1, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: Code-Review+1 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+1 has been copied to patch set 2"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Verified+1 has been copied to patch set 2 (copy condition: \"is:ANY\")."));
// Verify that votes have been not copied to the current patch set.
c = detailedChange(changeId);
@@ -804,8 +955,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
*/
@Test
public void copyApprovalDeletionAcrossMultipleFollowUpPatchSets() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -832,9 +983,33 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertCurrentVotes(c, admin, 2, 1);
assertCurrentVotes(c, user, -2, -1);
- // Delete the votes on the first patch set.
+ // Delete the votes on the first patch set and verify the change messages.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: -Code-Review -Verified\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set"
+ + " 2 (was Code-Review+2), 3 (was Code-Review+2), 4 (was Code-Review+2)"
+ + " since the new Code-Review=0 vote is not copyable"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Copied Verified vote has been removed from patch set"
+ + " 2 (was Verified+1), 3 (was Verified+1), 4 (was Verified+1)"
+ + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: -Code-Review -Verified\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set"
+ + " 2 (was Code-Review-2), 3 (was Code-Review-2), 4 (was Code-Review-2)"
+ + " since the new Code-Review=0 vote is not copyable"
+ + " (copy condition: \"is:ANY\").\n"
+ + "* Copied Verified vote has been removed from patch set"
+ + " 2 (was Verified-1), 3 (was Verified-1), 4 (was Verified-1)"
+ + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
// Verify that the votes has been copied to the current patch set.
c = detailedChange(changeId);
@@ -860,8 +1035,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
public void
copyApprovalDeletionAcrossMultipleFollowUpPatchSets_stopOnFirstFollowUpPatchSetToWhichTheVoteIsNotCopyable()
throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:1 or is:2"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:1 OR is:2"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -894,7 +1069,27 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
// Delete the votes on the first patch set.
vote(admin, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: -Code-Review -Verified\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+2)"
+ + " since the new Code-Review=0 vote is not copyable"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Copied Verified vote has been removed from patch set 2 (was Verified+1)"
+ + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet1.number(), 0, 0);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 1: -Code-Review -Verified\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Copied Code-Review vote has been removed from patch set 2 (was Code-Review+1)"
+ + " since the new Code-Review=0 vote is not copyable"
+ + " (copy condition: \"is:1 OR is:2\").\n"
+ + "* Copied Verified vote has been removed from patch set 2 (was Verified-1)"
+ + " since the new Verified=0 vote is not copyable (copy condition: \"is:ANY\")."));
// Verify that the vote deletions have been not copied to the current patch set.
c = detailedChange(changeId);
@@ -917,8 +1112,8 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
/** Tests that new approvals on an outdated patch set are not copied to predecessor patch sets. */
@Test
public void notCopyToPredecessorPatchSets() throws Exception {
- updateCodeReviewLabel(b -> b.setCopyCondition("is:any"));
- updateVerifiedLabel(b -> b.setCopyCondition("is:any"));
+ updateCodeReviewLabel(b -> b.setCopyCondition("is:ANY"));
+ updateVerifiedLabel(b -> b.setCopyCondition("is:ANY"));
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -936,9 +1131,23 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
r.assertOkStatus();
PatchSet patchSet4 = r.getChange().currentPatchSet();
- // Vote on the third patch set.
+ // Vote on the third patch set and verify the change messages.
vote(admin, changeId, patchSet3.number(), 2, 1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 3: Code-Review+2 Verified+1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review+2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+ + "* Verified+1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
vote(user, changeId, patchSet3.number(), -2, -1);
+ assertLastChangeMessage(
+ r.getChangeId(),
+ String.format(
+ "Patch Set 3: Code-Review-2 Verified-1\n\n"
+ + "Copied votes on follow-up patch sets have been updated:\n"
+ + "* Code-Review-2 has been copied to patch set 4 (copy condition: \"is:ANY\").\n"
+ + "* Verified-1 has been copied to patch set 4 (copy condition: \"is:ANY\")."));
// Verify that votes have been copied to the current patch set.
ChangeInfo c = detailedChange(changeId);
@@ -1057,4 +1266,10 @@ public class CopyApprovalsToFollowUpPatchSetsIT extends AbstractDaemonTest {
assertThat(patchSetApproval.get().value()).isEqualTo((short) expectedVote);
assertThat(patchSetApproval.get().copied()).isEqualTo(expectedToBeCopied);
}
+
+ private void assertLastChangeMessage(String changeId, String expectedMessage)
+ throws RestApiException {
+ assertThat(Iterables.getLast(gApi.changes().id(changeId).get().messages).message)
+ .isEqualTo(expectedMessage);
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 9e7a693138..a0f0fe6a36 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -16,6 +16,10 @@ package com.google.gerrit.acceptance.api.change;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.util.stream.Collectors.toList;
import static org.mockito.ArgumentMatchers.any;
@@ -36,13 +40,16 @@ import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
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.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -79,6 +86,7 @@ import com.google.gerrit.testing.TestCommentHelper;
import com.google.inject.Inject;
import com.google.inject.Module;
import java.sql.Timestamp;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -100,6 +108,7 @@ public class PostReviewIT extends AbstractDaemonTest {
@Inject private TestCommentHelper testCommentHelper;
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ExtensionRegistry extensionRegistry;
+ @Inject private ProjectOperations projectOperations;
private static final String COMMENT_TEXT = "The comment text";
private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -978,6 +987,36 @@ public class PostReviewIT extends AbstractDaemonTest {
user.fullName()));
}
+ @Test
+ public void votesInChangeMessageAreSorted() throws Exception {
+ // Create Verify label and allow voting on it.
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ LabelType.Builder verified =
+ labelBuilder(
+ LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
+ u.getConfig().upsertLabelType(verified.build());
+ u.save();
+ }
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(LabelId.VERIFIED)
+ .ref(RefNames.REFS_HEADS + "*")
+ .group(REGISTERED_USERS)
+ .range(-1, 1))
+ .update();
+
+ PushOneCommit.Result r = createChange();
+
+ ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+ gApi.changes().id(r.getChangeId()).current().review(input);
+
+ Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
+ assertThat(Iterables.getLast(messages).message)
+ .isEqualTo(String.format("Patch Set 1: Code-Review+2 Verified+1"));
+ }
+
private static class TestListener implements CommentAddedListener {
public CommentAddedListener.Event lastCommentAddedEvent;
@@ -1026,6 +1065,7 @@ public class PostReviewIT extends AbstractDaemonTest {
@Override
public Optional<String> getChangeMessageAddOn(
+ Instant when,
IdentifiedUser user,
ChangeNotes changeNotes,
PatchSet patchSet,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index 267f5a7eb2..519c1dc957 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -252,20 +253,22 @@ public class PrivateChangeIT extends AbstractDaemonTest {
try (BatchUpdate u =
batchUpdateFactory.create(
project, identifiedUserFactory.create(admin.id()), TimeUtil.now())) {
- u.addOp(
- changeId,
- new BatchUpdateOp() {
- @Override
- public boolean updateChange(ChangeContext ctx) {
- ctx.getChange().setPrivate(true);
- ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
- ctx.getChange().setPrivate(true);
- ctx.getChange().setLastUpdatedOn(ctx.getWhen());
- update.setPrivate(true);
- return true;
- }
- })
- .execute();
+ testRefAction(
+ () ->
+ u.addOp(
+ changeId,
+ new BatchUpdateOp() {
+ @Override
+ public boolean updateChange(ChangeContext ctx) {
+ ctx.getChange().setPrivate(true);
+ ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+ ctx.getChange().setPrivate(true);
+ ctx.getChange().setLastUpdatedOn(ctx.getWhen());
+ update.setPrivate(true);
+ return true;
+ }
+ })
+ .execute());
}
assertThat(gApi.changes().id(changeId.get()).get().isPrivate).isTrue();
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 3bfb5739fd..3f3ad37809 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.api.change;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -23,16 +24,20 @@ import static javax.servlet.http.HttpServletResponse.SC_OK;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
@@ -40,9 +45,11 @@ import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.TopLevelResource;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.util.Arrays;
@@ -53,6 +60,7 @@ import org.junit.Test;
public class QueryChangesIT extends AbstractDaemonTest {
@Inject private AccountOperations accountOperations;
+ @Inject private ChangeOperations changeOperations;
@Inject private ProjectOperations projectOperations;
@Inject private Provider<QueryChanges> queryChangesProvider;
@Inject private RequestScopeOperations requestScopeOperations;
@@ -364,6 +372,295 @@ public class QueryChangesIT extends AbstractDaemonTest {
assertThat(result3).hasSize(1);
}
+ /**
+ * This test verifies that querying by a non-visible account doesn't fail.
+ *
+ * <p>Change queries only return changes that are visible to the calling user. If a non-visible
+ * account participated in such a change the existence of this account is known to everyone who
+ * can see the change. Hence it's OK to that the account visibility check is skipped when querying
+ * changes by non-visible accounts. If the account is visible through any visible change these
+ * changes are returned, otherwise the result is empty (see
+ * emptyResultWhenQueryingByNonVisibleAccountAndMatchingChangesAreNotVisible()), same as for
+ * non-existing accounts (see test emptyResultWhenQueryingByNonExistingAccount()).
+ */
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
+ public void changesCanBeQueriesByNonVisibleAccounts() throws Exception {
+ String ownerEmail = "owner@example.com";
+ Account.Id nonVisibleOwner = accountOperations.newAccount().preferredEmail(ownerEmail).create();
+
+ String reviewerEmail = "reviewer@example.com";
+ Account.Id nonVisibleReviewer =
+ accountOperations.newAccount().preferredEmail(reviewerEmail).create();
+
+ // Create the change.
+ Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+
+ // Add a review.
+ requestScopeOperations.setApiUser(nonVisibleReviewer);
+ gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+ requestScopeOperations.setApiUser(user.id());
+
+ // Verify that user can see the change.
+ assertThat(gApi.changes().query("change:" + changeId).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+
+ // Verify that user cannot see the other accounts.
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleOwner.get()).get());
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleReviewer.get()).get());
+
+ // Verify that the change is also found if user queries for changes owned/uploaded by
+ // nonVisibleOwner.
+ assertThat(gApi.changes().query("owner:" + ownerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ assertThat(gApi.changes().query("uploader:" + ownerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+
+ // Verify that the change is also found if user queries for changes reviewed by
+ // nonVisibleReviewer.
+ assertThat(gApi.changes().query("reviewer:" + reviewerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ assertThat(gApi.changes().query("label:Code-Review+1,user=" + reviewerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ }
+
+ /**
+ * This test verifies that an empty result is returned for a query by a non-existing account.
+ *
+ * <p>Such queries must not return an error so that users cannot probe whether an account exists.
+ * Since we return an empty result for non-visible accounts if there are no matched changes or non
+ * of the matched changes is visible, users could conclude the existence of a account if we would
+ * return an error for non-existing accounts.
+ */
+ @Test
+ public void emptyResultWhenQueryingByNonExistingAccount() throws Exception {
+ assertThat(gApi.changes().query("owner:non-existing@example.com").get()).isEmpty();
+ assertThat(gApi.changes().query("uploader:non-existing@example.com").get()).isEmpty();
+ assertThat(gApi.changes().query("reviewer:non-existing@example.com").get()).isEmpty();
+ assertThat(gApi.changes().query("label:Code-Review+1,user=non-existing@example.com").get())
+ .isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
+ public void emptyResultWhenQueryingByNonVisibleAccountAndMatchingChangesAreNotVisible()
+ throws Exception {
+ String ownerEmail = "owner@example.com";
+ Account.Id nonVisibleOwner = accountOperations.newAccount().preferredEmail(ownerEmail).create();
+
+ String reviewerEmail = "reviewer@example.com";
+ Account.Id nonVisibleReviewer =
+ accountOperations.newAccount().preferredEmail(reviewerEmail).create();
+
+ // Create the change.
+ Change.Id changeId = changeOperations.newChange().owner(nonVisibleOwner).create();
+
+ // Add a review.
+ requestScopeOperations.setApiUser(nonVisibleReviewer);
+ gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+ // Block read permission so that the change is not visible.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.READ).ref("refs/heads/master").group(REGISTERED_USERS))
+ .update();
+
+ requestScopeOperations.setApiUser(user.id());
+
+ // Verify that user cannot see the change.
+ assertThat(gApi.changes().query("change:" + changeId).get()).isEmpty();
+
+ // Verify that user cannot see the other accounts.
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleOwner.get()).get());
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(nonVisibleReviewer.get()).get());
+
+ // Verify that the change is also found if user queries for changes owned/uploaded by
+ // nonVisibleOwner.
+ assertThat(gApi.changes().query("owner:" + ownerEmail).get()).isEmpty();
+ assertThat(gApi.changes().query("uploader:" + ownerEmail).get()).isEmpty();
+
+ // Verify that the change is also found if user queries for changes reviewed by
+ // nonVisibleReviewer.
+ assertThat(gApi.changes().query("reviewer:" + reviewerEmail).get()).isEmpty();
+ assertThat(gApi.changes().query("label:Code-Review+1,user=" + reviewerEmail).get()).isEmpty();
+ }
+
+ @Test
+ public void emptyResultWhenQueryingByNonVisibleSecondaryEmail() throws Exception {
+ String secondaryOwnerEmail = "owner-secondary@example.com";
+ Account.Id owner =
+ accountOperations
+ .newAccount()
+ .preferredEmail("owner@example.com")
+ .addSecondaryEmail(secondaryOwnerEmail)
+ .create();
+
+ String secondaryReviewerEmail = "reviewer-secondary@example.com";
+ Account.Id reviewer =
+ accountOperations
+ .newAccount()
+ .preferredEmail("reviewer@example.com")
+ .addSecondaryEmail(secondaryReviewerEmail)
+ .create();
+
+ // Create the change.
+ Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+ // Add a review.
+ requestScopeOperations.setApiUser(reviewer);
+ gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+ requestScopeOperations.setApiUser(user.id());
+
+ // Verify that user can see the change.
+ assertThat(gApi.changes().query("change:" + changeId).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+
+ // Verify that user cannot see the other accounts by their secondary email.
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(secondaryOwnerEmail).get());
+ assertThrows(
+ ResourceNotFoundException.class, () -> gApi.accounts().id(secondaryReviewerEmail).get());
+
+ // Verify that the change is not found if user queries for changes owned/uploaded by the
+ // secondary email of the owner that is not visible to user.
+ assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get()).isEmpty();
+ assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get()).isEmpty();
+
+ // Verify that the change is not found if user queries for changes reviewed by the secondary
+ // email of the reviewer that is not visible to user.
+ assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get()).isEmpty();
+ assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+ .isEmpty();
+ }
+
+ @Test
+ public void changesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability()
+ throws Exception {
+ testCangesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability(
+ GlobalCapability.MODIFY_ACCOUNT);
+ }
+
+ @Test
+ public void changesFoundWhenQueryingBySecondaryEmailWithViewSecondaryEmailsCapability()
+ throws Exception {
+ testCangesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability(
+ GlobalCapability.VIEW_SECONDARY_EMAILS);
+ }
+
+ private void testCangesFoundWhenQueryingBySecondaryEmailWithModifyAccountCapability(
+ String globalCapability) throws Exception {
+ String secondaryOwnerEmail = "owner-secondary@example.com";
+ Account.Id owner =
+ accountOperations
+ .newAccount()
+ .preferredEmail("owner@example.com")
+ .addSecondaryEmail(secondaryOwnerEmail)
+ .create();
+
+ String secondaryReviewerEmail = "reviewer-secondary@example.com";
+ Account.Id reviewer =
+ accountOperations
+ .newAccount()
+ .preferredEmail("reviewer@example.com")
+ .addSecondaryEmail(secondaryReviewerEmail)
+ .create();
+
+ // Create the change.
+ Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+ // Add a review.
+ requestScopeOperations.setApiUser(reviewer);
+ gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+ projectOperations
+ .allProjectsForUpdate()
+ .add(allowCapability(globalCapability).group(REGISTERED_USERS))
+ .update();
+
+ requestScopeOperations.setApiUser(user.id());
+
+ // Verify that user can see the other accounts by their secondary email.
+ assertThat(gApi.accounts().id(secondaryOwnerEmail).get()._accountId).isEqualTo(owner.get());
+ assertThat(gApi.accounts().id(secondaryReviewerEmail).get()._accountId)
+ .isEqualTo(reviewer.get());
+
+ // Verify that the change is found if user queries for changes owned/uploaded by the secondary
+ // email.
+ assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+
+ // Verify that the change is found if user queries for changes reviewed by the secondary email.
+ assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ }
+
+ @Test
+ public void changesFoundWhenQueryingByOwnSecondaryEmail() throws Exception {
+ String secondaryOwnerEmail = "owner-secondary@example.com";
+ Account.Id owner =
+ accountOperations
+ .newAccount()
+ .preferredEmail("owner@example.com")
+ .addSecondaryEmail(secondaryOwnerEmail)
+ .create();
+
+ String secondaryReviewerEmail = "reviewer-secondary@example.com";
+ Account.Id reviewer =
+ accountOperations
+ .newAccount()
+ .preferredEmail("reviewer@example.com")
+ .addSecondaryEmail(secondaryReviewerEmail)
+ .create();
+
+ // Create the change.
+ Change.Id changeId = changeOperations.newChange().owner(owner).create();
+
+ // Add a review.
+ requestScopeOperations.setApiUser(reviewer);
+ gApi.changes().id(changeId.get()).current().review(ReviewInput.recommend());
+
+ // Verify that the change is found if owner queries for changes owned/uploaded by their
+ // secondary email.
+ requestScopeOperations.setApiUser(owner);
+ assertThat(gApi.changes().query("owner:" + secondaryOwnerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ assertThat(gApi.changes().query("uploader:" + secondaryOwnerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+
+ // Verify that the change is found if reviewer queries for changes reviewed by their secondary
+ // email.
+ requestScopeOperations.setApiUser(reviewer);
+ assertThat(gApi.changes().query("reviewer:" + secondaryReviewerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ assertThat(gApi.changes().query("label:Code-Review+1,user=" + secondaryReviewerEmail).get())
+ .comparingElementsUsing(hasChangeId())
+ .containsExactly(changeId);
+ }
+
private static void assertNoChangeHasMoreChangesSet(List<ChangeInfo> results) {
for (ChangeInfo info : results) {
assertThat(info._moreChanges).isNull();
@@ -383,4 +680,9 @@ public class QueryChangesIT extends AbstractDaemonTest {
}
}
}
+
+ private static Correspondence<ChangeInfo, Change.Id> hasChangeId() {
+ return NullAwareCorrespondence.transforming(
+ changeInfo -> Change.id(changeInfo._number), "hasChangeId");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
new file mode 100644
index 0000000000..785186d8bf
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
@@ -0,0 +1,1401 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+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.RestApiException;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.junit.Test;
+
+/**
+ * Tests for the {@link com.google.gerrit.server.restapi.change.RebaseChain} REST endpoint with the
+ * {@link RebaseInput#onBehalfOfUploader} option being set.
+ *
+ * <p>Rebasing a single change on behalf of the uploader is covered by {@link
+ * RebaseOnBehalfOfUploaderIT}.
+ */
+public class RebaseChainOnBehalfOfUploaderIT extends AbstractDaemonTest {
+ @Inject private AccountOperations accountOperations;
+ @Inject private ChangeOperations changeOperations;
+ @Inject private GroupOperations groupOperations;
+ @Inject private ProjectOperations projectOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private ExtensionRegistry extensionRegistry;
+ @Inject private TestMetricMaker testMetricMaker;
+
+ @Test
+ public void cannotRebaseOnBehalfOfUploaderWithAllowConflicts() throws Exception {
+ Account.Id uploader = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ rebaseInput.allowConflicts = true;
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.changes().id(changeId.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo("allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfUploader_withRebasePermission() throws Exception {
+ testRebaseChainOnBehalfOfUploader(Permission.REBASE);
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfUploader_withSubmitPermission() throws Exception {
+ testRebaseChainOnBehalfOfUploader(Permission.SUBMIT);
+ }
+
+ private void testRebaseChainOnBehalfOfUploader(String permissionToAllow) throws Exception {
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Grant permission to rebaser that is required to rebase on behalf of the uploader.
+ AccountGroup.UUID allowedGroup =
+ groupOperations.newGroup().name("can-" + permissionToAllow).addMember(rebaser).create();
+ allowPermission(permissionToAllow, allowedGroup);
+
+ // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+ // doesn't require the rebaser to have the push permission.
+ AccountGroup.UUID cannotUploadGroup =
+ groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+ blockPermission(Permission.PUSH, cannotUploadGroup);
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ // Create a chain of changes for being rebased, each change with a different uploader.
+ Account.Id uploader1 =
+ accountOperations.newAccount().preferredEmail("uploader1@example.com").create();
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader1).create();
+
+ Account.Id uploader2 =
+ accountOperations.newAccount().preferredEmail("uploader2@example.com").create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .change(changeToBeRebased1)
+ .owner(uploader2)
+ .create();
+
+ Account.Id uploader3 =
+ accountOperations.newAccount().preferredEmail("uploader3@example.com").create();
+ Change.Id changeToBeRebased3 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .change(changeToBeRebased2)
+ .owner(uploader3)
+ .create();
+
+ Account.Id uploader4 =
+ accountOperations.newAccount().preferredEmail("uploader4@example.com").create();
+ Change.Id changeToBeRebased4 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .change(changeToBeRebased3)
+ .owner(uploader4)
+ .create();
+
+ // Block rebase and submit permission for the uploaders. For rebase on behalf of the uploader
+ // only
+ // the rebaser needs to have these permission, but not the uploaders on whom's behalf the rebase
+ // is done.
+ AccountGroup.UUID cannotRebaseAndSubmitGroup =
+ groupOperations
+ .newGroup()
+ .name("cannot-rebase")
+ .addMember(uploader1)
+ .addMember(uploader2)
+ .addMember(uploader3)
+ .addMember(uploader4)
+ .create();
+ blockPermission(Permission.REBASE, cannotRebaseAndSubmitGroup);
+ blockPermission(Permission.SUBMIT, cannotRebaseAndSubmitGroup);
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the chain on behalf of the uploaders through changeToBeRebased4
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+
+ TestRevisionCreatedListener testRevisionCreatedListener = new TestRevisionCreatedListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(testRevisionCreatedListener)) {
+ gApi.changes().id(changeToBeRebased4.get()).rebaseChain(rebaseInput);
+
+ testRevisionCreatedListener.assertUploaders(changeToBeRebased1, uploader1, rebaser);
+ testRevisionCreatedListener.assertUploaders(changeToBeRebased2, uploader2, rebaser);
+ testRevisionCreatedListener.assertUploaders(changeToBeRebased3, uploader3, rebaser);
+ testRevisionCreatedListener.assertUploaders(changeToBeRebased4, uploader4, rebaser);
+ }
+
+ assertRebase(changeToBeRebased1, 2, uploader1, rebaser);
+ assertRebase(changeToBeRebased2, 2, uploader2, rebaser);
+ assertRebase(changeToBeRebased3, 2, uploader3, rebaser);
+ assertRebase(changeToBeRebased4, 2, uploader4, rebaser);
+ }
+
+ @Test
+ public void rebaseChainOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ // Create a chain of changes for being rebased, each change with a different uploader.
+ Account.Id uploader1 =
+ accountOperations.newAccount().preferredEmail("uploader1@example.com").create();
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader1).create();
+
+ Account.Id uploader2 =
+ accountOperations.newAccount().preferredEmail("uploader2@example.com").create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .change(changeToBeRebased1)
+ .owner(uploader2)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the chain on behalf of the uploaders.
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ assertRebase(changeToBeRebased1, 2, uploader1, rebaser);
+ assertRebase(changeToBeRebased2, 2, uploader2, rebaser);
+
+ // Create and submit another change so that we can rebase the chain once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase2 = changeOperations.newChange().project(project).create();
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+ // Rebase the chain once again on behalf of the uploaders.
+ requestScopeOperations.setApiUser(rebaser);
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ assertRebase(changeToBeRebased1, 3, uploader1, rebaser);
+ assertRebase(changeToBeRebased2, 3, uploader2, rebaser);
+ }
+
+ @Test
+ public void nonChangeOwnerWithoutSubmitAndRebasePermissionCannotRebaseChainOnBehalfOfUploader()
+ throws Exception {
+ Change.Id changeToBeRebased1 = changeOperations.newChange().project(project).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations.newChange().project(project).childOf().change(changeToBeRebased1).create();
+
+ blockPermissionForAllUsers(Permission.REBASE);
+ blockPermissionForAllUsers(Permission.SUBMIT);
+
+ Account.Id rebaserId = accountOperations.newAccount().create();
+ requestScopeOperations.setApiUser(rebaserId);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ AuthException exception =
+ assertThrows(
+ AuthException.class,
+ () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase on behalf of uploader not permitted (change owners and users with the 'Submit'"
+ + " or 'Rebase' permission can rebase on behalf of the uploader)");
+ }
+
+ @Test
+ public void cannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoReadPermission()
+ throws Exception {
+ String uploaderEmail = "uploader@example.com";
+ testCannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+ uploaderEmail,
+ Permission.READ,
+ String.format("uploader %s cannot read change", uploaderEmail));
+ }
+
+ @Test
+ public void cannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPushPermission()
+ throws Exception {
+ String uploaderEmail = "uploader@example.com";
+ testCannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+ uploaderEmail,
+ Permission.PUSH,
+ String.format("uploader %s cannot add patch set", uploaderEmail));
+ }
+
+ private void testCannotRebaseChainOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+ String uploaderEmail, String permissionToBlock, String expectedErrorMessage)
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block the required permission for uploader. Without this permission it should not be possible
+ // to rebase the change on behalf of the uploader.
+ AccountGroup.UUID blockedGroup =
+ groupOperations.newGroup().name("cannot-" + permissionToBlock).addMember(uploader).create();
+ blockPermission(permissionToBlock, blockedGroup);
+
+ // Try to rebase the chain on behalf of the uploader.
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(String.format("change %s: %s", changeToBeRebased1, expectedErrorMessage));
+ }
+
+ @Test
+ public void rebaseChainOnBehalfOfYourself() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the chain as uploader on behalf of the uploader
+ requestScopeOperations.setApiUser(uploader);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ assertRebase(changeToBeRebased1, 2, uploader, /* expectedRealUploader= */ null);
+ assertRebase(changeToBeRebased2, 2, uploader, /* expectedRealUploader= */ null);
+ }
+
+ @Test
+ public void cannotRebaseChangeOnBehalfOfYourselfWithoutPushPermission() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block push for the uploader aka the rebaser. This permission is required for creating the new
+ // patch set and if it is blocked we expect the rebase to fail.
+ AccountGroup.UUID cannotPushGroup =
+ groupOperations.newGroup().name("cannot-push").addMember(uploader).create();
+ blockPermission(Permission.PUSH, cannotPushGroup);
+
+ // Rebase the chain as uploader on behalf of the uploader
+ requestScopeOperations.setApiUser(uploader);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ AuthException exception =
+ assertThrows(
+ AuthException.class,
+ () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+ + " permission can rebase if they have the 'Push' permission)");
+ }
+
+ @Test
+ public void rebaseChainOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwner() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id changeOwner =
+ accountOperations.newAccount().preferredEmail("change-owner@example.com").create();
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(changeOwner)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Create a second patch set for the second change in the chain that will be rebased so that the
+ // uploader is different to the change owner.
+ // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+ // require the Forge Author and Forge Committer permission.
+ changeOperations
+ .change(changeToBeRebased2)
+ .newPatchset()
+ .uploader(uploader)
+ .author(uploader)
+ .committer(uploader)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant add patch set permission for uploader. Without the add patch set permission it is not
+ // possible to rebase the change on behalf of the uploader since the uploader cannot add a
+ // patch set to a change that is owned by another user.
+ AccountGroup.UUID canAddPatchSet =
+ groupOperations.newGroup().name("can-add-patch-set").addMember(uploader).create();
+ allowPermission(Permission.ADD_PATCH_SET, canAddPatchSet);
+
+ // Rebase the chain on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ assertRebase(changeToBeRebased1, 2, changeOwner, rebaser);
+ assertRebase(changeToBeRebased2, 3, uploader, rebaser);
+ }
+
+ @Test
+ public void
+ cannotRebaseChainOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwnerAndDoesntHaveAddPatchSetPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(changeOwner)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Create a second patch set for the second change in the chain that will be rebased so that the
+ // uploader is different to the change owner.
+ // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+ // require the Forge Author and Forge Committer permission.
+ changeOperations
+ .change(changeToBeRebased2)
+ .newPatchset()
+ .uploader(uploader)
+ .author(uploader)
+ .committer(uploader)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block add patch set permission for uploader. Without the add patch set permission it should
+ // not possible to rebase the change on behalf of the uploader since the uploader cannot add a
+ // patch set to a change that is owned by another user.
+ AccountGroup.UUID cannotAddPatchSet =
+ groupOperations.newGroup().name("cannot-add-patch-set").addMember(uploader).create();
+ blockPermission(Permission.ADD_PATCH_SET, cannotAddPatchSet);
+
+ // Try to rebase the chain on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: uploader %s cannot add patch set", changeToBeRebased2, uploaderEmail));
+ }
+
+ @Test
+ public void rebaseChainWithForgedAuthorOnBehalfOfUploader() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String authorEmail = "author@example.com";
+ Account.Id author = accountOperations.newAccount().preferredEmail(authorEmail).create();
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).author(author).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .author(author)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant forge author permission for uploader. Without the forge author permission it is not
+ // possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID canForgeAuthor =
+ groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+ allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased1.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+ currentRevisionInfo = gApi.changes().id(changeToBeRebased2.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void
+ cannotRebaseChainWithForgedAuthorOnBehalfOfUploaderIfTheUploaderHasNoForgeAuthorPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id author = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).author(author).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .author(author)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block forge author permission for uploader. Without the forge author permission it should not
+ // be possible to rebase the chain on behalf of the uploader.
+ AccountGroup.UUID cannotForgeAuthor =
+ groupOperations.newGroup().name("cannot-forge-author").addMember(uploader).create();
+ blockPermission(Permission.FORGE_AUTHOR, cannotForgeAuthor);
+
+ // Try to rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: author of patch set 1 is forged and the uploader %s cannot forge author",
+ changeToBeRebased1, uploaderEmail));
+ }
+
+ @Test
+ public void
+ rebaseChainWithForgedCommitterOnBehalfOfUploaderDoesntRequireForgeCommitterPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id committer =
+ accountOperations.newAccount().preferredEmail("committer@example.com").create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).committer(committer).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .committer(committer)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased1.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+ currentRevisionInfo = gApi.changes().id(changeToBeRebased2.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void rebaseChainWithServerIdentOnBehalfOfUploader() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .authorIdent(serverIdent.get())
+ .create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .authorIdent(serverIdent.get())
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant forge author and forge server permission for uploader. Without these permissions it is
+ // not possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID canForgeAuthorAndForgeServer =
+ groupOperations
+ .newGroup()
+ .name("can-forge-author-and-forge-server")
+ .addMember(uploader)
+ .create();
+ allowPermission(Permission.FORGE_AUTHOR, canForgeAuthorAndForgeServer);
+ allowPermission(Permission.FORGE_SERVER, canForgeAuthorAndForgeServer);
+
+ // Rebase the chain on behalf of the uploader.
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased1.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.author.email)
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+ currentRevisionInfo = gApi.changes().id(changeToBeRebased2.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.author.email)
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void
+ cannotRebaseChainWithServerIdentOnBehalfOfUploaderIfTheUploaderHasNoForgeServerPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .authorIdent(serverIdent.get())
+ .create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .authorIdent(serverIdent.get())
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant forge author permission for uploader, but not the forge server permission. Without the
+ // forge server permission it is not possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID canForgeAuthor =
+ groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+ allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+ // Try to rebase the chain on behalf of the uploader.
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: author of patch set 1 is the server identity and the uploader %s cannot forge"
+ + " the server identity",
+ changeToBeRebased1, uploaderEmail));
+ }
+
+ @Test
+ public void rebaseChainActionEnabled_withRebasePermission() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+ testRebaseChainActionEnabled();
+ }
+
+ @Test
+ public void rebaseChainActionEnabled_withSubmitPermission() throws Exception {
+ allowPermissionToAllUsers(Permission.SUBMIT);
+ testRebaseChainActionEnabled();
+ }
+
+ private void testRebaseChainActionEnabled() throws Exception {
+ Account.Id uploader = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+ // doesn't require the rebaser to have the push permission.
+ AccountGroup.UUID cannotUploadGroup =
+ groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+ blockPermission(Permission.PUSH, cannotUploadGroup);
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain so that the chain is
+ // rebasable.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ requestScopeOperations.setApiUser(rebaser);
+ ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased2.get()).get();
+ assertThat(changeInfo.actions).containsKey("rebase:chain");
+ ActionInfo rebaseActionInfo = changeInfo.actions.get("rebase:chain");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+
+ // rebase is disabled because rebaser doesn't have the 'Push' permission and hence cannot create
+ // new patch sets
+ assertThat(rebaseActionInfo.enabledOptions).containsExactly("rebase_on_behalf_of_uploader");
+ }
+
+ @Test
+ public void rebaseChainActionEnabled_forChangeOwner() throws Exception {
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(changeOwner)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ requestScopeOperations.setApiUser(changeOwner);
+ ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased2.get()).get();
+ assertThat(changeInfo.actions).containsKey("rebase:chain");
+ ActionInfo rebaseActionInfo = changeInfo.actions.get("rebase:chain");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+
+ // rebase is enabled because change owner has the 'Push' permission and hence can create new
+ // patch sets
+ assertThat(rebaseActionInfo.enabledOptions)
+ .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+ }
+
+ @UseLocalDisk
+ @Test
+ public void rebaseChainWithIdenticalUploadersOnBehalfOfUploaderRecordsUploaderInRefLog()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ try (Repository repo = repoManager.openRepository(project)) {
+ String changeMetaRef1 = RefNames.changeMetaRef(changeToBeRebased1);
+ String patchSetRef1 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased1, 2));
+ String changeMetaRef2 = RefNames.changeMetaRef(changeToBeRebased2);
+ String patchSetRef2 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased2, 2));
+ createRefLogFileIfMissing(repo, changeMetaRef1);
+ createRefLogFileIfMissing(repo, patchSetRef1);
+ createRefLogFileIfMissing(repo, changeMetaRef2);
+ createRefLogFileIfMissing(repo, patchSetRef2);
+
+ // Rebase the chain on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ // The ref log for the patch set ref records the impersonated user aka the uploader.
+ ReflogEntry patchSetRefLogEntry1 = repo.getReflogReader(patchSetRef1).getLastEntry();
+ assertThat(patchSetRefLogEntry1.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+ ReflogEntry patchSetRefLogEntry2 = repo.getReflogReader(patchSetRef2).getLastEntry();
+ assertThat(patchSetRefLogEntry2.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+
+ // The ref log for the change meta ref records the impersonated user aka the uploader.
+ ReflogEntry changeMetaRefLogEntry1 = repo.getReflogReader(changeMetaRef1).getLastEntry();
+ assertThat(changeMetaRefLogEntry1.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+ ReflogEntry changeMetaRefLogEntry2 = repo.getReflogReader(changeMetaRef2).getLastEntry();
+ assertThat(changeMetaRefLogEntry2.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+ }
+ }
+
+ @UseLocalDisk
+ @Test
+ public void rebaseChainWithDifferentUploadersOnBehalfOfUploaderRecordsCombinedIdentityInRefLog()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Account.Id uploader1 = accountOperations.newAccount().create();
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader1).create();
+
+ Account.Id uploader2 = accountOperations.newAccount().create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader2)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ try (Repository repo = repoManager.openRepository(project)) {
+ String changeMetaRef1 = RefNames.changeMetaRef(changeToBeRebased1);
+ String patchSetRef1 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased1, 2));
+ String changeMetaRef2 = RefNames.changeMetaRef(changeToBeRebased2);
+ String patchSetRef2 = RefNames.patchSetRef(PatchSet.id(changeToBeRebased2, 2));
+ createRefLogFileIfMissing(repo, changeMetaRef1);
+ createRefLogFileIfMissing(repo, patchSetRef1);
+ createRefLogFileIfMissing(repo, changeMetaRef2);
+ createRefLogFileIfMissing(repo, patchSetRef2);
+
+ // Rebase the chain on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ String combinedEmail = String.format("account-%s|account-%s@unknown", uploader1, uploader2);
+
+ // The ref log for the patch set ref records the impersonated user aka the uploader.
+ ReflogEntry patchSetRefLogEntry1 = repo.getReflogReader(patchSetRef1).getLastEntry();
+ assertThat(patchSetRefLogEntry1.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+ ReflogEntry patchSetRefLogEntry2 = repo.getReflogReader(patchSetRef2).getLastEntry();
+ assertThat(patchSetRefLogEntry2.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+
+ // The ref log for the change meta ref records the impersonated user aka the uploader.
+ ReflogEntry changeMetaRefLogEntry1 = repo.getReflogReader(changeMetaRef1).getLastEntry();
+ assertThat(changeMetaRefLogEntry1.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+ ReflogEntry changeMetaRefLogEntry2 = repo.getReflogReader(changeMetaRef2).getLastEntry();
+ assertThat(changeMetaRefLogEntry2.getWho().getEmailAddress()).isEqualTo(combinedEmail);
+ }
+ }
+
+ @Test
+ public void rebaserCanApproveChainAfterRebasingOnBehalfOfUploader() throws Exception {
+ // Require a Code-Review approval from a non-uploader for submit.
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig()
+ .upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName(TestLabels.codeReview().getName())
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(
+ String.format(
+ "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ u.save();
+ }
+
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().owner(uploader).project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase it on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ // Approve the chain as the rebaser.
+ allowVotingOnCodeReviewToAllUsers();
+ gApi.changes().id(changeToBeRebased1.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeRebased2.get()).current().review(ReviewInput.approve());
+
+ // The chain is submittable because the approval is from a user (the rebaser) that is not the
+ // uploader.
+ assertThat(gApi.changes().id(changeToBeRebased1.get()).get().submittable).isTrue();
+ assertThat(gApi.changes().id(changeToBeRebased2.get()).get().submittable).isTrue();
+
+ // Create and submit another change so that we can rebase the chain once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase2 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+ // Doing a normal rebase (not on behalf of the uploader) makes the rebaser the uploader. This
+ // makse the chain non-submittable since the approval of the rebaser is ignored now (due to
+ // using 'user=non_uploader' in the submit requirement expression).
+ requestScopeOperations.setApiUser(rebaser);
+ rebaseInput.onBehalfOfUploader = false;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+ gApi.changes().id(changeToBeRebased1.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeRebased2.get()).current().review(ReviewInput.approve());
+ assertThat(gApi.changes().id(changeToBeRebased1.get()).get().submittable).isFalse();
+ assertThat(gApi.changes().id(changeToBeRebased2.get()).get().submittable).isFalse();
+ }
+
+ @Test
+ public void testSubmittedWithRebaserApprovalMetric() throws Exception {
+ // Require a Code-Review approval from a non-uploader for submit.
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig()
+ .upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName(TestLabels.codeReview().getName())
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(
+ String.format(
+ "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ u.save();
+ }
+
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().owner(uploader).project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ testMetricMaker.reset();
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+ assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+ // Rebase it on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+
+ // Approve the chain as the rebaser.
+ allowVotingOnCodeReviewToAllUsers();
+ gApi.changes().id(changeToBeRebased1.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeRebased2.get()).current().review(ReviewInput.approve());
+
+ // The chain is submittable because the approval is from a user (the rebaser) that is not the
+ // uploader.
+ allowPermissionToAllUsers(Permission.SUBMIT);
+ testMetricMaker.reset();
+ gApi.changes().id(changeToBeRebased1.get()).current().submit();
+ gApi.changes().id(changeToBeRebased2.get()).current().submit();
+ assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(2);
+ }
+
+ @Test
+ public void testCountRebasesMetric() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase it on behalf of the uploader
+ testMetricMaker.reset();
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+
+ // Create and submit another change so that we can rebase the change once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase2 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+ // Rebase the change once again, this time as the uploader.
+ // If the uploader sets on_behalf_of_uploader = true, the flag is ignored and a normal rebase is
+ // done, hence the metric should count this as a a rebase with on_behalf_of_uploader = false.
+ requestScopeOperations.setApiUser(uploader);
+ testMetricMaker.reset();
+ gApi.changes().id(changeToBeRebased2.get()).rebaseChain(rebaseInput);
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+ }
+
+ private void assertRebase(
+ Change.Id changeId,
+ int expectedPatchSetNum,
+ Account.Id expectedUploader,
+ @Nullable Account.Id expectedRealUploader)
+ throws RestApiException {
+ assertRebaseRevision(changeId, expectedPatchSetNum, expectedUploader, expectedRealUploader);
+ assetRebaseChangeMessage(changeId, expectedPatchSetNum, expectedUploader, expectedRealUploader);
+ assertRealUserForChangeUpdate(changeId, expectedRealUploader);
+ }
+
+ private void assertRebaseRevision(
+ Change.Id changeId,
+ int expectedPatchSetNum,
+ Account.Id expectedUploader,
+ @Nullable Account.Id expectedRealUploader)
+ throws RestApiException {
+ RevisionInfo currentRevisionInfo = gApi.changes().id(changeId.get()).get().getCurrentRevision();
+
+ assertThat(currentRevisionInfo._number).isEqualTo(expectedPatchSetNum);
+
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(expectedUploader.get());
+
+ if (expectedRealUploader != null) {
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(expectedRealUploader.get());
+ } else {
+ assertThat(currentRevisionInfo.realUploader).isNull();
+ }
+
+ String uploaderEmail = accountOperations.account(expectedUploader).get().preferredEmail().get();
+ assertThat(currentRevisionInfo.commit.author.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ }
+
+ private void assetRebaseChangeMessage(
+ Change.Id changeId,
+ int expectedPatchSetNum,
+ Account.Id expectedUploader,
+ @Nullable Account.Id expectedRealUploader)
+ throws RestApiException {
+ Collection<ChangeMessageInfo> changeMessages = gApi.changes().id(changeId.get()).get().messages;
+
+ // Expect 1 change message per patch set.
+ assertThat(changeMessages).hasSize(expectedPatchSetNum);
+
+ ChangeMessageInfo changeMessage = Iterables.getLast(changeMessages);
+ assertThat(changeMessage.author._accountId).isEqualTo(expectedUploader.get());
+
+ if (expectedRealUploader != null) {
+ assertThat(changeMessage.message)
+ .isEqualTo(
+ String.format(
+ "Patch Set %d: Patch Set %d was rebased on behalf of %s",
+ expectedPatchSetNum,
+ expectedPatchSetNum - 1,
+ AccountTemplateUtil.getAccountTemplate(expectedUploader)));
+ assertThat(changeMessage.realAuthor._accountId).isEqualTo(expectedRealUploader.get());
+ } else {
+ assertThat(changeMessage.message)
+ .isEqualTo(
+ String.format(
+ "Patch Set %d: Patch Set %d was rebased",
+ expectedPatchSetNum, expectedPatchSetNum - 1));
+ assertThat(changeMessage.realAuthor).isNull();
+ }
+ }
+
+ private void assertRealUserForChangeUpdate(
+ Change.Id changeId, @Nullable Account.Id expectedRealUser) {
+ Optional<FooterLine> realUserFooter =
+ projectOperations.project(project).getHead(RefNames.changeMetaRef(changeId))
+ .getFooterLines().stream()
+ .filter(footerLine -> footerLine.matches(FOOTER_REAL_USER))
+ .findFirst();
+
+ if (expectedRealUser != null) {
+ assertThat(realUserFooter.map(FooterLine::getValue))
+ .hasValue(
+ String.format(
+ "%s <%s>",
+ ChangeNoteUtil.getAccountIdAsUsername(expectedRealUser),
+ changeNoteUtil.getAccountIdAsEmailAddress(expectedRealUser)));
+ } else {
+ assertThat(realUserFooter).isEmpty();
+ }
+ }
+
+ private void allowPermissionToAllUsers(String permission) {
+ allowPermission(permission, REGISTERED_USERS);
+ }
+
+ private void allowPermission(String permission, AccountGroup.UUID groupUuid) {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(permission).ref("refs/*").group(groupUuid))
+ .update();
+ }
+
+ private void allowVotingOnCodeReviewToAllUsers() {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(TestLabels.codeReview().getName())
+ .ref("refs/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .update();
+ }
+
+ private void blockPermissionForAllUsers(String permission) {
+ blockPermission(permission, REGISTERED_USERS);
+ }
+
+ private void blockPermission(String permission, AccountGroup.UUID groupUuid) {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(permission).ref("refs/*").group(groupUuid))
+ .update();
+ }
+
+ private static class TestRevisionCreatedListener implements RevisionCreatedListener {
+ private Map<Change.Id, RevisionInfo> revisionInfos = new HashMap<>();
+
+ void assertUploaders(
+ Change.Id changeId, Account.Id expectedUploader, Account.Id expectedRealUploader) {
+ RevisionInfo revisionInfo = revisionInfos.get(changeId);
+ assertThat(revisionInfo.uploader._accountId).isEqualTo(expectedUploader.get());
+ assertThat(revisionInfo.realUploader._accountId).isEqualTo(expectedRealUploader.get());
+ }
+
+ @Override
+ public void onRevisionCreated(RevisionCreatedListener.Event event) {
+ revisionInfos.put(Change.id(event.getChange()._number), event.getRevision());
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
new file mode 100644
index 0000000000..5ecb5a7eab
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -0,0 +1,1412 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.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;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.RelatedChangeAndCommitInfo;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.GitPerson;
+import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.RebaseChainInfo;
+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.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.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.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+ RebaseIT.RebaseViaRevisionApi.class, //
+ RebaseIT.RebaseViaChangeApi.class, //
+ RebaseIT.RebaseChain.class, //
+})
+public class RebaseIT {
+ public abstract static class Base extends AbstractDaemonTest {
+ @Inject protected ChangeOperations changeOperations;
+ @Inject protected RequestScopeOperations requestScopeOperations;
+ @Inject protected ProjectOperations projectOperations;
+ @Inject protected ExtensionRegistry extensionRegistry;
+ @Inject protected TestMetricMaker testMetricMaker;
+
+ @FunctionalInterface
+ protected interface RebaseCall {
+ void call(String id) throws RestApiException;
+ }
+
+ @FunctionalInterface
+ protected interface RebaseCallWithInput {
+ void call(String id, RebaseInput in) throws RestApiException;
+ }
+
+ protected RebaseCall rebaseCall;
+ protected RebaseCallWithInput rebaseCallWithInput;
+
+ protected void init(RebaseCall call, RebaseCallWithInput callWithInput) {
+ this.rebaseCall = call;
+ this.rebaseCallWithInput = callWithInput;
+ }
+
+ @Test
+ public void rebaseChange() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Add an approval whose score should be copied on trivial rebase
+ gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+ // Rebase the second change
+ rebaseCall.call(r2.getChangeId());
+
+ verifyRebaseForChange(r2.getChange().getId(), r.getCommit().name(), true, 2);
+
+ // Rebasing the second change again should fail
+ verifyChangeIsUpToDate(r2);
+ }
+
+ @Test
+ public void rebaseAbandonedChange() throws Exception {
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+ gApi.changes().id(changeId).abandon();
+ ChangeInfo info = info(changeId);
+ assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+ ResourceConflictException thrown =
+ assertThrows(ResourceConflictException.class, () -> rebaseCall.call(changeId));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("Change " + r.getChange().getId() + " is abandoned");
+ }
+
+ @Test
+ public void rebaseOntoAbandonedChange() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Abandon the first change
+ String changeId = r.getChangeId();
+ assertThat(info(changeId).status).isEqualTo(ChangeStatus.NEW);
+ gApi.changes().id(changeId).abandon();
+ ChangeInfo info = info(changeId);
+ assertThat(info.status).isEqualTo(ChangeStatus.ABANDONED);
+
+ RebaseInput ri = new RebaseInput();
+ ri.base = r.getCommit().name();
+
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> rebaseCallWithInput.call(r2.getChangeId(), ri));
+ assertThat(thrown).hasMessageThat().contains("base change is abandoned: " + changeId);
+ }
+
+ @Test
+ public void rebaseOntoSelf() throws Exception {
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ String commit = r.getCommit().name();
+ RebaseInput ri = new RebaseInput();
+ ri.base = commit;
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class, () -> rebaseCallWithInput.call(changeId, ri));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("cannot rebase change " + r.getChange().getId() + " onto itself");
+ }
+
+ @Test
+ public void rebaseChangeBaseRecursion() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+ PushOneCommit.Result r2 = createChange();
+
+ RebaseInput ri = new RebaseInput();
+ ri.base = r2.getCommit().name();
+ String expectedMessage =
+ "base change "
+ + r2.getChangeId()
+ + " is a descendant of the current change - recursion not allowed";
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> rebaseCallWithInput.call(r1.getChangeId(), ri));
+ assertThat(thrown).hasMessageThat().contains(expectedMessage);
+ }
+
+ @Test
+ public void cannotRebaseOntoBaseThatIsNotPresentInTargetBranch() throws Exception {
+ ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+ BranchInput branchInput = new BranchInput();
+ branchInput.revision = initial.getName();
+ gApi.projects().name(project.get()).branch("foo").create(branchInput);
+
+ PushOneCommit.Result r1 =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "Change on foo branch", "a.txt", "a-content")
+ .to("refs/for/foo");
+ approve(r1.getChangeId());
+ gApi.changes().id(r1.getChangeId()).current().submit();
+
+ // reset HEAD in order to create a sibling of the first change
+ testRepo.reset(initial);
+
+ PushOneCommit.Result r2 =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "Change on master branch", "b.txt", "b-content")
+ .to("refs/for/master");
+
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.base = r1.getCommit().getName();
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains(
+ String.format(
+ "base change is targeting wrong branch: %s,refs/heads/foo", project.get()));
+
+ rebaseInput.base = "refs/heads/foo";
+ thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> rebaseCallWithInput.call(r2.getChangeId(), rebaseInput));
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains(
+ String.format(
+ "base revision is missing from the destination branch: %s", rebaseInput.base));
+ }
+
+ @Test
+ public void rebaseUpToDateChange() throws Exception {
+ PushOneCommit.Result r = createChange();
+ verifyChangeIsUpToDate(r);
+ }
+
+ @Test
+ public void rebaseDoesNotAddWorkInProgress() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // create an unrelated change so that we can rebase
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result unrelated = createChange();
+ gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+ gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+ rebaseCall.call(r.getChangeId());
+
+ // change is still ready for review after rebase
+ assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isNull();
+ }
+
+ @Test
+ public void rebaseDoesNotRemoveWorkInProgress() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).setWorkInProgress();
+
+ // create an unrelated change so that we can rebase
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result unrelated = createChange();
+ gApi.changes().id(unrelated.getChangeId()).current().review(ReviewInput.approve());
+ gApi.changes().id(unrelated.getChangeId()).current().submit();
+
+ rebaseCall.call(r.getChangeId());
+
+ // change is still work in progress after rebase
+ assertThat(gApi.changes().id(r.getChangeId()).get().workInProgress).isTrue();
+ }
+
+ @Test
+ public void rebaseAsUploaderInAttentionSet() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ TestAccount admin2 = accountCreator.admin2();
+ requestScopeOperations.setApiUser(admin2.id());
+ amendChangeWithUploader(r2, project, admin2);
+ gApi.changes()
+ .id(r2.getChangeId())
+ .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+ rebaseCall.call(r2.getChangeId());
+ }
+
+ @Test
+ public void rebaseOnChangeNumber() throws Exception {
+ String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+ PushOneCommit.Result r1 = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ RevisionInfo ri2 =
+ get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+ Change.Id id1 = r1.getChange().getId();
+ RebaseInput in = new RebaseInput();
+ in.base = id1.toString();
+ rebaseCallWithInput.call(r2.getChangeId(), in);
+
+ Change.Id id2 = r2.getChange().getId();
+ ri2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+ List<RelatedChangeAndCommitInfo> related =
+ gApi.changes().id(id2.get()).revision(ri2._number).related().changes;
+ assertThat(related).hasSize(2);
+ assertThat(related.get(0)._changeNumber).isEqualTo(id2.get());
+ assertThat(related.get(0)._revisionNumber).isEqualTo(2);
+ assertThat(related.get(1)._changeNumber).isEqualTo(id1.get());
+ assertThat(related.get(1)._revisionNumber).isEqualTo(1);
+ }
+
+ @Test
+ public void rebaseOnClosedChange() throws Exception {
+ String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
+ PushOneCommit.Result r1 = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ RevisionInfo ri2 =
+ get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(ri2.commit.parents.get(0).commit).isEqualTo(branchTip);
+
+ // Submit first change.
+ Change.Id id1 = r1.getChange().getId();
+ gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(id1.get()).current().submit();
+
+ // Rebase second change on first change.
+ RebaseInput in = new RebaseInput();
+ in.base = id1.toString();
+ rebaseCallWithInput.call(r2.getChangeId(), in);
+
+ Change.Id id2 = r2.getChange().getId();
+ ri2 = get(r2.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(ri2.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+ assertThat(gApi.changes().id(id2.get()).revision(ri2._number).related().changes).isEmpty();
+ }
+
+ @Test
+ public void rebaseOnNonExistingChange() throws Exception {
+ String changeId = createChange().getChangeId();
+ RebaseInput in = new RebaseInput();
+ in.base = "999999";
+ UnprocessableEntityException exception =
+ assertThrows(
+ UnprocessableEntityException.class, () -> rebaseCallWithInput.call(changeId, in));
+ assertThat(exception).hasMessageThat().contains("Base change not found: " + in.base);
+ }
+
+ @Test
+ public void rebaseNotAllowedWithoutPermission() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Rebase the second
+ String changeId = r2.getChangeId();
+ requestScopeOperations.setApiUser(user.id());
+ AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+ + " permission can rebase if they have the 'Push' permission)");
+ }
+
+ @Test
+ public void rebaseAllowedWithPermission() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+ .update();
+
+ // Rebase the second
+ String changeId = r2.getChangeId();
+ requestScopeOperations.setApiUser(user.id());
+ rebaseCall.call(changeId);
+ }
+
+ @Test
+ public void rebaseNotAllowedWithoutPushPermission() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REBASE).ref("refs/heads/master").group(REGISTERED_USERS))
+ .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+ .update();
+
+ // Rebase the second
+ String changeId = r2.getChangeId();
+ requestScopeOperations.setApiUser(user.id());
+ AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+ + " permission can rebase if they have the 'Push' permission)");
+ }
+
+ @Test
+ public void rebaseNotAllowedForOwnerWithoutPushPermission() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.PUSH).ref("refs/for/*").group(REGISTERED_USERS))
+ .update();
+
+ // Rebase the second
+ String changeId = r2.getChangeId();
+ AuthException thrown = assertThrows(AuthException.class, () -> rebaseCall.call(changeId));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+ + " permission can rebase if they have the 'Push' permission)");
+ }
+
+ @Test
+ public void rebaseWithValidationOptions() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.validationOptions = ImmutableMap.of("key", "value");
+
+ TestCommitValidationListener testCommitValidationListener =
+ new TestCommitValidationListener();
+ try (ExtensionRegistry.Registration unusedRegistration =
+ extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+ // Rebase the second change
+ rebaseCallWithInput.call(r2.getChangeId(), rebaseInput);
+ assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+ .containsExactly("key", "value");
+ }
+ }
+
+ @Test
+ public void rebaseChangeWhenChecksRefExists() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Create checks ref
+ try (TestRepository<Repository> testRepo =
+ new TestRepository<>(repoManager.openRepository(project))) {
+ testRepo.update(
+ RefNames.changeRefPrefix(r2.getChange().getId()) + "checks",
+ testRepo.commit().message("Empty commit"));
+ }
+
+ // Rebase the second change
+ rebaseCall.call(r2.getChangeId());
+
+ verifyRebaseForChange(
+ r2.getChange().getId(),
+ r.getCommit().name(),
+ /* shouldHaveApproval= */ false,
+ /* expectedNumRevisions= */ 2);
+ }
+
+ protected void verifyRebaseForChange(
+ Change.Id changeId, Change.Id baseChangeId, boolean shouldHaveApproval)
+ throws RestApiException {
+ verifyRebaseForChange(changeId, baseChangeId, shouldHaveApproval, 2);
+ }
+
+ protected void verifyRebaseForChange(
+ Change.Id changeId,
+ Change.Id baseChangeId,
+ boolean shouldHaveApproval,
+ int expectedNumRevisions)
+ throws RestApiException {
+ ChangeInfo baseInfo = gApi.changes().id(baseChangeId.get()).get(CURRENT_REVISION);
+ verifyRebaseForChange(
+ changeId, baseInfo.currentRevision, shouldHaveApproval, expectedNumRevisions);
+ }
+
+ protected void verifyRebaseForChange(
+ Change.Id changeId, String baseCommit, boolean shouldHaveApproval, int expectedNumRevisions)
+ throws RestApiException {
+ ChangeInfo info =
+ gApi.changes().id(changeId.get()).get(CURRENT_REVISION, CURRENT_COMMIT, DETAILED_LABELS);
+
+ RevisionInfo r = info.getCurrentRevision();
+ assertThat(r._number).isEqualTo(expectedNumRevisions);
+ assertThat(r.realUploader).isNull();
+
+ // ...and the base should be correct
+ assertThat(r.commit.parents).hasSize(1);
+ assertWithMessage("base commit for change " + changeId)
+ .that(r.commit.parents.get(0).commit)
+ .isEqualTo(baseCommit);
+
+ // ...and the committer and description should be correct
+ GitPerson committer = r.commit.committer;
+ assertThat(committer.name).isEqualTo(admin.fullName());
+ assertThat(committer.email).isEqualTo(admin.email());
+ String description = r.description;
+ assertThat(description).isEqualTo("Rebase");
+
+ if (shouldHaveApproval) {
+ // ...and the approval was copied
+ LabelInfo cr = info.labels.get(LabelId.CODE_REVIEW);
+ assertThat(cr).isNotNull();
+ assertThat(cr.all).isNotNull();
+ assertThat(cr.all).hasSize(1);
+ assertThat(cr.all.get(0).value).isEqualTo(1);
+ }
+ }
+
+ protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+ ResourceConflictException thrown =
+ assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+ 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;
+ Boolean wip;
+
+ @Override
+ public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event event) {
+ this.invoked = true;
+ this.wip =
+ event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+ }
+ }
+ }
+
+ public abstract static class Rebase extends Base {
+ @Test
+ public void rebaseChangeBase() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+ PushOneCommit.Result r2 = createChange();
+ PushOneCommit.Result r3 = createChange();
+ RebaseInput ri = new RebaseInput();
+
+ // rebase r3 directly onto master (break dep. towards r2)
+ ri.base = "";
+ rebaseCallWithInput.call(r3.getChangeId(), ri);
+ PatchSet ps3 = r3.getPatchSet();
+ assertThat(ps3.id().get()).isEqualTo(2);
+
+ // rebase r2 onto r3 (referenced by ref)
+ ri.base = ps3.id().toRefName();
+ rebaseCallWithInput.call(r2.getChangeId(), ri);
+ PatchSet ps2 = r2.getPatchSet();
+ assertThat(ps2.id().get()).isEqualTo(2);
+
+ // rebase r1 onto r2 (referenced by commit)
+ ri.base = ps2.commitId().name();
+ rebaseCallWithInput.call(r1.getChangeId(), ri);
+ PatchSet ps1 = r1.getPatchSet();
+ assertThat(ps1.id().get()).isEqualTo(2);
+
+ // rebase r1 onto r3 (referenced by change number)
+ ri.base = String.valueOf(r3.getChange().getId().get());
+ rebaseCallWithInput.call(r1.getChangeId(), ri);
+ assertThat(r1.getPatchSetId().get()).isEqualTo(3);
+ }
+
+ @Test
+ public void rebaseWithConflict_conflictsAllowed() throws Exception {
+ String patchSetSubject = "patch set change";
+ String patchSetContent = "patch set content";
+ String baseSubject = "base change";
+ String baseContent = "base content";
+
+ PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+ gApi.changes()
+ .id(r1.getChangeId())
+ .revision(r1.getCommit().name())
+ .review(ReviewInput.approve());
+ gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+ testRepo.reset("HEAD~1");
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ patchSetSubject,
+ PushOneCommit.FILE_NAME,
+ patchSetContent);
+ PushOneCommit.Result r2 = push.to("refs/for/master");
+ r2.assertOkStatus();
+
+ String changeId = r2.getChangeId();
+ RevCommit patchSet = r2.getCommit();
+ RevCommit base = r1.getCommit();
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.allowConflicts = true;
+ testMetricMaker.reset();
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).revision(patchSet.name()).rebaseAsInfo(rebaseInput);
+ assertThat(changeInfo.containsGitConflicts).isTrue();
+ assertThat(changeInfo.workInProgress).isTrue();
+
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, true))
+ .isEqualTo(1);
+ }
+ assertThat(wipStateChangedListener.invoked).isTrue();
+ assertThat(wipStateChangedListener.wip).isTrue();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
+ .isEqualTo(base.name());
+
+ // 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(PushOneCommit.FILE_NAME).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ String patchSetSha1 = abbreviateName(patchSet, 6);
+ String baseSha1 = abbreviateName(base, 6);
+ assertThat(fileContent)
+ .isEqualTo(
+ "<<<<<<< PATCH SET ("
+ + patchSetSha1
+ + " "
+ + patchSetSubject
+ + ")\n"
+ + patchSetContent
+ + "\n"
+ + "=======\n"
+ + baseContent
+ + "\n"
+ + ">>>>>>> BASE ("
+ + baseSha1
+ + " "
+ + baseSubject
+ + ")\n");
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message)
+ .isEqualTo(
+ "Patch Set 2: Patch Set 1 was rebased\n\n"
+ + "The following files contain Git conflicts:\n"
+ + "* "
+ + PushOneCommit.FILE_NAME
+ + "\n");
+ }
+
+ @Test
+ public void rebaseWithConflict_conflictsForbidden() throws Exception {
+ 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",
+ "If09d8782c1e59dd0b33de2b1ec3595d69cc10ad5");
+ PushOneCommit.Result r2 = push.to("refs/for/master");
+ r2.assertOkStatus();
+ ResourceConflictException exception =
+ assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r2.getChangeId()));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Change %s could not be rebased due to a conflict during merge.\n\n"
+ + "merge conflict(s):\n%s",
+ r2.getChange().getId(), PushOneCommit.FILE_NAME));
+ }
+
+ @Test
+ public void rebaseFromRelationChainToClosedChange() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+ testRepo.reset("HEAD~1");
+
+ createChange();
+ PushOneCommit.Result r3 = createChange();
+
+ // Submit first change.
+ Change.Id id1 = r1.getChange().getId();
+ gApi.changes().id(id1.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(id1.get()).current().submit();
+
+ // Rebase third change on first change.
+ RebaseInput in = new RebaseInput();
+ in.base = id1.toString();
+ rebaseCallWithInput.call(r3.getChangeId(), in);
+
+ Change.Id id3 = r3.getChange().getId();
+ RevisionInfo ri3 =
+ get(r3.getChangeId(), CURRENT_REVISION, CURRENT_COMMIT).getCurrentRevision();
+ assertThat(ri3.commit.parents.get(0).commit).isEqualTo(r1.getCommit().name());
+
+ assertThat(gApi.changes().id(id3.get()).revision(ri3._number).related().changes).isEmpty();
+ }
+
+ @Test
+ public void testCountRebasesMetric() throws Exception {
+ // Create two changes both with the same parent
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Rebase the second change
+ testMetricMaker.reset();
+ rebaseCallWithInput.call(r2.getChangeId(), new RebaseInput());
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+ .isEqualTo(1);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+ }
+
+ @Test
+ public void rebaseActionEnabledIfChangeCanBeRebased() throws Exception {
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+ Change.Id changeToBeRebased = changeOperations.newChange().project(project).create();
+
+ // Change cannot be rebased since its parent commit is the same commit as the HEAD of the
+ // destination branch.
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ assertThat(currentRevisionInfo.actions).containsKey("rebase");
+ ActionInfo rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+ assertThat(rebaseActionInfo.enabled).isNull();
+
+ // Approve and submit the change that will be the new base for the chain so that the chain is
+ // rebasable.
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Change can be rebased since its parent commit differs from the commit at the HEAD of the
+ // destination branch.
+ currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ assertThat(currentRevisionInfo.actions).containsKey("rebase");
+ rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+ }
+
+ @Test
+ public void rebaseActionEnabledIfChangeHasAParentChange() throws Exception {
+ Change.Id change1 = changeOperations.newChange().project(project).create();
+ Change.Id change2 =
+ changeOperations.newChange().project(project).childOf().change(change1).create();
+
+ // change1 cannot be rebased since its parent commit is the same commit as the HEAD of the
+ // destination branch.
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(change1.get()).get().getCurrentRevision();
+ assertThat(currentRevisionInfo.actions).containsKey("rebase");
+ ActionInfo rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+ assertThat(rebaseActionInfo.enabled).isNull();
+
+ // change2 can be rebased to break the relation to change1
+ currentRevisionInfo = gApi.changes().id(change2.get()).get().getCurrentRevision();
+ assertThat(currentRevisionInfo.actions).containsKey("rebase");
+ rebaseActionInfo = currentRevisionInfo.actions.get("rebase");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+ }
+ }
+
+ public static class RebaseViaRevisionApi extends Rebase {
+ @Before
+ public void setUp() throws Exception {
+ init(
+ id -> gApi.changes().id(id).current().rebase(),
+ (id, in) -> gApi.changes().id(id).current().rebase(in));
+ }
+
+ @Test
+ public void rebaseOutdatedPatchSet() throws Exception {
+ String fileName1 = "a.txt";
+ String fileContent1 = "some content";
+ String fileName2 = "b.txt";
+ String fileContent2Ps1 = "foo";
+ String fileContent2Ps2 = "foo/bar";
+
+ // Create two changes both with the same parent touching disjunct files
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName1, fileContent1)
+ .to("refs/for/master");
+ r.assertOkStatus();
+ String changeId1 = r.getChangeId();
+ testRepo.reset("HEAD~1");
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(), testRepo, PushOneCommit.SUBJECT, fileName2, fileContent2Ps1);
+ PushOneCommit.Result r2 = push.to("refs/for/master");
+ r2.assertOkStatus();
+ String changeId2 = r2.getChangeId();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(changeId1).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Amend the second change so that it has 2 patch sets
+ amendChange(
+ changeId2,
+ "refs/for/master",
+ admin,
+ testRepo,
+ PushOneCommit.SUBJECT,
+ fileName2,
+ fileContent2Ps2)
+ .assertOkStatus();
+ assertThat(gApi.changes().id(changeId2).get().getCurrentRevision()._number).isEqualTo(2);
+
+ // Rebase the first patch set of the second change
+ gApi.changes().id(changeId2).revision(1).rebase();
+
+ // Second change should have 3 patch sets
+ assertThat(gApi.changes().id(changeId2).get().getCurrentRevision()._number).isEqualTo(3);
+
+ // ... and the committer and description should be correct
+ ChangeInfo info = gApi.changes().id(changeId2).get(CURRENT_REVISION, CURRENT_COMMIT);
+ GitPerson committer = info.getCurrentRevision().commit.committer;
+ assertThat(committer.name).isEqualTo(admin.fullName());
+ assertThat(committer.email).isEqualTo(admin.email());
+ String description = info.getCurrentRevision().description;
+ assertThat(description).isEqualTo("Rebase");
+
+ // ... and the file contents should match with patch set 1 based on change1
+ assertThat(gApi.changes().id(changeId2).current().file(fileName1).content().asString())
+ .isEqualTo(fileContent1);
+ assertThat(gApi.changes().id(changeId2).current().file(fileName2).content().asString())
+ .isEqualTo(fileContent2Ps1);
+ }
+ }
+
+ public static class RebaseViaChangeApi extends Rebase {
+ @Before
+ public void setUp() throws Exception {
+ init(id -> gApi.changes().id(id).rebase(), (id, in) -> gApi.changes().id(id).rebase(in));
+ }
+ }
+
+ public static class RebaseChain extends Base {
+ @Before
+ public void setUp() throws Exception {
+ init(
+ id -> {
+ @SuppressWarnings("unused")
+ Object unused = gApi.changes().id(id).rebaseChain();
+ },
+ (id, in) -> {
+ @SuppressWarnings("unused")
+ Object unused = gApi.changes().id(id).rebaseChain(in);
+ });
+ }
+
+ @Override
+ protected void verifyChangeIsUpToDate(PushOneCommit.Result r) {
+ ResourceConflictException thrown =
+ assertThrows(ResourceConflictException.class, () -> rebaseCall.call(r.getChangeId()));
+ assertThat(thrown).hasMessageThat().contains("The whole chain is already up to date.");
+ }
+
+ @Test
+ public void rebaseChain() throws Exception {
+ // Create changes with the following hierarchy:
+ // * HEAD
+ // * r (merged)
+ // * r2
+ // * r3
+ // * r4
+ // *r5
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+ PushOneCommit.Result r3 = createChange();
+ PushOneCommit.Result r4 = createChange();
+ PushOneCommit.Result r5 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Add an approval whose score should be copied on trivial rebase
+ gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+ gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+ // Rebase the chain through r4.
+ verifyRebaseChainResponse(
+ 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);
+
+ verifyChangeIsUpToDate(r2);
+ verifyChangeIsUpToDate(r3);
+ verifyChangeIsUpToDate(r4);
+
+ // r5 wasn't rebased.
+ assertThat(
+ gApi.changes()
+ .id(r5.getChangeId())
+ .get(CURRENT_REVISION)
+ .getCurrentRevision()
+ ._number)
+ .isEqualTo(1);
+
+ // Rebasing r5
+ verifyRebaseChainResponse(
+ gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r2, r3, r4, r5);
+
+ verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+ }
+
+ @Test
+ public void rebasePartlyOutdatedChain() throws Exception {
+ final String file = "modified_file.txt";
+ final String oldContent = "old content";
+ final String newContent = "new content";
+ // Create changes with the following revision hierarchy:
+ // * HEAD
+ // * r (merged)
+ // * r2
+ // * r3/1 r3/2
+ // * r4
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+ PushOneCommit.Result r3 = createChange("original patch-set", file, oldContent);
+ PushOneCommit.Result r4 = createChange();
+ gApi.changes()
+ .id(r3.getChangeId())
+ .edit()
+ .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+ gApi.changes().id(r3.getChangeId()).edit().publish();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // 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);
+
+ assertThat(gApi.changes().id(r3.getChangeId()).current().file(file).content().asString())
+ .isEqualTo(newContent);
+
+ verifyChangeIsUpToDate(r2);
+ verifyChangeIsUpToDate(r3);
+ verifyChangeIsUpToDate(r4);
+ }
+
+ @Test
+ public void rebaseChainWithMergedAncestor() throws Exception {
+ final String file = "modified_file.txt";
+ final String newContent = "new content";
+
+ // Create changes with the following hierarchy:
+ // * HEAD
+ // * r (merged)
+ // * r2.1 r2.2 (merged)
+ // * r3
+ // * r4
+ // *r5
+ PushOneCommit.Result r = createChange();
+ PushOneCommit.Result r2 = createChange();
+ PushOneCommit.Result r3 = createChange();
+ PushOneCommit.Result r4 = createChange();
+ PushOneCommit.Result r5 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+ testRepo.reset("HEAD~1");
+
+ // Create r2.2
+ gApi.changes()
+ .id(r2.getChangeId())
+ .edit()
+ .modifyFile(file, RawInputUtil.create(newContent.getBytes(UTF_8)));
+ gApi.changes().id(r2.getChangeId()).edit().publish();
+ // Approve and submit r2.2
+ revision = gApi.changes().id(r2.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Add an approval whose score should be copied on trivial rebase
+ gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+ // Rebase the chain through r4.
+ 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);
+
+ verifyChangeIsUpToDate(r2);
+ verifyChangeIsUpToDate(r3);
+ verifyChangeIsUpToDate(r4);
+
+ // r5 wasn't rebased.
+ assertThat(
+ gApi.changes()
+ .id(r5.getChangeId())
+ .get(CURRENT_REVISION)
+ .getCurrentRevision()
+ ._number)
+ .isEqualTo(1);
+
+ // Rebasing r5
+ verifyRebaseChainResponse(
+ gApi.changes().id(r5.getChangeId()).rebaseChain(), false, r3, r4, r5);
+
+ verifyRebaseForChange(r5.getChange().getId(), r4.getChange().getId(), false);
+ }
+
+ @Test
+ public void rebaseChainWithConflicts_conflictsForbidden() throws Exception {
+ 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",
+ "I0020020020020020020020020020020020020002");
+ PushOneCommit.Result r2 = push.to("refs/for/master");
+ r2.assertOkStatus();
+ PushOneCommit.Result r3 = createChange("refs/for/master");
+ r3.assertOkStatus();
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(r3.getChangeId()).rebaseChain());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Change %s could not be rebased due to a conflict during merge.\n\n"
+ + "merge conflict(s):\n%s",
+ r2.getChange().getId(), PushOneCommit.FILE_NAME));
+ }
+
+ @Test
+ public void rebaseChainWithConflicts_conflictsAllowed() throws Exception {
+ String patchSetSubject = "patch set change";
+ String patchSetContent = "patch set content";
+ String baseSubject = "base change";
+ String baseContent = "base content";
+
+ PushOneCommit.Result r1 = createChange(baseSubject, PushOneCommit.FILE_NAME, baseContent);
+ gApi.changes()
+ .id(r1.getChangeId())
+ .revision(r1.getCommit().name())
+ .review(ReviewInput.approve());
+ gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
+
+ testRepo.reset("HEAD~1");
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ patchSetSubject,
+ PushOneCommit.FILE_NAME,
+ patchSetContent);
+ PushOneCommit.Result r2 = push.to("refs/for/master");
+ r2.assertOkStatus();
+
+ String changeWithConflictId = r2.getChangeId();
+ RevCommit patchSet = r2.getCommit();
+ RevCommit base = r1.getCommit();
+ PushOneCommit.Result r3 = createChange("refs/for/master");
+ r3.assertOkStatus();
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.allowConflicts = true;
+ Response<RebaseChainInfo> res =
+ 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 childChangeInfo = rebaseChainInfo.rebasedChanges.get(1);
+ assertThat(childChangeInfo.changeId).isEqualTo(r3.getChangeId());
+ assertThat(childChangeInfo.containsGitConflicts).isTrue();
+ assertThat(childChangeInfo.workInProgress).isTrue();
+ }
+ assertThat(wipStateChangedListener.invoked).isTrue();
+ assertThat(wipStateChangedListener.wip).isTrue();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes()
+ .id(changeWithConflictId)
+ .get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.getCurrentRevision().commit.parents.get(0).commit)
+ .isEqualTo(base.name());
+
+ // 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(changeWithConflictId).current().file(PushOneCommit.FILE_NAME).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ String patchSetSha1 = abbreviateName(patchSet, 6);
+ String baseSha1 = abbreviateName(base, 6);
+ assertThat(fileContent)
+ .isEqualTo(
+ "<<<<<<< PATCH SET ("
+ + patchSetSha1
+ + " "
+ + patchSetSubject
+ + ")\n"
+ + patchSetContent
+ + "\n"
+ + "=======\n"
+ + baseContent
+ + "\n"
+ + ">>>>>>> BASE ("
+ + baseSha1
+ + " "
+ + baseSubject
+ + ")\n");
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeWithConflictId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message)
+ .isEqualTo(
+ "Patch Set 2: Patch Set 1 was rebased\n\n"
+ + "The following files contain Git conflicts:\n"
+ + "* "
+ + PushOneCommit.FILE_NAME
+ + "\n");
+ }
+
+ @Test
+ public void rebaseOntoMidChain() throws Exception {
+ // Create changes with the following hierarchy:
+ // * HEAD
+ // * r1
+ // * r2
+ // * r3
+ // * r4
+ PushOneCommit.Result r = createChange();
+ r.assertOkStatus();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+ r2.assertOkStatus();
+ PushOneCommit.Result r3 = createChange();
+ r3.assertOkStatus();
+ PushOneCommit.Result r4 = createChange();
+
+ RebaseInput ri = new RebaseInput();
+ ri.base = r3.getCommit().name();
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> rebaseCallWithInput.call(r4.getChangeId(), ri));
+ assertThat(thrown).hasMessageThat().contains("recursion not allowed");
+ }
+
+ @Test
+ public void rebaseChainActionEnabled() throws Exception {
+ Change.Id changeToBeTheNewBase = changeOperations.newChange().project(project).create();
+
+ Change.Id changeToBeRebased1 = changeOperations.newChange().project(project).create();
+ Change.Id changeToBeRebased2 =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .change(changeToBeRebased1)
+ .create();
+
+ // Approve and submit the change that will be the new base for the chain so that the chain is
+ // rebasable.
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ ChangeInfo changeInfo = gApi.changes().id(changeToBeRebased2.get()).get();
+ assertThat(changeInfo.actions).containsKey("rebase:chain");
+ ActionInfo rebaseActionInfo = changeInfo.actions.get("rebase:chain");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+ assertThat(rebaseActionInfo.enabledOptions)
+ .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+ }
+
+ @Test
+ public void rebaseChainWhenChecksRefExists() throws Exception {
+ // Create changes with the following hierarchy:
+ // * HEAD
+ // * r1
+ // * r2
+ // * r3
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+ PushOneCommit.Result r3 = createChange();
+
+ // Create checks ref
+ try (TestRepository<Repository> testRepo =
+ new TestRepository<>(repoManager.openRepository(project))) {
+ testRepo.update(
+ RefNames.changeRefPrefix(r2.getChange().getId()) + "checks",
+ testRepo.commit().message("Empty commit"));
+ }
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Add an approval whose score should be copied on trivial rebase
+ gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+ gApi.changes().id(r3.getChangeId()).current().review(ReviewInput.recommend());
+
+ // Rebase the chain through r3.
+ verifyRebaseChainResponse(gApi.changes().id(r3.getChangeId()).rebaseChain(), false, r2, r3);
+ }
+
+ @Test
+ public void testCountRebasesMetric() throws Exception {
+ // Create changes with the following hierarchy:
+ // * HEAD
+ // * r1
+ // * r2
+ // * r3
+ // * r4
+ PushOneCommit.Result r = createChange();
+ testRepo.reset("HEAD~1");
+ PushOneCommit.Result r2 = createChange();
+ PushOneCommit.Result r3 = createChange();
+ PushOneCommit.Result r4 = createChange();
+
+ // Approve and submit the first change
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ revision.review(ReviewInput.approve());
+ revision.submit();
+
+ // Rebase the chain.
+ testMetricMaker.reset();
+ verifyRebaseChainResponse(
+ gApi.changes().id(r4.getChangeId()).rebaseChain(), false, r2, r3, r4);
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false))
+ .isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+ }
+
+ private void verifyRebaseChainResponse(
+ Response<RebaseChainInfo> res,
+ boolean shouldHaveConflicts,
+ PushOneCommit.Result... changes) {
+ assertThat(res.statusCode()).isEqualTo(200);
+ RebaseChainInfo info = res.value();
+ assertThat(info.rebasedChanges.stream().map(c -> c._number).collect(Collectors.toList()))
+ .containsExactlyElementsIn(
+ Arrays.stream(changes)
+ .map(c -> c.getChange().getId().get())
+ .collect(Collectors.toList()))
+ .inOrder();
+ assertThat(info.containsGitConflicts).isEqualTo(shouldHaveConflicts ? true : null);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
new file mode 100644
index 0000000000..96e3e8e482
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -0,0 +1,1229 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.notedb.ChangeNoteFooters.FOOTER_REAL_USER;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.TestMetricMaker;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+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.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.extensions.api.changes.RebaseInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+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.RestApiException;
+import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.project.testing.TestLabels;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
+import java.util.Collection;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.junit.Test;
+
+/**
+ * Tests for the {@link com.google.gerrit.server.restapi.change.Rebase} REST endpoint with the
+ * {@link RebaseInput#onBehalfOfUploader} option being set.
+ *
+ * <p>Rebasing a chain on behalf of the uploader is covered by {@link
+ * RebaseChainOnBehalfOfUploaderIT}.
+ */
+public class RebaseOnBehalfOfUploaderIT extends AbstractDaemonTest {
+ @Inject private AccountOperations accountOperations;
+ @Inject private ChangeOperations changeOperations;
+ @Inject private GroupOperations groupOperations;
+ @Inject private ProjectOperations projectOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private ExtensionRegistry extensionRegistry;
+ @Inject private TestMetricMaker testMetricMaker;
+
+ @Test
+ public void cannotRebaseOnBehalfOfUploaderWithAllowConflicts() throws Exception {
+ Account.Id uploader = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ rebaseInput.allowConflicts = true;
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo("allow_conflicts and on_behalf_of_uploader are mutually exclusive");
+ }
+
+ @Test
+ public void cannotRebaseNonCurrentPatchSetOnBehalfOfUploader() throws Exception {
+ Account.Id uploader = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().owner(uploader).create();
+ changeOperations.change(changeId).newPatchset().create();
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.changes().id(changeId.get()).revision(1).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: non-current patch set cannot be rebased on behalf of the uploader",
+ changeId));
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfUploader_withRebasePermission() throws Exception {
+ testRebaseChangeOnBehalfOfUploader(
+ Permission.REBASE,
+ (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+ }
+
+ @Test
+ public void rebaseCurrentPatchSetOnBehalfOfUploader_withRebasePermission() throws Exception {
+ testRebaseChangeOnBehalfOfUploader(
+ Permission.REBASE,
+ (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).current().rebase(rebaseInput));
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfUploader_withSubmitPermission() throws Exception {
+ testRebaseChangeOnBehalfOfUploader(
+ Permission.SUBMIT,
+ (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+ }
+
+ @Test
+ public void rebaseCurrentPatchSetOnBehalfOfUploader_withSubmitPermission() throws Exception {
+ testRebaseChangeOnBehalfOfUploader(
+ Permission.SUBMIT,
+ (changeId, rebaseInput) -> gApi.changes().id(changeId.get()).current().rebase(rebaseInput));
+ }
+
+ private void testRebaseChangeOnBehalfOfUploader(String permissionToAllow, RebaseCall rebaseCall)
+ throws Exception {
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Grant permission to rebaser that is required to rebase on behalf of the uploader.
+ AccountGroup.UUID allowedGroup =
+ groupOperations.newGroup().name("can-" + permissionToAllow).addMember(rebaser).create();
+ allowPermission(permissionToAllow, allowedGroup);
+
+ // Block rebase and submit permission for uploader. For rebase on behalf of the uploader only
+ // the rebaser needs to have this permission, but not the uploader on whom's behalf the rebase
+ // is done.
+ AccountGroup.UUID cannotRebaseAndSubmitGroup =
+ groupOperations.newGroup().name("cannot-rebase").addMember(uploader).create();
+ blockPermission(Permission.REBASE, cannotRebaseAndSubmitGroup);
+ blockPermission(Permission.SUBMIT, cannotRebaseAndSubmitGroup);
+
+ // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+ // doesn't require the rebaser to have the push permission.
+ AccountGroup.UUID cannotUploadGroup =
+ groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+ blockPermission(Permission.PUSH, cannotUploadGroup);
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(changeOwner);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+
+ // Create a second patch set for the change that will be rebased so that the uploader is
+ // different to the change owner. This is to verify that being change owner doesn't matter for
+ // the user on whom's behalf the rebase is done.
+ // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+ // require the Forge Author and Forge Committer permission.
+ changeOperations
+ .change(changeToBeRebased)
+ .newPatchset()
+ .uploader(uploader)
+ .author(uploader)
+ .committer(uploader)
+ .create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+
+ TestRevisionCreatedListener testRevisionCreatedListener = new TestRevisionCreatedListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(testRevisionCreatedListener)) {
+ rebaseCall.call(changeToBeRebased, rebaseInput);
+
+ assertThat(testRevisionCreatedListener.revisionInfo.uploader._accountId)
+ .isEqualTo(uploader.get());
+ assertThat(testRevisionCreatedListener.revisionInfo.realUploader._accountId)
+ .isEqualTo(rebaser.get());
+ }
+
+ ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+ RevisionInfo currentRevisionInfo = changeInfo2.getCurrentRevision();
+ // The change had 2 patch sets before the rebase, now it should be 3
+ assertThat(currentRevisionInfo._number).isEqualTo(3);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ assertThat(currentRevisionInfo.commit.author.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+
+ // Verify that the rebaser was recorded as realUser in NoteDb.
+ Optional<FooterLine> realUserFooter =
+ projectOperations.project(project).getHead(RefNames.changeMetaRef(changeToBeRebased))
+ .getFooterLines().stream()
+ .filter(footerLine -> footerLine.matches(FOOTER_REAL_USER))
+ .findFirst();
+ assertThat(realUserFooter.map(FooterLine::getValue))
+ .hasValue(
+ String.format(
+ "%s <%s>",
+ ChangeNoteUtil.getAccountIdAsUsername(rebaser),
+ changeNoteUtil.getAccountIdAsEmailAddress(rebaser)));
+
+ // Verify the message that has been posted on the change.
+ Collection<ChangeMessageInfo> changeMessages = changeInfo2.messages;
+ // Before the rebase the change had 2 messages for the upload of the 2 patch sets. Rebase is
+ // expected to add another message.
+ assertThat(changeMessages).hasSize(3);
+ ChangeMessageInfo changeMessage = Iterables.getLast(changeMessages);
+ assertThat(changeMessage.message)
+ .isEqualTo(
+ "Patch Set 3: Patch Set 2 was rebased on behalf of "
+ + AccountTemplateUtil.getAccountTemplate(uploader));
+ assertThat(changeMessage.author._accountId).isEqualTo(uploader.get());
+ assertThat(changeMessage.realAuthor._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfUploaderMultipleTimesInARow() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ ChangeInfo changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+ RevisionInfo currentRevisionInfo = changeInfo2.getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+ // Create and submit another change so that we can rebase the change once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase2 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+ // Rebase the change once again on behalf of the uploader.
+ requestScopeOperations.setApiUser(rebaser);
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+ currentRevisionInfo = changeInfo2.getCurrentRevision();
+ // The change had 2 patch sets before the rebase, now it should be 3
+ assertThat(currentRevisionInfo._number).isEqualTo(3);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+
+ // Create and submit another change so that we can rebase the change once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase3 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ gApi.changes().id(changeToBeTheNewBase3.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase3.get()).current().submit();
+
+ // Rebase the change once again on behalf of the uploader, this time by another rebaser.
+ Account.Id rebaser2 = accountOperations.newAccount().create();
+ requestScopeOperations.setApiUser(rebaser2);
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ changeInfo2 = gApi.changes().id(changeToBeRebased.get()).get();
+ currentRevisionInfo = changeInfo2.getCurrentRevision();
+ // The change had 3 patch sets before the rebase, now it should be 4
+ assertThat(currentRevisionInfo._number).isEqualTo(4);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser2.get());
+ }
+
+ @Test
+ public void nonChangeOwnerWithoutSubmitAndRebasePermissionCannotRebaseOnBehalfOfUploader()
+ throws Exception {
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+
+ blockPermissionForAllUsers(Permission.REBASE);
+ blockPermissionForAllUsers(Permission.SUBMIT);
+
+ Account.Id rebaserId = accountOperations.newAccount().create();
+ requestScopeOperations.setApiUser(rebaserId);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ AuthException exception =
+ assertThrows(
+ AuthException.class, () -> gApi.changes().id(changeId.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase on behalf of uploader not permitted (change owners and users with the 'Submit'"
+ + " or 'Rebase' permission can rebase on behalf of the uploader)");
+ }
+
+ @Test
+ public void cannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoReadPermission()
+ throws Exception {
+ String uploaderEmail = "uploader@example.com";
+ testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+ uploaderEmail,
+ Permission.READ,
+ String.format("uploader %s cannot read change", uploaderEmail));
+ }
+
+ @Test
+ public void cannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPushPermission()
+ throws Exception {
+ String uploaderEmail = "uploader@example.com";
+ testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+ uploaderEmail,
+ Permission.PUSH,
+ String.format("uploader %s cannot add patch set", uploaderEmail));
+ }
+
+ private void testCannotRebaseChangeOnBehalfOfUploaderIfTheUploaderHasNoPermission(
+ String uploaderEmail, String permissionToBlock, String expectedErrorMessage)
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block the required permission for uploader. Without this permission it should not be possible
+ // to rebase the change on behalf of the uploader.
+ AccountGroup.UUID blockedGroup =
+ groupOperations.newGroup().name("cannot-" + permissionToBlock).addMember(uploader).create();
+ blockPermission(permissionToBlock, blockedGroup);
+
+ // Try to rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(String.format("change %s: %s", changeToBeRebased, expectedErrorMessage));
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfYourself() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+
+ // Create two changes both with the same parent. Forge the author of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the second change as uploader on behalf of the uploader
+ requestScopeOperations.setApiUser(uploader);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader).isNull();
+ }
+
+ @Test
+ public void cannotRebaseChangeOnBehalfOfYourselfWithoutPushPermission() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+
+ // Create two changes both with the same parent. Forge the author of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block push for uploader. For rebase on behalf of the uploader only
+ // the rebaser needs to have this permission, but not the uploader on whom's behalf the rebase
+ // is done.
+ AccountGroup.UUID cannotPushGroup =
+ groupOperations.newGroup().name("cannot-push").addMember(uploader).create();
+ blockPermission(Permission.PUSH, cannotPushGroup);
+
+ // Rebase the second change as uploader on behalf of the uploader
+ requestScopeOperations.setApiUser(uploader);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ AuthException exception =
+ assertThrows(
+ AuthException.class,
+ () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ "rebase not permitted (change owners and users with the 'Submit' or 'Rebase'"
+ + " permission can rebase if they have the 'Push' permission)");
+ }
+
+ @Test
+ public void rebaseChangeOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwner() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Forge the author of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(changeOwner);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+
+ // Create a second patch set for the change that will be rebased so that the uploader is
+ // different to the change owner.
+ // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+ // require the Forge Author and Forge Committer permission.
+ changeOperations
+ .change(changeToBeRebased)
+ .newPatchset()
+ .uploader(uploader)
+ .author(uploader)
+ .committer(uploader)
+ .create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant add patch set permission for uploader. Without the add patch set permission it is not
+ // possible to rebase the change on behalf of the uploader since the uploader cannot add a
+ // patch set to a change that is owned by another user.
+ AccountGroup.UUID canAddPatchSet =
+ groupOperations.newGroup().name("can-add-patch-set").addMember(uploader).create();
+ allowPermission(Permission.ADD_PATCH_SET, canAddPatchSet);
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ // The change had 2 patch set before the rebase, now it should be 3
+ assertThat(currentRevisionInfo._number).isEqualTo(3);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void
+ cannotRebaseChangeOnBehalfOfUploaderWhenUploaderIsNotTheChangeOwnerAndDoesntHaveAddPatchSetPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Forge the author of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(changeOwner);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+
+ // Create a second patch set for the change that will be rebased so that the uploader is
+ // different to the change owner.
+ // Set author and committer to the uploader so that rebasing on behalf of the uploader doesn't
+ // require the Forge Author and Forge Committer permission.
+ changeOperations
+ .change(changeToBeRebased)
+ .newPatchset()
+ .uploader(uploader)
+ .author(uploader)
+ .committer(uploader)
+ .create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block add patch set permission for uploader. Without the add patch set permission it should
+ // not possible to rebase the change on behalf of the uploader since the uploader cannot add a
+ // patch set to a change that is owned by another user.
+ AccountGroup.UUID cannotAddPatchSet =
+ groupOperations.newGroup().name("cannot-add-patch-set").addMember(uploader).create();
+ blockPermission(Permission.ADD_PATCH_SET, cannotAddPatchSet);
+
+ // Try to rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: uploader %s cannot add patch set", changeToBeRebased, uploaderEmail));
+ }
+
+ @Test
+ public void rebaseChangeWithForgedAuthorOnBehalfOfUploader() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String authorEmail = "author@example.com";
+ Account.Id author = accountOperations.newAccount().preferredEmail(authorEmail).create();
+ Account.Id uploader =
+ accountOperations.newAccount().preferredEmail("uploader@example.com").create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Forge the author of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).author(author).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant forge author permission for uploader. Without the forge author permission it is not
+ // possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID canForgeAuthor =
+ groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+ allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.author.email).isEqualTo(authorEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void
+ cannotRebaseChangeWithForgedAuthorOnBehalfOfUploaderIfTheUploaderHasNoForgeAuthorPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id author = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Forge the author of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).author(author).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Block forge author permission for uploader. Without the forge author permission it should not
+ // be possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID cannotForgeAuthor =
+ groupOperations.newGroup().name("cannot-forge-author").addMember(uploader).create();
+ blockPermission(Permission.FORGE_AUTHOR, cannotForgeAuthor);
+
+ // Try to rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: author of patch set 1 is forged and the uploader %s cannot forge author",
+ changeToBeRebased, uploaderEmail));
+ }
+
+ @Test
+ public void
+ rebaseChangeWithForgedCommitterOnBehalfOfUploaderDoesntRequireForgeCommitterPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id committer =
+ accountOperations.newAccount().preferredEmail("committer@example.com").create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Forge the committer of the change that will be
+ // rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).committer(committer).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.committer.email).isEqualTo(uploaderEmail);
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void rebaseChangeWithServerIdentOnBehalfOfUploader() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Use the server identity as the author of the
+ // change that will be rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .authorIdent(serverIdent.get())
+ .create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant forge author and forge server permission for uploader. Without these permissions it is
+ // not possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID canForgeAuthorAndForgeServer =
+ groupOperations
+ .newGroup()
+ .name("can-forge-author-and-forge-server")
+ .addMember(uploader)
+ .create();
+ allowPermission(Permission.FORGE_AUTHOR, canForgeAuthorAndForgeServer);
+ allowPermission(Permission.FORGE_SERVER, canForgeAuthorAndForgeServer);
+
+ // Rebase the second change on behalf of the uploader.
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ RevisionInfo currentRevisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ // The change had 1 patch set before the rebase, now it should be 2
+ assertThat(currentRevisionInfo._number).isEqualTo(2);
+ assertThat(currentRevisionInfo.commit.author.email)
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ assertThat(currentRevisionInfo.uploader._accountId).isEqualTo(uploader.get());
+ assertThat(currentRevisionInfo.realUploader._accountId).isEqualTo(rebaser.get());
+ }
+
+ @Test
+ public void
+ cannotRebaseChangeWithServerIdentOnBehalfOfUploaderIfTheUploaderHasNoForgeServerPermission()
+ throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent. Use the server identity as the author of the
+ // change that will be rebased.
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations
+ .newChange()
+ .project(project)
+ .owner(uploader)
+ .authorIdent(serverIdent.get())
+ .create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Grant forge author permission for uploader, but not the forge server permission. Without the
+ // forge server permission it is not possible to rebase the change on behalf of the uploader.
+ AccountGroup.UUID canForgeAuthor =
+ groupOperations.newGroup().name("can-forge-author").addMember(uploader).create();
+ allowPermission(Permission.FORGE_AUTHOR, canForgeAuthor);
+
+ // Try to rebase the second change on behalf of the uploader.
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput));
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "change %s: author of patch set 1 is the server identity and the uploader %s cannot forge"
+ + " the server identity",
+ changeToBeRebased, uploaderEmail));
+ }
+
+ @Test
+ public void rebaseActionEnabled_withRebasePermission() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+ testRebaseActionEnabled();
+ }
+
+ @Test
+ public void rebaseActionEnabled_withSubmitPermission() throws Exception {
+ allowPermissionToAllUsers(Permission.SUBMIT);
+ testRebaseActionEnabled();
+ }
+
+ private void testRebaseActionEnabled() throws Exception {
+ Account.Id uploader = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Block push permission for rebaser, as in contrast to rebase, rebase on behalf of the uploader
+ // doesn't require the rebaser to have the push permission.
+ AccountGroup.UUID cannotUploadGroup =
+ groupOperations.newGroup().name("cannot-upload").addMember(rebaser).create();
+ blockPermission(Permission.PUSH, cannotUploadGroup);
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ requestScopeOperations.setApiUser(rebaser);
+ RevisionInfo revisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ assertThat(revisionInfo.actions).containsKey("rebase");
+ ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+
+ // rebase is disabled because rebaser doesn't have the 'Push' permission and hence cannot create
+ // new patch sets
+ assertThat(rebaseActionInfo.enabledOptions).containsExactly("rebase_on_behalf_of_uploader");
+ }
+
+ @Test
+ public void rebaseActionEnabled_forChangeOwner() throws Exception {
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(changeOwner);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(changeOwner).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ requestScopeOperations.setApiUser(changeOwner);
+ RevisionInfo revisionInfo =
+ gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision();
+ assertThat(revisionInfo.actions).containsKey("rebase");
+ ActionInfo rebaseActionInfo = revisionInfo.actions.get("rebase");
+ assertThat(rebaseActionInfo.enabled).isTrue();
+
+ // rebase is enabled because change owner has the 'Push' permission and hence can create new
+ // patch sets
+ assertThat(rebaseActionInfo.enabledOptions)
+ .containsExactly("rebase", "rebase_on_behalf_of_uploader");
+ }
+
+ @UseLocalDisk
+ @Test
+ public void rebaseChangeOnBehalfOfUploaderRecordsUploaderInRefLog() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ try (Repository repo = repoManager.openRepository(project)) {
+ String changeMetaRef = RefNames.changeMetaRef(changeToBeRebased);
+ String patchSetRef = RefNames.patchSetRef(PatchSet.id(changeToBeRebased, 2));
+ createRefLogFileIfMissing(repo, changeMetaRef);
+ createRefLogFileIfMissing(repo, patchSetRef);
+
+ // Rebase the second change on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ // The ref log for the patch set ref records the impersonated user aka the uploader.
+ ReflogEntry patchSetRefLogEntry = repo.getReflogReader(patchSetRef).getLastEntry();
+ assertThat(patchSetRefLogEntry.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+
+ // The ref log for the change meta ref records the impersonated user aka the uploader.
+ ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+ assertThat(changeMetaRefLogEntry.getWho().getEmailAddress()).isEqualTo(uploaderEmail);
+ }
+ }
+
+ @Test
+ public void rebaserCanApproveChangeAfterRebasingOnBehalfOfUploader() throws Exception {
+ // Require a Code-Review approval from a non-uploader for submit.
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig()
+ .upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName(TestLabels.codeReview().getName())
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(
+ String.format(
+ "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ u.save();
+ }
+
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase it on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ // Approve the change as the rebaser.
+ allowVotingOnCodeReviewToAllUsers();
+ gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+
+ // The change is submittable because the approval is from a user (the rebaser) that is not the
+ // uploader.
+ assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isTrue();
+
+ // Create and submit another change so that we can rebase the change once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase2 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+ // Doing a normal rebase (not on behalf of the uploader) makes the rebaser the uploader. This
+ // makse the change non-submittable since the approval of the rebaser is ignored now (due to
+ // using 'user=non_uploader' in the submit requirement expression).
+ requestScopeOperations.setApiUser(rebaser);
+ rebaseInput.onBehalfOfUploader = false;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+ gApi.changes().id(changeToBeRebased.get()).current().review(ReviewInput.approve());
+ assertThat(gApi.changes().id(changeToBeRebased.get()).get().submittable).isFalse();
+ }
+
+ @Test
+ public void testSubmittedWithRebaserApprovalMetric() throws Exception {
+ allowVotingOnCodeReviewToAllUsers();
+
+ createVerifiedLabel();
+ allowVotingOnVerifiedToAllUsers();
+
+ // Require a Code-Review approval from a non-uploader for submit.
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig()
+ .upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName(TestLabels.verified().getName())
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(
+ String.format("label:%s=MAX", TestLabels.verified().getName())))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ u.getConfig()
+ .upsertSubmitRequirement(
+ SubmitRequirement.builder()
+ .setName(TestLabels.codeReview().getName())
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create(
+ String.format(
+ "label:%s=MAX,user=non_uploader", TestLabels.codeReview().getName())))
+ .setAllowOverrideInChildProjects(false)
+ .build());
+ u.save();
+ }
+
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ String uploaderEmail = "uploader@example.com";
+ Account.Id uploader = accountOperations.newAccount().preferredEmail(uploaderEmail).create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes()
+ .id(changeToBeTheNewBase.get())
+ .current()
+ .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+ testMetricMaker.reset();
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+ assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(0);
+
+ // Rebase it on behalf of the uploader
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+
+ // Approve the change as the rebaser.
+ gApi.changes()
+ .id(changeToBeRebased.get())
+ .current()
+ .review(ReviewInput.approve().label(TestLabels.verified().getName(), 1));
+
+ // The change is submittable because the approval is from a user (the rebaser) that is not the
+ // uploader.
+ allowPermissionToAllUsers(Permission.SUBMIT);
+ testMetricMaker.reset();
+ gApi.changes().id(changeToBeRebased.get()).current().submit();
+ assertThat(testMetricMaker.getCount("change/submitted_with_rebaser_approval")).isEqualTo(1);
+ }
+
+ @Test
+ public void testCountRebasesMetric() throws Exception {
+ allowPermissionToAllUsers(Permission.REBASE);
+
+ Account.Id uploader = accountOperations.newAccount().create();
+ Account.Id approver = admin.id();
+ Account.Id rebaser = accountOperations.newAccount().create();
+
+ // Create two changes both with the same parent
+ requestScopeOperations.setApiUser(uploader);
+ Change.Id changeToBeTheNewBase =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ Change.Id changeToBeRebased =
+ changeOperations.newChange().project(project).owner(uploader).create();
+
+ // Approve and submit the change that will be the new base for the change that will be rebased.
+ requestScopeOperations.setApiUser(approver);
+ gApi.changes().id(changeToBeTheNewBase.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase.get()).current().submit();
+
+ // Rebase it on behalf of the uploader
+ testMetricMaker.reset();
+ requestScopeOperations.setApiUser(rebaser);
+ RebaseInput rebaseInput = new RebaseInput();
+ rebaseInput.onBehalfOfUploader = true;
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+
+ // Create and submit another change so that we can rebase the change once again.
+ requestScopeOperations.setApiUser(approver);
+ Change.Id changeToBeTheNewBase2 =
+ changeOperations.newChange().project(project).owner(uploader).create();
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(changeToBeTheNewBase2.get()).current().submit();
+
+ // Rebase the change once again, this time as the uploader.
+ // If the uploader sets on_behalf_of_uploader = true, the flag is ignored and a normal rebase is
+ // done, hence the metric should count this as a a rebase with on_behalf_of_uploader = false.
+ requestScopeOperations.setApiUser(uploader);
+ testMetricMaker.reset();
+ gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput);
+ // field1 is on_behalf_of_uploader, field2 is rebase_chain, field3 is allow_conflicts
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, false, false)).isEqualTo(1);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, false, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", false, true, false)).isEqualTo(0);
+ assertThat(testMetricMaker.getCount("change/count_rebases", true, true, false)).isEqualTo(0);
+ }
+
+ private void allowPermissionToAllUsers(String permission) {
+ allowPermission(permission, REGISTERED_USERS);
+ }
+
+ private void allowPermission(String permission, AccountGroup.UUID groupUuid) {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(permission).ref("refs/*").group(groupUuid))
+ .update();
+ }
+
+ private void allowVotingOnCodeReviewToAllUsers() {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(TestLabels.codeReview().getName())
+ .ref("refs/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .update();
+ }
+
+ private void createVerifiedLabel() throws Exception {
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ LabelType.Builder verified =
+ labelBuilder(
+ LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"))
+ .setCopyCondition("is:MIN");
+ u.getConfig().upsertLabelType(verified.build());
+ u.save();
+ }
+ }
+
+ private void allowVotingOnVerifiedToAllUsers() {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabel(TestLabels.verified().getName())
+ .ref("refs/*")
+ .group(REGISTERED_USERS)
+ .range(-1, 1))
+ .update();
+ }
+
+ private void blockPermissionForAllUsers(String permission) {
+ blockPermission(permission, REGISTERED_USERS);
+ }
+
+ private void blockPermission(String permission, AccountGroup.UUID groupUuid) {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(permission).ref("refs/*").group(groupUuid))
+ .update();
+ }
+
+ @FunctionalInterface
+ private interface RebaseCall {
+ void call(Change.Id changeId, RebaseInput rebaseInput) throws RestApiException;
+ }
+
+ private static class TestRevisionCreatedListener implements RevisionCreatedListener {
+ public RevisionInfo revisionInfo;
+
+ @Override
+ public void onRevisionCreated(Event event) {
+ revisionInfo = event.getRevision();
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 9de33be539..4855ba4b0b 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -26,14 +26,15 @@ 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.TestAccount;
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -62,9 +63,9 @@ import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.regex.Pattern;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.RefSpec;
import org.junit.Test;
@@ -73,63 +74,7 @@ public class RevertIT extends AbstractDaemonTest {
@Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ExtensionRegistry extensionRegistry;
-
- @Test
- public void pureRevertFactBlocksSubmissionOfNonReverts() throws Exception {
- addPureRevertSubmitRule();
-
- // Create a change that is not a revert of another change
- PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
- approve(r1.getChangeId());
-
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(r1.getChangeId()).current().submit());
- assertThat(thrown)
- .hasMessageThat()
- .contains("Failed to submit 1 change due to the following problems");
- assertThat(thrown)
- .hasMessageThat()
- .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
- }
-
- @Test
- public void pureRevertFactBlocksSubmissionOfNonPureReverts() throws Exception {
- PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
- merge(r1);
-
- addPureRevertSubmitRule();
-
- // Create a revert and push a content change
- String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
- amendChange(revertId);
- approve(revertId);
-
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class, () -> gApi.changes().id(revertId).current().submit());
- assertThat(thrown)
- .hasMessageThat()
- .contains("Failed to submit 1 change due to the following problems");
- assertThat(thrown)
- .hasMessageThat()
- .contains("submit requirement 'Is-Pure-Revert' is unsatisfied.");
- }
-
- @Test
- public void pureRevertFactAllowsSubmissionOfPureReverts() throws Exception {
- // Create a change that we can later revert
- PushOneCommit.Result r1 = pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
- merge(r1);
-
- addPureRevertSubmitRule();
-
- // Create a revert and submit it
- String revertId = gApi.changes().id(r1.getChangeId()).revert().get().changeId;
- approve(revertId);
- gApi.changes().id(revertId).current().submit();
- }
+ @Inject private AccountOperations accountOperations;
@Test
public void pureRevertReturnsTrueForPureRevert() throws Exception {
@@ -267,10 +212,19 @@ public class RevertIT extends AbstractDaemonTest {
PushOneCommit.Result r = createChange();
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
-
RevertInput in = createWipRevertInput();
+
ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(in).get();
+
assertThat(revertChange.workInProgress).isTrue();
+ // expected messages on source change:
+ // 1. Uploaded patch set 1.
+ // 2. Patch Set 1: Code-Review+2
+ // 3. Change has been successfully merged by Administrator
+ // No "reverted" message is expected.
+ List<ChangeMessageInfo> sourceMessages =
+ new ArrayList<>(gApi.changes().id(r.getChangeId()).get().messages);
+ assertThat(sourceMessages).hasSize(3);
}
@Test
@@ -365,14 +319,14 @@ public class RevertIT extends AbstractDaemonTest {
}
@Test
- public void revertNotificationsSupressedOnWip() throws Exception {
+ public void revertNotificationsSuppressedOnWip() throws Exception {
PushOneCommit.Result r = createChange();
gApi.changes().id(r.getChangeId()).addReviewer(user.email());
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
sender.clear();
- // If notify input not specified, the endpoint overrides it to OWNER
+ // If notify input not specified, the endpoint overrides it to NONE
RevertInput revertInput = createWipRevertInput();
revertInput.notify = null;
gApi.changes().id(r.getChangeId()).revert(revertInput).get();
@@ -424,6 +378,73 @@ public class RevertIT extends AbstractDaemonTest {
}
@Test
+ public void revertAllowedIfUserAccountIsInactive() throws Exception {
+ PushOneCommit.Result r = createChange();
+ ReviewInput in = ReviewInput.approve();
+ in.reviewer(user.email());
+ in.reviewer(accountCreator.user2().email(), ReviewerState.CC, true);
+ // Add user as reviewer that will create the revert
+ in.reviewer(accountCreator.admin2().email());
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+ accountOperations.account(user.id()).forUpdate().inactive().update();
+ accountOperations.account(accountCreator.user2().id()).forUpdate().inactive().update();
+
+ requestScopeOperations.setApiUser(accountCreator.admin2().id());
+ Map<ReviewerState, Collection<AccountInfo>> result =
+ gApi.changes().id(r.getChangeId()).revert().get().reviewers;
+ assertThat(result).containsKey(ReviewerState.REVIEWER);
+
+ // The active user should be preserved as reviewer. For inactive user this test doesn't
+ // fix specific behavior - they can be either preserved or removed depending on the
+ // implementation.
+ List<Integer> reviewers =
+ result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
+ assertThat(reviewers).contains(admin.id().get());
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void revertWithNonVisibleUsers() throws Exception {
+ // Define readable names for the users we use in this test.
+ TestAccount reverter = user;
+ TestAccount changeOwner = admin; // must be admin, since admin cloned testRepo
+ TestAccount reviewer = accountCreator.user2();
+ TestAccount cc =
+ accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+ // Check that the reverter can neither see the changeOwner, the reviewer nor the cc.
+ requestScopeOperations.setApiUser(reverter.id());
+ assertThatAccountIsNotVisible(changeOwner, reviewer, cc);
+
+ // Create the change.
+ requestScopeOperations.setApiUser(changeOwner.id());
+ PushOneCommit.Result r = createChange();
+
+ // Add reviewer and cc.
+ ReviewInput reviewerInput = ReviewInput.approve();
+ reviewerInput.reviewer(reviewer.email());
+ reviewerInput.cc(cc.email());
+ gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+ // Approve and submit the change.
+ gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+ gApi.changes().id(r.getChangeId()).current().submit();
+
+ // Revert the change.
+ requestScopeOperations.setApiUser(reverter.id());
+ String revertChangeId = gApi.changes().id(r.getChangeId()).revert().get().id;
+
+ // Revert doesn't check the reviewer/CC visibility. Since the reverter can see the reverted
+ // change, they can also see its reviewers/CCs. This means preserving them on the revert change
+ // doesn't expose their account existence and it's OK to keep them even if their accounts are
+ // not visible to the reverter.
+ assertReviewers(revertChangeId, changeOwner, reviewer);
+ assertCcs(revertChangeId, cc);
+ }
+
+ @Test
@TestProjectInput(createEmptyCommit = false)
public void revertInitialCommit() throws Exception {
PushOneCommit.Result r = createChange();
@@ -765,8 +786,7 @@ public class RevertIT extends AbstractDaemonTest {
gApi.changes().id(secondResult).current().submit();
sender.clear();
- RevertInput revertInput = new RevertInput();
- revertInput.workInProgress = true;
+ RevertInput revertInput = createWipRevertInput();
revertInput.notify = NotifyHandling.NONE;
gApi.changes().id(secondResult).revertSubmission(revertInput);
assertThat(sender.getMessages()).isEmpty();
@@ -788,9 +808,14 @@ public class RevertIT extends AbstractDaemonTest {
// If notify handling is specified, it will be used by the API
RevertInput revertInput = createWipRevertInput();
revertInput.notify = NotifyHandling.ALL;
- gApi.changes().id(changeId2).revertSubmission(revertInput);
+ RevertSubmissionInfo revertChanges = gApi.changes().id(changeId2).revertSubmission(revertInput);
- assertThat(sender.getMessages()).hasSize(4);
+ assertThat(revertChanges.revertChanges).hasSize(2);
+ assertThat(sender.getMessages()).hasSize(2);
+ assertThat(sender.getMessages(revertChanges.revertChanges.get(0).changeId, "newchange"))
+ .hasSize(1);
+ assertThat(sender.getMessages(revertChanges.revertChanges.get(1).changeId, "newchange"))
+ .hasSize(1);
}
@Test
@@ -801,17 +826,23 @@ public class RevertIT extends AbstractDaemonTest {
String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
approve(changeId2);
gApi.changes().id(changeId2).addReviewer(user.email());
-
gApi.changes().id(changeId2).current().submit();
-
sender.clear();
-
RevertInput revertInput = createWipRevertInput();
+
RevertSubmissionInfo revertSubmissionInfo =
gApi.changes().id(changeId2).revertSubmission(revertInput);
assertThat(revertSubmissionInfo.revertChanges.stream().allMatch(r -> r.workInProgress))
.isTrue();
+
+ // expected messages on source change:
+ // 1. Uploaded patch set 1.
+ // 2. Patch Set 1: Code-Review+2
+ // 3. Change has been successfully merged by Administrator
+ // No "reverted" message is expected.
+ assertThat(gApi.changes().id(changeId1).get().messages).hasSize(3);
+ assertThat(gApi.changes().id(changeId2).get().messages).hasSize(3);
}
@Test
@@ -1218,10 +1249,38 @@ public class RevertIT extends AbstractDaemonTest {
.distinct()
.count())
.isEqualTo(1);
+
+ // Size
List<ChangeApi> revertChanges = getChangeApis(revertSubmissionInfo);
+ assertThat(revertChanges).hasSize(3);
+ assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
+
+ // Contents
assertThat(revertChanges.get(0).current().files().get("c.txt").linesDeleted).isEqualTo(1);
assertThat(revertChanges.get(1).current().files().get("a.txt").linesDeleted).isEqualTo(1);
assertThat(revertChanges.get(2).current().files().get("b.txt").linesDeleted).isEqualTo(1);
+
+ // Commit message
+ assertThat(revertChanges.get(0).current().commit(false).message)
+ .matches(
+ Pattern.compile(
+ "Revert \"first change\"\n\n"
+ + "This reverts commit [a-f0-9]+\\.\n\n"
+ + "Change-Id: I[a-f0-9]+\n"));
+ assertThat(revertChanges.get(1).current().commit(false).message)
+ .matches(
+ Pattern.compile(
+ "Revert \"second change\"\n\n"
+ + "This reverts commit [a-f0-9]+\\.\n\n"
+ + "Change-Id: I[a-f0-9]+\n"));
+ assertThat(revertChanges.get(2).current().commit(false).message)
+ .matches(
+ Pattern.compile(
+ "Revert \"third change\"\n\n"
+ + "This reverts commit [a-f0-9]+\\.\n\n"
+ + "Change-Id: I[a-f0-9]+\n"));
+
+ // Relationships
String sha1FirstChange = resultCommits.get(0).getCommit().getName();
String sha1ThirdChange = resultCommits.get(2).getCommit().getName();
String sha1SecondRevert = revertChanges.get(2).current().commit(false).commit;
@@ -1231,9 +1290,6 @@ public class RevertIT extends AbstractDaemonTest {
.isEqualTo(sha1ThirdChange);
assertThat(revertChanges.get(1).current().commit(false).parents.get(0).commit)
.isEqualTo(sha1SecondRevert);
-
- assertThat(revertChanges).hasSize(3);
- assertThat(gApi.changes().id(revertChanges.get(1).id()).current().related().changes).hasSize(2);
}
@Test
@@ -1448,34 +1504,6 @@ public class RevertIT extends AbstractDaemonTest {
return result;
}
- private void addPureRevertSubmitRule() throws Exception {
- modifySubmitRules(
- "submit_rule(submit(R)) :- \n"
- + "gerrit:pure_revert(1), \n"
- + "!,"
- + "gerrit:uploader(U), \n"
- + "R = label('Is-Pure-Revert', ok(U)).\n"
- + "submit_rule(submit(R)) :- \n"
- + "gerrit:pure_revert(U), \n"
- + "U \\= 1,"
- + "R = label('Is-Pure-Revert', need(_)). \n\n");
- }
-
- private void modifySubmitRules(String newContent) throws Exception {
- try (Repository repo = repoManager.openRepository(project);
- TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
- testRepo
- .branch(RefNames.REFS_CONFIG)
- .commit()
- .author(admin.newIdent())
- .committer(admin.newIdent())
- .add("rules.pl", newContent)
- .message("Modify rules.pl")
- .create();
- }
- projectCache.evict(project);
- }
-
private List<ChangeApi> getChangeApis(RevertSubmissionInfo revertSubmissionInfo)
throws Exception {
List<ChangeApi> results = new ArrayList<>();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
index 242c2784ef..ab2f358bb2 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -33,6 +33,7 @@ import com.google.gerrit.acceptance.ExtensionRegistry;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.UseTimezone;
import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
import com.google.gerrit.acceptance.testsuite.change.IndexOperations;
@@ -424,6 +425,26 @@ public class SubmitRequirementIT extends AbstractDaemonTest {
}
@Test
+ public void checkSubmitRequirement_verifiesUploader() throws Exception {
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ voteLabel(changeId, "Code-Review", 2);
+ TestAccount anotherUser = accountCreator.createValid("anotherUser");
+
+ SubmitRequirementInput in =
+ createSubmitRequirementInput(
+ "Foo", /* submittabilityExpression= */ "uploader:" + anotherUser.id());
+ SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+ assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+ in =
+ createSubmitRequirementInput(
+ "Foo", /* submittabilityExpression= */ "uploader:" + r.getChange().change().getOwner());
+ result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+ assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+ }
+
+ @Test
public void submitRequirement_withLabelEqualsMax() throws Exception {
configSubmitRequirement(
project,
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
index a44373985a..2fe7038214 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -17,26 +17,45 @@ package com.google.gerrit.acceptance.api.change;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.codeReview;
import static com.google.gerrit.server.project.testing.TestLabels.label;
import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.UseTimezone;
import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
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.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.entities.SubmitRequirementExpressionResult;
import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Before;
import org.junit.Test;
@@ -198,6 +217,293 @@ public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
"distinctvoters:\"[Code-Review,Custom-Label,Custom-Label2],value=1,count>2\"", c1);
}
+ @Test
+ public void hasSubmoduleUpdate_withSubmoduleChangeInParent1() throws Exception {
+ ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+ PushOneCommit.Result r1 = createGitSubmoduleCommit("refs/for/master");
+ testRepo.reset(initial);
+ PushOneCommit.Result r2 = createNormalCommit("refs/for/master", "file1");
+ PushOneCommit.Result merge =
+ createMergeCommitChange(
+ "refs/for/master",
+ r1.getCommit(),
+ r2.getCommit(),
+ mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+ assertNotMatching("has:submodule-update,base=1", merge.getChange().getId());
+ assertMatching("has:submodule-update,base=2", merge.getChange().getId());
+ assertNotMatching("has:submodule-update", merge.getChange().getId());
+ }
+
+ @Test
+ public void hasSubmoduleUpdate_withSubmoduleChangeInParent2() throws Exception {
+ ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+ PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+ testRepo.reset(initial);
+ PushOneCommit.Result r2 = createGitSubmoduleCommit("refs/for/master");
+ PushOneCommit.Result merge =
+ createMergeCommitChange(
+ "refs/for/master",
+ r1.getCommit(),
+ r2.getCommit(),
+ mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+ assertMatching("has:submodule-update,base=1", merge.getChange().getId());
+ assertNotMatching("has:submodule-update,base=2", merge.getChange().getId());
+ assertNotMatching("has:submodule-update", merge.getChange().getId());
+ }
+
+ @Test
+ public void hasSubmoduleUpdate_withoutSubmoduleChange_doesNotMatch() throws Exception {
+ ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+ PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+ testRepo.reset(initial);
+ PushOneCommit.Result r2 = createNormalCommit("refs/for/master", "file2");
+ PushOneCommit.Result merge =
+ createMergeCommitChange(
+ "refs/for/master",
+ r1.getCommit(),
+ r2.getCommit(),
+ mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+ assertNotMatching("has:submodule-update,base=1", merge.getChange().getId());
+ assertNotMatching("has:submodule-update,base=2", merge.getChange().getId());
+ assertNotMatching("has:submodule-update", merge.getChange().getId());
+ }
+
+ @Test
+ public void hasSubmoduleUpdate_withBaseParamGreaterThanParentCount_doesNotMatch()
+ throws Exception {
+ ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+ PushOneCommit.Result r1 = createNormalCommit("refs/for/master", "file1");
+ testRepo.reset(initial);
+ PushOneCommit.Result r2 = createGitSubmoduleCommit("refs/for/master");
+ PushOneCommit.Result merge =
+ createMergeCommitChange(
+ "refs/for/master",
+ r1.getCommit(),
+ r2.getCommit(),
+ mergeAndGetTreeId(r1.getCommit(), r2.getCommit()));
+
+ assertNotMatching("has:submodule-update,base=3", merge.getChange().getId());
+ }
+
+ @Test
+ public void hasSubmoduleUpdate_withWrongArgs_throws() {
+ assertError(
+ "has:submodule-update,base=xyz",
+ changeOperations.newChange().project(project).create(),
+ "failed to parse the parent number xyz: For input string: \"xyz\"");
+ assertError(
+ "has:submodule-update,base=1,arg=foo",
+ changeOperations.newChange().project(project).create(),
+ "wrong number of arguments for the has:submodule-update operator");
+ assertError(
+ "has:submodule-update,base",
+ changeOperations.newChange().project(project).create(),
+ "unexpected base value format");
+ }
+
+ @Test
+ public void nonContributorLabelVote_match() throws Exception {
+ requestScopeOperations.setApiUser(user.id());
+ TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, user);
+ PushOneCommit.Result r1 =
+ pushFactory
+ .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+ .to("refs/for/master");
+
+ Change.Id cId = r1.getChange().getId();
+
+ ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+
+ // Assert on uploader, committer and author
+ assertUploader(changeInfo, user.email());
+ assertCommitter(changeInfo, user.email());
+ assertAuthor(changeInfo, user.email());
+
+ // Vote from admin (a.k.a. non uploader/committer/author) matches
+ requestScopeOperations.setApiUser(admin.id());
+ approve(cId.toString());
+ assertMatching("label:Code-Review=+2,user=non_contributor", cId);
+ // Also make sure magic label votes and > operator work
+ assertMatching("label:Code-Review=MAX,user=non_contributor", cId);
+ assertMatching("label:Code-Review>+1,user=non_contributor", cId);
+ }
+
+ @Test
+ public void nonContributorLabelVote_voteFromUploader_doesNotMatch() throws Exception {
+ PushOneCommit.Result r1 = createNormalCommit(user.newIdent(), "refs/for/master", "file1");
+
+ ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+ assertUploader(changeInfo, admin.email());
+
+ // Vote from admin (a.k.a. uploader) does not match
+ requestScopeOperations.setApiUser(admin.id());
+ approve(r1.getChangeId());
+ assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+ }
+
+ @Test
+ @Sandboxed
+ public void nonContributorLabelVote_voteFromAuthor_doesNotMatch() throws Exception {
+ Account.Id authorId =
+ accountOperations
+ .newAccount()
+ .fullname("author")
+ .preferredEmail("authoremail@example.com")
+ .create();
+ Account.Id committerId =
+ accountOperations
+ .newAccount()
+ .fullname("committer")
+ .preferredEmail("committeremail@example.com")
+ .create();
+
+ Change.Id changeId =
+ changeOperations.newChange().author(authorId).committer(committerId).create();
+ ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
+ assertAuthor(changeInfo, "authoremail@example.com");
+
+ allowLabelPermission(
+ codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+ // Vote from author does not match
+ requestScopeOperations.setApiUser(authorId);
+ approve(changeId.toString());
+ assertNotMatching("label:Code-Review=+2,user=non_contributor", changeId);
+ }
+
+ @Test
+ public void nonContributorLabelVote_voteFromCommitter_doesNotMatch() throws Exception {
+ Account.Id authorId =
+ accountOperations
+ .newAccount()
+ .fullname("author")
+ .preferredEmail("authoremail@example.com")
+ .create();
+ Account.Id committerId =
+ accountOperations
+ .newAccount()
+ .fullname("committer")
+ .preferredEmail("committeremail@example.com")
+ .create();
+
+ Change.Id changeId =
+ changeOperations.newChange().author(authorId).committer(committerId).create();
+ ChangeInfo changeInfo = gApi.changes().id(changeId.get()).get();
+ assertCommitter(changeInfo, "committeremail@example.com");
+
+ allowLabelPermission(
+ codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+ // Vote from committer does not match
+ requestScopeOperations.setApiUser(committerId);
+ approve(changeId.toString());
+ assertNotMatching("label:Code-Review=+2,user=non_contributor", changeId);
+ }
+
+ @Test
+ public void nonContributorLabelVote_uploaderAndAuthorDifferent() throws Exception {
+ TestRepository<InMemoryRepository> clonedRepo = cloneProject(project, admin);
+ PushOneCommit.Result r1 =
+ pushFactory
+ .create(user.newIdent(), clonedRepo, "Subject", "file.txt", "text")
+ .to("refs/for/master");
+
+ requestScopeOperations.setApiUser(admin.id());
+ ChangeInfo changeInfo = gApi.changes().id(r1.getChangeId()).get();
+ assertUploader(changeInfo, admin.email());
+ assertAuthor(changeInfo, user.email());
+
+ allowLabelPermission(
+ codeReview().getName(), RefNames.REFS_HEADS + "*", REGISTERED_USERS, -2, +2);
+
+ // Vote from admin (a.k.a. uploader) does not match
+ requestScopeOperations.setApiUser(user.id());
+ approve(r1.getChangeId());
+ assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+
+ // Vote from user (a.k.a. author) does not match
+ requestScopeOperations.setApiUser(admin.id());
+ approve(r1.getChangeId());
+ assertNotMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+
+ // Vote from user2 (a.k.a. non-author and non-uploader) matches
+ TestAccount user2 = accountCreator.create();
+ requestScopeOperations.setApiUser(user2.id());
+ approve(r1.getChangeId());
+ assertMatching("label:Code-Review=+2,user=non_contributor", r1.getChange().getId());
+ }
+
+ private static void assertUploader(ChangeInfo changeInfo, String email) {
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).uploader.email)
+ .isEqualTo(email);
+ }
+
+ private static void assertCommitter(ChangeInfo changeInfo, String email) {
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.committer.email)
+ .isEqualTo(email);
+ }
+
+ private static void assertAuthor(ChangeInfo changeInfo, String email) {
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.author.email)
+ .isEqualTo(email);
+ }
+
+ private void allowLabelPermission(
+ String labelName, String refPattern, AccountGroup.UUID group, int minVote, int maxVote) {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allowLabel(labelName).ref(refPattern).group(group).range(minVote, maxVote))
+ .update();
+ }
+
+ private PushOneCommit.Result createGitSubmoduleCommit(String ref) throws Exception {
+ return pushFactory
+ .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of())
+ .addGitSubmodule(
+ "modules/module-a", ObjectId.fromString("19f1787342cb15d7e82a762f6b494e91ccb4dd34"))
+ .to(ref);
+ }
+
+ private PushOneCommit.Result createNormalCommit(
+ PersonIdent personIdent, String ref, String fileName) throws Exception {
+ return pushFactory
+ .create(personIdent, testRepo, "subject", ImmutableMap.of(fileName, fileName))
+ .to(ref);
+ }
+
+ private PushOneCommit.Result createNormalCommit(String ref, String fileName) throws Exception {
+ return pushFactory
+ .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of(fileName, fileName))
+ .to(ref);
+ }
+
+ private PushOneCommit.Result createMergeCommitChange(
+ String ref, RevCommit parent1, RevCommit parent2, @Nullable ObjectId treeId)
+ throws Exception {
+ PushOneCommit m =
+ pushFactory
+ .create(admin.newIdent(), testRepo)
+ .setParents(ImmutableList.of(parent1, parent2));
+ if (treeId != null) {
+ m.setTopLevelTreeId(treeId);
+ }
+ PushOneCommit.Result result = m.to(ref);
+ result.assertOkStatus();
+ return result;
+ }
+
+ private ObjectId mergeAndGetTreeId(RevCommit c1, RevCommit c2) throws Exception {
+ ThreeWayMerger threeWayMerger = MergeStrategy.RESOLVE.newMerger(repo(), true);
+ threeWayMerger.setBase(c1.getParent(0));
+ boolean mergeResult = threeWayMerger.merge(c1, c2);
+ assertThat(mergeResult).isTrue();
+ return threeWayMerger.getResultTreeId();
+ }
+
private void assertMatching(String requirement, Change.Id change) {
assertThat(evaluate(requirement, change).status())
.isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
@@ -208,6 +514,12 @@ public class SubmitRequirementPredicateIT extends AbstractDaemonTest {
.isEqualTo(SubmitRequirementExpressionResult.Status.FAIL);
}
+ private void assertError(String requirement, Change.Id change, String errorMessage) {
+ SubmitRequirementExpressionResult result = evaluate(requirement, change);
+ assertThat(result.status()).isEqualTo(SubmitRequirementExpressionResult.Status.ERROR);
+ assertThat(result.errorMessage().get()).isEqualTo(errorMessage);
+ }
+
private SubmitRequirementExpressionResult evaluate(String requirement, Change.Id change) {
ChangeData cd = changeDataFactory.create(project, change);
return submitRequirementsEvaluator.evaluateExpression(
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
index a9afcbc80c..308e4e05bd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitTypeRuleIT.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.extensions.client.SubmitType.MERGE_ALWAYS;
import static com.google.gerrit.extensions.client.SubmitType.MERGE_IF_NECESSARY;
import static com.google.gerrit.extensions.client.SubmitType.REBASE_ALWAYS;
import static com.google.gerrit.extensions.client.SubmitType.REBASE_IF_NECESSARY;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.common.collect.ImmutableList;
@@ -60,8 +61,6 @@ public class SubmitTypeRuleIT extends AbstractDaemonTest {
}
private class RulesPl extends VersionedMetaData {
- private static final String FILENAME = "rules.pl";
-
private String rule;
@Override
@@ -71,7 +70,7 @@ public class SubmitTypeRuleIT extends AbstractDaemonTest {
@Override
protected void onLoad() throws IOException, ConfigInvalidException {
- rule = readUTF8(FILENAME);
+ rule = readUTF8(RULES_PL_FILE);
}
@Override
@@ -84,7 +83,7 @@ public class SubmitTypeRuleIT extends AbstractDaemonTest {
throw new ConfigInvalidException("Invalid submit type rule", e);
}
- saveUTF8(FILENAME, rule);
+ saveUTF8(RULES_PL_FILE, rule);
return true;
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index ece46c5baa..98ed56c31a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -14,12 +14,14 @@
package com.google.gerrit.acceptance.api.group;
+import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.server.group.testing.InternalGroupSubject.internalGroups;
-import static com.google.gerrit.truth.ListSubject.assertThat;
import static com.google.gerrit.truth.OptionalSubject.assertThat;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
import com.google.gerrit.exceptions.NoSuchGroupException;
@@ -34,13 +36,12 @@ import com.google.gerrit.server.group.testing.InternalGroupSubject;
import com.google.gerrit.server.index.group.GroupIndexer;
import com.google.gerrit.server.query.group.InternalGroupQuery;
import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.truth.ListSubject;
import com.google.gerrit.truth.OptionalSubject;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.io.IOException;
-import java.util.List;
import java.util.Optional;
+import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.junit.Rule;
import org.junit.Test;
@@ -53,6 +54,7 @@ public class GroupIndexerIT {
@Inject private GroupCache groupCache;
@Inject @ServerInitiated private GroupsUpdate groupsUpdate;
@Inject private Provider<InternalGroupQuery> groupQueryProvider;
+ @Inject private GroupOperations groupOperations;
@Test
public void indexingUpdatesTheIndex() throws Exception {
@@ -66,8 +68,10 @@ public class GroupIndexerIT {
groupIndexer.index(groupUuid);
- List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
- assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+ Set<AccountGroup.UUID> parentGroups =
+ groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
+ assertThat(parentGroups).hasSize(1);
+ assertThat(parentGroups).containsExactly(groupUuid);
}
@Test
@@ -83,8 +87,10 @@ public class GroupIndexerIT {
groupIndexer.index(groupUuid);
- List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
- assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+ Set<AccountGroup.UUID> parentGroups =
+ groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
+ assertThat(parentGroups).hasSize(1);
+ assertThat(parentGroups).containsExactly(groupUuid);
}
@Test
@@ -111,8 +117,10 @@ public class GroupIndexerIT {
groupIndexer.reindexIfStale(groupUuid);
- List<InternalGroup> parentGroups = groupQueryProvider.get().bySubgroup(subgroupUuid);
- assertThatGroups(parentGroups).onlyElement().groupUuid().isEqualTo(groupUuid);
+ Set<AccountGroup.UUID> parentGroups =
+ groupQueryProvider.get().bySubgroups(ImmutableSet.of(subgroupUuid)).get(subgroupUuid);
+ assertThat(parentGroups).hasSize(1);
+ assertThat(parentGroups).containsExactly(groupUuid);
}
@Test
@@ -137,6 +145,20 @@ public class GroupIndexerIT {
assertWithMessage("Group should have been reindexed").that(reindexed).isTrue();
}
+ @Test
+ public void getMultipleParents() throws Exception {
+ AccountGroup.UUID sub1 = groupOperations.newGroup().create();
+ AccountGroup.UUID sub2 = groupOperations.newGroup().create();
+ AccountGroup.UUID parent1 = groupOperations.newGroup().addSubgroup(sub1).create();
+ AccountGroup.UUID parent2 = groupOperations.newGroup().addSubgroup(sub2).create();
+ AccountGroup.UUID parent3 = groupOperations.newGroup().addSubgroup(sub2).create();
+
+ assertThat(groupQueryProvider.get().bySubgroups(ImmutableSet.of(sub1, sub2)))
+ .containsExactlyEntriesIn(
+ ImmutableMap.of(
+ sub1, ImmutableSet.of(parent1), sub2, ImmutableSet.of(parent2, parent3)));
+ }
+
private AccountGroup.UUID createGroup(String name) throws RestApiException {
GroupInfo group = gApi.groups().create(name).get();
return AccountGroup.uuid(group.id);
@@ -164,9 +186,4 @@ public class GroupIndexerIT {
Optional<InternalGroup> updatedGroup) {
return assertThat(updatedGroup, internalGroups());
}
-
- private static ListSubject<InternalGroupSubject, InternalGroup> assertThatGroups(
- List<InternalGroup> parentGroups) {
- return assertThat(parentGroups, internalGroups());
- }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index e6c3919e99..1607f09d76 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
@@ -90,7 +91,7 @@ public class GroupsConsistencyIT extends AbstractDaemonTest {
try (Repository repo = repoManager.openRepository(allUsers)) {
RefUpdate ru = repo.updateRef(RefNames.REFS_GROUPNAMES);
ru.setForceUpdate(true);
- RefUpdate.Result result = ru.delete();
+ RefUpdate.Result result = testRefAction(() -> ru.delete());
assertThat(result).isEqualTo(Result.FORCED);
}
@@ -103,7 +104,7 @@ public class GroupsConsistencyIT extends AbstractDaemonTest {
try (Repository repo = repoManager.openRepository(allUsers)) {
RefUpdate ru = repo.updateRef(RefNames.refsGroups(AccountGroup.uuid(g1.id)));
ru.setForceUpdate(true);
- RefUpdate.Result result = ru.delete();
+ RefUpdate.Result result = testRefAction(() -> ru.delete());
assertThat(result).isEqualTo(Result.FORCED);
}
@@ -117,7 +118,7 @@ public class GroupsConsistencyIT extends AbstractDaemonTest {
RefRename ru =
repo.renameRef(
RefNames.refsGroups(AccountGroup.uuid(g1.id)), RefNames.REFS_GROUPS + BOGUS_UUID);
- RefUpdate.Result result = ru.rename();
+ RefUpdate.Result result = testRefAction(() -> ru.rename());
assertThat(result).isEqualTo(Result.RENAMED);
}
@@ -132,7 +133,7 @@ public class GroupsConsistencyIT extends AbstractDaemonTest {
repo.renameRef(
RefNames.refsGroups(AccountGroup.uuid(g1.id)),
RefNames.refsGroups(AccountGroup.uuid(BOGUS_UUID)));
- RefUpdate.Result result = ru.rename();
+ RefUpdate.Result result = testRefAction(() -> ru.rename());
assertThat(result).isEqualTo(Result.RENAMED);
}
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index 12f8506b51..6dbbe9ac76 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -28,6 +28,7 @@ import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.a
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static com.google.gerrit.truth.MapSubject.assertThatMap;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
@@ -113,6 +114,7 @@ import java.time.Instant;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.stream.Stream;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -462,7 +464,7 @@ public class GroupsIT extends AbstractDaemonTest {
@Test
public void createDuplicateInternalGroupCaseInsensitiveName() throws Exception {
String dupGroupName = name("dupGroupA");
- String dupGroupNameLowerCase = name("dupGroupA").toLowerCase();
+ String dupGroupNameLowerCase = name("dupGroupA").toLowerCase(Locale.US);
gApi.groups().create(dupGroupName);
gApi.groups().create(dupGroupNameLowerCase);
assertThat(gApi.groups().list().getAsMap().keySet()).contains(dupGroupName);
@@ -601,6 +603,20 @@ public class GroupsIT extends AbstractDaemonTest {
}
@Test
+ public void getGroupFromMetaId() throws Exception {
+ AccountGroup.UUID uuid = groupOperations.newGroup().create();
+ InternalGroup preUpdateState = groupCache.get(uuid).get();
+ gApi.groups().id(uuid.toString()).description("New description");
+
+ InternalGroup postUpdateState = groupCache.get(uuid).get();
+ assertThat(postUpdateState).isNotEqualTo(preUpdateState);
+ assertThat(groupCache.getFromMetaId(uuid, preUpdateState.getRefState()))
+ .isEqualTo(preUpdateState);
+ assertThat(groupCache.getFromMetaId(uuid, postUpdateState.getRefState()))
+ .isEqualTo(postUpdateState);
+ }
+
+ @Test
@GerritConfig(name = "groups.global:Anonymous-Users.name", value = "All Users")
public void getSystemGroupByConfiguredName() throws Exception {
GroupReference anonymousUsersGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
@@ -1133,7 +1149,7 @@ public class GroupsIT extends AbstractDaemonTest {
RefUpdate ru = repo.updateRef(RefNames.refsGroups(uuid));
ru.setForceUpdate(true);
ru.setNewObjectId(ObjectId.zeroId());
- assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
// Reindex the group.
@@ -1343,7 +1359,7 @@ public class GroupsIT extends AbstractDaemonTest {
updateRef.setExpectedOldObjectId(commit.toObjectId());
updateRef.setNewObjectId(ObjectId.zeroId());
updateRef.setForceUpdate(true);
- assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
// refs/meta/group-names is only visible with ACCESS_DATABASE
@@ -1443,7 +1459,7 @@ public class GroupsIT extends AbstractDaemonTest {
RefUpdate updateRef = repo.updateRef(groupRef);
updateRef.setExpectedOldObjectId(commit.toObjectId());
updateRef.setNewObjectId(emptyCommit);
- assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(updateRef.forceUpdate()).isEqualTo(RefUpdate.Result.FORCED));
}
assertStaleGroupAndReindex(groupUuid);
@@ -1455,7 +1471,7 @@ public class GroupsIT extends AbstractDaemonTest {
updateRef.setExpectedOldObjectId(commit.toObjectId());
updateRef.setNewObjectId(ObjectId.zeroId());
updateRef.setForceUpdate(true);
- assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(updateRef.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
assertStaleGroupAndReindex(groupUuid);
}
@@ -1500,13 +1516,15 @@ public class GroupsIT extends AbstractDaemonTest {
// then run the reindexer -> only the new group is reindexed.
String groupName = "foo";
AccountGroup.UUID groupUuid = AccountGroup.uuid(groupName + "-UUID");
- groupsUpdate.createGroupInNoteDb(
- InternalGroupCreation.builder()
- .setGroupUUID(groupUuid)
- .setNameKey(AccountGroup.nameKey(groupName))
- .setId(AccountGroup.id(seq.nextGroupId()))
- .build(),
- GroupDelta.builder().build());
+ testRefAction(
+ () ->
+ groupsUpdate.createGroupInNoteDb(
+ InternalGroupCreation.builder()
+ .setGroupUUID(groupUuid)
+ .setNameKey(AccountGroup.nameKey(groupName))
+ .setId(AccountGroup.id(seq.nextGroupId()))
+ .build(),
+ GroupDelta.builder().build()));
slaveGroupIndexer.run();
groupIndexedCounter.assertReindexOf(groupUuid);
@@ -1522,7 +1540,7 @@ public class GroupsIT extends AbstractDaemonTest {
try (Repository repo = repoManager.openRepository(allUsers)) {
RefUpdate u = repo.updateRef(RefNames.refsGroups(groupUuid));
u.setForceUpdate(true);
- assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(u.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
slaveGroupIndexer.run();
groupIndexedCounter.assertReindexOf(groupUuid);
@@ -1608,7 +1626,7 @@ public class GroupsIT extends AbstractDaemonTest {
RefUpdate updateRef = r.updateRef(ref);
updateRef.setExpectedOldObjectId(ObjectId.zeroId());
updateRef.setNewObjectId(emptyCommit);
- assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(updateRef.update(rw)).isEqualTo(RefUpdate.Result.NEW));
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
index 18eca2744a..462d0a5d35 100644
--- a/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/plugin/PluginIT.java
@@ -24,6 +24,7 @@ import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.extensions.api.plugins.InstallPluginInput;
import com.google.gerrit.extensions.api.plugins.PluginApi;
@@ -198,6 +199,7 @@ public class PluginIT extends AbstractDaemonTest {
return pluginJarContent(plugin);
}
+ @Nullable
private String pluginVersion(String plugin) {
String name = pluginName(plugin);
if (name.endsWith("empty")) {
@@ -210,6 +212,7 @@ public class PluginIT extends AbstractDaemonTest {
return dash > 0 ? name.substring(dash + 1) : "";
}
+ @Nullable
private String pluginApiVersion(String plugin) {
if (plugin.endsWith("normal.jar")) {
return "2.16.19-SNAPSHOT";
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index 7c33ec2750..a2f1f46901 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -22,6 +22,7 @@ import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.schema.AclUtil.grant;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static com.google.gerrit.truth.ConfigSubject.assertThat;
import static com.google.gerrit.truth.MapSubject.assertThatMap;
import static java.util.Arrays.asList;
@@ -67,6 +68,7 @@ import com.google.gerrit.server.group.testing.TestGroupBackend;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.schema.GrantRevertPermission;
import com.google.inject.Inject;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -74,6 +76,7 @@ import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.RefUpdate.Result;
import org.eclipse.jgit.lib.Repository;
@@ -238,7 +241,7 @@ public class AccessIT extends AbstractDaemonTest {
Registration registration = newFileHistoryWebLink()) {
RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
u.setForceUpdate(true);
- assertThat(u.delete()).isEqualTo(Result.FORCED);
+ testRefAction(() -> assertThat(u.delete()).isEqualTo(Result.FORCED));
// This should not crash.
pApi().access();
@@ -263,6 +266,38 @@ public class AccessIT extends AbstractDaemonTest {
}
@Test
+ public void addDuplicatedAccessSection_doesNotAddDuplicateEntry() throws Exception {
+ ProjectAccessInput accessInput = newProjectAccessInput();
+ AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+ // Update project config. Record the file content and the refs_config object ID
+ accessInput.add.put(REFS_HEADS, accessSectionInfo);
+ pApi().access(accessInput);
+ ObjectId refsConfigId =
+ projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+ List<String> projectConfigLines =
+ Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+ assertThat(projectConfigLines)
+ .containsExactly(
+ "[submit]",
+ "\taction = inherit",
+ "[access \"refs/heads/*\"]",
+ "\tlabel-Code-Review = deny group Registered Users",
+ "\tlabel-Code-Review = -1..+1 group Project Owners",
+ "\tpush = group Registered Users");
+
+ // Apply the same update once more. Make sure that the file content and the ref did not change
+ pApi().access(accessInput);
+
+ List<String> newProjectConfigLines =
+ Arrays.asList(projectOperations.project(newProjectName).getConfig().toText().split("\n"));
+ ObjectId newRefsConfigId =
+ projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG).getId();
+ assertThat(projectConfigLines).isEqualTo(newProjectConfigLines);
+ assertThat(refsConfigId).isEqualTo(newRefsConfigId);
+ }
+
+ @Test
public void addAccessSectionForPluginPermission() throws Exception {
try (Registration registration =
extensionRegistry
@@ -325,6 +360,79 @@ public class AccessIT extends AbstractDaemonTest {
}
@Test
+ public void addAccessSectionWithInvalidLabelRange_minGreaterThanMax() throws Exception {
+ ProjectAccessInput accessInput = newProjectAccessInput();
+ AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+ PermissionInfo permissionInfo = newPermissionInfo();
+ PermissionRuleInfo permissionRuleInfo =
+ new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+ permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+ permissionRuleInfo.min = 1;
+ permissionRuleInfo.max = -1;
+ accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+ accessInput.add.put(REFS_HEADS, accessSectionInfo);
+ BadRequestException ex =
+ assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+ assertThat(ex)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Invalid range for permission rule that assigns label-Code-Review to group %s"
+ + " on ref refs/heads/*: 1..-1 (min must be <= max)",
+ SystemGroupBackend.REGISTERED_USERS.get()));
+ }
+
+ @Test
+ public void addAccessSectionWithInvalidLabelRange_minSetMaxMissing() throws Exception {
+ ProjectAccessInput accessInput = newProjectAccessInput();
+ AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+ PermissionInfo permissionInfo = newPermissionInfo();
+ PermissionRuleInfo permissionRuleInfo =
+ new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+ permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+ permissionRuleInfo.min = -1;
+ accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+ accessInput.add.put(REFS_HEADS, accessSectionInfo);
+ BadRequestException ex =
+ assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+ assertThat(ex)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Invalid range for permission rule that assigns label-Code-Review to group %s"
+ + " on ref refs/heads/*: -1.. (max is required if min is set)",
+ SystemGroupBackend.REGISTERED_USERS.get()));
+ }
+
+ @Test
+ public void addAccessSectionWithInvalidLabelRange_maxSetMinMissing() throws Exception {
+ ProjectAccessInput accessInput = newProjectAccessInput();
+ AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+ PermissionInfo permissionInfo = newPermissionInfo();
+ PermissionRuleInfo permissionRuleInfo =
+ new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+ permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), permissionRuleInfo);
+ permissionRuleInfo.max = 1;
+ accessSectionInfo.permissions.put("label-Code-Review", permissionInfo);
+
+ accessInput.add.put(REFS_HEADS, accessSectionInfo);
+ BadRequestException ex =
+ assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+ assertThat(ex)
+ .hasMessageThat()
+ .isEqualTo(
+ String.format(
+ "Invalid range for permission rule that assigns label-Code-Review to group %s"
+ + " on ref refs/heads/*: ..1 (min is required if max is set)",
+ SystemGroupBackend.REGISTERED_USERS.get()));
+ }
+
+ @Test
public void createAccessChangeNop() throws Exception {
ProjectAccessInput accessInput = newProjectAccessInput();
assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
@@ -335,7 +443,7 @@ public class AccessIT extends AbstractDaemonTest {
try (Repository repo = repoManager.openRepository(newProjectName)) {
RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
ru.setForceUpdate(true);
- assertThat(ru.delete()).isEqualTo(Result.FORCED);
+ testRefAction(() -> assertThat(ru.delete()).isEqualTo(Result.FORCED));
ProjectAccessInput accessInput = newProjectAccessInput();
AccessSectionInfo accessSection = newAccessSectionInfo();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index b0de1c1962..5c46fec064 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.b
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -388,7 +389,7 @@ public class CheckAccessIT extends AbstractDaemonTest {
try (Repository repo = repoManager.openRepository(normalProject)) {
RefUpdate u = repo.updateRef(RefNames.REFS_HEADS + "master");
u.setForceUpdate(true);
- assertThat(u.delete()).isEqualTo(Result.FORCED);
+ testRefAction(() -> assertThat(u.delete()).isEqualTo(Result.FORCED));
}
AccessCheckInput input = new AccessCheckInput();
input.account = privilegedUser.email();
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
index 168819cb4b..28a0196c32 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectConfigIT.java
@@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.GitUtil.fetch;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -25,17 +26,22 @@ import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.GroupList;
import com.google.gerrit.server.project.LabelConfigValidator;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.inject.Inject;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Test;
public class ProjectConfigIT extends AbstractDaemonTest {
@@ -131,6 +137,102 @@ public class ProjectConfigIT extends AbstractDaemonTest {
}
@Test
+ public void rejectCreatingLabelWithInvalidFunction() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ProjectConfig.PROJECT_CONFIG,
+ "[label \"Foo\"]\n function = INVALID");
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: invalid project configuration:\n"
+ + "ERROR: commit %s: project.config: Invalid function for label \"foo\"."
+ + " Valid names are: NoBlock, NoOp, PatchSetLock",
+ abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectCreatingLabelPermissionWithInvalidRange_minGreaterThanMax() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ImmutableMap.of(
+ ProjectConfig.PROJECT_CONFIG,
+ "[access \"refs/heads/*\"]\n label-Code-Review = 1..-1 group Registered-Users",
+ GroupList.FILE_NAME,
+ String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: invalid project configuration:\n"
+ + "ERROR: commit %s: project.config: invalid rule in"
+ + " access.refs/heads/*.label-Code-Review:"
+ + " invalid range in rule: 1..-1 group Registered-Users",
+ abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectCreatingLabelPermissionWithInvalidRange_minSetMaxMissing() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ImmutableMap.of(
+ ProjectConfig.PROJECT_CONFIG,
+ "[access \"refs/heads/*\"]\n label-Code-Review = -1.. group Registered-Users",
+ GroupList.FILE_NAME,
+ String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: invalid project configuration:\n"
+ + "ERROR: commit %s: project.config: invalid rule in"
+ + " access.refs/heads/*.label-Code-Review:"
+ + " invalid range in rule: -1.. group Registered-Users",
+ abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectCreatingLabelPermissionWithInvalidRange_maxSetMinMissing() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ImmutableMap.of(
+ ProjectConfig.PROJECT_CONFIG,
+ "[access \"refs/heads/*\"]\n label-Code-Review = ..1 group Registered-Users",
+ GroupList.FILE_NAME,
+ String.format("%s\tRegistered-Users", SystemGroupBackend.REGISTERED_USERS.get())));
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: invalid project configuration:\n"
+ + "ERROR: commit %s: project.config: invalid rule in"
+ + " access.refs/heads/*.label-Code-Review:"
+ + " invalid range in rule: ..1 group Registered-Users",
+ abbreviateName(r.getCommit()), abbreviateName(r.getCommit())));
+ }
+
+ @Test
public void rejectSettingCopyMinScore() throws Exception {
testRejectSettingLabelFlag(
LabelConfigValidator.KEY_COPY_MIN_SCORE, /* value= */ true, "is:MIN");
@@ -389,6 +491,168 @@ public class ProjectConfigIT extends AbstractDaemonTest {
}
@Test
+ public void rejectSubmitRequirement_duplicateDescriptionKeys() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ProjectConfig.PROJECT_CONFIG,
+ "[submit-requirement \"Foo\"]\n"
+ + " description = description 1\n "
+ + " submittableIf = label:Code-Review=MAX\n"
+ + "[submit-requirement \"Foo\"]\n"
+ + " description = description 2\n");
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: project.config: multiple definitions of description"
+ + " for submit requirement 'foo'",
+ abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectSubmitRequirement_duplicateApplicableIfKeys() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ProjectConfig.PROJECT_CONFIG,
+ "[submit-requirement \"Foo\"]\n "
+ + " applicableIf = is:true\n "
+ + " submittableIf = label:Code-Review=MAX\n"
+ + "[submit-requirement \"Foo\"]\n"
+ + " applicableIf = is:false\n");
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: project.config: multiple definitions of applicableif"
+ + " for submit requirement 'foo'",
+ abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectSubmitRequirement_duplicateSubmittableIfKeys() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ProjectConfig.PROJECT_CONFIG,
+ "[submit-requirement \"Foo\"]\n"
+ + " submittableIf = label:Code-Review=MAX\n"
+ + "[submit-requirement \"Foo\"]\n"
+ + " submittableIf = label:Code-Review=MIN\n");
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: project.config: multiple definitions of submittableif"
+ + " for submit requirement 'foo'",
+ abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectSubmitRequirement_duplicateOverrideIfKeys() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ProjectConfig.PROJECT_CONFIG,
+ "[submit-requirement \"Foo\"]\n"
+ + " overrideIf = is:true\n "
+ + " submittableIf = label:Code-Review=MAX\n"
+ + "[submit-requirement \"Foo\"]\n"
+ + " overrideIf = is:false\n");
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: project.config: multiple definitions of overrideif"
+ + " for submit requirement 'foo'",
+ abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void rejectSubmitRequirement_duplicateCanOverrideInChildProjectsKey() throws Exception {
+ fetchRefsMetaConfig();
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Test Change",
+ ProjectConfig.PROJECT_CONFIG,
+ "[submit-requirement \"Foo\"]\n"
+ + " canOverrideInChildProjects = true\n"
+ + " submittableIf = label:Code-Review=MAX\n"
+ + "[submit-requirement \"Foo\"]\n "
+ + " canOverrideInChildProjects = false\n");
+ PushOneCommit.Result r = push.to(RefNames.REFS_CONFIG);
+ r.assertErrorStatus(
+ String.format("commit %s: invalid project configuration", abbreviateName(r.getCommit())));
+ r.assertMessage(
+ String.format(
+ "ERROR: commit %s: project.config: multiple definitions of canoverrideinchildprojects"
+ + " for submit requirement 'foo'",
+ abbreviateName(r.getCommit())));
+ }
+
+ @Test
+ public void submitRequirementsAreParsed_forExistingDuplicateDefinitions() throws Exception {
+ // Duplicate submit requirement definitions are rejected on config change uploads. For setups
+ // already containing duplicate SR definitions, the server is able to parse the "submit
+ // requirements correctly"
+
+ RevCommit revision;
+ // Commit a change to the project config, bypassing server validation.
+ try (TestRepository<Repository> testRepo =
+ new TestRepository<>(repoManager.openRepository(project))) {
+ revision =
+ testRepo
+ .branch(RefNames.REFS_CONFIG)
+ .commit()
+ .add(
+ ProjectConfig.PROJECT_CONFIG,
+ "[submit-requirement \"Foo\"]\n"
+ + " canOverrideInChildProjects = true\n"
+ + " submittableIf = label:Code-Review=MAX\n"
+ + "[submit-requirement \"Foo\"]\n "
+ + " canOverrideInChildProjects = false\n")
+ .parent(projectOperations.project(project).getHead(RefNames.REFS_CONFIG))
+ .create();
+ }
+
+ try (Repository git = repoManager.openRepository(project)) {
+ // Server is able to parse the config.
+ ProjectConfig cfg = projectConfigFactory.create(project);
+ cfg.load(git, revision);
+
+ // One of the two definitions takes precedence and overrides the other.
+ assertThat(cfg.getSubmitRequirementSections())
+ .containsExactly(
+ "Foo",
+ SubmitRequirement.builder()
+ .setName("Foo")
+ .setAllowOverrideInChildProjects(false)
+ .setSubmittabilityExpression(
+ SubmitRequirementExpression.create("label:Code-Review=MAX"))
+ .build());
+ }
+ }
+
+ @Test
public void testRejectChangingLabelFunction_toMaxWithBlock() throws Exception {
testChangingLabelFunction(
/* initialLabelFunction= */ LabelFunction.NO_BLOCK,
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
index a625a7088f..4302b5058a 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIndexerIT.java
@@ -54,19 +54,19 @@ public class ProjectIndexerIT extends AbstractDaemonTest {
@Inject private IndexOperations.Project projectIndexOperations;
private static final ImmutableSet<String> FIELDS =
- ImmutableSet.of(ProjectField.NAME.getName(), ProjectField.REF_STATE.getName());
+ ImmutableSet.of(ProjectField.NAME_SPEC.getName(), ProjectField.REF_STATE_SPEC.getName());
@Test
public void indexProject_indexesRefStateOfProjectAndParents() throws Exception {
projectIndexer.index(project);
ProjectIndex i = indexes.getSearchIndex();
- assertThat(i.getSchema().hasField(ProjectField.REF_STATE)).isTrue();
+ assertThat(i.getSchema().hasField(ProjectField.REF_STATE_SPEC)).isTrue();
Optional<FieldBundle> result =
i.getRaw(project, QueryOptions.create(indexConfig, 0, 1, FIELDS));
assertThat(result).isPresent();
- Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE);
+ Iterable<byte[]> refState = result.get().getValue(ProjectField.REF_STATE_SPEC);
assertThat(refState).isNotEmpty();
Map<Project.NameKey, Collection<RefState>> states = RefState.parseStates(refState).asMap();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index f347e19149..b570466856 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -227,6 +227,66 @@ public class RevisionDiffIT extends AbstractDaemonTest {
}
@Test
+ public void fileModeChangeIsIncludedInListFilesDiff() throws Exception {
+ String fileName = "file.txt";
+ PushOneCommit push =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+ .addFile(fileName, "content", /* fileMode= */ 0100644);
+ PushOneCommit.Result result = push.to("refs/for/master");
+ String commitRev1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+ push =
+ pushFactory
+ .create(admin.newIdent(), testRepo, result.getChangeId())
+ .addFile(fileName, "content", /* fileMode= */ 0100755);
+ result = push.to("refs/for/master");
+ String commitRev2 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+ Map<String, FileInfo> changedFiles =
+ gApi.changes().id(result.getChangeId()).revision(commitRev2).files(commitRev1);
+
+ assertThat(changedFiles.get(fileName)).oldMode().isEqualTo(0100644);
+ assertThat(changedFiles.get(fileName)).newMode().isEqualTo(0100755);
+ }
+
+ @Test
+ public void fileMode_oldMode_isMissingInListFilesDiff_forAddedFile() throws Exception {
+ String fileName = "file.txt";
+ PushOneCommit push =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+ .addFile(fileName, "content", /* fileMode= */ 0100644);
+ PushOneCommit.Result result = push.to("refs/for/master");
+ String commitRev = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+ Map<String, FileInfo> changedFiles =
+ gApi.changes().id(result.getChangeId()).revision(commitRev).files();
+
+ assertThat(changedFiles.get(fileName)).oldMode().isNull();
+ assertThat(changedFiles.get(fileName)).newMode().isEqualTo(0100644);
+ }
+
+ @Test
+ public void fileMode_newMode_isMissingInListFilesDiff_forDeletedFile() throws Exception {
+ String fileName = "file.txt";
+ PushOneCommit push =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "Commit Subject", /* files= */ ImmutableMap.of())
+ .addFile(fileName, "content", /* fileMode= */ 0100644);
+ PushOneCommit.Result result = push.to("refs/for/master");
+ String commitRev1 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+ push = pushFactory.create(admin.newIdent(), testRepo, result.getChangeId()).rmFile(fileName);
+ result = push.to("refs/for/master");
+ String commitRev2 = gApi.changes().id(result.getChangeId()).get().currentRevision;
+
+ Map<String, FileInfo> changedFiles =
+ gApi.changes().id(result.getChangeId()).revision(commitRev2).files(commitRev1);
+
+ assertThat(changedFiles.get(fileName)).oldMode().isEqualTo(0100644);
+ assertThat(changedFiles.get(fileName)).newMode().isNull();
+ }
+
+ @Test
public void numberOfLinesInDiffOfDeletedFileWithoutNewlineAtEndIsCorrect() throws Exception {
String filePath = "a_new_file.txt";
String fileContent = "Line 1\nLine 2\nLine 3";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 804516ae3c..d0d6fb40ab 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -1009,6 +1009,84 @@ public class RevisionIT extends AbstractDaemonTest {
}
@Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void cherryPickWithNonVisibleUsers() throws Exception {
+ // Create a target branch for the cherry-pick.
+ createBranch(BranchNameKey.create(project, "stable"));
+
+ // Define readable names for the users we use in this test.
+ TestAccount cherryPicker = user;
+ TestAccount changeOwner = admin;
+ TestAccount reviewer = accountCreator.user2();
+ TestAccount cc =
+ accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+ TestAccount authorCommitter =
+ accountCreator.create("user4", "user4@example.com", "User4", /* displayName= */ null);
+
+ // Check that the cherry-picker can neither see the changeOwner, the reviewer, the cc nor the
+ // authorCommitter.
+ requestScopeOperations.setApiUser(cherryPicker.id());
+ assertThatAccountIsNotVisible(changeOwner, reviewer, cc, authorCommitter);
+
+ // Create the change with authorCommitter as the author and the committer.
+ requestScopeOperations.setApiUser(changeOwner.id());
+ PushOneCommit push = pushFactory.create(authorCommitter.newIdent(), testRepo);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+
+ // Check that authorCommitter was set as the author and committer.
+ ChangeInfo changeInfo = gApi.changes().id(r.getChangeId()).get();
+ CommitInfo commit = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+ assertThat(commit.author.email).isEqualTo(authorCommitter.email());
+ assertThat(commit.committer.email).isEqualTo(authorCommitter.email());
+
+ // Pushing a commit with a forged author/committer adds the author/committer as a CC.
+ assertCcs(r.getChangeId(), authorCommitter);
+
+ // Remove the author/committer as a CC because because otherwise there are two signals for CCing
+ // authorCommitter on the cherry-pick change: once because they are author and committer and
+ // once because they are a CC. For authorCommitter we only want to test the first signal here
+ // (the second signal is covered by adding an explicit CC below).
+ gApi.changes().id(r.getChangeId()).reviewer(authorCommitter.email()).remove();
+ assertNoCcs(r.getChangeId());
+
+ // Add reviewer and cc.
+ ReviewInput reviewerInput = ReviewInput.approve();
+ reviewerInput.reviewer(reviewer.email());
+ reviewerInput.cc(cc.email());
+ gApi.changes().id(r.getChangeId()).current().review(reviewerInput);
+
+ // Approve and submit the change.
+ gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+ gApi.changes().id(r.getChangeId()).current().submit();
+
+ // Cherry-pick the change.
+ requestScopeOperations.setApiUser(cherryPicker.id());
+ CherryPickInput cherryPickInput = new CherryPickInput();
+ cherryPickInput.message = "Cherry-pick to stable branch";
+ cherryPickInput.destination = "stable";
+ cherryPickInput.keepReviewers = true;
+ String cherryPickChangeId =
+ gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput).get().id;
+
+ // Cherry-pick doesn't check the visibility of explicit reviewers/CCs. Since the cherry-picker
+ // can see the cherry-picked change, they can also see its reviewers/CCs. This means preserving
+ // them on the cherry-pick change doesn't expose their account existence and it's OK to keep
+ // them even if their accounts are not visible to the cherry-picker.
+ // In contrast to this for implicit CCs that are added for the author/committer the account
+ // visibility is checked, but if their accounts are not visible the CC is silently dropped (so
+ // that the cherry-pick request can still succeed). Since in this case authorCommitter is not
+ // visible, we expect that CCing them is being dropped and hence authorCommitter is not returned
+ // as a CC here. The reason that the visibility for author/committer must be checked is that
+ // author/committer may not match a Gerrit account (if they are forged). This means by seeing
+ // the author/committer on the cherry-picked change, it's not possible to deduce that these
+ // Gerrit accounts exists, but if they would be added as a CC on the cherry-pick change even if
+ // they are not visible the account existence would be exposed.
+ assertReviewers(cherryPickChangeId, changeOwner, reviewer);
+ assertCcs(cherryPickChangeId, cc);
+ }
+
+ @Test
public void cherryPickToMergedChangeRevision() throws Exception {
createBranch(BranchNameKey.create(project, "foo"));
@@ -1693,7 +1771,6 @@ public class RevisionIT extends AbstractDaemonTest {
assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);
- assertThat(patchSetLinkInfo.target).isEqualTo(expectedPatchSetLinkInfo.target);
assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
WebLinkInfo resolveCommentsLinkInfo =
@@ -1702,7 +1779,6 @@ public class RevisionIT extends AbstractDaemonTest {
assertThat(resolveCommentsLinkInfo.imageUrl)
.isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
- assertThat(resolveCommentsLinkInfo.target).isEqualTo(expectedResolveConflictsLinkInfo.target);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index a16cdb6d35..1363ce7632 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -588,6 +588,22 @@ public class RobotCommentsIT extends AbstractDaemonTest {
}
@Test
+ public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+ FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+ fixReplacementInfo1.path = FILE_NAME;
+ fixReplacementInfo1.range = createRange(2, 0, 3, 1);
+ fixReplacementInfo1.replacement = "First modification\n";
+
+ withFixRobotCommentInput.line = 1;
+ withFixRobotCommentInput.range = createRange(2, 0, 3, 1);
+ withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+ testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+ List<RobotCommentInfo> robotComments = getRobotComments();
+ assertThat(robotComments.get(0).line).isEqualTo(3);
+ }
+
+ @Test
public void rangesOfFixReplacementsOfSameFixSuggestionForSameFileMayNotOverlap() {
FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
fixReplacementInfo1.path = FILE_NAME;
@@ -1450,7 +1466,7 @@ public class RobotCommentsIT extends AbstractDaemonTest {
}
@Test
- public void PreviewStoredFixForNonExistingFile() throws Exception {
+ public void previewStoredFixForNonExistingFile() throws Exception {
FixReplacementInfo replacement = new FixReplacementInfo();
replacement.path = "a_non_existent_file.txt";
replacement.range = createRange(1, 0, 2, 0);
@@ -1471,7 +1487,7 @@ public class RobotCommentsIT extends AbstractDaemonTest {
}
@Test
- public void PreviewStoredFix() throws Exception {
+ public void previewStoredFix() throws Exception {
FixReplacementInfo fixReplacementInfoFile1 = new FixReplacementInfo();
fixReplacementInfoFile1.path = FILE_NAME;
fixReplacementInfoFile1.replacement = "some replacement code";
@@ -1581,7 +1597,7 @@ public class RobotCommentsIT extends AbstractDaemonTest {
}
@Test
- public void PreviewStoredFixAddNewLineAtEnd() throws Exception {
+ public void previewStoredFixAddNewLineAtEnd() throws Exception {
FixReplacementInfo replacement = new FixReplacementInfo();
replacement.path = FILE_NAME3;
replacement.range = createRange(2, 8, 2, 8);
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 9fae6c0abe..4168164d4a 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -94,6 +94,7 @@ public class ChangeEditIT extends AbstractDaemonTest {
private static final String FILE_NAME = "foo";
private static final String FILE_NAME2 = "foo2";
private static final String FILE_NAME3 = "foo3";
+ private static final int FILE_MODE = 100644;
private static final byte[] CONTENT_OLD = "bar".getBytes(UTF_8);
private static final byte[] CONTENT_NEW = "baz".getBytes(UTF_8);
private static final String CONTENT_NEW2_STR = "quxÄÜÖßµ";
@@ -107,6 +108,7 @@ public class ChangeEditIT extends AbstractDaemonTest {
"Uploading to an edit worked!".getBytes(UTF_8);
private static final String CONTENT_BINARY_ENCODED_NEW3 =
"data:text/plain,VXBsb2FkaW5nIHRvIGFuIGVkaXQgd29ya2VkIQ==";
+ private static final String CONTENT_BINARY_ENCODED_EMPTY = "data:text/plain;base64,";
@Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@@ -323,6 +325,21 @@ public class ChangeEditIT extends AbstractDaemonTest {
}
@Test
+ public void updateCommitMessageByEditingMagicCommitMsgFileChangingChangeIdFooterToLinkFooter()
+ throws Exception {
+ createEmptyEditFor(changeId);
+ String updatedCommitMsg =
+ "Foo Bar\n\n\n\nLink: " + canonicalWebUrl.get() + "id/" + changeId + "\n";
+ gApi.changes()
+ .id(changeId)
+ .edit()
+ .modifyFile(Patch.COMMIT_MSG, RawInputUtil.create(updatedCommitMsg.getBytes(UTF_8)));
+ assertThat(getEdit(changeId)).isPresent();
+ ensureSameBytes(
+ getFileContentOfEdit(changeId, Patch.COMMIT_MSG), updatedCommitMsg.getBytes(UTF_8));
+ }
+
+ @Test
public void updateCommitMessageByEditingMagicCommitMsgFileWithoutContent() throws Exception {
createEmptyEditFor(changeId);
BadRequestException ex =
@@ -686,6 +703,26 @@ public class ChangeEditIT extends AbstractDaemonTest {
}
@Test
+ public void changeEditModifyFileModeRest() throws Exception {
+ createEmptyEditFor(changeId);
+ FileContentInput in = new FileContentInput();
+ in.binary_content = CONTENT_BINARY_ENCODED_NEW;
+ in.fileMode = FILE_MODE;
+ adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+ ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), CONTENT_BINARY_DECODED_NEW);
+ }
+
+ @Test
+ public void changeEditModifyFileSetEmptyContentModeRest() throws Exception {
+ createEmptyEditFor(changeId);
+ FileContentInput in = new FileContentInput();
+ in.binary_content = CONTENT_BINARY_ENCODED_EMPTY;
+ in.fileMode = FILE_MODE;
+ adminRestSession.put(urlEditFile(changeId, FILE_NAME), in).assertNoContent();
+ ensureSameBytes(getFileContentOfEdit(changeId, FILE_NAME), "".getBytes(UTF_8));
+ }
+
+ @Test
public void createAndUploadBinaryInChangeEditOneRequestRest() throws Exception {
FileContentInput in = new FileContentInput();
in.binary_content = CONTENT_BINARY_ENCODED_NEW;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index cd1d9113a2..e120f976bf 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -41,6 +41,7 @@ import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.testing.TestLabels.label;
import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
@@ -128,6 +129,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.ObjectId;
@@ -334,7 +336,7 @@ public abstract class AbstractPushForReview extends AbstractDaemonTest {
RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
u.setForceUpdate(true);
u.setExpectedOldObjectId(repo.resolve(RefNames.REFS_CONFIG));
- assertThat(u.delete(rw)).isEqualTo(Result.FORCED);
+ testRefAction(() -> assertThat(u.delete(rw)).isEqualTo(Result.FORCED));
}
RevCommit c =
@@ -1154,7 +1156,7 @@ public abstract class AbstractPushForReview extends AbstractDaemonTest {
.add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
.message(PushOneCommit.SUBJECT)
.create();
- // Push commit as "Admnistrator".
+ // Push commit as "Administrator".
pushHead(testRepo, "refs/for/master");
String changeId = GitUtil.getChangeId(testRepo, c).get();
@@ -1168,6 +1170,92 @@ public abstract class AbstractPushForReview extends AbstractDaemonTest {
}
@Test
+ public void pushForMasterWithForgedAuthorAndCommitter_skipAddingAuthorAndCommitterAsReviewers()
+ throws Exception {
+ setSkipAddingAuthorAndCommitterAsReviewers(InheritableBoolean.TRUE);
+ TestAccount user2 = accountCreator.user2();
+ // Create a commit with different forged author and committer.
+ RevCommit c =
+ commitBuilder()
+ .author(user.newIdent())
+ .committer(user2.newIdent())
+ .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+ .message(PushOneCommit.SUBJECT)
+ .create();
+ // Push commit as "Administrator".
+ pushHead(testRepo, "refs/for/master");
+
+ String changeId = GitUtil.getChangeId(testRepo, c).get();
+ assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+ assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+ }
+
+ @Test
+ public void pushForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+ // Create a commit with different forged author and committer.
+ RevCommit c =
+ commitBuilder()
+ .author(new PersonIdent("author", "author@example.com"))
+ .committer(new PersonIdent("committer", "committer@example.com"))
+ .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+ .message(PushOneCommit.SUBJECT)
+ .create();
+ // Push commit as "Administrator".
+ pushHead(testRepo, "refs/for/master");
+
+ String changeId = GitUtil.getChangeId(testRepo, c).get();
+ assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
+ assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void pushForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+ // Define readable names for the users we use in this test.
+ TestAccount uploader = user; // cannot use admin since admin can see all users
+ TestAccount author = accountCreator.user2();
+ TestAccount committer =
+ accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+ // Check that the uploader can neither see the author nor the committer.
+ requestScopeOperations.setApiUser(uploader.id());
+ assertThatAccountIsNotVisible(author, committer);
+
+ // Allow the uploader to forge author and committer.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+ .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+ .update();
+
+ // Clone the repo as uploader so that the push is done by the uplaoder.
+ TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+ // Create a commit with different forged author and committer.
+ RevCommit c =
+ testRepo
+ .branch("HEAD")
+ .commit()
+ .insertChangeId()
+ .author(author.newIdent())
+ .committer(committer.newIdent())
+ .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT)
+ .message(PushOneCommit.SUBJECT)
+ .create();
+
+ PushResult r = pushHead(testRepo, "refs/for/master");
+ RemoteRefUpdate refUpdate = r.getRemoteUpdate("refs/for/master");
+ assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+ String changeId = GitUtil.getChangeId(testRepo, c).get();
+ assertThat(getOwnerEmail(changeId)).isEqualTo(uploader.email());
+
+ // author and committer have not been CCed because their accounts are not visible
+ assertThat(getReviewerEmails(changeId, ReviewerState.CC)).isEmpty();
+ }
+
+ @Test
public void pushNewPatchSetForMasterWithForgedAuthorAndCommitter() throws Exception {
TestAccount user2 = accountCreator.user2();
// First patch set has author and committer matching change owner.
@@ -1192,6 +1280,74 @@ public abstract class AbstractPushForReview extends AbstractDaemonTest {
.containsExactly(user.getNameEmail(), user2.getNameEmail());
}
+ @Test
+ public void pushNewPatchSetForMasterWithNonExistingForgedAuthorAndCommitter() throws Exception {
+ // First patch set has author and committer matching change owner.
+ PushOneCommit.Result r = pushTo("refs/for/master");
+
+ assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+ assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+ amendBuilder()
+ .author(new PersonIdent("author", "author@example.com"))
+ .committer(new PersonIdent("committer", "committer@example.com"))
+ .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+ .create();
+ pushHead(testRepo, "refs/for/master");
+
+ assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
+ assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void pushNewPatchSetForMasterWithNonVisibleForgedAuthorAndCommitter() throws Exception {
+ // Define readable names for the users we use in this test.
+ TestAccount uploader = user; // cannot use admin since admin can see all users
+ TestAccount author = accountCreator.user2();
+ TestAccount committer =
+ accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
+
+ // Check that the uploader can neither see the author nor the committer.
+ requestScopeOperations.setApiUser(uploader.id());
+ assertThatAccountIsNotVisible(author, committer);
+
+ // Allow the uploader to forge author and committer.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.FORGE_AUTHOR).ref("refs/heads/master").group(REGISTERED_USERS))
+ .add(allow(Permission.FORGE_COMMITTER).ref("refs/heads/master").group(REGISTERED_USERS))
+ .update();
+
+ // Clone the repo as uploader so that the push is done by the uplaoder.
+ TestRepository<InMemoryRepository> testRepo = cloneProject(project, uploader);
+
+ // First patch set has author and committer matching uploader.
+ PushOneCommit push = pushFactory.create(uploader.newIdent(), testRepo);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+
+ assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+ assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER)).isEmpty();
+
+ testRepo
+ .amendRef("HEAD")
+ .author(author.newIdent())
+ .committer(committer.newIdent())
+ .add(PushOneCommit.FILE_NAME, PushOneCommit.FILE_CONTENT + "2")
+ .create();
+
+ PushResult r2 = pushHead(testRepo, "refs/for/master");
+ RemoteRefUpdate refUpdate = r2.getRemoteUpdate("refs/for/master");
+ assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.OK);
+
+ assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(uploader.email());
+
+ // author and committer have not been CCed because their accounts are not visible
+ assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC)).isEmpty();
+ }
+
/**
* There was a bug that allowed a user with Forge Committer Identity access right to upload a
* commit and put *votes on behalf of another user* on it. This test checks that this is not
@@ -2544,6 +2700,23 @@ public abstract class AbstractPushForReview extends AbstractDaemonTest {
}
@Test
+ @GerritConfig(
+ name = "plugins.transitionalPushOptions",
+ values = {"gerrit~foo", "gerrit~bar"})
+ public void transitionalPushOptionsArePassedToCommitValidationListener() throws Exception {
+ TestValidator validator = new TestValidator();
+ try (Registration registration = extensionRegistry.newRegistration().add(validator)) {
+ PushOneCommit push =
+ pushFactory.create(admin.newIdent(), testRepo, "change2", "b.txt", "content");
+ push.setPushOptions(ImmutableList.of("trace=123", "gerrit~foo", "gerrit~bar=456"));
+ PushOneCommit.Result r = push.to("refs/for/master");
+ r.assertOkStatus();
+ assertThat(validator.pushOptions())
+ .containsExactly("trace", "123", "gerrit~foo", "", "gerrit~bar", "456");
+ }
+ }
+
+ @Test
public void pluginPushOptionsHelp() throws Exception {
PluginPushOption fooOption = new TestPluginPushOption("foo", "some description");
PluginPushOption barOption = new TestPluginPushOption("bar", "other description");
@@ -2910,6 +3083,12 @@ public abstract class AbstractPushForReview extends AbstractDaemonTest {
assertThat(r.getChange().attentionSet()).isEmpty();
}
+ @Test
+ public void pushWithInvalidBaseIsRejected() throws Exception {
+ PushOneCommit.Result r = pushTo("refs/for/master%base=invalid");
+ r.assertErrorStatus("expected SHA1 for option --base: invalid");
+ }
+
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/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 0cdac5adf3..8295550e04 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -41,7 +41,9 @@ import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.events.ChangeMergedEvent;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gerrit.testing.RefUpdateContextCollector;
import com.google.inject.Inject;
import java.util.List;
import org.eclipse.jgit.api.errors.GitAPIException;
@@ -54,12 +56,16 @@ import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
public abstract class AbstractSubmitOnPush extends AbstractDaemonTest {
@Inject private ApprovalsUtil approvalsUtil;
@Inject private ProjectOperations projectOperations;
+ @Rule
+ public RefUpdateContextCollector refUpdateContextCollector = new RefUpdateContextCollector();
+
@Before
public void blockAnonymous() throws Exception {
blockAnonymousRead();
@@ -229,6 +235,25 @@ public abstract class AbstractSubmitOnPush extends AbstractDaemonTest {
}
@Test
+ public void pushAutoclosesChanges_changeMetaInAutoClosesChangesContext() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.PUSH).ref("refs/heads/master").group(adminGroupUuid()))
+ .update();
+ PushOneCommit.Result r = push("refs/for/master", PushOneCommit.SUBJECT, "one.txt", "One");
+ String refPrefix = r.getChange().getId().toRefPrefix();
+ assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.DIRECT_PUSH)).isEmpty();
+ assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.AUTO_CLOSE_CHANGES))
+ .isEmpty();
+ git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
+ assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.DIRECT_PUSH))
+ .containsExactly("refs/heads/master", refPrefix + "meta");
+ assertThat(refUpdateContextCollector.getRefsByUpdateType(RefUpdateType.AUTO_CLOSE_CHANGES))
+ .containsExactly(refPrefix + "meta");
+ }
+
+ @Test
public void mergeOnPushToBranchWithChangeMergedInOther() throws Exception {
enableCreateNewChangeForAllNotInTarget();
String master = "refs/heads/master";
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index c3bcbd3a5a..206a9d523f 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -17,6 +17,7 @@ package com.google.gerrit.acceptance.git;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.util.stream.Collectors.toList;
import com.google.common.collect.Iterables;
@@ -511,7 +512,7 @@ public abstract class AbstractSubmoduleSubscription extends AbstractDaemonTest {
RefUpdate ru = serverRepo.updateRef(refName);
ru.setExpectedOldObjectId(oldCommitId);
ru.setNewObjectId(newCommitId);
- assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+ testRefAction(() -> assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD));
}
}
}
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
index b16394dab6..3b158a98a7 100644
--- a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.git;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static org.eclipse.jgit.lib.Constants.HEAD;
import com.google.common.collect.ImmutableList;
@@ -201,7 +202,7 @@ public class AutoMergeIT extends AbstractDaemonTest {
try (Repository repo = repoManager.openRepository(project)) {
RefUpdate ru = repo.updateRef(RefNames.refsCacheAutomerge(mergeCommit.name()));
ru.setForceUpdate(true);
- assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
assertNoAutoMergeCreated(mergeCommit);
}
diff --git a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
index 80cc5086ec..e352e2d512 100644
--- a/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/ImplicitMergeCheckIT.java
@@ -22,6 +22,7 @@ import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.git.ObjectIds;
+import java.util.Locale;
import org.eclipse.jgit.lib.ObjectId;
import org.junit.Test;
@@ -61,7 +62,8 @@ public class ImplicitMergeCheckIT extends AbstractDaemonTest {
PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
PushOneCommit.Result c = push("refs/for/stable", "2", "f", "2");
- assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
+ assertThat(c.getMessage().toLowerCase(Locale.US))
+ .doesNotContain(implicitMergeOf(m.getCommit()));
}
@Test
@@ -74,7 +76,8 @@ public class ImplicitMergeCheckIT extends AbstractDaemonTest {
PushOneCommit.Result m = push("refs/heads/master", "1", "f", "1");
PushOneCommit.Result c = push("refs/for/master", "2", "f", "2");
- assertThat(c.getMessage().toLowerCase()).doesNotContain(implicitMergeOf(m.getCommit()));
+ assertThat(c.getMessage().toLowerCase(Locale.US))
+ .doesNotContain(implicitMergeOf(m.getCommit()));
}
private String implicitMergeOf(ObjectId commit) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index f58f81c192..9e85d8ce73 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -24,6 +24,7 @@ import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.a
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.util.stream.Collectors.toList;
import com.google.common.collect.ImmutableList;
@@ -217,7 +218,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RefUpdate mtu = repo.updateRef("refs/tags/master-tag");
mtu.setExpectedOldObjectId(ObjectId.zeroId());
mtu.setNewObjectId(repo.exactRef("refs/heads/master").getObjectId());
- assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(mtu.update()).isEqualTo(RefUpdate.Result.NEW));
// rcMaster (c1 master master-tag) <-- rcBranch (c2 branch branch-tag)
// \ \
@@ -225,14 +226,14 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
btu.setExpectedOldObjectId(ObjectId.zeroId());
btu.setNewObjectId(repo.exactRef("refs/heads/branch").getObjectId());
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
// Create a tag for the tree of the commit on 'master'
// tree-tag -> master.tree
RefUpdate ttu = repo.updateRef("refs/tags/tree-tag");
ttu.setExpectedOldObjectId(ObjectId.zeroId());
ttu.setNewObjectId(rcMaster.getTree().toObjectId());
- assertThat(ttu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(ttu.update()).isEqualTo(RefUpdate.Result.NEW));
}
}
@@ -588,14 +589,17 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
.forUpdate()
.add(allow(Permission.READ).ref("refs/heads/branch").group(REGISTERED_USERS))
.update();
- // Create a tag for the pending change on 'branch' so that the tag is orphaned
- try (Repository repo = repoManager.openRepository(project)) {
- // change4-tag -> psRef4
- RefUpdate ctu = repo.updateRef("refs/tags/change4-tag");
- ctu.setExpectedOldObjectId(ObjectId.zeroId());
- ctu.setNewObjectId(repo.exactRef(psRef4).getObjectId());
- assertThat(ctu.update()).isEqualTo(RefUpdate.Result.NEW);
- }
+ testRefAction(
+ () -> {
+ // Create a tag for the pending change on 'branch' so that the tag is orphaned
+ try (Repository repo = repoManager.openRepository(project)) {
+ // change4-tag -> psRef4
+ RefUpdate ctu = repo.updateRef("refs/tags/change4-tag");
+ ctu.setExpectedOldObjectId(ObjectId.zeroId());
+ ctu.setNewObjectId(repo.exactRef(psRef4).getObjectId());
+ assertThat(ctu.update()).isEqualTo(RefUpdate.Result.NEW);
+ }
+ });
requestScopeOperations.setApiUser(user.id());
assertUploadPackRefs(
@@ -641,7 +645,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RefUpdate btu = repo.updateRef("refs/tags/master-newtag");
btu.setExpectedOldObjectId(ObjectId.zeroId());
btu.setNewObjectId(r.getCommit());
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
}
assertUploadPackRefs(
@@ -695,7 +699,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
btu.setExpectedOldObjectId(ObjectId.zeroId());
btu.setNewObjectId(tagRc);
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
}
assertUploadPackRefs(
@@ -751,7 +755,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RefUpdate btu = repo.updateRef("refs/tags/branch-newtag");
btu.setExpectedOldObjectId(ObjectId.zeroId());
btu.setNewObjectId(tagRc);
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
}
assertUploadPackRefs(
@@ -794,10 +798,11 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RevCommit bRc = r.getCommit();
// rcBranch (c2) <- newcommit1 (branch-oldtag) <- newcommit2 (branch)
- RefUpdate btu = repo.updateRef("refs/tags/branch-oldtag");
- btu.setExpectedOldObjectId(ObjectId.zeroId());
- btu.setNewObjectId(tagRc);
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+ RefUpdate btu1 = repo.updateRef("refs/tags/branch-oldtag");
+
+ btu1.setExpectedOldObjectId(ObjectId.zeroId());
+ btu1.setNewObjectId(tagRc);
+ testRefAction(() -> assertThat(btu1.update()).isEqualTo(RefUpdate.Result.NEW));
assertUploadPackRefs(
psRef2,
@@ -811,11 +816,11 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
"refs/tags/master-tag");
// rcBranch (c2 branch) <- newcommit1 (branch-oldtag) <- newcommit2
- btu = repo.updateRef("refs/heads/branch");
- btu.setExpectedOldObjectId(bRc);
- btu.setNewObjectId(rcBranch);
- btu.setForceUpdate(true);
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+ RefUpdate btu2 = repo.updateRef("refs/heads/branch");
+ btu2.setExpectedOldObjectId(bRc);
+ btu2.setNewObjectId(rcBranch);
+ btu2.setForceUpdate(true);
+ testRefAction(() -> assertThat(btu2.update()).isEqualTo(RefUpdate.Result.FORCED));
}
assertUploadPackRefs(
@@ -907,7 +912,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
btu.setExpectedOldObjectId(tagRc);
btu.setNewObjectId(rcBranch);
btu.setForceUpdate(true);
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.FORCED));
}
assertUploadPackRefs(
@@ -939,7 +944,7 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
RefUpdate btu = repo.updateRef("refs/tags/updated-tag");
btu.setExpectedOldObjectId(ObjectId.zeroId());
btu.setNewObjectId(rcBranch);
- assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW);
+ testRefAction(() -> assertThat(btu.update()).isEqualTo(RefUpdate.Result.NEW));
assertUploadPackRefs(
psRef2,
@@ -995,13 +1000,16 @@ public class RefAdvertisementIT extends AbstractDaemonTest {
"refs/tags/master-tag");
// rcBranch (c2 branch)
- try (Repository repo = repoManager.openRepository(project)) {
- RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
- btu.setExpectedOldObjectId(rcBranch);
- btu.setNewObjectId(ObjectId.zeroId());
- btu.setForceUpdate(true);
- assertThat(btu.delete()).isEqualTo(RefUpdate.Result.FORCED);
- }
+ testRefAction(
+ () -> {
+ try (Repository repo = repoManager.openRepository(project)) {
+ RefUpdate btu = repo.updateRef("refs/tags/branch-tag");
+ btu.setExpectedOldObjectId(rcBranch);
+ btu.setNewObjectId(ObjectId.zeroId());
+ btu.setForceUpdate(true);
+ assertThat(btu.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ }
+ });
assertUploadPackRefs(
psRef2, metaRef2, psRef4, metaRef4, "refs/heads/branch", "refs/tags/master-tag");
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index d2aab5b398..09957b3ee7 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -16,6 +16,7 @@ package com.google.gerrit.acceptance.git;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.NoHttpd;
@@ -742,7 +743,7 @@ public class SubmoduleSubscriptionsIT extends AbstractSubmoduleSubscription {
.commit()
.author(admin.newIdent())
.committer(admin.newIdent())
- .add("rules.pl", newContent)
+ .add(RULES_PL_FILE, newContent)
.message("Modify rules.pl")
.create();
}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
new file mode 100644
index 0000000000..9d37497b10
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -0,0 +1,556 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.projects.SubmitRequirementApi;
+import com.google.gerrit.extensions.common.LabelDefinitionInfo;
+import com.google.gerrit.extensions.common.LabelDefinitionInput;
+import com.google.gerrit.extensions.common.SubmitRequirementInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement;
+import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement.Status;
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.inject.Inject;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+/** Test for {@link com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement}. */
+@Sandboxed
+public class MigrateLabelFunctionsToSubmitRequirementIT extends AbstractDaemonTest {
+
+ @Inject private ProjectOperations projectOperations;
+
+ @Test
+ public void migrateBlockingLabel_maxWithBlock() throws Exception {
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_maxNoBlock() throws Exception {
+ createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_anyWithBlock() throws Exception {
+ createLabel("Foo", "AnyWithBlock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "-label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_maxWithBlock_withIgnoreSelfApproval() throws Exception {
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ true);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_maxNoBlock_withIgnoreSelfApproval() throws Exception {
+ createLabel("Foo", "MaxNoBlock", /* ignoreSelfApproval= */ true);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateNonBlockingLabel_noBlock() throws Exception {
+ // NoBlock labels are left as is, i.e. we don't create a "submit requirement" for them. Those
+ // labels will then be treated as trigger votes in the change page.
+ createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ // No SR was created for the label. Label will be treated as a trigger vote.
+ assertNonExistentSr("Foo");
+ // Label function has not changed.
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateNonBlockingLabel_noOp() throws Exception {
+ // NoOp labels are left as is, i.e. we don't create a "submit requirement" for them. Those
+ // labels will then be treated as trigger votes in the change page.
+ createLabel("Foo", "NoOp", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ // No SR was created for the label. Label will be treated as a trigger vote.
+ assertNonExistentSr("Foo");
+ // The NoOp function is converted to NoBlock. Both are same.
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateNoBlockLabel_withSingleZeroValue() throws Exception {
+ // Labels that have a single "zero" value are skipped in the project. The migrator creates
+ // non-applicable SR for these labels.
+ createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of("0", "No vote"));
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ // a non-applicable SR was created for the skipped label.
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ "is:false",
+ /* submittabilityExpression= */ "is:true",
+ /* canOverride= */ true);
+
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateMaxWithBlockLabel_withSingleZeroValue() throws Exception {
+ // Labels that have a single "zero" value are skipped in the project. The migrator creates
+ // non-applicable SRs for these labels.
+ createLabel(
+ "Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of("0", "No vote"));
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ // a non-applicable SR was created for the skipped label.
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ "is:false",
+ /* submittabilityExpression= */ "is:true",
+ /* canOverride= */ true);
+
+ // The MaxWithBlock function is converted to NoBlock. This has no effect anyway because the
+ // label was originally skipped.
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void cannotCreateLabelsWithNoValues() {
+ // This test just asserts the server's behaviour for visibility; admins cannot create a label
+ // without any defined values.
+ Exception thrown =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ createLabel("Foo", "NoBlock", /* ignoreSelfApproval= */ false, ImmutableMap.of()));
+ assertThat(thrown).hasMessageThat().isEqualTo("values are required");
+ }
+
+ @Test
+ public void migrateNonBlockingLabel_patchSetLock_doesNothing() throws Exception {
+ createLabel("Foo", "PatchSetLock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.NO_CHANGE);
+ // No submit requirement created for the patchset lock label function
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertNonExistentSr(/* srName = */ "Foo");
+ assertLabelFunction("Foo", "PatchSetLock");
+ }
+
+ @Test
+ public void migrationIsCommittedWithServerIdent() throws Exception {
+ RevCommit oldMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+
+ RevCommit newMetaCommit = projectOperations.project(project).getHead(RefNames.REFS_CONFIG);
+ assertThat(newMetaCommit).isNotEqualTo(oldMetaCommit);
+ assertThat(newMetaCommit.getCommitterIdent().getEmailAddress())
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ }
+
+ @Test
+ public void migrateBlockingLabel_withBranchAttribute() throws Exception {
+ createLabelWithBranch(
+ "Foo",
+ "MaxWithBlock",
+ /* ignoreSelfApproval= */ false,
+ ImmutableList.of("refs/heads/master"));
+
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\"",
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_withMultipleBranchAttributes() throws Exception {
+ createLabelWithBranch(
+ "Foo",
+ "MaxWithBlock",
+ /* ignoreSelfApproval= */ false,
+ ImmutableList.of("refs/heads/master", "refs/heads/develop"));
+
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+ + "OR branch:\\\"refs/heads/develop\\\"",
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_withRegexBranchAttribute() throws Exception {
+ createLabelWithBranch(
+ "Foo",
+ "MaxWithBlock",
+ /* ignoreSelfApproval= */ false,
+ ImmutableList.of("^refs/heads/main-.*"));
+
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ "branch:\\\"^refs/heads/main-.*\\\"",
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrateBlockingLabel_withRegexAndNonRegexBranchAttributes() throws Exception {
+ createLabelWithBranch(
+ "Foo",
+ "MaxWithBlock",
+ /* ignoreSelfApproval= */ false,
+ ImmutableList.of("refs/heads/master", "^refs/heads/main-.*"));
+
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ "branch:\\\"refs/heads/master\\\" "
+ + "OR branch:\\\"^refs/heads/main-.*\\\"",
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ @Test
+ public void migrationIsIdempotent() throws Exception {
+ String oldRefsConfigId;
+ try (Repository repo = repoManager.openRepository(project)) {
+ oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+ }
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+ assertNonExistentSr(/* srName = */ "Foo");
+
+ // Running the migration causes REFS_CONFIG to change.
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(1);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+ try (Repository repo = repoManager.openRepository(project)) {
+ assertThat(oldRefsConfigId)
+ .isNotEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+ oldRefsConfigId = repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString();
+ }
+
+ // No new SRs will be created. No conflicting submit requirements either since the migration
+ // detects that a previous run was made and skips the migration.
+ updateUI = runMigration(/* expectedResult= */ Status.PREVIOUSLY_MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+ // Running the migration a second time won't update REFS_CONFIG.
+ try (Repository repo = repoManager.openRepository(project)) {
+ assertThat(oldRefsConfigId)
+ .isEqualTo(repo.exactRef(RefNames.REFS_CONFIG).getObjectId().toString());
+ }
+ }
+
+ @Test
+ public void migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_matchingWithMigration()
+ throws Exception {
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+ createSubmitRequirement("Foo", "label:Foo=MAX AND -label:Foo=MIN", /* canOverride= */ true);
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ // No new submit requirements are created.
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+ // No conflicting submit requirements from migration vs. what was previously configured.
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(0);
+
+ // The existing SR was left as is.
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "label:Foo=MAX AND -label:Foo=MIN",
+ /* canOverride= */ true);
+ }
+
+ @Test
+ public void
+ migrationDoesNotCreateANewSubmitRequirement_ifSRAlreadyExists_mismatchingWithMigration()
+ throws Exception {
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+ createSubmitRequirement("Foo", "project:" + project, /* canOverride= */ true);
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "project:" + project,
+ /* canOverride= */ true);
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ // One conflicting submit requirement between migration vs. what was previously configured.
+ assertThat(updateUI.existingSrsMismatchingWithMigration).isEqualTo(1);
+
+ // The existing SR was left as is.
+ assertExistentSr(
+ /* srName */ "Foo",
+ /* applicabilityExpression= */ null,
+ /* submittabilityExpression= */ "project:" + project,
+ /* canOverride= */ true);
+ }
+
+ @Test
+ public void migrationResetsBlockingLabel_ifSRAlreadyExists() throws Exception {
+ createLabel("Foo", "MaxWithBlock", /* ignoreSelfApproval= */ false);
+ createSubmitRequirement("Foo", "owner:" + admin.email(), /* canOverride= */ true);
+
+ TestUpdateUI updateUI = runMigration(/* expectedResult= */ Status.MIGRATED);
+ assertThat(updateUI.newlyCreatedSrs).isEqualTo(0);
+
+ // The label function was reset
+ assertLabelFunction("Foo", "NoBlock");
+ }
+
+ private TestUpdateUI runMigration(Status expectedResult) throws Exception {
+ TestUpdateUI updateUi = new TestUpdateUI();
+ MigrateLabelFunctionsToSubmitRequirement executor =
+ new MigrateLabelFunctionsToSubmitRequirement(repoManager, serverIdent.get());
+ Status status = executor.executeMigration(project, updateUi);
+ assertThat(status).isEqualTo(expectedResult);
+ projectCache.evictAndReindex(project);
+ return updateUi;
+ }
+
+ private void createLabel(String labelName, String function, boolean ignoreSelfApproval)
+ throws Exception {
+ createLabel(
+ labelName,
+ function,
+ ignoreSelfApproval,
+ ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad"));
+ }
+
+ private void createLabel(
+ String labelName, String function, boolean ignoreSelfApproval, Map<String, String> values)
+ throws Exception {
+ LabelDefinitionInput input = new LabelDefinitionInput();
+ input.name = labelName;
+ input.function = function;
+ input.ignoreSelfApproval = ignoreSelfApproval;
+ input.values = values;
+ gApi.projects().name(project.get()).label(labelName).create(input);
+ }
+
+ private void createLabelWithBranch(
+ String labelName,
+ String function,
+ boolean ignoreSelfApproval,
+ ImmutableList<String> refPatterns)
+ throws Exception {
+ LabelDefinitionInput input = new LabelDefinitionInput();
+ input.name = labelName;
+ input.function = function;
+ input.ignoreSelfApproval = ignoreSelfApproval;
+ input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+ input.branches = refPatterns;
+ gApi.projects().name(project.get()).label(labelName).create(input);
+ }
+
+ @CanIgnoreReturnValue
+ private SubmitRequirementApi createSubmitRequirement(
+ String name, String submitExpression, boolean canOverride) throws Exception {
+ SubmitRequirementInput input = new SubmitRequirementInput();
+ input.name = name;
+ input.submittabilityExpression = submitExpression;
+ input.allowOverrideInChildProjects = canOverride;
+ return gApi.projects().name(project.get()).submitRequirement(name).create(input);
+ }
+
+ private void assertLabelFunction(String labelName, String function) throws Exception {
+ LabelDefinitionInfo info = gApi.projects().name(project.get()).label(labelName).get();
+ assertThat(info.function).isEqualTo(function);
+ }
+
+ private void assertNonExistentSr(String srName) {
+ ResourceNotFoundException foo =
+ assertThrows(
+ ResourceNotFoundException.class,
+ () -> gApi.projects().name(project.get()).submitRequirement("Foo").get());
+ assertThat(foo.getMessage()).isEqualTo("Submit requirement '" + srName + "' does not exist");
+ }
+
+ private void assertExistentSr(
+ String srName,
+ String applicabilityExpression,
+ String submittabilityExpression,
+ boolean canOverride)
+ throws Exception {
+ SubmitRequirementInfo sr = gApi.projects().name(project.get()).submitRequirement(srName).get();
+ assertThat(sr.applicabilityExpression).isEqualTo(applicabilityExpression);
+ assertThat(sr.submittabilityExpression).isEqualTo(submittabilityExpression);
+ assertThat(sr.allowOverrideInChildProjects).isEqualTo(canOverride);
+ }
+
+ private static class TestUpdateUI implements UpdateUI {
+ int existingSrsMismatchingWithMigration = 0;
+ int newlyCreatedSrs = 0;
+
+ @Override
+ public void message(String message) {
+ if (message.startsWith("Warning")) {
+ existingSrsMismatchingWithMigration += 1;
+ } else if (message.startsWith("Project")) {
+ newlyCreatedSrs += 1;
+ }
+ }
+
+ @Override
+ public boolean yesno(boolean defaultValue, String message) {
+ return false;
+ }
+
+ @Override
+ public void waitForUser() {}
+
+ @Override
+ public String readString(String defaultValue, Set<String> allowedValues, String message) {
+ return null;
+ }
+
+ @Override
+ public boolean isBatch() {
+ return false;
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index 4efdbbaa1e..a0ae91b676 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -417,6 +417,16 @@ public class RestApiServletIT extends AbstractDaemonTest {
}
}
+ @Test
+ public void requestsOnRootCollectionDontRequireTrailingSlash() throws Exception {
+ adminRestSession.get("/access").assertOK();
+ adminRestSession.get("/accounts?q=is:active").assertOK();
+ adminRestSession.get("/changes?q=status:open").assertOK();
+ // GET on /config/ is not supported, hence we cannot test GET on /config
+ adminRestSession.get("/groups").assertOK();
+ adminRestSession.get("/projects").assertOK();
+ }
+
private ObjectId getMetaRefSha1(Result change) {
return change.getChange().notes().getRevision();
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 64e37629ac..9710bf4088 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -43,6 +43,7 @@ import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.events.ChangeIndexedListener;
import com.google.gerrit.httpd.restapi.ParameterParser;
import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.ExceptionHook;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.WorkQueue;
import com.google.gerrit.server.git.validators.CommitValidationException;
@@ -801,7 +802,7 @@ public class TraceIT extends AbstractDaemonTest {
@Test
@GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
- public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+ public void autoRetryWithTrace() throws Exception {
String changeId = createChange().getChangeId();
approve(changeId);
@@ -811,6 +812,49 @@ public class TraceIT extends AbstractDaemonTest {
RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).startsWith("retry-on-failure-");
+ assertThat(traceSubmitRule.traceId).startsWith("retry-on-failure-");
+ assertThat(traceSubmitRule.isLoggingForced).isTrue();
+ }
+ }
+
+ @Test
+ @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
+ public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
+ String changeId = createChange().getChangeId();
+ approve(changeId);
+
+ TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+ traceSubmitRule.failAlways = true;
+ try (Registration registration =
+ extensionRegistry
+ .newRegistration()
+ .add(traceSubmitRule)
+ .add(
+ new ExceptionHook() {
+ @Override
+ public boolean shouldRetry(String actionType, String actionName, Throwable t) {
+ return true;
+ }
+ })) {
+ RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+ assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+ assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+ assertThat(traceSubmitRule.traceId).isNull();
+ assertThat(traceSubmitRule.isLoggingForced).isFalse();
+ }
+ }
+
+ @Test
+ public void noAutoRetryWithTraceIfDisabled() throws Exception {
+ String changeId = createChange().getChangeId();
+ approve(changeId);
+
+ TraceSubmitRule traceSubmitRule = new TraceSubmitRule();
+ traceSubmitRule.failOnce = true;
+ try (Registration registration = extensionRegistry.newRegistration().add(traceSubmitRule)) {
+ RestResponse response = adminRestSession.post("/changes/" + changeId + "/submit");
+ assertThat(response.getStatusCode()).isEqualTo(SC_INTERNAL_SERVER_ERROR);
+ assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
assertThat(traceSubmitRule.traceId).isNull();
assertThat(traceSubmitRule.isLoggingForced).isFalse();
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index 7598062b2a..3ce6d8d87f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -38,6 +38,7 @@ class CapabilityInfo {
public boolean viewConnections;
public boolean viewPlugins;
public boolean viewQueue;
+ public boolean viewSecondaryEmails;
static class QueryLimit {
short min;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index d0558759ae..f40910a8f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -33,6 +33,7 @@ import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.RefLogIdentityProvider;
import com.google.gerrit.server.ServerInitiated;
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.DefaultRealm;
@@ -51,12 +52,14 @@ import com.google.gson.reflect.TypeToken;
import com.google.inject.Inject;
import com.google.inject.Provider;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import org.junit.Test;
public class EmailIT extends AbstractDaemonTest {
@Inject private @AnonymousCowardName String anonymousCowardName;
+ @Inject private RefLogIdentityProvider refLogIdentityProvider;
@Inject private @CanonicalWebUrl Provider<String> canonicalUrl;
@Inject private @EnablePeerIPInReflogRecord boolean enablePeerIPInReflogRecord;
@Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
@@ -177,7 +180,7 @@ public class EmailIT extends AbstractDaemonTest {
assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
requestScopeOperations.resetCurrentApiUser();
- String emailOtherCase = email.toUpperCase();
+ String emailOtherCase = email.toUpperCase(Locale.US);
gApi.accounts().self().email(emailOtherCase).setPreferred();
assertThat(gApi.accounts().self().get().email).isEqualTo(email);
}
@@ -283,6 +286,7 @@ public class EmailIT extends AbstractDaemonTest {
authConfig,
realm,
anonymousCowardName,
+ refLogIdentityProvider,
canonicalUrl,
enablePeerIPInReflogRecord,
accountCache,
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 39a32af98e..61164f787e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -949,7 +949,7 @@ public class ExternalIdIT extends AbstractDaemonTest {
extIdNotes.insert(extId);
extIdNotes.commit(md);
assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
- assertThat(getExternalId(extIdNotes, scheme, id.toLowerCase()).isPresent()).isFalse();
+ assertThat(getExternalId(extIdNotes, scheme, id.toLowerCase(Locale.US)).isPresent()).isFalse();
}
private void testCaseInsensitiveExternalIdKey(
@@ -959,7 +959,8 @@ public class ExternalIdIT extends AbstractDaemonTest {
extIdNotes.insert(extId);
extIdNotes.commit(md);
assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
- assertThat(getAccountId(extIdNotes, scheme, id.toLowerCase())).isEqualTo(accountId.get());
+ assertThat(getAccountId(extIdNotes, scheme, id.toLowerCase(Locale.US)))
+ .isEqualTo(accountId.get());
}
/**
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index c89e11a417..b21f97d6b5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -91,16 +91,26 @@ public class GetAccountDetailIT extends AbstractDaemonTest {
Account.Id id =
accountOperations
.newAccount()
- .preferredEmail("preferred@email")
- .addSecondaryEmail("secondary@email")
+ .preferredEmail("preferred@eexample.com")
+ .addSecondaryEmail("secondary@example.com")
.create();
+
RestResponse r = userRestSession.get("/accounts/secondary/detail/");
r.assertStatus(404);
+
+ r = userRestSession.get("/accounts/secondary@example.com/detail/");
+ r.assertStatus(404);
+
// The admin has MODIFY_ACCOUNT permission and can see the user.
r = adminRestSession.get("/accounts/secondary/detail/");
r.assertStatus(200);
AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
assertThat(info._accountId).isEqualTo(id.get());
+
+ r = adminRestSession.get("/accounts/secondary@example.com/detail/");
+ r.assertStatus(200);
+ info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+ assertThat(info._accountId).isEqualTo(id.get());
}
private static class CustomAccountTagProvider implements AccountTagProvider {
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index eb827c0fc1..44771405eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -20,6 +20,8 @@ import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.a
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
+import static com.google.gerrit.entities.RefNames.changeMetaRef;
+import static com.google.gerrit.entities.RefNames.patchSetRef;
import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -28,11 +30,13 @@ import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.RestSession;
import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseLocalDisk;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -43,8 +47,10 @@ import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -55,6 +61,7 @@ import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.changes.SubmitInput;
import com.google.gerrit.extensions.api.groups.GroupInput;
import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -63,6 +70,7 @@ 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.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.CommentsUtil;
@@ -73,6 +81,11 @@ import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import org.apache.http.Header;
import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ReflogEntry;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@@ -105,28 +118,210 @@ public class ImpersonationIT extends AbstractDaemonTest {
}
@Test
+ @UseLocalDisk
public void voteOnBehalfOf() throws Exception {
allowCodeReviewOnBehalfOf();
+ TestAccount realUser = admin;
+ TestAccount impersonatedUser = user;
PushOneCommit.Result r = createChange();
RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+ try (Repository repo = repoManager.openRepository(project)) {
+ String changeMetaRef = changeMetaRef(r.getChange().getId());
+ createRefLogFileIfMissing(repo, changeMetaRef);
+
+ ReviewInput in = ReviewInput.recommend();
+ in.onBehalfOf = impersonatedUser.id().toString();
+ in.message = "Message on behalf of";
+ revision.review(in);
+
+ PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(1);
+ assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+ // The change meta commit is created by the server and has the impersonated user as the
+ // author.
+ // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+ RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+ assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+ .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+ // The ref log for the change meta ref records the impersonated user.
+ ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+ assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+ .isEqualTo(impersonatedUser.email());
+ }
+ }
+
+ @Test
+ public void overrideImpersonatedVoteWithOtherImpersonatedVote_sameValue() throws Exception {
+ allowCodeReviewOnBehalfOf();
+ TestAccount realUser = admin;
+ TestAccount realUser2 = admin2;
+ TestAccount impersonatedUser = user;
+ PushOneCommit.Result r = createChange();
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+ // realUser votes Code-Review+1 on behalf of impersonatedUser
ReviewInput in = ReviewInput.recommend();
- in.onBehalfOf = user.id().toString();
+ in.onBehalfOf = impersonatedUser.id().toString();
in.message = "Message on behalf of";
revision.review(in);
PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
assertThat(psa.patchSetId().get()).isEqualTo(1);
assertThat(psa.label()).isEqualTo("Code-Review");
- assertThat(psa.accountId()).isEqualTo(user.id());
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
assertThat(psa.value()).isEqualTo(1);
- assertThat(psa.realAccountId()).isEqualTo(admin.id());
+ assertThat(psa.realAccountId()).isEqualTo(realUser.id());
- ChangeData cd = r.getChange();
- ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
- assertThat(m.getMessage()).endsWith(in.message);
- assertThat(m.getAuthor()).isEqualTo(user.id());
- assertThat(m.getRealAuthor()).isEqualTo(admin.id());
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+ // realUser2 votes Code-Review+1 on behalf of impersonatedUser, this should override the
+ // impersonated Code-Review+1 of realUser with an impersonated Code-Review+1 of realUser2
+ requestScopeOperations.setApiUser(realUser2.id());
+ in = ReviewInput.recommend();
+ in.onBehalfOf = impersonatedUser.id().toString();
+ in.message = "Another message on behalf of";
+ gApi.changes().id(r.getChangeId()).current().review(in);
+
+ psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(1);
+ assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser2);
+ }
+
+ @Test
+ public void overrideImpersonatedVoteWithOtherImpersonatedVote_differentValue() throws Exception {
+ allowCodeReviewOnBehalfOf();
+ TestAccount realUser = admin;
+ TestAccount realUser2 = admin2;
+ TestAccount impersonatedUser = user;
+ PushOneCommit.Result r = createChange();
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+ // realUser votes Code-Review+1 on behalf of impersonatedUser
+ ReviewInput in = ReviewInput.recommend();
+ in.onBehalfOf = impersonatedUser.id().toString();
+ in.message = "Message on behalf of";
+ revision.review(in);
+
+ PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(1);
+ assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+ // realUser2 votes Code-Review-1 on behalf of impersonatedUser, this should override the
+ // impersonated Code-Review+1 of realUser with an impersonated Code-Review-1 of realUser2
+ requestScopeOperations.setApiUser(realUser2.id());
+ in = ReviewInput.dislike();
+ in.onBehalfOf = impersonatedUser.id().toString();
+ in.message = "Another message on behalf of";
+ gApi.changes().id(r.getChangeId()).current().review(in);
+
+ psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(-1);
+ assertThat(psa.realAccountId()).isEqualTo(realUser2.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser2);
+ }
+
+ @Test
+ public void overrideImpersonatedVoteWithNonImpersonatedVote_sameValue() throws Exception {
+ allowCodeReviewOnBehalfOf();
+ TestAccount realUser = admin;
+ TestAccount impersonatedUser = user;
+ PushOneCommit.Result r = createChange();
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+ // realUser votes Code-Review+1 on behalf of impersonatedUser
+ ReviewInput in = ReviewInput.recommend();
+ in.onBehalfOf = impersonatedUser.id().toString();
+ in.message = "Message on behalf of";
+ revision.review(in);
+
+ PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(1);
+ assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+ // impersonatedUser votes Code-Review+1 themselves, this should override the impersonated
+ // Code-Review+1 with a non-impersonated Code-Review+1
+ requestScopeOperations.setApiUser(impersonatedUser.id());
+ in = ReviewInput.recommend();
+ in.message = "Message";
+ gApi.changes().id(r.getChangeId()).current().review(in);
+
+ psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(1);
+ assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, impersonatedUser);
+ }
+
+ @Test
+ public void overrideImpersonatedVoteWithNonImpersonatedVote_differentValue() throws Exception {
+ allowCodeReviewOnBehalfOf();
+ TestAccount realUser = admin;
+ TestAccount impersonatedUser = user;
+ PushOneCommit.Result r = createChange();
+ RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+
+ // realUser votes Code-Review+1 on behalf of impersonatedUser
+ ReviewInput in = ReviewInput.recommend();
+ in.onBehalfOf = impersonatedUser.id().toString();
+ in.message = "Message on behalf of";
+ revision.review(in);
+
+ PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(1);
+ assertThat(psa.realAccountId()).isEqualTo(realUser.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser);
+
+ // impersonatedUser votes Code-Review-1 themselves, this should override the impersonated
+ // Code-Review+1 with a non-impersonated Code-Review-1
+ requestScopeOperations.setApiUser(impersonatedUser.id());
+ in = ReviewInput.dislike();
+ in.message = "Message";
+ gApi.changes().id(r.getChangeId()).current().review(in);
+
+ psa = Iterables.getOnlyElement(r.getChange().approvals().values());
+ assertThat(psa.patchSetId().get()).isEqualTo(1);
+ assertThat(psa.label()).isEqualTo("Code-Review");
+ assertThat(psa.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(psa.value()).isEqualTo(-1);
+ assertThat(psa.realAccountId()).isEqualTo(impersonatedUser.id());
+
+ assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, impersonatedUser);
}
@Test
@@ -342,21 +537,122 @@ public class ImpersonationIT extends AbstractDaemonTest {
}
@Test
- public void submitOnBehalfOf() throws Exception {
- allowSubmitOnBehalfOf();
- PushOneCommit.Result r = createChange();
+ @UseLocalDisk
+ public void submitOnBehalfOf_mergeAlways() throws Exception {
+ TestAccount realUser = admin;
+ TestAccount impersonatedUser = admin2;
+
+ // Create a project with MERGE_ALWAYS submit strategy so that a merge commit is created on
+ // submit and we can verify its committer and author and the ref log for the update of the
+ // target branch.
+ Project.NameKey project =
+ projectOperations.newProject().submitType(SubmitType.MERGE_ALWAYS).create();
+
+ testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+ // The merge commit is created by the server and has the impersonated user as the author.
+ RevCommit mergeCommit = projectOperations.project(project).getHead("refs/heads/master");
+ assertThat(mergeCommit.getCommitterIdent().getEmailAddress())
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ assertThat(mergeCommit.getAuthorIdent().getEmailAddress()).isEqualTo(impersonatedUser.email());
+
+ // The ref log for the target branch records the impersonated user.
+ try (Repository repo = repoManager.openRepository(project)) {
+ ReflogEntry targetBranchRefLogEntry =
+ repo.getReflogReader("refs/heads/master").getLastEntry();
+ assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+ .isEqualTo(impersonatedUser.email());
+ }
+ }
+
+ @Test
+ @UseLocalDisk
+ public void submitOnBehalfOf_rebaseAlways() throws Exception {
+ TestAccount originalAuthor = admin; // user that creates and authors the change that is rebased
+ TestAccount realUser = admin2;
+ TestAccount impersonatedUser = user;
+
+ // Create a project with REBASE_ALWAYS submit strategy so that a new patch set is created on
+ // submit and we can verify its committer and author and the ref log for the update of the
+ // patch set ref and the target branch.
+ Project.NameKey project =
+ projectOperations.newProject().submitType(SubmitType.REBASE_ALWAYS).create();
+
+ ChangeData cd = testSubmitOnBehalfOf(project, realUser, impersonatedUser);
+
+ // Rebase on submit is expected to create a new patch set.
+ assertThat(cd.currentPatchSet().id().get()).isEqualTo(2);
+
+ // The patch set commit is created by the impersonated user and has the author of the rebased
+ // commit as the author.
+ RevCommit newPatchSetCommit =
+ projectOperations.project(project).getHead(cd.currentPatchSet().refName());
+ assertThat(newPatchSetCommit.getCommitterIdent().getEmailAddress())
+ .isEqualTo(impersonatedUser.email());
+ assertThat(newPatchSetCommit.getAuthorIdent().getEmailAddress())
+ .isEqualTo(originalAuthor.email());
+
+ try (Repository repo = repoManager.openRepository(project)) {
+ // The ref log for the patch set ref records the impersonated user.
+ ReflogEntry patchSetRefLogEntry =
+ repo.getReflogReader(cd.currentPatchSet().refName()).getLastEntry();
+ assertThat(patchSetRefLogEntry.getWho().getEmailAddress())
+ .isEqualTo(impersonatedUser.email());
+
+ // The ref log for the target branch records the impersonated user.
+ ReflogEntry targetBranchRefLogEntry =
+ repo.getReflogReader("refs/heads/master").getLastEntry();
+ assertThat(targetBranchRefLogEntry.getWho().getEmailAddress())
+ .isEqualTo(impersonatedUser.email());
+ }
+ }
+
+ @CanIgnoreReturnValue
+ private ChangeData testSubmitOnBehalfOf(
+ Project.NameKey project, TestAccount realUser, TestAccount impersonatedUser)
+ throws Exception {
+ allowSubmitOnBehalfOf(project);
+
+ TestRepository<InMemoryRepository> testRepo = cloneProject(project, realUser);
+
+ PushOneCommit.Result r = createChange(testRepo);
String changeId = project.get() + "~master~" + r.getChangeId();
gApi.changes().id(changeId).current().review(ReviewInput.approve());
SubmitInput in = new SubmitInput();
- in.onBehalfOf = admin2.email();
- gApi.changes().id(changeId).current().submit(in);
-
- ChangeData cd = r.getChange();
- assertThat(cd.change().isMerged()).isTrue();
- PatchSetApproval submitter =
- approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
- assertThat(submitter.accountId()).isEqualTo(admin2.id());
- assertThat(submitter.realAccountId()).isEqualTo(admin.id());
+ in.onBehalfOf = impersonatedUser.email();
+
+ try (Repository repo = repoManager.openRepository(project)) {
+ String changeMetaRef = changeMetaRef(r.getChange().getId());
+ createRefLogFileIfMissing(repo, changeMetaRef);
+ createRefLogFileIfMissing(repo, "refs/heads/master");
+ createRefLogFileIfMissing(repo, patchSetRef(PatchSet.id(r.getChange().getId(), 2)));
+
+ requestScopeOperations.setApiUser(realUser.id());
+ gApi.changes().id(changeId).current().submit(in);
+
+ ChangeData cd = r.getChange();
+ assertThat(cd.change().isMerged()).isTrue();
+ PatchSetApproval submitter =
+ approvalsUtil.getSubmitter(cd.notes(), cd.change().currentPatchSetId());
+ assertThat(submitter.accountId()).isEqualTo(impersonatedUser.id());
+ assertThat(submitter.realAccountId()).isEqualTo(realUser.id());
+
+ // The change meta commit is created by the server and has the impersonated user as the
+ // author.
+ // Person idents of users in NoteDb commits are obfuscated due to privacy reasons.
+ RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef);
+ assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress())
+ .isEqualTo(serverIdent.get().getEmailAddress());
+ assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress())
+ .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id()));
+
+ // The ref log for the change meta ref records the impersonated user.
+ ReflogEntry changeMetaRefLogEntry = repo.getReflogReader(changeMetaRef).getLastEntry();
+ assertThat(changeMetaRefLogEntry.getWho().getEmailAddress())
+ .isEqualTo(impersonatedUser.email());
+
+ return cd;
+ }
}
@Test
@@ -548,11 +844,7 @@ public class ImpersonationIT extends AbstractDaemonTest {
assertThat(psa.value()).isEqualTo(1);
assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2
- ChangeData cd = r.getChange();
- ChangeMessage m = Iterables.getLast(cmUtil.byChange(cd.notes()));
- assertThat(m.getMessage()).endsWith(in.message);
- assertThat(m.getAuthor()).isEqualTo(user.id());
- assertThat(m.getRealAuthor()).isEqualTo(admin.id()); // not user2
+ assertLastChangeMessage(r.getChange(), in.message, user, admin);
}
@Test
@@ -571,10 +863,30 @@ public class ImpersonationIT extends AbstractDaemonTest {
ChangeInfo info = gApi.changes().id(r.getChangeId()).get(MESSAGES);
assertThat(info.messages).hasSize(2);
- ChangeMessageInfo changeMessageInfo = Iterables.getLast(info.messages);
- assertThat(changeMessageInfo.realAuthor).isNotNull();
- assertThat(changeMessageInfo.realAuthor._accountId)
- .isEqualTo(accountCreator.user2().id().get());
+ assertLastChangeMessage(r.getChange(), in.message, user, accountCreator.user2());
+ }
+
+ private void assertLastChangeMessage(
+ ChangeData changeData,
+ String expectedMessage,
+ TestAccount expectedAuthor,
+ TestAccount expectedRealAuthor)
+ throws RestApiException {
+ ChangeMessage m = Iterables.getLast(cmUtil.byChange(changeData.notes()));
+ assertThat(m.getMessage()).endsWith(expectedMessage);
+ assertThat(m.getAuthor()).isEqualTo(expectedAuthor.id());
+ assertThat(m.getRealAuthor()).isEqualTo(expectedRealAuthor.id());
+
+ ChangeMessageInfo lastChangeMessageInfo =
+ Iterables.getLast(gApi.changes().id(changeData.getId().get()).get().messages);
+ assertThat(lastChangeMessageInfo.message).endsWith(expectedMessage);
+ assertThat(lastChangeMessageInfo.author._accountId).isEqualTo(expectedAuthor.id().get());
+ if (expectedAuthor.id().equals(expectedRealAuthor.id())) {
+ assertThat(lastChangeMessageInfo.realAuthor).isNull();
+ } else {
+ assertThat(lastChangeMessageInfo.realAuthor._accountId)
+ .isEqualTo(expectedRealAuthor.id().get());
+ }
}
private void allowCodeReviewOnBehalfOf() throws Exception {
@@ -591,6 +903,10 @@ public class ImpersonationIT extends AbstractDaemonTest {
}
private void allowSubmitOnBehalfOf() throws Exception {
+ allowSubmitOnBehalfOf(project);
+ }
+
+ private void allowSubmitOnBehalfOf(Project.NameKey project) throws Exception {
String heads = "refs/heads/*";
projectOperations
.project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index f46cf0ca9c..f4e94572ee 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -20,6 +20,7 @@ import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.extensions.api.accounts.UsernameInput;
+import java.util.Locale;
import org.junit.Test;
public class PutUsernameIT extends AbstractDaemonTest {
@@ -46,7 +47,7 @@ public class PutUsernameIT extends AbstractDaemonTest {
@GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
public void setExistingCaseInsensitive_Conflict() throws Exception {
UsernameInput in = new UsernameInput();
- in.username = admin.username().toUpperCase();
+ in.username = admin.username().toUpperCase(Locale.US);
adminRestSession
.put("/accounts/" + accountCreator.create().id().get() + "/username", in)
.assertConflict();
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 2d663dfabb..27bd6b9f09 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -65,10 +65,6 @@ public class ChangesRestApiBindingsIT extends AbstractDaemonTest {
RestCall.get("/changes/%s/drafts"),
RestCall.get("/changes/%s/attention"),
RestCall.post("/changes/%s/attention"),
- RestCall.get("/changes/%s/assignee"),
- RestCall.get("/changes/%s/past_assignees"),
- RestCall.put("/changes/%s/assignee"),
- RestCall.delete("/changes/%s/assignee"),
RestCall.post("/changes/%s/private"),
RestCall.post("/changes/%s/private.delete"),
RestCall.delete("/changes/%s/private"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 0e4f2124c9..f9fb92c687 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -95,6 +95,8 @@ import com.google.gerrit.server.restapi.change.Submit;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
+import com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.server.validators.ValidationException;
import com.google.gerrit.testing.ConfigSuite;
@@ -1125,20 +1127,22 @@ public abstract class AbstractSubmit extends AbstractDaemonTest {
}
private void setChangeStatusToNew(PushOneCommit.Result... changes) throws Throwable {
- for (PushOneCommit.Result change : changes) {
- try (BatchUpdate bu =
- batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
- bu.addOp(
- change.getChange().getId(),
- new BatchUpdateOp() {
- @Override
- public boolean updateChange(ChangeContext ctx) {
- ctx.getChange().setStatus(Change.Status.NEW);
- ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
- return true;
- }
- });
- bu.execute();
+ try (RefUpdateContext ctx = RefUpdateContext.open(RefUpdateType.CHANGE_MODIFICATION)) {
+ for (PushOneCommit.Result change : changes) {
+ try (BatchUpdate bu =
+ batchUpdateFactory.create(project, userFactory.create(admin.id()), TimeUtil.now())) {
+ bu.addOp(
+ change.getChange().getId(),
+ new BatchUpdateOp() {
+ @Override
+ public boolean updateChange(ChangeContext ctx) {
+ ctx.getChange().setStatus(Change.Status.NEW);
+ ctx.getUpdate(ctx.getChange().currentPatchSetId()).setStatus(Change.Status.NEW);
+ return true;
+ }
+ });
+ bu.execute();
+ }
}
}
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index 81c098f21b..aeebc108d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -234,11 +234,11 @@ public abstract class AbstractSubmitByRebase extends AbstractSubmit {
PushOneCommit.Result change2 = createChange("Change 2", "a.txt", "other content");
submitWithConflict(
change2.getChangeId(),
- "Cannot rebase "
- + change2.getCommit().name()
- + ": The change could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n"
- + "a.txt");
+ String.format(
+ "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+ + "merge conflict(s):\n"
+ + "a.txt",
+ change2.getCommit().name(), change2.getChange().getId()));
RevCommit head = projectOperations.project(project).getHead("master");
assertThat(head).isEqualTo(headAfterFirstSubmit);
assertCurrentRevision(change2.getChangeId(), 1, change2.getCommit());
@@ -362,12 +362,11 @@ public abstract class AbstractSubmitByRebase extends AbstractSubmit {
submitWithConflict(
change2.getChangeId(),
- "Cannot rebase "
- + change2.getCommit().getName()
- + ": "
- + "The change could not be rebased due to a conflict during merge.\n\n"
- + "merge conflict(s):\n"
- + "fileName 2");
+ String.format(
+ "Cannot rebase %s: Change %s could not be rebased due to a conflict during merge.\n\n"
+ + "merge conflict(s):\n"
+ + "fileName 2",
+ change2.getCommit().name(), change2.getChange().getId()));
assertThat(projectOperations.project(project).getHead("master")).isEqualTo(headAfterChange1);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
deleted file mode 100644
index be94cdf16e..0000000000
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.acceptance.rest.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.acceptance.UseClockStep;
-import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
-import com.google.gerrit.extensions.client.ReviewerState;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
-import com.google.gerrit.testing.FakeEmailSender.Message;
-import com.google.inject.Inject;
-import java.util.Iterator;
-import java.util.List;
-import org.eclipse.jgit.transport.RefSpec;
-import org.junit.Test;
-
-@NoHttpd
-@UseClockStep
-public class AssigneeIT extends AbstractDaemonTest {
- @Inject private ProjectOperations projectOperations;
- @Inject private RequestScopeOperations requestScopeOperations;
-
- @Test
- public void getNoAssignee() throws Exception {
- PushOneCommit.Result r = createChange();
- assertThat(getAssignee(r)).isNull();
- }
-
- @Test
- public void addGetAssignee() throws Exception {
- PushOneCommit.Result r = createChange();
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- assertThat(getAssignee(r)._accountId).isEqualTo(user.id().get());
-
- assertThat(sender.getMessages()).hasSize(1);
- Message m = sender.getMessages().get(0);
- assertThat(m.rcpt()).containsExactly(user.getNameEmail());
- }
-
- @Test
- public void setNewAssigneeWhenExists() throws Exception {
- PushOneCommit.Result r = createChange();
- setAssignee(r, user.email());
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- }
-
- @Test
- public void getPastAssignees() throws Exception {
- PushOneCommit.Result r = createChange();
- setAssignee(r, user.email());
- setAssignee(r, admin.email());
- List<AccountInfo> assignees = getPastAssignees(r);
- assertThat(assignees).hasSize(2);
- Iterator<AccountInfo> itr = assignees.iterator();
- assertThat(itr.next()._accountId).isEqualTo(user.id().get());
- assertThat(itr.next()._accountId).isEqualTo(admin.id().get());
- }
-
- @Test
- public void assigneeAddedAsCc() throws Exception {
- PushOneCommit.Result r = createChange();
- Iterable<AccountInfo> reviewers = getReviewers(r, ReviewerState.CC);
- assertThat(reviewers).isNull();
-
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- reviewers = getReviewers(r, ReviewerState.CC);
- assertThat(reviewers).hasSize(1);
- assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
- assertThat(getReviewers(r, ReviewerState.REVIEWER)).isNull();
- }
-
- @Test
- public void assigneeStaysReviewer() throws Exception {
- PushOneCommit.Result r = createChange();
- gApi.changes().id(r.getChangeId()).addReviewer(user.email());
- Iterable<AccountInfo> reviewers = getReviewers(r, ReviewerState.REVIEWER);
- assertThat(reviewers).hasSize(1);
- assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
- assertThat(getReviewers(r, ReviewerState.CC)).isNull();
-
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- reviewers = getReviewers(r, ReviewerState.REVIEWER);
- assertThat(reviewers).hasSize(1);
- assertThat(Iterables.getFirst(reviewers, null)._accountId).isEqualTo(user.id().get());
- assertThat(getReviewers(r, ReviewerState.CC)).isNull();
- }
-
- @Test
- public void setAlreadyExistingAssignee() throws Exception {
- PushOneCommit.Result r = createChange();
- setAssignee(r, user.email());
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- }
-
- @Test
- public void deleteAssignee() throws Exception {
- PushOneCommit.Result r = createChange();
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- assertThat(deleteAssignee(r)._accountId).isEqualTo(user.id().get());
- assertThat(getAssignee(r)).isNull();
- }
-
- @Test
- public void deleteAssigneeWhenNoAssignee() throws Exception {
- PushOneCommit.Result r = createChange();
- assertThat(deleteAssignee(r)).isNull();
- }
-
- @Test
- public void setAssigneeToInactiveUser() throws Exception {
- PushOneCommit.Result r = createChange();
- gApi.accounts().id(user.id().get()).setActive(false);
- UnresolvableAccountException thrown =
- assertThrows(UnresolvableAccountException.class, () -> setAssignee(r, user.email()));
- assertThat(thrown)
- .hasMessageThat()
- .isEqualTo(
- "Account '"
- + user.email()
- + "' only matches inactive accounts. To use an inactive account, retry with one"
- + " of the following exact account IDs:\n"
- + user.id()
- + ": User1 <user1@example.com>");
- }
-
- @Test
- public void setAssigneeToInactiveUserById() throws Exception {
- PushOneCommit.Result r = createChange();
- gApi.accounts().id(user.id().get()).setActive(false);
- setAssignee(r, user.id().toString());
- assertThat(getAssignee(r)._accountId).isEqualTo(user.id().get());
- }
-
- @Test
- public void setAssigneeForNonVisibleChange() throws Exception {
- git().fetch().setRefSpecs(new RefSpec("refs/meta/config:refs/meta/config")).call();
- testRepo.reset(RefNames.REFS_CONFIG);
- PushOneCommit.Result r = createChange("refs/for/refs/meta/config");
- AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
- assertThat(thrown).hasMessageThat().contains("read not permitted");
- }
-
- @Test
- public void setAssigneeNotAllowedWithoutPermission() throws Exception {
- PushOneCommit.Result r = createChange();
- requestScopeOperations.setApiUser(user.id());
- AuthException thrown = assertThrows(AuthException.class, () -> setAssignee(r, user.email()));
- assertThat(thrown).hasMessageThat().contains("not permitted");
- }
-
- @Test
- public void setAssigneeAllowedWithPermission() throws Exception {
- PushOneCommit.Result r = createChange();
- projectOperations
- .project(project)
- .forUpdate()
- .add(allow(Permission.EDIT_ASSIGNEE).ref("refs/heads/master").group(REGISTERED_USERS))
- .update();
- requestScopeOperations.setApiUser(user.id());
- assertThat(setAssignee(r, user.email())._accountId).isEqualTo(user.id().get());
- }
-
- private AccountInfo getAssignee(PushOneCommit.Result r) throws Exception {
- return change(r).getAssignee();
- }
-
- private List<AccountInfo> getPastAssignees(PushOneCommit.Result r) throws Exception {
- return change(r).getPastAssignees();
- }
-
- private Iterable<AccountInfo> getReviewers(PushOneCommit.Result r, ReviewerState state)
- throws Exception {
- return get(r.getChangeId(), DETAILED_LABELS).reviewers.get(state);
- }
-
- private AccountInfo setAssignee(PushOneCommit.Result r, String identifieer) throws Exception {
- AssigneeInput input = new AssigneeInput();
- input.assignee = identifieer;
- return change(r).setAssignee(input);
- }
-
- private AccountInfo deleteAssignee(PushOneCommit.Result r) throws Exception {
- return change(r).deleteAssignee();
- }
-}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index ea526904af..824e01ef38 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -982,6 +982,27 @@ public class AttentionSetIT extends AbstractDaemonTest {
}
@Test
+ public void robotRepliesDoNotAddToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).addReviewer(user.email());
+
+ TestAccount robot =
+ accountCreator.create(
+ "robot1",
+ "robot1@example.com",
+ "Ro Bot",
+ "Ro",
+ ServiceUserClassifier.SERVICE_USERS,
+ "Administrators");
+ requestScopeOperations.setApiUser(robot.id());
+
+ ReviewInput reviewInput = new ReviewInput();
+ change(r).current().review(reviewInput);
+
+ assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+ }
+
+ @Test
public void repliesDoNotAddOwnerWhenChangeIsClosed() throws Exception {
PushOneCommit.Result r = createChange();
change(r).abandon();
@@ -1488,6 +1509,64 @@ public class AttentionSetIT extends AbstractDaemonTest {
}
@Test
+ public void robotReviewWithNegativeLabelDoesntAddOwnerIfChangeIsMerged() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+
+ PushOneCommit.Result r = createChange();
+
+ // The robot votes with Code-Review-1 on patch set 1.
+ // Without this vote the robot cannot (re-)apply a negative vote on the change after it was
+ // merged change later.
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).revision(1).review(ReviewInput.dislike());
+
+ // Amend the change so that patch set 2 gets created.
+ requestScopeOperations.setApiUser(admin.id());
+ amendChange(r.getChangeId()).assertOkStatus();
+
+ // Approve the change.
+ approve(r.getChangeId());
+
+ // User adds a comment so that the admin user is added to the attention set.
+ // This has to be a comment from a user, since comments from robots do not trigger attention set
+ // updates.
+ requestScopeOperations.setApiUser(user.id());
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.message = "A comment";
+ change(r).current().review(reviewInput);
+
+ // Verify that the admin user was added to the attention set.
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+
+ // Submit the change.
+ requestScopeOperations.setApiUser(admin.id());
+ change(r).current().submit();
+
+ // Verify that the attention set was cleared on submit.
+ attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+
+ // Re-apply the negative robot vote on patch set 1.
+ // Note it's possible to a apply a negative vote on merged changes if it wasn't already present
+ // since we disallow downgrading votes on merged changes (e.g. downgrade from not present aka 0
+ // to -1 is not allowed).
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).revision(1).review(ReviewInput.dislike());
+
+ // Verify that re-applying the negative robot vote on patch set 1 didn't add the admin user
+ // back to the attention set.
+ attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
+ }
+
+ @Test
public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
TestAccount robot =
accountCreator.create(
@@ -1613,7 +1692,6 @@ public class AttentionSetIT extends AbstractDaemonTest {
}
@Test
- @GerritConfig(name = "change.enableAttentionSet", value = "true")
public void attentionSetEmailHeader() throws Exception {
PushOneCommit.Result r = createChange();
TestAccount user2 = accountCreator.user2();
@@ -1654,21 +1732,6 @@ public class AttentionSetIT extends AbstractDaemonTest {
}
@Test
- @GerritConfig(name = "change.enableAttentionSet", value = "false")
- public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
- PushOneCommit.Result r = createChange();
- // Add user and to the attention set.
- change(r).addReviewer(user.id().toString());
-
- // Attention set is not referenced.
- assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
- .doesNotContain("Attention is currently required");
- assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
- .doesNotContain("Attention is currently required");
- sender.clear();
- }
-
- @Test
public void attentionSetWithEmailFilter() throws Exception {
PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
index 06e24ab81b..779d8eb978 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIdIT.java
@@ -19,6 +19,7 @@ import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.restapi.IdString;
import org.junit.Test;
public class ChangeIdIT extends AbstractDaemonTest {
@@ -47,6 +48,13 @@ public class ChangeIdIT extends AbstractDaemonTest {
}
@Test
+ public void invalidProjectChangeNumberReturnsNotFound() throws Exception {
+ RestResponse res =
+ adminRestSession.get(changeDetail(IdString.fromDecoded("<%=FOO%>~1").encoded()));
+ res.assertNotFound();
+ }
+
+ @Test
public void changeNumberReturnsChange() throws Exception {
PushOneCommit.Result c = createChange();
RestResponse res = adminRestSession.get(changeDetail(getNumericChangeId(c.getChangeId())));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index dbebbf9ab4..1952b3203a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -19,11 +19,15 @@ import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.a
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.entities.Permission.CREATE;
import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.entities.RefNames.HEAD;
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.common.testing.GitPersonSubject.assertThat;
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 com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
@@ -47,18 +51,23 @@ import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.projects.BranchInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.InheritableBoolean;
+import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
+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.restapi.AuthException;
@@ -74,6 +83,7 @@ import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
import com.google.gerrit.testing.FakeEmailSender.Message;
+import com.google.gson.stream.JsonReader;
import com.google.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
@@ -94,6 +104,7 @@ import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.util.Base64;
import org.junit.Before;
import org.junit.Test;
@@ -105,16 +116,19 @@ public class CreateChangeIT extends AbstractDaemonTest {
@Before
public void addNonCommitHead() throws Exception {
- try (Repository repo = repoManager.openRepository(project);
- ObjectInserter ins = repo.newObjectInserter()) {
- ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
- ins.flush();
- ins.close();
-
- RefUpdate update = repo.getRefDatabase().newUpdate("refs/heads/answer", false);
- update.setNewObjectId(answer);
- assertThat(update.forceUpdate()).isEqualTo(RefUpdate.Result.NEW);
- }
+ testRefAction(
+ () -> {
+ try (Repository repo = repoManager.openRepository(project);
+ ObjectInserter ins = repo.newObjectInserter()) {
+ ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
+ ins.flush();
+ ins.close();
+
+ RefUpdate update = repo.getRefDatabase().newUpdate("refs/heads/answer", false);
+ update.setNewObjectId(answer);
+ assertThat(update.forceUpdate()).isEqualTo(RefUpdate.Result.NEW);
+ }
+ });
}
@Test
@@ -210,6 +224,38 @@ public class CreateChangeIT extends AbstractDaemonTest {
}
@Test
+ public void formatResponse_fieldsPresentWhenRequested() throws Exception {
+ ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+ String changeId = "I1234000000000000000000000000000000000000";
+ String changeIdLine = "Change-Id: " + changeId;
+ ci.subject = "Subject\n\n" + changeIdLine;
+ ci.responseFormatOptions =
+ ImmutableList.of(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_ACTIONS);
+ // Must use REST directly because the Java API returns a ChangeApi upon
+ // creation that will do its own formatting when #get is called on it.
+ RestResponse resp = adminRestSession.post("/changes/", ci);
+ resp.assertCreated();
+ ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+ assertThat(res.actions).isNotEmpty();
+ assertThat(res.revisions.values()).hasSize(1);
+ }
+
+ @Test
+ public void formatResponse_fieldsAbsentWhenNotRequested() throws Exception {
+ ChangeInput ci = newChangeInput(ChangeStatus.NEW);
+ String changeId = "I1234000000000000000000000000000000000000";
+ String changeIdLine = "Change-Id: " + changeId;
+ ci.subject = "Subject\n\n" + changeIdLine;
+ // Must use REST directly because the Java API returns a ChangeApi upon
+ // creation that will do its own formatting when #get is called on it.
+ RestResponse resp = adminRestSession.post("/changes/", ci);
+ resp.assertCreated();
+ ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+ assertThat(res.actions).isNull();
+ assertThat(res.revisions).isNull();
+ }
+
+ @Test
public void cannotCreateChangeOnGerritInternalRefs() throws Exception {
requestScopeOperations.setApiUser(admin.id());
projectOperations
@@ -485,6 +531,20 @@ public class CreateChangeIT extends AbstractDaemonTest {
}
@Test
+ public void createAuthorNotAddedAsCcWithAvoidAddingOriginalAuthorAsReviewer() throws Exception {
+ ConfigInput config = new ConfigInput();
+ config.skipAddingAuthorAndCommitterAsReviewers = InheritableBoolean.TRUE;
+ gApi.projects().name(project.get()).config(config);
+ ChangeInput input = newChangeInput(ChangeStatus.NEW);
+ input.author = new AccountInput();
+ input.author.email = user.email();
+ input.author.name = user.fullName();
+
+ ChangeInfo info = assertCreateSucceeds(input);
+ assertThat(info.reviewers).isEmpty();
+ }
+
+ @Test
public void createNewWorkInProgressChange() throws Exception {
ChangeInput input = newChangeInput(ChangeStatus.NEW);
input.workInProgress = true;
@@ -923,6 +983,170 @@ public class CreateChangeIT extends AbstractDaemonTest {
}
@Test
+ public void createChangeWithBothMergeAndPatch_fails() throws Exception {
+ ChangeInput input = newMergeChangeInput("foo", "master", "");
+ input.patch = new ApplyPatchInput();
+ assertCreateFails(
+ input, BadRequestException.class, "Only one of `merge` and `patch` arguments can be set");
+ }
+
+ private static final String PATCH_FILE_NAME = "a_file.txt";
+ private static final String PATCH_NEW_FILE_CONTENT = "First added line\nSecond added line\n";
+ private static final String PATCH_INPUT =
+ "diff --git a/a_file.txt b/a_file.txt\n"
+ + "new file mode 100644\n"
+ + "index 0000000..f0eec86\n"
+ + "--- /dev/null\n"
+ + "+++ b/a_file.txt\n"
+ + "@@ -0,0 +1,2 @@\n"
+ + "+First added line\n"
+ + "+Second added line\n";
+ private static final String MODIFICATION_PATCH_INPUT =
+ "diff --git a/a_file.txt b/a_file.txt\n"
+ + "new file mode 100644\n"
+ + "--- a/a_file.txt\n"
+ + "+++ b/a_file.txt.txt\n"
+ + "@@ -1,2 +1 @@\n"
+ + "-First original line\n"
+ + "-Second original line\n"
+ + "+Modified line\n";
+
+ @Test
+ public void createPatchApplyingChange_success() throws Exception {
+ createBranch(BranchNameKey.create(project, "other"));
+ ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+
+ ChangeInfo info = assertCreateSucceeds(input);
+
+ DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+ assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ assertThat(info.revisions.get(info.currentRevision).commit.message)
+ .isEqualTo("apply patch to other\n\nChange-Id: " + info.changeId + "\n");
+ }
+
+ @Test
+ public void createPatchApplyingChange_fromGerritPatch_success() throws Exception {
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+ PushOneCommit.Result baseCommit =
+ createChange("Add file", PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ baseCommit.assertOkStatus();
+ BinaryResult originalPatch = gApi.changes().id(baseCommit.getChangeId()).current().patch();
+ createBranchWithRevision(BranchNameKey.create(project, "other"), head);
+ ChangeInput input = newPatchApplyingChangeInput("other", originalPatch.asString());
+
+ ChangeInfo info = assertCreateSucceeds(input);
+
+ DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+ assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ }
+
+ @Test
+ public void createPatchApplyingChange_fromGerritPatchUsingRest_success() throws Exception {
+ String head = getHead(repo(), HEAD).name();
+ createBranchWithRevision(BranchNameKey.create(project, "branch"), head);
+ PushOneCommit.Result baseCommit =
+ createChange("Add file", PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ baseCommit.assertOkStatus();
+ createBranchWithRevision(BranchNameKey.create(project, "other"), head);
+ RestResponse patchResp =
+ userRestSession.get("/changes/" + baseCommit.getChangeId() + "/revisions/current/patch");
+ patchResp.assertOK();
+ String originalPatch = new String(Base64.decode(patchResp.getEntityContent()), UTF_8);
+ ChangeInput input = newPatchApplyingChangeInput("other", originalPatch);
+
+ ChangeInfo info = assertCreateSucceedsUsingRest(input);
+
+ DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+ assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ }
+
+ @Test
+ public void createPatchApplyingChange_withParentChange_success() throws Exception {
+ Result change = createChange();
+ ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+ input.baseChange = change.getChangeId();
+
+ ChangeInfo info = assertCreateSucceeds(input);
+
+ assertThat(gApi.changes().id(info.id).current().commit(false).parents.get(0).commit)
+ .isEqualTo(change.getCommit().getId().name());
+ DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+ assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ }
+
+ @Test
+ public void createPatchApplyingChange_withParentCommit_success() throws Exception {
+ createBranch(BranchNameKey.create(project, "other"));
+ Result baseChange = createChange("refs/heads/other");
+ PushOneCommit.Result ignoredCommit = createChange();
+ ignoredCommit.assertOkStatus();
+ ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+ input.baseCommit = baseChange.getCommit().getId().name();
+
+ ChangeInfo info = assertCreateSucceeds(input);
+
+ assertThat(gApi.changes().id(info.id).current().commit(false).parents.get(0).commit)
+ .isEqualTo(input.baseCommit);
+ DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+ assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+ }
+
+ @Test
+ public void createPatchApplyingChange_withEmptyTip_fails() throws Exception {
+ ChangeInput input = newPatchApplyingChangeInput("foo", "patch");
+ input.newBranch = true;
+ assertCreateFails(
+ input, BadRequestException.class, "Cannot apply patch on top of an empty tree");
+ }
+
+ @Test
+ public void createPatchApplyingChange_fromBadPatch_fails() throws Exception {
+ final String invalidPatch = "@@ -2,2 +2,3 @@ a\n" + " b\n" + "+c\n" + " d";
+ createBranch(BranchNameKey.create(project, "other"));
+ ChangeInput input = newPatchApplyingChangeInput("other", invalidPatch);
+ assertCreateFails(input, BadRequestException.class, "Invalid patch format");
+ }
+
+ @Test
+ public void createPatchApplyingChange_withAuthorOverride_success() throws Exception {
+ createBranch(BranchNameKey.create(project, "other"));
+ ChangeInput input = newPatchApplyingChangeInput("other", PATCH_INPUT);
+ input.author = new AccountInput();
+ input.author.email = "gerritlessjane@invalid";
+ // This is an email address that doesn't exist as account on the Gerrit server.
+ input.author.name = "Gerritless Jane";
+ ChangeInfo info = assertCreateSucceeds(input);
+
+ RevisionApi rApi = gApi.changes().id(info.id).current();
+ GitPerson author = rApi.commit(false).author;
+ assertThat(author).email().isEqualTo(input.author.email);
+ assertThat(author).name().isEqualTo(input.author.name);
+ GitPerson committer = rApi.commit(false).committer;
+ assertThat(committer).email().isEqualTo(admin.getNameEmail().email());
+ }
+
+ @Test
+ public void createPatchApplyingChange_withConflicts_appendErrorsToCommitMessage()
+ throws Exception {
+ createBranch(BranchNameKey.create(project, "other"));
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "Adding unexpected base content, which will cause errors",
+ PATCH_FILE_NAME,
+ "unexpected base content");
+ Result conflictingChange = push.to("refs/heads/other");
+ conflictingChange.assertOkStatus();
+ ChangeInput input = newPatchApplyingChangeInput("other", MODIFICATION_PATCH_INPUT);
+
+ ChangeInfo info = assertCreateSucceeds(input);
+
+ assertThat(info.revisions.get(info.currentRevision).commit.message).contains("errors occurred");
+ }
+
+ @Test
@UseSystemTime
public void sha1sOfTwoNewChangesDiffer() throws Exception {
ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
@@ -1084,17 +1308,38 @@ public class CreateChangeIT extends AbstractDaemonTest {
private ChangeInfo assertCreateSucceeds(ChangeInput in) throws Exception {
ChangeInfo out = gApi.changes().create(in).get();
+ validateCreateSucceeds(in, out);
+ return out;
+ }
+
+ private ChangeInfo assertCreateSucceedsUsingRest(ChangeInput in) throws Exception {
+ RestResponse resp = adminRestSession.post("/changes/", in);
+ resp.assertCreated();
+ ChangeInfo res = readContentFromJson(resp, ChangeInfo.class);
+ // The original result doesn't contain any revision data.
+ ChangeInfo out = gApi.changes().id(res.changeId).get(ALL_REVISIONS, CURRENT_COMMIT);
+ validateCreateSucceeds(in, out);
+ return out;
+ }
+
+ private static <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
+ try (JsonReader jsonReader = new JsonReader(r.getReader())) {
+ return newGson().fromJson(jsonReader, clazz);
+ }
+ }
+
+ private void validateCreateSucceeds(ChangeInput in, ChangeInfo out) throws Exception {
assertThat(out.project).isEqualTo(in.project);
assertThat(RefNames.fullName(out.branch)).isEqualTo(RefNames.fullName(in.branch));
assertThat(out.subject).isEqualTo(Splitter.on("\n").splitToList(in.subject).get(0));
assertThat(out.topic).isEqualTo(in.topic);
assertThat(out.status).isEqualTo(in.status);
- if (in.isPrivate) {
+ if (Boolean.TRUE.equals(in.isPrivate)) {
assertThat(out.isPrivate).isTrue();
} else {
assertThat(out.isPrivate).isNull();
}
- if (in.workInProgress) {
+ if (Boolean.TRUE.equals(in.workInProgress)) {
assertThat(out.workInProgress).isTrue();
} else {
assertThat(out.workInProgress).isNull();
@@ -1103,7 +1348,6 @@ public class CreateChangeIT extends AbstractDaemonTest {
assertThat(out.submitted).isNull();
assertThat(out.containsGitConflicts).isNull();
assertThat(in.status).isEqualTo(ChangeStatus.NEW);
- return out;
}
private ChangeInfo assertCreateSucceedsWithConflicts(ChangeInput in) throws Exception {
@@ -1174,6 +1418,19 @@ public class CreateChangeIT extends AbstractDaemonTest {
return in;
}
+ private ChangeInput newPatchApplyingChangeInput(String targetBranch, String patch) {
+ // create a change applying the given patch on the target branch in gerrit
+ ChangeInput in = new ChangeInput();
+ in.project = project.get();
+ in.branch = targetBranch;
+ in.subject = "apply patch to " + targetBranch;
+ in.status = ChangeStatus.NEW;
+ ApplyPatchInput patchInput = new ApplyPatchInput();
+ patchInput.patch = patch;
+ in.patch = patchInput;
+ return in;
+ }
+
/**
* Create an empty commit in master, two new branches with one commit each.
*
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index c57d285fc6..6491202571 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -15,16 +15,24 @@
package com.google.gerrit.acceptance.rest.change;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabelRemoval;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
+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.entities.Account;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
@@ -39,56 +47,168 @@ import java.util.Map;
import org.junit.Test;
public class DeleteVoteIT extends AbstractDaemonTest {
+ @Inject private ProjectOperations projectOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@Test
- public void deleteVoteOnChange() throws Exception {
- deleteVote(false);
+ public void deleteVoteOnChange_withRemoveLabelPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ verifyDeleteVote(false);
}
@Test
- public void deleteVoteOnRevision() throws Exception {
- deleteVote(true);
+ public void deleteVoteOnChange_withRemoveReviewerPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ blockLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ verifyDeleteVote(false);
}
- private void deleteVote(boolean onRevisionLevel) throws Exception {
+ @Test
+ public void deleteVoteOnChange_noPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ blockLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ verifyCannotDeleteVote(false);
+ }
+
+ @Test
+ public void deleteVoteOnRevision_withRemoveLabelPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ allowLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ verifyDeleteVote(true);
+ }
+
+ @Test
+ public void deleteVoteOnRevision_withRemoveReviewerPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ blockLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ verifyDeleteVote(true);
+ }
+
+ @Test
+ public void deleteVoteOnRevision_noPermission() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(
+ blockLabelRemoval(LabelId.CODE_REVIEW)
+ .ref("refs/heads/*")
+ .group(REGISTERED_USERS)
+ .range(-2, 2))
+ .add(block(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ verifyCannotDeleteVote(true);
+ }
+
+ @Test
+ public void deleteAlreadyDeletedVote_returnsNotFoundAndWithoutEmails() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.REMOVE_REVIEWER).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ PushOneCommit.Result r = createChange();
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+ String deleteAdminVoteEndPoint =
+ "/changes/"
+ + r.getChangeId()
+ + "/reviewers/"
+ + admin.id().toString()
+ + "/votes/Code-Review";
+
+ sender.clear();
+ RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+ response.assertNoContent();
+ assertThat(sender.getMessages()).hasSize(1);
+
+ sender.clear();
+ response = userRestSession.delete(deleteAdminVoteEndPoint);
+ response.assertNotFound();
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ private void verifyDeleteVote(boolean onRevisionLevel) throws Exception {
PushOneCommit.Result r = createChange();
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
PushOneCommit.Result r2 = amendChange(r.getChangeId());
- requestScopeOperations.setApiUser(user.id());
+ requestScopeOperations.setApiUser(admin.id());
+ recommend(r.getChangeId());
+
+ TestAccount user2 = accountCreator.user2();
+ requestScopeOperations.setApiUser(user2.id());
recommend(r.getChangeId());
sender.clear();
- String endPoint =
+ String deleteAdminVoteEndPoint =
"/changes/"
+ r.getChangeId()
+ (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+ "/reviewers/"
- + user.id().toString()
+ + admin.id().toString()
+ "/votes/Code-Review";
- RestResponse response = adminRestSession.delete(endPoint);
+ RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
response.assertNoContent();
List<FakeEmailSender.Message> messages = sender.getMessages();
assertThat(messages).hasSize(1);
FakeEmailSender.Message msg = messages.get(0);
- assertThat(msg.rcpt()).containsExactly(user.getNameEmail());
- assertThat(msg.body()).contains(admin.fullName() + " has removed a vote from this change.");
+ assertThat(msg.rcpt()).containsExactly(admin.getNameEmail(), user2.getNameEmail());
+ assertThat(msg.body()).contains(user.fullName() + " has removed a vote from this change.");
assertThat(msg.body())
- .contains("Removed Code-Review+1 by " + user.fullName() + " <" + user.email() + ">\n");
+ .contains("Removed Code-Review+1 by " + admin.fullName() + " <" + admin.email() + ">\n");
- endPoint =
+ String viewVotesEndPoint =
"/changes/"
+ r.getChangeId()
+ (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+ "/reviewers/"
- + user.id().toString()
+ + admin.id().toString()
+ "/votes";
- response = adminRestSession.get(endPoint);
+ response = userRestSession.get(viewVotesEndPoint);
response.assertOK();
Map<String, Short> m =
@@ -99,14 +219,38 @@ public class DeleteVoteIT extends AbstractDaemonTest {
ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
ChangeMessageInfo message = Iterables.getLast(c.messages);
- assertThat(message.author._accountId).isEqualTo(admin.id().get());
+ assertThat(message.author._accountId).isEqualTo(user.id().get());
assertThat(message.message)
.isEqualTo(
String.format(
"Removed Code-Review+1 by %s\n",
- AccountTemplateUtil.getAccountTemplate(user.id())));
+ AccountTemplateUtil.getAccountTemplate(admin.id())));
assertThat(getReviewers(c.reviewers.get(REVIEWER)))
- .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
+ .containsExactlyElementsIn(ImmutableSet.of(user2.id(), admin.id()));
+ }
+
+ private void verifyCannotDeleteVote(boolean onRevisionLevel) throws Exception {
+ PushOneCommit.Result r = createChange();
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+
+ PushOneCommit.Result r2 = amendChange(r.getChangeId());
+
+ requestScopeOperations.setApiUser(admin.id());
+ recommend(r.getChangeId());
+
+ sender.clear();
+ String deleteAdminVoteEndPoint =
+ "/changes/"
+ + r.getChangeId()
+ + (onRevisionLevel ? ("/revisions/" + r2.getCommit().getName()) : "")
+ + "/reviewers/"
+ + admin.id().toString()
+ + "/votes/Code-Review";
+
+ RestResponse response = userRestSession.delete(deleteAdminVoteEndPoint);
+ response.assertForbidden();
+
+ assertThat(sender.getMessages()).isEmpty();
}
private Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 80bedcdd6b..6dfa82b926 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -47,6 +47,7 @@ import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.inject.Inject;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.Before;
@@ -336,7 +337,7 @@ public class SuggestReviewersIT extends AbstractDaemonTest {
reviewers = suggestReviewers(changeId, user1.username() + " example");
assertThat(reviewers).hasSize(1);
- reviewers = suggestReviewers(changeId, user4.email().toLowerCase());
+ reviewers = suggestReviewers(changeId, user4.email().toLowerCase(Locale.US));
assertThat(reviewers).hasSize(1);
assertThat(reviewers.get(0).account.email).isEqualTo(user4.email());
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
index 6d2c6dfaae..a9e3cf666e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/GetTaskIT.java
@@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.server.restapi.config.ListTasks.TaskInfo;
import com.google.gson.reflect.TypeToken;
import java.util.List;
@@ -41,6 +42,7 @@ public class GetTaskIT extends AbstractDaemonTest {
userRestSession.get("/config/server/tasks/" + getLogFileCompressorTaskId()).assertNotFound();
}
+ @Nullable
private String getLogFileCompressorTaskId() throws Exception {
RestResponse r = adminRestSession.get("/config/server/tasks/");
List<TaskInfo> result =
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index a6d660d045..b8b63e6ae6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -62,8 +62,7 @@ public class ServerInfoIT extends AbstractDaemonTest {
// change
@GerritConfig(name = "change.updateDelay", value = "50s")
@GerritConfig(name = "change.disablePrivateChanges", value = "true")
- @GerritConfig(name = "change.enableAttentionSet", value = "true")
- @GerritConfig(name = "change.enableAssignee", value = "true")
+ @GerritConfig(name = "change.enableRobotComments", value = "false")
// download
@GerritConfig(
@@ -104,8 +103,7 @@ public class ServerInfoIT extends AbstractDaemonTest {
// change
assertThat(i.change.updateDelay).isEqualTo(50);
assertThat(i.change.disablePrivateChanges).isTrue();
- assertThat(i.change.enableAttentionSet).isTrue();
- assertThat(i.change.enableAssignee).isTrue();
+ assertThat(i.change.enableRobotComments).isNull();
// download
assertThat(i.download.archives).containsExactly("tar", "tbz2", "tgz", "txz");
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index b94ea37bf2..ca7c3c53f1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -109,4 +109,13 @@ public class AccessIT extends AbstractDaemonTest {
.fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
assertThat(infoByProject.keySet()).containsExactly(project.get());
}
+
+ @Test
+ public void listAccess_invalidProject() throws Exception {
+ String invalidProject = "<%=FOO%>";
+ RestResponse r =
+ adminRestSession.get("/access/?project=" + IdString.fromDecoded(invalidProject));
+ r.assertNotFound();
+ assertThat(r.getEntityContent()).isEqualTo(invalidProject);
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
index 0c221aa21c..7b42d93fbd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateChangeIT.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.rest.project;
+import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.entities.RefNames.REFS_HEADS;
@@ -21,6 +22,7 @@ import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.IdString;
import org.junit.Test;
public class CreateChangeIT extends AbstractDaemonTest {
@@ -43,7 +45,44 @@ public class CreateChangeIT extends AbstractDaemonTest {
ChangeInput input = new ChangeInput();
input.branch = "foo";
input.subject = "subject";
- RestResponse cr = adminRestSession.post("/projects/" + project.get() + "/create.change", input);
- cr.assertCreated();
+ RestResponse response =
+ adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+ response.assertCreated();
+ }
+
+ @Test
+ public void nonMatchingProjectIsRejected() throws Exception {
+ ChangeInput input = new ChangeInput();
+ input.project = "non-matching-project";
+ input.branch = "master";
+ input.subject = "subject";
+ RestResponse response =
+ adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+ response.assertBadRequest();
+ assertThat(response.getEntityContent()).isEqualTo("project must match URL");
+ }
+
+ @Test
+ public void matchingProjectIsAccepted() throws Exception {
+ ChangeInput input = new ChangeInput();
+ input.project = project.get();
+ input.branch = "master";
+ input.subject = "subject";
+ RestResponse response =
+ adminRestSession.post("/projects/" + project.get() + "/create.change", input);
+ response.assertCreated();
+ }
+
+ @Test
+ public void matchingProjectWithTrailingSlashIsAccepted() throws Exception {
+ ChangeInput input = new ChangeInput();
+ input.project = project.get() + "/";
+ input.branch = "master";
+ input.subject = "subject";
+ RestResponse response =
+ adminRestSession.post(
+ "/projects/" + IdString.fromDecoded(project.get() + "/").encoded() + "/create.change",
+ input);
+ response.assertCreated();
}
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
index 8dce9c3423..8c8f267bde 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FileBranchIT.java
@@ -16,6 +16,7 @@ package com.google.gerrit.acceptance.rest.project;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
@@ -58,7 +59,7 @@ public class FileBranchIT extends AbstractDaemonTest {
@Test
public void getFileFromSymbolicRefPointingToAnUnbornBranch() throws Exception {
try (Repository repo = repoManager.openRepository(project)) {
- repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing");
+ testRefAction(() -> repo.updateRef(Constants.HEAD, true).link("refs/heads/non-existing"));
}
RestResponse response =
adminRestSession.get(String.format("/projects/%s/branches/HEAD/files/path", project.get()));
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
index 3ac2d10a3b..5f02af172d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListCommitFilesIT.java
@@ -15,17 +15,21 @@
package com.google.gerrit.acceptance.rest.project;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
import static com.google.gerrit.acceptance.GitUtil.getChangeId;
import static com.google.gerrit.acceptance.GitUtil.pushHead;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.entities.Patch;
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.Map;
import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.transport.PushResult;
import org.junit.Test;
public class ListCommitFilesIT extends AbstractDaemonTest {
@@ -87,4 +91,31 @@ public class ListCommitFilesIT extends AbstractDaemonTest {
assertThat(files1).isEqualTo(files2);
}
+
+ @Test
+ @TestProjectInput(createEmptyCommit = false)
+ public void listFilesOfInitialCommitAgainstFirstParent() throws Exception {
+ // create initial commit with no parent and push it directly to refs/heads/master
+ RevCommit c =
+ testRepo
+ .commit()
+ .message("Initial commit")
+ .add("a.txt", "aContent")
+ .add("b.txt", "bContent")
+ .create();
+ testRepo.reset(c);
+ PushResult r = pushHead(testRepo, "refs/heads/master", false);
+ assertPushOk(r, "refs/heads/master");
+
+ // Request diff against first parent although the initial commit doesn't have a parent
+ RestResponse response =
+ userRestSession.get(
+ "/projects/" + project.get() + "/commits/" + c.name() + "/files/?parent=1");
+ response.assertOK();
+ Type type = new TypeToken<Map<String, FileInfo>>() {}.getType();
+ Map<String, FileInfo> files = newGson().fromJson(response.getReader(), type);
+ response.consume();
+
+ assertThat(files.keySet()).containsExactly(Patch.COMMIT_MSG, "a.txt", "b.txt");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index 0cfa0f8bf3..35eccebfcf 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -26,11 +26,13 @@ import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.account.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.common.AccountVisibility;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountControl;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.account.AccountResolver.Result;
import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
@@ -41,6 +43,7 @@ import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Inject;
import com.google.inject.Provider;
+import java.util.Locale;
import java.util.Optional;
import org.eclipse.jgit.lib.Config;
import org.junit.Test;
@@ -56,7 +59,9 @@ public class AccountResolverIT extends AbstractDaemonTest {
@Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
@Inject private AccountOperations accountOperations;
@Inject private AccountResolver accountResolver;
+ @Inject private AccountControl.Factory accountControlFactory;
@Inject private Provider<CurrentUser> self;
+ @Inject private GroupOperations groupOperations;
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private Sequences sequences;
@@ -172,7 +177,7 @@ public class AccountResolverIT extends AbstractDaemonTest {
assertThat(resolve(existingUsername)).containsExactly(idWithUsername);
assertThat(resolve(existingMixedCaseUsername)).containsExactly(idWithMixedCaseUsername);
- assertThat(resolve(existingMixedCaseUsername.toLowerCase()))
+ assertThat(resolve(existingMixedCaseUsername.toLowerCase(Locale.US)))
.containsExactly(idWithMixedCaseUsername);
}
@@ -365,6 +370,37 @@ public class AccountResolverIT extends AbstractDaemonTest {
assertThat(resolve("doe")).containsExactly(id2);
}
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void resolveAsUser_byFullName_accountThatIsNotVisibleToCurrentUserIsFound()
+ throws Exception {
+ Account.Id currentUser = accountOperations.newAccount().create();
+ Account.Id resolveAsUser = accountOperations.newAccount().create();
+ Account.Id userToBeFound = accountOperations.newAccount().fullname("Somebodys Name").create();
+
+ // Create a group that contains resolveAsUser and userToBeFound, so that resolveAsUser can see
+ // userToBeFound.
+ groupOperations.newGroup().addMember(resolveAsUser).addMember(userToBeFound).create();
+
+ // Verify that resolveAsUser can see userToBeFound.
+ assertThat(canSee(resolveAsUser, userToBeFound)).isTrue();
+
+ // Verify that currentUser cannot see userToBeFound
+ assertThat(canSee(currentUser, userToBeFound)).isFalse();
+
+ // Resolving userToBeFound as resolveAsUser should work even if the currentUser cannot see
+ // userToBeFound.
+ requestScopeOperations.setApiUser(currentUser);
+ String input = accountOperations.account(userToBeFound).get().fullname().get();
+ assertThat(resolveAsUser(resolveAsUser, input)).containsExactly(userToBeFound);
+ }
+
+ private boolean canSee(Account.Id currentUser, Account.Id userToBeSeen) {
+ return accountControlFactory
+ .get(identifiedUserFactory.create(currentUser))
+ .canSee(userToBeSeen);
+ }
+
private ImmutableSet<Account.Id> resolve(Object input) throws Exception {
return resolveAsResult(input).asIdSet();
}
@@ -373,6 +409,16 @@ public class AccountResolverIT extends AbstractDaemonTest {
return accountResolver.resolve(input.toString());
}
+ private ImmutableSet<Account.Id> resolveAsUser(Account.Id resolveAsUser, Object input)
+ throws Exception {
+ return resolveAsUserAsResult(resolveAsUser, input).asIdSet();
+ }
+
+ private Result resolveAsUserAsResult(Account.Id resolveAsUser, Object input) throws Exception {
+ return accountResolver.resolveAsUser(
+ identifiedUserFactory.create(resolveAsUser), input.toString());
+ }
+
@SuppressWarnings("deprecation")
private ImmutableSet<Account.Id> resolveByNameOrEmail(Object input) throws Exception {
return accountResolver.resolveByNameOrEmail(input.toString()).asIdSet();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
index 0d06946ce5..379a712d7b 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ApprovalCopierIT.java
@@ -17,18 +17,23 @@ package com.google.gerrit.acceptance.server.change;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.truth.Truth.assertAbout;
import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.assertThatList;
-import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.PatchSetApprovalSubject.hasTestId;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThat;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.assertThatList;
+import static com.google.gerrit.acceptance.server.change.ApprovalCopierIT.ApprovalDataSubject.hasTestId;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
import static com.google.gerrit.server.project.testing.TestLabels.value;
+import static com.google.gerrit.truth.ListSubject.elements;
import com.google.auto.value.AutoValue;
import com.google.common.collect.ImmutableSet;
import com.google.common.truth.Correspondence;
import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StandardSubjectBuilder;
+import com.google.common.truth.StringSubject;
import com.google.common.truth.Subject;
+import com.google.common.truth.Truth8;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
@@ -43,6 +48,7 @@ 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.client.ChangeKind;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.approval.ApprovalCopier;
import com.google.gerrit.server.query.change.ChangeData;
@@ -50,6 +56,7 @@ import com.google.gerrit.truth.ListSubject;
import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
import java.io.IOException;
+import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import org.eclipse.jgit.lib.Repository;
@@ -71,6 +78,24 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
@Before
public void setup() throws Exception {
+ // Overwrite "Code-Review" label that is inherited from All-Projects.
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ LabelType.Builder codeReview =
+ labelBuilder(
+ LabelId.CODE_REVIEW,
+ value(2, "Looks good to me, approved"),
+ value(1, "Looks good to me, but someone else must approve"),
+ value(0, "No score"),
+ value(-1, "I would prefer this is not submitted as is"),
+ value(-2, "This shall not be submitted"))
+ .setCopyCondition(
+ String.format(
+ "changekind:%s OR changekind:%s OR is:MIN",
+ ChangeKind.NO_CHANGE, ChangeKind.TRIVIAL_REBASE.name()));
+ u.getConfig().upsertLabelType(codeReview.build());
+ u.save();
+ }
+
// Add Verified label.
try (ProjectConfigUpdate u = updateProject(project)) {
LabelType.Builder verified =
@@ -153,6 +178,18 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
.containsExactly(
PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, 2),
PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.VERIFIED, 1));
+
+ ApprovalDataSubject codeReviewApprovalSubject =
+ assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+ codeReviewApprovalSubject.hasPassingAtomsThat().isEmpty();
+ codeReviewApprovalSubject
+ .hasFailingAtomsThat()
+ .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+ ApprovalDataSubject verifiedApprovalSubject =
+ assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, user.id());
+ verifiedApprovalSubject.hasPassingAtomsThat().isEmpty();
+ verifiedApprovalSubject.hasFailingAtomsThat().containsExactly("is:MIN");
}
@Test
@@ -176,6 +213,18 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
PatchSetApprovalTestId.create(patchSet2Id, admin.id(), LabelId.CODE_REVIEW, -2),
PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
assertThatList(approvalCopierResult.outdatedApprovals()).isEmpty();
+
+ ApprovalDataSubject codeReviewApprovalSubject =
+ assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+ codeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+ codeReviewApprovalSubject
+ .hasFailingAtomsThat()
+ .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+ ApprovalDataSubject verifiedApprovalSubject =
+ assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+ verifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+ verifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
}
@Test
@@ -230,6 +279,30 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
.containsExactly(
PatchSetApprovalTestId.create(patchSet1Id, user.id(), LabelId.CODE_REVIEW, 1),
PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.VERIFIED, 1));
+
+ ApprovalDataSubject copiedCodeReviewApprovalSubject =
+ assertThat(approvalCopierResult.copiedApprovals(), LabelId.CODE_REVIEW, admin.id());
+ copiedCodeReviewApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+ copiedCodeReviewApprovalSubject
+ .hasFailingAtomsThat()
+ .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE");
+
+ ApprovalDataSubject copiedVerifiedApprovalSubject =
+ assertThat(approvalCopierResult.copiedApprovals(), LabelId.VERIFIED, user.id());
+ copiedVerifiedApprovalSubject.hasPassingAtomsThat().containsExactly("is:MIN");
+ copiedVerifiedApprovalSubject.hasFailingAtomsThat().isEmpty();
+
+ ApprovalDataSubject outdatedCodeReviewApprovalSubject1 =
+ assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, user.id());
+ outdatedCodeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+ outdatedCodeReviewApprovalSubject1
+ .hasFailingAtomsThat()
+ .containsExactly("changekind:NO_CHANGE", "changekind:TRIVIAL_REBASE", "is:MIN");
+
+ ApprovalDataSubject outdatedVerifiedApprovalSubject1 =
+ assertThat(approvalCopierResult.outdatedApprovals(), LabelId.VERIFIED, admin.id());
+ outdatedVerifiedApprovalSubject1.hasPassingAtomsThat().isEmpty();
+ outdatedVerifiedApprovalSubject1.hasFailingAtomsThat().containsExactly("is:MIN");
}
@Test
@@ -275,6 +348,11 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
.comparingElementsUsing(hasTestId())
.containsExactly(
PatchSetApprovalTestId.create(patchSet1Id, admin.id(), LabelId.CODE_REVIEW, -2));
+
+ ApprovalDataSubject codeReviewApprovalSubject1 =
+ assertThat(approvalCopierResult.outdatedApprovals(), LabelId.CODE_REVIEW, admin.id());
+ codeReviewApprovalSubject1.hasPassingAtomsThat().isEmpty();
+ codeReviewApprovalSubject1.hasFailingAtomsThat().isEmpty();
}
@Test
@@ -347,12 +425,14 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
ApprovalCopier.Result approvalCopierResult =
invokeApprovalCopierForCurrentPatchSet(
r.getChange().getId(), /* expectedCurrentPatchSetNum= */ 2);
- ImmutableSet<PatchSetApproval> copiedApprovals = approvalCopierResult.copiedApprovals();
- assertThatList(filter(copiedApprovals, PatchSetApproval::copied))
+ ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> copiedApprovals =
+ approvalCopierResult.copiedApprovals();
+ assertThatList(filter(copiedApprovals, approval -> approval.patchSetApproval().copied()))
.comparingElementsUsing(hasTestId())
.containsExactly(
PatchSetApprovalTestId.create(patchSet2Id, user.id(), LabelId.VERIFIED, -1));
- assertThatList(filter(copiedApprovals, psa -> !psa.copied())).isEmpty();
+ assertThatList(filter(copiedApprovals, approval -> !approval.patchSetApproval().copied()))
+ .isEmpty();
}
private void vote(String changeId, TestAccount testAccount, String label, int value)
@@ -362,8 +442,9 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
requestScopeOperations.setApiUser(admin.id());
}
- private ImmutableSet<PatchSetApproval> filter(
- Set<PatchSetApproval> approvals, Predicate<PatchSetApproval> filter) {
+ private ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> filter(
+ Set<ApprovalCopier.Result.PatchSetApprovalData> approvals,
+ Predicate<ApprovalCopier.Result.PatchSetApprovalData> filter) {
return approvals.stream().filter(filter).collect(toImmutableSet());
}
@@ -378,18 +459,73 @@ public class ApprovalCopierIT extends AbstractDaemonTest {
}
}
- public static class PatchSetApprovalSubject extends Subject {
- public static Correspondence<PatchSetApproval, PatchSetApprovalTestId> hasTestId() {
- return NullAwareCorrespondence.transforming(PatchSetApprovalTestId::create, "has test ID");
+ public static class ApprovalDataSubject extends Subject {
+ public static Correspondence<ApprovalCopier.Result.PatchSetApprovalData, PatchSetApprovalTestId>
+ hasTestId() {
+ return NullAwareCorrespondence.transforming(
+ approvalData -> PatchSetApprovalTestId.create(approvalData.patchSetApproval()),
+ "has test ID");
}
- public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
- return assertAbout(patchSetApprovals()).that(patchSetApproval);
+ public static ApprovalDataSubject assertThat(
+ ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+ return assertAbout(approvalDatas()).that(approvalData);
}
- public static ListSubject<PatchSetApprovalSubject, PatchSetApproval> assertThatList(
- ImmutableSet<PatchSetApproval> patchSetApprovals) {
- return ListSubject.assertThat(patchSetApprovals.asList(), patchSetApprovals());
+ public static ApprovalDataSubject assertThat(
+ ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas,
+ String labelId,
+ Account.Id accountId) {
+ Optional<ApprovalCopier.Result.PatchSetApprovalData> approvalDataForLabelAndAccount =
+ approvalDatas.stream()
+ .filter(
+ approvalData ->
+ approvalData.patchSetApproval().label().equals(labelId)
+ && approvalData.patchSetApproval().accountId().equals(accountId))
+ .findAny();
+ Truth8.assertThat(approvalDataForLabelAndAccount).isPresent();
+ return assertAbout(approvalDatas()).that(approvalDataForLabelAndAccount.get());
+ }
+
+ public static ListSubject<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+ assertThatList(ImmutableSet<ApprovalCopier.Result.PatchSetApprovalData> approvalDatas) {
+ return ListSubject.assertThat(approvalDatas.asList(), approvalDatas());
+ }
+
+ private static Factory<ApprovalDataSubject, ApprovalCopier.Result.PatchSetApprovalData>
+ approvalDatas() {
+ return ApprovalDataSubject::new;
+ }
+
+ private final ApprovalCopier.Result.PatchSetApprovalData approvalData;
+
+ private ApprovalDataSubject(
+ FailureMetadata metadata, ApprovalCopier.Result.PatchSetApprovalData approvalData) {
+ super(metadata, approvalData);
+ this.approvalData = approvalData;
+ }
+
+ public ListSubject<StringSubject, String> hasPassingAtomsThat() {
+ return check("passingAtoms()")
+ .about(elements())
+ .that(approvalData().passingAtoms().asList(), StandardSubjectBuilder::that);
+ }
+
+ public ListSubject<StringSubject, String> hasFailingAtomsThat() {
+ return check("failingAtoms()")
+ .about(elements())
+ .that(approvalData().failingAtoms().asList(), StandardSubjectBuilder::that);
+ }
+
+ private ApprovalCopier.Result.PatchSetApprovalData approvalData() {
+ isNotNull();
+ return approvalData;
+ }
+ }
+
+ public static class PatchSetApprovalSubject extends Subject {
+ public static PatchSetApprovalSubject assertThat(PatchSetApproval patchSetApproval) {
+ return assertAbout(patchSetApprovals()).that(patchSetApproval);
}
private static Factory<PatchSetApprovalSubject, PatchSetApproval> patchSetApprovals() {
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 6d980c7352..15baa78a15 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -185,6 +185,39 @@ public class CommentsIT extends AbstractDaemonTest {
}
@Test
+ public void commentWithRangeAndLine_lineIsIgnored() throws Exception {
+ String file = "file";
+ String contents = "contents";
+ PushOneCommit push =
+ pushFactory.create(admin.newIdent(), testRepo, "first subject", file, contents);
+ PushOneCommit.Result r = push.to("refs/for/master");
+ String changeId = r.getChangeId();
+ String revId = r.getCommit().getName();
+ ReviewInput input = new ReviewInput();
+ CommentInput comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment 1", false);
+ int rangeEndLine = 3;
+ comment.range = createRange(1, 1, rangeEndLine, 3);
+ input.comments = new HashMap<>();
+ input.comments.put(comment.path, Lists.newArrayList(comment));
+ revision(r).review(input);
+ Map<String, List<CommentInfo>> result = getPublishedComments(changeId, revId);
+ assertThat(result).isNotEmpty();
+ CommentInfo actual = Iterables.getOnlyElement(result.get(comment.path));
+ assertThat(actual.line).isEqualTo(rangeEndLine);
+ input = new ReviewInput();
+ comment = CommentsUtil.newComment(file, Side.REVISION, 1, "comment 1 reply", false);
+ comment.range = createRange(1, 1, rangeEndLine, 3);
+ // Post another comment in reply, and the line is still fixed to the range.endLine
+ comment.inReplyTo = actual.id;
+ input.comments = new HashMap<>();
+ input.comments.put(comment.path, Lists.newArrayList(comment));
+ revision(r).review(input);
+ result = getPublishedComments(changeId, revId);
+ assertThat(result.get(comment.path)).hasSize(2);
+ assertThat(result.get(comment.path).stream().allMatch(c -> c.line == rangeEndLine)).isTrue();
+ }
+
+ @Test
public void patchsetLevelCommentCanBeAddedAndRetrieved() throws Exception {
PushOneCommit.Result result = createChange();
String changeId = result.getChangeId();
@@ -1229,7 +1262,7 @@ public class CommentsIT extends AbstractDaemonTest {
+ c
+ "/comment/"
+ ps1List.get(0).id
- + " \n"
+ + " :\n"
+ "PS1, Line 1: initial\n"
+ "what happened to this?\n"
+ "\n"
@@ -1241,7 +1274,7 @@ public class CommentsIT extends AbstractDaemonTest {
+ c
+ "/comment/"
+ ps1List.get(1).id
- + " \n"
+ + " :\n"
+ "PS1, Line 1: boring\n"
+ "Is it that bad?\n"
+ "\n"
@@ -1255,7 +1288,7 @@ public class CommentsIT extends AbstractDaemonTest {
+ c
+ "/comment/"
+ ps2List.get(0).id
- + " \n"
+ + " :\n"
+ "PS2, Line 1: initial content\n"
+ "comment 1 on base\n"
+ "\n"
@@ -1267,7 +1300,7 @@ public class CommentsIT extends AbstractDaemonTest {
+ c
+ "/comment/"
+ ps2List.get(1).id
- + " \n"
+ + " :\n"
+ "PS2, Line 2: \n"
+ "comment 2 on base\n"
+ "\n"
@@ -1279,7 +1312,7 @@ public class CommentsIT extends AbstractDaemonTest {
+ c
+ "/comment/"
+ ps2List.get(2).id
- + " \n"
+ + " :\n"
+ "PS2, Line 1: interesting\n"
+ "better now\n"
+ "\n"
@@ -1291,7 +1324,7 @@ public class CommentsIT extends AbstractDaemonTest {
+ c
+ "/comment/"
+ ps2List.get(3).id
- + " \n"
+ + " :\n"
+ "PS2, Line 2: cntent\n"
+ "typo: content\n"
+ "\n"
@@ -2073,6 +2106,16 @@ public class CommentsIT extends AbstractDaemonTest {
return range;
}
+ private static Comment.Range createRange(
+ int startLine, int startCharacter, int endLine, int endCharacter) {
+ Comment.Range range = new Comment.Range();
+ range.startLine = startLine;
+ range.startCharacter = startCharacter;
+ range.endLine = endLine;
+ range.endCharacter = endCharacter;
+ return range;
+ }
+
private static Function<CommentInfo, CommentInput> infoToInput(String path) {
return info -> {
CommentInput commentInput = new CommentInput();
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index bcde618b7c..55f102fb94 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -17,6 +17,8 @@ package com.google.gerrit.acceptance.server.change;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIXED;
import static com.google.gerrit.extensions.common.ProblemInfo.Status.FIX_FAILED;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static com.google.gerrit.testing.TestChanges.newPatchSet;
import static java.util.Objects.requireNonNull;
@@ -47,6 +49,7 @@ import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.TestChanges;
import com.google.inject.Inject;
@@ -297,7 +300,7 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
serverSideTestRepo.reset(serverSideTestRepo.getRepository().exactRef(ref).getObjectId());
RefUpdate ru = serverSideTestRepo.getRepository().updateRef(ref);
ru.setForceUpdate(true);
- assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
assertProblems(notes, null, problem("Destination ref not found (may be new branch): " + ref));
}
@@ -305,20 +308,21 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
@Test
public void mergedChangeIsNotMerged() throws Exception {
ChangeNotes notes = insertChange();
-
- try (BatchUpdate bu = newUpdate(adminId)) {
- bu.addOp(
- notes.getChangeId(),
- new BatchUpdateOp() {
- @Override
- public boolean updateChange(ChangeContext ctx) {
- ctx.getChange().setStatus(Change.Status.MERGED);
- ctx.getUpdate(ctx.getChange().currentPatchSetId())
- .fixStatusToMerged(new SubmissionId(ctx.getChange()));
- return true;
- }
- });
- bu.execute();
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (BatchUpdate bu = newUpdate(adminId)) {
+ bu.addOp(
+ notes.getChangeId(),
+ new BatchUpdateOp() {
+ @Override
+ public boolean updateChange(ChangeContext ctx) {
+ ctx.getChange().setStatus(Change.Status.MERGED);
+ ctx.getUpdate(ctx.getChange().currentPatchSetId())
+ .fixStatusToMerged(new SubmissionId(ctx.getChange()));
+ return true;
+ }
+ });
+ bu.execute();
+ }
}
notes = reload(notes);
@@ -745,19 +749,22 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
private ChangeNotes insertChange(TestAccount owner, String dest) throws Exception {
Change.Id id = Change.id(sequences.nextChangeId());
- ChangeInserter ins;
- try (BatchUpdate bu = newUpdate(owner.id())) {
- RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
- bu.setNotify(NotifyResolver.Result.none());
- ins =
- changeInserterFactory
- .create(id, commit, dest)
- .setValidate(false)
- .setFireRevisionCreated(false)
- .setSendMail(false);
- bu.insertChange(ins).execute();
- }
- return changeNotesFactory.create(project, ins.getChange().getId());
+ return testRefAction(
+ () -> {
+ ChangeInserter ins;
+ try (BatchUpdate bu = newUpdate(owner.id())) {
+ RevCommit commit = patchSetCommit(PatchSet.id(id, 1));
+ bu.setNotify(NotifyResolver.Result.none());
+ ins =
+ changeInserterFactory
+ .create(id, commit, dest)
+ .setValidate(false)
+ .setFireRevisionCreated(false)
+ .setSendMail(false);
+ bu.insertChange(ins).execute();
+ }
+ return changeNotesFactory.create(project, ins.getChange().getId());
+ });
}
private PatchSet.Id nextPatchSetId(ChangeNotes notes) throws Exception {
@@ -770,17 +777,20 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
}
private ChangeNotes incrementPatchSet(ChangeNotes notes, RevCommit commit) throws Exception {
- PatchSetInserter ins;
- try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
- bu.setNotify(NotifyResolver.Result.none());
- ins =
- patchSetInserterFactory
- .create(notes, nextPatchSetId(notes), commit)
- .setValidate(false)
- .setFireRevisionCreated(false);
- bu.addOp(notes.getChangeId(), ins).execute();
- }
- return reload(notes);
+ return testRefAction(
+ () -> {
+ PatchSetInserter ins;
+ try (BatchUpdate bu = newUpdate(notes.getChange().getOwner())) {
+ bu.setNotify(NotifyResolver.Result.none());
+ ins =
+ patchSetInserterFactory
+ .create(notes, nextPatchSetId(notes), commit)
+ .setValidate(false)
+ .setFireRevisionCreated(false);
+ bu.addOp(notes.getChangeId(), ins).execute();
+ }
+ return reload(notes);
+ });
}
private ChangeNotes reload(ChangeNotes notes) throws Exception {
@@ -822,7 +832,7 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
private void deleteRef(String refName) throws Exception {
RefUpdate ru = serverSideTestRepo.getRepository().updateRef(refName, true);
ru.setForceUpdate(true);
- assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ testRefAction(() -> assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED));
}
private void addNoteDbCommit(Change.Id id, String commitMessage) throws Exception {
@@ -847,30 +857,33 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
}
private ChangeNotes mergeChange(ChangeNotes notes) throws Exception {
- ObjectId oldId = getDestRef(notes);
- ObjectId newId = psUtil.current(notes).commitId();
- String dest = notes.getChange().getDest().branch();
-
- try (BatchUpdate bu = newUpdate(adminId)) {
- bu.addOp(
- notes.getChangeId(),
- new BatchUpdateOp() {
- @Override
- public void updateRepo(RepoContext ctx) throws IOException {
- ctx.addRefUpdate(oldId, newId, dest);
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) {
- ctx.getChange().setStatus(Change.Status.MERGED);
- ctx.getUpdate(ctx.getChange().currentPatchSetId())
- .fixStatusToMerged(new SubmissionId(ctx.getChange()));
- return true;
- }
- });
- bu.execute();
- }
- return reload(notes);
+ return testRefAction(
+ () -> {
+ ObjectId oldId = getDestRef(notes);
+ ObjectId newId = psUtil.current(notes).commitId();
+ String dest = notes.getChange().getDest().branch();
+
+ try (BatchUpdate bu = newUpdate(adminId)) {
+ bu.addOp(
+ notes.getChangeId(),
+ new BatchUpdateOp() {
+ @Override
+ public void updateRepo(RepoContext ctx) throws IOException {
+ ctx.addRefUpdate(oldId, newId, dest);
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext ctx) {
+ ctx.getChange().setStatus(Change.Status.MERGED);
+ ctx.getUpdate(ctx.getChange().currentPatchSetId())
+ .fixStatusToMerged(new SubmissionId(ctx.getChange()));
+ return true;
+ }
+ });
+ bu.execute();
+ }
+ return reload(notes);
+ });
}
private static ProblemInfo problem(String message) {
@@ -911,7 +924,7 @@ public class ConsistencyCheckerIT extends AbstractDaemonTest {
ru.setExpectedOldObjectId(ref.getObjectId());
ru.setNewObjectId(ObjectId.zeroId());
ru.setForceUpdate(true);
- Result result = ru.delete();
+ Result result = testRefAction(() -> ru.delete());
if (result != Result.FORCED) {
throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
index 1eef944c5e..107b777ef4 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/DeleteZombieDraftIT.java
@@ -15,6 +15,7 @@
package com.google.gerrit.acceptance.server.change;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableList;
@@ -30,6 +31,7 @@ import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.server.notedb.ChangeNoteJson;
import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.testing.ConfigSuite;
import com.google.gson.JsonParser;
import com.google.inject.Inject;
@@ -191,10 +193,12 @@ public class DeleteZombieDraftIT extends AbstractDaemonTest {
}
private void restoreRef(String refName, ObjectId id) throws Exception {
- try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
- RefUpdate u = allUsersRepo.updateRef(refName);
- u.setNewObjectId(id);
- u.forceUpdate();
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
+ RefUpdate u = allUsersRepo.updateRef(refName);
+ u.setNewObjectId(id);
+ u.forceUpdate();
+ }
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 70b57013e6..a1ba293dc4 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -20,6 +20,7 @@ import static com.google.gerrit.acceptance.GitUtil.assertPushOk;
import static com.google.gerrit.acceptance.GitUtil.pushHead;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
import static com.google.gerrit.extensions.common.testing.EditInfoSubject.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
@@ -39,6 +40,7 @@ import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.common.data.GlobalCapability;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.GetRelatedOption;
@@ -53,6 +55,7 @@ import com.google.gerrit.server.restapi.change.ChangesCollection;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Inject;
@@ -98,6 +101,35 @@ public class GetRelatedIT extends AbstractDaemonTest {
}
@Test
+ public void getRelatedAcrossBranchesWithMergeChange() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "foo"));
+
+ // Create and merge change on 'foo' branch.
+ merge(createChange("refs/for/foo"));
+
+ // Create change on 'foo' branch
+ PushOneCommit.Result base = createChange("refs/for/foo");
+ base.assertOkStatus();
+ RevCommit c1_1 = base.getCommit();
+ PatchSet.Id ps1_1 = getPatchSetId(c1_1);
+
+ testRepo.reset(initialHead);
+
+ // Create and push merge commit
+ PushOneCommit m = pushFactory.create(admin.newIdent(), testRepo);
+ m.setParents(ImmutableList.of(c1_1, initialHead));
+ PushOneCommit.Result result = m.to("refs/for/master");
+ result.assertOkStatus();
+ RevCommit c2_1 = result.getCommit();
+ PatchSet.Id ps2_1 = getPatchSetId(c2_1);
+
+ for (PatchSet.Id ps : ImmutableList.of(ps2_1, ps1_1)) {
+ assertRelated(ps, changeAndCommit(ps2_1, c2_1, 1), changeAndCommit(ps1_1, c1_1, 1));
+ }
+ }
+
+ @Test
public void getRelatedLinear() throws Exception {
// 1,1---2,1
RevCommit c1_1 = commitBuilder().add("a.txt", "1").message("subject: 1").create();
@@ -703,17 +735,19 @@ public class GetRelatedIT extends AbstractDaemonTest {
}
private void clearGroups(PatchSet.Id psId) throws Exception {
- try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
- bu.addOp(
- psId.changeId(),
- new BatchUpdateOp() {
- @Override
- public boolean updateChange(ChangeContext ctx) {
- ctx.getUpdate(psId).setGroups(ImmutableList.of());
- return true;
- }
- });
- bu.execute();
+ try (RefUpdateContext ctx = openTestRefUpdateContext()) {
+ try (BatchUpdate bu = batchUpdateFactory.create(project, user(user), TimeUtil.now())) {
+ bu.addOp(
+ psId.changeId(),
+ new BatchUpdateOp() {
+ @Override
+ public boolean updateChange(ChangeContext ctx) {
+ ctx.getUpdate(psId).setGroups(ImmutableList.of());
+ return true;
+ }
+ });
+ bu.execute();
+ }
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index fb3259f0fa..f2184de392 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -213,7 +213,7 @@ public class CommentAddedEventIT extends AbstractDaemonTest {
@Test
@GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "false")
- public void publishPatchSetLevelComment() throws Exception {
+ public void publishPatchSetLevelComment_disabled() throws Exception {
PushOneCommit.Result r = createChange();
TestListener listener = new TestListener();
try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
@@ -225,6 +225,20 @@ public class CommentAddedEventIT extends AbstractDaemonTest {
}
@Test
+ @GerritConfig(name = "event.comment-added.publishPatchSetLevelComment", value = "true")
+ public void publishPatchSetLevelComment_enabled() throws Exception {
+ PushOneCommit.Result r = createChange();
+ TestListener listener = new TestListener();
+ try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+ String patchSetLevelComment = "a patch set level comment";
+ ReviewInput reviewInput = new ReviewInput().patchSetLevelComment(patchSetLevelComment);
+ revision(r).review(reviewInput);
+ assertThat(listener.getLastCommentAddedEvent().getComment())
+ .isEqualTo(String.format("Patch Set 1:\n\n%s", patchSetLevelComment));
+ }
+ }
+
+ @Test
public void reviewChange_MultipleVotes() throws Exception {
TestListener listener = new TestListener();
try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
index b2a0ded55d..e011ffc053 100644
--- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -68,10 +68,7 @@ public class ExperimentFeaturesIT extends AbstractDaemonTest {
values = {"UiFeature__patchset_comments", "UiFeature__submit_requirements_ui"})
public void configOverride_defaultFeatureDisabled() {
assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue();
- assertThat(
- experimentFeatures.isFeatureEnabled(
- ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS))
- .isFalse();
+ assertThat(experimentFeatures.isFeatureEnabled("UiFeature__patchset_comments")).isFalse();
assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature");
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 7603aec029..ea836e64c6 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -17,6 +17,7 @@ package com.google.gerrit.acceptance.server.mail;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.entities.NotifyConfig.NotifyType.ABANDONED_CHANGES;
import static com.google.gerrit.entities.NotifyConfig.NotifyType.ALL_COMMENTS;
import static com.google.gerrit.entities.NotifyConfig.NotifyType.NEW_CHANGES;
@@ -28,9 +29,13 @@ import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER;
import static com.google.gerrit.extensions.api.changes.NotifyHandling.OWNER_REVIEWERS;
import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.CC_ON_OWN_COMMENTS;
import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.ENABLED;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.server.project.testing.TestLabels.labelBuilder;
+import static com.google.gerrit.server.project.testing.TestLabels.value;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.truth.Truth;
import com.google.gerrit.acceptance.AbstractNotificationTest;
import com.google.gerrit.acceptance.PushOneCommit;
@@ -38,11 +43,15 @@ 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.entities.Address;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.NotifyConfig;
+import com.google.gerrit.entities.NotifyConfig.Header;
+import com.google.gerrit.entities.NotifyConfig.NotifyType;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -304,6 +313,25 @@ public class ChangeNotificationsIT extends AbstractNotificationTest {
addReviewerToReviewableChange(batch());
}
+ @Test
+ public void addReviewerToChangeNoAnonymousUsersNotified() throws Exception {
+ StagedChange sc = stageReviewableChange();
+ // Remove read permission for anonymous users.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
+ addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
+
+ // No BY_EMAIL cc's.
+ assertThat(sender).sent("newchange", sc).to(reviewer).cc(sc.reviewer).noOneElse();
+ assertThat(sender).didNotSend();
+ }
+
private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
StagedChange sc = stageReviewableChange();
TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
@@ -670,6 +698,95 @@ public class ChangeNotificationsIT extends AbstractNotificationTest {
}
@Test
+ public void commentOnChangeWithNotifyConfig() throws Exception {
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ NotifyConfig nc =
+ NotifyConfig.builder()
+ .setName("observer")
+ .setNotify(ImmutableSet.of(NotifyType.ALL))
+ .setHeader(Header.CC)
+ .addAddress(Address.create("observer@example.com"))
+ .build();
+ u.getConfig().putNotifyConfig("observer", nc);
+ u.save();
+ }
+
+ StagedChange sc = stageReviewableChange();
+ review(sc.reviewer, sc.changeId, ENABLED);
+ assertThat(sender)
+ .sent("comment", sc)
+ .to(sc.owner)
+ .cc(sc.ccer)
+ .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+ .cc("observer@example.com")
+ .bcc(sc.starrer)
+ .bcc(ALL_COMMENTS)
+ .noOneElse();
+ assertThat(sender).didNotSend();
+ }
+
+ @Test
+ public void commentOnChangeNotVisibleToAnonymousByReviewer() throws Exception {
+ StagedChange sc = stageReviewableChange();
+
+ // Remove read permission for anonymous users.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ review(sc.reviewer, sc.changeId, ENABLED);
+ // Not cc'ed to BY_EMAIL added addresses.
+ assertThat(sender)
+ .sent("comment", sc)
+ .to(sc.owner)
+ .cc(sc.ccer)
+ .bcc(sc.starrer)
+ .bcc(ALL_COMMENTS)
+ .noOneElse();
+ assertThat(sender).didNotSend();
+ }
+
+ @Test
+ public void commentOnChangeNotVisibleToAnonymousByReviewerWithNotifyConfig() throws Exception {
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ NotifyConfig nc =
+ NotifyConfig.builder()
+ .setName("observer")
+ .setNotify(ImmutableSet.of(NotifyType.ALL))
+ .setHeader(Header.CC)
+ .addAddress(Address.create("observer@example.com"))
+ .build();
+ u.getConfig().putNotifyConfig("observer", nc);
+ u.save();
+ }
+
+ StagedChange sc = stageReviewableChange();
+
+ // Remove read permission for anonymous users.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+
+ review(sc.reviewer, sc.changeId, ENABLED);
+ // Not cc'ed to BY_EMAIL added addresses.
+ assertThat(sender)
+ .sent("comment", sc)
+ .to(sc.owner)
+ .cc(sc.ccer)
+ .cc("observer@example.com")
+ .bcc(sc.starrer)
+ .bcc(ALL_COMMENTS)
+ .noOneElse();
+ assertThat(sender).didNotSend();
+ }
+
+ @Test
public void commentOnReviewableChangeByOwnerCcingSelf() throws Exception {
StagedChange sc = stageReviewableChange();
review(sc.owner, sc.changeId, CC_ON_OWN_COMMENTS);
@@ -982,7 +1099,7 @@ public class ChangeNotificationsIT extends AbstractNotificationTest {
StagedPreChange spc = stagePreChange("refs/for/master");
assertThat(sender)
.sent("newchange", spc)
- .title(String.format("[S] Change in %s[master]: test commit", project));
+ .title(String.format("[XS] Change in %s[master]: test commit", project));
assertThat(sender).didNotSend();
}
@@ -1710,6 +1827,42 @@ public class ChangeNotificationsIT extends AbstractNotificationTest {
assertThat(sender).didNotSend();
}
+ @Test
+ public void mergeByOtherAlwaysNotifiesAllIfThereIsAStickyApprovalDiff() throws Exception {
+ StagedChange sc = stageChangeReadyForMergeWithStickyApprovalDiff();
+ // The user requests to notify NONE, but if there is a sticky approval diff we notify ALL.
+ merge(sc.changeId, other, NONE);
+ assertThat(sender)
+ .sent("merged", sc)
+ .to(sc.owner)
+ .cc(sc.reviewer)
+ .cc(sc.ccer)
+ .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+ .bcc(sc.starrer)
+ .bcc(SUBMITTED_CHANGES)
+ .bcc(ALL_COMMENTS)
+ .noOneElse();
+ assertThat(sender).didNotSend();
+ }
+
+ @Test
+ public void mergeOnBehalfOfAlwaysNotifiesAllIfThereIsAStickyApprovalDiff() throws Exception {
+ StagedChange sc = stageChangeReadyForMergeWithStickyApprovalDiff();
+ // The user requests to notify NONE, but if there is a sticky approval diff we notify ALL.
+ merge(sc.changeId, other, sc.owner, NONE);
+ assertThat(sender)
+ .sent("merged", sc)
+ .to(sc.owner)
+ .cc(sc.reviewer)
+ .cc(sc.ccer)
+ .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+ .bcc(sc.starrer)
+ .bcc(SUBMITTED_CHANGES)
+ .bcc(ALL_COMMENTS)
+ .noOneElse();
+ assertThat(sender).didNotSend();
+ }
+
private void merge(String changeId, TestAccount by) throws Exception {
merge(changeId, by, ENABLED);
}
@@ -1753,6 +1906,29 @@ public class ChangeNotificationsIT extends AbstractNotificationTest {
return sc;
}
+ private StagedChange stageChangeReadyForMergeWithStickyApprovalDiff() throws Exception {
+ try (ProjectConfigUpdate u = updateProject(project)) {
+ LabelType.Builder codeReview =
+ labelBuilder(
+ LabelId.CODE_REVIEW,
+ value(2, "Looks good to me, approved"),
+ value(1, "Looks good to me, but someone else must approve"),
+ value(0, "No score"),
+ value(-1, "I would prefer this is not submitted as is"),
+ value(-2, "This shall not be submitted"))
+ .setCopyCondition("is:ANY");
+ u.getConfig().upsertLabelType(codeReview.build());
+ u.save();
+ }
+
+ StagedChange sc = stageReviewableChange();
+ requestScopeOperations.setApiUser(sc.reviewer.id());
+ gApi.changes().id(sc.changeId).current().review(ReviewInput.approve());
+ amendChange(sc.changeId, "refs/for/master", sc.owner, sc.repo).assertOkStatus();
+ sender.clear();
+ return sc;
+ }
+
/*
* ReplacePatchSetSender tests.
*/
@@ -2390,154 +2566,6 @@ public class ChangeNotificationsIT extends AbstractNotificationTest {
}
/*
- * SetAssigneeSender tests.
- */
-
- @Test
- public void setAssigneeOnReviewableChange() throws Exception {
- StagedChange sc = stageReviewableChange();
- assign(sc, sc.owner, sc.assignee);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void setAssigneeOnReviewableChangeByOwnerCcingSelf() throws Exception {
- StagedChange sc = stageReviewableChange();
- assign(sc, sc.owner, sc.assignee, CC_ON_OWN_COMMENTS);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(sc.owner)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void setAssigneeOnReviewableChangeByAdmin() throws Exception {
- StagedChange sc = stageReviewableChange();
- assign(sc, admin, sc.assignee);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void setAssigneeOnReviewableChangeByAdminCcingSelf() throws Exception {
- StagedChange sc = stageReviewableChange();
- assign(sc, admin, sc.assignee, CC_ON_OWN_COMMENTS);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(admin)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void setAssigneeToSelfOnReviewableChange() throws Exception {
- StagedChange sc = stageReviewableChange();
- assign(sc, sc.owner, sc.owner);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void changeAssigneeOnReviewableChange() throws Exception {
- StagedChange sc = stageReviewableChange();
- TestAccount other = accountCreator.create("other", "other@example.com", "other", null);
- assign(sc, sc.owner, other);
- sender.clear();
- assign(sc, sc.owner, sc.assignee);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void changeAssigneeToSelfOnReviewableChange() throws Exception {
- StagedChange sc = stageReviewableChange();
- assign(sc, sc.owner, sc.assignee);
- sender.clear();
- assign(sc, sc.owner, sc.owner);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void setAssigneeOnReviewableWipChange() throws Exception {
- StagedChange sc = stageReviewableWipChange();
- assign(sc, sc.owner, sc.assignee);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- @Test
- public void setAssigneeOnWipChange() throws Exception {
- StagedChange sc = stageWipChange();
- assign(sc, sc.owner, sc.assignee);
- assertThat(sender)
- .sent("setassignee", sc)
- .cc(
- StagedUsers.REVIEWER_BY_EMAIL,
- StagedUsers.CC_BY_EMAIL) // TODO(logan): This is probably not intended!
- .to(sc.assignee)
- .noOneElse();
- assertThat(sender).didNotSend();
- }
-
- private void assign(StagedChange sc, TestAccount by, TestAccount to) throws Exception {
- assign(sc, by, to, ENABLED);
- }
-
- private void assign(StagedChange sc, TestAccount by, TestAccount to, EmailStrategy emailStrategy)
- throws Exception {
- setEmailStrategy(by, emailStrategy);
- requestScopeOperations.setApiUser(by.id());
- AssigneeInput in = new AssigneeInput();
- in.assignee = to.email();
- gApi.changes().id(sc.changeId).setAssignee(in);
- }
-
- /*
* Start review and WIP tests.
*/
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
index cc61dfbded..31452343c6 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/EmailValidatorIT.java
@@ -26,6 +26,7 @@ import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
+import java.util.Locale;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -83,12 +84,13 @@ public class EmailValidatorIT extends AbstractDaemonTest {
continue;
}
if (tld.startsWith(UNSUPPORTED_PREFIX)) {
- String test = "test@example." + tld.toLowerCase().substring(UNSUPPORTED_PREFIX.length());
+ String test =
+ "test@example." + tld.toLowerCase(Locale.US).substring(UNSUPPORTED_PREFIX.length());
assertWithMessage("expected invalid TLD \"" + test + "\"")
.that(validator.isValid(test))
.isFalse();
} else {
- String test = "test@example." + tld.toLowerCase();
+ String test = "test@example." + tld.toLowerCase(Locale.US);
assertWithMessage("failed to validate TLD \"" + test + "\"")
.that(validator.isValid(test))
.isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 45a471b26a..f7289950d7 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -15,16 +15,25 @@
package com.google.gerrit.acceptance.server.mail;
import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseLocalDisk;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.entities.EmailHeader;
import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
+import com.google.gerrit.server.config.SitePaths;
import java.net.URI;
+import java.nio.file.Files;
import java.util.Map;
+import javax.inject.Inject;
import org.junit.Test;
+@UseLocalDisk
public class MailSenderIT extends AbstractMailIT {
+ @Inject private SitePaths sitePaths;
+
@Test
@GerritConfig(name = "sendemail.replyToAddress", value = "custom@gerritcodereview.com")
@GerritConfig(name = "receiveemail.protocol", value = "POP3")
@@ -63,6 +72,20 @@ public class MailSenderIT extends AbstractMailIT {
assertThat(headerString(headers, "In-Reply-To")).isEqualTo(threadId);
}
+ @Test
+ @Sandboxed
+ public void useCustomTemplates() throws Exception {
+ String customTemplate =
+ "{namespace com.google.gerrit.server.mail.template.ChangeSubject}\n"
+ + "\n"
+ + "{template ChangeSubject kind=\"text\"}CUSTOM-TEMPLATE{/template}\n";
+ Files.write(sitePaths.mail_dir.resolve("ChangeSubject.soy"), customTemplate.getBytes(UTF_8));
+
+ createChangeWithReview(user);
+ String subject = headerString(sender.getMessages().iterator().next().headers(), "Subject");
+ assertThat(subject).isEqualTo("CUSTOM-TEMPLATE");
+ }
+
private String headerString(Map<String, EmailHeader> headers, String name) {
EmailHeader header = headers.get(name);
assertThat(header).isInstanceOf(StringEmailHeader.class);
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
index ab5e1d8d79..fc746adcea 100644
--- a/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -19,6 +19,7 @@ import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.util.stream.Collectors.toList;
import com.google.common.collect.Iterables;
@@ -100,10 +101,13 @@ public class NoteDbOnlyIT extends AbstractDaemonTest {
}
};
- try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
- bu.addOp(id, backupMasterOp);
- bu.execute();
- }
+ testRefAction(
+ () -> {
+ try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+ bu.addOp(id, backupMasterOp);
+ bu.execute();
+ }
+ });
// Ensure backupMasterOp worked.
assertThat(getRef(backup)).hasValue(master1);
@@ -158,13 +162,16 @@ public class NoteDbOnlyIT extends AbstractDaemonTest {
.changeUpdate(
"testUpdateRefAndAddMessageOp",
batchUpdateFactory -> {
- try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
- bu.addOp(
- id,
- new UpdateRefAndAddMessageOp(
- updateRepoCalledCount, updateChangeCalledCount));
- bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
- }
+ testRefAction(
+ () -> {
+ try (BatchUpdate bu = newBatchUpdate(batchUpdateFactory)) {
+ bu.addOp(
+ id,
+ new UpdateRefAndAddMessageOp(
+ updateRepoCalledCount, updateChangeCalledCount));
+ bu.execute(new ConcurrentWritingListener(afterUpdateReposCalledCount));
+ }
+ });
return "Done";
})
.call();
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 350811245e..7a55ecbfd2 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -30,6 +30,7 @@ import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.GroupDescription;
@@ -123,6 +124,7 @@ public class ExternalUserPermissionIT extends AbstractDaemonTest {
}
@Override
+ @Nullable
public String getUrl() {
return null;
}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 1900158e35..432a6c6332 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -22,6 +22,7 @@ import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.AccountGroup;
@@ -348,6 +349,173 @@ public class ProjectWatchIT extends AbstractDaemonTest {
}
@Test
+ public void noNotificationForWatchKeywordWhenKeywordMatchesChangeOwner() throws Exception {
+ String watchedProject = projectOperations.newProject().create().get();
+ requestScopeOperations.setApiUser(user.id());
+
+ // watch keyword in project as user
+ watch(watchedProject, admin.email());
+
+ // push a change with owner=keyword -> should not trigger email notification
+ requestScopeOperations.setApiUser(admin.id());
+ TestRepository<InMemoryRepository> watchedRepo =
+ cloneProject(Project.nameKey(watchedProject), admin);
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+ .to("refs/for/master");
+ r.assertOkStatus();
+
+ // assert email notification for user
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
+ public void noNotificationForWatchKeywordWhenKeywordMatchesChangeReviewer() throws Exception {
+ TestAccount user2 = accountCreator.user2();
+ String watchedProject = projectOperations.newProject().create().get();
+ requestScopeOperations.setApiUser(user.id());
+
+ // watch keyword in project as user
+ watch(watchedProject, user2.email());
+
+ requestScopeOperations.setApiUser(admin.id());
+ TestRepository<InMemoryRepository> watchedRepo =
+ cloneProject(Project.nameKey(watchedProject), admin);
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+ .to("refs/for/master");
+ r.assertOkStatus();
+ sender.clear();
+
+ // Add reviewer=keyword -> should trigger email notification only to new reviewer
+ gApi.changes().id(r.getChangeId()).addReviewer(user2.email());
+
+ // assert email notification
+ List<Message> messages = sender.getMessages();
+ assertThat(messages).hasSize(1);
+ Message m = messages.get(0);
+ assertNotifyTo(user2);
+ assertThat(m.body()).contains("Change subject: subject\n");
+ }
+
+ @Test
+ public void watchOwner() throws Exception {
+ String watchedProject = projectOperations.newProject().create().get();
+ requestScopeOperations.setApiUser(user.id());
+
+ // watch keyword in project as user
+ watch(watchedProject, "owner:admin");
+
+ // push a change with keyword -> should trigger email notification
+ requestScopeOperations.setApiUser(admin.id());
+ TestRepository<InMemoryRepository> watchedRepo =
+ cloneProject(Project.nameKey(watchedProject), admin);
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+ .to("refs/for/master");
+ r.assertOkStatus();
+
+ // assert email notification for user
+ List<Message> messages = sender.getMessages();
+ assertThat(messages).hasSize(1);
+ Message m = messages.get(0);
+ assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+ assertThat(m.body()).contains("Change subject: subject\n");
+ assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+ sender.clear();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+ public void watchNonVisibleOwner() throws Exception {
+ String watchedProject = projectOperations.newProject().create().get();
+ requestScopeOperations.setApiUser(user.id());
+
+ // watch keyword in project as user
+ watch(watchedProject, "owner:admin");
+
+ // Verify that 'user' can't see 'admin'
+ assertThatAccountIsNotVisible(admin);
+
+ // push a change with keyword -> should trigger email notification
+ requestScopeOperations.setApiUser(admin.id());
+ TestRepository<InMemoryRepository> watchedRepo =
+ cloneProject(Project.nameKey(watchedProject), admin);
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+ .to("refs/for/master");
+ r.assertOkStatus();
+
+ // Assert email notification for user.
+ // The non-visible account participated in a change that is visible to user, hence through this
+ // change user can see the non-visible account.
+ // Even if watching by the non-visible account was not possible, user could just watch all
+ // changes that are visible to them and then filter them by the non-visible account locally.
+ List<Message> messages = sender.getMessages();
+ assertThat(messages).hasSize(1);
+ Message m = messages.get(0);
+ assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+ assertThat(m.body()).contains("Change subject: subject\n");
+ assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+ sender.clear();
+ }
+
+ @Test
+ public void watchChangesCommentedBySelf() throws Exception {
+ String watchedProject = projectOperations.newProject().create().get();
+ requestScopeOperations.setApiUser(user.id());
+
+ // user watches all changes that have a comment by themselves
+ watch(watchedProject, "commentby:self");
+
+ // pushing a change as admin should not trigger an email to user
+ requestScopeOperations.setApiUser(admin.id());
+ TestRepository<InMemoryRepository> watchedRepo =
+ cloneProject(Project.nameKey(watchedProject), admin);
+ PushOneCommit.Result r =
+ pushFactory
+ .create(admin.newIdent(), watchedRepo, "subject", "a.txt", "a1")
+ .to("refs/for/master");
+ r.assertOkStatus();
+ assertThat(sender.getMessages()).isEmpty();
+
+ // commenting by admin should not trigger an email to user
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.message = "A Comment";
+ gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+ assertThat(sender.getMessages()).isEmpty();
+
+ // commenting by user matches the project watch, but doesn't send an email to user because
+ // CC_ON_OWN_COMMENTS is false by default, so the user is removed from the TO list, but an email
+ // is sent to the admin user
+ requestScopeOperations.setApiUser(user.id());
+ gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+ List<Message> messages = sender.getMessages();
+ assertThat(messages).hasSize(1);
+ Message m = messages.get(0);
+ assertThat(m.rcpt()).containsExactly(admin.getNameEmail());
+ assertThat(m.body()).contains("Change subject: subject\n");
+ assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+ sender.clear();
+
+ // commenting by admin now triggers an email to user because the change has a comment by user
+ // and hence matches the project watch
+ requestScopeOperations.setApiUser(admin.id());
+ gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+ messages = sender.getMessages();
+ assertThat(messages).hasSize(1);
+ m = messages.get(0);
+ assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+ assertThat(m.body()).contains("Change subject: subject\n");
+ assertThat(m.body()).contains("Gerrit-PatchSet: 1\n");
+ sender.clear();
+ }
+
+ @Test
public void watchAllProjects() throws Exception {
String anyProject = projectOperations.newProject().create().get();
requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
index d3c494984d..9e27e933d1 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -493,6 +493,74 @@ public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
SubmitRequirementResult.Status.UNSATISFIED);
}
+ @Test
+ public void byCommitterEmail() throws Exception {
+ TestAccount user2 =
+ accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+ requestScopeOperations.setApiUser(user2.id());
+ ChangeInfo info =
+ gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+ ChangeData cd =
+ changeQueryProvider
+ .get()
+ .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+ .get(0);
+
+ // Match by email works
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "committeremail:\"^.*@example\\.com\"",
+ SubmitRequirementResult.Status.SATISFIED);
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "committeremail:\"^user@.*\\.com\"",
+ SubmitRequirementResult.Status.SATISFIED);
+
+ // Match by name does not work
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "committeremail:\"^Foo$\"",
+ SubmitRequirementResult.Status.UNSATISFIED);
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "committeremail:\"^User$\"",
+ SubmitRequirementResult.Status.UNSATISFIED);
+ }
+
+ @Test
+ public void byUploaderEmail() throws Exception {
+ TestAccount user2 =
+ accountCreator.create("Foo", "user@example.com", "User", /* displayName = */ null);
+ requestScopeOperations.setApiUser(user2.id());
+ ChangeInfo info =
+ gApi.changes().create(new ChangeInput(project.get(), "master", "Test Change")).get();
+ ChangeData cd =
+ changeQueryProvider
+ .get()
+ .byLegacyChangeId(Change.Id.tryParse(Integer.toString(info._number)).get())
+ .get(0);
+
+ // Match by email works
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "uploaderemail:\"^.*@example\\.com\"",
+ SubmitRequirementResult.Status.SATISFIED);
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "uploaderemail:\"^user@.*\\.com\"",
+ SubmitRequirementResult.Status.SATISFIED);
+
+ // Match by name does not work
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "uploaderemail:\"^Foo$\"",
+ SubmitRequirementResult.Status.UNSATISFIED);
+ checkSubmitRequirementResult(
+ cd,
+ /* submittabilityExpr= */ "uploaderemail:\"^User$\"",
+ SubmitRequirementResult.Status.UNSATISFIED);
+ }
+
private void checkSubmitRequirementResult(
ChangeData cd, String submittabilityExpr, SubmitRequirementResult.Status expectedStatus) {
SubmitRequirement sr =
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 4f93dd6946..2938065778 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -16,6 +16,7 @@ package com.google.gerrit.acceptance.server.rules;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.GitUtil.pushHead;
+import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -266,7 +267,7 @@ public class RulesIT extends AbstractDaemonTest {
.commit()
.author(admin.newIdent())
.committer(admin.newIdent())
- .add("rules.pl", newContent)
+ .add(RULES_PL_FILE, newContent)
.message("Modify rules.pl")
.create();
}
diff --git a/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
new file mode 100644
index 0000000000..fdfef879fe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/util/TaskListenerIT.java
@@ -0,0 +1,285 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.git.WorkQueue.TaskListener;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TaskListenerIT extends AbstractDaemonTest {
+ /**
+ * Use a LatchedMethod in a method to allow another thread to await the method's call. Once
+ * called, the Latch.call() method will block until another thread calls its LatchedMethods's
+ * complete() method.
+ */
+ private static class LatchedMethod {
+ private static final int AWAIT_TIMEOUT = 20;
+ private static final TimeUnit AWAIT_TIMEUNIT = TimeUnit.MILLISECONDS;
+
+ /** API class meant be used by the class whose method is being latched */
+ private class Latch {
+ /** Ensure that the latched method calls this on entry */
+ public void call() {
+ called.countDown();
+ await(complete);
+ }
+ }
+
+ public Latch latch = new Latch();
+
+ private final CountDownLatch called = new CountDownLatch(1);
+ private final CountDownLatch complete = new CountDownLatch(1);
+
+ /** Assert that the Latch's call() method has not yet been called */
+ public void assertUncalled() {
+ assertThat(called.getCount()).isEqualTo(1);
+ }
+
+ /**
+ * Assert that a timeout does not occur while awaiting Latch's call() method to be called. Fails
+ * if the waiting time elapses before Latch's call() method is called, otherwise passes.
+ */
+ public void assertAwait() {
+ assertThat(await(called)).isEqualTo(true);
+ }
+
+ /** Unblock the Latch's call() method so that it can complete */
+ public void complete() {
+ complete.countDown();
+ }
+
+ @CanIgnoreReturnValue
+ private static boolean await(CountDownLatch latch) {
+ try {
+ return latch.await(AWAIT_TIMEOUT, AWAIT_TIMEUNIT);
+ } catch (InterruptedException e) {
+ return false;
+ }
+ }
+ }
+
+ private static class LatchedRunnable implements Runnable {
+ public LatchedMethod run = new LatchedMethod();
+
+ @Override
+ public void run() {
+ run.latch.call();
+ }
+ }
+
+ private static class ForwardingListener implements TaskListener {
+ public volatile TaskListener delegate;
+ public volatile Task<?> task;
+
+ public void resetDelegate(TaskListener listener) {
+ delegate = listener;
+ task = null;
+ }
+
+ @Override
+ public void onStart(Task<?> task) {
+ if (delegate != null) {
+ if (this.task == null || this.task == task) {
+ this.task = task;
+ delegate.onStart(task);
+ }
+ }
+ }
+
+ @Override
+ public void onStop(Task<?> task) {
+ if (delegate != null) {
+ if (this.task == task) {
+ delegate.onStop(task);
+ }
+ }
+ }
+ }
+
+ private static class LatchedListener implements TaskListener {
+ public LatchedMethod onStart = new LatchedMethod();
+ public LatchedMethod onStop = new LatchedMethod();
+
+ @Override
+ public void onStart(Task<?> task) {
+ onStart.latch.call();
+ }
+
+ @Override
+ public void onStop(Task<?> task) {
+ onStop.latch.call();
+ }
+ }
+
+ private static ForwardingListener forwarder;
+
+ @Inject private WorkQueue workQueue;
+ private ScheduledExecutorService executor;
+
+ private final LatchedListener listener = new LatchedListener();
+ private final LatchedRunnable runnable = new LatchedRunnable();
+
+ @Override
+ public Module createModule() {
+ return new AbstractModule() {
+ @Override
+ public void configure() {
+ // Forwarder.delegate is empty on start to protect test listener from non test tasks
+ // (such as the "Log File Compressor") interference
+ forwarder = new ForwardingListener(); // Only gets bound once for all tests
+ bind(TaskListener.class).annotatedWith(Exports.named("listener")).toInstance(forwarder);
+ }
+ };
+ }
+
+ @Before
+ public void setupExecutorAndForwarder() throws InterruptedException {
+ executor = workQueue.createQueue(1, "TaskListeners");
+
+ // "Log File Compressor"s are likely running and will interfere with tests
+ while (0 != workQueue.getTasks().size()) {
+ for (Task<?> t : workQueue.getTasks()) {
+ @SuppressWarnings("unused")
+ boolean unused = t.cancel(true);
+ }
+ TimeUnit.MILLISECONDS.sleep(1);
+ }
+
+ forwarder.resetDelegate(listener);
+
+ assertQueueSize(0);
+ assertThat(forwarder.task).isEqualTo(null);
+ listener.onStart.assertUncalled();
+ runnable.run.assertUncalled();
+ listener.onStop.assertUncalled();
+ }
+
+ @Test
+ public void onStartThenRunThenOnStopAreCalled() throws Exception {
+ int size = assertQueueBlockedOnExecution(runnable);
+
+ // onStartThenRunThenOnStopAreCalled -> onStart...Called
+ listener.onStart.assertAwait();
+ assertQueueSize(size);
+ runnable.run.assertUncalled();
+ listener.onStop.assertUncalled();
+
+ listener.onStart.complete();
+ // onStartThenRunThenOnStopAreCalled -> ...ThenRun...Called
+ runnable.run.assertAwait();
+ listener.onStop.assertUncalled();
+
+ runnable.run.complete();
+ // onStartThenRunThenOnStopAreCalled -> ...ThenOnStop...Called
+ listener.onStop.assertAwait();
+ assertQueueSize(size);
+
+ listener.onStop.complete();
+ assertAwaitQueueSize(--size);
+ }
+
+ @Test
+ public void firstBlocksSecond() throws Exception {
+ int size = assertQueueBlockedOnExecution(runnable);
+
+ // firstBlocksSecond -> first...
+ listener.onStart.assertAwait();
+ assertQueueSize(size);
+
+ LatchedRunnable runnable2 = new LatchedRunnable();
+ size = assertQueueBlockedOnExecution(runnable2);
+
+ // firstBlocksSecond -> ...BlocksSecond
+ runnable2.run.assertUncalled();
+ assertQueueSize(size); // waiting on first
+
+ listener.onStart.complete();
+ runnable.run.assertAwait();
+ assertQueueSize(size); // waiting on first
+ runnable2.run.assertUncalled();
+
+ runnable.run.complete();
+ listener.onStop.assertAwait();
+ assertQueueSize(size); // waiting on first
+ runnable2.run.assertUncalled();
+
+ listener.onStop.complete();
+ runnable2.run.assertAwait();
+ assertQueueSize(--size);
+
+ runnable2.run.complete();
+ assertAwaitQueueSize(--size);
+ }
+
+ @Test
+ public void states() throws Exception {
+ executor.execute(runnable);
+ listener.onStart.assertAwait();
+ assertStateIs(Task.State.STARTING);
+
+ listener.onStart.complete();
+ runnable.run.assertAwait();
+ assertStateIs(Task.State.RUNNING);
+
+ runnable.run.complete();
+ listener.onStop.assertAwait();
+ assertStateIs(Task.State.STOPPING);
+
+ listener.onStop.complete();
+ assertAwaitQueueIsEmpty();
+ assertStateIs(Task.State.DONE);
+ }
+
+ private void assertStateIs(Task.State state) {
+ assertThat(forwarder.task.getState()).isEqualTo(state);
+ }
+
+ private int assertQueueBlockedOnExecution(Runnable runnable) {
+ int expectedSize = workQueue.getTasks().size() + 1;
+ executor.execute(runnable);
+ assertQueueSize(expectedSize);
+ return expectedSize;
+ }
+
+ private void assertQueueSize(int size) {
+ assertThat(workQueue.getTasks().size()).isEqualTo(size);
+ }
+
+ private void assertAwaitQueueIsEmpty() throws InterruptedException {
+ assertAwaitQueueSize(0);
+ }
+
+ /** Fails if the waiting time elapses before the count is reached, otherwise passes */
+ private void assertAwaitQueueSize(int size) throws InterruptedException {
+ long i = 0;
+ do {
+ TimeUnit.NANOSECONDS.sleep(10);
+ assertThat(i++).isLessThan(100);
+ } while (size != workQueue.getTasks().size());
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
index 80b8ff074b..1b04e80309 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -30,7 +30,9 @@ import com.google.gerrit.server.query.change.ChangeData;
import java.io.IOException;
import java.io.Reader;
import java.time.Duration;
+import java.util.Arrays;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -85,10 +87,13 @@ public class StreamEventsIT extends AbstractDaemonTest {
@Test
public void batchRefsUpdatedShowSeparatelyInStreamEvents() throws Exception {
String refName = createChange().getChange().currentPatchSet().refName();
+ AtomicInteger numberOfFoundEvents = new AtomicInteger(0);
waitForEvent(
() ->
- pollEventsContaining("ref-updated", refName.substring(0, refName.lastIndexOf('/')))
- .size()
+ numberOfFoundEvents.addAndGet(
+ pollEventsContaining(
+ "ref-updated", refName.substring(0, refName.lastIndexOf('/')))
+ .size())
== 2);
}
@@ -121,8 +126,8 @@ public class StreamEventsIT extends AbstractDaemonTest {
char[] cbuf = new char[2048];
StringBuilder eventsOutput = new StringBuilder();
while (streamEventsReader.ready()) {
- streamEventsReader.read(cbuf);
- eventsOutput.append(cbuf);
+ int read = streamEventsReader.read(cbuf);
+ eventsOutput.append(Arrays.copyOfRange(cbuf, 0, read));
}
return StreamSupport.stream(
Splitter.on('\n').trimResults().split(eventsOutput.toString()).spliterator(), false)
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
index 6c629c9d32..5bdf91f53c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -24,11 +24,13 @@ import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static com.google.gerrit.truth.MapSubject.assertThatMap;
import static com.google.gerrit.truth.OptionalSubject.assertThat;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.account.TestAccount;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Account;
@@ -36,6 +38,7 @@ import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeType;
@@ -49,6 +52,7 @@ import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
import java.util.Map;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
import org.junit.Test;
public class ChangeOperationsImplTest extends AbstractDaemonTest {
@@ -145,6 +149,124 @@ public class ChangeOperationsImplTest extends AbstractDaemonTest {
}
@Test
+ public void createdChangeHasDefaultGroupsByDefault() throws Exception {
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+ Change.Id changeId =
+ changeOperations.newChange().project(project).branch("test-branch").create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+ }
+
+ @Test
+ public void createdChangeHasDefaultGroupsIfBranchTipIsSpecifiedAsParent() throws Exception {
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .tipOfBranch("refs/heads/test-branch")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+ }
+
+ @Test
+ public void createdChangeHasSameGroupsAsOpenParentChange() throws Exception {
+ Project.NameKey project = projectOperations.newProject().create();
+
+ Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+
+ ChangeInfo parentChange = getChangeFromServer(parentChangeId);
+ ImmutableList<String> parentGroups = getGroups(project, parentChangeId);
+ assertThat(parentGroups).containsExactly(parentChange.currentRevision);
+
+ Change.Id changeId =
+ changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
+
+ assertThat(getGroups(project, changeId)).isEqualTo(parentGroups);
+ }
+
+ @Test
+ public void createdChangeHasDefaultGroupsIfClosedChangeIsSpecifiedAsParent() throws Exception {
+ Project.NameKey project = projectOperations.newProject().create();
+
+ Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+ gApi.changes().id(parentChangeId.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(parentChangeId.get()).current().submit();
+
+ Change.Id changeId =
+ changeOperations.newChange().project(project).childOf().change(parentChangeId).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+ }
+
+ @Test
+ public void createdChangeHasSameGroupsAsPatchSetOfOpenParentChange() throws Exception {
+ Project.NameKey project = projectOperations.newProject().create();
+
+ Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+ TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+ changeOperations.change(parentChangeId).newPatchset().create();
+
+ ImmutableList<String> parentGroups = getGroups(project, parentPatchset.patchsetId());
+ assertThat(parentGroups).containsExactly(parentPatchset.commitId().name());
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .patchset(parentPatchset.patchsetId())
+ .create();
+
+ assertThat(getGroups(project, changeId)).isEqualTo(parentGroups);
+ }
+
+ @Test
+ public void createdChangeHasDefaultGroupsIfPatchSetOfClosedChangeIsSpecifiedAsParent()
+ throws Exception {
+ Project.NameKey project = projectOperations.newProject().create();
+
+ Change.Id parentChangeId = changeOperations.newChange().project(project).create();
+ TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+ changeOperations.change(parentChangeId).newPatchset().create();
+ gApi.changes().id(parentChangeId.get()).current().review(ReviewInput.approve());
+ gApi.changes().id(parentChangeId.get()).current().submit();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .patchset(parentPatchset.patchsetId())
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+ }
+
+ @Test
+ public void createdChangeHasDefaultGroupsIfCommitIsSpecifiedAsParent() throws Exception {
+ Project.NameKey project = projectOperations.newProject().create();
+
+ // Currently, the easiest way to create a commit is by creating another change.
+ Change.Id anotherChangeId = changeOperations.newChange().project(project).create();
+ ObjectId parentCommitId =
+ changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
+
+ Change.Id changeId =
+ changeOperations.newChange().project(project).childOf().commit(parentCommitId).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(getGroups(project, changeId)).containsExactly(change.currentRevision);
+ }
+
+ @Test
public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
@@ -611,6 +733,155 @@ public class ChangeOperationsImplTest extends AbstractDaemonTest {
}
@Test
+ public void createdChangeHasOwnerAsAuthor() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(changeOwner.fullname().get());
+ assertThat(revision.commit.author.email).isEqualTo(changeOwner.preferredEmail().get());
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedOwnerAsAuthor() throws Exception {
+ String changeOwnerName = "Change Owner";
+ String changeOwnerEmail = "change-owner@example.com";
+ Account.Id changeOwner =
+ accountOperations
+ .newAccount()
+ .fullname(changeOwnerName)
+ .preferredEmail(changeOwnerEmail)
+ .create();
+ Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(changeOwnerName);
+ assertThat(revision.commit.author.email).isEqualTo(changeOwnerEmail);
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedAuthor() throws Exception {
+ String authorName = "Author";
+ String authorEmail = "author@example.com";
+ Account.Id author =
+ accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+ Change.Id changeId = changeOperations.newChange().author(author).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isNotEqualTo(author.get());
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(authorName);
+ assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedAuthorIdent() throws Exception {
+ PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+ Change.Id changeId = changeOperations.newChange().authorIdent(authorIdent).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+ assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+ }
+
+ @Test
+ public void changeCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+ Account.Id author = accountOperations.newAccount().create();
+ PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () -> changeOperations.newChange().author(author).authorIdent(authorIdent).create());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo("author and authorIdent cannot be set together");
+ }
+
+ @Test
+ public void createdChangeHasOwnerAsCommitter() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ TestAccount changeOwner = accountOperations.account(Account.id(change.owner._accountId)).get();
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(changeOwner.fullname().get());
+ assertThat(revision.commit.committer.email).isEqualTo(changeOwner.preferredEmail().get());
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedOwnerAsCommitter() throws Exception {
+ String changeOwnerName = "Change Owner";
+ String changeOwnerEmail = "change-owner@example.com";
+ Account.Id changeOwner =
+ accountOperations
+ .newAccount()
+ .fullname(changeOwnerName)
+ .preferredEmail(changeOwnerEmail)
+ .create();
+ Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(changeOwnerName);
+ assertThat(revision.commit.committer.email).isEqualTo(changeOwnerEmail);
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedCommitter() throws Exception {
+ String committerName = "Committer";
+ String committerEmail = "committer@example.com";
+ Account.Id committer =
+ accountOperations
+ .newAccount()
+ .fullname(committerName)
+ .preferredEmail(committerEmail)
+ .create();
+ Change.Id changeId = changeOperations.newChange().committer(committer).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(committerName);
+ assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedCommitterIdent() throws Exception {
+ PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+ Change.Id changeId = changeOperations.newChange().committerIdent(committerIdent).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+ assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+ }
+
+ @Test
+ public void changeCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+ Account.Id committer = accountOperations.newAccount().create();
+ PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .newChange()
+ .committer(committer)
+ .committerIdent(committerIdent)
+ .create());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo("committer and committerIdent cannot be set together");
+ }
+
+ @Test
public void createdChangeHasSpecifiedTopic() throws Exception {
Change.Id changeId = changeOperations.newChange().topic("test-topic").create();
@@ -795,6 +1066,173 @@ public class ChangeOperationsImplTest extends AbstractDaemonTest {
}
@Test
+ public void newPatchsetCanHaveDifferentUploader() throws Exception {
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo currentPatchsetRevision = change.revisions.get(change.currentRevision);
+ assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(changeOwner.get());
+
+ Account.Id newUploader = accountOperations.newAccount().create();
+ changeOperations.change(changeId).newPatchset().uploader(newUploader).create();
+
+ change = getChangeFromServer(changeId);
+ currentPatchsetRevision = change.revisions.get(change.currentRevision);
+ assertThat(currentPatchsetRevision.uploader._accountId).isEqualTo(newUploader.get());
+ }
+
+ @Test
+ public void createdPatchsetPreviousAuthorAsAuthor() throws Exception {
+ String authorName = "Author";
+ String authorEmail = "author@example.com";
+ Account.Id author =
+ accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+ Change.Id changeId = changeOperations.newChange().author(author).create();
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(authorName);
+ assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+
+ changeOperations.change(changeId).newPatchset().create();
+ change = getChangeFromServer(changeId);
+ revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(authorName);
+ assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+ }
+
+ @Test
+ public void createdPatchsetHasSpecifiedAuthor() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String authorName = "Author";
+ String authorEmail = "author@example.com";
+ Account.Id author =
+ accountOperations.newAccount().fullname(authorName).preferredEmail(authorEmail).create();
+ changeOperations.change(changeId).newPatchset().author(author).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isNotEqualTo(author.get());
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(authorName);
+ assertThat(revision.commit.author.email).isEqualTo(authorEmail);
+ }
+
+ @Test
+ public void createdPatchsetHasSpecifiedAuthorIdent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+ changeOperations.change(changeId).newPatchset().authorIdent(authorIdent).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.author.name).isEqualTo(authorIdent.getName());
+ assertThat(revision.commit.author.email).isEqualTo(authorIdent.getEmailAddress());
+ }
+
+ @Test
+ public void patchsetCannotBeCreatedWithAuthorAndAuthorIdent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ Account.Id author = accountOperations.newAccount().create();
+ PersonIdent authorIdent = new PersonIdent("Author", "author@example.com");
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .author(author)
+ .authorIdent(authorIdent)
+ .create());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo("author and authorIdent cannot be set together");
+ }
+
+ @Test
+ public void createdPatchsetPreviousCommitterAsCommitter() throws Exception {
+ String committerName = "Committer";
+ String committerEmail = "committer@example.com";
+ Account.Id committer =
+ accountOperations
+ .newAccount()
+ .fullname(committerName)
+ .preferredEmail(committerEmail)
+ .create();
+ Change.Id changeId = changeOperations.newChange().committer(committer).create();
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(committerName);
+ assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+
+ changeOperations.change(changeId).newPatchset().create();
+ change = getChangeFromServer(changeId);
+ revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(committerName);
+ assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+ }
+
+ @Test
+ public void createdPatchsetHasSpecifiedCommitter() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String committerName = "Committer";
+ String committerEmail = "committer@example.com";
+ Account.Id committer =
+ accountOperations
+ .newAccount()
+ .fullname(committerName)
+ .preferredEmail(committerEmail)
+ .create();
+ changeOperations.change(changeId).newPatchset().committer(committer).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isNotEqualTo(committer.get());
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(committerName);
+ assertThat(revision.commit.committer.email).isEqualTo(committerEmail);
+ }
+
+ @Test
+ public void createdPatchsetHasSpecifiedCommitterIdent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+ changeOperations.change(changeId).newPatchset().committerIdent(committerIdent).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo revision = change.revisions.get(change.currentRevision);
+ assertThat(revision.commit.committer.name).isEqualTo(committerIdent.getName());
+ assertThat(revision.commit.committer.email).isEqualTo(committerIdent.getEmailAddress());
+ }
+
+ @Test
+ public void patchsetCannotBeCreatedWithCommitterAndCommitterIdent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ Account.Id committer = accountOperations.newAccount().create();
+ PersonIdent committerIdent = new PersonIdent("Committer", "committer@example.com");
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .committer(committer)
+ .committerIdent(committerIdent)
+ .create());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo("committer and committerIdent cannot be set together");
+ }
+
+ @Test
public void newPatchsetCanHaveUpdatedCommitMessage() throws Exception {
Change.Id changeId = changeOperations.newChange().commitMessage("Old message").create();
@@ -1316,6 +1754,17 @@ public class ChangeOperationsImplTest extends AbstractDaemonTest {
return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
}
+ private ImmutableList<String> getGroups(Project.NameKey projectName, Change.Id changeId) {
+ return changeDataFactory.create(projectName, changeId).currentPatchSet().groups();
+ }
+
+ private ImmutableList<String> getGroups(Project.NameKey projectName, PatchSet.Id patchSetId) {
+ return changeDataFactory
+ .create(projectName, patchSetId.changeId())
+ .patchSet(patchSetId)
+ .groups();
+ }
+
private Correspondence<CommitInfo, String> hasSha1() {
return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasSha1");
}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 7543ba808a..661802e131 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -18,11 +18,14 @@ 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.allowLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabelRemoval;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabel;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.blockLabelRemoval;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.capabilityKey;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelRemovalPermissionKey;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
import static com.google.gerrit.common.data.GlobalCapability.ADMINISTRATE_SERVER;
import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
@@ -160,7 +163,8 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
Project.NameKey key = projectOperations.newProject().create();
Config config = projectOperations.project(key).getConfig();
assertThat(config).isNotInstanceOf(StoredConfig.class);
- assertThat(config).text().isEmpty();
+ assertThat(config).sections().containsExactly("submit");
+ assertThat(config).sectionValues("submit").containsExactly("action", "inherit");
ConfigInput input = new ConfigInput();
input.description = "my fancy project";
@@ -168,7 +172,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
config = projectOperations.project(key).getConfig();
assertThat(config).isNotInstanceOf(StoredConfig.class);
- assertThat(config).sections().containsExactly("project");
+ assertThat(config).sections().containsExactly("project", "submit");
assertThat(config).subsections("project").isEmpty();
assertThat(config).sectionValues("project").containsExactly("description", "my fancy project");
}
@@ -193,7 +197,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -210,7 +214,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -227,7 +231,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -244,7 +248,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -262,7 +266,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -277,7 +281,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -318,7 +322,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -328,31 +332,28 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
}
@Test
- public void addDuplicatePermissions() throws Exception {
+ public void addDuplicatePermissions_isIgnored() throws Exception {
TestPermission permission =
TestProjectUpdate.allow(Permission.ABANDON).ref("refs/foo").group(REGISTERED_USERS).build();
Project.NameKey key = projectOperations.newProject().create();
projectOperations.project(key).forUpdate().add(permission).add(permission).update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().contains("access");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
- .containsExactly(
- "abandon", "group global:Registered-Users",
- "abandon", "group global:Registered-Users");
+ // Duplicated permission was recorded only once
+ .containsExactly("abandon", "group global:Registered-Users");
projectOperations.project(key).forUpdate().add(permission).update();
config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
- .containsExactly(
- "abandon", "group global:Registered-Users",
- "abandon", "group global:Registered-Users",
- "abandon", "group global:Registered-Users");
+ // Duplicated permission in request was dropped
+ .containsExactly("abandon", "group global:Registered-Users");
}
@Test
@@ -365,7 +366,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -382,7 +383,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -400,7 +401,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -415,7 +416,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -437,7 +438,7 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
.update();
Config config = projectOperations.project(key).getConfig();
- assertThat(config).sections().containsExactly("access");
+ assertThat(config).sections().containsExactly("access", "submit");
assertThat(config).subsections("access").containsExactly("refs/foo");
assertThat(config)
.subsectionValues("access", "refs/foo")
@@ -445,6 +446,73 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
}
@Test
+ public void addAllowLabelRemovalPermission() throws Exception {
+ Project.NameKey key = projectOperations.newProject().create();
+ projectOperations
+ .project(key)
+ .forUpdate()
+ .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+ .update();
+
+ Config config = projectOperations.project(key).getConfig();
+ assertThat(config).sections().containsExactly("access", "submit");
+ assertThat(config).subsections("access").containsExactly("refs/foo");
+ assertThat(config)
+ .subsectionValues("access", "refs/foo")
+ .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+ }
+
+ @Test
+ public void addBlockLabelRemovalPermission() throws Exception {
+ Project.NameKey key = projectOperations.newProject().create();
+ projectOperations
+ .project(key)
+ .forUpdate()
+ .add(blockLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+ .update();
+
+ Config config = projectOperations.project(key).getConfig();
+ assertThat(config).sections().containsExactly("access", "submit");
+ assertThat(config).subsections("access").containsExactly("refs/foo");
+ assertThat(config)
+ .subsectionValues("access", "refs/foo")
+ .containsExactly("removeLabel-Code-Review", "block -1..+2 group global:Registered-Users");
+ }
+
+ @Test
+ public void addAllowExclusiveLabelRemovalPermission() throws Exception {
+ Project.NameKey key = projectOperations.newProject().create();
+ projectOperations
+ .project(key)
+ .forUpdate()
+ .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+ .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), true)
+ .update();
+
+ Config config = projectOperations.project(key).getConfig();
+ assertThat(config).sections().containsExactly("access", "submit");
+ assertThat(config).subsections("access").containsExactly("refs/foo");
+ assertThat(config)
+ .subsectionValues("access", "refs/foo")
+ .containsExactly(
+ "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+ "exclusiveGroupPermissions", "removeLabel-Code-Review");
+
+ projectOperations
+ .project(key)
+ .forUpdate()
+ .setExclusiveGroup(labelRemovalPermissionKey("Code-Review").ref("refs/foo"), false)
+ .update();
+
+ config = projectOperations.project(key).getConfig();
+ assertThat(config).sections().containsExactly("access", "submit");
+ assertThat(config).subsections("access").containsExactly("refs/foo");
+ assertThat(config)
+ .subsectionValues("access", "refs/foo")
+ .containsExactly("removeLabel-Code-Review", "-1..+2 group global:Registered-Users");
+ }
+
+ @Test
public void addAllowCapability() throws Exception {
Config config = projectOperations.project(allProjects).getConfig();
assertThat(config)
@@ -542,6 +610,31 @@ public class ProjectOperationsImplTest extends AbstractDaemonTest {
}
@Test
+ public void removeLabelRemovalPermission() throws Exception {
+ Project.NameKey key = projectOperations.newProject().create();
+ projectOperations
+ .project(key)
+ .forUpdate()
+ .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(REGISTERED_USERS).range(-1, 2))
+ .add(allowLabelRemoval("Code-Review").ref("refs/foo").group(PROJECT_OWNERS).range(-2, 1))
+ .update();
+ assertThat(projectOperations.project(key).getConfig())
+ .subsectionValues("access", "refs/foo")
+ .containsExactly(
+ "removeLabel-Code-Review", "-1..+2 group global:Registered-Users",
+ "removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+
+ projectOperations
+ .project(key)
+ .forUpdate()
+ .remove(labelRemovalPermissionKey("Code-Review").ref("refs/foo").group(REGISTERED_USERS))
+ .update();
+ assertThat(projectOperations.project(key).getConfig())
+ .subsectionValues("access", "refs/foo")
+ .containsExactly("removeLabel-Code-Review", "-2..+1 group global:Project-Owners");
+ }
+
+ @Test
public void removeCapability() throws Exception {
projectOperations
.allProjectsForUpdate()
diff --git a/javatests/com/google/gerrit/common/data/BUILD b/javatests/com/google/gerrit/common/data/BUILD
index f2b7d63b26..154fd89027 100644
--- a/javatests/com/google/gerrit/common/data/BUILD
+++ b/javatests/com/google/gerrit/common/data/BUILD
@@ -4,6 +4,7 @@ junit_tests(
name = "data_tests",
srcs = glob(["*.java"]),
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/common:server",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/testing:gerrit-test-util",
diff --git a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
index 477f9d28ff..ba4b586420 100644
--- a/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
+++ b/javatests/com/google/gerrit/common/data/GroupReferenceTest.java
@@ -17,6 +17,7 @@ package com.google.gerrit.common.data;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.AccountGroup.UUID;
import com.google.gerrit.entities.GroupDescription;
@@ -33,6 +34,7 @@ public class GroupReferenceTest {
new GroupDescription.Basic() {
@Override
+ @Nullable
public String getUrl() {
return null;
}
@@ -48,6 +50,7 @@ public class GroupReferenceTest {
}
@Override
+ @Nullable
public String getEmailAddress() {
return null;
}
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
index 3175671942..d25d8337a5 100644
--- a/javatests/com/google/gerrit/entities/PermissionTest.java
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -36,6 +36,7 @@ public class PermissionTest {
assertThat(Permission.isPermission(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
assertThat(Permission.isPermission(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+ assertThat(Permission.isPermission(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
assertThat(Permission.isPermission(LabelId.CODE_REVIEW)).isFalse();
}
@@ -56,6 +57,7 @@ public class PermissionTest {
assertThat(Permission.isLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isTrue();
assertThat(Permission.isLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+ assertThat(Permission.isLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
assertThat(Permission.isLabel(LabelId.CODE_REVIEW)).isFalse();
}
@@ -66,10 +68,22 @@ public class PermissionTest {
assertThat(Permission.isLabelAs(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
assertThat(Permission.isLabelAs(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isTrue();
+ assertThat(Permission.isLabelAs(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isFalse();
assertThat(Permission.isLabelAs(LabelId.CODE_REVIEW)).isFalse();
}
@Test
+ public void isRemoveLabel() {
+ assertThat(Permission.isRemoveLabel(Permission.ABANDON)).isFalse();
+ assertThat(Permission.isRemoveLabel("no-permission")).isFalse();
+
+ assertThat(Permission.isRemoveLabel(Permission.LABEL + LabelId.CODE_REVIEW)).isFalse();
+ assertThat(Permission.isRemoveLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW)).isFalse();
+ assertThat(Permission.isRemoveLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW)).isTrue();
+ assertThat(Permission.isRemoveLabel(LabelId.CODE_REVIEW)).isFalse();
+ }
+
+ @Test
public void forLabel() {
assertThat(Permission.forLabel(LabelId.CODE_REVIEW))
.isEqualTo(Permission.LABEL + LabelId.CODE_REVIEW);
@@ -82,11 +96,19 @@ public class PermissionTest {
}
@Test
+ public void forRemoveLabel() {
+ assertThat(Permission.forRemoveLabel(LabelId.CODE_REVIEW))
+ .isEqualTo(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW);
+ }
+
+ @Test
public void extractLabel() {
assertThat(Permission.extractLabel(Permission.LABEL + LabelId.CODE_REVIEW))
.isEqualTo(LabelId.CODE_REVIEW);
assertThat(Permission.extractLabel(Permission.LABEL_AS + LabelId.CODE_REVIEW))
.isEqualTo(LabelId.CODE_REVIEW);
+ assertThat(Permission.extractLabel(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+ .isEqualTo(LabelId.CODE_REVIEW);
assertThat(Permission.extractLabel(LabelId.CODE_REVIEW)).isNull();
assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
}
@@ -103,6 +125,10 @@ public class PermissionTest {
Permission.canBeOnAllProjects(
AccessSection.ALL, Permission.LABEL_AS + LabelId.CODE_REVIEW))
.isTrue();
+ assertThat(
+ Permission.canBeOnAllProjects(
+ AccessSection.ALL, Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+ .isTrue();
assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
@@ -113,6 +139,10 @@ public class PermissionTest {
Permission.canBeOnAllProjects(
"refs/heads/*", Permission.LABEL_AS + LabelId.CODE_REVIEW))
.isTrue();
+ assertThat(
+ Permission.canBeOnAllProjects(
+ "refs/heads/*", Permission.REMOVE_LABEL + LabelId.CODE_REVIEW))
+ .isTrue();
}
@Test
@@ -126,6 +156,8 @@ public class PermissionTest {
.isEqualTo(LabelId.CODE_REVIEW);
assertThat(Permission.create(Permission.LABEL_AS + LabelId.CODE_REVIEW).getLabel())
.isEqualTo(LabelId.CODE_REVIEW);
+ assertThat(Permission.create(Permission.REMOVE_LABEL + LabelId.CODE_REVIEW).getLabel())
+ .isEqualTo(LabelId.CODE_REVIEW);
assertThat(Permission.create(LabelId.CODE_REVIEW).getLabel()).isNull();
assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
}
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bd4b2b1562..bbf10bd6f5 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -48,7 +48,6 @@ public class ChangeProtoConverterTest {
PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
change.setTopic("my topic");
change.setSubmissionId("submission ID 234");
- change.setAssignee(Account.id(100001));
change.setPrivate(true);
change.setWorkInProgress(true);
change.setReviewStarted(true);
@@ -73,7 +72,6 @@ public class ChangeProtoConverterTest {
.setTopic("my topic")
.setOriginalSubject("original subject ABC")
.setSubmissionId("submission ID 234")
- .setAssignee(Entities.Account_Id.newBuilder().setId(100001))
.setIsPrivate(true)
.setWorkInProgress(true)
.setReviewStarted(true)
@@ -205,7 +203,6 @@ public class ChangeProtoConverterTest {
PatchSet.id(Change.id(14), 23), "subject XYZ", "original subject ABC");
change.setTopic("my topic");
change.setSubmissionId("submission ID 234");
- change.setAssignee(Account.id(100001));
change.setPrivate(true);
change.setWorkInProgress(true);
change.setReviewStarted(true);
@@ -289,7 +286,6 @@ public class ChangeProtoConverterTest {
.put("topic", String.class)
.put("originalSubject", String.class)
.put("submissionId", String.class)
- .put("assignee", Account.Id.class)
.put("isPrivate", boolean.class)
.put("workInProgress", boolean.class)
.put("reviewStarted", boolean.class)
@@ -313,7 +309,6 @@ public class ChangeProtoConverterTest {
assertThat(change.getTopic()).isEqualTo(expectedChange.getTopic());
assertThat(change.getOriginalSubject()).isEqualTo(expectedChange.getOriginalSubject());
assertThat(change.getSubmissionId()).isEqualTo(expectedChange.getSubmissionId());
- assertThat(change.getAssignee()).isEqualTo(expectedChange.getAssignee());
assertThat(change.isPrivate()).isEqualTo(expectedChange.isPrivate());
assertThat(change.isWorkInProgress()).isEqualTo(expectedChange.isWorkInProgress());
assertThat(change.hasReviewStarted()).isEqualTo(expectedChange.hasReviewStarted());
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
index 3a534e99fa..447b62505b 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetProtoConverterTest.java
@@ -42,6 +42,7 @@ public class PatchSetProtoConverterTest {
.id(PatchSet.id(Change.id(103), 73))
.commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
.uploader(Account.id(452))
+ .realUploader(Account.id(687))
.createdOn(Instant.ofEpochMilli(930349320L))
.groups(ImmutableList.of("group1", " group2"))
.pushCertificate("my push certificate")
@@ -59,6 +60,7 @@ public class PatchSetProtoConverterTest {
.setCommitId(
Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
.setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+ .setRealUploaderAccountId(Entities.Account_Id.newBuilder().setId(687))
.setCreatedOn(930349320L)
.setGroups("group1, group2")
.setPushCertificate("my push certificate")
@@ -74,6 +76,7 @@ public class PatchSetProtoConverterTest {
.id(PatchSet.id(Change.id(103), 73))
.commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
.uploader(Account.id(452))
+ .realUploader(Account.id(687))
.createdOn(Instant.ofEpochMilli(930349320L))
.build();
@@ -88,6 +91,7 @@ public class PatchSetProtoConverterTest {
.setCommitId(
Entities.ObjectId.newBuilder().setName("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
.setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+ .setRealUploaderAccountId(Entities.Account_Id.newBuilder().setId(687))
.setCreatedOn(930349320L)
.build();
assertThat(proto).isEqualTo(expectedProto);
@@ -100,6 +104,7 @@ public class PatchSetProtoConverterTest {
.id(PatchSet.id(Change.id(103), 73))
.commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
.uploader(Account.id(452))
+ .realUploader(Account.id(687))
.createdOn(Instant.ofEpochMilli(930349320L))
.groups(ImmutableList.of("group1", " group2"))
.pushCertificate("my push certificate")
@@ -118,6 +123,7 @@ public class PatchSetProtoConverterTest {
.id(PatchSet.id(Change.id(103), 73))
.commitId(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))
.uploader(Account.id(452))
+ .realUploader(Account.id(687))
.createdOn(Instant.ofEpochMilli(930349320L))
.build();
@@ -143,6 +149,30 @@ public class PatchSetProtoConverterTest {
.id(PatchSet.id(Change.id(103), 73))
.commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
.uploader(Account.id(0))
+ .realUploader(Account.id(0))
+ .createdOn(Instant.EPOCH)
+ .build());
+ }
+
+ @Test
+ public void realUploaderIsSetToUploaderIfMissingFromProto() {
+ Entities.PatchSet proto =
+ Entities.PatchSet.newBuilder()
+ .setId(
+ Entities.PatchSet_Id.newBuilder()
+ .setChangeId(Entities.Change_Id.newBuilder().setId(103))
+ .setId(73))
+ .setUploaderAccountId(Entities.Account_Id.newBuilder().setId(452))
+ .build();
+
+ PatchSet convertedPatchSet = patchSetProtoConverter.fromProto(proto);
+ Truth.assertThat(convertedPatchSet)
+ .isEqualTo(
+ PatchSet.builder()
+ .id(PatchSet.id(Change.id(103), 73))
+ .commitId(ObjectId.fromString("0000000000000000000000000000000000000000"))
+ .uploader(Account.id(452))
+ .realUploader(Account.id(452))
.createdOn(Instant.EPOCH)
.build());
}
@@ -156,6 +186,7 @@ public class PatchSetProtoConverterTest {
.put("id", PatchSet.Id.class)
.put("commitId", ObjectId.class)
.put("uploader", Account.Id.class)
+ .put("realUploader", Account.Id.class)
.put("createdOn", Instant.class)
.put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
.put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
diff --git a/javatests/com/google/gerrit/extensions/BUILD b/javatests/com/google/gerrit/extensions/BUILD
index 2202a1168a..1bb39c84b3 100644
--- a/javatests/com/google/gerrit/extensions/BUILD
+++ b/javatests/com/google/gerrit/extensions/BUILD
@@ -5,6 +5,7 @@ junit_tests(
size = "small",
srcs = glob(["**/*.java"]),
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/extensions/common/testing:common-test-util",
"//lib:guava",
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 024e35e5e9..3704969817 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ReviewerState;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
@@ -47,6 +48,7 @@ public final class ChangeInfoDifferTest {
assertThat(diff.added().messages).isNull();
assertThat(diff.added().reviewers).isNull();
assertThat(diff.added().hashtags).isNull();
+ assertThat(diff.added().removableLabels).isNull();
assertThat(diff.removed()._number).isNull();
assertThat(diff.removed().branch).isNull();
assertThat(diff.removed().project).isNull();
@@ -55,6 +57,7 @@ public final class ChangeInfoDifferTest {
assertThat(diff.removed().messages).isNull();
assertThat(diff.removed().reviewers).isNull();
assertThat(diff.removed().hashtags).isNull();
+ assertThat(diff.removed().removableLabels).isNull();
}
@Test
@@ -91,61 +94,6 @@ public final class ChangeInfoDifferTest {
}
@Test
- public void getDiff_givenEqualAssignees_returnsNullAssignee() {
- ChangeInfo oldChangeInfo =
- createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
- ChangeInfo newChangeInfo =
- createChangeInfoWithAccount(
- new AccountInfo(oldChangeInfo.assignee.name, oldChangeInfo.assignee.email));
-
- ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
- assertThat(diff.added().assignee).isNull();
- assertThat(diff.removed().assignee).isNull();
- }
-
- @Test
- public void getDiff_givenNewAssignee_returnsAssignee() {
- ChangeInfo oldChangeInfo = new ChangeInfo();
- ChangeInfo newChangeInfo =
- createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
-
- ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
- assertThat(diff.added().assignee).isEqualTo(newChangeInfo.assignee);
- assertThat(diff.removed().assignee).isNull();
- }
-
- @Test
- public void getDiff_withRemovedAssignee_returnsAssignee() {
- ChangeInfo oldChangeInfo =
- createChangeInfoWithAccount(new AccountInfo("name", "mail@mail.com"));
- ChangeInfo newChangeInfo = new ChangeInfo();
-
- ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
- assertThat(diff.added().assignee).isNull();
- assertThat(diff.removed().assignee).isEqualTo(oldChangeInfo.assignee);
- }
-
- @Test
- public void getDiff_givenAssigneeWithNewName_returnsNameButNotEmail() {
- ChangeInfo oldChangeInfo =
- createChangeInfoWithAccount(new AccountInfo("old name", "mail@mail.com"));
- ChangeInfo newChangeInfo =
- createChangeInfoWithAccount(new AccountInfo("new name", oldChangeInfo.assignee.email));
-
- ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
-
- assertThat(diff.added().assignee).isNotNull();
- assertThat(diff.added().assignee.name).isEqualTo(newChangeInfo.assignee.name);
- assertThat(diff.added().assignee.email).isNull();
- assertThat(diff.removed().assignee).isNotNull();
- assertThat(diff.removed().assignee.name).isEqualTo(oldChangeInfo.assignee.name);
- assertThat(diff.removed().assignee.email).isNull();
- }
-
- @Test
public void getDiff_whenHashtagsChanged_returnsHashtags() {
String removedHashtag = "removed";
String addedHashtag = "added";
@@ -291,6 +239,7 @@ public final class ChangeInfoDifferTest {
assertThat(diff.added().revisions.get(REVISION).uploader.name).isNull();
assertThat(diff.added().revisions.get(REVISION).uploader.email)
.isEqualTo(newRevision.uploader.email);
+ assertThat(diff.added().revisions.get(REVISION).realUploader).isNull();
assertThat(diff.removed().revisions).isNotNull();
assertThat(diff.removed().revisions).hasSize(1);
assertThat(diff.removed().revisions).containsKey(REVISION);
@@ -298,6 +247,37 @@ public final class ChangeInfoDifferTest {
assertThat(diff.removed().revisions.get(REVISION).uploader.name).isNull();
assertThat(diff.removed().revisions.get(REVISION).uploader.email)
.isEqualTo(oldRevision.uploader.email);
+ assertThat(diff.removed().revisions.get(REVISION).realUploader).isNull();
+ }
+
+ @Test
+ public void getDiff_whenOneModifiedRevisionUploader_returnsModificationsToRevisionRealUploader() {
+ RevisionInfo oldRevision = new RevisionInfo(new AccountInfo("uploader", "uploader@mail.com"));
+ oldRevision.realUploader = new AccountInfo("real-uploader", "real-uploader@mail.com");
+ RevisionInfo newRevision = new RevisionInfo(oldRevision.uploader);
+ newRevision.realUploader =
+ new AccountInfo(oldRevision.realUploader.name, oldRevision.realUploader.email + "2");
+ ChangeInfo oldChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, oldRevision));
+ ChangeInfo newChangeInfo = new ChangeInfo(ImmutableMap.of(REVISION, newRevision));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().revisions).isNotNull();
+ assertThat(diff.added().revisions).hasSize(1);
+ assertThat(diff.added().revisions).containsKey(REVISION);
+ assertThat(diff.added().revisions.get(REVISION).uploader).isNull();
+ assertThat(diff.added().revisions.get(REVISION).realUploader).isNotNull();
+ assertThat(diff.added().revisions.get(REVISION).realUploader.name).isNull();
+ assertThat(diff.added().revisions.get(REVISION).realUploader.email)
+ .isEqualTo(newRevision.realUploader.email);
+ assertThat(diff.removed().revisions).isNotNull();
+ assertThat(diff.removed().revisions).hasSize(1);
+ assertThat(diff.removed().revisions).containsKey(REVISION);
+ assertThat(diff.removed().revisions.get(REVISION).uploader).isNull();
+ assertThat(diff.removed().revisions.get(REVISION).realUploader).isNotNull();
+ assertThat(diff.removed().revisions.get(REVISION).realUploader.name).isNull();
+ assertThat(diff.removed().revisions.get(REVISION).realUploader.email)
+ .isEqualTo(oldRevision.realUploader.email);
}
@Test
@@ -314,6 +294,295 @@ public final class ChangeInfoDifferTest {
}
@Test
+ public void getDiff_removableLabelsEmpty_returnsNullRemovableLabels() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ oldChangeInfo.removableLabels = ImmutableMap.of();
+ newChangeInfo.removableLabels = ImmutableMap.of();
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels).isNull();
+ assertThat(diff.removed().removableLabels).isNull();
+ }
+
+ @Test
+ public void getDiff_removableLabelsNullAndEmpty_returnsEmptyRemovableLabels() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ newChangeInfo.removableLabels = ImmutableMap.of();
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels).isEmpty();
+ assertThat(diff.removed().removableLabels).isNull();
+ }
+
+ @Test
+ public void getDiff_removableLabelsEmptyAndNull_returnsEmptyRemovableLabels() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ oldChangeInfo.removableLabels = ImmutableMap.of();
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels).isNull();
+ assertThat(diff.removed().removableLabels).isEmpty();
+ }
+
+ @Test
+ public void getDiff_removableLabelsLabelAdded() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "Cow";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "Pig";
+ AccountInfo acc3 = new AccountInfo();
+ acc3.name = "Cat";
+ AccountInfo acc4 = new AccountInfo();
+ acc4.name = "Dog";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of(
+ "Code-Review",
+ ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of(
+ "Code-Review",
+ ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+ "Verified",
+ ImmutableMap.of("-1", ImmutableList.of(acc4)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+ assertThat(diff.removed().removableLabels).isNull();
+ }
+
+ @Test
+ public void getDiff_removableLabelsLabelRemoved() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "Cow";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "Pig";
+ AccountInfo acc3 = new AccountInfo();
+ acc3.name = "Cat";
+ AccountInfo acc4 = new AccountInfo();
+ acc4.name = "Dog";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of(
+ "Code-Review",
+ ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)),
+ "Verified",
+ ImmutableMap.of("-1", ImmutableList.of(acc4)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of(
+ "Code-Review",
+ ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels).isNull();
+ assertThat(diff.removed().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc4))));
+ }
+
+ @Test
+ public void getDiff_removableLabelsVoteAdded() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "acc2";
+ AccountInfo acc3 = new AccountInfo();
+ acc3.name = "acc3";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of(
+ "Code-Review",
+ ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+ assertThat(diff.removed().removableLabels).isNull();
+ }
+
+ @Test
+ public void getDiff_removableLabelsVoteRemoved() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "acc2";
+ AccountInfo acc3 = new AccountInfo();
+ acc3.name = "acc3";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of(
+ "Code-Review",
+ ImmutableMap.of("+1", ImmutableList.of(acc1), "-1", ImmutableList.of(acc2, acc3)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels).isNull();
+ assertThat(diff.removed().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc2, acc3))));
+ }
+
+ @Test
+ public void getDiff_removableLabelsAccountAdded() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "acc2";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+ assertThat(diff.removed().removableLabels).isNull();
+ }
+
+ @Test
+ public void getDiff_removableLabelsAccountRemoved() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "acc2";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1, acc2)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+ assertThat(diff.removed().removableLabels).isNull();
+ }
+
+ @Test
+ public void getDiff_removableLabelsAccountChanged() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "acc2";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc2))));
+ assertThat(diff.removed().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+ }
+
+ @Test
+ public void getDiff_removableLabelsScoreChanged() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("-1", ImmutableList.of(acc1))));
+ assertThat(diff.removed().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+ }
+
+ @Test
+ public void getDiff_removableLabelsLabelChanged() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Verified", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+ assertThat(diff.removed().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+ }
+
+ @Test
+ public void getDiff_removableLabelsLabelScoreAndAccountChanged() {
+ ChangeInfo oldChangeInfo = new ChangeInfo();
+ ChangeInfo newChangeInfo = new ChangeInfo();
+ AccountInfo acc1 = new AccountInfo();
+ acc1.name = "acc1";
+ AccountInfo acc2 = new AccountInfo();
+ acc2.name = "acc2";
+
+ oldChangeInfo.removableLabels =
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1)));
+ newChangeInfo.removableLabels =
+ ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2)));
+
+ ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+ assertThat(diff.added().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Verified", ImmutableMap.of("-1", ImmutableList.of(acc2))));
+ assertThat(diff.removed().removableLabels)
+ .containsExactlyEntriesIn(
+ ImmutableMap.of("Code-Review", ImmutableMap.of("+1", ImmutableList.of(acc1))));
+ }
+
+ @Test
public void getDiff_assertCanConstructAllChangeInfoReferences() throws Exception {
buildObjectWithFullFields(ChangeInfo.class);
}
@@ -344,6 +613,7 @@ public final class ChangeInfoDifferTest {
assertThat(diff.removed().reviewers).isNull();
}
+ @Nullable
private static Object buildObjectWithFullFields(Class<?> c) throws Exception {
if (c == null) {
return null;
@@ -365,6 +635,7 @@ public final class ChangeInfoDifferTest {
return toPopulate;
}
+ @Nullable
private static Class<?> getParameterizedType(Field field) {
if (!Collection.class.isAssignableFrom(field.getType())) {
return null;
@@ -382,12 +653,6 @@ public final class ChangeInfoDifferTest {
return changeInfo;
}
- private static ChangeInfo createChangeInfoWithAccount(AccountInfo accountInfo) {
- ChangeInfo changeInfo = new ChangeInfo();
- changeInfo.assignee = accountInfo;
- return changeInfo;
- }
-
private static ChangeInfo createChangeInfoWithHashtags(String... hashtags) {
ChangeInfo changeInfo = new ChangeInfo();
changeInfo.hashtags = ImmutableList.copyOf(hashtags);
diff --git a/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java b/javatests/com/google/gerrit/extensions/restapi/IdStringTest.java
index 7fc0f032c5..3a928644d6 100644
--- a/java/com/google/gerrit/extensions/events/AssigneeChangedListener.java
+++ b/javatests/com/google/gerrit/extensions/restapi/IdStringTest.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -12,19 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-package com.google.gerrit.extensions.events;
+package com.google.gerrit.extensions.restapi;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.annotations.ExtensionPoint;
-import com.google.gerrit.extensions.common.AccountInfo;
+import static com.google.common.truth.Truth.assertThat;
-/** Notified whenever a change assignee is changed. */
-@ExtensionPoint
-public interface AssigneeChangedListener {
- interface Event extends ChangeEvent {
- @Nullable
- AccountInfo getOldAssignee();
- }
+import org.junit.Test;
- void onAssigneeChanged(Event event);
+/** Unit tests for {@link IdString}. */
+public class IdStringTest {
+ @Test
+ public void decodeStringWithPercentageThatIsNotFollowedByTwoHexadecimalDigits() throws Exception {
+ String s = "<%=FOO%>";
+ assertThat(IdString.fromUrl(s).get()).isEqualTo(s);
+ }
}
diff --git a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
index 3727d38afd..a01807aee9 100644
--- a/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
+++ b/javatests/com/google/gerrit/gpg/PublicKeyStoreTest.java
@@ -33,6 +33,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.Set;
import java.util.TreeSet;
import org.bouncycastle.openpgp.PGPPublicKey;
@@ -80,7 +81,7 @@ public class PublicKeyStoreTest {
PGPPublicKey key = validKeyWithoutExpiration().getPublicKey();
String objId = keyObjectId(key.getKeyID()).name();
assertEquals("ed0625dc46328a8c000000000000000000000000", objId);
- assertEquals(keyIdToString(key.getKeyID()).toLowerCase(), objId.substring(8, 16));
+ assertEquals(keyIdToString(key.getKeyID()).toLowerCase(Locale.US), objId.substring(8, 16));
}
@Test
diff --git a/javatests/com/google/gerrit/httpd/BUILD b/javatests/com/google/gerrit/httpd/BUILD
index 121cbc482c..a69d60f91d 100644
--- a/javatests/com/google/gerrit/httpd/BUILD
+++ b/javatests/com/google/gerrit/httpd/BUILD
@@ -21,6 +21,7 @@ junit_tests(
"//lib/bouncycastle:bcprov",
"//lib/guice",
"//lib/guice:guice-servlet",
+ "//lib/jsoup",
"//lib/mockito",
"//lib/truth",
"//lib/truth:truth-java8-extension",
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 71695f3e26..04f9827262 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -45,6 +45,7 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
+import java.util.Collection;
import java.util.Optional;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
@@ -72,8 +73,6 @@ public class ProjectBasicAuthFilterTest {
@Mock private AccountCache accountCache;
- @Mock private AccountState accountState;
-
@Mock private AccountManager accountManager;
@Mock private AuthConfig authConfig;
@@ -105,12 +104,8 @@ public class ProjectBasicAuthFilterTest {
authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
- Account account = Account.builder(Account.id(1000000), Instant.now()).build();
authSuccessful =
new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
- doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
- doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
- doReturn(account).when(accountState).account();
doReturn(authSuccessful).when(accountManager).authenticate(any());
doReturn(new WebSessionManager.Key(AUTH_COOKIE_VALUE)).when(webSessionManager).createKey(any());
@@ -123,6 +118,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldAllowAnonymousRequest() throws Exception {
+ initAccount();
initMockedWebSession();
res.setStatus(HttpServletResponse.SC_OK);
@@ -143,6 +139,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldRequestAuthenticationForBasicAuthRequest() throws Exception {
+ initAccount();
initMockedWebSession();
req.addHeader("Authorization", "Basic " + AUTH_USER_B64);
res.setStatus(HttpServletResponse.SC_OK);
@@ -165,6 +162,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldAuthenticateSucessfullyAgainstRealmAndReturnCookie() throws Exception {
+ initAccount();
initWebSessionWithoutCookie();
requestBasicAuth(req);
res.setStatus(HttpServletResponse.SC_OK);
@@ -191,9 +189,10 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldValidateUserPasswordAndNotReturnCookie() throws Exception {
+ ExternalId extId = createUsernamePasswordExternalId();
+ initAccount(ImmutableSet.of(extId));
initWebSessionWithoutCookie();
requestBasicAuth(req);
- initMockedUsernamePasswordExternalId();
doReturn(GitBasicAuthPolicy.HTTP).when(authConfig).getGitBasicAuthPolicy();
res.setStatus(HttpServletResponse.SC_OK);
@@ -217,6 +216,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldNotReauthenticateForGitPostRequest() throws Exception {
+ initAccount();
req.setPathInfo("/a/project.git/git-upload-pack");
req.setMethod("POST");
req.addHeader("Content-Type", "application/x-git-upload-pack-request");
@@ -229,6 +229,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldReauthenticateForRegularRequestEvenIfAlreadySignedIn() throws Exception {
+ initAccount();
doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
doFilterForRequestWhenAlreadySignedIn();
@@ -239,6 +240,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldReauthenticateEvenIfHasExistingCookie() throws Exception {
+ initAccount();
initWebSessionWithCookie("GerritAccount=" + AUTH_COOKIE_VALUE);
res.setStatus(HttpServletResponse.SC_OK);
requestBasicAuth(req);
@@ -262,6 +264,7 @@ public class ProjectBasicAuthFilterTest {
@Test
public void shouldFailedAuthenticationAgainstRealm() throws Exception {
+ initAccount();
initMockedWebSession();
requestBasicAuth(req);
@@ -285,6 +288,17 @@ public class ProjectBasicAuthFilterTest {
assertThat(res.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
}
+ private void initAccount() throws Exception {
+ initAccount(ImmutableSet.of());
+ }
+
+ private void initAccount(Collection<ExternalId> extIds) throws Exception {
+ Account account = Account.builder(Account.id(1000000), Instant.now()).build();
+ AccountState accountState = AccountState.forAccount(account, extIds);
+ doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
+ doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
+ }
+
private void doFilterForRequestWhenAlreadySignedIn()
throws IOException, ServletException, AccountException {
initMockedWebSession();
@@ -322,14 +336,12 @@ public class ProjectBasicAuthFilterTest {
doReturn(webSession).when(webSessionItem).get();
}
- private void initMockedUsernamePasswordExternalId() {
- ExternalId extId =
- extIdFactory.createWithPassword(
- extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
- AUTH_ACCOUNT_ID,
- null,
- AUTH_PASSWORD);
- doReturn(ImmutableSet.builder().add(extId).build()).when(accountState).externalIds();
+ private ExternalId createUsernamePasswordExternalId() {
+ return extIdFactory.createWithPassword(
+ extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
+ AUTH_ACCOUNT_ID,
+ null,
+ AUTH_PASSWORD);
}
private void requestBasicAuth(FakeHttpServletRequest fakeReq) {
diff --git a/javatests/com/google/gerrit/httpd/raw/DocServletTest.java b/javatests/com/google/gerrit/httpd/raw/DocServletTest.java
new file mode 100644
index 0000000000..2f09aa2305
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/DocServletTest.java
@@ -0,0 +1,167 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION;
+import static org.mockito.Mockito.when;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
+import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+@RunWith(JUnit4.class)
+public class DocServletTest {
+
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+ @Mock private ExperimentFeatures experimentFeatures;
+ private FileSystem fs = Jimfs.newFileSystem(Configuration.unix());
+ private DocServlet docServlet;
+
+ @Before
+ public void setUp() throws Exception {
+ when(experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION))
+ .thenReturn(true);
+
+ docServlet =
+ new DocServlet(
+ CacheBuilder.newBuilder().maximumSize(1).build(), false, experimentFeatures) {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected Path getResourcePath(String pathInfo) throws IOException {
+ return fs.getPath("/" + CharMatcher.is('/').trimLeadingFrom(pathInfo));
+ }
+ };
+
+ Files.createDirectories(fs.getPath(DOC_PATH).getParent());
+ Files.write(fs.getPath(DOC_PATH), HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+ Files.write(
+ fs.getPath(DOC_PATH_NO_SCRIPT), HTML_RESPONSE_NO_SCRIPT.getBytes(StandardCharsets.UTF_8));
+ Files.write(fs.getPath(NON_HTML_FILE_PATH), NON_HTML_FILE);
+ }
+
+ @Test
+ public void noNonce_unchangedResponse() throws Exception {
+ FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ docServlet.doGet(request, response);
+
+ assertThat(response.getActualBody()).isEqualTo(HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void experimentDisabled_unchangedResponse() throws Exception {
+ when(experimentFeatures.isFeatureEnabled(GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION))
+ .thenReturn(false);
+ FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+ request.setAttribute("nonce", NONCE);
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ docServlet.doGet(request, response);
+
+ assertThat(response.getActualBody()).isEqualTo(HTML_RESPONSE.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void nonHtmlResponse_unchangedResponse() throws Exception {
+ FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(NON_HTML_FILE_PATH);
+ request.setAttribute("nonce", NONCE);
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ docServlet.doGet(request, response);
+
+ assertThat(response.getActualBody()).isEqualTo(NON_HTML_FILE);
+ }
+
+ @Test
+ public void responseWithoutScripts_equivalentResponse() throws Exception {
+ FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH_NO_SCRIPT);
+ request.setAttribute("nonce", NONCE);
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ docServlet.doGet(request, response);
+
+ // Normally file is not guaranteed to not get reformatted, but in the simple example like we use
+ // here we can check byte-wise equality.
+ assertThat(response.getActualBody())
+ .isEqualTo(HTML_RESPONSE_NO_SCRIPT.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void htmlResponse_nonceAttached() throws Exception {
+ FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+ request.setAttribute("nonce", NONCE);
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ docServlet.doGet(request, response);
+
+ Document doc = Jsoup.parse(response.getActualBodyString());
+ for (Element el : doc.getElementsByTag("script")) {
+ assertThat(el.attributes().get("nonce")).isEqualTo(NONCE);
+ }
+ }
+
+ @Test
+ public void htmlResponse_noCacheHeaderSet() throws Exception {
+ FakeHttpServletRequest request = new FakeHttpServletRequest().setPathInfo(DOC_PATH);
+ request.setAttribute("nonce", NONCE);
+ FakeHttpServletResponse response = new FakeHttpServletResponse();
+
+ docServlet.doGet(request, response);
+
+ assertThat(response.getHeader("Cache-Control"))
+ .isEqualTo("no-cache, no-store, max-age=0, must-revalidate");
+ }
+
+ private static final String NONCE = "1234abcde";
+ private static final String HTML_RESPONSE =
+ "<!DOCTYPE html>"
+ + "<html lang=\"en\">"
+ + "<head>"
+ + " <title>Gerrit Code Review - Searching Changes</title>"
+ + " <link rel=\"stylesheet\" href=\"./asciidoctor.css\">"
+ + " <script src=\"./prettify.min.js\"></script>"
+ + " <script>document.addEventListener('DOMContentLoaded', prettyPrint)</script>"
+ + "</head><body></body></html>";
+ private static final String DOC_PATH = "/Documentation/page1.html";
+ private static final String HTML_RESPONSE_NO_SCRIPT =
+ "<html><head></head><body><div>Hello</div></body></html>";
+ private static final String DOC_PATH_NO_SCRIPT = "/Documentation/page_no_script.html";
+ private static final byte[] NON_HTML_FILE = "<script></script>".getBytes(StandardCharsets.UTF_8);
+ private static final String NON_HTML_FILE_PATH = "/foo";
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
index e1cccf89f7..9f8f4941b1 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
@@ -52,6 +52,7 @@ public class IndexPreloadingUtilTest {
@Test
public void preloadOnlyForSelfDashboard() throws Exception {
assertThat(parseRequestedPage("/dashboard/self")).isEqualTo(RequestedPage.DASHBOARD);
+ assertThat(parseRequestedPage("/profile/self")).isEqualTo(RequestedPage.PROFILE);
assertThat(parseRequestedPage("/dashboard/1085901"))
.isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
assertThat(parseRequestedPage("/dashboard/gerrit"))
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
index f65e8234e2..06ea8b6169 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -60,15 +60,13 @@ public class IndexServletTest {
String testCdnPath = "bar-cdn";
String testFaviconURL = "zaz-url";
- // Pick any known experiment enabled by default;
- String disabledDefault = ExperimentFeaturesConstants.UI_FEATURE_PATCHSET_COMMENTS;
- assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).contains(disabledDefault);
+ assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).isEmpty();
org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config();
serverConfig.setStringList(
"experiments", null, "enabled", ImmutableList.of("NewFeature", "DisabledFeature"));
serverConfig.setStringList(
- "experiments", null, "disabled", ImmutableList.of("DisabledFeature", disabledDefault));
+ "experiments", null, "disabled", ImmutableList.of("DisabledFeature"));
ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig);
IndexServlet servlet =
new IndexServlet(
@@ -97,7 +95,6 @@ public class IndexServletTest {
+ "\\x5b\\x5d\\x7d');");
ImmutableSet<String> enabledDefaults =
ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream()
- .filter(e -> !e.equals(disabledDefault))
.collect(ImmutableSet.toImmutableSet());
List<String> expectedEnabled = new ArrayList<>();
expectedEnabled.add("NewFeature");
diff --git a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
index 36641febf9..8a2de7dcc4 100644
--- a/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/ResourceServletTest.java
@@ -40,6 +40,7 @@ import java.nio.file.attribute.FileTime;
import java.time.LocalDateTime;
import java.time.Month;
import java.time.ZoneOffset;
+import java.util.Locale;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.GZIPInputStream;
import org.junit.Before;
@@ -331,7 +332,7 @@ public class ResourceServletTest {
}
private static void assertCacheable(FakeHttpServletResponse res, boolean revalidate) {
- String header = res.getHeader("Cache-Control").toLowerCase();
+ String header = res.getHeader("Cache-Control").toLowerCase(Locale.US);
assertThat(header).contains("public");
if (revalidate) {
assertThat(header).contains("must-revalidate");
diff --git a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
index c2caff806e..af6f28a9fd 100644
--- a/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
+++ b/javatests/com/google/gerrit/index/IndexUpgradeValidatorTest.java
@@ -18,10 +18,10 @@ import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.index.SchemaUtil.schema;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.index.SchemaFieldDefs.Getter;
import com.google.gerrit.server.index.change.ChangeField;
import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@@ -34,12 +34,41 @@ public class IndexUpgradeValidatorTest {
// SchemaFields.
@Test
public void valid() {
- IndexUpgradeValidator.assertValid(schema(1, ChangeField.ID), schema(2, ChangeField.ID));
IndexUpgradeValidator.assertValid(
- schema(1, ChangeField.ID), schema(2, ChangeField.ID, ChangeField.OWNER));
+ schema(
+ 1,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+ schema(
+ 2,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)));
IndexUpgradeValidator.assertValid(
- schema(1, ChangeField.ID),
- schema(2, ChangeField.ID, ChangeField.OWNER, ChangeField.COMMITTER));
+ schema(
+ 1,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+ schema(
+ 2,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(
+ ChangeField.OWNER_FIELD, ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.OWNER_SPEC, ChangeField.CHANGE_ID_SPEC)));
+ IndexUpgradeValidator.assertValid(
+ schema(
+ 1,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.CHANGE_ID_SPEC)),
+ schema(
+ 2,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(
+ ChangeField.CHANGE_ID_FIELD,
+ ChangeField.OWNER_FIELD,
+ ChangeField.COMMITTER_PARTS_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.CHANGE_ID_SPEC,
+ ChangeField.OWNER_SPEC,
+ ChangeField.COMMITTER_PARTS_SPEC)));
}
@Test
@@ -49,7 +78,16 @@ public class IndexUpgradeValidatorTest {
AssertionError.class,
() ->
IndexUpgradeValidator.assertValid(
- schema(1, ChangeField.ID), schema(2, ChangeField.OWNER)));
+ schema(
+ 1,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.CHANGE_ID_SPEC)),
+ schema(
+ 2,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.OWNER_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.OWNER_SPEC))));
assertThat(e)
.hasMessageThat()
.contains("Schema upgrade to version 2 may either add or remove fields, but not both");
@@ -58,32 +96,42 @@ public class IndexUpgradeValidatorTest {
@Test
public void invalid_modify() {
// Change value type from String to Integer.
- FieldDef<ChangeData, Integer> ID_MODIFIED =
- new FieldDef.Builder<>(FieldType.INTEGER, ChangeQueryBuilder.FIELD_CHANGE_ID)
- .build(cd -> 42);
+ IndexedField<ChangeData, Integer> ID_MODIFIED =
+ IndexedField.<ChangeData>integerBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(cd -> 42);
AssertionError e =
assertThrows(
AssertionError.class,
() ->
IndexUpgradeValidator.assertValid(
- schema(1, ChangeField.ID), schema(2, ID_MODIFIED)));
+ schema(
+ 1,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.CHANGE_ID_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.CHANGE_ID_SPEC)),
+ schema(
+ 2,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ID_MODIFIED),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of())));
assertThat(e).hasMessageThat().contains("Fields may not be modified");
- assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+ assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
}
@Test
public void invalid_modify_referenceEquality() {
// Comparison uses Object.equals(), i.e. reference equality.
Getter<ChangeData, String> getter = cd -> cd.change().getKey().get();
- FieldDef<ChangeData, String> ID_1 =
- new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
- FieldDef<ChangeData, String> ID_2 =
- new FieldDef.Builder<>(FieldType.PREFIX, ChangeQueryBuilder.FIELD_CHANGE_ID).build(getter);
+ IndexedField<ChangeData, String> ID_1 =
+ IndexedField.<ChangeData>stringBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(getter);
+ IndexedField<ChangeData, String> ID_2 =
+ IndexedField.<ChangeData>stringBuilder(ChangeField.CHANGE_ID_FIELD.name()).build(getter);
AssertionError e =
assertThrows(
AssertionError.class,
- () -> IndexUpgradeValidator.assertValid(schema(1, ID_1), schema(2, ID_2)));
+ () ->
+ IndexUpgradeValidator.assertValid(
+ schema(1, ImmutableList.of(ID_1), ImmutableList.of()),
+ schema(2, ImmutableList.of(ID_2), ImmutableList.of())));
assertThat(e).hasMessageThat().contains("Fields may not be modified");
- assertThat(e).hasMessageThat().contains(ChangeQueryBuilder.FIELD_CHANGE_ID);
+ assertThat(e).hasMessageThat().contains(ChangeField.CHANGE_ID_FIELD.name());
}
}
diff --git a/javatests/com/google/gerrit/index/SchemaUtilTest.java b/javatests/com/google/gerrit/index/SchemaUtilTest.java
index a92ee0ca32..4f67f8e361 100644
--- a/javatests/com/google/gerrit/index/SchemaUtilTest.java
+++ b/javatests/com/google/gerrit/index/SchemaUtilTest.java
@@ -15,12 +15,12 @@
package com.google.gerrit.index;
import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.index.FieldDef.exact;
import static com.google.gerrit.index.SchemaUtil.getNameParts;
import static com.google.gerrit.index.SchemaUtil.getPersonParts;
import static com.google.gerrit.index.SchemaUtil.schema;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import com.google.common.collect.ImmutableList;
import java.util.Map;
import org.eclipse.jgit.lib.PersonIdent;
import org.junit.Test;
@@ -30,20 +30,14 @@ import org.junit.runners.JUnit4;
@RunWith(JUnit4.class)
public class SchemaUtilTest {
- private static final FieldDef<String, String> TEST_DEF =
- exact("test_id").stored().build(id -> id);
-
- private static final FieldDef<String, String> OTHER_TEST_DEF =
- exact("other_test_id").stored().build(id -> id);
-
private static final IndexedField<String, String> TEST_FIELD =
IndexedField.<String>stringBuilder("TestId").build(a -> a);
private static final IndexedField<String, String> TEST_FIELD_DUPLICATE_NAME =
- IndexedField.<String>stringBuilder(TEST_DEF.getName()).build(a -> a);
+ IndexedField.<String>stringBuilder("TestId").build(a -> a);
private static final IndexedField<String, String>.SearchSpec TEST_FIELD_SPEC =
- TEST_FIELD.exact(TEST_DEF.getName());
+ TEST_FIELD.exact("test_id");
static class TestSchemas {
@@ -71,8 +65,10 @@ public class SchemaUtilTest {
@Test
public void schemaVersion_incrementedOnVersionUpgrades() {
- Schema<String> initialSchemaVersion = schema(/* version= */ 1);
- Schema<String> schemaVersionUpgrade = schema(initialSchemaVersion);
+ Schema<String> initialSchemaVersion =
+ schema(/* version= */ 1, ImmutableList.of(), ImmutableList.of());
+ Schema<String> schemaVersionUpgrade =
+ schema(initialSchemaVersion, ImmutableList.of(), ImmutableList.of());
assertThat(initialSchemaVersion.getVersion()).isEqualTo(1);
assertThat(schemaVersionUpgrade.getVersion()).isEqualTo(2);
}
@@ -108,21 +104,6 @@ public class SchemaUtilTest {
}
@Test
- public void canAddFieldSpecAndFieldDef() {
- Schema<String> schema0 =
- new Schema.Builder<String>()
- .version(0)
- .addIndexedFields(TEST_FIELD)
- .addSearchSpecs(TEST_FIELD_SPEC)
- .add(OTHER_TEST_DEF)
- .build();
-
- assertThat(schema0.hasField(TEST_FIELD_SPEC)).isTrue();
- assertThat(schema0.hasField(OTHER_TEST_DEF)).isTrue();
- assertThat(schema0.getIndexFields().values()).contains(TEST_FIELD);
- }
-
- @Test
public void canRemoveIndexedField() {
Schema<String> schema0 =
new Schema.Builder<String>()
@@ -157,23 +138,6 @@ public class SchemaUtilTest {
}
@Test
- public void canRemoveFieldDef() {
- Schema<String> schema0 =
- new Schema.Builder<String>()
- .version(0)
- .addIndexedFields(TEST_FIELD)
- .addSearchSpecs(TEST_FIELD_SPEC)
- .add(OTHER_TEST_DEF)
- .build();
-
- Schema<String> schema1 =
- new Schema.Builder<String>().add(schema0).remove(OTHER_TEST_DEF).build();
- assertThat(schema1.hasField(TEST_FIELD_SPEC)).isTrue();
- assertThat(schema1.hasField(OTHER_TEST_DEF)).isFalse();
- assertThat(schema1.getIndexFields().values()).contains(TEST_FIELD);
- }
-
- @Test
public void addSearchWithoutStoredField_disallowed() {
IllegalArgumentException thrown =
assertThrows(
@@ -199,7 +163,7 @@ public class SchemaUtilTest {
}
@Test
- public void addDuplicateSearchSpec_disallowed() {
+ public void addDuplicateIndexField_byName_disallowed() {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
@@ -207,14 +171,13 @@ public class SchemaUtilTest {
new Schema.Builder<String>()
.version(0)
.addIndexedFields(TEST_FIELD)
- .addSearchSpecs(TEST_FIELD_SPEC)
- .addSearchSpecs(TEST_FIELD_SPEC)
+ .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
.build());
- assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
+ assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: TestId");
}
@Test
- public void addFieldDefWithDuplicateSearchName_disallowed() {
+ public void addDuplicateSearchSpec_disallowed() {
IllegalArgumentException thrown =
assertThrows(
IllegalArgumentException.class,
@@ -223,28 +186,12 @@ public class SchemaUtilTest {
.version(0)
.addIndexedFields(TEST_FIELD)
.addSearchSpecs(TEST_FIELD_SPEC)
- .add(TEST_DEF)
+ .addSearchSpecs(TEST_FIELD_SPEC)
.build());
assertThat(thrown).hasMessageThat().contains("Multiple entries with same key: test_id");
}
@Test
- public void addFieldDefWithDuplicateFieldName_disallowed() {
- IllegalArgumentException thrown =
- assertThrows(
- IllegalArgumentException.class,
- () ->
- new Schema.Builder<String>()
- .version(0)
- .addIndexedFields(TEST_FIELD_DUPLICATE_NAME)
- .add(TEST_DEF)
- .build());
- assertThat(thrown)
- .hasMessageThat()
- .isEqualTo("DuplicateKeys found [test_id], indexFields:[test_id], schemaFields: [test_id]");
- }
-
- @Test
public void removeFieldWithExistingSearchSpec_disallowed() {
Schema<String> schema0 =
new Schema.Builder<String>()
diff --git a/javatests/com/google/gerrit/index/query/AndSourceTest.java b/javatests/com/google/gerrit/index/query/AndSourceTest.java
index 8b95bff4e7..068ae8c65d 100644
--- a/javatests/com/google/gerrit/index/query/AndSourceTest.java
+++ b/javatests/com/google/gerrit/index/query/AndSourceTest.java
@@ -29,7 +29,7 @@ public class AndSourceTest extends PredicateTest {
TestDataSourcePredicate p1 = new TestDataSourcePredicate("predicate1", "foo", 10, 10);
TestDataSourcePredicate p2 = new TestDataSourcePredicate("predicate2", "foo", 1, 10);
AndSource<String> andSource = new AndSource<>(Lists.newArrayList(p1, p2), null);
- andSource.match("bar");
+ assertFalse(andSource.match("bar"));
assertFalse(p1.ranMatch);
assertTrue(p2.ranMatch);
}
diff --git a/javatests/com/google/gerrit/json/BUILD b/javatests/com/google/gerrit/json/BUILD
index 575f575bd2..a242b0ea86 100644
--- a/javatests/com/google/gerrit/json/BUILD
+++ b/javatests/com/google/gerrit/json/BUILD
@@ -5,7 +5,6 @@ junit_tests(
srcs = glob(["*.java"]),
deps = [
"//java/com/google/gerrit/json",
- "//java/com/google/gerrit/server/util/time",
"//java/com/google/gerrit/testing:gerrit-test-util",
"//lib:gson",
"//lib:guava",
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index a586c0eef0..4821f20dd8 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -69,6 +69,7 @@ junit_tests(
"//java/com/google/gerrit/sshd",
"//java/com/google/gerrit/testing:assertable-executor",
"//java/com/google/gerrit/testing:gerrit-test-util",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//java/com/google/gerrit/truth",
"//lib:gson",
"//lib:guava",
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index 855a0bcb93..ce045f7506 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -37,6 +37,7 @@ import com.google.inject.Inject;
import com.google.inject.Injector;
import java.util.Arrays;
import java.util.HashSet;
+import java.util.Locale;
import java.util.Set;
import org.eclipse.jgit.lib.Config;
import org.junit.Before;
@@ -92,6 +93,7 @@ public class IdentifiedUserTest {
bind(AccountCache.class).toInstance(accountCache);
bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
bind(Realm.class).toInstance(mockRealm);
+ install(new DefaultRefLogIdentityProvider.Module());
}
};
@@ -113,14 +115,14 @@ public class IdentifiedUserTest {
@Test
public void emailsExistence() {
assertThat(identifiedUser.hasEmailAddress(TEST_CASES[0])).isTrue();
- assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase())).isTrue();
+ assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toLowerCase(Locale.US))).isTrue();
assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
- assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toUpperCase())).isTrue();
+ assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1].toUpperCase(Locale.US))).isTrue();
/* assert again to test cached email address by IdentifiedUser.validEmails */
assertThat(identifiedUser.hasEmailAddress(TEST_CASES[1])).isTrue();
assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2])).isTrue();
- assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase())).isTrue();
+ assertThat(identifiedUser.hasEmailAddress(TEST_CASES[2].toLowerCase(Locale.US))).isTrue();
assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
/* assert again to test cached email address by IdentifiedUser.invalidEmails */
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 3658834a15..34f746ad87 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -23,6 +23,8 @@ import static java.util.stream.Collectors.joining;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountResolver.Result;
import com.google.gerrit.server.account.AccountResolver.Searcher;
import com.google.gerrit.server.account.AccountResolver.StringSearcher;
@@ -35,11 +37,12 @@ import java.util.stream.Stream;
import org.junit.Test;
public class AccountResolverTest {
+ private final CurrentUser user = new AnonymousUser();
+
private static class TestSearcher extends StringSearcher {
private final String pattern;
private final boolean shortCircuit;
private final ImmutableList<AccountState> accounts;
- private boolean assumeVisible;
private boolean filterInactive;
private TestSearcher(String pattern, boolean shortCircuit, AccountState... accounts) {
@@ -64,15 +67,6 @@ public class AccountResolverTest {
}
@Override
- public boolean callerMayAssumeCandidatesAreVisible() {
- return assumeVisible;
- }
-
- void setCallerMayAssumeCandidatesAreVisible() {
- this.assumeVisible = true;
- }
-
- @Override
public boolean callerShouldFilterOutInactiveCandidates() {
return filterInactive;
}
@@ -135,17 +129,6 @@ public class AccountResolverTest {
}
@Test
- public void skipVisibilityCheck() throws Exception {
- TestSearcher searcher = new TestSearcher("foo", false, newAccount(1), newAccount(2));
- ImmutableList<Searcher<?>> searchers = ImmutableList.of(searcher);
-
- assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(2));
-
- searcher.setCallerMayAssumeCandidatesAreVisible();
- assertThat(search("foo", searchers, only(2)).asIdSet()).containsExactlyElementsIn(ids(1, 2));
- }
-
- @Test
public void dontFilterInactive() throws Exception {
ImmutableList<Searcher<?>> searchers =
ImmutableList.of(
@@ -282,7 +265,7 @@ public class AccountResolverTest {
AccountResolver resolver = newAccountResolver();
assertThat(
new UnresolvableAccountException(
- resolver.new Result("foo", ImmutableList.of(), ImmutableList.of())))
+ resolver.new Result("foo", ImmutableList.of(), ImmutableList.of(), user)))
.hasMessageThat()
.isEqualTo("Account 'foo' not found");
}
@@ -292,7 +275,7 @@ public class AccountResolverTest {
AccountResolver resolver = newAccountResolver();
UnresolvableAccountException e =
new UnresolvableAccountException(
- resolver.new Result("self", ImmutableList.of(), ImmutableList.of()));
+ resolver.new Result("self", ImmutableList.of(), ImmutableList.of(), user));
assertThat(e.isSelf()).isTrue();
assertThat(e).hasMessageThat().isEqualTo("Resolving account 'self' requires login");
}
@@ -302,7 +285,7 @@ public class AccountResolverTest {
AccountResolver resolver = newAccountResolver();
UnresolvableAccountException e =
new UnresolvableAccountException(
- resolver.new Result("me", ImmutableList.of(), ImmutableList.of()));
+ resolver.new Result("me", ImmutableList.of(), ImmutableList.of(), user));
assertThat(e.isSelf()).isTrue();
assertThat(e).hasMessageThat().isEqualTo("Resolving account 'me' requires login");
}
@@ -314,7 +297,10 @@ public class AccountResolverTest {
new UnresolvableAccountException(
resolver
.new Result(
- "foo", ImmutableList.of(newAccount(3), newAccount(1)), ImmutableList.of())))
+ "foo",
+ ImmutableList.of(newAccount(3), newAccount(1)),
+ ImmutableList.of(),
+ user)))
.hasMessageThat()
.isEqualTo(
"Account 'foo' is ambiguous (at most 3 shown):\n1: Anonymous Name (1)\n3: Anonymous Name (3)");
@@ -329,7 +315,8 @@ public class AccountResolverTest {
.new Result(
"foo",
ImmutableList.of(),
- ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)))))
+ ImmutableList.of(newInactiveAccount(3), newInactiveAccount(1)),
+ user)))
.hasMessageThat()
.isEqualTo(
"Account 'foo' only matches inactive accounts. To use an inactive account, retry"
@@ -352,10 +339,11 @@ public class AccountResolverTest {
Supplier<Predicate<AccountState>> visibilitySupplier,
Predicate<AccountState> activityPredicate)
throws Exception {
- return newAccountResolver().searchImpl(input, searchers, visibilitySupplier, activityPredicate);
+ return newAccountResolver()
+ .searchImpl(input, searchers, user, visibilitySupplier, activityPredicate);
}
- private static AccountResolver newAccountResolver() {
+ private AccountResolver newAccountResolver() {
return new AccountResolver(null, null, null, null, null, null, null, null, "Anonymous Name");
}
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index cbe64a5ccc..cd6a6b40df 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -8,11 +8,9 @@ junit_tests(
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/testing:gerrit-test-util",
- "//javatests/com/google/gerrit/util/http/testutil",
"//lib:junit",
"//lib/truth",
"//lib/truth:truth-java8-extension",
- "@servlet-api//jar",
],
)
diff --git a/javatests/com/google/gerrit/server/cache/mem/BUILD b/javatests/com/google/gerrit/server/cache/mem/BUILD
index 5ae4b73da3..baa6ff8ca0 100644
--- a/javatests/com/google/gerrit/server/cache/mem/BUILD
+++ b/javatests/com/google/gerrit/server/cache/mem/BUILD
@@ -4,6 +4,8 @@ junit_tests(
name = "tests",
srcs = glob(["*Test.java"]),
deps = [
+ "//java/com/google/gerrit/common:annotations",
+ "//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/metrics",
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/server/cache/mem",
diff --git a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
index 0771afa2fb..e7dbbfecd3 100644
--- a/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/mem/DefaultMemoryCacheFactoryTest.java
@@ -22,10 +22,14 @@ import com.google.common.cache.LoadingCache;
import com.google.common.cache.RemovalNotification;
import com.google.common.cache.Weigher;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.metrics.DisabledMetricMaker;
import com.google.gerrit.server.cache.CacheDef;
import com.google.gerrit.server.cache.ForwardingRemovalListener;
import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.plugincontext.PluginContext;
+import com.google.gerrit.server.plugincontext.PluginMapContext;
import com.google.gerrit.server.util.IdGenerator;
import com.google.inject.Guice;
import com.google.inject.TypeLiteral;
@@ -74,7 +78,13 @@ public class DefaultMemoryCacheFactoryTest {
@Before
public void setUp() {
IdGenerator idGenerator = Guice.createInjector().getInstance(IdGenerator.class);
- workQueue = new WorkQueue(idGenerator, 10, new DisabledMetricMaker());
+ workQueue =
+ new WorkQueue(
+ idGenerator,
+ 10,
+ new DisabledMetricMaker(),
+ new PluginMapContext<>(
+ DynamicMap.emptyMap(), PluginContext.PluginMetrics.DISABLED_INSTANCE));
memoryCacheConfig = new Config();
memoryCacheConfigDirectExecutor = new Config();
memoryCacheConfigDirectExecutor.setInt("cache", null, "threads", 0);
@@ -273,11 +283,13 @@ public class DefaultMemoryCacheFactoryTest {
}
@Override
+ @Nullable
public TypeLiteral<Integer> keyType() {
return null;
}
@Override
+ @Nullable
public TypeLiteral<Integer> valueType() {
return null;
}
@@ -288,26 +300,31 @@ public class DefaultMemoryCacheFactoryTest {
}
@Override
+ @Nullable
public Duration expireAfterWrite() {
return null;
}
@Override
+ @Nullable
public Duration expireFromMemoryAfterAccess() {
return null;
}
@Override
+ @Nullable
public Duration refreshAfterWrite() {
return null;
}
@Override
+ @Nullable
public Weigher<Integer, Integer> weigher() {
return null;
}
@Override
+ @Nullable
public CacheLoader<Integer, Integer> loader() {
return null;
}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
index c7e09dc345..bf8a071c93 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
@@ -52,7 +52,7 @@ public class CachedProjectConfigSerializerTest {
.addNotifySection(NotifyConfigSerializerTest.ALL_VALUES_SET)
.addLabelSection(LabelTypeSerializerTest.ALL_VALUES_SET)
.addSubscribeSection(SubscribeSectionSerializerTest.ALL_VALUES_SET)
- .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.HTML_ONLY)
+ .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.LINK_ONLY)
.setRevision(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
.setRulesId(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
.setExtensionPanelSections(ImmutableMap.of("key1", ImmutableList.of("val1", "val2")))
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
index c5e8574589..0027211208 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.Patch.FileMode;
import com.google.gerrit.entities.Patch.PatchType;
import com.google.gerrit.server.patch.ComparisonType;
import com.google.gerrit.server.patch.filediff.Edit;
@@ -42,6 +43,8 @@ public class FileDiffOutputSerializerTest {
.comparisonType(ComparisonType.againstOtherPatchSet())
.oldPath(Optional.of("old_file_path.txt"))
.newPath(Optional.empty())
+ .oldMode(Optional.of(FileMode.REGULAR_FILE))
+ .newMode(Optional.of(FileMode.SYMLINK))
.changeType(ChangeType.DELETED)
.patchType(Optional.of(PatchType.UNIFIED))
.size(23)
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
index 29fd5edc9e..1f725f8051 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -40,6 +40,9 @@ public class ProjectSerializerTest {
.setBooleanConfig(
BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
InheritableBoolean.INHERIT)
+ .setBooleanConfig(
+ BooleanProjectConfig.SKIP_ADDING_AUTHOR_AND_COMMITTER_AS_REVIEWERS,
+ InheritableBoolean.TRUE)
.build();
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
index e293493f8a..aa6cfefcab 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
@@ -22,27 +22,16 @@ import com.google.gerrit.entities.StoredCommentLinkInfo;
import org.junit.Test;
public class StoredCommentLinkInfoSerializerTest {
- static final StoredCommentLinkInfo HTML_ONLY =
+ static final StoredCommentLinkInfo LINK_ONLY =
StoredCommentLinkInfo.builder("name")
.setEnabled(true)
- .setHtml("<p>html")
+ .setLink("a.com/b.html")
.setMatch("*")
.build();
@Test
- public void htmlOnly_roundTrip() {
- assertThat(deserialize(serialize(HTML_ONLY))).isEqualTo(HTML_ONLY);
- }
-
- @Test
public void linkOnly_roundTrip() {
- StoredCommentLinkInfo autoValue =
- StoredCommentLinkInfo.builder("name")
- .setEnabled(true)
- .setLink("<p>html")
- .setMatch("*")
- .build();
- assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+ assertThat(deserialize(serialize(LINK_ONLY))).isEqualTo(LINK_ONLY);
}
@Test
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
index 00b92b4959..390aa844c8 100644
--- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -69,22 +69,6 @@ public class EventDeserializerTest {
}
@Test
- public void assigneeChangedEvent() {
- Change change = newChange();
- AssigneeChangedEvent orig = new AssigneeChangedEvent(change);
- orig.change = asChangeAttribute(change);
- orig.changer = newAccount("changer");
- orig.oldAssignee = newAccount("oldAssignee");
-
- AssigneeChangedEvent e = roundTrip(orig);
-
- assertThat(e).isNotNull();
- assertSameChangeEvent(e, orig);
- assertSameAccount(e.changer, orig.changer);
- assertSameAccount(e.oldAssignee, orig.oldAssignee);
- }
-
- @Test
public void changeDeletedEvent() {
Change change = newChange();
ChangeDeletedEvent orig = new ChangeDeletedEvent(change);
diff --git a/javatests/com/google/gerrit/server/events/EventJsonTest.java b/javatests/com/google/gerrit/server/events/EventJsonTest.java
index c2b67c3d42..3f8519e6fe 100644
--- a/javatests/com/google/gerrit/server/events/EventJsonTest.java
+++ b/javatests/com/google/gerrit/server/events/EventJsonTest.java
@@ -153,51 +153,6 @@ public class EventJsonTest {
}
@Test
- public void assigneeChangedEvent() {
- Change change = newChange();
- AssigneeChangedEvent event = new AssigneeChangedEvent(change);
- event.change = asChangeAttribute(change);
- event.changer = newAccount("changer");
- event.oldAssignee = newAccount("oldAssignee");
-
- assertThatJsonMap(event)
- .isEqualTo(
- ImmutableMap.builder()
- .put(
- "changer",
- ImmutableMap.builder()
- .put("name", event.changer.get().name)
- .put("email", event.changer.get().email)
- .put("username", event.changer.get().username)
- .build())
- .put(
- "oldAssignee",
- ImmutableMap.builder()
- .put("name", event.oldAssignee.get().name)
- .put("email", event.oldAssignee.get().email)
- .put("username", event.oldAssignee.get().username)
- .build())
- .put(
- "change",
- ImmutableMap.builder()
- .put("project", PROJECT)
- .put("branch", BRANCH)
- .put("id", CHANGE_ID)
- .put("number", CHANGE_NUM_DOUBLE)
- .put("url", URL)
- .put("commitMessage", COMMIT_MESSAGE)
- .put("createdOn", TS1)
- .put("status", NEW.name())
- .build())
- .put("project", PROJECT)
- .put("refName", REF)
- .put("changeKey", map("id", CHANGE_ID))
- .put("type", "assignee-changed")
- .put("eventCreatedOn", TS2)
- .build());
- }
-
- @Test
public void changeDeletedEvent() {
Change change = newChange();
ChangeDeletedEvent event = new ChangeDeletedEvent(change);
diff --git a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
index 9143dd5f67..8ff682586a 100644
--- a/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
+++ b/javatests/com/google/gerrit/server/extensions/events/GitReferenceUpdatedTest.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.extensions.events;
import static com.google.common.truth.Truth.assertThat;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.events.GitBatchRefUpdateListener;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
@@ -25,6 +26,7 @@ import com.google.gerrit.server.extensions.events.GitReferenceUpdated.GitBatchRe
import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import java.io.IOException;
+import java.time.Instant;
import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -46,10 +48,12 @@ public class GitReferenceUpdatedTest {
private DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
private DynamicSet<GitBatchRefUpdateListener> batchRefUpdateListeners;
+ private final AccountState updater =
+ AccountState.forAccount(Account.builder(Account.id(1), Instant.now()).build());
+
@Mock GitReferenceUpdatedListener refUpdatedListener;
@Mock GitBatchRefUpdateListener batchRefUpdateListener;
@Mock EventUtil util;
- @Mock AccountState updater;
@Captor ArgumentCaptor<GitBatchRefUpdateEvent> eventCaptor;
@Before
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index 42a80c3c27..782c7d73df 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -41,7 +41,7 @@ public class FixCalculatorVariousTest {
new FixReplacement(
"AnyPath", new Range(startLine, startChar, endLine, endChar), replacement);
return FixCalculator.calculateFix(
- new Text(content.getBytes(UTF_8)), ImmutableList.of(fixReplacement));
+ new Text(content.getBytes(UTF_8)), ImmutableList.of(fixReplacement), false);
}
@Test
@@ -117,7 +117,8 @@ public class FixCalculatorVariousTest {
FixReplacement insert = new FixReplacement("path", new Range(2, 5, 2, 5), "DEFG");
FixReplacement delete = new FixReplacement("path", new Range(2, 7, 2, 9), "");
FixResult result =
- FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, delete, insert));
+ FixCalculator.calculateFix(
+ multilineContent, ImmutableList.of(replace, delete, insert), false);
assertThat(result)
.text()
.isEqualTo("First line\nSABConDEFGd ne\nThird line\nFourth line\nFifth line\n");
@@ -136,7 +137,8 @@ public class FixCalculatorVariousTest {
FixReplacement insert = new FixReplacement("path", new Range(3, 5, 3, 5), "DEFG");
FixReplacement delete = new FixReplacement("path", new Range(4, 7, 4, 9), "");
FixResult result =
- FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, insert, delete));
+ FixCalculator.calculateFix(
+ multilineContent, ImmutableList.of(replace, insert, delete), false);
assertThat(result)
.text()
.isEqualTo("First line\nSABCond line\nThirdDEFG line\nFourth ne\nFifth line\n");
@@ -150,12 +152,28 @@ public class FixCalculatorVariousTest {
}
@Test
+ public void intraline() throws Exception {
+ FixReplacement replace = new FixReplacement("path", new Range(2, 0, 2, 11), "Second ABC line");
+ FixResult result =
+ FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace), true);
+ assertThat(result)
+ .text()
+ .isEqualTo("First line\nSecond ABC line\nThird line\nFourth line\nFifth line\n");
+ assertThat(result).edits().hasSize(1);
+ Edit edit = result.edits.get(0);
+ assertThat(edit).isReplace(1, 1, 1, 1);
+ assertThat(edit).internalEdits().hasSize(1);
+ assertThat(edit).internalEdits().element(0).isInsert(7, 7, 4);
+ }
+
+ @Test
public void severalChangesInNonConsecutiveLines() throws Exception {
FixReplacement replace = new FixReplacement("path", new Range(1, 1, 1, 3), "ABC");
FixReplacement insert = new FixReplacement("path", new Range(3, 5, 3, 5), "DEFG");
FixReplacement delete = new FixReplacement("path", new Range(5, 9, 6, 0), "");
FixResult result =
- FixCalculator.calculateFix(multilineContent, ImmutableList.of(replace, insert, delete));
+ FixCalculator.calculateFix(
+ multilineContent, ImmutableList.of(replace, insert, delete), false);
assertThat(result)
.text()
.isEqualTo("FABCst line\nSecond line\nThirdDEFG line\nFourth line\nFifth lin");
@@ -195,7 +213,8 @@ public class FixCalculatorVariousTest {
singleLineInsert,
singleLineReplace,
multiLineInsert,
- singleLineDelete));
+ singleLineDelete),
+ false);
assertThat(result)
.text()
.isEqualTo(
@@ -237,7 +256,7 @@ public class FixCalculatorVariousTest {
new FixReplacement("path", new Range(2, 7, 3, 5), "content");
FixResult result =
FixCalculator.calculateFix(
- multilineContent, ImmutableList.of(firstReplace, consecutiveReplace));
+ multilineContent, ImmutableList.of(firstReplace, consecutiveReplace), false);
assertThat(result).text().isEqualTo("First modified content line\nFourth line\nFifth line\n");
assertThat(result).edits().hasSize(1);
Edit edit = result.edits.get(0);
@@ -259,7 +278,7 @@ public class FixCalculatorVariousTest {
ResourceConflictException.class,
() ->
FixCalculator.calculateFix(
- multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+ multilineContent, ImmutableList.of(firstReplace, secondReplace), false));
}
@Test
@@ -272,6 +291,6 @@ public class FixCalculatorVariousTest {
ResourceConflictException.class,
() ->
FixCalculator.calculateFix(
- multilineContent, ImmutableList.of(firstReplace, secondReplace)));
+ multilineContent, ImmutableList.of(firstReplace, secondReplace), false));
}
}
diff --git a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
index 6bdf80f704..0112f8883e 100644
--- a/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
+++ b/javatests/com/google/gerrit/server/git/DeleteZombieCommentsRefsTest.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.git;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.gerrit.entities.Account;
@@ -166,7 +167,7 @@ public class DeleteZombieCommentsRefsTest {
new ReceiveCommand(ObjectId.zeroId(), commitId, refName, ReceiveCommand.Type.CREATE));
refNames.add(refName);
}
- RefUpdateUtil.executeChecked(bru, usersRepo);
+ testRefAction(() -> RefUpdateUtil.executeChecked(bru, usersRepo));
return refNames;
}
@@ -201,7 +202,7 @@ public class DeleteZombieCommentsRefsTest {
RefUpdate update = repo.updateRef(refName);
update.setNewObjectId(commitId);
update.setForceUpdate(true);
- update.update();
+ testRefAction(() -> update.update());
return repo.exactRef(refName);
}
diff --git a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
index bea5eaad50..65eb5b0afb 100644
--- a/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AbstractGroupTest.java
@@ -74,6 +74,7 @@ public class AbstractGroupTest {
allUsersRepo.close();
}
+ @Nullable
protected Instant getTipTimestamp(AccountGroup.UUID uuid) throws Exception {
try (RevWalk rw = new RevWalk(allUsersRepo)) {
Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(uuid));
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index c06c6230fb..6d90309e77 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.group.db;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
@@ -239,34 +240,40 @@ public final class AuditLogReaderTest extends AbstractGroupTest {
private InternalGroup createGroup(
int next, String groupName, PersonIdent authorIdent, Account.Id authorId) throws Exception {
- InternalGroupCreation groupCreation =
- InternalGroupCreation.builder()
- .setGroupUUID(GroupUuid.make(groupName, serverIdent))
- .setNameKey(AccountGroup.nameKey(groupName))
- .setId(AccountGroup.id(next))
- .build();
- GroupDelta groupDelta =
- authorIdent.equals(serverIdent)
- ? GroupDelta.builder().setDescription("Groups").build()
- : GroupDelta.builder()
- .setDescription("Groups")
- .setMemberModification(members -> ImmutableSet.of(authorId))
- .build();
-
- GroupConfig groupConfig =
- GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
- groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
-
- groupConfig.commit(createMetaDataUpdate(authorIdent));
- return groupConfig
- .getLoadedGroup()
- .orElseThrow(() -> new IllegalStateException("create group failed"));
+ return testRefAction(
+ () -> {
+ InternalGroupCreation groupCreation =
+ InternalGroupCreation.builder()
+ .setGroupUUID(GroupUuid.make(groupName, serverIdent))
+ .setNameKey(AccountGroup.nameKey(groupName))
+ .setId(AccountGroup.id(next))
+ .build();
+ GroupDelta groupDelta =
+ authorIdent.equals(serverIdent)
+ ? GroupDelta.builder().setDescription("Groups").build()
+ : GroupDelta.builder()
+ .setDescription("Groups")
+ .setMemberModification(members -> ImmutableSet.of(authorId))
+ .build();
+
+ GroupConfig groupConfig =
+ GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
+ groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
+
+ groupConfig.commit(createMetaDataUpdate(authorIdent));
+ return groupConfig
+ .getLoadedGroup()
+ .orElseThrow(() -> new IllegalStateException("create group failed"));
+ });
}
private void updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta) throws Exception {
- GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
- groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
- groupConfig.commit(createMetaDataUpdate(userIdent));
+ testRefAction(
+ () -> {
+ GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
+ groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
+ groupConfig.commit(createMetaDataUpdate(userIdent));
+ });
}
private void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
diff --git a/javatests/com/google/gerrit/server/group/db/BUILD b/javatests/com/google/gerrit/server/group/db/BUILD
index 9f9f459e9f..47550bb83a 100644
--- a/javatests/com/google/gerrit/server/group/db/BUILD
+++ b/javatests/com/google/gerrit/server/group/db/BUILD
@@ -17,6 +17,7 @@ junit_tests(
"//java/com/google/gerrit/server/group/testing",
"//java/com/google/gerrit/server/util/time",
"//java/com/google/gerrit/testing:gerrit-test-util",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//java/com/google/gerrit/truth",
"//lib:guava",
"//lib:jgit",
diff --git a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
index 6a62ed14f8..5029334c7c 100644
--- a/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/IndexedFieldTest.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.index;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.index.testing.TestIndexedFields.EXACT_STRING_FIELD_SPEC;
import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD;
import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_FIELD_SPEC;
import static com.google.gerrit.index.testing.TestIndexedFields.INTEGER_RANGE_FIELD_SPEC;
@@ -31,6 +32,7 @@ import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_
import static com.google.gerrit.index.testing.TestIndexedFields.ITERABLE_STRING_FIELD_SPEC;
import static com.google.gerrit.index.testing.TestIndexedFields.LONG_FIELD_SPEC;
import static com.google.gerrit.index.testing.TestIndexedFields.LONG_RANGE_FIELD_SPEC;
+import static com.google.gerrit.index.testing.TestIndexedFields.PREFIX_STRING_FIELD_SPEC;
import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_FIELD;
import static com.google.gerrit.index.testing.TestIndexedFields.STORED_BYTE_SPEC;
import static com.google.gerrit.index.testing.TestIndexedFields.STORED_PROTO_FIELD;
@@ -59,7 +61,6 @@ import org.junit.experimental.theories.Theory;
import org.junit.runner.RunWith;
/** Tests for {@link com.google.gerrit.index.IndexedField} */
-@SuppressWarnings("serial")
@RunWith(Theories.class)
public class IndexedFieldTest {
@@ -78,6 +79,8 @@ public class IndexedFieldTest {
.put(ITERABLE_LONG_RANGE_FIELD_SPEC, ImmutableList.of(123456L, 654321L))
.put(TIMESTAMP_FIELD_SPEC, new Timestamp(1234567L))
.put(STRING_FIELD_SPEC, "123456")
+ .put(PREFIX_STRING_FIELD_SPEC, "123456")
+ .put(EXACT_STRING_FIELD_SPEC, "123456")
.put(ITERABLE_STRING_FIELD_SPEC, ImmutableList.of("123456"))
.put(
ITERABLE_STORED_BYTE_SPEC,
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index 65eb3b813b..a40afe8cc6 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -40,8 +40,7 @@ public class AccountFieldTest {
String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
account.setMetaId(metaId);
Iterable<byte[]> refStates =
- (Iterable<byte[]>)
- AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
+ AccountField.REF_STATE_SPEC.get(AccountState.forAccount(account.build()));
List<String> values = toStrings(refStates);
String expectedValue =
allUsersName.get() + ":" + RefNames.refsUsers(account.id()) + ":" + metaId;
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index e35941c2e2..59b354c0b0 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -159,7 +159,7 @@ public class ChangeFieldTest {
Project.NameKey project = Project.nameKey("project");
ChangeData cd =
ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
- assertThat(ChangeField.ADDED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+ assertThat(ChangeField.ADDED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
}
@Test
@@ -167,7 +167,8 @@ public class ChangeFieldTest {
Project.NameKey project = Project.nameKey("project");
ChangeData cd =
ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
- assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+ assertThat(ChangeField.DELETED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null)))
+ .isTrue();
}
@Test
diff --git a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
index f70c97a175..6e3514e8e5 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -16,7 +16,9 @@ package com.google.gerrit.server.index.change;
import static com.google.gerrit.index.SchemaUtil.schema;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.QueryOptions;
import com.google.gerrit.index.Schema;
import com.google.gerrit.index.query.FieldBundle;
@@ -29,10 +31,19 @@ import org.junit.Ignore;
@Ignore
public class FakeChangeIndex implements ChangeIndex {
- static final Schema<ChangeData> V1 = schema(1, ChangeField.STATUS);
+ static final Schema<ChangeData> V1 =
+ schema(
+ 1,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(ChangeField.STATUS_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(ChangeField.STATUS_SPEC));
static final Schema<ChangeData> V2 =
- schema(2, ChangeField.STATUS, ChangeField.PATH, ChangeField.UPDATED);
+ schema(
+ 2,
+ ImmutableList.<IndexedField<ChangeData, ?>>of(
+ ChangeField.PATH_FIELD, ChangeField.STATUS_FIELD, ChangeField.UPDATED_FIELD),
+ ImmutableList.<IndexedField<ChangeData, ?>.SearchSpec>of(
+ ChangeField.PATH_SPEC, ChangeField.STATUS_SPEC, ChangeField.UPDATED_SPEC));
private static class Source implements ChangeDataSource {
private final Predicate<ChangeData> p;
@@ -84,6 +95,11 @@ public class FakeChangeIndex implements ChangeIndex {
}
@Override
+ public void deleteByValue(ChangeData value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
public void delete(Change.Id id) {
throw new UnsupportedOperationException();
}
diff --git a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java b/javatests/com/google/gerrit/server/mail/send/BranchEmailUtilsTest.java
index b87c4a1356..3c60b79f71 100644
--- a/javatests/com/google/gerrit/server/mail/send/NotificationEmailTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/BranchEmailUtilsTest.java
@@ -15,12 +15,12 @@
package com.google.gerrit.server.mail.send;
import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.mail.send.NotificationEmail.getInstanceAndProjectName;
-import static com.google.gerrit.server.mail.send.NotificationEmail.getShortProjectName;
+import static com.google.gerrit.server.mail.send.BranchEmailUtils.getInstanceAndProjectName;
+import static com.google.gerrit.server.mail.send.BranchEmailUtils.getShortProjectName;
import org.junit.Test;
-public class NotificationEmailTest {
+public class BranchEmailUtilsTest {
@Test
public void instanceAndProjectName() throws Exception {
assertThat(getInstanceAndProjectName("test", "/my/api")).isEqualTo("test/api");
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
index fbeabe1017..7f893f1559 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceLoaderTest.java
@@ -20,7 +20,6 @@ import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.plugincontext.PluginContext.PluginMetrics;
import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.template.soy.shared.SoyAstCache;
import java.nio.file.Paths;
import org.junit.Before;
import org.junit.Test;
@@ -40,9 +39,7 @@ public class MailSoySauceLoaderTest {
public void soyCompilation() {
MailSoySauceLoader loader =
new MailSoySauceLoader(
- sitePaths,
- new SoyAstCache(),
- new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
+ sitePaths, new PluginSetContext<>(set, PluginMetrics.DISABLED_INSTANCE));
assertThat(loader.load()).isNotNull(); // should not throw
}
}
diff --git a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
index 3ce60b8dd3..5a6db42d76 100644
--- a/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/MailSoySauceModuleTest.java
@@ -25,6 +25,7 @@ import com.google.gerrit.server.CacheRefreshExecutor;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.WorkQueue;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
import com.google.inject.Injector;
@@ -54,6 +55,7 @@ public class MailSoySauceModuleTest {
bind(SitePaths.class).toInstance(sitePaths);
bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(new Config());
bind(MetricMaker.class).to(DisabledMetricMaker.class);
+ install(new WorkQueue.WorkQueueModule());
install(new DefaultMemoryCacheModule());
}
});
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index 069a1de8f7..20e441b773 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.notedb;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static com.google.inject.Scopes.SINGLETON;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -30,6 +31,7 @@ import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.metrics.DisabledMetricMaker;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DefaultRefLogIdentityProvider;
import com.google.gerrit.server.FanOutExecutor;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
@@ -75,6 +77,7 @@ import com.google.inject.Module;
import com.google.inject.TypeLiteral;
import java.time.Instant;
import java.time.ZoneId;
+import java.util.Optional;
import java.util.concurrent.ExecutorService;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -180,6 +183,7 @@ public abstract class AbstractChangeNotesTest {
install(new DefaultUrlFormatterModule());
install(NoteDbModule.forTest());
+ install(new DefaultRefLogIdentityProvider.Module());
bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
bind(String.class).annotatedWith(GerritServerId.class).toInstance(serverId);
bind(new TypeLiteral<ImmutableList<String>>() {})
@@ -245,13 +249,16 @@ public abstract class AbstractChangeNotesTest {
}
protected Change newChange(Injector injector, boolean workInProgress) throws Exception {
- Change c = TestChanges.newChange(project, changeOwner.getAccountId());
- ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
- u.setChangeId(c.getKey().get());
- u.setBranch(c.getDest().branch());
- u.setWorkInProgress(workInProgress);
- u.commit();
- return c;
+ return testRefAction(
+ () -> {
+ Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+ ChangeUpdate u = newUpdate(injector, c, changeOwner, false);
+ u.setChangeId(c.getKey().get());
+ u.setBranch(c.getDest().branch());
+ u.setWorkInProgress(workInProgress);
+ u.commit();
+ return c;
+ });
}
protected Change newWorkInProgressChange() throws Exception {
@@ -277,7 +284,7 @@ public abstract class AbstractChangeNotesTest {
protected ChangeUpdate newUpdate(
Injector injector, Change c, CurrentUser user, boolean shouldExist) throws Exception {
- ChangeUpdate update = TestChanges.newUpdate(injector, c, user, shouldExist);
+ ChangeUpdate update = TestChanges.newUpdate(injector, c, Optional.of(user), shouldExist);
update.setPatchSetId(c.currentPatchSetId());
update.setAllowWriteToNewRef(true);
return update;
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 546bb18263..23c57048bf 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -167,9 +167,11 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
+ "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ "Patch-set: 1\n"
+ "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7\n"
- + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
+ + "Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2"
+ + " <2@gerrit>\n"
+ "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n"
- + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 3 (name,with, comma) <3@gerrit>\n"
+ + "Label: Label1=0, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 3 (name,with,"
+ + " comma) <3@gerrit>\n"
+ "Subject: This is a test change\n");
assertParseSucceeds(
@@ -185,14 +187,24 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1, \n");
assertParseFails("Update change\n\nPatch-set: 1\nLabel: Label1=+1,\n");
assertParseFails(
- "Update change\n\nPatch-set: 1\nLabel: Label1=-1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2 <2@gerrit>\n");
+ "Update change\n\n"
+ + "Patch-set: 1\n"
+ + "Label: Label1=-1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 2"
+ + " <2@gerrit>\n");
assertParseFails(
- "Update change\n\nPatch-set: 1\nLabel: Label1=-1, 577fb248e474018276351785930358ec0450e9f7\n");
+ "Update change\n\n"
+ + "Patch-set: 1\n"
+ + "Label: Label1=-1, 577fb248e474018276351785930358ec0450e9f7\n");
// UUID for removals is not supported.
assertParseFails(
- "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
+ "Update change\n\n"
+ + "Patch-set: 1\n"
+ + "Label: -Label1, 577fb248e474018276351785930358ec0450e9f7\n");
assertParseFails(
- "Update change\n\nPatch-set: 1\nLabel: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account <2@gerrit>\n");
+ "Update change\n\n"
+ + "Patch-set: 1\n"
+ + "Label: -Label1, 577fb248e474018276351785930358ec0450e9f7 Other Account"
+ + " <2@gerrit>\n");
}
@Test
@@ -210,7 +222,8 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
+ "Copied-Label: -Label1 Account <1@gerrit>,Other Account <2@gerrit>\\n"
+ "Copied-Label: -Label1 Account <1@gerrit>\n"
+ "Copied-Label: Label1=+1 Gerrit User 1 (name,with, comma) <1@gerrit>\n"
- + "Copied-Label: Label2=+1 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2 (name,with, comma) <2@gerrit>\n"
+ + "Copied-Label: Label2=+1 Gerrit User 1 (name,with, comma) <1@gerrit>,Gerrit User 2"
+ + " (name,with, comma) <2@gerrit>\n"
+ "Subject: This is a test change\n");
assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
@@ -238,14 +251,22 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
+ "Branch: refs/heads/master\n"
+ "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ "Patch-set: 1\n"
- + "Copied-Label: Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>\n"
- + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
- + "Copied-Label: Label3=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
- + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
- + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
- + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
- + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
- + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 (name,with, comma) <2@gerrit>,Gerrit User 3 (name,with, comma) <3@gerrit>\n"
+ + "Copied-Label: Label2=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit>\n"
+ + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
+ + "Copied-Label: Label3=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
+ + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+ + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit> :\"tag with uuid delimiter , \"\n"
+ + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+ + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+ + "Copied-Label: Label4=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " (name,with, comma) <2@gerrit>,Gerrit User 3 (name,with, comma) <3@gerrit>\n"
+ "Subject: This is a test change\n");
assertParseSucceeds(
@@ -255,23 +276,34 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
+ "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+ "Patch-set: 1\n"
+ "Copied-Label: Label2=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>\n"
- + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n"
- + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag\"\n"
- + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with characters %^#@^( *::!\"\n"
- + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid delimiter , \"\n"
- + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
- + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit> :\"tag with uuid delimiter , \"\n"
+ + "Copied-Label: Label1=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+ + " <2@gerrit>\n"
+ + "Copied-Label: Label3=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+ + " <2@gerrit> :\"tag\"\n"
+ + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with"
+ + " characters %^#@^( *::!\"\n"
+ + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit> :\"tag with uuid"
+ + " delimiter , \"\n"
+ + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+ + " <2@gerrit> :\"tag with characters %^#@^( *::!\"\n"
+ + "Copied-Label: Label4=+1, non-SHA1_UUID Gerrit User 1 <1@gerrit>,Gerrit User 2"
+ + " <2@gerrit> :\"tag with uuid delimiter , \"\n"
+ "Subject: This is a test change\n");
assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1,\n");
assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1 ,\n");
assertParseFails(
- "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1 <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
+ "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 Gerrit User 1"
+ + " <1@gerrit>,Gerrit User 2 <2@gerrit>\n\n");
assertParseFails(
- "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
+ "Update change\n\n"
+ + "Patch-set: 1\n"
+ + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7");
assertParseFails(
- "Update change\n\nPatch-set: 1\nCopied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
+ "Update change\n\n"
+ + "Patch-set: 1\n"
+ + "Copied-Label: Label1=+1, 577fb248e474018276351785930358ec0450e9f7 :\"tag\"\n");
// UUID for removals is not supported.
assertParseFails(
@@ -355,26 +387,6 @@ public class ChangeNotesParserTest extends AbstractChangeNotesTest {
}
@Test
- public void parseAssignee() throws Exception {
- assertParseSucceeds(
- "Update change\n"
- + "\n"
- + "Branch: refs/heads/master\n"
- + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
- + "Patch-set: 1\n"
- + "Assignee: Change Owner <1@gerrit>\n"
- + "Subject: This is a test change\n");
- assertParseSucceeds(
- "Update change\n"
- + "\n"
- + "Branch: refs/heads/master\n"
- + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
- + "Patch-set: 2\n"
- + "Assignee:\n"
- + "Subject: This is a test change\n");
- }
-
- @Test
public void parseTopic() throws Exception {
assertParseSucceeds(
"Update change\n"
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 976ffc846b..9a29230406 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -45,12 +45,10 @@ import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
import com.google.gerrit.proto.Entities;
import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.ReviewerStatusUpdate;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto;
-import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AssigneeStatusUpdateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.AttentionSetUpdateProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ChangeColumnsProto;
import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
@@ -342,22 +340,24 @@ public class ChangeNotesStateTest {
.id(PatchSet.id(ID, 1))
.commitId(ObjectId.fromString("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"))
.uploader(Account.id(2000))
+ .realUploader(Account.id(2001))
.createdOn(cols.createdOn())
.build();
Entities.PatchSet ps1Proto = PatchSetProtoConverter.INSTANCE.toProto(ps1);
ByteString ps1Bytes = Protos.toByteString(ps1Proto);
- assertThat(ps1Bytes.size()).isEqualTo(66);
+ assertThat(ps1Bytes.size()).isEqualTo(71);
PatchSet ps2 =
PatchSet.builder()
.id(PatchSet.id(ID, 2))
.commitId(ObjectId.fromString("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
.uploader(Account.id(3000))
+ .realUploader(Account.id(3001))
.createdOn(cols.lastUpdatedOn())
.build();
Entities.PatchSet ps2Proto = PatchSetProtoConverter.INSTANCE.toProto(ps2);
ByteString ps2Bytes = Protos.toByteString(ps2Proto);
- assertThat(ps2Bytes.size()).isEqualTo(66);
+ assertThat(ps2Bytes.size()).isEqualTo(71);
assertThat(ps2Bytes).isNotEqualTo(ps1Bytes);
assertRoundTrip(
@@ -804,37 +804,6 @@ public class ChangeNotesStateTest {
}
@Test
- public void serializeAssigneeUpdates() throws Exception {
- assertRoundTrip(
- newBuilder()
- .assigneeUpdates(
- ImmutableList.of(
- AssigneeStatusUpdate.create(
- Instant.ofEpochMilli(1212L),
- Account.id(1000),
- Optional.of(Account.id(2001))),
- AssigneeStatusUpdate.create(
- Instant.ofEpochMilli(3434L), Account.id(1000), Optional.empty())))
- .build(),
- ChangeNotesStateProto.newBuilder()
- .setMetaId(SHA_BYTES)
- .setChangeId(ID.get())
- .setColumns(colsProto)
- .addAssigneeUpdate(
- AssigneeStatusUpdateProto.newBuilder()
- .setTimestampMillis(1212L)
- .setUpdatedBy(1000)
- .setCurrentAssignee(2001)
- .setHasCurrentAssignee(true))
- .addAssigneeUpdate(
- AssigneeStatusUpdateProto.newBuilder()
- .setTimestampMillis(3434L)
- .setUpdatedBy(1000)
- .setHasCurrentAssignee(false))
- .build());
- }
-
- @Test
public void serializeSubmitRecords() throws Exception {
SubmitRecord sr1 = new SubmitRecord();
sr1.status = SubmitRecord.Status.OK;
@@ -969,9 +938,6 @@ public class ChangeNotesStateTest {
.put(
"allAttentionSetUpdates",
new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
- .put(
- "assigneeUpdates",
- new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
.put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
.put("changeMessages", new TypeLiteral<ImmutableList<ChangeMessage>>() {}.getType())
.put(
@@ -1018,6 +984,7 @@ public class ChangeNotesStateTest {
.put("id", PatchSet.Id.class)
.put("commitId", ObjectId.class)
.put("uploader", Account.Id.class)
+ .put("realUploader", Account.Id.class)
.put("createdOn", Instant.class)
.put("groups", new TypeLiteral<ImmutableList<String>>() {}.getType())
.put("pushCertificate", new TypeLiteral<Optional<String>>() {}.getType())
@@ -1085,19 +1052,6 @@ public class ChangeNotesStateTest {
}
@Test
- public void assigneeStatusUpdateMethods() throws Exception {
- assertThatSerializedClass(AssigneeStatusUpdate.class)
- .hasAutoValueMethods(
- ImmutableMap.of(
- "date",
- Instant.class,
- "updatedBy",
- Account.Id.class,
- "currentAssignee",
- new TypeLiteral<Optional<Account.Id>>() {}.getType()));
- }
-
- @Test
public void submitRecordFields() throws Exception {
assertThatSerializedClass(SubmitRecord.class)
.hasFields(
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index c7237253f4..50ff860005 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -25,6 +25,7 @@ import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Comparator.comparing;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -37,6 +38,7 @@ import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
@@ -52,7 +54,6 @@ import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.SubmissionId;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.ReviewerSet;
@@ -824,7 +825,8 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
ImmutableList.of(", ", ":\"", ",", "!@#$%^\0&*):\" \n: \r\"#$@,. :");
for (String strangeTag : strangeTags) {
Change c = newChange();
- CurrentUser otherUserAsOwner = userFactory.runAs(null, changeOwner.getAccountId(), otherUser);
+ CurrentUser otherUserAsOwner =
+ userFactory.runAs(/* remotePeer= */ null, changeOwner.getAccountId(), otherUser);
ChangeUpdate update = newUpdate(c, otherUserAsOwner);
update.putApproval(LabelId.CODE_REVIEW, (short) 2);
update.setTag(strangeTag);
@@ -1112,7 +1114,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
ChangeUpdate update = newUpdate(c, changeOwner);
update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
update.putReviewer(otherUser.getAccount().id(), CC);
- update.commit();
+ testRefAction(() -> update.commit());
ChangeNotes notes = newNotes(c);
Instant ts = update.getWhen();
@@ -1472,91 +1474,6 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
}
@Test
- public void assigneeCommit() throws Exception {
- Change c = newChange();
- ChangeUpdate update = newUpdate(c, changeOwner);
- update.setAssignee(otherUserId);
- ObjectId result = update.commit();
- assertThat(result).isNotNull();
- try (RevWalk rw = new RevWalk(repo)) {
- RevCommit commit = rw.parseCommit(update.getResult());
- rw.parseBody(commit);
- String strIdent = "Gerrit User " + otherUserId + " <" + otherUserId + "@" + serverId + ">";
- assertThat(commit.getFullMessage()).contains("Assignee: " + strIdent);
- }
- }
-
- @Test
- public void assigneeChangeNotes() throws Exception {
- Change c = newChange();
- ChangeUpdate update = newUpdate(c, changeOwner);
- update.setAssignee(otherUserId);
- update.commit();
-
- ChangeNotes notes = newNotes(c);
- assertThat(notes.getChange().getAssignee()).isEqualTo(otherUserId);
-
- update = newUpdate(c, changeOwner);
- update.setAssignee(changeOwner.getAccountId());
- update.commit();
-
- notes = newNotes(c);
- assertThat(notes.getChange().getAssignee()).isEqualTo(changeOwner.getAccountId());
- }
-
- @Test
- public void pastAssigneesChangeNotes() throws Exception {
- Change c = newChange();
- ChangeUpdate update = newUpdate(c, changeOwner);
- update.setAssignee(otherUserId);
- update.commit();
-
- update = newUpdate(c, changeOwner);
- update.setAssignee(changeOwner.getAccountId());
- update.commit();
-
- update = newUpdate(c, changeOwner);
- update.setAssignee(otherUserId);
- update.commit();
-
- update = newUpdate(c, changeOwner);
- update.removeAssignee();
- update.commit();
-
- ChangeNotes notes = newNotes(c);
- assertThat(notes.getPastAssignees()).hasSize(2);
- }
-
- @Test
- public void assigneeStatusUpdateChangeNotes() throws Exception {
- Change c = newChange();
- ChangeUpdate update = newUpdate(c, otherUser);
- update.setAssignee(otherUserId);
- update.commit();
-
- update = newUpdate(c, changeOwner);
- update.removeAssignee();
- update.commit();
-
- update = newUpdate(c, changeOwner);
- update.setAssignee(changeOwner.getAccountId());
- update.commit();
-
- update = newUpdate(c, changeOwner);
- update.setAssignee(otherUserId);
- update.commit();
-
- ChangeNotes notes = newNotes(c);
- ImmutableList<AssigneeStatusUpdate> statusUpdates = notes.getAssigneeUpdates();
- assertThat(statusUpdates).hasSize(4);
- assertThat(statusUpdates.get(3).updatedBy()).isEqualTo(otherUserId);
- assertThat(statusUpdates.get(3).currentAssignee()).hasValue(otherUserId);
- assertThat(statusUpdates.get(2).currentAssignee()).isEmpty();
- assertThat(statusUpdates.get(1).currentAssignee()).hasValue(changeOwner.getAccountId());
- assertThat(statusUpdates.get(0).currentAssignee()).hasValue(otherUserId);
- }
-
- @Test
public void hashtagCommit() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
@@ -2020,7 +1937,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
updateManager.add(update1);
updateManager.add(update2);
- updateManager.execute();
+ testRefAction(() -> updateManager.execute());
}
ChangeNotes notes = newNotes(c);
@@ -2069,7 +1986,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
update2.putApproval(LabelId.CODE_REVIEW, (short) 2);
updateManager.add(update2);
- updateManager.execute();
+ testRefAction(() -> updateManager.execute());
}
ChangeNotes notes = newNotes(c);
@@ -2129,7 +2046,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
try (NoteDbUpdateManager updateManager = updateManagerFactory.create(project)) {
updateManager.add(update1);
updateManager.add(update2);
- updateManager.execute();
+ testRefAction(() -> updateManager.execute());
}
Ref ref1 = repo.exactRef(update1.getRefName());
@@ -3444,7 +3361,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
draftUpdate.putComment(comment2);
try (NoteDbUpdateManager manager = updateManagerFactory.create(c.getProject())) {
manager.add(draftUpdate);
- manager.execute();
+ testRefAction(() -> manager.execute());
}
// Looking at drafts directly shows the zombie comment.
@@ -3508,7 +3425,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
try (NoteDbUpdateManager manager = updateManagerFactory.create(project)) {
manager.add(update1);
manager.add(update2);
- manager.execute();
+ testRefAction(() -> manager.execute());
}
ChangeNotes notes = newNotes(c);
@@ -3948,6 +3865,7 @@ public class ChangeNotesTest extends AbstractChangeNotesTest {
return new String(rw.getObjectReader().open(dataId, OBJ_BLOB).getCachedBytes(), UTF_8);
}
+ @Nullable
private ObjectId exactRefAllUsers(String refName) throws Exception {
try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
Ref ref = allUsersRepo.exactRef(refName);
diff --git a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
index 2191f00c06..5a89584588 100644
--- a/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommentTimestampAdapterTest.java
@@ -123,6 +123,18 @@ public class CommentTimestampAdapterTest {
}
@Test
+ public void fixedFallbackFormatCanParseOutputOfLegacyAdapter() {
+ assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 7, 2017 2:20:30 AM"))
+ .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-07T10:20:30Z").toInstant()));
+ assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 17, 2017 10:20:30 AM"))
+ .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-17T18:20:30Z").toInstant()));
+ assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 17, 2017 02:20:30 PM"))
+ .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-17T22:20:30Z").toInstant()));
+ assertThat(CommentTimestampAdapter.parseDateTimeWithFixedFormat("Feb 07, 2017 10:20:30 PM"))
+ .isEqualTo(Timestamp.from(ZonedDateTime.parse("2017-02-08T06:20:30Z").toInstant()));
+ }
+
+ @Test
public void newAdapterDisagreesWithLegacyAdapterDuringDstTransition() {
String duringJson = legacyGson.toJson(new Timestamp(MID_DST_MS));
Timestamp duringTs = legacyGson.fromJson(duringJson, Timestamp.class);
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index b53de89d0f..25f2f9867e 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -354,7 +354,8 @@ public class CommitMessageOutputTest extends AbstractChangeNotesTest {
@Test
public void realUser() throws Exception {
Change c = newChange();
- CurrentUser ownerAsOtherUser = userFactory.runAs(null, otherUserId, changeOwner);
+ CurrentUser ownerAsOtherUser =
+ userFactory.runAs(/* remotePeer= */ null, otherUserId, changeOwner);
ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
update.setChangeMessage("Message on behalf of other user");
update.commit();
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
index 5e6803e8dd..d13ccdddfd 100644
--- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -21,6 +21,7 @@ import static com.google.gerrit.entities.LabelId.VERIFIED;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
@@ -90,7 +91,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
bru.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
}
- RefUpdateUtil.executeChecked(bru, repo);
+ testRefAction(() -> RefUpdateUtil.executeChecked(bru, repo));
}
@Test
@@ -214,37 +215,37 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
@Test
public void maxRefsToUpdate_coversAllInvalid_inMultipleBatches() throws Exception {
testMaxRefsToUpdate(
- /*numberOfInvalidChanges=*/ 11,
- /*numberOfValidChanges=*/ 9,
- /*maxRefsToUpdate=*/ 12,
- /*maxRefsInBatch=*/ 2);
+ /* numberOfInvalidChanges= */ 11,
+ /* numberOfValidChanges= */ 9,
+ /* maxRefsToUpdate= */ 12,
+ /* maxRefsInBatch= */ 2);
}
@Test
public void maxRefsToUpdate_coversAllInvalid_inSingleBatch() throws Exception {
testMaxRefsToUpdate(
- /*numberOfInvalidChanges=*/ 11,
- /*numberOfValidChanges=*/ 9,
- /*maxRefsToUpdate=*/ 12,
- /*maxRefsInBatch=*/ 12);
+ /* numberOfInvalidChanges= */ 11,
+ /* numberOfValidChanges= */ 9,
+ /* maxRefsToUpdate= */ 12,
+ /* maxRefsInBatch= */ 12);
}
@Test
public void moreInvalidRefs_thenMaxRefsToUpdate_inMultipleBatches() throws Exception {
testMaxRefsToUpdate(
- /*numberOfInvalidChanges=*/ 11,
- /*numberOfValidChanges=*/ 9,
- /*maxRefsToUpdate=*/ 10,
- /*maxRefsInBatch=*/ 2);
+ /* numberOfInvalidChanges= */ 11,
+ /* numberOfValidChanges= */ 9,
+ /* maxRefsToUpdate= */ 10,
+ /* maxRefsInBatch= */ 2);
}
@Test
public void moreInvalidRefs_thenMaxRefsToUpdate_inSingleBatch() throws Exception {
testMaxRefsToUpdate(
- /*numberOfInvalidChanges=*/ 11,
- /*numberOfValidChanges=*/ 9,
- /*maxRefsToUpdate=*/ 10,
- /*maxRefsInBatch=*/ 10);
+ /* numberOfInvalidChanges= */ 11,
+ /* numberOfValidChanges= */ 9,
+ /* maxRefsToUpdate= */ 10,
+ /* maxRefsInBatch= */ 10);
}
private void testMaxRefsToUpdate(
@@ -333,7 +334,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RevCommit invalidUpdateCommit =
writeUpdate(
RefNames.changeMetaRef(c.getId()),
- getChangeUpdateBody(c, /*changeMessage=*/ null),
+ getChangeUpdateBody(c, /* changeMessage= */ null),
invalidAuthorIdent);
ChangeUpdate validUpdate = newUpdate(c, changeOwner);
validUpdate.setChangeMessage("verification from jenkins");
@@ -399,7 +400,9 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
IdentifiedUser impersonatedChangeOwner =
this.userFactory.runAs(
- null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+ /* remotePeer= */ null,
+ changeOwner.getAccountId(),
+ requireNonNull(otherUser).getRealUser());
ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
impersonatedChangeMessageUpdate.commit();
@@ -470,7 +473,8 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
// valid change message that should not be overwritten
getChangeUpdateBody(
c,
- "Removed cc <GERRIT_ACCOUNT_2> with the following votes:\n\n * Code-Review+2 by <GERRIT_ACCOUNT_2>",
+ "Removed cc <GERRIT_ACCOUNT_2> with the following votes:\n\n"
+ + " * Code-Review+2 by <GERRIT_ACCOUNT_2>",
"CC: " + reviewerIdentToFix),
getAuthorIdent(otherUser.getAccount())))
.add(
@@ -675,7 +679,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ null,
+ /* changeMessage= */ null,
"Label: -Verified " + approverIdentToFix,
"Label: Custom-Label-1=-1 " + approverIdentToFix,
"Label: Verified=+1",
@@ -688,7 +692,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ null,
+ /* changeMessage= */ null,
"Label: -Verified " + changeOwnerIdentToFix,
"Label: Custom-Label-1=+1"),
getAuthorIdent(otherUser.getAccount())))
@@ -810,7 +814,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed Code-Review+2 by " + otherUser.getNameEmail(),
+ /* changeMessage= */ "Removed Code-Review+2 by " + otherUser.getNameEmail(),
"Label: -Code-Review " + approverIdentToFix),
getAuthorIdent(changeOwner.getAccount())))
.add(
@@ -818,7 +822,8 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed Custom-Label-1 by " + otherUser.getNameEmail(),
+ /* changeMessage= */ "Removed Custom-Label-1 by "
+ + otherUser.getNameEmail(),
"Label: -Custom-Label " + getValidIdentAsString(otherUser.getAccount())),
getAuthorIdent(changeOwner.getAccount())))
.add(
@@ -826,7 +831,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail(),
+ /* changeMessage= */ "Removed Verified+2 by " + changeOwner.getNameEmail(),
"Label: -Verified"),
getAuthorIdent(changeOwner.getAccount())))
.build();
@@ -926,7 +931,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed Verified+2 by " + otherUser.getNameEmail(),
+ /* changeMessage= */ "Removed Verified+2 by " + otherUser.getNameEmail(),
"Label: -Verified"),
invalidAuthorIdent);
@@ -959,19 +964,19 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
// Even though footer is missing, accounts are matched among the account in change updates.
- getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified-1 by Other Account (0002)"),
+ getChangeUpdateBody(c, /* changeMessage= */ "Removed Verified-1 by Other Account (0002)"),
getAuthorIdent(changeOwner.getAccount()));
writeUpdate(
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
- c, /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail()),
+ c, /* changeMessage= */ "Removed Verified+2 by " + changeOwner.getNameEmail()),
getAuthorIdent(changeOwner.getAccount()));
// No rewrite for default
writeUpdate(
RefNames.changeMetaRef(c.getId()),
- getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified+2 by Gerrit Account"),
+ getChangeUpdateBody(c, /* changeMessage= */ "Removed Verified+2 by Gerrit Account"),
getAuthorIdent(changeOwner.getAccount()));
RunOptions options = new RunOptions();
@@ -1004,7 +1009,8 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
writeUpdate(
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
- c, /*changeMessage=*/ "Removed Verified+2 by Renamed Change Owner <change@owner.com>"),
+ c,
+ /* changeMessage= */ "Removed Verified+2 by Renamed Change Owner <change@owner.com>"),
getAuthorIdent(changeOwner.getAccount()));
RunOptions options = new RunOptions();
@@ -1033,7 +1039,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
approvalUpdate.commit();
writeUpdate(
RefNames.changeMetaRef(c.getId()),
- getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified+2 by Change Owner"),
+ getChangeUpdateBody(c, /* changeMessage= */ "Removed Verified+2 by Change Owner"),
getAuthorIdent(changeOwner.getAccount()));
RunOptions options = new RunOptions();
@@ -1070,17 +1076,17 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
writeUpdate(
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
- c, /*changeMessage=*/ "Removed Verified+2 by Change Owner <other@test.com>"),
+ c, /* changeMessage= */ "Removed Verified+2 by Change Owner <other@test.com>"),
getAuthorIdent(changeOwner.getAccount()));
writeUpdate(
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
- c, /*changeMessage=*/ "Removed Verified+2 by Change Owner <change@owner.com>"),
+ c, /* changeMessage= */ "Removed Verified+2 by Change Owner <change@owner.com>"),
getAuthorIdent(changeOwner.getAccount()));
writeUpdate(
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
- c, /*changeMessage=*/ "Removed Verified-1 by Change Owner <other@test.com>"),
+ c, /* changeMessage= */ "Removed Verified-1 by Change Owner <other@test.com>"),
getAuthorIdent(changeOwner.getAccount()));
RunOptions options = new RunOptions();
@@ -1119,7 +1125,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
// Even though footer is missing, accounts are matched among the account in change updates.
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed the following votes:\n"
+ /* changeMessage= */ "Removed the following votes:\n"
+ String.format("* Verified-1 by %s\n", otherUser.getNameEmail())),
getAuthorIdent(changeOwner.getAccount()));
@@ -1127,7 +1133,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed the following votes:\n"
+ /* changeMessage= */ "Removed the following votes:\n"
+ String.format("* Verified+2 by %s\n", changeOwner.getNameEmail())
+ String.format("* Verified-1 by %s\n", changeOwner.getNameEmail())
+ String.format("* Code-Review by %s\n", otherUser.getNameEmail())),
@@ -1138,7 +1144,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ "Removed the following votes:\n"
+ /* changeMessage= */ "Removed the following votes:\n"
+ "* Verified+2 by Gerrit Account\n"
+ "* Verified-1 by <GERRIT_ACCOUNT_2>\n"),
getAuthorIdent(changeOwner.getAccount()));
@@ -1198,7 +1204,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ null,
+ /* changeMessage= */ null,
// Only 'person_ident' fix is required
"Attention: "
+ gson.toJson(
@@ -1352,27 +1358,51 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
assertThat(commitHistoryDiff.get(0))
.isEqualTo(
"@@ -8 +8 @@\n"
- + "-Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Other Account using the hovercard menu\"}\n"
- + "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}\n");
+ + "-Attention: {\"person_ident\":\"Gerrit User 2"
+ + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Other"
+ + " Account using the hovercard menu\"}\n"
+ + "+Attention: {\"person_ident\":\"Gerrit User 2"
+ + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone"
+ + " using the hovercard menu\"}\n");
assertThat(Arrays.asList(commitHistoryDiff.get(1).split("\n")))
.containsExactly(
"@@ -7,2 +7,2 @@",
- "-Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Other Account replied on the change\"}",
- "-Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other Account using the hovercard menu\"}",
- "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Someone replied on the change\"}",
- "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by someone using the hovercard menu\"}");
+ "-Attention: {\"person_ident\":\"Gerrit User 1"
+ + " \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Other Account"
+ + " replied on the change\"}",
+ "-Attention: {\"person_ident\":\"Gerrit User 2"
+ + " \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other"
+ + " Account using the hovercard menu\"}",
+ "+Attention: {\"person_ident\":\"Gerrit User 1"
+ + " \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Someone replied on"
+ + " the change\"}",
+ "+Attention: {\"person_ident\":\"Gerrit User 2"
+ + " \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by"
+ + " someone using the hovercard menu\"}");
assertThat(Arrays.asList(commitHistoryDiff.get(2).split("\n")))
.containsExactly(
"@@ -7,2 +7,2 @@",
- "-Attention: {\"person_ident\":\"Other Account \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}",
- "-Attention: {\"person_ident\":\"Change Owner \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Other Account replied on the change\"}",
- "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}",
- "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Someone replied on the change\"}");
+ "-Attention: {\"person_ident\":\"Other Account"
+ + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone"
+ + " using the hovercard menu\"}",
+ "-Attention: {\"person_ident\":\"Change Owner"
+ + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Other Account"
+ + " replied on the change\"}",
+ "+Attention: {\"person_ident\":\"Gerrit User 2"
+ + " \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone"
+ + " using the hovercard menu\"}",
+ "+Attention: {\"person_ident\":\"Gerrit User 1"
+ + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Someone replied"
+ + " on the change\"}");
assertThat(commitHistoryDiff.get(3))
.isEqualTo(
"@@ -7 +7 @@\n"
- + "-Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other Account by clicking the attention icon\"}\n"
- + "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by someone by clicking the attention icon\"}\n");
+ + "-Attention: {\"person_ident\":\"Gerrit User 1"
+ + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other"
+ + " Account by clicking the attention icon\"}\n"
+ + "+Attention: {\"person_ident\":\"Gerrit User 1"
+ + " \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by"
+ + " someone by clicking the attention icon\"}\n");
BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
assertThat(secondRunResult.refsFailedToFix).isEmpty();
@@ -1481,13 +1511,15 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
commitsToFix.add(invalidCherryPickedMessageUpdate.commit());
ChangeUpdate invalidRebasedMessageUpdate = newUpdate(c, changeOwner);
invalidRebasedMessageUpdate.setChangeMessage(
- "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+ "Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+ changeOwner.getName());
commitsToFix.add(invalidRebasedMessageUpdate.commit());
ChangeUpdate validSubmitMessageUpdate = newUpdate(c, changeOwner);
validSubmitMessageUpdate.setChangeMessage(
- "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+ "Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b");
validSubmitMessageUpdate.commit();
Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -1510,15 +1542,21 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
assertThat(changeMessages(notesBeforeRewrite))
.containsExactly(
"Change has been successfully merged by Change Owner",
- "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
- "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
- "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+ "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b"
+ + " by Change Owner",
+ "Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
+ "Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b");
assertThat(changeMessages(notesAfterRewrite))
.containsExactly(
"Change has been successfully merged",
- "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
- "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
- "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+ "Change has been successfully cherry-picked as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+ "Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+ "Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b");
Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
@@ -1534,11 +1572,15 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
+ "-Change has been successfully merged by Change Owner\n"
+ "+Change has been successfully merged\n",
"@@ -6 +6 @@\n"
- + "-Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
- + "+Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n",
+ + "-Change has been successfully cherry-picked as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+ + "+Change has been successfully cherry-picked as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b\n",
"@@ -6 +6 @@\n"
- + "-Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
- + "+Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
+ + "-Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+ + "+Change has been successfully rebased and submitted as"
+ + " e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
assertThat(secondRunResult.refsFailedToFix).isEmpty();
@@ -1608,7 +1650,7 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
RefNames.changeMetaRef(c.getId()),
getChangeUpdateBody(
c,
- /*changeMessage=*/ null,
+ /* changeMessage= */ null,
"Label: SUBM=+1",
"Submission-id: 5271-1496917120975-10a10df9",
"Submitted-with: NOT_READY",
@@ -1874,59 +1916,72 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
ChangeUpdate invalidOnReviewUpdate = newUpdate(c, changeOwner);
invalidOnReviewUpdate.setChangeMessage(
"Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
- + "By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+ + "By voting Code-Review+2 the following files are now code-owner approved by Change"
+ + " Owner:\n"
+ " * file1.java\n"
+ " * file2.ts\n"
- + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
- + "By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n");
+ + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change"
+ + " Owner\n"
+ + "By voting Other-Label+2 the code-owners submit requirement is still overridden by"
+ + " Change Owner\n");
commitsToFix.add(invalidOnReviewUpdate.commit());
ChangeUpdate invalidOnReviewUpdateAnyOrder = newUpdate(c, changeOwner);
invalidOnReviewUpdateAnyOrder.setChangeMessage(
"Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
- + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
- + "By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
- + "By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+ + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change"
+ + " Owner\n"
+ + "By voting Other-Label+2 the code-owners submit requirement is still overridden by"
+ + " Change Owner\n"
+ + "By voting Code-Review+2 the following files are now code-owner approved by Change"
+ + " Owner:\n"
+ " * file1.java\n"
+ " * file2.ts\n");
commitsToFix.add(invalidOnReviewUpdateAnyOrder.commit());
ChangeUpdate invalidOnApprovalUpdate = newUpdate(c, otherUser);
invalidOnApprovalUpdate.setChangeMessage(
"Patch Set 1: -Code-Review\n\n"
- + "By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by Other Account:\n"
+ + "By removing the Code-Review+2 vote the following files are no longer explicitly"
+ + " code-owner approved by Other Account:\n"
+ " * file1.java\n"
+ " * file2.ts\n"
- + "\nThe listed files are still implicitly approved by Other Account.\n");
+ + "\n"
+ + "The listed files are still implicitly approved by Other Account.\n");
commitsToFix.add(invalidOnApprovalUpdate.commit());
ChangeUpdate invalidOnOverrideUpdate = newUpdate(c, changeOwner);
invalidOnOverrideUpdate.setChangeMessage(
"Patch Set 1: -Owners-Override\n\n"
+ "(1 comment)\n\n"
- + "By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by Change Owner\n");
+ + "By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+ + " longer overridden by Change Owner\n");
commitsToFix.add(invalidOnOverrideUpdate.commit());
ChangeUpdate partiallyValidOnReviewUpdate = newUpdate(c, changeOwner);
partiallyValidOnReviewUpdate.setChangeMessage(
"Patch Set 1: Any-Label+2 Code-Review+2\n\n"
- + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "By voting Code-Review+2 the following files are now code-owner approved by"
+ + " <GERRIT_ACCOUNT_1>:\n"
+ " * file1.java\n"
+ " * file2.ts\n"
- + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n");
+ + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change"
+ + " Owner\n");
commitsToFix.add(partiallyValidOnReviewUpdate.commit());
ChangeUpdate validOnApprovalUpdate = newUpdate(c, changeOwner);
validOnApprovalUpdate.setChangeMessage(
"Patch Set 1: Code-Review-2\n\n"
- + "By voting Code-Review-2 the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "By voting Code-Review-2 the following files are no longer explicitly code-owner"
+ + " approved by <GERRIT_ACCOUNT_1>:\n"
+ " * file4.java\n");
validOnApprovalUpdate.commit();
ChangeUpdate validOnOverrideUpdate = newUpdate(c, changeOwner);
validOnOverrideUpdate.setChangeMessage(
"Patch Set 1: Owners-Override+1\n\n"
- + "By voting Owners-Override+1 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n");
+ + "By voting Owners-Override+1 the code-owners submit requirement is still overridden"
+ + " by <GERRIT_ACCOUNT_1>\n");
validOnOverrideUpdate.commit();
Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
@@ -1950,39 +2005,52 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
assertThat(changeMessages(notesAfterRewrite))
.containsExactly(
"Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
- + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "By voting Code-Review+2 the following files are now code-owner approved by"
+ + " <GERRIT_ACCOUNT_1>:\n"
+ " * file1.java\n"
+ " * file2.ts\n"
- + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
- + "By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n",
+ + "By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " <GERRIT_ACCOUNT_1>\n"
+ + "By voting Other-Label+2 the code-owners submit requirement is still overridden"
+ + " by <GERRIT_ACCOUNT_1>\n",
"Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
- + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
- + "By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n"
- + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " <GERRIT_ACCOUNT_1>\n"
+ + "By voting Other-Label+2 the code-owners submit requirement is still overridden"
+ + " by <GERRIT_ACCOUNT_1>\n"
+ + "By voting Code-Review+2 the following files are now code-owner approved by"
+ + " <GERRIT_ACCOUNT_1>:\n"
+ " * file1.java\n"
+ " * file2.ts\n",
"Patch Set 1: -Code-Review\n"
+ "\n"
- + "By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+ + "By removing the Code-Review+2 vote the following files are no longer explicitly"
+ + " code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+ " * file1.java\n"
+ " * file2.ts\n"
- + "\nThe listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
+ + "\n"
+ + "The listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
"Patch Set 1: -Owners-Override\n"
+ "\n"
+ "(1 comment)\n"
+ "\n"
- + "By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by <GERRIT_ACCOUNT_1>\n",
+ + "By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+ + " longer overridden by <GERRIT_ACCOUNT_1>\n",
"Patch Set 1: Any-Label+2 Code-Review+2\n\n"
- + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "By voting Code-Review+2 the following files are now code-owner approved by"
+ + " <GERRIT_ACCOUNT_1>:\n"
+ " * file1.java\n"
+ " * file2.ts\n"
- + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n",
+ + "By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " <GERRIT_ACCOUNT_1>\n",
"Patch Set 1: Code-Review-2\n\n"
- + "By voting Code-Review-2 the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "By voting Code-Review-2 the following files are no longer explicitly code-owner"
+ + " approved by <GERRIT_ACCOUNT_1>:\n"
+ " * file4.java\n",
"Patch Set 1: Owners-Override+1\n"
+ "\n"
- + "By voting Owners-Override+1 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n");
+ + "By voting Owners-Override+1 the code-owners submit requirement is still"
+ + " overridden by <GERRIT_ACCOUNT_1>\n");
Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
@@ -1995,32 +2063,50 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
assertThat(commitHistoryDiff)
.containsExactly(
"@@ -8 +8 @@\n"
- + "-By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
- + "+By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+ + "-By voting Code-Review+2 the following files are now code-owner approved by"
+ + " Change Owner:\n"
+ + "+By voting Code-Review+2 the following files are now code-owner approved by"
+ + " <GERRIT_ACCOUNT_1>:\n"
+ "@@ -11,2 +11,2 @@\n"
- + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
- + "-By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
- + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
- + "+By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n",
+ + "-By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " Change Owner\n"
+ + "-By voting Other-Label+2 the code-owners submit requirement is still overridden"
+ + " by Change Owner\n"
+ + "+By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " <GERRIT_ACCOUNT_1>\n"
+ + "+By voting Other-Label+2 the code-owners submit requirement is still overridden"
+ + " by <GERRIT_ACCOUNT_1>\n",
"@@ -8,3 +8,3 @@\n"
- + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
- + "-By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
- + "-By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
- + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
- + "+By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n"
- + "+By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n",
+ + "-By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " Change Owner\n"
+ + "-By voting Other-Label+2 the code-owners submit requirement is still overridden"
+ + " by Change Owner\n"
+ + "-By voting Code-Review+2 the following files are now code-owner approved by"
+ + " Change Owner:\n"
+ + "+By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " <GERRIT_ACCOUNT_1>\n"
+ + "+By voting Other-Label+2 the code-owners submit requirement is still overridden"
+ + " by <GERRIT_ACCOUNT_1>\n"
+ + "+By voting Code-Review+2 the following files are now code-owner approved by"
+ + " <GERRIT_ACCOUNT_1>:\n",
"@@ -8 +8 @@\n"
- + "-By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by Other Account:\n"
- + "+By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+ + "-By removing the Code-Review+2 vote the following files are no longer explicitly"
+ + " code-owner approved by Other Account:\n"
+ + "+By removing the Code-Review+2 vote the following files are no longer explicitly"
+ + " code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+ "@@ -12 +12 @@\n"
+ "-The listed files are still implicitly approved by Other Account.\n"
+ "+The listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
"@@ -10 +10 @@\n"
- + "-By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by Change Owner\n"
- + "+By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by <GERRIT_ACCOUNT_1>\n",
+ + "-By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+ + " longer overridden by Change Owner\n"
+ + "+By removing the Owners-Override+1 vote the code-owners submit requirement is no"
+ + " longer overridden by <GERRIT_ACCOUNT_1>\n",
"@@ -11 +11 @@\n"
- + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
- + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n");
+ + "-By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " Change Owner\n"
+ + "+By voting Any-Label+2 the code-owners submit requirement is overridden by"
+ + " <GERRIT_ACCOUNT_1>\n");
BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
assertThat(secondRunResult.refsFailedToFix).isEmpty();
@@ -2037,34 +2123,30 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
getChangeUpdateBody(c, "Assignee added", "Assignee: " + assigneeIdentToFix),
getAuthorIdent(changeOwner.getAccount()));
- ChangeUpdate changeAssigneeUpdate = newUpdate(c, changeOwner);
- changeAssigneeUpdate.setAssignee(otherUserId);
- changeAssigneeUpdate.commit();
-
- ChangeUpdate removeAssigneeUpdate = newUpdate(c, changeOwner);
- removeAssigneeUpdate.removeAssignee();
- removeAssigneeUpdate.commit();
+ // Valid commits
+ writeUpdate(
+ RefNames.changeMetaRef(c.getId()),
+ getChangeUpdateBody(
+ c,
+ "Assignee added: <GERRIT_ACCOUNT_2>",
+ "Assignee: " + getValidIdentAsString(otherUser.getAccount())),
+ getAuthorIdent(changeOwner.getAccount()));
+ writeUpdate(
+ RefNames.changeMetaRef(c.getId()),
+ getChangeUpdateBody(c, "Assignee deleted: <GERRIT_ACCOUNT_2>", "Assignee:"),
+ getAuthorIdent(changeOwner.getAccount()));
Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
- ChangeNotes notesBeforeRewrite = newNotes(c);
RunOptions options = new RunOptions();
options.dryRun = false;
BackfillResult result = rewriter.backfillProject(project, repo, options);
assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
- ChangeNotes notesAfterRewrite = newNotes(c);
- assertThat(notesBeforeRewrite.getPastAssignees())
- .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
- assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
- assertThat(notesAfterRewrite.getPastAssignees())
- .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
- assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
-
Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
@@ -2143,18 +2225,13 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
ChangeNotes notesAfterRewrite = newNotes(c);
- assertThat(notesBeforeRewrite.getPastAssignees())
- .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
- assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
assertThat(changeMessages(notesBeforeRewrite))
.containsExactly(
"Assignee added: Change Owner <change@owner.com>",
- "Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>",
+ "Assignee changed from: Change Owner <change@owner.com> to: Other Account"
+ + " <other@account.com>",
"Assignee deleted: Other Account <other@account.com>");
- assertThat(notesAfterRewrite.getPastAssignees())
- .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
- assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
assertThat(changeMessages(notesAfterRewrite))
.containsExactly(
"Assignee added: " + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()),
@@ -2179,7 +2256,8 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
+ "-Assignee added: Change Owner <change@owner.com>\n"
+ "+Assignee added: <GERRIT_ACCOUNT_1>\n",
"@@ -6 +6 @@\n"
- + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+ + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account"
+ + " <other@account.com>\n"
+ "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n",
"@@ -6 +6 @@\n"
+ "-Assignee deleted: Other Account <other@account.com>\n"
@@ -2242,7 +2320,8 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
+ "-Assignee added: Change Owner\n"
+ "+Assignee added: <GERRIT_ACCOUNT_1>\n",
"@@ -6 +6 @@\n"
- + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+ + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account"
+ + " <other@account.com>\n"
+ "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n",
"@@ -6 +6 @@\n"
+ "-Assignee deleted: Other Account\n"
@@ -2278,17 +2357,12 @@ public class CommitRewriterTest extends AbstractChangeNotesTest {
ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
- ChangeNotes notesBeforeRewrite = newNotes(c);
RunOptions options = new RunOptions();
options.dryRun = false;
BackfillResult result = rewriter.backfillProject(project, repo, options);
assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
- ChangeNotes notesAfterRewrite = newNotes(c);
- assertThat(notesBeforeRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
- assertThat(notesAfterRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
-
Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
diff --git a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
index 0c9f731437..1b2d9065ab 100644
--- a/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/javatests/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.notedb;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -385,7 +386,9 @@ public class RepoSequenceTest {
ins.flush();
RefUpdate ru = repo.updateRef(refName);
ru.setNewObjectId(newId);
- assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED);
+ testRefAction(
+ () ->
+ assertThat(ru.forceUpdate()).isAnyOf(RefUpdate.Result.NEW, RefUpdate.Result.FORCED));
return newId;
} catch (IOException e) {
throw new RuntimeException(e);
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index ef92139902..3b7ad1e504 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -537,14 +537,14 @@ public class ProjectConfigTest {
StoredCommentLinkInfo cm =
StoredCommentLinkInfo.builder("Test")
.setMatch("abc.*")
- .setHtml("<a>link</a>")
+ .setLink("link")
.setEnabled(true)
.setOverrideOnly(false)
.build();
cfg.addCommentLinkSection(cm);
rev = commit(cfg);
assertThat(text(rev, "project.config"))
- .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\thtml = <a>link</a>\n");
+ .isEqualTo("[commentlink \"Test\"]\n\tmatch = abc.*\n\tlink = link\n");
}
@Test
@@ -722,7 +722,7 @@ public class ProjectConfigTest {
}
@Test
- public void readCommentLinksNoHtmlOrLinkButEnabled() throws Exception {
+ public void readCommentLinksNoLinkButEnabled() throws Exception {
RevCommit rev =
tr.commit().add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = true").create();
ProjectConfig cfg = read(rev);
@@ -732,7 +732,7 @@ public class ProjectConfigTest {
}
@Test
- public void readCommentLinksNoHtmlOrLinkAndDisabled() throws Exception {
+ public void readCommentLinksNoLinkAndDisabled() throws Exception {
RevCommit rev =
tr.commit()
.add("project.config", "[commentlink \"bugzilla\"]\n \tenabled = false")
@@ -744,7 +744,7 @@ public class ProjectConfigTest {
}
@Test
- public void readCommentLinksNoHtmlOrLinkAndMissingEnabled() throws Exception {
+ public void readCommentLinksMissingEnabled() throws Exception {
RevCommit rev =
tr.commit()
.add(
@@ -792,26 +792,7 @@ public class ProjectConfigTest {
}
@Test
- public void readCommentLinkRawHtml() throws Exception {
- RevCommit rev =
- tr.commit()
- .add(
- "project.config",
- "[commentlink \"bugzilla\"]\n"
- + "\tmatch = \"(bugs#?)(d+)\"\n"
- + "\thtml = http://bugs.example.com/show_bug.cgi?id=$2")
- .create();
- ProjectConfig cfg = read(rev);
- assertThat(cfg.getCommentLinkSections()).isEmpty();
- assertThat(cfg.getValidationErrors())
- .containsExactly(
- ValidationError.create(
- "project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
- + "Raw html replacement not allowed"));
- }
-
- @Test
- public void readCommentLinkMatchButNoHtmlOrLink() throws Exception {
+ public void readCommentLinkMatchButNoLink() throws Exception {
RevCommit rev =
tr.commit()
.add("project.config", "[commentlink \"bugzilla\"]\n" + "\tmatch = \"(bugs#?)(d+)\"\n")
@@ -822,7 +803,7 @@ public class ProjectConfigTest {
.containsExactly(
ValidationError.create(
"project.config: Error in pattern \"(bugs#?)(d+)\" in commentlink.bugzilla.match: "
- + "commentlink.bugzilla must have either link or html"));
+ + "commentlink.bugzilla must have link specified"));
}
@Test
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index b0e705b991..aea4b95e44 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.query.account;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.util.stream.Collectors.toList;
import static org.junit.Assert.fail;
@@ -24,6 +25,7 @@ import static org.junit.Assert.fail;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.GerritApi;
@@ -32,10 +34,12 @@ import com.google.gerrit.extensions.api.access.PermissionInfo;
import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
import com.google.gerrit.extensions.api.access.ProjectAccessInput;
import com.google.gerrit.extensions.api.accounts.Accounts.QueryRequest;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
import com.google.gerrit.extensions.api.groups.GroupInput;
import com.google.gerrit.extensions.api.projects.ProjectInput;
import com.google.gerrit.extensions.client.ListAccountsOption;
import com.google.gerrit.extensions.client.ProjectWatchInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.common.AccountExternalIdInfo;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
@@ -90,6 +94,7 @@ import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
+import java.util.Locale;
import java.util.Optional;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
@@ -238,7 +243,7 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
assertQuery(user5.email, user5);
assertQuery("email:" + user5.email, user5);
- assertQuery("email:" + user5.email.toUpperCase(), user5);
+ assertQuery("email:" + user5.email.toUpperCase(Locale.US), user5);
}
@Test
@@ -277,6 +282,7 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
@Test
public void byUsername() throws Exception {
+ assume().that(hasIndexByUsername()).isTrue();
AccountInfo user1 = newAccount("myuser");
assertQuery("notexisting");
@@ -284,7 +290,7 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
assertQuery(user1.username, user1);
assertQuery("username:" + user1.username, user1);
- assertQuery("username:" + user1.username.toUpperCase(), user1);
+ assertQuery("username:" + user1.username.toUpperCase(Locale.US), user1);
}
@Test
@@ -387,6 +393,46 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
}
@Test
+ public void byCanSee_privateChange() throws Exception {
+ String domain = name("test.com");
+ AccountInfo user1 = newAccountWithEmail("account1", "account1@" + domain);
+ AccountInfo user2 = newAccountWithEmail("account2", "account2@" + domain);
+ AccountInfo user3 = newAccountWithEmail("account3", "account3@" + domain);
+ AccountInfo user4 = newAccountWithEmail("account4", "account4@" + domain);
+
+ Project.NameKey p = createProject(name("p"));
+
+ // Create the change as User1
+ requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+ ChangeInfo c = createPrivateChange(p);
+ assertThat(c.owner).isEqualTo(user1);
+
+ // Add user2 as a reviewer, user3 as a CC, and leave user4 dangling.
+ addReviewer(c.changeId, user2.email, ReviewerState.REVIEWER);
+ addReviewer(c.changeId, user3.email, ReviewerState.CC);
+
+ // Request as the owner
+ requestContext.setContext(newRequestContext(Account.id(user1._accountId)));
+ assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+ // Request as the reviewer
+ requestContext.setContext(newRequestContext(Account.id(user2._accountId)));
+ assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+ // Request as the CC
+ requestContext.setContext(newRequestContext(Account.id(user3._accountId)));
+ assertQuery("cansee:" + c.changeId, user1, user2, user3);
+
+ // Request as an account not in {owner, reviewer, CC}
+ requestContext.setContext(newRequestContext(Account.id(user4._accountId)));
+ BadRequestException exception =
+ assertThrows(BadRequestException.class, () -> newQuery("cansee:" + c.changeId).get());
+ assertThat(exception)
+ .hasMessageThat()
+ .isEqualTo(String.format("change %s not found", c.changeId));
+ }
+
+ @Test
public void byWatchedProject() throws Exception {
Project.NameKey p = createProject(name("p"));
Project.NameKey p2 = createProject(name("p2"));
@@ -517,7 +563,7 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
public void withDetails() throws Exception {
AccountInfo user1 = newAccount("myuser", "My User", "my.user@example.com", true);
- List<AccountInfo> result = assertQuery(user1.username, user1);
+ List<AccountInfo> result = assertQuery(getDefaultSearch(user1), user1);
AccountInfo ai = result.get(0);
assertThat(ai._accountId).isEqualTo(user1._accountId);
assertThat(ai.name).isNull();
@@ -525,7 +571,9 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
assertThat(ai.email).isNull();
assertThat(ai.avatars).isNull();
- result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+ result =
+ assertQuery(
+ newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.DETAILS), user1);
ai = result.get(0);
assertThat(ai._accountId).isEqualTo(user1._accountId);
assertThat(ai.name).isEqualTo(user1.name);
@@ -540,25 +588,29 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
String[] secondaryEmails = new String[] {"bar@example.com", "foo@example.com"};
addEmails(user1, secondaryEmails);
- List<AccountInfo> result = assertQuery(user1.username, user1);
+ List<AccountInfo> result = assertQuery(getDefaultSearch(user1), user1);
assertThat(result.get(0).secondaryEmails).isNull();
- result = assertQuery(newQuery(user1.username).withSuggest(true), user1);
+ result = assertQuery(newQuery(getDefaultSearch(user1)).withSuggest(true), user1);
assertThat(result.get(0).secondaryEmails)
.containsExactlyElementsIn(Arrays.asList(secondaryEmails))
.inOrder();
- result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.DETAILS), user1);
+ result =
+ assertQuery(
+ newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.DETAILS), user1);
assertThat(result.get(0).secondaryEmails).isNull();
- result = assertQuery(newQuery(user1.username).withOption(ListAccountsOption.ALL_EMAILS), user1);
+ result =
+ assertQuery(
+ newQuery(getDefaultSearch(user1)).withOption(ListAccountsOption.ALL_EMAILS), user1);
assertThat(result.get(0).secondaryEmails)
.containsExactlyElementsIn(Arrays.asList(secondaryEmails))
.inOrder();
result =
assertQuery(
- newQuery(user1.username)
+ newQuery(getDefaultSearch(user1))
.withOptions(ListAccountsOption.DETAILS, ListAccountsOption.ALL_EMAILS),
user1);
assertThat(result.get(0).secondaryEmails)
@@ -576,21 +628,22 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
requestContext.setContext(newRequestContext(Account.id(user._accountId)));
- List<AccountInfo> result = newQuery(otherUser.username).withSuggest(true).get();
+ List<AccountInfo> result = newQuery(getDefaultSearch(otherUser)).withSuggest(true).get();
assertThat(result.get(0).secondaryEmails).isNull();
assertThrows(
AuthException.class,
- () -> newQuery(otherUser.username).withOption(ListAccountsOption.ALL_EMAILS).get());
+ () ->
+ newQuery(getDefaultSearch(otherUser)).withOption(ListAccountsOption.ALL_EMAILS).get());
}
@Test
public void asAnonymous() throws Exception {
- AccountInfo user1 = newAccount("user1");
+ AccountInfo user1 = newAccount("user1", "user1@gerrit.com", /*active=*/ true);
setAnonymous();
assertQuery("9999999");
assertQuery("self");
- assertQuery("username:" + user1.username, user1);
+ assertQuery("email:" + user1.email, user1);
}
// reindex permissions are tested by {@link AccountIT#reindexPermissions}
@@ -631,7 +684,12 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
.getRaw(
Account.id(userInfo._accountId),
QueryOptions.create(
- IndexConfig.fromConfig(config).build(), 0, 1, schema.getStoredFields()));
+ config != null
+ ? IndexConfig.fromConfig(config).build()
+ : IndexConfig.createDefault(),
+ 0,
+ 1,
+ schema.getStoredFields()));
assertThat(rawFields).isPresent();
if (schema.hasField(AccountField.ID_FIELD_SPEC)) {
@@ -649,6 +707,11 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
assertThat(extId).isPresent();
blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
}
+
+ // Some installations do not store EXTERNAL_ID_STATE_SPEC
+ if (!schema.hasField(AccountField.EXTERNAL_ID_STATE_SPEC)) {
+ return;
+ }
Iterable<byte[]> externalIdStates =
rawFields.get().<Iterable<byte[]>>getValue(AccountField.EXTERNAL_ID_STATE_SPEC);
assertThat(externalIdStates).hasSize(blobs.size());
@@ -656,6 +719,21 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
.containsExactlyElementsIn(blobs);
}
+ private String getDefaultSearch(AccountInfo user) {
+ return hasIndexByUsername() ? user.username : user.name;
+ }
+
+ /**
+ * Returns 'true' is {@link AccountField#USERNAME_FIELD} is indexed.
+ *
+ * <p>Some installations do not index {@link AccountField#USERNAME_FIELD}, since they do not use
+ * {@link ExternalId#SCHEME_USERNAME}
+ */
+ private boolean hasIndexByUsername() {
+ Schema<AccountState> schema = indexes.getSearchIndex().getSchema();
+ return schema.hasField(AccountField.USERNAME_SPEC);
+ }
+
protected AccountInfo newAccount(String username) throws Exception {
return newAccountWithEmail(username, null);
}
@@ -709,6 +787,15 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
gApi.projects().name(project.get()).access(in);
}
+ protected ChangeInfo createPrivateChange(Project.NameKey project) throws RestApiException {
+ ChangeInput in = new ChangeInput();
+ in.subject = "A change";
+ in.project = project.get();
+ in.branch = "master";
+ in.isPrivate = true;
+ return gApi.changes().create(in).get();
+ }
+
protected ChangeInfo createChange(Project.NameKey project) throws RestApiException {
ChangeInput in = new ChangeInput();
in.subject = "A change";
@@ -717,6 +804,14 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
return gApi.changes().create(in).get();
}
+ protected void addReviewer(String changeId, String email, ReviewerState state)
+ throws RestApiException {
+ ReviewerInput reviewerInput = new ReviewerInput();
+ reviewerInput.reviewer = email;
+ reviewerInput.state = state;
+ gApi.changes().id(changeId).addReviewer(reviewerInput);
+ }
+
protected GroupInfo createGroup(String name, AccountInfo... members) throws RestApiException {
GroupInput in = new GroupInput();
in.name = name;
@@ -742,6 +837,7 @@ public abstract class AbstractQueryAccountsTest extends GerritServerTests {
return "\"" + s + "\"";
}
+ @Nullable
protected String name(String name) {
if (name == null) {
return null;
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index c255f5de26..c781d8b894 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -13,6 +13,7 @@ java_library(
"//prolog:gerrit-prolog-common",
],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 556d5f05d6..2d7ed10b6c 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -26,6 +26,7 @@ import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS
import static com.google.gerrit.server.project.testing.TestLabels.label;
import static com.google.gerrit.server.project.testing.TestLabels.value;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.concurrent.TimeUnit.HOURS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
@@ -46,6 +47,7 @@ import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Streams;
import com.google.common.truth.ThrowableSubject;
+import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.gerrit.acceptance.ExtensionRegistry;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.FakeSubmitRule;
@@ -69,7 +71,6 @@ import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.Changes.QueryRequest;
@@ -112,6 +113,7 @@ import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.AuthRequest;
import com.google.gerrit.server.account.ListGroupMembership;
import com.google.gerrit.server.account.VersionedAccountQueries;
+import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.account.externalids.ExternalIdFactory;
import com.google.gerrit.server.change.ChangeInserter;
import com.google.gerrit.server.change.ChangeTriplet;
@@ -119,6 +121,7 @@ import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.group.testing.TestGroupBackend;
import com.google.gerrit.server.index.change.ChangeField;
@@ -126,6 +129,7 @@ import com.google.gerrit.server.index.change.ChangeIndexCollection;
import com.google.gerrit.server.index.change.ChangeIndexer;
import com.google.gerrit.server.index.change.IndexedChangeQuery;
import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
@@ -137,8 +141,7 @@ import com.google.gerrit.server.util.RequestContext;
import com.google.gerrit.server.util.ThreadLocalRequestContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.GerritServerTests;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testing.TestChanges;
import com.google.gerrit.testing.TestTimeUtil;
import com.google.inject.Inject;
import com.google.inject.Injector;
@@ -152,6 +155,7 @@ import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -161,7 +165,7 @@ import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.SystemReader;
@@ -187,7 +191,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Inject protected ChangeIndexer indexer;
@Inject protected ExtensionRegistry extensionRegistry;
@Inject protected IndexConfig indexConfig;
- @Inject protected InMemoryRepositoryManager repoManager;
+ @Inject protected GitRepositoryManager repoManager;
@Inject protected Provider<AnonymousUser> anonymousUserProvider;
@Inject protected Provider<InternalChangeQuery> queryProvider;
@Inject protected ChangeNotes.Factory notesFactory;
@@ -211,11 +215,20 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
protected Injector injector;
protected LifecycleManager lifecycle;
+
+ /**
+ * Index tests should not use username in query assert, since some backends do not use {@link
+ * ExternalId#SCHEME_USERNAME}
+ */
protected Account.Id userId;
+
protected CurrentUser user;
+ protected Account userAccount;
private String systemTimeZone;
+ protected TestRepository<Repository> repo;
+
protected abstract Injector createInjector();
@Before
@@ -231,6 +244,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@After
public void cleanUp() {
+ if (repo != null) {
+ repo.close();
+ repo = null;
+ }
lifecycle.stop();
}
@@ -257,8 +274,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
return () -> requestUser;
}
- protected void resetUser() {
+ protected void resetUser() throws ConfigInvalidException, IOException {
user = userFactory.create(userId);
+ userAccount = accounts.get(userId).get().account();
requestContext.setContext(newRequestContext(userId));
}
@@ -294,9 +312,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byId() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
assertQuery("12345");
assertQuery(change1.getId().get(), change1);
@@ -305,8 +323,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byKey() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
String key = change.getKey().get();
assertQuery("I0000000000000000000000000000000000000000");
@@ -318,8 +336,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byTriplet() throws Exception {
- TestRepository<Repo> repo = createProject("iabcde");
- Change change = insert(repo, newChangeForBranch(repo, "branch"));
+ repo = createAndOpenProject("iabcde");
+ Change change = insert("iabcde", newChangeForBranch(repo, "branch"));
String k = change.getKey().get();
assertQuery("iabcde~branch~" + k, change);
@@ -341,11 +359,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStatus() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
- Change change1 = insert(repo, ins1);
+ Change change1 = insert("repo", ins1);
ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
- Change change2 = insert(repo, ins2);
+ Change change2 = insert("repo", ins2);
assertQuery("status:new", change1);
assertQuery("status:NEW", change1);
@@ -360,11 +378,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStatusOr() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
- Change change1 = insert(repo, ins1);
+ Change change1 = insert("repo", ins1);
ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.MERGED);
- Change change2 = insert(repo, ins2);
+ Change change2 = insert("repo", ins2);
assertQuery("status:new OR status:merged", change2, change1);
assertQuery("status:new or status:merged", change2, change1);
@@ -372,10 +390,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStatusOpen() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
- Change change1 = insert(repo, ins1);
- insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+ Change change1 = insert("repo", ins1);
+ insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
Change[] expected = new Change[] {change1};
assertQuery("status:open", expected);
@@ -394,12 +412,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStatusClosed() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
- Change change1 = insert(repo, ins1);
+ Change change1 = insert("repo", ins1);
ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
- Change change2 = insert(repo, ins2);
- insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+ Change change2 = insert("repo", ins2);
+ insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
Change[] expected = new Change[] {change2, change1};
assertQuery("status:closed", expected);
@@ -415,12 +433,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStatusAbandoned() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
- insert(repo, ins1);
+ insert("repo", ins1);
ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.ABANDONED);
- Change change1 = insert(repo, ins2);
- insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+ Change change1 = insert("repo", ins2);
+ insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
assertQuery("status:abandoned", change1);
assertQuery("status:ABANDONED", change1);
@@ -429,10 +447,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStatusPrefix() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
- Change change1 = insert(repo, ins1);
- insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+ Change change1 = insert("repo", ins1);
+ Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
assertQuery("status:n", change1);
assertQuery("status:ne", change1);
@@ -440,6 +458,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("status:N", change1);
assertQuery("status:nE", change1);
assertQuery("status:neW", change1);
+ assertQuery("status:m", change2);
Exception thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:newx"));
assertThat(thrown).hasMessageThat().isEqualTo("Unrecognized value: newx");
thrown = assertThrows(BadRequestException.class, () -> assertQuery("status:nx"));
@@ -448,11 +467,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byPrivate() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo), userId);
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- Change change2 = insert(repo, newChange(repo), user2);
+ Change change2 = insert("repo", newChange(repo), user2);
// No private changes.
assertQuery("is:open", change2, change1);
@@ -472,8 +491,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byWip() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo), userId);
assertQuery("is:open", change1);
assertQuery("is:wip");
@@ -490,8 +509,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void excludeWipChangeFromReviewersDashboards() throws Exception {
Account.Id user1 = createAccount("user1");
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWorkInProgress(repo), userId);
assertQuery("is:wip", change1);
assertQuery("reviewer:" + user1);
@@ -507,8 +526,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStarted() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWorkInProgress(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWorkInProgress(repo));
assertQuery("is:started");
@@ -543,11 +562,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void restorePendingReviewers() throws Exception {
Project.NameKey project = Project.nameKey("repo");
- TestRepository<Repo> repo = createProject(project.get());
+ repo = createAndOpenProject(project.get());
ConfigInput conf = new ConfigInput();
conf.enableReviewerByEmail = InheritableBoolean.TRUE;
gApi.projects().name(project.get()).config(conf);
- Change change1 = insert(repo, newChangeWorkInProgress(repo));
+ Change change1 = insert("repo", newChangeWorkInProgress(repo));
Account.Id user1 = createAccount("user1");
Account.Id user2 = createAccount("user2");
String email1 = "email1@example.com";
@@ -600,9 +619,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byCommit() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins = newChange(repo);
- Change change = insert(repo, ins);
+ Change change = insert("repo", ins);
String sha = ins.getCommitId().name();
assertQuery("0000000000000000000000000000000000000000");
@@ -616,11 +635,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byOwner() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo), userId);
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- Change change2 = insert(repo, newChange(repo), user2);
+ Change change2 = insert("repo", newChange(repo), user2);
assertQuery("is:owner", change1);
assertQuery("owner:" + userId.get(), change1);
@@ -632,16 +651,17 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byUploader() throws Exception {
- assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
- Account.Id user2 =
- accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- CurrentUser user2CurrentUser = userFactory.create(user2);
+ assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo), userId);
assertQuery("is:uploader", change1);
assertQuery("uploader:" + userId.get(), change1);
- change1 = newPatchSet(repo, change1, user2CurrentUser);
+
+ Account.Id user2 = createAccount("anotheruser");
+ CurrentUser user2CurrentUser = userFactory.create(user2);
+
+ change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
// Uploader has changed
assertQuery("uploader:" + userId.get());
assertQuery("uploader:" + user2.get(), change1);
@@ -659,11 +679,21 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
@Test
+ public void byAuthorExact_byAlias() throws Exception {
+ byAuthorOrCommitterExact("a:");
+ }
+
+ @Test
public void byAuthorFullText() throws Exception {
byAuthorOrCommitterFullText("author:");
}
@Test
+ public void byAuthorFullText_byAlias() throws Exception {
+ byAuthorOrCommitterFullText("a:");
+ }
+
+ @Test
public void byCommitterExact() throws Exception {
byAuthorOrCommitterExact("committer:");
}
@@ -674,7 +704,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ createProject("repo");
PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
PersonIdent john = new PersonIdent("John", "john@example.com");
PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
@@ -682,10 +712,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
PersonIdent myself = new PersonIdent("I Am", ua.preferredEmail());
PersonIdent selfName = new PersonIdent("My Self", "my.self@example.com");
- Change change1 = createChange(repo, johnDoe);
- Change change2 = createChange(repo, john);
- Change change3 = createChange(repo, doeSmith);
- createChange(repo, selfName);
+ Change change1 = createChange("repo", johnDoe);
+ Change change2 = createChange("repo", john);
+ Change change3 = createChange("repo", doeSmith);
+ createChange("repo", selfName);
// Only email address.
assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -710,19 +740,19 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery(searchOperator + "self");
// ':self' matches a change created with the current user's email address
- Change change5 = createChange(repo, myself);
+ Change change5 = createChange("repo", myself);
assertQuery(searchOperator + "me", change5);
assertQuery(searchOperator + "self", change5);
}
private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ createProject("repo");
PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
PersonIdent john = new PersonIdent("John", "john@example.com");
PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
- Change change1 = createChange(repo, johnDoe);
- Change change2 = createChange(repo, john);
- Change change3 = createChange(repo, doeSmith);
+ Change change1 = createChange("repo", johnDoe);
+ Change change2 = createChange("repo", john);
+ Change change3 = createChange("repo", doeSmith);
// By exact name.
assertQuery(searchOperator + "\"John Doe\"", change1);
@@ -743,20 +773,25 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertThat(thrown).hasMessageThat().contains("invalid value");
}
- protected Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
- RevCommit commit =
- repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
- return insert(repo, newChangeForCommit(repo, commit), null);
+ @CanIgnoreReturnValue
+ protected Change createChange(String repoName, PersonIdent person) throws Exception {
+ try (TestRepository<Repository> repo =
+ new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+ RevCommit commit =
+ repo.parseBody(
+ repo.commit().message("message").author(person).committer(person).create());
+ return insert("repo", newChangeForCommit(repo, commit), null);
+ }
}
@Test
public void byOwnerIn() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo), userId);
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- Change change2 = insert(repo, newChange(repo), user2);
- Change change3 = insert(repo, newChange(repo), user2);
+ Change change2 = insert("repo", newChange(repo), user2);
+ Change change3 = insert("repo", newChange(repo), user2);
gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
gApi.changes().id(change3.getId().get()).current().submit();
@@ -770,7 +805,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
testGroupBackend.setMembershipsOf(
user2, new ListGroupMembership(ImmutableList.of(externalGroup.getGroupUUID())));
- assertQuery("ownerin:\"" + "testbackend:" + externalGroup.getName() + "\"", change3, change2);
+ assertQuery(
+ "ownerin:\"" + TestGroupBackend.PREFIX + externalGroup.getName() + "\"",
+ change3,
+ change2);
String nameOfGroupThatContainsExternalGroupAsSubgroup = "test-group-1";
AccountGroup.UUID uuidOfGroupThatContainsExternalGroupAsSubgroup =
@@ -800,15 +838,16 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byUploaderIn() throws Exception {
- assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
+ assume().that(getSchema().hasField(ChangeField.UPLOADER_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo), userId);
+
assertQuery("uploaderin:Administrators", change1);
- Account.Id user2 =
- accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+ Account.Id user2 = createAccount("anotheruser");
CurrentUser user2CurrentUser = userFactory.create(user2);
- newPatchSet(repo, change1, user2CurrentUser);
+ change1 = newPatchSet("repo", change1, user2CurrentUser, /* message= */ Optional.empty());
+
assertQuery("uploaderin:Administrators");
assertQuery("uploaderin:\"Registered Users\"", change1);
@@ -817,7 +856,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
testGroupBackend.setMembershipsOf(
user2, new ListGroupMembership(ImmutableList.of(externalGroup.getGroupUUID())));
- assertQuery("uploaderin:\"" + "testbackend:" + externalGroup.getName() + "\"", change1);
+ assertQuery(
+ "uploaderin:\"" + TestGroupBackend.PREFIX + externalGroup.getName() + "\"", change1);
String nameOfGroupThatContainsExternalGroupAsSubgroup = "test-group-1";
AccountGroup.UUID uuidOfGroupThatContainsExternalGroupAsSubgroup =
@@ -845,10 +885,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byProject() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("project:foo");
assertQuery("project:repo");
@@ -858,16 +898,16 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byProjectWithHidden() throws Exception {
- TestRepository<Repo> hiddenProject = createProject("hiddenProject");
- insert(hiddenProject, newChange(hiddenProject));
+ createProject("hiddenProject");
+ insert("hiddenProject", newChange("hiddenProject"));
projectOperations
.project(Project.nameKey("hiddenProject"))
.forUpdate()
.add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
.update();
- TestRepository<Repo> visibleProject = createProject("visibleProject");
- Change visibleChange = insert(visibleProject, newChange(visibleProject));
+ createProject("visibleProject");
+ Change visibleChange = insert("visibleProject", newChange("visibleProject"));
assertQuery("project:visibleProject", visibleChange);
assertQuery("project:hiddenProject");
assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
@@ -875,13 +915,13 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byParentOf() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
- Change change1 = insert(repo1, newChangeForCommit(repo1, commit1));
- RevCommit commit2 = repo1.parseBody(repo1.commit(commit1));
- Change change2 = insert(repo1, newChangeForCommit(repo1, commit2));
- RevCommit commit3 = repo1.parseBody(repo1.commit(commit1, commit2));
- Change change3 = insert(repo1, newChangeForCommit(repo1, commit3));
+ repo = createAndOpenProject("repo1");
+ RevCommit commit1 = repo.parseBody(repo.commit().message("message").create());
+ Change change1 = insert("repo1", newChangeForCommit(repo, commit1));
+ RevCommit commit2 = repo.parseBody(repo.commit(commit1));
+ Change change2 = insert("repo1", newChangeForCommit(repo, commit2));
+ RevCommit commit3 = repo.parseBody(repo.commit(commit1, commit2));
+ Change change3 = insert("repo1", newChangeForCommit(repo, commit3));
assertQuery("parentof:" + change1.getId().get());
assertQuery("parentof:" + change1.getKey().get());
@@ -893,10 +933,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byParentProject() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2", "repo1");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2", "repo1");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("parentproject:repo1", change2, change1);
assertQuery("parentproject:repo2", change2);
@@ -904,10 +944,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byProjectPrefix() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("projects:foo");
assertQuery("projects:repo1", change1);
@@ -917,10 +957,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byRepository() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("repository:foo");
assertQuery("repository:repo");
@@ -930,10 +970,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byParentRepository() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2", "repo1");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2", "repo1");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("parentrepository:repo1", change2, change1);
assertQuery("parentrepository:repo2", change2);
@@ -941,10 +981,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byRepositoryPrefix() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("repositories:foo");
assertQuery("repositories:repo1", change1);
@@ -954,10 +994,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byRepo() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("repo:foo");
assertQuery("repo:repo");
@@ -967,10 +1007,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byParentRepo() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2", "repo1");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2", "repo1");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("parentrepo:repo1", change2, change1);
assertQuery("parentrepo:repo2", change2);
@@ -978,10 +1018,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byRepoPrefix() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change1 = insert(repo1, newChange(repo1));
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ createProject("repo2");
+ Change change1 = insert("repo1", newChange("repo1"));
+ Change change2 = insert("repo2", newChange("repo2"));
assertQuery("repos:foo");
assertQuery("repos:repo1", change1);
@@ -991,9 +1031,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byBranchAndRef() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeForBranch(repo, "master"));
- Change change2 = insert(repo, newChangeForBranch(repo, "branch"));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeForBranch(repo, "master"));
+ Change change2 = insert("repo", newChangeForBranch(repo, "branch"));
assertQuery("branch:foo");
assertQuery("branch:master", change1);
@@ -1010,26 +1050,26 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byTopic() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
- Change change1 = insert(repo, ins1);
+ Change change1 = insert("repo", ins1);
ChangeInserter ins2 = newChangeWithTopic(repo, "feature2");
- Change change2 = insert(repo, ins2);
+ Change change2 = insert("repo", ins2);
ChangeInserter ins3 = newChangeWithTopic(repo, "Cherrypick-feature2");
- Change change3 = insert(repo, ins3);
+ Change change3 = insert("repo", ins3);
ChangeInserter ins4 = newChangeWithTopic(repo, "feature2-fixup");
- Change change4 = insert(repo, ins4);
+ Change change4 = insert("repo", ins4);
ChangeInserter ins5 = newChangeWithTopic(repo, "https://gerrit.local");
- Change change5 = insert(repo, ins5);
+ Change change5 = insert("repo", ins5);
ChangeInserter ins6 = newChangeWithTopic(repo, "git_gerrit_training");
- Change change6 = insert(repo, ins6);
+ Change change6 = insert("repo", ins6);
- Change change_no_topic = insert(repo, newChange(repo));
+ Change changeNoTopic = insert("repo", newChange(repo));
assertQuery("intopic:foo");
assertQuery("intopic:feature1", change1);
@@ -1038,8 +1078,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("intopic:feature2", change4, change3, change2);
assertQuery("intopic:fixup", change4);
assertQuery("intopic:gerrit", change6, change5);
- assertQuery("topic:\"\"", change_no_topic);
- assertQuery("intopic:\"\"", change_no_topic);
+ assertQuery("topic:\"\"", changeNoTopic);
+ assertQuery("intopic:\"\"", changeNoTopic);
assume().that(getSchema().hasField(ChangeField.PREFIX_TOPIC)).isTrue();
assertQuery("prefixtopic:feature", change4, change2, change1);
@@ -1049,16 +1089,16 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byTopicRegex() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
- Change change1 = insert(repo, ins1);
+ Change change1 = insert("repo", ins1);
ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
- Change change2 = insert(repo, ins2);
+ Change change2 = insert("repo", ins2);
ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
- Change change3 = insert(repo, ins3);
+ Change change3 = insert("repo", ins3);
assertQuery("intopic:^feature1.*", change3, change1);
assertQuery("intopic:{^.*feature1$}", change2, change1);
@@ -1066,13 +1106,13 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byMessageExact() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("two").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
RevCommit commit3 = repo.parseBody(repo.commit().message("A great \"fix\" to my bug").create());
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
assertQuery("message:foo");
assertQuery("message:one", change1);
@@ -1083,16 +1123,16 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byMessageRegEx() throws Exception {
assume().that(getSchema().hasField(ChangeField.COMMIT_MESSAGE_EXACT)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("aaaabcc").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("aaaacc").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
RevCommit commit3 = repo.parseBody(repo.commit().message("Title\n\nHELLO WORLD").create());
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
RevCommit commit4 =
repo.parseBody(repo.commit().message("Title\n\nfoobar hello WORLD").create());
- Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+ Change change4 = insert("repo", newChangeForCommit(repo, commit4));
assertQuery("message:\"^aaaa(b|c)*\"", change2, change1);
assertQuery("message:\"^aaaa(c)*c.*\"", change2);
@@ -1102,12 +1142,109 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
@Test
+ public void bySubject() throws Exception {
+ assume().that(getSchema().hasField(ChangeField.SUBJECT_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
+ RevCommit commit1 =
+ repo.parseBody(
+ repo.commit()
+ .message(
+ "First commit with test subject\n\n"
+ + "Message body\n\n"
+ + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+ .create());
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+ RevCommit commit2 =
+ repo.parseBody(
+ repo.commit()
+ .message(
+ "Second commit with test subject\n\n"
+ + "Message body for another commit\n\n"
+ + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+ .create());
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+ RevCommit commit3 =
+ repo.parseBody(
+ repo.commit()
+ .message(
+ "Third commit with test subject\n\n"
+ + "Last message body\n\n"
+ + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+ .create());
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+ assertQuery("subject:First", change1);
+ assertQuery("subject:Second", change2);
+ assertQuery("subject:Third", change3);
+ assertQuery("subject:\"commit with test subject\"", change3, change2, change1);
+ assertQuery("subject:\"Message body\"");
+ assertQuery("subject:body");
+ change1 =
+ newPatchSet(
+ "repo",
+ change1,
+ user,
+ Optional.of("Rework of commit with test subject\n\n" + "Message body\n\n"));
+ assertQuery("subject:Rework", change1);
+ assertQuery("subject:First");
+ assertQuery("subject:\"commit with test subject\"", change1, change3, change2);
+ }
+
+ @Test
+ public void bySubjectPrefix() throws Exception {
+ assume().that(getSchema().hasField(ChangeField.PREFIX_SUBJECT_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
+ RevCommit commit1 =
+ repo.parseBody(
+ repo.commit()
+ .message(
+ "[FOO123] First commit with test subject\n\n"
+ + "Message body\n\n"
+ + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b920")
+ .create());
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+ RevCommit commit2 =
+ repo.parseBody(
+ repo.commit()
+ .message(
+ "[BAR45] Second commit with test subject\n\n"
+ + "Message body for another commit\n\n"
+ + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+ .create());
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+ RevCommit commit3 =
+ repo.parseBody(
+ repo.commit()
+ .message(
+ "[FOO99] Third commit with test subject\n\n"
+ + "Last message body\n\n"
+ + "Change-Id: I986c6a013dd5b3a2e8a0271c04deac2c9752b921")
+ .create());
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+
+ assertQuery("prefixsubject:\"[FOO\"", change3, change1);
+ assertQuery("prefixsubject:\"[BAR\"", change2);
+ assertQuery("prefixsubject:\"[FOO1\"", change1);
+ assertQuery("prefixsubject:\"[FOO123]\"", change1);
+ assertQuery("prefixsubject:\"[\"", change3, change2, change1);
+ assertQuery("prefixsubject:FOO");
+ change1 =
+ newPatchSet(
+ "repo",
+ change1,
+ user,
+ Optional.of("[BAR123] Rework of commit with test subject\n\n" + "Message body\n\n"));
+ assertQuery("prefixsubject:\"[FOO\"", change3);
+ assertQuery("prefixsubject:\"[BAR\"", change1, change2);
+ }
+
+ @Test
public void fullTextWithNumbers() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("12345 67890").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("12346 67891").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
assertQuery("message:1234");
assertQuery("message:12345", change1);
@@ -1116,13 +1253,13 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void fullTextMultipleTerms() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
assertQuery("message:\"Signed-off: owner\"", change1);
assertQuery("message:\"Signed\"", change2, change1);
@@ -1131,11 +1268,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byMessageMixedCase() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("Hello Gerrit").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
assertQuery("message:gerrit", change2, change1);
assertQuery("message:Gerrit", change2, change1);
@@ -1143,16 +1280,16 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byMessageSubstring() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("https://gerrit.local").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
assertQuery("message:gerrit", change1);
}
@Test
public void byLabel() throws Exception {
- accountManager.authenticate(authRequestFactory.createForUser("anotheruser"));
- TestRepository<Repo> repo = createProject("repo");
+ Account.Id anotherUser = createAccount("anotheruser");
+ repo = createAndOpenProject("repo");
ChangeInserter ins = newChange(repo);
ChangeInserter ins2 = newChange(repo);
ChangeInserter ins3 = newChange(repo);
@@ -1160,24 +1297,24 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
ChangeInserter ins5 = newChange(repo);
ChangeInserter ins6 = newChange(repo);
- Change reviewMinus2Change = insert(repo, ins);
+ Change reviewMinus2Change = insert("repo", ins);
gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
- Change reviewMinus1Change = insert(repo, ins2);
+ Change reviewMinus1Change = insert("repo", ins2);
gApi.changes().id(reviewMinus1Change.getId().get()).current().review(ReviewInput.dislike());
- Change noLabelChange = insert(repo, ins3);
+ Change noLabelChange = insert("repo", ins3);
- Change reviewPlus1Change = insert(repo, ins4);
+ Change reviewPlus1Change = insert("repo", ins4);
gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
- Change reviewTwoPlus1Change = insert(repo, ins5);
+ Change reviewTwoPlus1Change = insert("repo", ins5);
gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
requestContext.setContext(newRequestContext(createAccount("user1")));
gApi.changes().id(reviewTwoPlus1Change.getId().get()).current().review(ReviewInput.recommend());
requestContext.setContext(newRequestContext(userId));
- Change reviewPlus2Change = insert(repo, ins6);
+ Change reviewPlus2Change = insert("repo", ins6);
gApi.changes().id(reviewPlus2Change.getId().get()).current().review(ReviewInput.approve());
Map<String, Short> m =
@@ -1241,9 +1378,15 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("label:Code-Review<=-2", reviewMinus2Change);
assertQuery("label:Code-Review<-2");
- assertQuery("label:Code-Review=+1,anotheruser");
- assertQuery("label:Code-Review=+1,user", reviewTwoPlus1Change, reviewPlus1Change);
- assertQuery("label:Code-Review=+1,user=user", reviewTwoPlus1Change, reviewPlus1Change);
+ assertQuery("label:Code-Review=+1," + anotherUser);
+ assertQuery(
+ String.format("label:Code-Review=+1,%s", userAccount.preferredEmail()),
+ reviewTwoPlus1Change,
+ reviewPlus1Change);
+ assertQuery(
+ String.format("label:Code-Review=+1,user=%s", userAccount.preferredEmail()),
+ reviewTwoPlus1Change,
+ reviewPlus1Change);
assertQuery("label:Code-Review=+1,Administrators", reviewTwoPlus1Change, reviewPlus1Change);
assertQuery(
"label:Code-Review=+1,group=Administrators", reviewTwoPlus1Change, reviewPlus1Change);
@@ -1306,13 +1449,21 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
// "count" and "group" args cannot be used simultaneously.
assertThrows(
BadRequestException.class, () -> assertQuery("label:Code-Review=+1,group=gerrit,count=2"));
+
+ // "non_contributor arg for the label operator is not allowed in change queries
+ thrown =
+ assertThrows(
+ BadRequestException.class,
+ () -> assertQuery("label:Code-Review=+2,user=non_contributor"));
+ assertThat(thrown)
+ .hasMessageThat()
+ .isEqualTo("non_contributor arg is not allowed in change queries");
}
@Test
public void byLabelMulti() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Project.NameKey project =
- Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+ Project.NameKey project = Project.nameKey("repo");
+ repo = createAndOpenProject(project.get());
LabelType verified =
label(LabelId.VERIFIED, value(1, "Passes"), value(0, "No score"), value(-1, "Failed"));
@@ -1339,25 +1490,25 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
ChangeInserter ins5 = newChange(repo);
// CR+1
- Change reviewCRplus1 = insert(repo, ins);
+ Change reviewCRplus1 = insert(project.get(), ins);
gApi.changes().id(reviewCRplus1.getId().get()).current().review(ReviewInput.recommend());
// CR+2
- Change reviewCRplus2 = insert(repo, ins2);
+ Change reviewCRplus2 = insert(project.get(), ins2);
gApi.changes().id(reviewCRplus2.getId().get()).current().review(ReviewInput.approve());
// CR+1 VR+1
- Change reviewCRplus1VRplus1 = insert(repo, ins3);
+ Change reviewCRplus1VRplus1 = insert(project.get(), ins3);
gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(ReviewInput.recommend());
gApi.changes().id(reviewCRplus1VRplus1.getId().get()).current().review(reviewVerified);
// CR+2 VR+1
- Change reviewCRplus2VRplus1 = insert(repo, ins4);
+ Change reviewCRplus2VRplus1 = insert(project.get(), ins4);
gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(ReviewInput.approve());
gApi.changes().id(reviewCRplus2VRplus1.getId().get()).current().review(reviewVerified);
// VR+1
- Change reviewVRplus1 = insert(repo, ins5);
+ Change reviewVRplus1 = insert(project.get(), ins5);
gApi.changes().id(reviewVRplus1.getId().get()).current().review(reviewVerified);
assertQuery("label:Code-Review=+1", reviewCRplus1VRplus1, reviewCRplus1);
@@ -1376,28 +1527,28 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byLabelNotOwner() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins = newChange(repo);
Account.Id user1 = createAccount("user1");
- Change reviewPlus1Change = insert(repo, ins);
+ Change reviewPlus1Change = insert("repo", ins);
// post a review with user1
requestContext.setContext(newRequestContext(user1));
gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
- assertQuery("label:Code-Review=+1,user=user1", reviewPlus1Change);
+ assertQuery("label:Code-Review=+1,user=" + user1, reviewPlus1Change);
assertQuery("label:Code-Review=+1,owner");
}
@Test
public void byLabelNonUploader() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins = newChange(repo);
Account.Id user1 = createAccount("user1");
// create a change with "user"
- Change reviewPlus1Change = insert(repo, ins);
+ Change reviewPlus1Change = insert("repo", ins);
// add a +1 vote with "user". Query doesn't match since voter is the uploader.
gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
@@ -1436,8 +1587,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byLabelGroup() throws Exception {
Account.Id user1 = createAccount("user1");
- createAccount("user2");
- TestRepository<Repo> repo = createProject("repo");
+ Account.Id user2 = createAccount("user2");
+ repo = createAndOpenProject("repo");
// create group and add users
String g1 = createGroup("group1", "Administrators");
@@ -1446,7 +1597,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
gApi.groups().id(g2).addMembers("user2");
// create a change
- Change change1 = insert(repo, newChange(repo), user1);
+ Change change1 = insert("repo", newChange(repo), user1);
// post a review with user1
requestContext.setContext(newRequestContext(user1));
@@ -1459,8 +1610,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
requestContext.setContext(newRequestContext(userId));
assertQuery("label:Code-Review=+1,group1", change1);
assertQuery("label:Code-Review=+1,group=group1", change1);
- assertQuery("label:Code-Review=+1,user=user1", change1);
- assertQuery("label:Code-Review=+1,user=user2");
+ assertQuery("label:Code-Review=+1,user=" + user1, change1);
+ assertQuery("label:Code-Review=+1,user=" + user2);
assertQuery("label:Code-Review=+1,group=group2");
}
@@ -1468,7 +1619,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void byLabelExternalGroup() throws Exception {
Account.Id user1 = createAccount("user1");
Account.Id user2 = createAccount("user2");
- TestRepository<InMemoryRepositoryManager.Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
// create group and add users
AccountGroup.UUID external_group1 = AccountGroup.uuid("testbackend:group1");
@@ -1493,8 +1644,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.addSubgroup(uuidOfGroupThatContainsExternalGroupAsSubgroup)
.create();
- Change change1 = insert(repo, newChange(repo), user1);
- Change change2 = insert(repo, newChange(repo), user1);
+ Change change1 = insert("repo", newChange(repo), user1);
+ Change change2 = insert("repo", newChange(repo), user1);
// post a review with user1 and other_user
requestContext.setContext(newRequestContext(user1));
@@ -1514,8 +1665,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
"label:Code-Review=+1,group=" + nameOfGroupThatContainsExternalGroupAsSubgroup, change1);
assertQuery(
"label:Code-Review=+1,group=" + nameOfGroupThatContainsExternalGroupAsSubSubgroup, change1);
- assertQuery("label:Code-Review=+1,user=user1", change1);
- assertQuery("label:Code-Review=+1,user=user2");
+ assertQuery("label:Code-Review=+1,user=" + user1, change1);
+ assertQuery("label:Code-Review=+1,user=" + user2);
assertQuery("label:Code-Review=+1,group=" + external_group2.get());
// Negated operator tests
@@ -1526,18 +1677,18 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery(
"-label:Code-Review=+1,group=" + nameOfGroupThatContainsExternalGroupAsSubSubgroup,
change2);
- assertQuery("-label:Code-Review=+1,user=user1", change2);
+ assertQuery("-label:Code-Review=+1,user=" + user1, change2);
assertQuery("-label:Code-Review=+1,group=" + external_group2.get(), change2, change1);
- assertQuery("-label:Code-Review=+1,user=user2", change2, change1);
+ assertQuery("-label:Code-Review=+1,user=" + user2, change2, change1);
}
@Test
public void limit() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Change last = null;
int n = 5;
for (int i = 0; i < n; i++) {
- last = insert(repo, newChange(repo));
+ last = insert("repo", newChange(repo));
}
for (int i = 1; i <= n + 2; i++) {
@@ -1562,10 +1713,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void start() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
List<Change> changes = new ArrayList<>();
for (int i = 0; i < 2; i++) {
- changes.add(insert(repo, newChange(repo)));
+ changes.add(insert("repo", newChange(repo)));
}
assertQuery("status:new", changes.get(1), changes.get(0));
@@ -1582,10 +1733,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void startWithLimit() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
List<Change> changes = new ArrayList<>();
for (int i = 0; i < 3; i++) {
- changes.add(insert(repo, newChange(repo)));
+ changes.add(insert("repo", newChange(repo)));
}
assertQuery("status:new limit:2", changes.get(2), changes.get(1));
@@ -1596,8 +1747,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void maxPages() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
QueryRequest query = newQuery("status:new").withLimit(10);
assertQuery(query, change);
@@ -1612,12 +1763,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void updateOrder() throws Exception {
resetTimeWithClockStep(2, MINUTES);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
List<ChangeInserter> inserters = new ArrayList<>();
List<Change> changes = new ArrayList<>();
for (int i = 0; i < 5; i++) {
inserters.add(newChange(repo));
- changes.add(insert(repo, inserters.get(i)));
+ changes.add(insert("repo", inserters.get(i)));
}
for (int i : ImmutableList.of(2, 0, 1, 4, 3)) {
@@ -1639,10 +1790,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void updatedOrder() throws Exception {
resetTimeWithClockStep(1, SECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins1 = newChange(repo);
- Change change1 = insert(repo, ins1);
- Change change2 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", ins1);
+ Change change2 = insert("repo", newChange(repo));
assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
assertQuery("status:new", change2, change1);
@@ -1660,12 +1811,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void filterOutMoreThanOnePageOfResults() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo), userId);
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo), userId);
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
for (int i = 0; i < 5; i++) {
- insert(repo, newChange(repo), user2);
+ insert("repo", newChange(repo), user2);
}
assertQuery("status:new ownerin:Administrators", change);
@@ -1674,11 +1825,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void filterOutAllResults() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
for (int i = 0; i < 5; i++) {
- insert(repo, newChange(repo), user2);
+ insert("repo", newChange(repo), user2);
}
assertQuery("status:new ownerin:Administrators");
@@ -1687,8 +1838,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byFileExact() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
assertQuery("file:file");
assertQuery("file:dir", change);
@@ -1700,8 +1851,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byFileRegex() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
assertQuery("file:.*file.*");
assertQuery("file:^file.*"); // Whole path only.
@@ -1710,8 +1861,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byPathExact() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
assertQuery("path:file");
assertQuery("path:dir");
@@ -1723,8 +1874,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byPathRegex() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChangeWithFiles(repo, "dir/file1", "dir/file2"));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChangeWithFiles(repo, "dir/file1", "dir/file2"));
assertQuery("path:.*file.*");
assertQuery("path:^dir.file.*", change);
@@ -1732,12 +1883,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byExtension() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc"));
- Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC"));
- Change change3 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
- Change change4 = insert(repo, newChangeWithFiles(repo, "Quux.java", "foo"));
- Change change5 = insert(repo, newChangeWithFiles(repo, "foo"));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc"));
+ Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC"));
+ Change change3 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+ Change change4 = insert("repo", newChangeWithFiles(repo, "Quux.java", "foo"));
+ Change change5 = insert("repo", newChangeWithFiles(repo, "foo"));
assertQuery("extension:java", change4);
assertQuery("ext:java", change4);
@@ -1753,14 +1904,14 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byOnlyExtensions() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
- Change change2 = insert(repo, newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
- Change change3 = insert(repo, newChangeWithFiles(repo, "foo.CC", "bar.cc"));
- Change change4 = insert(repo, newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
- Change change5 = insert(repo, newChangeWithFiles(repo, "Quux.java"));
- Change change6 = insert(repo, newChangeWithFiles(repo, "foo.txt", "foo"));
- Change change7 = insert(repo, newChangeWithFiles(repo, "foo"));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWithFiles(repo, "foo.h", "foo.cc", "bar.cc"));
+ Change change2 = insert("repo", newChangeWithFiles(repo, "bar.H", "bar.CC", "foo.H"));
+ Change change3 = insert("repo", newChangeWithFiles(repo, "foo.CC", "bar.cc"));
+ Change change4 = insert("repo", newChangeWithFiles(repo, "dir/baz.h", "dir/baz.cc"));
+ Change change5 = insert("repo", newChangeWithFiles(repo, "Quux.java"));
+ Change change6 = insert("repo", newChangeWithFiles(repo, "foo.txt", "foo"));
+ Change change7 = insert("repo", newChangeWithFiles(repo, "foo"));
// case doesn't matter
assertQuery("onlyextensions:cc,h", change4, change2, change1);
@@ -1800,23 +1951,23 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byFooter() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nfoo: baz").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
RevCommit commit3 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar\nfoo:baz").create());
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
RevCommit commit4 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar=baz").create());
- Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+ Change change4 = insert("repo", newChangeForCommit(repo, commit4));
// create a changes with lines that look like footers, but which are not
RevCommit commit5 =
repo.parseBody(
repo.commit().message("Test\n\nfoo: bar\n\nfoo=bar").insertChangeId().create());
- Change change5 = insert(repo, newChangeForCommit(repo, commit5));
+ Change change5 = insert("repo", newChangeForCommit(repo, commit5));
RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
- insert(repo, newChangeForCommit(repo, commit6));
+ insert("repo", newChangeForCommit(repo, commit6));
// matching by 'key=value' works
assertQuery("footer:foo=bar", change3, change1);
@@ -1850,15 +2001,15 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byFooterName() throws Exception {
assume().that(getSchema().hasField(ChangeField.FOOTER_NAME)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("Test\n\nfoo: bar").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("Test\n\nBaR: baz").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
// create a changes with lines that look like footers, but which are not
RevCommit commit6 = repo.parseBody(repo.commit().message("Test\n\na=b: c").create());
- insert(repo, newChangeForCommit(repo, commit6));
+ insert("repo", newChangeForCommit(repo, commit6));
// matching by 'key=value' works
assertQuery("hasfooter:foo", change1);
@@ -1870,14 +2021,14 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byDirectory() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
- Change change2 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWithFiles(repo, "src/foo.h", "src/foo.cc"));
+ Change change2 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
Change change3 =
- insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
- Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
- Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
- Change change6 = insert(repo, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
+ insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+ Change change4 = insert("repo", newChangeWithFiles(repo, "a.txt"));
+ Change change5 = insert("repo", newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+ Change change6 = insert("repo", newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
// matching by directory prefix works
assertQuery("directory:src", change2, change1);
@@ -1938,10 +2089,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byDirectoryRegex() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWithFiles(repo, "src/java/foo.java", "src/js/bar.js"));
Change change2 =
- insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
+ insert("repo", newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
// match by regexp
assertQuery("directory:^.*va.*", change1);
@@ -1951,9 +2102,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byComment() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ChangeInserter ins = newChange(repo);
- Change change = insert(repo, ins);
+ Change change = insert("repo", ins);
ReviewInput input = new ReviewInput();
input.message = "toplevel";
@@ -1981,11 +2132,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void byAge() throws Exception {
long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
long startMs = TestTimeUtil.START.toEpochMilli();
- Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+ Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
Change change2 =
- insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+ insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
// Stop time so age queries use the same endpoint.
TestTimeUtil.setClockStep(0, MILLISECONDS);
@@ -2022,11 +2173,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void byBeforeUntil() throws Exception {
long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
long startMs = TestTimeUtil.START.toEpochMilli();
- Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+ Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
Change change2 =
- insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+ insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
TestTimeUtil.setClockStep(0, MILLISECONDS);
// Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2074,11 +2225,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void byAfterSince() throws Exception {
long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
resetTimeWithClockStep(thirtyHoursInMs, MILLISECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
long startMs = TestTimeUtil.START.toEpochMilli();
- Change change1 = insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs));
+ Change change1 = insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs));
Change change2 =
- insert(repo, newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
+ insert("repo", newChange(repo), null, Instant.ofEpochMilli(startMs + thirtyHoursInMs));
TestTimeUtil.setClockStep(0, MILLISECONDS);
// Change1 was last updated on 2009-09-30 21:00:00 -0000
@@ -2113,17 +2264,17 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byMergedBefore() throws Exception {
- assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+ assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
// Stop the clock, will set time to specific test values.
resetTimeWithClockStep(0, MILLISECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
long startMs = TestTimeUtil.START.toEpochMilli();
TestTimeUtil.setClock(new Timestamp(startMs));
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
submit(change3);
@@ -2173,17 +2324,17 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byMergedAfter() throws Exception {
- assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+ assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
// Stop the clock, will set time to specific test values.
resetTimeWithClockStep(0, MILLISECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
long startMs = TestTimeUtil.START.toEpochMilli();
TestTimeUtil.setClock(new Timestamp(startMs));
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
assertThat(TimeUtil.nowMs()).isEqualTo(startMs);
TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
@@ -2243,18 +2394,18 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void updatedThenMergedOrder() throws Exception {
- assume().that(getSchema().hasField(ChangeField.MERGED_ON)).isTrue();
+ assume().that(getSchema().hasField(ChangeField.MERGED_ON_SPEC)).isTrue();
long thirtyHoursInMs = MILLISECONDS.convert(30, HOURS);
// Stop the clock, will set time to specific test values.
resetTimeWithClockStep(0, MILLISECONDS);
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
long startMs = TestTimeUtil.START.toEpochMilli();
TestTimeUtil.setClock(new Timestamp(startMs));
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
TestTimeUtil.setClock(new Timestamp(startMs + thirtyHoursInMs));
submit(change2);
@@ -2281,15 +2432,15 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void bySize() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
// added = 3, deleted = 0, delta = 3
RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "foo\n\foo\nfoo").create());
// added = 0, deleted = 2, delta = 2
RevCommit commit2 = repo.parseBody(repo.commit().parent(commit1).add("file1", "foo").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
assertQuery("added:>4");
assertQuery("-added:<=4");
@@ -2337,9 +2488,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
private List<Change> setUpHashtagChanges() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
addHashtags(change1.getId(), "foo", "aaa-bbb-ccc");
addHashtags(change2.getId(), "foo", "bar", "a tag", "ACamelCaseTag");
@@ -2386,10 +2537,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byHashtagRegex() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
addHashtags(change1.getId(), "feature1");
addHashtags(change1.getId(), "trending");
addHashtags(change2.getId(), "Cherrypick-feature1");
@@ -2402,27 +2553,27 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byDefault() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
- Change change1 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
RevCommit commit2 = repo.parseBody(repo.commit().message("foosubject").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
RevCommit commit3 = repo.parseBody(repo.commit().add("Foo.java", "foo contents").create());
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
ChangeInserter ins4 = newChange(repo);
- Change change4 = insert(repo, ins4);
+ Change change4 = insert("repo", ins4);
ReviewInput ri4 = new ReviewInput();
ri4.message = "toplevel";
ri4.labels = ImmutableMap.of("Code-Review", (short) 1);
gApi.changes().id(change4.getId().get()).current().review(ri4);
ChangeInserter ins5 = newChangeWithTopic(repo, "feature5");
- Change change5 = insert(repo, ins5);
+ Change change5 = insert("repo", ins5);
- Change change6 = insert(repo, newChangeForBranch(repo, "branch6"));
+ Change change6 = insert("repo", newChangeForBranch(repo, "branch6"));
assertQuery(change1.getId().get(), change1);
assertQuery(ChangeTriplet.format(change1), change1);
@@ -2443,18 +2594,18 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byDefaultWithCommitPrefix() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit = repo.parseBody(repo.commit().message("message").create());
- Change change = insert(repo, newChangeForCommit(repo, commit));
+ Change change = insert("repo", newChangeForCommit(repo, commit));
assertQuery(commit.getId().getName().substring(0, 6), change);
}
@Test
public void visible() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChangePrivate(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChangePrivate(repo));
String q = "project:repo";
@@ -2508,8 +2659,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
// Switch to user3
requestContext.setContext(newRequestContext(user3));
- Change change3 = insert(repo, newChange(repo), user3);
- Change change4 = insert(repo, newChangePrivate(repo), user3);
+ Change change3 = insert("repo", newChange(repo), user3);
+ Change change4 = insert("repo", newChangePrivate(repo), user3);
// User3 can see both their changes and the first user's change
assertQuery(q + " visibleto:" + user3.get(), change4, change3, change1);
@@ -2549,9 +2700,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void visibleToSelf() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
@@ -2567,16 +2718,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byCommentBy() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- int user2 =
- accountManager
- .authenticate(authRequestFactory.createForUser("anotheruser"))
- .getAccountId()
- .get();
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Account.Id user2 = createAccount("anotheruser");
ReviewInput input = new ReviewInput();
input.message = "toplevel";
ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
@@ -2597,8 +2744,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void bySubmitRuleResult() throws Exception {
try (Registration registration =
extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
// The fake submit rule exports its ruleName as "FakeSubmitRule"
assertQuery("rule:FakeSubmitRule");
@@ -2617,17 +2764,17 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
try (Registration registration =
extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
- TestRepository<Repo> repo = createProject("repo");
- insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ insert("repo", newChange(repo));
assertQuery("rule:non-existent-rule");
}
}
@Test
public void byHasDraft() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
assertQuery("has:draft");
@@ -2659,8 +2806,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
*/
public void byHasDraftExcludesZombieDrafts() throws Exception {
Project.NameKey project = Project.nameKey("repo");
- TestRepository<Repo> repo = createProject(project.get());
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject(project.get());
+ Change change = insert("repo", newChange(repo));
Change.Id id = change.getId();
DraftInput in = new DraftInput();
@@ -2672,7 +2819,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("has:draft", change);
assertQuery("commentby:" + userId);
- try (TestRepository<Repo> allUsers =
+ try (TestRepository<Repository> allUsers =
new TestRepository<>(repoManager.openRepository(allUsersName))) {
Ref draftsRef = allUsers.getRepository().exactRef(RefNames.refsDraftComments(id, userId));
assertThat(draftsRef).isNotNull();
@@ -2696,15 +2843,15 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byHasDraftWithManyDrafts() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Change[] changesWithDrafts = new Change[30];
// unrelated change not shown in the result.
- insert(repo, newChange(repo));
+ insert("repo", newChange(repo));
for (int i = 0; i < changesWithDrafts.length; i++) {
// put the changes in reverse order since this is the order we receive them from the index.
- changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+ changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
DraftInput in = new DraftInput();
in.line = 1;
in.message = "nit: trailing whitespace";
@@ -2724,10 +2871,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStarredBy() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ insert("repo", newChange(repo));
gApi.accounts().self().starChange(change1.getId().toString());
gApi.accounts().self().starChange(change2.getId().toString());
@@ -2743,8 +2890,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStar() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
@@ -2759,11 +2906,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byStarWithManyStars() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Change[] changesWithDrafts = new Change[30];
for (int i = 0; i < changesWithDrafts.length; i++) {
// put the changes in reverse order since this is the order we receive them from the index.
- changesWithDrafts[changesWithDrafts.length - 1 - i] = insert(repo, newChange(repo));
+ changesWithDrafts[changesWithDrafts.length - 1 - i] = insert("repo", newChange(repo));
// star the change
gApi.accounts()
@@ -2777,12 +2924,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byFrom() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- Change change2 = insert(repo, newChange(repo), user2);
+ Change change2 = insert("repo", newChange(repo), user2);
ReviewInput input = new ReviewInput();
input.message = "toplevel";
@@ -2798,7 +2945,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void conflicts() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 =
repo.parseBody(
repo.commit()
@@ -2810,10 +2957,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
RevCommit commit3 =
repo.parseBody(repo.commit().add("dir/file2", "contents2 different").create());
RevCommit commit4 = repo.parseBody(repo.commit().add("file4", "contents4").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
- Change change4 = insert(repo, newChangeForCommit(repo, commit4));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
+ Change change4 = insert("repo", newChangeForCommit(repo, commit4));
assertQuery("conflicts:" + change1.getId().get(), change3);
assertQuery("conflicts:" + change2.getId().get());
@@ -2826,11 +2973,12 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
name = "change.mergeabilityComputationBehavior",
value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
public void mergeable() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ assume().that(getSchema().hasField(ChangeField.MERGEABLE_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
assertQuery("conflicts:" + change1.getId().get(), change2);
assertQuery("conflicts:" + change2.getId().get(), change1);
@@ -2853,10 +3001,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void cherrypick() throws Exception {
- assume().that(getSchema().hasField(ChangeField.CHERRY_PICK)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
+ assume().that(getSchema().hasField(ChangeField.CHERRY_PICK_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
assertQuery("is:cherrypick", change2);
assertQuery("-is:cherrypick", change1);
@@ -2864,15 +3012,15 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void merge() throws Exception {
- assume().that(getSchema().hasField(ChangeField.MERGE)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
+ assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
RevCommit commit2 = repo.parseBody(repo.commit().add("file1", "contents2").create());
RevCommit commit3 =
repo.parseBody(repo.commit().parent(commit2).add("file1", "contents3").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
RevCommit mergeCommit =
repo.branch("master")
.commit()
@@ -2881,7 +3029,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.parent(commit3)
.insertChangeId()
.create();
- Change mergeChange = insert(repo, newChangeForCommit(repo, mergeCommit));
+ Change mergeChange = insert("repo", newChangeForCommit(repo, mergeCommit));
assertQuery("status:open is:merge", mergeChange);
assertQuery("status:open -is:merge", change3, change2, change1);
@@ -2891,10 +3039,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void reviewedBy() throws Exception {
resetTimeWithClockStep(2, MINUTES);
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
@@ -2905,7 +3053,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
PatchSet.Id ps3_1 = change3.currentPatchSetId();
- change3 = newPatchSet(repo, change3, user);
+ change3 = newPatchSet("repo", change3, user, /* message= */ Optional.empty());
assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
// Response to previous patch set still counts as reviewing.
gApi.changes()
@@ -2932,11 +3080,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void reviewerAndCc() throws Exception {
Account.Id user1 = createAccount("user1");
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
- insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
+ insert("repo", newChange(repo));
ReviewerInput rin = new ReviewerInput();
rin.reviewer = user1.toString();
@@ -2963,11 +3111,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byReviewed() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Account.Id otherUser =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
assertQuery("is:reviewed");
assertQuery("status:reviewed");
@@ -2991,11 +3139,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
Account.Id user3 =
accountManager.authenticate(authRequestFactory.createForUser("user3")).getAccountId();
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
ReviewerInput rin = new ReviewerInput();
rin.reviewer = user1.toString();
@@ -3035,7 +3183,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void reviewerAndCcByEmail() throws Exception {
Project.NameKey project = Project.nameKey("repo");
- TestRepository<Repo> repo = createProject(project.get());
+ repo = createAndOpenProject(project.get());
ConfigInput conf = new ConfigInput();
conf.enableReviewerByEmail = InheritableBoolean.TRUE;
gApi.projects().name(project.get()).config(conf);
@@ -3043,9 +3191,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
String userByEmail = "un.registered@reviewer.com";
String userByEmailWithName = "John Doe <" + userByEmail + ">";
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ insert("repo", newChange(repo));
ReviewerInput rin = new ReviewerInput();
rin.reviewer = userByEmailWithName;
@@ -3068,16 +3216,16 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
Project.NameKey project = Project.nameKey("repo");
- TestRepository<Repo> repo = createProject(project.get());
+ repo = createAndOpenProject(project.get());
ConfigInput conf = new ConfigInput();
conf.enableReviewerByEmail = InheritableBoolean.TRUE;
gApi.projects().name(project.get()).config(conf);
String userByEmail = "John Doe <un.registered@reviewer.com>";
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- insert(repo, newChange(repo));
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ insert("repo", newChange(repo));
ReviewerInput rin = new ReviewerInput();
rin.reviewer = userByEmail;
@@ -3096,9 +3244,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void submitRecords() throws Exception {
Account.Id user1 = createAccount("user1");
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
gApi.changes().id(change1.getId().get()).current().review(ReviewInput.approve());
requestContext.setContext(newRequestContext(user1));
@@ -3109,7 +3257,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("-is:submittable", change2);
assertQuery("label:CodE-RevieW=ok", change1);
- assertQuery("label:CodE-RevieW=ok,user=user", change1);
+ assertQuery("label:CodE-RevieW=ok,user=" + userAccount.preferredEmail(), change1);
assertQuery("label:CodE-RevieW=ok,Administrators", change1);
assertQuery("label:CodE-RevieW=ok,group=Administrators", change1);
assertQuery("label:CodE-RevieW=ok,owner", change1);
@@ -3128,10 +3276,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void hasEdit() throws Exception {
Account.Id user1 = createAccount("user1");
Account.Id user2 = createAccount("user2");
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
String changeId1 = change1.getKey().get();
- Change change2 = insert(repo, newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
String changeId2 = change2.getKey().get();
requestContext.setContext(newRequestContext(user1));
@@ -3152,10 +3300,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byUnresolved() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
- Change change3 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
+ Change change3 = insert("repo", newChange(repo));
// Change1 has one resolved comment (unresolvedcount = 0)
// Change2 has one unresolved comment (unresolvedcount = 1)
@@ -3183,13 +3331,13 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byCommitsOnBranchNotMerged() throws Exception {
- TestRepository<Repo> tr = createProject("repo");
- testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+ createProject("repo");
+ testByCommitsOnBranchNotMerged("repo", ImmutableSet.of());
}
@Test
public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
ObjectId missing =
repo.branch(PatchSet.id(Change.id(987654), 1).toRefName())
.commit()
@@ -3197,72 +3345,75 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.insertChangeId()
.create()
.copy();
- testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+ testByCommitsOnBranchNotMerged("repo", ImmutableSet.of(missing));
}
- private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+ private void testByCommitsOnBranchNotMerged(String repo, Collection<ObjectId> extra)
throws Exception {
int n = 10;
List<String> shas = new ArrayList<>(n + extra.size());
extra.forEach(i -> shas.add(i.name()));
List<Integer> expectedIds = new ArrayList<>(n);
BranchNameKey dest = null;
- for (int i = 0; i < n; i++) {
- ChangeInserter ins = newChange(repo);
- insert(repo, ins);
- if (dest == null) {
- dest = ins.getChange().getDest();
+ try (TestRepository<Repository> repository =
+ new TestRepository<>(repoManager.openRepository(Project.nameKey(repo)))) {
+ for (int i = 0; i < n; i++) {
+ ChangeInserter ins = newChange(repository);
+ insert("repo", ins);
+ if (dest == null) {
+ dest = ins.getChange().getDest();
+ }
+ shas.add(ins.getCommitId().name());
+ expectedIds.add(ins.getChange().getId().get());
}
- shas.add(ins.getCommitId().name());
- expectedIds.add(ins.getChange().getId().get());
}
-
- for (int i = 1; i <= 11; i++) {
- Iterable<ChangeData> cds =
- queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), dest, shas, i);
- Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
- String name = "limit " + i;
- assertWithMessage(name).that(ids).hasSize(n);
- assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+ try (Repository repository = repoManager.openRepository(Project.nameKey(repo))) {
+ for (int i = 1; i <= 11; i++) {
+ Iterable<ChangeData> cds =
+ queryProvider.get().byCommitsOnBranchNotMerged(repository, dest, shas, i);
+ Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
+ String name = "limit " + i;
+ assertWithMessage(name).that(ids).hasSize(n);
+ assertWithMessage(name).that(ids).containsExactlyElementsIn(expectedIds);
+ }
}
}
@Test
public void reindexIfStale() throws Exception {
- Account.Id user = createAccount("user");
Project.NameKey project = Project.nameKey("repo");
- TestRepository<Repo> repo = createProject(project.get());
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject(project.get());
+ Change change = insert("repo", newChange(repo));
String changeId = change.getKey().get();
- ChangeNotes notes = notesFactory.create(change.getProject(), change.getId());
- PatchSet ps = psUtil.get(notes, change.currentPatchSetId());
- requestContext.setContext(newRequestContext(user));
- gApi.changes().id(changeId).edit().create();
- assertQuery("has:edit", change);
+ Account.Id anotherUser = createAccount("another-user");
+ requestContext.setContext(newRequestContext(anotherUser));
+ gApi.changes().id(changeId).addReviewer(anotherUser.toString());
+
+ assertQuery("reviewer:self", change);
assertThat(indexer.reindexIfStale(project, change.getId()).get()).isFalse();
- // Delete edit ref behind index's back.
- RefUpdate ru = repo.getRepository().updateRef(RefNames.refsEdit(user, change.getId(), ps.id()));
- ru.setForceUpdate(true);
- assertThat(ru.delete()).isEqualTo(RefUpdate.Result.FORCED);
+ // Remove reviewer behind index's back.
+ ChangeUpdate update = newUpdate(change);
+ update.removeReviewer(anotherUser);
+ update.commit();
// Index is stale.
- assertQuery("has:edit", change);
+ assertQuery("reviewer:self", change);
assertThat(indexer.reindexIfStale(project, change.getId()).get()).isTrue();
- assertQuery("has:edit");
+ assertQuery("reviewer:self");
}
@Test
public void watched() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
- Change change1 = insert(repo, ins1);
+ createProject("repo");
+ ChangeInserter ins1 = newChangeWithStatus("repo", Change.Status.NEW);
+ Change change1 = insert("repo", ins1);
- TestRepository<Repo> repo2 = createProject("repo2");
+ createProject("repo2");
- ChangeInserter ins2 = newChangeWithStatus(repo2, Change.Status.NEW);
- insert(repo2, ins2);
+ ChangeInserter ins2 = newChangeWithStatus("repo2", Change.Status.NEW);
+ insert("repo2", ins2);
assertQuery("is:watched");
@@ -3282,17 +3433,17 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void trackingid() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 =
repo.parseBody(repo.commit().message("Change one\n\nBug:QUERY123").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 =
repo.parseBody(repo.commit().message("Change two\n\nIssue: Issue 16038\n").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
RevCommit commit3 =
repo.parseBody(repo.commit().message("Change two\n\nGoogle-Bug-Id: b/16039\n").create());
- Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+ Change change3 = insert("repo", newChangeForCommit(repo, commit3));
assertQuery("tr:QUERY123", change1);
assertQuery("bug:QUERY123", change1);
@@ -3318,9 +3469,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void revertOf() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
// Create two commits and revert second commit (initial commit can't be reverted)
- Change initial = insert(repo, newChange(repo));
+ Change initial = insert("repo", newChange(repo));
gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
gApi.changes().id(initial.getChangeId()).current().submit();
@@ -3335,10 +3486,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void submissionId() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
// create irrelevant change
- insert(repo, newChange(repo));
+ insert("repo", newChange(repo));
gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve());
gApi.changes().id(change.getChangeId()).current().submit();
String submissionId = gApi.changes().id(change.getChangeId()).get().submissionId;
@@ -3356,7 +3507,6 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
private boolean wip;
private boolean abandoned;
@Nullable private Account.Id mergedBy;
- @Nullable private Account.Id assigneeId;
@Nullable Change.Id id;
@@ -3368,11 +3518,6 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
deleteDraftCommentBy = new ArrayList<>();
}
- DashboardChangeState assignTo(Account.Id assigneeId) {
- this.assigneeId = assigneeId;
- return this;
- }
-
DashboardChangeState wip() {
wip = true;
return this;
@@ -3408,16 +3553,11 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
return this;
}
- DashboardChangeState create(TestRepository<Repo> repo) throws Exception {
+ DashboardChangeState create(TestRepository<Repository> repo) throws Exception {
requestContext.setContext(newRequestContext(ownerId));
- Change change = insert(repo, newChange(repo), ownerId);
+ Change change = insert("repo", newChange(repo), ownerId);
id = change.getId();
ChangeApi cApi = gApi.changes().id(change.getChangeId());
- if (assigneeId != null) {
- AssigneeInput in = new AssigneeInput();
- in.assignee = "" + assigneeId;
- cApi.setAssignee(in);
- }
if (wip) {
cApi.setWorkInProgress();
}
@@ -3478,7 +3618,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void dashboardHasUnpublishedDrafts() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Account.Id otherAccountId = createAccount("other");
DashboardChangeState hasUnpublishedDraft =
new DashboardChangeState(otherAccountId).draftCommentBy(user.getAccountId()).create(repo);
@@ -3495,35 +3635,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
@Test
- public void dashboardAssignedReviews() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Account.Id otherAccountId = createAccount("other");
- DashboardChangeState otherOpenWip =
- new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
- DashboardChangeState selfOpenWip =
- new DashboardChangeState(user.getAccountId())
- .wip()
- .assignTo(user.getAccountId())
- .create(repo);
-
- // Create changes that should not be returned by query.
- new DashboardChangeState(user.getAccountId()).assignTo(user.getAccountId()).abandon();
- new DashboardChangeState(user.getAccountId())
- .assignTo(user.getAccountId())
- .mergeBy(user.getAccountId());
-
- assertDashboardQuery(
- "self", IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
-
- // Viewing another user's dashboard.
- requestContext.setContext(newRequestContext(otherAccountId));
- assertDashboardQuery(
- user.getUserName().get(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
- }
-
- @Test
public void dashboardWorkInProgressReviews() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
DashboardChangeState ownedOpenWip =
new DashboardChangeState(user.getAccountId()).wip().create(repo);
@@ -3538,7 +3651,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void dashboardOutgoingReviews() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Account.Id otherAccountId = createAccount("other");
DashboardChangeState ownedOpenReviewable =
new DashboardChangeState(user.getAccountId()).create(repo);
@@ -3553,23 +3666,18 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
assertDashboardQuery(
- user.getUserName().get(),
- IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
- ownedOpenReviewable);
+ userId.toString(), IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
}
@Test
public void dashboardIncomingReviews() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Account.Id otherAccountId = createAccount("other");
DashboardChangeState reviewingReviewable =
new DashboardChangeState(otherAccountId).addReviewer(user.getAccountId()).create(repo);
- DashboardChangeState assignedReviewable =
- new DashboardChangeState(otherAccountId).assignTo(user.getAccountId()).create(repo);
// Create changes that should not be returned by any queries in this test.
new DashboardChangeState(otherAccountId).wip().addReviewer(user.getAccountId()).create(repo);
- new DashboardChangeState(otherAccountId).wip().assignTo(user.getAccountId()).create(repo);
new DashboardChangeState(otherAccountId).addReviewer(otherAccountId).create(repo);
new DashboardChangeState(otherAccountId)
.addReviewer(user.getAccountId())
@@ -3577,24 +3685,17 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.create(repo);
// Viewing one's own dashboard.
- assertDashboardQuery(
- "self",
- IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
- assignedReviewable,
- reviewingReviewable);
+ assertDashboardQuery("self", IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY, reviewingReviewable);
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
assertDashboardQuery(
- user.getUserName().get(),
- IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
- assignedReviewable,
- reviewingReviewable);
+ userId.toString(), IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY, reviewingReviewable);
}
@Test
public void dashboardRecentlyClosedReviews() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
Account.Id otherAccountId = createAccount("other");
DashboardChangeState mergedOwned =
new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
@@ -3608,11 +3709,6 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.addCc(user.getAccountId())
.mergeBy(user.getAccountId())
.create(repo);
- DashboardChangeState mergedAssigned =
- new DashboardChangeState(otherAccountId)
- .assignTo(user.getAccountId())
- .mergeBy(user.getAccountId())
- .create(repo);
DashboardChangeState abandonedOwned =
new DashboardChangeState(user.getAccountId()).abandon().create(repo);
DashboardChangeState abandonedOwnedWip =
@@ -3622,17 +3718,6 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
.addReviewer(user.getAccountId())
.abandon()
.create(repo);
- DashboardChangeState abandonedAssigned =
- new DashboardChangeState(otherAccountId)
- .assignTo(user.getAccountId())
- .abandon()
- .create(repo);
- DashboardChangeState abandonedAssignedWip =
- new DashboardChangeState(otherAccountId)
- .assignTo(user.getAccountId())
- .wip()
- .abandon()
- .create(repo);
// Create changes that should not be returned by any queries in this test.
new DashboardChangeState(otherAccountId)
@@ -3645,11 +3730,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertDashboardQuery(
"self",
IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
- abandonedAssigned,
abandonedReviewing,
abandonedOwnedWip,
abandonedOwned,
- mergedAssigned,
mergedCced,
mergedReviewing,
mergedOwned);
@@ -3657,13 +3740,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
assertDashboardQuery(
- user.getUserName().get(),
+ userId.toString(),
IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
- abandonedAssignedWip,
- abandonedAssigned,
abandonedReviewing,
abandonedOwned,
- mergedAssigned,
mergedCced,
mergedReviewing,
mergedOwned);
@@ -3673,9 +3753,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
public void attentionSetIndexed() throws Exception {
assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChange(repo));
AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
@@ -3684,7 +3764,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertQuery("-is:attention", change2);
assertQuery("has:attention", change1);
assertQuery("-has:attention", change2);
- assertQuery("attention:" + user.getUserName().get(), change1);
+ assertQuery("attention:" + userAccount.preferredEmail(), change1);
assertQuery("-attention:" + userId.toString(), change2);
gApi.changes()
@@ -3697,8 +3777,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void attentionSetStored() throws Exception {
assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3724,31 +3804,13 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertThat(changeInfo.attentionSet.get(user2Id.get()).reason).isEqualTo("reason 2");
}
- @Test
- public void assignee() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChange(repo));
-
- AssigneeInput input = new AssigneeInput();
- input.assignee = user.getUserName().get();
- gApi.changes().id(change1.getChangeId()).setAssignee(input);
-
- assertQuery("is:assigned", change1);
- assertQuery("-is:assigned", change2);
- assertQuery("is:unassigned", change2);
- assertQuery("-is:unassigned", change1);
- assertQuery("assignee:" + user.getUserName().get(), change1);
- assertQuery("-assignee:" + user.getUserName().get(), change2);
- }
-
@GerritConfig(name = "accounts.visibility", value = "NONE")
@Test
public void userDestination() throws Exception {
- TestRepository<Repo> repo1 = createProject("repo1");
- Change change1 = insert(repo1, newChange(repo1));
- TestRepository<Repo> repo2 = createProject("repo2");
- Change change2 = insert(repo2, newChange(repo2));
+ createProject("repo1");
+ Change change1 = insert("repo1", newChange("repo1"));
+ createProject("repo2");
+ Change change2 = insert("repo2", newChange("repo2"));
assertThatQueryException("destination:foo")
.hasMessageThat()
@@ -3762,7 +3824,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
String destination4 = "refs/heads/master\trepo3";
String destination5 = "refs/heads/other\trepo1";
- try (TestRepository<Repo> allUsers =
+ try (TestRepository<Repository> allUsers =
new TestRepository<>(repoManager.openRepository(allUsersName))) {
String refsUsers = RefNames.refsUsers(userId);
allUsers.branch(refsUsers).commit().add("destinations/destination1", destination1).create();
@@ -3813,26 +3875,25 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
assertThatQueryException("destination:destination3,user=" + anotherUserId)
.hasMessageThat()
.isEqualTo("Unknown named destination: destination3");
- assertThatQueryException("destination:destination3,user=test")
+ assertThatQueryException("destination:destination3,user=non-existent")
.hasMessageThat()
- .isEqualTo("Account 'test' not found");
+ .isEqualTo("Account 'non-existent' not found");
requestContext.setContext(newRequestContext(anotherUserId));
- // account 1000000 is not visible to 'anotheruser' as they are not an admin
+ // account userId is not visible to 'anotheruser' as they are not an admin
assertThatQueryException("destination:destination3,user=" + userId)
.hasMessageThat()
- .isEqualTo("Account '1000000' not found");
+ .isEqualTo(String.format("Account '%s' not found", userId));
}
@GerritConfig(name = "accounts.visibility", value = "NONE")
@Test
public void userQuery() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo));
- Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
+ repo = createAndOpenProject("repo");
+ Change change1 = insert("repo", newChange(repo));
+ Change change2 = insert("repo", newChangeForBranch(repo, "stable"));
- Account.Id anotherUserId =
- accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+ Account.Id anotherUserId = createAccount("anotheruser");
String queryListText =
"query1\tproject:repo\n"
+ "query2\tproject:repo status:open\n"
@@ -3844,7 +3905,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
+ "query7\tproject:repo branch:stable\n"
+ "query8\tproject:repo branch:other";
- try (TestRepository<Repo> allUsers =
+ try (TestRepository<Repository> allUsers =
new TestRepository<>(repoManager.openRepository(allUsersName));
MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
@@ -3858,19 +3919,20 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
anotherQueries.commit(anotherMd);
}
+ assertThat(gApi.accounts().self().get()._accountId).isEqualTo(userId.get());
assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
assertThatQueryException("query:query1,user=" + anotherUserId)
.hasMessageThat()
.isEqualTo("Unknown named query: query1");
- assertThatQueryException("query:query1,user=test")
+ assertThatQueryException("query:query1,user=non-existent")
.hasMessageThat()
- .isEqualTo("Account 'test' not found");
+ .isEqualTo("Account 'non-existent' not found");
requestContext.setContext(newRequestContext(anotherUserId));
// account 1000000 is not visible to 'anotheruser' as they are not an admin
assertThatQueryException("query:query1,user=" + userId)
.hasMessageThat()
- .isEqualTo("Account '1000000' not found");
+ .isEqualTo(String.format("Account '%s' not found", userId));
requestContext.setContext(newRequestContext(userId));
assertQuery("query:query1", change2, change1);
@@ -3888,17 +3950,9 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
@Test
- public void byOwnerInvalidQuery() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- insert(repo, newChange(repo), userId);
- String nameEmail = user.asIdentifiedUser().getNameEmail();
- assertQuery("owner: \"" + nameEmail + "\"\\");
- }
-
- @Test
public void byDeletedChange() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
String query = "change:" + change.getId();
assertQuery(query, change);
@@ -3909,8 +3963,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void byUrlEncodedProject() throws Exception {
- TestRepository<Repo> repo = createProject("repo+foo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo+foo");
+ Change change = insert("repo+foo", newChange(repo));
assertQuery("project:repo+foo", change);
}
@@ -3942,10 +3996,10 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void isPureRevert() throws Exception {
- assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT)).isTrue();
- TestRepository<Repo> repo = createProject("repo");
+ assume().that(getSchema().hasField(ChangeField.IS_PURE_REVERT_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
// Create two commits and revert second commit (initial commit can't be reverted)
- Change initial = insert(repo, newChange(repo));
+ Change initial = insert("repo", newChange(repo));
gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
gApi.changes().id(initial.getChangeId()).current().submit();
@@ -3969,7 +4023,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void selfFailsForAnonymousUser() throws Exception {
- for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred")) {
+ for (String query : ImmutableList.of("has:star", "is:starred")) {
assertQuery(query);
RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
@@ -3989,8 +4043,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
Account.Id user2 =
accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
@@ -4005,8 +4059,8 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
public void none() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change = insert(repo, newChange(repo));
+ repo = createAndOpenProject("repo");
+ Change change = insert("repo", newChange(repo));
assertQuery(ChangeIndexPredicate.none());
@@ -4026,27 +4080,24 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
@Test
@GerritConfig(name = "change.mergeabilityComputationBehavior", value = "NEVER")
public void mergeableFailsWhenNotIndexed() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ assume().that(getSchema().hasField(ChangeField.MERGE_SPEC)).isTrue();
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().add("file1", "contents1").create());
- insert(repo, newChangeForCommit(repo, commit1));
+ insert("repo", newChangeForCommit(repo, commit1));
Throwable thrown = assertThrows(Throwable.class, () -> assertQuery("status:open is:mergeable"));
assertThat(thrown.getCause()).isInstanceOf(QueryParseException.class);
assertThat(thrown)
.hasMessageThat()
- .contains("'is:mergeable' operator is not supported by server");
+ .contains("'is:mergeable' operator is not supported on this gerrit host");
}
- protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
- return newChange(repo, null, null, null, null, null, false, false);
- }
-
- protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
+ protected ChangeInserter newChangeForCommit(TestRepository<Repository> repo, RevCommit commit)
throws Exception {
return newChange(repo, commit, null, null, null, null, false, false);
}
- protected ChangeInserter newChangeWithFiles(TestRepository<Repo> repo, String... paths)
+ protected ChangeInserter newChangeWithFiles(TestRepository<Repository> repo, String... paths)
throws Exception {
TestRepository<?>.CommitBuilder b = repo.commit().message("Change with files");
for (String path : paths) {
@@ -4055,36 +4106,67 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
return newChangeForCommit(repo, repo.parseBody(b.create()));
}
- protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
+ protected ChangeInserter newChangeForBranch(TestRepository<Repository> repo, String branch)
throws Exception {
return newChange(repo, null, branch, null, null, null, false, false);
}
- protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
- throws Exception {
+ protected ChangeInserter newChangeWithStatus(
+ TestRepository<Repository> repo, Change.Status status) throws Exception {
return newChange(repo, null, null, status, null, null, false, false);
}
- protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
+ protected ChangeInserter newChangeWithStatus(String repoName, Change.Status status)
+ throws Exception {
+ return newChange(repoName, null, null, status, null, null, false, false);
+ }
+
+ protected ChangeInserter newChangeWithTopic(TestRepository<Repository> repo, String topic)
throws Exception {
return newChange(repo, null, null, null, topic, null, false, false);
}
- protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
+ protected ChangeInserter newChangeWorkInProgress(TestRepository<Repository> repo)
+ throws Exception {
return newChange(repo, null, null, null, null, null, true, false);
}
- protected ChangeInserter newChangePrivate(TestRepository<Repo> repo) throws Exception {
+ protected ChangeInserter newChangePrivate(TestRepository<Repository> repo) throws Exception {
return newChange(repo, null, null, null, null, null, false, true);
}
protected ChangeInserter newCherryPickChange(
- TestRepository<Repo> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
+ TestRepository<Repository> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
return newChange(repo, null, branch, null, null, cherryPickOf, false, true);
}
+ protected ChangeInserter newChange(String repoName) throws Exception {
+ return newChange(repoName, null, null, null, null, null, false, false);
+ }
+
+ protected ChangeInserter newChange(
+ String repoName,
+ @Nullable RevCommit commit,
+ @Nullable String branch,
+ @Nullable Change.Status status,
+ @Nullable String topic,
+ @Nullable PatchSet.Id cherryPickOf,
+ boolean workInProgress,
+ boolean isPrivate)
+ throws Exception {
+ try (TestRepository<Repository> repo =
+ new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+ return newChange(
+ repo, commit, branch, status, topic, cherryPickOf, workInProgress, isPrivate);
+ }
+ }
+
+ protected ChangeInserter newChange(TestRepository<Repository> repo) throws Exception {
+ return newChange(repo, null, null, null, null, null, false, false);
+ }
+
protected ChangeInserter newChange(
- TestRepository<Repo> repo,
+ TestRepository<Repository> repo,
@Nullable RevCommit commit,
@Nullable String branch,
@Nullable Change.Status status,
@@ -4094,7 +4176,7 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
boolean isPrivate)
throws Exception {
if (commit == null) {
- commit = repo.parseBody(repo.commit().message("message").create());
+ commit = repo.parseBody(repo.commit().message("initial message").create());
}
branch = MoreObjects.firstNonNull(branch, "refs/heads/master");
@@ -4103,65 +4185,78 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
Change.Id id = Change.id(seq.nextChangeId());
- ChangeInserter ins =
- changeFactory
- .create(id, commit, branch)
- .setValidate(false)
- .setStatus(status)
- .setTopic(topic)
- .setWorkInProgress(workInProgress)
- .setPrivate(isPrivate)
- .setCherryPickOf(cherryPickOf);
- return ins;
+ return changeFactory
+ .create(id, commit, branch)
+ .setValidate(false)
+ .setStatus(status)
+ .setTopic(topic)
+ .setWorkInProgress(workInProgress)
+ .setPrivate(isPrivate)
+ .setCherryPickOf(cherryPickOf);
+ }
+
+ @CanIgnoreReturnValue
+ protected Change insert(String repoName, ChangeInserter ins, @Nullable Account.Id owner)
+ throws Exception {
+ return insert(repoName, ins, owner, TimeUtil.now());
}
- protected Change insert(TestRepository<Repo> repo, ChangeInserter ins) throws Exception {
- return insert(repo, ins, null, TimeUtil.now());
- }
-
- protected Change insert(TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner)
- throws Exception {
- return insert(repo, ins, owner, TimeUtil.now());
+ @CanIgnoreReturnValue
+ protected Change insert(String repoName, ChangeInserter ins) throws Exception {
+ return insert(repoName, ins, null, TimeUtil.now());
}
+ @CanIgnoreReturnValue
protected Change insert(
- TestRepository<Repo> repo, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
+ String repoName, ChangeInserter ins, @Nullable Account.Id owner, Instant createdOn)
throws Exception {
- Project.NameKey project =
- Project.nameKey(repo.getRepository().getDescription().getRepositoryName());
+ Project.NameKey project = Project.nameKey(repoName);
Account.Id ownerId = owner != null ? owner : userId;
IdentifiedUser user = userFactory.create(ownerId);
- try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
- bu.insertChange(ins);
- bu.execute();
- return ins.getChange();
- }
- }
+ return testRefAction(
+ () -> {
+ try (BatchUpdate bu = updateFactory.create(project, user, createdOn)) {
+ bu.insertChange(ins);
+ bu.execute();
+ return ins.getChange();
+ }
+ });
+ }
+
+ protected Change newPatchSet(
+ String repoName, Change c, CurrentUser user, Optional<String> message) throws Exception {
+ try (TestRepository<Repository> repo =
+ new TestRepository<>(repoManager.openRepository(Project.nameKey(repoName)))) {
+ // Add a new file so the patch set is not a trivial rebase, to avoid default
+ // Code-Review label copying.
+ int n = c.currentPatchSetId().get() + 1;
+ RevCommit commit =
+ repo.parseBody(
+ repo.commit()
+ .message(message.orElse("updated message"))
+ .add("file" + n, "contents " + n)
+ .create());
+
+ PatchSetInserter inserter =
+ patchSetFactory
+ .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
+ .setFireRevisionCreated(false)
+ .setValidate(false);
+ testRefAction(
+ () -> {
+ try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
+ ObjectInserter oi = repo.getRepository().newObjectInserter();
+ ObjectReader reader = oi.newReader();
+ RevWalk rw = new RevWalk(reader)) {
+ bu.setRepository(repo.getRepository(), rw, oi);
+ bu.setNotify(NotifyResolver.Result.none());
+ bu.addOp(c.getId(), inserter);
+ bu.execute();
+ }
+ });
- protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
- throws Exception {
- // Add a new file so the patch set is not a trivial rebase, to avoid default
- // Code-Review label copying.
- int n = c.currentPatchSetId().get() + 1;
- RevCommit commit =
- repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
-
- PatchSetInserter inserter =
- patchSetFactory
- .create(changeNotesFactory.createChecked(c), PatchSet.id(c.getId(), n), commit)
- .setFireRevisionCreated(false)
- .setValidate(false);
- try (BatchUpdate bu = updateFactory.create(c.getProject(), user, TimeUtil.now());
- ObjectInserter oi = repo.getRepository().newObjectInserter();
- ObjectReader reader = oi.newReader();
- RevWalk rw = new RevWalk(reader)) {
- bu.setRepository(repo.getRepository(), rw, oi);
- bu.setNotify(NotifyResolver.Result.none());
- bu.addOp(c.getId(), inserter);
- bu.execute();
+ return inserter.getChange();
}
-
- return inserter.getChange();
}
protected ThrowableSubject assertThatQueryException(Object query) throws Exception {
@@ -4186,17 +4281,27 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
}
}
- protected TestRepository<Repo> createProject(String name) throws Exception {
- gApi.projects().create(name).get();
+ @CanIgnoreReturnValue
+ protected TestRepository<Repository> createAndOpenProject(String name) throws Exception {
+ createProject(name);
return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
}
- protected TestRepository<Repo> createProject(String name, String parent) throws Exception {
+ protected TestRepository<Repository> createAndOpenProject(String name, String parent)
+ throws Exception {
+ createProject(name, parent);
+ return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
+ }
+
+ protected void createProject(String name) throws Exception {
+ gApi.projects().create(name).get();
+ }
+
+ protected void createProject(String name, String parent) throws Exception {
ProjectInput input = new ProjectInput();
input.name = name;
input.parent = parent;
gApi.projects().create(input).get();
- return new TestRepository<>(repoManager.openRepository(Project.nameKey(name)));
}
protected QueryRequest newQuery(Object query) {
@@ -4408,6 +4513,14 @@ public abstract class AbstractQueryChangesTest extends GerritServerTests {
return indexes.getSearchIndex().getSchema();
}
+ protected ChangeUpdate newUpdate(Change c) throws Exception {
+ ChangeUpdate update =
+ TestChanges.newUpdate(injector, c, Optional.empty(), /* shouldExist= */ true);
+ update.setPatchSetId(c.currentPatchSetId());
+ update.setAllowWriteToNewRef(true);
+ return update;
+ }
+
PaginationType getCurrentPaginationType() {
return config.getEnum("index", null, "paginationType", PaginationType.OFFSET);
}
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index 5124021af3..0ce00ebfc7 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -77,6 +77,7 @@ public class ChangeDataTest {
.id(PatchSet.id(changeId, num))
.commitId(ObjectId.zeroId())
.uploader(Account.id(1234))
+ .realUploader(Account.id(5678))
.createdOn(TimeUtil.now())
.build();
}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index b1faf03e7d..72fc6d270d 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -35,14 +35,13 @@ import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.index.change.ChangeIndexCollection;
import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
import com.google.inject.Guice;
import com.google.inject.Inject;
import com.google.inject.Injector;
import java.util.List;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
import org.junit.Test;
/**
@@ -64,47 +63,51 @@ public abstract class FakeQueryChangesTest extends AbstractQueryChangesTest {
@Test
@UseClockStep
- @SuppressWarnings("unchecked")
public void stopQueryIfNoMoreResults() throws Exception {
// create 2 visible changes
- TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
+ try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ }
// create 2 invisible changes
- TestRepository<Repo> hiddenProject = createProject("hiddenProject");
- insert(hiddenProject, newChange(hiddenProject));
- insert(hiddenProject, newChange(hiddenProject));
- projectOperations
- .project(Project.nameKey("hiddenProject"))
- .forUpdate()
- .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
- .update();
-
- AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
+ try (TestRepository<Repository> hiddenProject = createAndOpenProject("hiddenProject")) {
+ insert("hiddenProject", newChange(hiddenProject));
+ insert("hiddenProject", newChange(hiddenProject));
+ projectOperations
+ .project(Project.nameKey("hiddenProject"))
+ .forUpdate()
+ .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ }
+
+ AbstractFakeIndex<?, ?, ?> idx =
+ (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
newQuery("status:new").withLimit(5).get();
assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
}
@Test
@UseClockStep
- @SuppressWarnings("unchecked")
public void noLimitQueryPaginates() throws Exception {
assumeFalse(PaginationType.NONE == getCurrentPaginationType());
- TestRepository<InMemoryRepositoryManager.Repo> testRepo = createProject("repo");
- // create 4 changes
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
+ try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ }
// Set queryLimit to 2
projectOperations
.project(allProjects)
.forUpdate()
.add(allowCapability(QUERY_LIMIT).group(REGISTERED_USERS).range(0, 2))
.update();
- AbstractFakeIndex idx = (AbstractFakeIndex) changeIndexCollection.getSearchIndex();
+
+ AbstractFakeIndex<?, ?, ?> idx =
+ (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+
// 2 index searches are expected. The first index search will run with size 3 (i.e.
// the configured query-limit+1), and then we will paginate to get the remaining
// changes with the second index search.
@@ -180,17 +183,16 @@ public abstract class FakeQueryChangesTest extends AbstractQueryChangesTest {
@Test
@UseClockStep
- @SuppressWarnings("unchecked")
public void internalQueriesPaginate() throws Exception {
assumeFalse(PaginationType.NONE == getCurrentPaginationType());
final int LIMIT = 2;
- TestRepository<Repo> testRepo = createProject("repo");
- // create 4 changes
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
+ try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ }
// Set queryLimit to 2
projectOperations
.project(allProjects)
@@ -230,12 +232,12 @@ public abstract class FakeQueryChangesTest extends AbstractQueryChangesTest {
}
private AbstractFakeIndex setupRepoWithFourChanges() throws Exception {
- TestRepository<Repo> testRepo = createProject("repo");
- // create 4 changes
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
- insert(testRepo, newChange(testRepo));
+ try (TestRepository<Repository> testRepo = createAndOpenProject("repo")) {
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ insert("repo", newChange(testRepo));
+ }
// Set queryLimit to 2
projectOperations
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 9717bfb919..7f383f948b 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -24,11 +24,9 @@ import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.InMemoryRepositoryManager.Repo;
import com.google.inject.Guice;
import com.google.inject.Inject;
import com.google.inject.Injector;
-import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Test;
@@ -45,11 +43,11 @@ public abstract class LuceneQueryChangesTest extends AbstractQueryChangesTest {
@Test
public void fullTextWithSpecialChars() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
RevCommit commit1 = repo.parseBody(repo.commit().message("foo_bar_foo").create());
- Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+ Change change1 = insert("repo", newChangeForCommit(repo, commit1));
RevCommit commit2 = repo.parseBody(repo.commit().message("one.two.three").create());
- Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+ Change change2 = insert("repo", newChangeForCommit(repo, commit2));
assertQuery("message:foo_ba");
assertQuery("message:bar", change1);
@@ -61,32 +59,25 @@ public abstract class LuceneQueryChangesTest extends AbstractQueryChangesTest {
}
@Test
- @Override
- public void byOwnerInvalidQuery() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
- Change change1 = insert(repo, newChange(repo), userId);
- String nameEmail = user.asIdentifiedUser().getNameEmail();
-
+ public void invalidQuery() throws Exception {
BadRequestException thrown =
- assertThrows(
- BadRequestException.class,
- () -> assertQuery("owner: \"" + nameEmail + "\"\\", change1));
+ assertThrows(BadRequestException.class, () -> newQuery("\\").get());
assertThat(thrown).hasMessageThat().contains("Cannot create full-text query with value: \\");
}
@Test
public void openAndClosedChanges() throws Exception {
- TestRepository<Repo> repo = createProject("repo");
+ repo = createAndOpenProject("repo");
// create 3 closed changes
- Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
- Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
- Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+ Change change1 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+ Change change2 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
+ Change change3 = insert("repo", newChangeWithStatus(repo, Change.Status.MERGED));
// create 3 new changes
- Change change4 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
- Change change5 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
- Change change6 = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+ Change change4 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+ Change change5 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
+ Change change6 = insert("repo", newChangeWithStatus(repo, Change.Status.NEW));
// Set queryLimit to 1
projectOperations
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 1ca45719ce..12bafd5d71 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -23,6 +23,7 @@ import static java.util.stream.Collectors.toList;
import static org.junit.Assert.fail;
import com.google.common.base.CharMatcher;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.InternalGroup;
@@ -382,7 +383,9 @@ public abstract class AbstractQueryGroupsTest extends GerritServerTests {
.getRaw(
uuid,
QueryOptions.create(
- IndexConfig.fromConfig(config).build(),
+ config != null
+ ? IndexConfig.fromConfig(config).build()
+ : IndexConfig.createDefault(),
0,
10,
indexes.getSearchIndex().getSchema().getStoredFields()));
@@ -398,9 +401,8 @@ public abstract class AbstractQueryGroupsTest extends GerritServerTests {
String query = "uuid:" + uuid;
assertQuery(query, group);
- for (GroupIndex index : groupIndexes.getWriteIndexes()) {
- index.delete(uuid);
- }
+ deleteGroup(uuid);
+
assertQuery(query);
}
@@ -438,6 +440,10 @@ public abstract class AbstractQueryGroupsTest extends GerritServerTests {
return createGroupWithDescription(name, null, members);
}
+ protected GroupInfo createGroup(GroupInput in) throws Exception {
+ return gApi.groups().create(in).get();
+ }
+
protected GroupInfo createGroupWithDescription(
String name, String description, AccountInfo... members) throws Exception {
GroupInput in = new GroupInput();
@@ -445,21 +451,27 @@ public abstract class AbstractQueryGroupsTest extends GerritServerTests {
in.description = description;
in.members =
Arrays.asList(members).stream().map(a -> String.valueOf(a._accountId)).collect(toList());
- return gApi.groups().create(in).get();
+ return createGroup(in);
}
protected GroupInfo createGroupWithOwner(String name, GroupInfo ownerGroup) throws Exception {
GroupInput in = new GroupInput();
in.name = name;
in.ownerId = ownerGroup.id;
- return gApi.groups().create(in).get();
+ return createGroup(in);
}
protected GroupInfo createGroupThatIsVisibleToAll(String name) throws Exception {
GroupInput in = new GroupInput();
in.name = name;
in.visibleToAll = true;
- return gApi.groups().create(in).get();
+ return createGroup(in);
+ }
+
+ protected void deleteGroup(AccountGroup.UUID uuid) throws Exception {
+ for (GroupIndex index : groupIndexes.getWriteIndexes()) {
+ index.delete(uuid);
+ }
}
protected GroupInfo getGroup(AccountGroup.UUID uuid) throws Exception {
@@ -558,6 +570,7 @@ public abstract class AbstractQueryGroupsTest extends GerritServerTests {
return groups.stream().map(g -> g.id).sorted().collect(toList());
}
+ @Nullable
protected String name(String name) {
if (name == null) {
return null;
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index 0094bd6a93..550cb410ce 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -13,6 +13,7 @@ java_library(
visibility = ["//visibility:public"],
runtime_deps = ["//java/com/google/gerrit/lucene"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index c06fcde4ae..b11910498e 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -24,6 +24,7 @@ import static org.junit.Assert.fail;
import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.GerritApi;
@@ -469,6 +470,7 @@ public abstract class AbstractQueryProjectsTest extends GerritServerTests {
return projects.stream().map(p -> p.name).collect(toList());
}
+ @Nullable
protected String name(String name) {
if (name == null) {
return null;
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index f476ae6032..53f9d9d848 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -10,6 +10,7 @@ java_library(
visibility = ["//visibility:public"],
runtime_deps = ["//java/com/google/gerrit/lucene"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/index",
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index 4c8750af6b..131bd057f0 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -239,6 +239,7 @@ public class CommentPorterTest {
.id(id)
.commitId(dummyObjectId)
.uploader(Account.id(123))
+ .realUploader(Account.id(456))
.createdOn(Instant.ofEpochMilli(12345))
.build();
}
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
index a5fd4a2932..d2ccaa9a6d 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionCheckTest.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.schema;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.SitePaths;
@@ -38,7 +39,7 @@ public class NoteDbSchemaVersionCheckTest {
GitRepositoryManager repoManager = new InMemoryRepositoryManager();
repoManager.createRepository(allProjectsName);
versionManager = new NoteDbSchemaVersionManager(allProjectsName, repoManager);
- versionManager.init();
+ testRefAction(() -> versionManager.init());
sitePaths = new SitePaths(Paths.get("/tmp/foo"));
}
@@ -51,7 +52,7 @@ public class NoteDbSchemaVersionCheckTest {
@Test
public void shouldFailIfCurrentVersionIsOneMoreThanExpected() throws IOException {
- versionManager.increment(NoteDbSchemaVersions.LATEST);
+ testRefAction(() -> versionManager.increment(NoteDbSchemaVersions.LATEST));
ProvisionException e =
assertThrows(
@@ -69,7 +70,7 @@ public class NoteDbSchemaVersionCheckTest {
throws IOException {
Config gerritConfig = new Config();
gerritConfig.setBoolean("gerrit", null, "experimentalRollingUpgrade", true);
- versionManager.increment(NoteDbSchemaVersions.LATEST);
+ testRefAction(() -> versionManager.increment(NoteDbSchemaVersions.LATEST));
NoteDbSchemaVersionCheck versionCheck =
new NoteDbSchemaVersionCheck(versionManager, sitePaths, gerritConfig);
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
index 38e19f76bb..3a1ea12dbf 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionManagerTest.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.schema;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.entities.RefNames.REFS_VERSION;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.config.AllProjectsName;
@@ -62,14 +63,14 @@ public class NoteDbSchemaVersionManagerTest {
@Test
public void incrementFromMissing() throws Exception {
- manager.increment(123);
+ testRefAction(() -> manager.increment(123));
assertThat(manager.read()).isEqualTo(124);
}
@Test
public void increment() throws Exception {
tr.update(REFS_VERSION, tr.blob("123"));
- manager.increment(123);
+ testRefAction(() -> manager.increment(123));
assertThat(manager.read()).isEqualTo(124);
}
diff --git a/javatests/com/google/gerrit/server/submit/BUILD b/javatests/com/google/gerrit/server/submit/BUILD
index 7425bc8765..01acb721f7 100644
--- a/javatests/com/google/gerrit/server/submit/BUILD
+++ b/javatests/com/google/gerrit/server/submit/BUILD
@@ -11,6 +11,7 @@ junit_tests(
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/testing:gerrit-test-util",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:jgit",
"//lib/mockito",
"//lib/truth",
diff --git a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
index 313e697b43..a391c03439 100644
--- a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
+++ b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
@@ -17,6 +17,7 @@ package com.google.gerrit.server.submit;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@@ -197,7 +198,7 @@ public class SubmoduleCommitsTest {
RefUpdate ru = serverRepo.updateRef(refName);
ru.setExpectedOldObjectId(oldCommitId);
ru.setNewObjectId(newCommitId);
- assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+ testRefAction(() -> assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD));
return rw.parseCommit(newCommitId);
}
diff --git a/javatests/com/google/gerrit/server/update/BUILD b/javatests/com/google/gerrit/server/update/BUILD
index 6d96c10237..345681d276 100644
--- a/javatests/com/google/gerrit/server/update/BUILD
+++ b/javatests/com/google/gerrit/server/update/BUILD
@@ -15,6 +15,7 @@ junit_tests(
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/server/util/time",
"//java/com/google/gerrit/testing:gerrit-test-util",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
"//lib:guava",
"//lib:jgit",
"//lib:jgit-junit",
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index fcb680fe6f..ff1f6a3ccc 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -37,6 +38,7 @@ import com.google.gerrit.extensions.events.AttentionSetListener;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.InternalUser;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.AuthRequest;
@@ -47,19 +49,25 @@ import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.notedb.Sequences;
import com.google.gerrit.server.patch.DiffSummary;
import com.google.gerrit.server.patch.DiffSummaryKey;
+import com.google.gerrit.server.update.context.RefUpdateContext;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.testing.InMemoryTestEnvironment;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.name.Named;
+import java.util.ArrayList;
+import java.util.List;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -96,6 +104,7 @@ public class BatchUpdateTest {
@Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
@Inject private AccountManager accountManager;
@Inject private AuthRequest.Factory authRequestFactory;
+ @Inject private IdentifiedUser.GenericFactory userFactory;
@Inject private InternalUser.Factory internalUserFactory;
@Inject private AbandonOp.Factory abandonOpFactory;
@@ -110,12 +119,21 @@ public class BatchUpdateTest {
private Project.NameKey project;
private TestRepository<Repository> repo;
+ private RefUpdateContext testRefUpdateContext;
+
@Before
public void setUp() throws Exception {
project = Project.nameKey("test");
Repository inMemoryRepo = repoManager.createRepository(project);
repo = new TestRepository<>(inMemoryRepo);
+ // All tests here are low level. Open context here to avoid repeated code in multiple tests.
+ testRefUpdateContext = openTestRefUpdateContext();
+ }
+
+ @After
+ public void tearDown() {
+ testRefUpdateContext.close();
}
@Test
@@ -133,7 +151,6 @@ public class BatchUpdateTest {
});
bu.execute();
}
-
assertThat(repo.getRepository().exactRef("refs/heads/master").getObjectId())
.isEqualTo(branchCommit.getId());
}
@@ -379,7 +396,8 @@ public class BatchUpdateTest {
int cacheSizeBefore = diffSummaryCache.asMap().size();
- // We don't want to depend on the test helper used above so we perform an explicit commit here.
+ // We don't want to depend on the test helper used above so we perform an explicit commit
+ // here.
try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
ObjectId commitId =
repo.amend(notes.getCurrentPatchSet().commitId())
@@ -400,6 +418,165 @@ public class BatchUpdateTest {
assertThat(diffSummaryCache.asMap()).hasSize(cacheSizeBefore + 1);
}
+ @Test
+ public void executeOpsWithDifferentUsers() throws Exception {
+ Change.Id changeId = createChange();
+
+ ObjectId oldHead = getMetaId(changeId);
+
+ CurrentUser defaultUser = user.get();
+ IdentifiedUser user1 =
+ userFactory.create(
+ accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId());
+ IdentifiedUser user2 =
+ userFactory.create(
+ accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId());
+
+ TestOp testOp1 = new TestOp().addReviewer(defaultUser.getAccountId());
+ TestOp testOp2 = new TestOp().addReviewer(user1.getAccountId());
+ TestOp testOp3 = new TestOp().addReviewer(user2.getAccountId());
+
+ try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) {
+ bu.addOp(changeId, user1, testOp1);
+ bu.addOp(changeId, user2, testOp2);
+ bu.addOp(changeId, testOp3);
+ bu.execute();
+
+ PersonIdent refLogIdent = bu.getRefLogIdent().get();
+ assertThat(refLogIdent.getName())
+ .isEqualTo(
+ String.format(
+ "account-%s|account-%s|account-%s",
+ defaultUser.asIdentifiedUser().getAccountId(),
+ user1.asIdentifiedUser().getAccountId(),
+ user2.asIdentifiedUser().getAccountId()));
+ assertThat(refLogIdent.getEmailAddress())
+ .isEqualTo(String.format("%s@unknown", refLogIdent.getName()));
+ }
+
+ assertThat(testOp1.updateRepoUser).isEqualTo(user1);
+ assertThat(testOp1.updateChangeUser).isEqualTo(user1);
+ assertThat(testOp1.postUpdateUser).isEqualTo(user1);
+
+ assertThat(testOp2.updateRepoUser).isEqualTo(user2);
+ assertThat(testOp2.updateChangeUser).isEqualTo(user2);
+ assertThat(testOp2.postUpdateUser).isEqualTo(user2);
+
+ assertThat(testOp3.updateRepoUser).isEqualTo(defaultUser);
+ assertThat(testOp3.updateChangeUser).isEqualTo(defaultUser);
+ assertThat(testOp3.postUpdateUser).isEqualTo(defaultUser);
+
+ // Verify that we got one meta commit per op.
+ RevCommit metaCommitForTestOp3 = repo.getRepository().parseCommit(getMetaId(changeId));
+ assertThat(metaCommitForTestOp3.getAuthorIdent().getEmailAddress())
+ .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId()));
+ assertThat(metaCommitForTestOp3.getFullMessage())
+ .startsWith(
+ "Update patch set 1\n"
+ + "\n"
+ + "Patch-set: 1\n"
+ + String.format(
+ "Reviewer: Gerrit User %s <%s@gerrit>\n",
+ user2.getAccountId(), user2.getAccountId())
+ + "Attention:");
+
+ RevCommit metaCommitForTestOp2 =
+ repo.getRepository().parseCommit(metaCommitForTestOp3.getParent(0));
+ assertThat(metaCommitForTestOp2.getAuthorIdent().getEmailAddress())
+ .isEqualTo(String.format("%s@gerrit", user2.getAccountId()));
+ assertThat(metaCommitForTestOp2.getFullMessage())
+ .startsWith(
+ "Update patch set 1\n"
+ + "\n"
+ + "Patch-set: 1\n"
+ + String.format(
+ "Reviewer: Gerrit User %s <%s@gerrit>\n",
+ user1.getAccountId(), user1.getAccountId())
+ + "Attention:");
+
+ RevCommit metaCommitForTestOp1 =
+ repo.getRepository().parseCommit(metaCommitForTestOp2.getParent(0));
+ assertThat(metaCommitForTestOp1.getAuthorIdent().getEmailAddress())
+ .isEqualTo(String.format("%s@gerrit", user1.getAccountId()));
+ assertThat(metaCommitForTestOp1.getFullMessage())
+ .isEqualTo(
+ "Update patch set 1\n"
+ + "\n"
+ + "Patch-set: 1\n"
+ + String.format(
+ "Reviewer: Gerrit User %s <%s@gerrit>\n",
+ defaultUser.getAccountId(), defaultUser.getAccountId()));
+
+ assertThat(metaCommitForTestOp1.getParent(0)).isEqualTo(oldHead);
+ }
+
+ @Test
+ public void executeOpsWithSameUser() throws Exception {
+ Change.Id changeId = createChange();
+
+ ObjectId oldHead = getMetaId(changeId);
+
+ CurrentUser defaultUser = user.get();
+ IdentifiedUser user1 =
+ userFactory.create(
+ accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId());
+ IdentifiedUser user2 =
+ userFactory.create(
+ accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId());
+
+ TestOp testOp1 = new TestOp().addReviewer(user1.getAccountId());
+ TestOp testOp2 = new TestOp().addReviewer(user2.getAccountId());
+
+ try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) {
+ bu.addOp(changeId, defaultUser, testOp1);
+ bu.addOp(changeId, testOp2);
+ bu.execute();
+
+ PersonIdent refLogIdent = bu.getRefLogIdent().get();
+ PersonIdent defaultUserRefLogIdent = defaultUser.asIdentifiedUser().newRefLogIdent();
+ assertThat(refLogIdent.getName()).isEqualTo(defaultUserRefLogIdent.getName());
+ assertThat(refLogIdent.getEmailAddress()).isEqualTo(defaultUserRefLogIdent.getEmailAddress());
+ }
+
+ assertThat(testOp1.updateRepoUser).isEqualTo(defaultUser);
+ assertThat(testOp1.updateChangeUser).isEqualTo(defaultUser);
+ assertThat(testOp1.postUpdateUser).isEqualTo(defaultUser);
+
+ assertThat(testOp2.updateRepoUser).isEqualTo(defaultUser);
+ assertThat(testOp2.updateChangeUser).isEqualTo(defaultUser);
+ assertThat(testOp2.postUpdateUser).isEqualTo(defaultUser);
+
+ // Verify that we got a single meta commit (updates of both ops squashed into one commit).
+ RevCommit metaCommit = repo.getRepository().parseCommit(getMetaId(changeId));
+ assertThat(metaCommit.getAuthorIdent().getEmailAddress())
+ .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId()));
+ assertThat(metaCommit.getFullMessage())
+ .startsWith(
+ "Update patch set 1\n"
+ + "\n"
+ + "Patch-set: 1\n"
+ + String.format(
+ "Reviewer: Gerrit User %s <%s@gerrit>\n",
+ user1.getAccountId(), user1.getAccountId())
+ + String.format(
+ "Reviewer: Gerrit User %s <%s@gerrit>\n",
+ user2.getAccountId(), user2.getAccountId())
+ + "Attention:");
+
+ assertThat(metaCommit.getParent(0)).isEqualTo(oldHead);
+ }
+
+ private Change.Id createChange() throws Exception {
+ Change.Id id = Change.id(sequences.nextChangeId());
+ try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+ bu.insertChange(
+ changeInserterFactory.create(
+ id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master"));
+ bu.execute();
+ }
+ return id;
+ }
+
private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception {
checkArgument(totalUpdates > 0);
checkArgument(totalUpdates <= MAX_UPDATES);
@@ -494,4 +671,38 @@ public class BatchUpdateTest {
return true;
}
}
+
+ private static class TestOp implements BatchUpdateOp {
+ CurrentUser updateRepoUser;
+ CurrentUser updateChangeUser;
+ CurrentUser postUpdateUser;
+
+ private List<Account.Id> reviewersToAdd = new ArrayList<>();
+
+ TestOp addReviewer(Account.Id accountId) {
+ reviewersToAdd.add(accountId);
+ return this;
+ }
+
+ @Override
+ public void updateRepo(RepoContext ctx) {
+ updateRepoUser = ctx.getUser();
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext ctx) {
+ updateChangeUser = ctx.getUser();
+
+ reviewersToAdd.forEach(
+ accountId ->
+ ctx.getUpdate(ctx.getChange().currentPatchSetId())
+ .putReviewer(accountId, ReviewerStateInternal.REVIEWER));
+ return true;
+ }
+
+ @Override
+ public void postUpdate(PostUpdateContext ctx) {
+ postUpdateUser = ctx.getUser();
+ }
+ }
}
diff --git a/javatests/com/google/gerrit/server/update/RepoViewTest.java b/javatests/com/google/gerrit/server/update/RepoViewTest.java
index b37e302dbf..b118c9fc5d 100644
--- a/javatests/com/google/gerrit/server/update/RepoViewTest.java
+++ b/javatests/com/google/gerrit/server/update/RepoViewTest.java
@@ -16,6 +16,7 @@ package com.google.gerrit.server.update;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.TestActionRefUpdateContext.testRefAction;
import static org.eclipse.jgit.lib.Constants.R_HEADS;
import com.google.gerrit.entities.Project;
@@ -43,8 +44,14 @@ public class RepoViewTest {
InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
Project.NameKey project = Project.nameKey("project");
repo = repoManager.createRepository(project);
- tr = new TestRepository<>(repo);
- tr.branch(MASTER).commit().create();
+ tr =
+ testRefAction(
+ () -> {
+ TestRepository<?> testRepo = new TestRepository<>(repo);
+ testRepo.branch(MASTER).commit().create();
+ return testRepo;
+ });
+
view = new RepoView(repoManager, project);
}
@@ -75,8 +82,11 @@ public class RepoViewTest {
assertThat(view.getRef(MASTER)).hasValue(oldMaster);
assertThat(view.getRef(BRANCH)).isEmpty();
- tr.branch(MASTER).commit().create();
- tr.branch(BRANCH).commit().create();
+ testRefAction(
+ () -> {
+ tr.branch(MASTER).commit().create();
+ tr.branch(BRANCH).commit().create();
+ });
assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
assertThat(repo.exactRef(BRANCH)).isNotNull();
assertThat(view.getRef(MASTER)).hasValue(oldMaster);
@@ -88,7 +98,7 @@ public class RepoViewTest {
ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
- ObjectId newBranch = tr.branch(BRANCH).commit().create();
+ ObjectId newBranch = testRefAction(() -> tr.branch(BRANCH).commit().create());
assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
}
@@ -99,17 +109,17 @@ public class RepoViewTest {
assertThat(view.getRef(MASTER)).hasValue(master1);
// Doesn't reflect new value for master.
- ObjectId master2 = tr.branch(MASTER).commit().create();
+ ObjectId master2 = testRefAction(() -> tr.branch(MASTER).commit().create());
assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
// Branch wasn't previously cached, so does reflect new value.
- ObjectId branch1 = tr.branch(BRANCH).commit().create();
+ ObjectId branch1 = testRefAction(() -> tr.branch(BRANCH).commit().create());
assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
// Looking up branch causes it to be cached.
assertThat(view.getRef(BRANCH)).hasValue(branch1);
- ObjectId branch2 = tr.branch(BRANCH).commit().create();
+ ObjectId branch2 = testRefAction(() -> tr.branch(BRANCH).commit().create());
assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
}
diff --git a/javatests/com/google/gerrit/server/update/context/BUILD b/javatests/com/google/gerrit/server/update/context/BUILD
new file mode 100644
index 0000000000..e5805955ff
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/context/BUILD
@@ -0,0 +1,14 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+ name = "update_context_tests",
+ size = "small",
+ srcs = glob(["*.java"]),
+ deps = [
+ "//java/com/google/gerrit/server",
+ "//java/com/google/gerrit/testing:gerrit-test-util",
+ "//java/com/google/gerrit/testing:test-ref-update-context",
+ "//lib/truth",
+ "//lib/truth:truth-java8-extension",
+ ],
+)
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
new file mode 100644
index 0000000000..178d67d14b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2023 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update.context;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.GROUPS_UPDATE;
+import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.INIT_REPO;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.After;
+import org.junit.Test;
+
+public class RefUpdateContextTest {
+ @After
+ public void tearDown() {
+ // Each test should close all opened context to avoid interference with other tests.
+ assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+ }
+
+ @Test
+ public void contextNotOpen() {
+ assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+ assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+ assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+ }
+
+ @Test
+ public void singleContext_openedAndClosedCorrectly() {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+ assertThat(openedContexts).hasSize(1);
+ assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+ assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+ assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+ }
+
+ assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+ assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isFalse();
+ }
+
+ @Test
+ public void nestedContext_openedAndClosedCorrectly() {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) {
+ ImmutableList<RefUpdateContext> nestedOpenedContexts = RefUpdateContext.getOpenedContexts();
+ assertThat(nestedOpenedContexts).hasSize(2);
+ assertThat(nestedOpenedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+ assertThat(nestedOpenedContexts.get(1).getUpdateType()).isEqualTo(INIT_REPO);
+ assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+ assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isTrue();
+ assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+ }
+ ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+ assertThat(openedContexts).hasSize(1);
+ assertThat(openedContexts.get(0).getUpdateType()).isEqualTo(CHANGE_MODIFICATION);
+ assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+ assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+ assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+ }
+
+ assertThat(RefUpdateContext.getOpenedContexts()).isEmpty();
+ assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isFalse();
+ assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isFalse();
+ assertThat(RefUpdateContext.hasOpen(GROUPS_UPDATE)).isFalse();
+ }
+
+ @Test
+ public void incorrectCloseOrder_exceptionThrown() {
+ try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
+ try (RefUpdateContext nestedCtx = RefUpdateContext.open(INIT_REPO)) {
+ assertThrows(Exception.class, () -> ctx.close());
+ ImmutableList<RefUpdateContext> openedContexts = RefUpdateContext.getOpenedContexts();
+ assertThat(openedContexts).hasSize(2);
+ assertThat(RefUpdateContext.hasOpen(CHANGE_MODIFICATION)).isTrue();
+ assertThat(RefUpdateContext.hasOpen(INIT_REPO)).isTrue();
+ }
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/util/http/testutil/BUILD b/javatests/com/google/gerrit/util/http/testutil/BUILD
index 3a67d45daf..3b4817b1d2 100644
--- a/javatests/com/google/gerrit/util/http/testutil/BUILD
+++ b/javatests/com/google/gerrit/util/http/testutil/BUILD
@@ -6,6 +6,7 @@ java_library(
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/common:annotations",
"//lib:guava",
"//lib:jgit",
"//lib:servlet-api",
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index ebdf2d957c..0347177a02 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -25,6 +25,7 @@ import com.google.common.collect.Iterables;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
import java.io.BufferedReader;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
@@ -105,6 +106,7 @@ public class FakeHttpServletRequest implements HttpServletRequest {
return -1;
}
+ @Nullable
@Override
public String getContentType() {
List<String> contentType = headers.get("Content-Type");
diff --git a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
index f39b875500..18714ac732 100644
--- a/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
+++ b/javatests/com/google/gerrit/util/http/testutil/FakeHttpServletResponse.java
@@ -171,7 +171,7 @@ public class FakeHttpServletResponse implements HttpServletResponse {
@Override
public void addHeader(String name, String value) {
- headers.put(name.toLowerCase(), value);
+ headers.put(name.toLowerCase(Locale.US), value);
}
@Override
@@ -181,7 +181,7 @@ public class FakeHttpServletResponse implements HttpServletResponse {
@Override
public boolean containsHeader(String name) {
- return headers.containsKey(name.toLowerCase());
+ return headers.containsKey(name.toLowerCase(Locale.US));
}
@Override
@@ -232,13 +232,13 @@ public class FakeHttpServletResponse implements HttpServletResponse {
@Override
public void setHeader(String name, String value) {
- headers.removeAll(name.toLowerCase());
+ headers.removeAll(name.toLowerCase(Locale.US));
addHeader(name, value);
}
@Override
public void setIntHeader(String name, int value) {
- headers.removeAll(name.toLowerCase());
+ headers.removeAll(name.toLowerCase(Locale.US));
addIntHeader(name, value);
}
@@ -262,7 +262,7 @@ public class FakeHttpServletResponse implements HttpServletResponse {
@Override
public String getHeader(String name) {
- return Iterables.getFirst(headers.get(requireNonNull(name.toLowerCase())), null);
+ return Iterables.getFirst(headers.get(requireNonNull(name.toLowerCase(Locale.US))), null);
}
@Override
@@ -272,7 +272,7 @@ public class FakeHttpServletResponse implements HttpServletResponse {
@Override
public Collection<String> getHeaders(String name) {
- return headers.get(requireNonNull(name.toLowerCase()));
+ return headers.get(requireNonNull(name.toLowerCase(Locale.US)));
}
public byte[] getActualBody() {
diff --git a/modules/jgit b/modules/jgit
-Subproject 5ae8d28faaf6168921f673c89a4e6d601ffad78
+Subproject 74fa245b3c3ccf13afcbec7911c7c8459e48527
diff --git a/package.json b/package.json
index 745a34e3a3..362b9dcf3e 100644
--- a/package.json
+++ b/package.json
@@ -33,14 +33,16 @@
"typescript": "^4.7.2"
},
"scripts": {
+ "setup": "yarn && yarn --cwd=polygerrit-ui && yarn --cwd=polygerrit-ui/app",
"clean": "git clean -fdx && bazel clean --expunge",
- "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
- "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
+ "compile": "tsc --project ./polygerrit-ui/app/tsconfig.json",
+ "compile:watch": "npm run compile -- --preserveWatchOutput --watch",
"start": "run-p -rl compile:watch start:server",
"start:server": "web-dev-server",
"test": "yarn --cwd=polygerrit-ui test",
"test:screenshot": "yarn --cwd=polygerrit-ui test:screenshot",
"test:screenshot-update": "yarn --cwd=polygerrit-ui test:screenshot-update",
+ "test:browsers": "yarn --cwd=polygerrit-ui test:browsers",
"test:coverage": "yarn --cwd=polygerrit-ui test:coverage",
"test:watch": "yarn --cwd=polygerrit-ui test:watch",
"test:single": "yarn --cwd=polygerrit-ui test:single",
@@ -48,7 +50,8 @@
"safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
"eslint": "npm run safe_bazelisk test polygerrit-ui/app:lint_test",
"eslintfix": "npm run safe_bazelisk run polygerrit-ui/app:lint_bin -- -- --fix $(pwd)/polygerrit-ui/app",
- "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis"
+ "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"
},
"repository": {
"type": "git",
diff --git a/plugins/BUILD b/plugins/BUILD
index 32efa3e14c..39560c50e9 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -6,7 +6,7 @@ load(
"CORE_PLUGINS",
"CUSTOM_PLUGINS",
)
-load("@npm//@bazel/typescript:index.bzl", "ts_config")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test")
package(default_visibility = ["//visibility:public"])
@@ -169,3 +169,32 @@ java_doc(
pkgs = ["com.google.gerrit"],
title = "Gerrit Review Plugin API Documentation",
)
+
+# This is a generic test target for TypeScript plugins.
+#
+# `nodejs_test` needs to run in the directory where the `package.json` and
+# `node_modules` are, so unfortunately we cannot move this target into the
+# BUILD files of individual plugins. On the other hand one common target
+# for all plugins also has the advantage of being re-usable.
+#
+# For making this work for a specific plugin you have make the source files
+# of the plugin available as a `filegroup` and add it to the `data` attribute.
+# And you have to specify the `PLUGIN_DIR` in the `env` attribute.
+nodejs_test(
+ name = "web-test-runner",
+ size = "large",
+ chdir = package_name(),
+ data = [
+ ":package.json",
+ ":web-test-runner.config.mjs",
+ # This is an example of how you could reference your plugin sources:
+ # "//plugins/codemirror-editor/web:codemirror-test-sources",
+ "@plugins_npm//:node_modules",
+ ],
+ entry_point = "@plugins_npm//:node_modules/@web/test-runner/dist/bin.js",
+ env = {"PLUGIN_DIR": "codemirror-editor"},
+ tags = [
+ "local",
+ "manual",
+ ],
+)
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
-Subproject 3af12c5a5e65861830b42bd07933e275c33b915
+Subproject be8e04b1a6de091a63c9bc79b56508f2ad56a83
diff --git a/plugins/delete-project b/plugins/delete-project
-Subproject b183ee5230273670f3235cc5b3cf32562ccfb7e
+Subproject b080ed4630104cee0078f6be3561600ed1c3647
diff --git a/plugins/gitiles b/plugins/gitiles
-Subproject 24529d232268ac51fd6850770f70dc0fcd732dd
+Subproject 20f65c2067b9190d1c85fbf61e5d72edf449372
diff --git a/plugins/package.json b/plugins/package.json
index 79bb766589..504fc172e9 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -3,12 +3,38 @@
"description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
"browser": true,
"dependencies": {
- "@gerritcodereview/typescript-api": "3.7.0",
+ "@gerritcodereview/typescript-api": "3.8.0",
"@polymer/decorators": "^3.0.0",
"@polymer/polymer": "^3.4.1",
"@open-wc/testing": "^3.1.6",
+ "@web/dev-server-esbuild": "^0.3.2",
+ "@web/test-runner": "^0.14.0",
+ "@codemirror/autocomplete": "^6.5.1",
+ "@codemirror/commands": "^6.2.3",
+ "@codemirror/legacy-modes": "^6.3.2",
+ "@codemirror/lang-cpp": "^6.0.2",
+ "@codemirror/lang-css": "^6.2.0",
+ "@codemirror/lang-html": "^6.4.3",
+ "@codemirror/lang-java": "^6.0.1",
+ "@codemirror/lang-javascript": "^6.1.7",
+ "@codemirror/lang-json": "^6.0.1",
+ "@codemirror/lang-less": "^6.0.0",
+ "@codemirror/lang-markdown": "^6.1.1",
+ "@codemirror/lang-php": "^6.0.1",
+ "@codemirror/lang-python": "^6.1.2",
+ "@codemirror/lang-rust": "^6.0.1",
+ "@codemirror/lang-sass": "^6.0.1",
+ "@codemirror/lang-sql": "^6.4.1",
+ "@codemirror/lang-xml": "^6.0.2",
+ "@codemirror/language": "^6.6.0",
+ "@codemirror/language-data": "^6.3.0",
+ "@codemirror/lint": "^6.2.1",
+ "@codemirror/search": "^6.4.0",
+ "@codemirror/state": "^6.2.0",
+ "@codemirror/view": "^6.10.0",
"lit": "^2.2.3",
- "rxjs": "^6.6.7"
+ "rxjs": "^6.6.7",
+ "sinon": "^13.0.0"
},
"license": "Apache-2.0",
"private": true
diff --git a/plugins/replication b/plugins/replication
-Subproject 47ee3dab0dd96900e85662adf0d5f48a33d1773
+Subproject 8fd3c271ce0a21480e3d04da5ad2112efea3bed
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
-Subproject 4198fe8df1c1b86d812f32da63e891b1c2fc6f3
+Subproject 9321303265fcab2ff7f764a444f8c2391574763
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
-Subproject 3239ce3a471f5aa9edd8f6f702bee655ea81f77
+Subproject 084a37253dc94ac52cfaa1c9d516fcb8b0318b3
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index e012bd1f71..3f534537f3 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -10,9 +10,9 @@
"@babel/highlight" "^7.18.6"
"@babel/helper-validator-identifier@^7.18.6":
- version "7.18.6"
- resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076"
- integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==
+ version "7.19.1"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2"
+ integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==
"@babel/highlight@^7.18.6":
version "7.18.6"
@@ -23,6 +23,284 @@
chalk "^2.0.0"
js-tokens "^4.0.0"
+"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.5.1":
+ version "6.5.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.5.1.tgz#539cfff291dbffd3841cba078b222cea28ff7eda"
+ integrity sha512-/Sv9yJmqyILbZ26U4LBHnAtbikuVxWUp+rQ8BXuRGtxZfbfKOY/WPbsUtvSP2h0ZUZMlkxV/hqbKRFzowlA6xw==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.6.0"
+ "@lezer/common" "^1.0.0"
+
+"@codemirror/commands@^6.2.3":
+ version "6.2.3"
+ resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.2.3.tgz#ec476fd588f7a4333f54584d4783dd3862befe3b"
+ integrity sha512-9uf0g9m2wZyrIim1SavcxMdwsu8wc/y5uSw6JRUBYIGWrN+RY4vSru/BqB+MyNWqx4C2uRhQ/Kh7Pw8lAyT3qQ==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.2.0"
+ "@codemirror/view" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+
+"@codemirror/lang-angular@^0.1.0":
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-angular/-/lang-angular-0.1.0.tgz#1054747c8196357a2aee2b9c36f0f6de9a6ffef9"
+ integrity sha512-vTjoHjzJmLrrMFmf/tojwp+O0P+R9mgWtjjaKDNDoY58PzOPg7ldMEBqIzABBc+/2mYPD85SG7O5byfBxc83eA==
+ dependencies:
+ "@codemirror/lang-html" "^6.0.0"
+ "@codemirror/lang-javascript" "^6.1.2"
+ "@codemirror/language" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/highlight" "^1.0.0"
+
+"@codemirror/lang-cpp@^6.0.0", "@codemirror/lang-cpp@^6.0.2":
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-cpp/-/lang-cpp-6.0.2.tgz#076c98340c3beabde016d7d83e08eebe17254ef9"
+ integrity sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@lezer/cpp" "^1.0.0"
+
+"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@^6.1.1", "@codemirror/lang-css@^6.2.0":
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-css/-/lang-css-6.2.0.tgz#f84f9da392099432445c75e32fdac63ae572315f"
+ integrity sha512-oyIdJM29AyRPM3+PPq1I2oIk8NpUfEN3kAM05XWDDs6o3gSneIKaVJifT2P+fqONLou2uIgXynFyMUDQvo/szA==
+ dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@lezer/common" "^1.0.2"
+ "@lezer/css" "^1.0.0"
+
+"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.3":
+ version "6.4.3"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.3.tgz#dec78f76d9d0261cbe9f2a3a247a1b546327f700"
+ integrity sha512-VKzQXEC8nL69Jg2hvAFPBwOdZNvL8tMFOrdFwWpU+wc6a6KEkndJ/19R5xSaglNX6v2bttm8uIEFYxdQDcIZVQ==
+ dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
+ "@codemirror/lang-css" "^6.0.0"
+ "@codemirror/lang-javascript" "^6.0.0"
+ "@codemirror/language" "^6.4.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.2.2"
+ "@lezer/common" "^1.0.0"
+ "@lezer/css" "^1.1.0"
+ "@lezer/html" "^1.3.0"
+
+"@codemirror/lang-java@^6.0.0", "@codemirror/lang-java@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-java/-/lang-java-6.0.1.tgz#03bd06334da7c8feb9dff6db01ac6d85bd2e48bb"
+ integrity sha512-OOnmhH67h97jHzCuFaIEspbmsT98fNdhVhmA3zCxW0cn7l8rChDhZtwiwJ/JOKXgfm4J+ELxQihxaI7bj7mJRg==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@lezer/java" "^1.0.0"
+
+"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.1.7":
+ version "6.1.7"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.1.7.tgz#e39fb9757b1cf47de432e4244d18ca5284a73a58"
+ integrity sha512-KXKqxlZ4W6t5I7i2ScmITUD3f/F5Cllk3kj0De9P9mFeYVfhOVOWuDLgYiLpk357u7Xh4dhqjJAnsNPPoTLghQ==
+ dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
+ "@codemirror/language" "^6.6.0"
+ "@codemirror/lint" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/javascript" "^1.0.0"
+
+"@codemirror/lang-json@^6.0.0", "@codemirror/lang-json@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.1.tgz#0a0be701a5619c4b0f8991f9b5e95fe33f462330"
+ integrity sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@lezer/json" "^1.0.0"
+
+"@codemirror/lang-less@^6.0.0":
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-less/-/lang-less-6.0.0.tgz#47ac36242f45bcc211dbcbce11e10f3b249519c9"
+ integrity sha512-hQVj+AxcUW/LybRkwaOope8K8+U6bjWH91t0tW8MMok33Y65xo+Wx0t1BaXi3Iuo6CgJ4tW7Rz09cfNwloIdNA==
+ dependencies:
+ "@codemirror/lang-css" "^6.2.0"
+ "@codemirror/language" "^6.0.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.1.1":
+ version "6.1.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.1.1.tgz#ff3cdd339c277f6a02d08eb12f1090977873e771"
+ integrity sha512-n87Ms6Y5UYb1UkFu8sRzTLfq/yyF1y2AYiWvaVdbBQi5WDj1tFk5N+AKA+WC0Jcjc1VxvrCCM0iizjdYYi9sFQ==
+ dependencies:
+ "@codemirror/lang-html" "^6.0.0"
+ "@codemirror/language" "^6.3.0"
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/markdown" "^1.0.0"
+
+"@codemirror/lang-php@^6.0.0", "@codemirror/lang-php@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-php/-/lang-php-6.0.1.tgz#fa34cc75562178325861a5731f79bd621f57ffaa"
+ integrity sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==
+ dependencies:
+ "@codemirror/lang-html" "^6.0.0"
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/php" "^1.0.0"
+
+"@codemirror/lang-python@^6.0.0", "@codemirror/lang-python@^6.1.2":
+ version "6.1.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-python/-/lang-python-6.1.2.tgz#cabb57529679981f170491833dbf798576e7ab18"
+ integrity sha512-nbQfifLBZstpt6Oo4XxA2LOzlSp4b/7Bc5cmodG1R+Cs5PLLCTUvsMNWDnziiCfTOG/SW1rVzXq/GbIr6WXlcw==
+ dependencies:
+ "@codemirror/autocomplete" "^6.3.2"
+ "@codemirror/language" "^6.0.0"
+ "@lezer/python" "^1.0.0"
+
+"@codemirror/lang-rust@^6.0.0", "@codemirror/lang-rust@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-rust/-/lang-rust-6.0.1.tgz#d6829fc7baa39a15bcd174a41a9e0a1bf7cf6ba8"
+ integrity sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@lezer/rust" "^1.0.0"
+
+"@codemirror/lang-sass@^6.0.0", "@codemirror/lang-sass@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-sass/-/lang-sass-6.0.1.tgz#e390f427c8601175f155046e142371c3c4fb718c"
+ integrity sha512-USy9zqtdLYxSuqq0s4peMoQi+BDzyOyO7chUzli+X2xVCjmBhc3CsWQ4kkDU0NYtCHHFQRkcFO8770eaOwZqfw==
+ dependencies:
+ "@codemirror/lang-css" "^6.1.1"
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@lezer/common" "^1.0.2"
+ "@lezer/sass" "^1.0.0"
+
+"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.4.1":
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.4.1.tgz#e680fe8c12e5902a29fd952207bf454ae02b3bdc"
+ integrity sha512-PFB56L+A0WGY35uRya+Trt5g19V9k2V9X3c55xoFW4RgiATr/yLqWsbbnEsdxuMn5tLpuikp7Kmj9smRsqBXAg==
+ dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/state" "^6.0.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-vue@^0.1.1":
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-vue/-/lang-vue-0.1.1.tgz#79567fb3be3f411354cd135af59d67f956cdb042"
+ integrity sha512-GIfc/MemCFKUdNSYGTFZDN8XsD2z0DUY7DgrK34on0dzdZ/CawZbi+SADYfVzWoPPdxngHzLhqlR5pSOqyPCvA==
+ dependencies:
+ "@codemirror/lang-html" "^6.0.0"
+ "@codemirror/lang-javascript" "^6.1.2"
+ "@codemirror/language" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.3.1"
+
+"@codemirror/lang-wast@^6.0.0":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-wast/-/lang-wast-6.0.1.tgz#c15bec84548a5e9b0a43fa69fb63631d087d6047"
+ integrity sha512-sQLsqhRjl2MWG3rxZysX+2XAyed48KhLBHLgq9xcKxIJu3npH/G+BIXW5NM5mHeDUjG0jcGh9BcjP0NfMStuzA==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@codemirror/lang-xml@^6.0.0", "@codemirror/lang-xml@^6.0.2":
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/lang-xml/-/lang-xml-6.0.2.tgz#66f75390bf8013fd8645db9cdd0b1d177e0777a4"
+ integrity sha512-JQYZjHL2LAfpiZI2/qZ/qzDuSqmGKMwyApYmEUUCTxLM4MWS7sATUEfIguZQr9Zjx/7gcdnewb039smF6nC2zw==
+ dependencies:
+ "@codemirror/autocomplete" "^6.0.0"
+ "@codemirror/language" "^6.4.0"
+ "@codemirror/state" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/xml" "^1.0.0"
+
+"@codemirror/language-data@^6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.3.0.tgz#058365fc2e857eb48810ed92134ee469d9a9bba6"
+ integrity sha512-D9tOZS38mK59jDs1Flqe8GgCdUAYI339SqBdwHJZwxgyXHsBc8RIhAlz2oXWGpvZeP/kVHy9LVfoBFgO02mx7w==
+ dependencies:
+ "@codemirror/lang-angular" "^0.1.0"
+ "@codemirror/lang-cpp" "^6.0.0"
+ "@codemirror/lang-css" "^6.0.0"
+ "@codemirror/lang-html" "^6.0.0"
+ "@codemirror/lang-java" "^6.0.0"
+ "@codemirror/lang-javascript" "^6.0.0"
+ "@codemirror/lang-json" "^6.0.0"
+ "@codemirror/lang-markdown" "^6.0.0"
+ "@codemirror/lang-php" "^6.0.0"
+ "@codemirror/lang-python" "^6.0.0"
+ "@codemirror/lang-rust" "^6.0.0"
+ "@codemirror/lang-sass" "^6.0.0"
+ "@codemirror/lang-sql" "^6.0.0"
+ "@codemirror/lang-vue" "^0.1.1"
+ "@codemirror/lang-wast" "^6.0.0"
+ "@codemirror/lang-xml" "^6.0.0"
+ "@codemirror/language" "^6.0.0"
+ "@codemirror/legacy-modes" "^6.1.0"
+
+"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0":
+ version "6.6.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.6.0.tgz#2204407174a38a68053715c19e28ad61f491779f"
+ integrity sha512-cwUd6lzt3MfNYOobdjf14ZkLbJcnv4WtndYaoBkbor/vF+rCNguMPK0IRtvZJG4dsWiaWPcK8x1VijhvSxnstg==
+ dependencies:
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
+ "@lezer/common" "^1.0.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+ style-mod "^4.0.0"
+
+"@codemirror/legacy-modes@^6.1.0", "@codemirror/legacy-modes@^6.3.2":
+ version "6.3.2"
+ resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.3.2.tgz#d5616b453f38866717437b51c16bde1ae3f011ec"
+ integrity sha512-ki5sqNKWzKi5AKvpVE6Cna4Q+SgxYuYVLAZFSsMjGBWx5qSVa+D+xipix65GS3f2syTfAD9pXKMX4i4p49eneQ==
+ dependencies:
+ "@codemirror/language" "^6.0.0"
+
+"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.2.1":
+ version "6.2.1"
+ resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.2.1.tgz#654581d8cc293c315ecfa5c9d61d78c52bbd9ccd"
+ integrity sha512-y1muai5U/uUPAGRyHMx9mHuHLypPcHWxzlZGknp/U5Mdb5Ol8Q5ZLp67UqyTbNFJJ3unVxZ8iX3g1fMN79S1JQ==
+ dependencies:
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
+ crelt "^1.0.5"
+
+"@codemirror/search@^6.4.0":
+ version "6.4.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.4.0.tgz#2b256a9e0eaa9317fb48e3cc81eb2735360a59b4"
+ integrity sha512-zMDgaBXah+nMLK2dHz9GdCnGbQu+oaGRXS1qviqNZkvOCv/whp5XZFyoikLp/23PM9RBcbuKUUISUmQHM1eRHw==
+ dependencies:
+ "@codemirror/state" "^6.0.0"
+ "@codemirror/view" "^6.0.0"
+ crelt "^1.0.5"
+
+"@codemirror/state@^6.0.0", "@codemirror/state@^6.1.4", "@codemirror/state@^6.2.0":
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.2.0.tgz#a0fb08403ced8c2a68d1d0acee926bd20be922f2"
+ integrity sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==
+
+"@codemirror/view@^6.0.0", "@codemirror/view@^6.10.0", "@codemirror/view@^6.2.2", "@codemirror/view@^6.6.0":
+ version "6.10.0"
+ resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.10.0.tgz#40bb39f391955db8960337a9e80fd7564f8915e2"
+ integrity sha512-Oea3rvE4JQLMmLsy2b54yxXQJgJM9xKpUQIpF/LGgKUTH2lA06GAmEtKKWn5OUnbW3jrH1hHeUd8DJEgePMOeQ==
+ dependencies:
+ "@codemirror/state" "^6.1.4"
+ style-mod "^4.0.0"
+ w3c-keyname "^2.2.4"
+
+"@esbuild/linux-loong64@0.14.54":
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+ integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
"@esm-bundle/chai@^4.3.4-fix.0":
version "4.3.4-fix.0"
resolved "https://registry.yarnpkg.com/@esm-bundle/chai/-/chai-4.3.4-fix.0.tgz#3084cff7eb46d741749f47f3a48dbbdcbaf30a92"
@@ -30,20 +308,143 @@
dependencies:
"@types/chai" "^4.2.12"
-"@gerritcodereview/typescript-api@3.7.0":
- version "3.7.0"
- resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.7.0.tgz#ae3886b5c4ddc6a02659a11d577e1df0b6158727"
- integrity sha512-8zeZClN1gur+Isrn02bRXJ0wUjYvK99jQxg36ZbDelrGDglXKddf8QQkZmaH9sYIRcCFDLlh5+ZlRUTcXTuDVA==
+"@gerritcodereview/typescript-api@3.8.0":
+ version "3.8.0"
+ resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.8.0.tgz#2e418b814d7451c40365b2dc4f88e9965ece0769"
+ integrity sha512-wUkIWUx99Rj1vxRYQISxyzN0nplqu7t5sRDyJ8R3yNNkvALQAMC6Whj63qzCsZsymVFzC5up3y+ZVxaeh7b+xA==
-"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.4.0":
- version "1.4.1"
- resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.4.1.tgz#3f587eec5708692135bc9e94cf396130604979f3"
- integrity sha512-qDv4851VFSaBWzpS02cXHclo40jsbAjRXnebNXpm0uVg32kCneZPo9RYVQtrTNICtZ+1wAYHu1ZtxWSWMbKrBw==
+"@lezer/common@^1.0.0", "@lezer/common@^1.0.2":
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.0.2.tgz#8fb9b86bdaa2ece57e7d59e5ffbcb37d71815087"
+ integrity sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng==
-"@lit/reactive-element@^1.3.0":
- version "1.3.2"
- resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.3.2.tgz#43e470537b6ec2c23510c07812616d5aa27a17cd"
- integrity sha512-A2e18XzPMrIh35nhIdE4uoqRzoIpEU5vZYuQN4S3Ee1zkGdYC27DP12pewbw/RLgPHzaE4kx/YqxMzebOpm0dA==
+"@lezer/cpp@^1.0.0":
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.0.tgz#5aaecac684437925d650252d7e9d97acf8f8f095"
+ integrity sha512-zUHrjNFuY/DOZCkOBJ6qItQIkcopHM/Zv/QOE0a4XNG3HDNahxTNu5fQYl8dIuKCpxCqRdMl5cEwl5zekFc7BA==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/css@^1.0.0", "@lezer/css@^1.1.0":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.1.1.tgz#c36dcb0789317cb80c3740767dd3b85e071ad082"
+ integrity sha512-mSjx+unLLapEqdOYDejnGBokB5+AiJKZVclmud0MKQOKx3DLJ5b5VTCstgDDknR6iIV4gVrN6euzsCnj0A2gQA==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3":
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.1.4.tgz#98ed821e89f72981b7ba590474e6ee86c8185619"
+ integrity sha512-IECkFmw2l7sFcYXrV8iT9GeY4W0fU4CxX0WMwhmhMIVjoDdD1Hr6q3G2NqVtLg/yVe5n7i4menG3tJ2r4eCrPQ==
+ dependencies:
+ "@lezer/common" "^1.0.0"
+
+"@lezer/html@^1.3.0":
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.4.tgz#7a5c5498dae6c93aee3de208bfb01aa3a0a932e3"
+ integrity sha512-HdJYMVZcT4YsMo7lW3ipL4NoyS2T67kMPuSVS5TgLGqmaCjEU/D6xv7zsa1ktvTK5lwk7zzF1e3eU6gBZIPm5g==
+ dependencies:
+ "@lezer/common" "^1.0.0"
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/java@^1.0.0":
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/@lezer/java/-/java-1.0.3.tgz#393e333fcdb64f7308e0ce120005b0065668e1d2"
+ integrity sha512-kKN17wmgP1cgHb8juR4pwVSPMKkDMzY/lAPbBsZ1fpXwbk2sg3N1kIrf0q+LefxgrANaQb/eNO7+m2QPruTFng==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/javascript@^1.0.0":
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.4.3.tgz#f59e764a0578184c6fb86abb5279a9679777c3ba"
+ integrity sha512-k7Eo9z9B1supZ5cCD4ilQv/RZVN30eUQL+gGbr6ybrEY3avBAL5MDiYi2aa23Aj0A79ry4rJRvPAwE2TM8bd+A==
+ dependencies:
+ "@lezer/highlight" "^1.1.3"
+ "@lezer/lr" "^1.3.0"
+
+"@lezer/json@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@lezer/json/-/json-1.0.0.tgz#848ad9c2c3e812518eb02897edd5a7f649e9c160"
+ integrity sha512-zbAuUY09RBzCoCA3lJ1+ypKw5WSNvLqGMtasdW6HvVOqZoCpPr8eWrsGnOVWGKGn8Rh21FnrKRVlJXrGAVUqRw==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/lr@^1.0.0", "@lezer/lr@^1.1.0", "@lezer/lr@^1.3.0", "@lezer/lr@^1.3.1":
+ version "1.3.4"
+ resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.3.4.tgz#8795bf2ba4f69b998e8fb4b5a7c57ea68753474c"
+ integrity sha512-7o+e4og/QoC/6btozDPJqnzBhUaD1fMfmvnEKQO1wRRiTse1WxaJ3OMEXZJnkgT6HCcTVOctSoXK9jGJw2oe9g==
+ dependencies:
+ "@lezer/common" "^1.0.0"
+
+"@lezer/markdown@^1.0.0":
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.0.2.tgz#8c804a9f6fe1ccca4a20acd2fd9fbe0fae1ae178"
+ integrity sha512-8CY0OoZ6V5EzPjSPeJ4KLVbtXdLBd8V6sRCooN5kHnO28ytreEGTyrtU/zUwo/XLRzGr/e1g44KlzKi3yWGB5A==
+ dependencies:
+ "@lezer/common" "^1.0.0"
+ "@lezer/highlight" "^1.0.0"
+
+"@lezer/php@^1.0.0":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@lezer/php/-/php-1.0.1.tgz#4496b58c980ca710c0433fd743d27e9964fd74ea"
+ integrity sha512-aqdCQJOXJ66De22vzdwnuC502hIaG9EnPK2rSi+ebXyUd+j7GAX1mRjWZOVOmf3GST1YUfUCu6WXDiEgDGOVwA==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.1.0"
+
+"@lezer/python@^1.0.0":
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/@lezer/python/-/python-1.1.4.tgz#6ef58ff965286150fea9f2db776944a1d69cd9b9"
+ integrity sha512-x82XgYxqqX0Yiw7uIemQJ3z2QyQme5BYpectkPfNg99OQrakqfwqVolqEVIrsj4QO9rVDLFZZ49J0Vbne7UbAA==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/rust@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@lezer/rust/-/rust-1.0.0.tgz#939f3e7b0376ebe13f4ac336ed7d59ca2c8adf52"
+ integrity sha512-IpGAxIjNxYmX9ra6GfQTSPegdCAWNeq23WNmrsMMQI7YNSvKtYxO4TX5rgZUmbhEucWn0KTBMeDEPXg99YKtTA==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/sass@^1.0.0":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@lezer/sass/-/sass-1.0.1.tgz#c0ec3ece28b04e92437a75ac4a806367e5cb6fd4"
+ integrity sha512-S/aYAzABzMqWLfKKqV89pCWME4yjZYC6xzD02l44wbmb0sHxmN9/8aE4GULrKFzFaGazHdXcGEbPZ4zzB6yqwQ==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lezer/xml@^1.0.0":
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/@lezer/xml/-/xml-1.0.1.tgz#c4c738a407db610f0e9c59d0e9b16607cd029591"
+ integrity sha512-jMDXrV953sDAUEMI25VNrI9dz94Ai96FfeglytFINhhwQ867HKlCE2jt3AwZTCT7M528WxdDWv/Ty8e9wizwmQ==
+ dependencies:
+ "@lezer/highlight" "^1.0.0"
+ "@lezer/lr" "^1.0.0"
+
+"@lit-labs/ssr-dom-shim@^1.0.0":
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.0.0.tgz#427e19a2765681fd83411cd72c55ba80a01e0523"
+ integrity sha512-ic93MBXfApIFTrup4a70M/+ddD8xdt2zxxj9sRwHQzhS9ag/syqkD8JPdTXsc1gUy2K8TTirhlCqyTEM/sifNw==
+
+"@lit/reactive-element@^1.0.0", "@lit/reactive-element@^1.3.0", "@lit/reactive-element@^1.6.0":
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.6.1.tgz#0d958b6d479d0e3db5fc1132ecc4fa84be3f0b93"
+ integrity sha512-va15kYZr7KZNNPZdxONGQzpUr+4sxVu7V/VG7a8mRfPPXUyhEYj5RzXCQmGrlP3tAh0L3HHm5AjBMFYRqlM9SA==
+ dependencies:
+ "@lit-labs/ssr-dom-shim" "^1.0.0"
+
+"@mdn/browser-compat-data@^4.0.0":
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-4.2.1.tgz#1fead437f3957ceebe2e8c3f46beccdb9bc575b8"
+ integrity sha512-EWUguj2kd7ldmrF9F+vI5hUOralPd+sdsUnYbRy33vZTuZkduC1shE9TtEMEjAQwyfyMb4ole5KtjF8MsnQOlA==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
@@ -80,9 +481,9 @@
integrity sha512-ukowSvzpZQDUH0Y3znJTsY88HkiGk3Khc0WGpIPhap1xlerieYi27QBg6wx/nTurpWfU6XXXsx9ocxDYCdtw0Q==
"@open-wc/scoped-elements@^2.1.3":
- version "2.1.3"
- resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.3.tgz#c4f06fa16091c6ebf2a69b3f40afc03821f42535"
- integrity sha512-WoQD5T8Me9obek+iyjgrAMw9wxZZg4ytIteIN1i9LXW2KohezUp0LTOlWgBajWJo0/bpjUKiODX73cMYL2i3hw==
+ version "2.1.4"
+ resolved "https://registry.yarnpkg.com/@open-wc/scoped-elements/-/scoped-elements-2.1.4.tgz#8064abaa69bc2fb67695115c077aabedc9333b68"
+ integrity sha512-KX/bOkcDG9kbBDSmgsbpp40ZjEWxpWNrNRZZVSO0KqBygMfvfiEeVfP16uJp9YyWHi/PVZ/C0aUEgf8Pg1Eq7A==
dependencies:
"@lit/reactive-element" "^1.0.0"
"@open-wc/dedupe-mixin" "^1.3.0"
@@ -100,24 +501,24 @@
"@types/chai" "^4.3.1"
"@web/test-runner-commands" "^0.6.1"
-"@open-wc/testing-helpers@^2.1.2":
- version "2.1.3"
- resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.3.tgz#85a133ac8637ed1d880d523b07650788eab4a128"
- integrity sha512-hQujGaWncmWLx/974jq5yf2jydBNNTwnkISw2wLGiYgX34+3R6/ns301Oi9S3Il96Kzd8B7avdExp/gDgqcF5w==
+"@open-wc/testing-helpers@^2.1.4":
+ version "2.1.4"
+ resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-2.1.4.tgz#4b439442ecb1ea3fbcbb1ef76e8717574d78dc97"
+ integrity sha512-iZJxxKI9jRgnPczm8p2jpuvBZ3DHYSLrBmhDfzs7ol8vXMNt+HluzM1j1TSU95MFVGnfaspvvt9fMbXKA7cNcA==
dependencies:
"@open-wc/scoped-elements" "^2.1.3"
lit "^2.0.0"
lit-html "^2.0.0"
"@open-wc/testing@^3.1.6":
- version "3.1.6"
- resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.6.tgz#89f71710e5530d74f0c478b0a9239d68dcdb9f5e"
- integrity sha512-MIf9cBtac4/UBE5a+R5cXiRhOGfzetsV+ZPFc188AfkPDPbmffHqjrRoCyk4B/qS6fLEulSBMLSaQ+6ze971gQ==
+ version "3.1.7"
+ resolved "https://registry.yarnpkg.com/@open-wc/testing/-/testing-3.1.7.tgz#65200c759626d510fda103c3cb4ede6202b1b88b"
+ integrity sha512-HCS2LuY6hXtEwjqmad+eanId5H7E+3mUi9Z3rjAhH+1DCJ53lUnjzWF1lbCYbREqrdCpmzZvW1t5R3e9gJZSCA==
dependencies:
"@esm-bundle/chai" "^4.3.4-fix.0"
"@open-wc/chai-dom-equals" "^0.12.36"
"@open-wc/semantic-dom-diff" "^0.19.7"
- "@open-wc/testing-helpers" "^2.1.2"
+ "@open-wc/testing-helpers" "^2.1.4"
"@types/chai" "^4.2.11"
"@types/chai-dom" "^0.0.12"
"@types/sinon-chai" "^3.2.3"
@@ -131,12 +532,75 @@
"@polymer/polymer" "^3.0.5"
"@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
- version "3.4.1"
- resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
- integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+ version "3.5.1"
+ resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.1.tgz#4b5234e43b8876441022bcb91313ab3c4a29f0c8"
+ integrity sha512-JlAHuy+1qIC6hL1ojEUfIVD58fzTpJAoCxFwV5yr0mYTXV1H8bz5zy0+rC963Cgr9iNXQ4T9ncSjC2fkF9BQfw==
dependencies:
"@webcomponents/shadycss" "^1.9.1"
+"@rollup/plugin-node-resolve@^13.0.4":
+ version "13.3.0"
+ resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-13.3.0.tgz#da1c5c5ce8316cef96a2f823d111c1e4e498801c"
+ integrity sha512-Lus8rbUo1eEcnS4yTFKLZrVumLPY+YayBdWXgFSHYhTT2iJbMhoaaBL3xl5NCdeRytErGr8tZ0L71BMRmnlwSw==
+ dependencies:
+ "@rollup/pluginutils" "^3.1.0"
+ "@types/resolve" "1.17.1"
+ deepmerge "^4.2.2"
+ is-builtin-module "^3.1.0"
+ is-module "^1.0.0"
+ resolve "^1.19.0"
+
+"@rollup/pluginutils@^3.1.0":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-3.1.0.tgz#706b4524ee6dc8b103b3c995533e5ad680c02b9b"
+ integrity sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==
+ dependencies:
+ "@types/estree" "0.0.39"
+ estree-walker "^1.0.1"
+ picomatch "^2.2.2"
+
+"@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.3":
+ version "1.8.6"
+ resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.6.tgz#80c516a4dc264c2a69115e7578d62581ff455ed9"
+ integrity sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==
+ dependencies:
+ type-detect "4.0.8"
+
+"@sinonjs/commons@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3"
+ integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==
+ dependencies:
+ type-detect "4.0.8"
+
+"@sinonjs/fake-timers@^10.0.2":
+ version "10.0.2"
+ resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.0.2.tgz#d10549ed1f423d80639c528b6c7f5a1017747d0c"
+ integrity sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==
+ dependencies:
+ "@sinonjs/commons" "^2.0.0"
+
+"@sinonjs/fake-timers@^9.1.2":
+ version "9.1.2"
+ resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-9.1.2.tgz#4eaab737fab77332ab132d396a3c0d364bd0ea8c"
+ integrity sha512-BPS4ynJW/o92PUR4wgriz2Ud5gpST5vz6GQfMixEDK0Z8ZCUv2M7SkBLykH56T++Xs+8ln9zTGbOvNGIe02/jw==
+ dependencies:
+ "@sinonjs/commons" "^1.7.0"
+
+"@sinonjs/samsam@^6.1.1":
+ version "6.1.3"
+ resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.1.3.tgz#4e30bcd4700336363302a7d72cbec9b9ab87b104"
+ integrity sha512-nhOb2dWPeb1sd3IQXL/dVPnKHDOAFfvichtBf4xV00/rU1QbPCQqKMbvIheIjqwVjh7qIgf2AHTHi391yMOMpQ==
+ dependencies:
+ "@sinonjs/commons" "^1.6.0"
+ lodash.get "^4.4.2"
+ type-detect "^4.0.8"
+
+"@sinonjs/text-encoding@^0.7.1":
+ version "0.7.2"
+ resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918"
+ integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==
+
"@types/accepts@*":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
@@ -165,9 +629,9 @@
"@types/chai" "*"
"@types/chai@*", "@types/chai@^4.1.7", "@types/chai@^4.2.11", "@types/chai@^4.2.12", "@types/chai@^4.3.1":
- version "4.3.3"
- resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
- integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
+ version "4.3.4"
+ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4"
+ integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw==
"@types/co-body@^6.1.0":
version "6.1.0"
@@ -177,6 +641,11 @@
"@types/node" "*"
"@types/qs" "*"
+"@types/command-line-args@^5.0.0":
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
+ integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
+
"@types/connect@*":
version "3.4.35"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
@@ -209,22 +678,27 @@
resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.1.tgz#79b65710bc8b6d44094d286aecf38e44f9627852"
integrity sha512-epMsEE85fi4lfmJUH/89/iV/LI+F5CvNIvmgs5g5jYFPfhO2S/ae8WSsLOKWdwtoaZw9Q2IhJ4tQ5tFCcS/4HA==
-"@types/express-serve-static-core@^4.17.18":
- version "4.17.31"
- resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.31.tgz#a1139efeab4e7323834bb0226e62ac019f474b2f"
- integrity sha512-DxMhY+NAsTwMMFHBTtJFNp5qiHKJ7TeqOo23zVEM9alT1Ml27Q3xcTH0xwxn7Q0BbMcVEJOs/7aQtUWupUQN3Q==
+"@types/estree@0.0.39":
+ version "0.0.39"
+ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f"
+ integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
+
+"@types/express-serve-static-core@^4.17.33":
+ version "4.17.33"
+ resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz#de35d30a9d637dc1450ad18dd583d75d5733d543"
+ integrity sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==
dependencies:
"@types/node" "*"
"@types/qs" "*"
"@types/range-parser" "*"
"@types/express@*":
- version "4.17.14"
- resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.14.tgz#143ea0557249bc1b3b54f15db4c81c3d4eb3569c"
- integrity sha512-TEbt+vaPFQ+xpxFLFssxUDXj5cWCxZJjIcB7Yg0k0GMHGtgtQgpvx/MUQUeAkNbA9AAGrwkAsoeItdTgS7FMyg==
+ version "4.17.17"
+ resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4"
+ integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==
dependencies:
"@types/body-parser" "*"
- "@types/express-serve-static-core" "^4.17.18"
+ "@types/express-serve-static-core" "^4.17.33"
"@types/qs" "*"
"@types/serve-static" "*"
@@ -234,11 +708,11 @@
integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
"@types/http-errors@*":
- version "1.8.2"
- resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
- integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.1.tgz#20172f9578b225f6c7da63446f56d4ce108d5a65"
+ integrity sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==
-"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.3":
+"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.3":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44"
integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==
@@ -288,10 +762,15 @@
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10"
integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==
+"@types/mocha@^8.2.0":
+ version "8.2.3"
+ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
+ integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
+
"@types/node@*":
- version "18.7.18"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.18.tgz#633184f55c322e4fb08612307c274ee6d5ed3154"
- integrity sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==
+ version "18.14.2"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.2.tgz#c076ed1d7b6095078ad3cf21dfeea951842778b1"
+ integrity sha512-1uEQxww3DaghA0RxqHx0O0ppVlo43pJhepY51OxuQIKHpjbnYLA7vcdwioNPzIqmC2u3I/dmylcqjlh0e7AyUA==
"@types/parse5@^6.0.1":
version "6.0.3"
@@ -308,18 +787,25 @@
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
+"@types/resolve@1.17.1":
+ version "1.17.1"
+ resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6"
+ integrity sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==
+ dependencies:
+ "@types/node" "*"
+
"@types/serve-static@*":
- version "1.15.0"
- resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.0.tgz#c7930ff61afb334e121a9da780aac0d9b8f34155"
- integrity sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==
+ version "1.15.1"
+ resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d"
+ integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ==
dependencies:
"@types/mime" "*"
"@types/node" "*"
"@types/sinon-chai@^3.2.3":
- version "3.2.8"
- resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
- integrity sha512-d4ImIQbT/rKMG8+AXpmcan5T2/PNeSjrYhvkwet6z0p8kzYtfgA32xzOBlbU0yqJfq+/0Ml805iFoODO0LP5/g==
+ version "3.2.9"
+ resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.9.tgz#71feb938574bbadcb176c68e5ff1a6014c5e69d4"
+ integrity sha512-/19t63pFYU0ikrdbXKBWj9PCdnKyTd0Qkz0X91Ta081cYsq90OxYdcWwK/dwEoDa6dtXgj2HJfmzgq+QZTHdmQ==
dependencies:
"@types/chai" "*"
"@types/sinon" "*"
@@ -337,9 +823,9 @@
integrity sha512-9GcLXF0/v3t80caGs5p2rRfkB+a8VBGLJZVih6CNFkx8IZ994wiKKLSRs9nuFwk1HevWs/1mnUmkApGrSGsShA==
"@types/trusted-types@^2.0.2":
- version "2.0.2"
- resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
- integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.3.tgz#a136f83b0758698df454e328759dbd3d44555311"
+ integrity sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==
"@types/ws@^7.4.0":
version "7.4.7"
@@ -348,14 +834,28 @@
dependencies:
"@types/node" "*"
-"@web/browser-logs@^0.2.1":
+"@types/yauzl@^2.9.1":
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.0.tgz#b3248295276cf8c6f153ebe6a9aba0c988cb2599"
+ integrity sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==
+ dependencies:
+ "@types/node" "*"
+
+"@web/browser-logs@^0.2.1", "@web/browser-logs@^0.2.2":
version "0.2.5"
resolved "https://registry.yarnpkg.com/@web/browser-logs/-/browser-logs-0.2.5.tgz#0895efb641eacb0fbc1138c6092bd18c01df2734"
integrity sha512-Qxo1wY/L7yILQqg0jjAaueh+tzdORXnZtxQgWH23SsTCunz9iq9FvsZa8Q5XlpjnZ3vLIsFEuEsCMqFeohJnEg==
dependencies:
errorstacks "^2.2.0"
-"@web/dev-server-core@^0.3.18":
+"@web/config-loader@^0.1.3":
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/@web/config-loader/-/config-loader-0.1.3.tgz#8325ea54f75ef2ee7166783e64e66936db25bff7"
+ integrity sha512-XVKH79pk4d3EHRhofete8eAnqto1e8mCRAqPV00KLNFzCWSe8sWmLnqKCqkPNARC6nksMaGrATnA5sPDRllMpQ==
+ dependencies:
+ semver "^7.3.4"
+
+"@web/dev-server-core@^0.3.18", "@web/dev-server-core@^0.3.19":
version "0.3.19"
resolved "https://registry.yarnpkg.com/@web/dev-server-core/-/dev-server-core-0.3.19.tgz#b61f9a0b92351371347a758b30ba19e683c72e94"
integrity sha512-Q/Xt4RMVebLWvALofz1C0KvP8qHbzU1EmdIA2Y1WMPJwiFJFhPxdr75p9YxK32P2t0hGs6aqqS5zE0HW9wYzYA==
@@ -379,6 +879,49 @@
picomatch "^2.2.2"
ws "^7.4.2"
+"@web/dev-server-esbuild@^0.3.2":
+ version "0.3.3"
+ resolved "https://registry.yarnpkg.com/@web/dev-server-esbuild/-/dev-server-esbuild-0.3.3.tgz#e82af2e5acec0e645b920400be9601601b3921c5"
+ integrity sha512-hB9C8X9NsFWUG2XKT3W+Xcw3IZ/VObf4LNbK14BTjApnNyZfV6hVhSlJfvhgOoJ4DxsImfhIB5+gMRKOG9NmBw==
+ dependencies:
+ "@mdn/browser-compat-data" "^4.0.0"
+ "@web/dev-server-core" "^0.3.19"
+ esbuild "^0.12 || ^0.13 || ^0.14"
+ parse5 "^6.0.1"
+ ua-parser-js "^1.0.2"
+
+"@web/dev-server-rollup@^0.3.19":
+ version "0.3.21"
+ resolved "https://registry.yarnpkg.com/@web/dev-server-rollup/-/dev-server-rollup-0.3.21.tgz#edeecc599970fcc03f6a53fd7c5fdaf01178e88a"
+ integrity sha512-138t+vMFkegRip6Rtlz68Bo5rl984C9c2rLg3dWl9JEEJSQcWgA3iEwXYh4xTc52WjXnM3/LpboAjTYQOMyfrA==
+ dependencies:
+ "@rollup/plugin-node-resolve" "^13.0.4"
+ "@web/dev-server-core" "^0.3.19"
+ nanocolors "^0.2.1"
+ parse5 "^6.0.1"
+ rollup "^2.67.0"
+ whatwg-url "^11.0.0"
+
+"@web/dev-server@^0.1.35":
+ version "0.1.35"
+ resolved "https://registry.yarnpkg.com/@web/dev-server/-/dev-server-0.1.35.tgz#d845822d7c3c7749adf03f7abac4a69e2a4490cc"
+ integrity sha512-E7TSTSFdGPzhkiE3kIVt8i49gsiAYpJIZHzs1vJmVfdt8U4rsmhE+5roezxZo0hkEw4mNsqj9zCc4Dzqy/IFHg==
+ dependencies:
+ "@babel/code-frame" "^7.12.11"
+ "@types/command-line-args" "^5.0.0"
+ "@web/config-loader" "^0.1.3"
+ "@web/dev-server-core" "^0.3.19"
+ "@web/dev-server-rollup" "^0.3.19"
+ camelcase "^6.2.0"
+ command-line-args "^5.1.1"
+ command-line-usage "^6.1.1"
+ debounce "^1.2.0"
+ deepmerge "^4.2.2"
+ ip "^1.1.5"
+ nanocolors "^0.2.1"
+ open "^8.0.2"
+ portfinder "^1.0.32"
+
"@web/parse5-utils@^1.2.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@web/parse5-utils/-/parse5-utils-1.3.0.tgz#e2e9e98b31a4ca948309f74891bda8d77399f6bd"
@@ -387,7 +930,17 @@
"@types/parse5" "^6.0.1"
parse5 "^6.0.1"
-"@web/test-runner-commands@^0.6.1":
+"@web/test-runner-chrome@^0.10.7":
+ version "0.10.7"
+ resolved "https://registry.yarnpkg.com/@web/test-runner-chrome/-/test-runner-chrome-0.10.7.tgz#2dc35da47aa8b98c59f9e229a70ea3f443303e0c"
+ integrity sha512-DKJVHhHh3e/b6/erfKOy0a4kGfZ47qMoQRgROxi9T4F9lavEY3E5/MQ7hapHFM2lBF4vDrm+EWjtBdOL8o42tw==
+ dependencies:
+ "@web/test-runner-core" "^0.10.20"
+ "@web/test-runner-coverage-v8" "^0.4.8"
+ chrome-launcher "^0.15.0"
+ puppeteer-core "^13.1.3"
+
+"@web/test-runner-commands@^0.6.1", "@web/test-runner-commands@^0.6.3":
version "0.6.5"
resolved "https://registry.yarnpkg.com/@web/test-runner-commands/-/test-runner-commands-0.6.5.tgz#69a2a06b52fd9d329f9cf1e172cd8fb1d5ffc521"
integrity sha512-W+wLg10jEAJY9N6tNWqG1daKmAzxGmTbO/H9fFfcgOgdxdn+hHiR4r2/x1iylKbFLujHUQlnjNQeu2d6eDPFqg==
@@ -395,7 +948,7 @@
"@web/test-runner-core" "^0.10.27"
mkdirp "^1.0.4"
-"@web/test-runner-core@^0.10.27":
+"@web/test-runner-core@^0.10.20", "@web/test-runner-core@^0.10.27":
version "0.10.27"
resolved "https://registry.yarnpkg.com/@web/test-runner-core/-/test-runner-core-0.10.27.tgz#8d1430f2364fb36b3ac15b9b43034fae9d94e177"
integrity sha512-ClV/hSxs4wDm/ANFfQOdRRFb/c0sYywC1QfUXG/nS4vTp3nnt7x7mjydtMGGLmvK9f6Zkubkc1aa+7ryfmVwNA==
@@ -427,10 +980,50 @@
picomatch "^2.2.2"
source-map "^0.7.3"
+"@web/test-runner-coverage-v8@^0.4.8":
+ version "0.4.9"
+ resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.4.9.tgz#334d80cd19fc68c08ec3339b1b1d2725078b51a2"
+ integrity sha512-y9LWL4uY25+fKQTljwr0XTYjeWIwU4h8eYidVuLoW3n1CdFkaddv+smrGzzF5j8XY+Mp6TmV9NdxjvMWqVkDdw==
+ dependencies:
+ "@web/test-runner-core" "^0.10.20"
+ istanbul-lib-coverage "^3.0.0"
+ picomatch "^2.2.2"
+ v8-to-istanbul "^8.0.0"
+
+"@web/test-runner-mocha@^0.7.5":
+ version "0.7.5"
+ resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
+ integrity sha512-12/OBq6efPCAvJpcz3XJs2OO5nHe7GtBibZ8Il1a0QtsGpRmuJ4/m1EF0Fj9f6KHg7JdpGo18A37oE+5hXjHwg==
+ dependencies:
+ "@types/mocha" "^8.2.0"
+ "@web/test-runner-core" "^0.10.20"
+
+"@web/test-runner@^0.14.0":
+ version "0.14.1"
+ resolved "https://registry.yarnpkg.com/@web/test-runner/-/test-runner-0.14.1.tgz#a637e45c9b6ce7860ab780b5ac82dbfa1ed824f9"
+ integrity sha512-S2/Xp/bZBJdbWeTQxhs45cO9Khwqx99X+rrx8l0uDR0Ju/+kX+yC3RpjnOY1ooKD3rjkoEAE82soZTZNz+aKIg==
+ dependencies:
+ "@web/browser-logs" "^0.2.2"
+ "@web/config-loader" "^0.1.3"
+ "@web/dev-server" "^0.1.35"
+ "@web/test-runner-chrome" "^0.10.7"
+ "@web/test-runner-commands" "^0.6.3"
+ "@web/test-runner-core" "^0.10.27"
+ "@web/test-runner-mocha" "^0.7.5"
+ camelcase "^6.2.0"
+ command-line-args "^5.1.1"
+ command-line-usage "^6.1.1"
+ convert-source-map "^1.7.0"
+ diff "^5.0.0"
+ globby "^11.0.1"
+ nanocolors "^0.2.1"
+ portfinder "^1.0.32"
+ source-map "^0.7.3"
+
"@webcomponents/shadycss@^1.9.1":
- version "1.11.0"
- resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
- integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.1.tgz#add19d5e0db4a014e143d2278921347dcd8f0a55"
+ integrity sha512-qSok/oMynEgS99wFY5fKT6cR1y64i01RkHGYOspkh2JQsLSM8pjciER+gu3fqTx589y/7LoSuyB5G9Rh7dyXaQ==
accepts@^1.3.5:
version "1.3.8"
@@ -440,6 +1033,13 @@ accepts@^1.3.5:
mime-types "~2.1.34"
negotiator "0.6.3"
+agent-base@6:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+ integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+ dependencies:
+ debug "4"
+
ansi-escapes@^4.3.0:
version "4.3.2"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -467,13 +1067,23 @@ ansi-styles@^4.0.0:
color-convert "^2.0.1"
anymatch@~3.1.2:
- version "3.1.2"
- resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
- integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
dependencies:
normalize-path "^3.0.0"
picomatch "^2.0.4"
+array-back@^3.0.1, array-back@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
+ integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
+
+array-back@^4.0.1, array-back@^4.0.2:
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+ integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
+
array-union@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@@ -484,16 +1094,50 @@ astral-regex@^2.0.0:
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
+async@^2.6.4:
+ version "2.6.4"
+ resolved "https://registry.yarnpkg.com/async/-/async-2.6.4.tgz#706b7ff6084664cd7eae713f6f965433b5504221"
+ integrity sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==
+ dependencies:
+ lodash "^4.17.14"
+
axe-core@^4.3.3:
- version "4.4.3"
- resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.3.tgz#11c74d23d5013c0fa5d183796729bc3482bd2f6f"
- integrity sha512-32+ub6kkdhhWick/UjvEwRchgoetXqTK14INLqbGm5U2TzBkBNF3nQtLYm8ovxSkQWArjEQvftCKryjZaATu3w==
+ version "4.6.3"
+ resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.6.3.tgz#fc0db6fdb65cc7a80ccf85286d91d64ababa3ece"
+ integrity sha512-/BQzOX780JhsxDnPpH4ZiyrJAzcd8AfzFPkv+89veFSr1rcMjuq2JDCwypKaPeB6ljHp9KjXhPpjgCvQlWYuqg==
+
+balanced-match@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+ integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64-js@^1.3.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+ integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+bl@^4.0.3:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+ integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
+ dependencies:
+ buffer "^5.5.0"
+ inherits "^2.0.4"
+ readable-stream "^3.4.0"
+
+brace-expansion@^1.1.7:
+ version "1.1.11"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+ integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+ dependencies:
+ balanced-match "^1.0.0"
+ concat-map "0.0.1"
+
braces@^3.0.2, braces@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
@@ -501,6 +1145,24 @@ braces@^3.0.2, braces@~3.0.2:
dependencies:
fill-range "^7.0.1"
+buffer-crc32@~0.2.3:
+ version "0.2.13"
+ resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+ integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==
+
+buffer@^5.2.1, buffer@^5.5.0:
+ version "5.7.1"
+ resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+ integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
+ dependencies:
+ base64-js "^1.3.1"
+ ieee754 "^1.1.13"
+
+builtin-modules@^3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6"
+ integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==
+
bytes@3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
@@ -522,6 +1184,11 @@ call-bind@^1.0.0:
function-bind "^1.1.1"
get-intrinsic "^1.0.2"
+camelcase@^6.2.0:
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+ integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
chai-a11y-axe@^1.3.2:
version "1.4.0"
resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.4.0.tgz#e584af967727a8656e27c32e845f5db21f2bf2e0"
@@ -529,7 +1196,7 @@ chai-a11y-axe@^1.3.2:
dependencies:
axe-core "^4.3.3"
-chalk@^2.0.0:
+chalk@^2.0.0, chalk@^2.4.2:
version "2.4.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -553,6 +1220,21 @@ chokidar@^3.4.3:
optionalDependencies:
fsevents "~2.3.2"
+chownr@^1.1.1:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+ integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chrome-launcher@^0.15.0:
+ version "0.15.1"
+ resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.1.tgz#0a0208037063641e2b3613b7e42b0fcb3fa2d399"
+ integrity sha512-UugC8u59/w2AyX5sHLZUHoxBAiSiunUhZa3zZwMH6zPVis0C3dDKiRWyUGIo14tTbZHGVviWxv3PQWZ7taZ4fg==
+ dependencies:
+ "@types/node" "*"
+ escape-string-regexp "^4.0.0"
+ is-wsl "^2.2.0"
+ lighthouse-logger "^1.0.0"
+
cli-cursor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
@@ -604,6 +1286,31 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+command-line-args@^5.1.1:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.1.tgz#c44c32e437a57d7c51157696893c5909e9cec42e"
+ integrity sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==
+ dependencies:
+ array-back "^3.1.0"
+ find-replace "^3.0.0"
+ lodash.camelcase "^4.3.0"
+ typical "^4.0.0"
+
+command-line-usage@^6.1.1:
+ version "6.1.3"
+ resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.3.tgz#428fa5acde6a838779dfa30e44686f4b6761d957"
+ integrity sha512-sH5ZSPr+7UStsloltmDh7Ce5fb8XPlHyoPzTpyyMuYCtervL65+ubVZ6Q61cFtFl62UyJlc8/JwERRbAFPUqgw==
+ dependencies:
+ array-back "^4.0.2"
+ chalk "^2.4.2"
+ table-layout "^1.0.2"
+ typical "^5.2.0"
+
+concat-map@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+ integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+
content-disposition@~0.5.2:
version "0.5.4"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
@@ -612,16 +1319,14 @@ content-disposition@~0.5.2:
safe-buffer "5.2.1"
content-type@^1.0.4:
- version "1.0.4"
- resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
- integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+ integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
-convert-source-map@^1.7.0:
- version "1.8.0"
- resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
- integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
- dependencies:
- safe-buffer "~5.1.1"
+convert-source-map@^1.6.0, convert-source-map@^1.7.0:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
+ integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==
cookies@~0.8.0:
version "0.8.0"
@@ -631,30 +1336,59 @@ cookies@~0.8.0:
depd "~2.0.0"
keygrip "~1.1.0"
+crelt@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.5.tgz#57c0d52af8c859e354bace1883eb2e1eb182bb94"
+ integrity sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==
+
+cross-fetch@3.1.5:
+ version "3.1.5"
+ resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
+ integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+ dependencies:
+ node-fetch "2.6.7"
+
debounce@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
-debug@^3.1.0:
- version "3.2.7"
- resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
- integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
- dependencies:
- ms "^2.1.1"
-
-debug@^4.1.1, debug@^4.3.2:
+debug@4, debug@4.3.4, debug@^4.1.1, debug@^4.3.2:
version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
dependencies:
ms "2.1.2"
+debug@^2.6.9:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+ dependencies:
+ ms "2.0.0"
+
+debug@^3.1.0, debug@^3.2.7:
+ version "3.2.7"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+ integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
+ dependencies:
+ ms "^2.1.1"
+
deep-equal@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw==
+deep-extend@~0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+ integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
+deepmerge@^4.2.2:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.0.tgz#65491893ec47756d44719ae520e0e2609233b59b"
+ integrity sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==
+
define-lazy-prop@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
@@ -685,6 +1419,16 @@ destroy@^1.0.4:
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+devtools-protocol@0.0.981744:
+ version "0.0.981744"
+ resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
+ integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
+
+diff@^5.0.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
+ integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
+
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@@ -707,15 +1451,149 @@ encodeurl@^1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+ version "1.4.4"
+ resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+ integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
+ dependencies:
+ once "^1.4.0"
+
errorstacks@^2.2.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/errorstacks/-/errorstacks-2.4.0.tgz#2155674dd9e741aacda3f3b8b967d9c40a4a0baf"
integrity sha512-5ecWhU5gt0a5G05nmQcgCxP5HperSMxLDzvWlT5U+ZSKkuDK0rJ3dbCQny6/vSCIXjwrhwSecXBbw1alr295hQ==
es-module-lexer@^1.0.0:
- version "1.0.3"
- resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.0.3.tgz#f0d8d35b36d13024110000d5e6fadc8eeaeb66b8"
- integrity sha512-iC67eXHToclrlVhQfpRawDiF8D8sQxNxmbqw5oebegOaJkyx/w9C/k57/5e6yJR2zIByRt9OXdqX50DV2t6ZKw==
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.2.0.tgz#812264973b613195ba214f69a84e05b0f4241a67"
+ integrity sha512-2BMfqBDeVCcOlLaL1ZAfp+D868SczNpKArrTM3dhpd7dK/OVlogzY15qpUngt+LMTq5UC/csb9vVQAgupucSbA==
+
+esbuild-android-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+ integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+ integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+ integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+ integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+ integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+ integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+ integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
+esbuild-linux-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz#de5fdba1c95666cf72369f52b40b03be71226652"
+ integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
+
+esbuild-linux-arm64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+ integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+ integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+ integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+ integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+ integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+ integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+ integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+ integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
+esbuild-sunos-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+ integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+ integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+ integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.14.54:
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+ integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
+"esbuild@^0.12 || ^0.13 || ^0.14":
+ version "0.14.54"
+ resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.54.tgz#8b44dcf2b0f1a66fc22459943dccf477535e9aa2"
+ integrity sha512-Cy9llcy8DvET5uznocPyqL3BFRrFXSVqbgpMJ9Wz8oVjZlh/zUSNbPRbov0VX7VxN2JH1Oa0uNxZ7eLRb62pJA==
+ optionalDependencies:
+ "@esbuild/linux-loong64" "0.14.54"
+ esbuild-android-64 "0.14.54"
+ esbuild-android-arm64 "0.14.54"
+ esbuild-darwin-64 "0.14.54"
+ esbuild-darwin-arm64 "0.14.54"
+ esbuild-freebsd-64 "0.14.54"
+ esbuild-freebsd-arm64 "0.14.54"
+ esbuild-linux-32 "0.14.54"
+ esbuild-linux-64 "0.14.54"
+ esbuild-linux-arm "0.14.54"
+ esbuild-linux-arm64 "0.14.54"
+ esbuild-linux-mips64le "0.14.54"
+ esbuild-linux-ppc64le "0.14.54"
+ esbuild-linux-riscv64 "0.14.54"
+ esbuild-linux-s390x "0.14.54"
+ esbuild-netbsd-64 "0.14.54"
+ esbuild-openbsd-64 "0.14.54"
+ esbuild-sunos-64 "0.14.54"
+ esbuild-windows-32 "0.14.54"
+ esbuild-windows-64 "0.14.54"
+ esbuild-windows-arm64 "0.14.54"
escape-html@^1.0.3:
version "1.0.3"
@@ -727,11 +1605,32 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
+escape-string-regexp@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+ integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+estree-walker@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-1.0.1.tgz#31bc5d612c96b704106b477e6dd5d8aa138cb700"
+ integrity sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==
+
etag@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+extract-zip@2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
+ integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
+ dependencies:
+ debug "^4.1.1"
+ get-stream "^5.1.0"
+ yauzl "^2.10.0"
+ optionalDependencies:
+ "@types/yauzl" "^2.9.1"
+
fast-glob@^3.2.9:
version "3.2.12"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
@@ -744,12 +1643,19 @@ fast-glob@^3.2.9:
micromatch "^4.0.4"
fastq@^1.6.0:
- version "1.13.0"
- resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c"
- integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==
+ version "1.15.0"
+ resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
+ integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==
dependencies:
reusify "^1.0.4"
+fd-slicer@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+ integrity sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==
+ dependencies:
+ pend "~1.2.0"
+
fill-range@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
@@ -757,11 +1663,36 @@ fill-range@^7.0.1:
dependencies:
to-regex-range "^5.0.1"
+find-replace@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
+ integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
+ dependencies:
+ array-back "^3.0.1"
+
+find-up@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+ integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+ dependencies:
+ locate-path "^5.0.0"
+ path-exists "^4.0.0"
+
fresh@~0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+fs-constants@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
+ integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
+
+fs.realpath@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+ integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+
fsevents@~2.3.2:
version "2.3.2"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
@@ -773,14 +1704,21 @@ function-bind@^1.1.1:
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
get-intrinsic@^1.0.2:
- version "1.1.3"
- resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385"
- integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f"
+ integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==
dependencies:
function-bind "^1.1.1"
has "^1.0.3"
has-symbols "^1.0.3"
+get-stream@^5.1.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
+ integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
+ dependencies:
+ pump "^3.0.0"
+
get-stream@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
@@ -793,6 +1731,18 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
+glob@^7.1.3:
+ version "7.2.3"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+ integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^3.1.1"
+ once "^1.3.0"
+ path-is-absolute "^1.0.0"
+
globby@^11.0.1:
version "11.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
@@ -879,6 +1829,14 @@ http-errors@~1.6.2:
setprototypeof "1.1.0"
statuses ">= 1.4.0 < 2"
+https-proxy-agent@5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
+ integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
+ dependencies:
+ agent-base "6"
+ debug "4"
+
iconv-lite@0.4.24:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -886,26 +1844,39 @@ iconv-lite@0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
+ieee754@^1.1.13:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+ integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
ignore@^5.2.0:
- version "5.2.0"
- resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
- integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+ version "5.2.4"
+ resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324"
+ integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==
inflation@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.0.0.tgz#8b417e47c28f925a45133d914ca1fd389107f30f"
integrity sha512-m3xv4hJYR2oXw4o4Y5l6P5P16WYmazYof+el6Al3f+YlggGj6qT9kImBAnzDelRALnP5d3h4jGBPKzYCizjZZw==
-inherits@2.0.3:
- version "2.0.3"
- resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
- integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+inflight@^1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+ integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
+ dependencies:
+ once "^1.3.0"
+ wrappy "1"
-inherits@2.0.4:
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+inherits@2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+ integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==
+
ip@^1.1.5:
version "1.1.8"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48"
@@ -918,6 +1889,20 @@ is-binary-path@~2.1.0:
dependencies:
binary-extensions "^2.0.0"
+is-builtin-module@^3.1.0:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.1.tgz#f03271717d8654cfcaf07ab0463faa3571581169"
+ integrity sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==
+ dependencies:
+ builtin-modules "^3.3.0"
+
+is-core-module@^2.9.0:
+ version "2.11.0"
+ resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.11.0.tgz#ad4cb3e3863e814523c96f3f58d26cc570ff0144"
+ integrity sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==
+ dependencies:
+ has "^1.0.3"
+
is-docker@^2.0.0, is-docker@^2.1.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
@@ -947,6 +1932,11 @@ is-glob@^4.0.1, is-glob@~4.0.1:
dependencies:
is-extglob "^2.1.1"
+is-module@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
+ integrity sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==
+
is-number@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
@@ -964,6 +1954,11 @@ is-wsl@^2.2.0:
dependencies:
is-docker "^2.0.0"
+isarray@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
+ integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==
+
isbinaryfile@^4.0.6:
version "4.0.10"
resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
@@ -996,6 +1991,11 @@ js-tokens@^4.0.0:
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
+just-extend@^4.0.2:
+ version "4.2.1"
+ resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.2.1.tgz#ef5e589afb61e5d66b24eca749409a8939a8c744"
+ integrity sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==
+
keygrip@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
@@ -1041,9 +2041,9 @@ koa-static@^5.0.0:
koa-send "^5.0.0"
koa@^2.13.0:
- version "2.13.4"
- resolved "https://registry.yarnpkg.com/koa/-/koa-2.13.4.tgz#ee5b0cb39e0b8069c38d115139c774833d32462e"
- integrity sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==
+ version "2.14.1"
+ resolved "https://registry.yarnpkg.com/koa/-/koa-2.14.1.tgz#defb9589297d8eb1859936e777f3feecfc26925c"
+ integrity sha512-USJFyZgi2l0wDgqkfD27gL4YGno7TfUkcmOe6UOLFOVuN+J7FwnNu4Dydl4CUQzraM1lBAiGed0M9OVJoT0Kqw==
dependencies:
accepts "^1.3.5"
cache-content-type "^1.0.0"
@@ -1069,45 +2069,59 @@ koa@^2.13.0:
type-is "^1.6.16"
vary "^1.1.2"
+lighthouse-logger@^1.0.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/lighthouse-logger/-/lighthouse-logger-1.3.0.tgz#ba6303e739307c4eee18f08249524e7dafd510db"
+ integrity sha512-BbqAKApLb9ywUli+0a+PcV04SyJ/N1q/8qgCNe6U97KbPCS1BTksEuHFLYdvc8DltuhfxIUBqDZsC0bBGtl3lA==
+ dependencies:
+ debug "^2.6.9"
+ marky "^1.2.2"
+
lit-element@^3.2.0:
- version "3.2.0"
- resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.0.tgz#9c981c55dfd9a8f124dc863edb62cc529d434db7"
- integrity sha512-HbE7yt2SnUtg5DCrWt028oaU4D5F4k/1cntAFHTkzY8ZIa8N0Wmu92PxSxucsQSOXlODFrICkQ5x/tEshKi13g==
+ version "3.2.2"
+ resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.2.2.tgz#d148ab6bf4c53a33f707a5168e087725499e5f2b"
+ integrity sha512-6ZgxBR9KNroqKb6+htkyBwD90XGRiqKDHVrW/Eh0EZ+l+iC+u+v+w3/BA5NGi4nizAVHGYvQBHUDuSmLjPp7NQ==
dependencies:
"@lit/reactive-element" "^1.3.0"
lit-html "^2.2.0"
-lit-html@^2.0.0, lit-html@^2.3.0:
- version "2.3.1"
- resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.3.1.tgz#56f15104ea75c0a702904893e3409d0e89e2a2b9"
- integrity sha512-FyKH6LTW6aBdkfNhNSHyZTnLgJSTe5hMk7HFtc/+DcN1w74C215q8B+Cfxc2OuIEpBNcEKxgF64qL8as30FDHA==
- dependencies:
- "@types/trusted-types" "^2.0.2"
-
-lit-html@^2.2.0:
- version "2.2.6"
- resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.2.6.tgz#e70679605420a34c4f3cbd0c483b2fb1fff781df"
- integrity sha512-xOKsPmq/RAKJ6dUeOxhmOYFjcjf0Q7aSdfBJgdJkOfCUnkmmJPxNrlZpRBeVe1Gg50oYWMlgm6ccAE/SpJgSdw==
+lit-html@^2.0.0, lit-html@^2.2.0, lit-html@^2.6.0:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.6.1.tgz#eb29f0b0c2ab54ea77379db11fc011b0c71f1cda"
+ integrity sha512-Z3iw+E+3KKFn9t2YKNjsXNEu/LRLI98mtH/C6lnFg7kvaqPIzPn124Yd4eT/43lyqrejpc5Wb6BHq3fdv4S8Rw==
dependencies:
"@types/trusted-types" "^2.0.2"
-lit@^2.0.0:
- version "2.3.1"
- resolved "https://registry.yarnpkg.com/lit/-/lit-2.3.1.tgz#2cf1c2042da1e44c7a7cc72dff2d72303fd26f48"
- integrity sha512-TejktDR4mqG3qB32Y8Lm5Lye3c8SUehqz7qRsxe1PqGYL6me2Ef+jeQAEqh20BnnGncv4Yxy2njEIT0kzK1WCw==
+lit@^2.0.0, lit@^2.2.3:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/lit/-/lit-2.6.1.tgz#5951a2098b9bde5b328c73b55c15fdc0eefd96d7"
+ integrity sha512-DT87LD64f8acR7uVp7kZfhLRrHkfC/N4BVzAtnw9Yg8087mbBJ//qedwdwX0kzDbxgPccWRW6mFwGbRQIxy0pw==
dependencies:
- "@lit/reactive-element" "^1.4.0"
+ "@lit/reactive-element" "^1.6.0"
lit-element "^3.2.0"
- lit-html "^2.3.0"
+ lit-html "^2.6.0"
-lit@^2.2.3:
- version "2.2.6"
- resolved "https://registry.yarnpkg.com/lit/-/lit-2.2.6.tgz#4ef223e88517c000b0c01baf2e3535e61a75a5b5"
- integrity sha512-K2vkeGABfSJSfkhqHy86ujchJs3NR9nW1bEEiV+bXDkbiQ60Tv5GUausYN2mXigZn8lC1qXuc46ArQRKYmumZw==
+locate-path@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+ integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
dependencies:
- "@lit/reactive-element" "^1.3.0"
- lit-element "^3.2.0"
- lit-html "^2.2.0"
+ p-locate "^4.1.0"
+
+lodash.camelcase@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
+ integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
+
+lodash.get@^4.4.2:
+ version "4.4.2"
+ resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
+ integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==
+
+lodash@^4.17.14:
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+ integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-update@^4.0.0:
version "4.0.0"
@@ -1133,6 +2147,11 @@ make-dir@^3.0.0:
dependencies:
semver "^6.0.0"
+marky@^1.2.2:
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
+ integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
+
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -1168,11 +2187,40 @@ mimic-fn@^2.1.0:
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+minimatch@^3.1.1:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+ integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+ dependencies:
+ brace-expansion "^1.1.7"
+
+minimist@^1.2.6:
+ version "1.2.8"
+ resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+ integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
+
+mkdirp-classic@^0.5.2:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113"
+ integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==
+
+mkdirp@^0.5.6:
+ version "0.5.6"
+ resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+ integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+ dependencies:
+ minimist "^1.2.6"
+
mkdirp@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+ integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@@ -1198,15 +2246,33 @@ negotiator@0.6.3:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+nise@^5.1.1:
+ version "5.1.4"
+ resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.4.tgz#491ce7e7307d4ec546f5a659b2efe94a18b4bbc0"
+ integrity sha512-8+Ib8rRJ4L0o3kfmyVCL7gzrohyDe0cMFTBa2d364yIrEGMEoetznKJx899YxjybU6bL9SQkYPSBBs1gyYs8Xg==
+ dependencies:
+ "@sinonjs/commons" "^2.0.0"
+ "@sinonjs/fake-timers" "^10.0.2"
+ "@sinonjs/text-encoding" "^0.7.1"
+ just-extend "^4.0.2"
+ path-to-regexp "^1.7.0"
+
+node-fetch@2.6.7:
+ version "2.6.7"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+ integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+ dependencies:
+ whatwg-url "^5.0.0"
+
normalize-path@^3.0.0, normalize-path@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
object-inspect@^1.9.0:
- version "1.12.2"
- resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
- integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
+ version "1.12.3"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
+ integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
on-finished@^2.3.0:
version "2.4.1"
@@ -1215,6 +2281,13 @@ on-finished@^2.3.0:
dependencies:
ee-first "1.1.1"
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+ integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
+ dependencies:
+ wrappy "1"
+
onetime@^5.1.0:
version "5.1.2"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
@@ -1228,14 +2301,33 @@ only@~0.0.2:
integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
open@^8.0.2:
- version "8.4.0"
- resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8"
- integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==
+ version "8.4.2"
+ resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
+ integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
dependencies:
define-lazy-prop "^2.0.0"
is-docker "^2.1.1"
is-wsl "^2.2.0"
+p-limit@^2.2.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+ integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+ dependencies:
+ p-try "^2.0.0"
+
+p-locate@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+ integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+ dependencies:
+ p-limit "^2.2.0"
+
+p-try@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+ integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
parse5@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
@@ -1246,21 +2338,100 @@ parseurl@^1.3.2:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
-path-is-absolute@1.0.1:
+path-exists@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+ integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-is-absolute@1.0.1, path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
+path-parse@^1.0.7:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+ integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+path-to-regexp@^1.7.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
+ integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
+ dependencies:
+ isarray "0.0.1"
+
path-type@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+pend@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+ integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
+
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2, picomatch@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+pkg-dir@4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+ integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+ dependencies:
+ find-up "^4.0.0"
+
+portfinder@^1.0.32:
+ version "1.0.32"
+ resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.32.tgz#2fe1b9e58389712429dc2bea5beb2146146c7f81"
+ integrity sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==
+ dependencies:
+ async "^2.6.4"
+ debug "^3.2.7"
+ mkdirp "^0.5.6"
+
+progress@2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+ integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
+proxy-from-env@1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
+ integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+
+pump@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+ integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+ dependencies:
+ end-of-stream "^1.1.0"
+ once "^1.3.1"
+
+punycode@^2.1.1:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
+ integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
+
+puppeteer-core@^13.1.3:
+ version "13.7.0"
+ resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-13.7.0.tgz#3344bee3994163f49120a55ddcd144a40575ba5b"
+ integrity sha512-rXja4vcnAzFAP1OVLq/5dWNfwBGuzcOARJ6qGV7oAZhnLmVRU8G5MsdeQEAOy332ZhkIOnn9jp15R89LKHyp2Q==
+ dependencies:
+ cross-fetch "3.1.5"
+ debug "4.3.4"
+ devtools-protocol "0.0.981744"
+ extract-zip "2.0.1"
+ https-proxy-agent "5.0.1"
+ pkg-dir "4.2.0"
+ progress "2.0.3"
+ proxy-from-env "1.1.0"
+ rimraf "3.0.2"
+ tar-fs "2.1.1"
+ unbzip2-stream "1.4.3"
+ ws "8.5.0"
+
qs@^6.5.2:
version "6.11.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
@@ -1274,15 +2445,24 @@ queue-microtask@^1.2.2:
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
raw-body@^2.3.3:
- version "2.5.1"
- resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"
- integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+ integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
dependencies:
bytes "3.1.2"
http-errors "2.0.0"
iconv-lite "0.4.24"
unpipe "1.0.0"
+readable-stream@^3.1.1, readable-stream@^3.4.0:
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.1.tgz#f9f9b5f536920253b3d26e7660e7da4ccff9bb62"
+ integrity sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==
+ dependencies:
+ inherits "^2.0.3"
+ string_decoder "^1.1.1"
+ util-deprecate "^1.0.1"
+
readdirp@~3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
@@ -1290,6 +2470,11 @@ readdirp@~3.6.0:
dependencies:
picomatch "^2.2.1"
+reduce-flatten@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
+ integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
+
resolve-path@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7"
@@ -1298,6 +2483,15 @@ resolve-path@^1.4.0:
http-errors "~1.6.2"
path-is-absolute "1.0.1"
+resolve@^1.19.0:
+ version "1.22.1"
+ resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177"
+ integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==
+ dependencies:
+ is-core-module "^2.9.0"
+ path-parse "^1.0.7"
+ supports-preserve-symlinks-flag "^1.0.0"
+
restore-cursor@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
@@ -1311,6 +2505,20 @@ reusify@^1.0.4:
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
+rimraf@3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+ integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+ dependencies:
+ glob "^7.1.3"
+
+rollup@^2.67.0:
+ version "2.79.1"
+ resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.79.1.tgz#bedee8faef7c9f93a2647ac0108748f497f081c7"
+ integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==
+ optionalDependencies:
+ fsevents "~2.3.2"
+
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -1325,16 +2533,11 @@ rxjs@^6.6.7:
dependencies:
tslib "^1.9.0"
-safe-buffer@5.2.1:
+safe-buffer@5.2.1, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
-safe-buffer@~5.1.1:
- version "5.1.2"
- resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
- integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-
"safer-buffer@>= 2.1.2 < 3":
version "2.1.2"
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
@@ -1345,6 +2548,13 @@ semver@^6.0.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
+semver@^7.3.4:
+ version "7.3.8"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
+ integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
+ dependencies:
+ lru-cache "^6.0.0"
+
setprototypeof@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
@@ -1369,6 +2579,18 @@ signal-exit@^3.0.2:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
+sinon@^13.0.0:
+ version "13.0.2"
+ resolved "https://registry.yarnpkg.com/sinon/-/sinon-13.0.2.tgz#c6a8ddd655dc1415bbdc5ebf0e5b287806850c3a"
+ integrity sha512-KvOrztAVqzSJWMDoxM4vM+GPys1df2VBoXm+YciyB/OLMamfS3VXh3oGh5WtrAGSzrgczNWFFY22oKb7Fi5eeA==
+ dependencies:
+ "@sinonjs/commons" "^1.8.3"
+ "@sinonjs/fake-timers" "^9.1.2"
+ "@sinonjs/samsam" "^6.1.1"
+ diff "^5.0.0"
+ nise "^5.1.1"
+ supports-color "^7.2.0"
+
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
@@ -1407,6 +2629,13 @@ string-width@^4.1.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
+string_decoder@^1.1.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+ integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+ dependencies:
+ safe-buffer "~5.2.0"
+
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
@@ -1414,6 +2643,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1:
dependencies:
ansi-regex "^5.0.1"
+style-mod@^4.0.0:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.0.3.tgz#136c4abc905f82a866a18b39df4dc08ec762b1ad"
+ integrity sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw==
+
supports-color@^5.3.0:
version "5.5.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -1421,13 +2655,54 @@ supports-color@^5.3.0:
dependencies:
has-flag "^3.0.0"
-supports-color@^7.1.0:
+supports-color@^7.1.0, supports-color@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
dependencies:
has-flag "^4.0.0"
+supports-preserve-symlinks-flag@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+ integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+table-layout@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.2.tgz#c4038a1853b0136d63365a734b6931cf4fad4a04"
+ integrity sha512-qd/R7n5rQTRFi+Zf2sk5XVVd9UQl6ZkduPFC3S7WEGJAmetDTjY3qPN50eSKzwuzEyQKy5TN2TiZdkIjos2L6A==
+ dependencies:
+ array-back "^4.0.1"
+ deep-extend "~0.6.0"
+ typical "^5.2.0"
+ wordwrapjs "^4.0.0"
+
+tar-fs@2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.1.tgz#489a15ab85f1f0befabb370b7de4f9eb5cbe8784"
+ integrity sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==
+ dependencies:
+ chownr "^1.1.1"
+ mkdirp-classic "^0.5.2"
+ pump "^3.0.0"
+ tar-stream "^2.1.4"
+
+tar-stream@^2.1.4:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+ integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
+ dependencies:
+ bl "^4.0.3"
+ end-of-stream "^1.4.1"
+ fs-constants "^1.0.0"
+ inherits "^2.0.3"
+ readable-stream "^3.1.1"
+
+through@^2.3.8:
+ version "2.3.8"
+ resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+ integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@@ -1440,6 +2715,18 @@ toidentifier@1.0.1:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+tr46@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9"
+ integrity sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==
+ dependencies:
+ punycode "^2.1.1"
+
+tr46@~0.0.3:
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+ integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@@ -1450,6 +2737,11 @@ tsscmp@1.0.6:
resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==
+type-detect@4.0.8, type-detect@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c"
+ integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==
+
type-fest@^0.21.3:
version "0.21.3"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
@@ -1463,16 +2755,92 @@ type-is@^1.6.16:
media-typer "0.3.0"
mime-types "~2.1.24"
+typical@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
+ integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
+
+typical@^5.2.0:
+ version "5.2.0"
+ resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
+ integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
+
+ua-parser-js@^1.0.2:
+ version "1.0.33"
+ resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.33.tgz#f21f01233e90e7ed0f059ceab46eb190ff17f8f4"
+ integrity sha512-RqshF7TPTE0XLYAqmjlu5cLLuGdKrNu9O1KLA/qp39QtbZwuzwv1dT46DZSopoUMsYgXpB3Cv8a03FI8b74oFQ==
+
+unbzip2-stream@1.4.3:
+ version "1.4.3"
+ resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7"
+ integrity sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==
+ dependencies:
+ buffer "^5.2.1"
+ through "^2.3.8"
+
unpipe@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+util-deprecate@^1.0.1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+ integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
+
+v8-to-istanbul@^8.0.0:
+ version "8.1.1"
+ resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz#77b752fd3975e31bbcef938f85e9bd1c7a8d60ed"
+ integrity sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==
+ dependencies:
+ "@types/istanbul-lib-coverage" "^2.0.1"
+ convert-source-map "^1.6.0"
+ source-map "^0.7.3"
+
vary@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+w3c-keyname@^2.2.4:
+ version "2.2.6"
+ resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f"
+ integrity sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==
+
+webidl-conversions@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+ integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
+webidl-conversions@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+ integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-url@^11.0.0:
+ version "11.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-11.0.0.tgz#0a849eebb5faf2119b901bb76fd795c2848d4018"
+ integrity sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==
+ dependencies:
+ tr46 "^3.0.0"
+ webidl-conversions "^7.0.0"
+
+whatwg-url@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+ integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+ dependencies:
+ tr46 "~0.0.3"
+ webidl-conversions "^3.0.0"
+
+wordwrapjs@^4.0.0:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
+ integrity sha512-kKlNACbvHrkpIw6oPeYDSmdCTu2hdMHoyXLTcUKala++lx5Y+wjJ/e474Jqv5abnVmwxw08DiTuHmw69lJGksA==
+ dependencies:
+ reduce-flatten "^2.0.0"
+ typical "^5.2.0"
+
wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -1482,6 +2850,16 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
+wrappy@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+ integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+
+ws@8.5.0:
+ version "8.5.0"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+ integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
ws@^7.4.2:
version "7.5.9"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591"
@@ -1492,6 +2870,14 @@ yallist@^4.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+yauzl@^2.10.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+ integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
+ dependencies:
+ buffer-crc32 "~0.2.3"
+ fd-slicer "~1.1.0"
+
ylru@^1.2.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md
index 6673cdf13b..d2b865b803 100644
--- a/polygerrit-ui/FE_Style_Guide.md
+++ b/polygerrit-ui/FE_Style_Guide.md
@@ -125,10 +125,6 @@ as parameters in the constructor.
Do not use getAppContext() anywhere else in a class.
-**Note:** This rule doesn't apply for HTML/Polymer elements classes. A browser creates instances of such classes
-implicitly and calls the constructor without parameters. See
-[Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor)
-
**Good:**
```Javascript
export class UserService {
@@ -160,90 +156,3 @@ export class AdminService {
}
```
-
-## <a name="assign-dependencies-in-html-element-constructor"></a>Assign required services in a HTML/Polymer element constructor
-If a class is a custom HTML/Polymer element, the class must assign all required services in the constructor.
-A browser creates instances of such classes implicitly, so it is impossible to pass anything as a parameter to
-the element's class constructor.
-
-Do not use appContext anywhere except the constructor of the class.
-
-**Note for legacy elements:** If a polymer element extends a LegacyElementMixin and overrides the `created()` method,
-move all code from this method to a constructor right after the call to a `super()`
-([example](#assign-dependencies-legacy-element-example)). The `created()`
-method is [deprecated](https://polymer-library.polymer-project.org/2.0/docs/about_20#lifecycle-changes) and is called
-when a super (i.e. base) class constructor is called. If you are unsure about moving the code from the `created` method
-to the class constructor, consult with the source code:
-[`LegacyElementMixin._initializeProperties`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/legacy/legacy-element-mixin.js#L318)
-and
-[`PropertiesChanged.constructor`](https://github.com/Polymer/polymer/blob/v3.4.0/lib/mixins/properties-changed.js#L177)
-
-
-
-**Good:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
- constructor() {
- super(); //This is mandatory to call parent constructor
- this._userModel = appContext.userModel;
- }
- //...
- _getUserName() {
- return this._userModel.activeUserName();
- }
-}
-```
-
-**Bad:**
-```Javascript
-import {appContext} from `.../services/app-context.js`;
-
-export class MyCustomElement extends ...{
- created() {
- // Incorrect: assign all dependencies in the constructor
- this._userModel = appContext.userModel;
- }
- //...
- _getUserName() {
- // Incorrect: use appContext outside of a constructor
- return appContext.userModel.activeUserName();
- }
-}
-```
-
-<a name="assign-dependencies-legacy-element-example"></a>
-**Legacy element:**
-
-Before:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
- constructor() {
- super();
- someAction();
- }
- created() {
- super();
- createdAction1();
- createdAction2();
- }
-}
-```
-
-After:
-```Javascript
-export class MyCustomElement extends ...LegacyElementMixin(...) {
- constructor() {
- super();
- // Assign services here
- this._userModel = appContext.userModel;
- // Code from the created method - put it before existing actions in constructor
- createdAction1();
- createdAction2();
- // Original constructor code
- someAction();
- }
- // created method is removed
-}
-```
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index d599230721..0848925c07 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -24,11 +24,13 @@ cd gerrit && (
Follow the instructions
[here](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_installation)
-to get and install Bazel.
+to get and install Bazel. The `npm install -g @bazel/bazelisk` method is
+probably easiest since you will have npm as part of Nodejs.
## Installing [Node.js](https://nodejs.org/en/download/) and npm packages
-The minimum nodejs version supported is 10.x+.
+The minimum nodejs version supported is 10.x+. We recommend at least the latest
+LTS (v16 as of October 2022).
```sh
# Debian experimental
@@ -80,11 +82,12 @@ yarn remove @bazel/...
## Setup typescript support in the IDE
-Modern IDE should automatically handle typescript settings from the
-`polygerrit-ui/app/tsconfig.json` files. IDE places compiled files in the
-`.ts-out/pg` directory at the root of gerrit workspace and you can configure IDE
-to exclude the whole .ts-out directory. To do it in the IntelliJ IDEA click on
-this directory and select "Mark Directory As > Excluded" in the context menu.
+Modern IDEs should automatically handle typescript settings from the
+`polygerrit-ui/app/tsconfig.json` files. The `tsc` compiler places compiled
+files in the `.ts-out/pg` directory at the root of gerrit workspace and you can
+configure the IDE to exclude the whole .ts-out directory. To do it in the
+IntelliJ IDEA click on this directory and select "Mark Directory As > Excluded"
+in the context menu.
However, if you receive some errors from IDE, you can try to configure IDE
manually. For example, if IntelliJ IDEA shows
@@ -92,22 +95,27 @@ manually. For example, if IntelliJ IDEA shows
options `--project polygerrit-ui/app/tsconfig.json` in the IDE settings.
-## Serving files locally
+## Developing locally
-#### Web Dev Server
+The preferred method for development is to serve the web files locally using the
+Web Dev Server and then view a running gerrit instance (local or otherwise) to
+replace its web client with the local one using the Gerrit FE Dev Helper
+extension.
-To test the local frontend against production data or a local test site execute:
+### Web Dev Server
+
+The [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/) serves
+the compiled web files and dependencies unbundled over localhost. Start it using
+this command:
```sh
yarn start
```
-This command starts the [Web Dev Server](https://modern-web.dev/docs/dev-server/overview/).
To inject plugins or other files, we use the [Gerrit FE Dev Helper](https://chrome.google.com/webstore/detail/gerrit-fe-dev-helper/jimgomcnodkialnpmienbomamgomglkd) Chrome extension.
If any issues occured, please refer to the Troubleshooting section at the bottom or contact the team!
-## Running locally against production data
### Chrome extension: Gerrit FE Dev Helper
@@ -120,7 +128,7 @@ The source code is in [Gerrit - gerrit-fe-dev-helper](https://gerrit-review.goog
To use this extension, just follow its [readme here](https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/master/README.md).
-## Running locally against a Gerrit test site
+### Running locally against a Gerrit test site
Set up a local test site once:
@@ -144,62 +152,49 @@ $(bazel info output_base)/external/local_jdk/bin/java \
--dev-cdn http://localhost:8081
```
+The Web Dev Server is currently not serving fonts or other static assets. Follow
+[Issue 16341](https://bugs.chromium.org/p/gerrit/issues/detail?id=16341) for
+fixing this issue.
+
*NOTE* You can use any other cdn here, for example: https://cdn.googlesource.com/polygerrit_ui/678.0
## Running Tests
For daily development you typically only want to run and debug individual tests.
-There are several ways to run tests.
+Our tests run using the
+[Web Test Runner](https://modern-web.dev/docs/test-runner/overview/). There are
+several ways to trigger tests:
-* Run all tests:
+* Run all tests once:
```sh
yarn test
```
-* Run all tests under bazel:
+* Run all tests and then watches for changes. Change a file will trigger all
+tests affected by the changes.
+```sh
+yarn test:watch
+```
+
+* Run all tests once under bazel:
```sh
./polygerrit-ui/app/run_test.sh
```
-* Run a single test file:
+* Run a single test file and rerun on any changes affecting it:
```
-yarn test:single "**/async-foreach-behavior_test.js"
+yarn test:single "**/gr-comment_test.ts"
```
Compiling code:
```sh
# Compile frontend once to check for type errors:
-yarn compile:local
+yarn compile
# Watch mode:
-## Terminal 1:
yarn compile:watch
-## Terminal 2, test & watch a file for example:
-yarn test:single "**/async-foreach-behavior_test.js"
```
-### Generated file overview
-
-A generated file starts with imports followed by a static content with
-different type definitions. You can skip this part - it doesn't contains
-anything usefule.
-
-After the static content there is a class definition. Example:
-```typescript
-export class GrCreateGroupDialogCheck extends GrCreateGroupDialog {
- templateCheck() {
- // Converted template
- // Each HTML element from the template is wrapped into own block.
- }
-}
-```
-
-The converted template usually quite straightforward, but in some cases
-additional functions are added. For example, `<element x=[[y.a]]>` converts into
-`el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
-then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
-if `a` is defined only in one type of a union).
-
## Style guide
We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index c51946560e..e18a3af56b 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -327,6 +327,8 @@ module.exports = {
'error',
{argsIgnorePattern: '^_'},
],
+ // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/es-builtins.md
+ 'node/no-unsupported-features/es-builtins': 'off',
// https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
'node/no-unsupported-features/node-builtins': 'off',
// Disable no-invalid-this for ts files, because it incorrectly reports
@@ -399,19 +401,6 @@ module.exports = {
},
},
{
- files: ['test/functional/**/*.js'],
- // Settings for functional tests. These scripts are node scripts.
- // Turn off "no-undef" to allow any global variable
- env: {
- browser: false,
- node: true,
- es6: false,
- },
- rules: {
- 'no-undef': 'off',
- },
- },
- {
files: ['*_html.js', 'gr-icons.js', '*-theme.js', '*-styles.js'],
rules: {
'max-len': 'off',
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 2807a6d163..925820cc54 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -203,7 +203,12 @@ nodejs_test(
"--rules.no-property-visibility-mismatch off",
"--rules.no-incompatible-property-type off",
"--rules.no-incompatible-type-binding off",
- "--rules.no-unknown-attribute error",
+ # TODO: We would actually like to change this to `error`, but we also
+ # want to allow certain attributes, for example `aria-description`. This
+ # would be possible, if we would run the lit-analyzer as a ts plugin.
+ # In tsconfig.json there is an option `globalAttributes` that we could
+ # use. But that is not available when running lit-analyzer as cli.
+ "--rules.no-unknown-attribute warn",
],
)
diff --git a/polygerrit-ui/app/api/annotation.ts b/polygerrit-ui/app/api/annotation.ts
index c670c581cf..c394ef7831 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -3,7 +3,7 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {CoverageRange, Side} from './diff';
+import {CoverageRange} from './diff';
import {ChangeInfo} from './rest-api';
/**
@@ -28,18 +28,5 @@ export declare interface AnnotationPluginApi {
* providers are not supported. A second call will just overwrite the
* provider of the first call.
*/
- setCoverageProvider(coverageProvider: CoverageProvider): AnnotationPluginApi;
-
- /**
- * For plugins notifying Gerrit about new annotations being ready to be
- * applied for a certain range. Gerrit will then re-render the relevant lines
- * of the diff and call back to the layer annotation function that was
- * registered in addLayer().
- *
- * @param path The file path whose listeners should be notified.
- * @param start The line where the update starts.
- * @param end The line where the update ends.
- * @param side The side of the update ('left' or 'right').
- */
- notify(path: string, start: number, end: number, side: Side): void;
+ setCoverageProvider(coverageProvider: CoverageProvider): void;
}
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index e631642ea7..f66c3739e6 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -3,8 +3,7 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {CommentRange} from './core';
-import {ChangeInfo} from './rest-api';
+import {ChangeInfo, CommentRange} from './rest-api';
export declare interface ChecksPluginApi {
/**
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index c44edfba18..d3d268a509 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -9,33 +9,6 @@
*/
/**
- * The CommentRange entity describes the range of an inline comment.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
- *
- * The range includes all characters from the start position, specified by
- * start_line and start_character, to the end position, specified by end_line
- * and end_character. The start position is inclusive and the end position is
- * exclusive.
- *
- * So, a range over part of a line will have start_line equal to end_line;
- * however a range with end_line set to 5 and end_character equal to 0 will not
- * include any characters on line 5.
- */
-export declare interface CommentRange {
- /** The start line number of the range. (1-based) */
- start_line: number;
-
- /** The character position in the start line. (0-based) */
- start_character: number;
-
- /** The end line number of the range. (1-based) */
- end_line: number;
-
- /** The character position in the end line. (0-based) */
- end_character: number;
-}
-
-/**
* Return type for cursor moves, that indicate whether a move was possible.
*/
export enum CursorMoveResult {
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 683638ee2d..4bf253d995 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -9,7 +9,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import {CommentRange, CursorMoveResult} from './core';
+import {CursorMoveResult} from './core';
+import {CommentRange} from './rest-api';
/**
* Diff type in preferences
@@ -201,7 +202,7 @@ export declare interface DiffPreferencesInfo {
syntax_highlighting?: boolean;
tab_size: number;
font_size: number;
- // TODO: Missing documentation
+ // Hides the FILE and LOST diff rows. Default is TRUE.
show_file_comment_button?: boolean;
line_wrapping?: boolean;
}
@@ -242,13 +243,14 @@ export declare interface RenderPreferences {
image_diff_prefs?: ImageDiffPreferences;
responsive_mode?: DiffResponsiveMode;
num_lines_rendered_at_once?: number;
+ show_sign_col?: boolean;
/**
- * If enabled, then a new (experimental) diff rendering is used that is
- * based on Lit components and multiple rendering passes. This is planned to
- * be a temporary setting until the experiment is concluded.
+ * The default view mode is SIDE_BY_SIDE.
+ *
+ * Note that gr-diff also still supports setting viewMode as a dedicated
+ * property on <gr-diff>. TODO: Migrate usages to RenderPreferences.
*/
- use_lit_components?: boolean;
- show_sign_col?: boolean;
+ view_mode?: DiffViewMode;
}
/**
@@ -329,6 +331,12 @@ export declare interface LineNumberEventDetail {
lineNum: LineNumber;
}
+export declare interface LineSelectedEventDetail {
+ number: LineNumber;
+ side: Side;
+ path?: string;
+}
+
// TODO: Currently unused and not fired.
export declare interface RenderProgressEventDetail {
linesRendered: number;
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
index de2e1bfd45..af481fd7a8 100644
--- a/polygerrit-ui/app/api/embed.ts
+++ b/polygerrit-ui/app/api/embed.ts
@@ -24,7 +24,8 @@ declare global {
TokenHighlightLayer: {
new (
container: HTMLElement,
- listener?: TokenHighlightListener
+ listener?: TokenHighlightListener,
+ getTokenQueryContainer?: () => HTMLElement
): DiffLayer;
};
};
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
index a5f7731861..404907a8a1 100644
--- a/polygerrit-ui/app/api/gerrit.ts
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -17,7 +17,7 @@ declare global {
export declare interface Gerrit {
install(
callback: (plugin: PluginApi) => void,
- opt_version?: string,
+ version?: string,
src?: string
): void;
styles: Styles;
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
index 79c8bb6d33..7df755b1db 100644
--- a/polygerrit-ui/app/api/package.json
+++ b/polygerrit-ui/app/api/package.json
@@ -1,9 +1,9 @@
{
"name": "@gerritcodereview/typescript-api",
- "version": "3.7.0",
+ "version": "3.8.0",
"description": "Gerrit Code Review - TypeScript API",
"homepage": "https://www.gerritcodereview.com/",
"browser": true,
"dependencies": {},
"license": "Apache-2.0"
-}
+} \ No newline at end of file
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index b9c065fc18..d3d012dd86 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -14,6 +14,7 @@ import {ReportingPluginApi} from './reporting';
import {ChangeActionsPluginApi} from './change-actions';
import {RestPluginApi} from './rest';
import {HookApi, RegisterOptions} from './hook';
+import {StylePluginApi} from './styles';
export enum TargetElement {
CHANGE_ACTIONS = 'changeactions',
@@ -22,19 +23,15 @@ export enum TargetElement {
// Note: for new events, naming convention should be: `a-b`
export enum EventType {
- HISTORY = 'history',
LABEL_CHANGE = 'labelchange',
SHOW_CHANGE = 'showchange',
SUBMIT_CHANGE = 'submitchange',
SHOW_REVISION_ACTIONS = 'show-revision-actions',
COMMIT_MSG_EDIT = 'commitmsgedit',
- COMMENT = 'comment',
REVERT = 'revert',
REVERT_SUBMISSION = 'revert_submission',
POST_REVERT = 'postrevert',
- ANNOTATE_DIFF = 'annotatediff',
ADMIN_MENU_LINKS = 'admin-menu-links',
- HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
}
export declare interface PluginApi {
@@ -81,10 +78,9 @@ export declare interface PluginApi {
moduleName?: string,
options?: RegisterOptions
): HookApi<T>;
- // DEPRECATED: Just add <style> elements to `document.head`.
- registerStyleModule(endpoint: string, moduleName: string): void;
reporting(): ReportingPluginApi;
restApi(): RestPluginApi;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
screen(screenName: string, moduleName?: string): any;
+ styleApi(): StylePluginApi;
}
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
index 0ed4e0d331..993f24dc8c 100644
--- a/polygerrit-ui/app/api/rest-api.ts
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -142,9 +142,9 @@ export enum ProblemInfoStatus {
}
/**
- * The state of the projects
+ * The state of the repository
*/
-export enum ProjectState {
+export enum RepoState {
ACTIVE = 'ACTIVE',
READ_ONLY = 'READ_ONLY',
HIDDEN = 'HIDDEN',
@@ -452,12 +452,11 @@ export type CloneCommandMap = {[name: string]: string};
*/
export declare interface CommentLinkInfo {
match: string;
- link?: string;
+ link: string;
prefix?: string;
suffix?: string;
text?: string;
enabled?: boolean;
- html?: string;
}
export declare interface CommentLinks {
@@ -489,7 +488,7 @@ export declare interface ConfigArrayParameterInfo
/**
* The ConfigInfo entity contains information about the effective
- * project configuration.
+ * repository configuration.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
*/
export declare interface ConfigInfo {
@@ -508,7 +507,7 @@ export declare interface ConfigInfo {
default_submit_type: SubmitTypeInfo;
submit_type: SubmitType;
match_author_to_committer_date?: InheritedBooleanInfo;
- state?: ProjectState;
+ state?: RepoState;
commentlinks: CommentLinks;
plugin_config?: PluginNameToPluginParametersMap;
actions?: {[viewName: string]: ActionInfo};
@@ -516,6 +515,33 @@ export declare interface ConfigInfo {
enable_reviewer_by_email: InheritedBooleanInfo;
}
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ *
+ * The range includes all characters from the start position, specified by
+ * start_line and start_character, to the end position, specified by end_line
+ * and end_character. The start position is inclusive and the end position is
+ * exclusive.
+ *
+ * So, a range over part of a line will have start_line equal to end_line;
+ * however a range with end_line set to 5 and end_character equal to 0 will not
+ * include any characters on line 5.
+ */
+export declare interface CommentRange {
+ /** The start line number of the range. (1-based) */
+ start_line: number;
+
+ /** The character position in the start line. (0-based) */
+ start_character: number;
+
+ /** The end line number of the range. (1-based) */
+ end_line: number;
+
+ /** The character position in the end line. (0-based) */
+ end_character: number;
+}
+
export declare interface ConfigListParameterInfo
extends ConfigParameterInfoBase {
type: ConfigParameterInfoType.LIST;
@@ -619,6 +645,8 @@ export declare interface FileInfo {
lines_deleted?: number;
size_delta?: number; // in bytes
size?: number; // in bytes
+ old_mode?: number;
+ new_mode?: number;
}
/**
@@ -668,7 +696,6 @@ export declare interface GitPersonInfo {
name: string;
email: EmailAddress;
date: Timestamp;
- tz: TimezoneOffset;
}
export type GroupId = BrandType<string, '_groupId'>;
@@ -740,7 +767,7 @@ export type LabelInfo =
export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
/**
- * The LabelTypeInfo entity contains metadata about the labels that a project
+ * The LabelTypeInfo entity contains metadata about the labels that a repository
* has.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
*/
@@ -756,7 +783,7 @@ export type LabelValueToDescriptionMap = {[labelValue: string]: string};
/**
* The MaxObjectSizeLimitInfo entity contains information about the max object
- * size limit of a project.
+ * size limit of a repository.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
*/
export declare interface MaxObjectSizeLimitInfo {
@@ -835,23 +862,23 @@ export declare interface ProblemInfo {
}
/**
- * The ProjectInfo entity contains information about a project
+ * The ProjectInfo entity contains information about a repository
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
*/
export declare interface ProjectInfo {
id: UrlEncodedRepoName;
- // name is not set if returned in a map where the project name is used as
+ // name is not set if returned in a map where the repo name is used as
// map key
name?: RepoName;
- // ?-<n> if the parent project is not visible (<n> is a number which
- // is increased for each non-visible project).
+ // ?-<n> if the parent repository is not visible (<n> is a number which
+ // is increased for each non-visible repository).
parent?: RepoName;
description?: string;
- state?: ProjectState;
+ state?: RepoState;
branches?: {[branchName: string]: CommitId};
- // labels is filled for Create Project and Get Project calls.
+ // labels is filled for Create Repo and Get Repo calls.
labels?: LabelNameToLabelTypeInfoMap;
- // Links to the project in external sites
+ // Links to the repository in external sites
web_links?: WebLinkInfo[];
}
@@ -1005,8 +1032,8 @@ export type StarLabel = BrandType<string, '_startLabel'>;
// where "'ffffffffff'" represents nanoseconds.
/**
- * Information about the default submittype of a project, taking into account
- * project inheritance.
+ * Information about the default submittype of a repository, taking into account
+ * repository inheritance.
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
*/
export declare interface SubmitTypeInfo {
@@ -1027,8 +1054,6 @@ export declare interface SuggestInfo {
export type Timestamp = BrandType<string, '_timestamp'>;
// The timezone offset from UTC in minutes
-export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
-
export type TopicName = BrandType<string, '_topicName'>;
export type TrackingId = BrandType<string, '_trackingId'>;
@@ -1067,14 +1092,14 @@ export declare interface VotingRangeInfo {
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
*/
export declare interface WebLinkInfo {
- /** The link name. */
+ /** The text to be linkified. */
name: string;
+ /** Tooltip to show when hovering over the link. */
+ tooltip?: string;
/** The link URL. */
url: string;
/** URL to the icon of the link. */
image_url?: string;
- /* Value of the "target" attribute for anchor elements. */
- target?: string;
}
/**
@@ -1169,7 +1194,7 @@ export enum LabelStatus {
/**
* The label is required for submission, but is impossible to complete.
* The likely cause is access has not been granted correctly by the
- * project owner or site administrator.
+ * repository owner or site administrator.
*/
IMPOSSIBLE = 'IMPOSSIBLE',
OPTIONAL = 'OPTIONAL',
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index 283e029f3b..a09f711ce5 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -15,7 +15,10 @@ export enum HttpMethod {
PUT = 'PUT',
}
-export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+export type ErrorCallback = (
+ response?: Response | null,
+ err?: Error
+) => Promise<void> | void;
export declare interface RestPluginApi {
getLoggedIn(): Promise<boolean>;
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
index 1e1f60a92a..6ca84962a9 100644
--- a/polygerrit-ui/app/api/styles.ts
+++ b/polygerrit-ui/app/api/styles.ts
@@ -22,6 +22,7 @@ export declare interface Style {
toString(): string;
}
+/** Accessible via `window.Gerrit.styles`. */
export declare interface Styles {
font: Style;
form: Style;
@@ -30,4 +31,24 @@ export declare interface Styles {
spinner: Style;
subPage: Style;
table: Style;
+ modal: Style;
+}
+
+/** Accessible via `window.Gerrit.install(plugin => {plugin.styleApi()})`. */
+export declare interface StylePluginApi {
+ /**
+ * Convenience method for adding a CSS rule to a <style> element in <head>.
+ *
+ * Note that you can only insert one rule per call. See `insertRule()`
+ * documentation of `CSSStyleSheet`:
+ * https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/insertRule
+ *
+ * @param rule the css rule, e.g.:
+ * ```
+ * html.darkTheme {
+ * --header-background-color: blue;
+ * }
+ * ```
+ */
+ insertCSSRule(rule: string): void;
}
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index bb7b3130af..b9ed56b1b7 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -22,7 +22,7 @@ import {
InheritedBooleanInfoConfiguredValue,
MergeabilityComputationBehavior,
ProblemInfoStatus,
- ProjectState,
+ RepoState,
RequirementStatus,
ReviewerState,
RevisionKind,
@@ -41,7 +41,7 @@ export {
InheritedBooleanInfoConfiguredValue,
MergeabilityComputationBehavior,
ProblemInfoStatus,
- ProjectState,
+ RepoState,
RequirementStatus,
ReviewerState,
RevisionKind,
@@ -258,6 +258,8 @@ export enum AccountsVisibility {
NONE = 'NONE',
}
+// These defaults should match the defaults in
+// java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
export function createDefaultPreferences(): PreferencesInfo {
return {
changes_per_page: 25,
@@ -265,8 +267,8 @@ export function createDefaultPreferences(): PreferencesInfo {
size_bar_in_change_table: true,
my: [],
theme: AppTheme.AUTO,
- date_format: DateFormat.EURO,
- time_format: TimeFormat.HHMM_24,
+ date_format: DateFormat.STD,
+ time_format: TimeFormat.HHMM_12,
change_table: [],
email_strategy: EmailStrategy.ATTENTION_SET_ONLY,
default_base_for_merges: DefaultBase.AUTO_MERGE,
@@ -319,6 +321,4 @@ export function createDefaultEditPrefs(): EditPreferencesInfo {
export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
-export const SHOWN_ITEMS_COUNT = 25;
-
export const WAITING = 'Waiting';
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0e00d07522..ad59edd625 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -14,6 +14,8 @@ export enum LifeCycle {
PLUGINS_INSTALLED = 'Plugins installed',
PLUGINS_FAILED = 'Some plugins failed to load',
USER_REFERRED_FROM = 'User referred from',
+ NOTIFICATION_PERMISSION = 'Notification Permission',
+ SERVICE_WORKER_UPDATE = 'Service worker update',
}
export enum Execution {
@@ -91,6 +93,8 @@ export enum Timing {
FID = 'FID',
// WebVitals - Largest Contentful Paint (LCP): measures loading performance.
LCP = 'LCP',
+ // WebVitals - Interaction to Next Paint (INP): measures responsiveness
+ INP = 'INP',
}
export enum Interaction {
@@ -120,32 +124,7 @@ export enum Interaction {
CHECKS_RUNS_PANEL_TOGGLE = 'checks-runs-panel-toggle',
CHECKS_RUNS_SELECTED_TRIGGERED = 'checks-runs-selected-triggered',
CHECKS_STATS = 'checks-stats',
- // The following interactions are logged for investigating a spurious bug of
- // auto-closing draft comments.
- COMMENTS_AUTOCLOSE_FIRST_UPDATE = 'comments-autoclose-first-update',
- COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE = 'comments-autoclose-editing-false-save',
- COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED = 'comments-autoclose-editing-disconnected',
- COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED = 'comments-autoclose-editing-thread-disconnected',
- COMMENTS_AUTOCLOSE_CHECKS_UPDATED = 'comments-autoclose-checks-updated',
- COMMENTS_AUTOCLOSE_THREADS_UPDATED = 'comments-autoclose-threads-updated',
- COMMENTS_AUTOCLOSE_COMMENT_REMOVED = 'comments-autoclose-comment-removed',
- COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED = 'comments-autoclose-messages-list-created',
- COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED = 'comments-autoclose-messages-list-updated',
- COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED = 'comments-autoclose-thread-list-created',
- COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED = 'comments-autoclose-thread-list-updated',
- // The following interactions are logged for investigating a spurious bug of
- // auto-closing diffs.
- DIFF_AUTOCLOSE_DIFF_UNDEFINED = 'diff-autoclose-diff-undefined',
- DIFF_AUTOCLOSE_DIFF_ONGOING = 'diff-autoclose-diff-ongoing',
- DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE = 'diff-autoclose-reload-on-whitespace',
- DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX = 'diff-autoclose-reload-on-syntax',
- DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS = 'diff-autoclose-reload-filelist-prefs',
- DIFF_AUTOCLOSE_DIFF_HOST_CREATED = 'diff-autoclose-diff-host-created',
- DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING = 'diff-autoclose-diff-not-rendering',
- DIFF_AUTOCLOSE_FILE_LIST_UPDATED = 'diff-autoclose-file-list-updated',
- DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED = 'diff-autoclose-shown-files-changed',
- // The following interaction is logged for reporting and counting a suspected
- // Chrome bug that leads to html`` misbehavior.
- AUTOCLOSE_HTML_PATCHED = 'autoclose-html-patched',
CHANGE_ACTION_FIRED = 'change-action-fired',
+ BUTTON_CLICK = 'button-click',
+ LINK_CLICK = 'link-click',
}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
index d5a83a7767..e15c24086f 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -16,7 +16,7 @@ import {
import {
EditablePermissionInfo,
PermissionAccessSection,
- EditableProjectAccessGroups,
+ EditableRepoAccessGroups,
} from '../gr-repo-access/gr-repo-access-interfaces';
import {
CapabilityInfoMap,
@@ -24,7 +24,7 @@ import {
LabelNameToLabelTypeInfoMap,
RepoName,
} from '../../../types/common';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {IronInputElement} from '@polymer/iron-input/iron-input';
import {fontStyles} from '../../../styles/gr-font-styles';
import {formStyles} from '../../../styles/gr-form-styles';
@@ -34,25 +34,11 @@ import {customElement, property, query, state} from 'lit/decorators.js';
import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
-/**
- * Fired when the section has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a section that was previously added was removed.
- *
- * @event added-section-removed
- */
-
const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
// The name that gets automatically input when a new reference is added.
const NEW_NAME = 'refs/heads/*';
const REFS_NAME = 'refs/';
-const ON_BEHALF_OF = '(On Behalf Of)';
-const LABEL = 'Label';
@customElement('gr-access-section')
export class GrAccessSection extends LitElement {
@@ -68,7 +54,7 @@ export class GrAccessSection extends LitElement {
section?: PermissionAccessSection;
@property({type: Object})
- groups?: EditableProjectAccessGroups;
+ groups?: EditableRepoAccessGroups;
@property({type: Object})
labels?: LabelNameToLabelTypeInfoMap;
@@ -300,7 +286,7 @@ export class GrAccessSection extends LitElement {
// For a new section, this is not fired because new permissions and
// rules have to be added in order to save, modifying the ref is not
// enough.
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
}
this.section.value.updatedId = this.section.id;
this.requestUpdate();
@@ -372,14 +358,14 @@ export class GrAccessSection extends LitElement {
labelOptions.push({
id: 'label-' + labelName,
value: {
- name: `${LABEL} ${labelName}`,
+ name: `Label ${labelName}`,
id: 'label-' + labelName,
},
});
labelOptions.push({
id: 'labelAs-' + labelName,
value: {
- name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+ name: `Label ${labelName} (On Behalf Of)`,
id: 'labelAs-' + labelName,
},
});
@@ -396,11 +382,13 @@ export class GrAccessSection extends LitElement {
} else if (AccessPermissions[permission.id]) {
return AccessPermissions[permission.id]?.name;
} else if (permission.value.label) {
- let behalfOf = '';
if (permission.id.startsWith('labelAs-')) {
- behalfOf = ON_BEHALF_OF;
+ return `Label ${permission.value.label} (On Behalf Of)`;
+ } else if (permission.id.startsWith('removeLabel-')) {
+ return `Remove Label ${permission.value.label}`;
+ } else {
+ return `Label ${permission.value.label}`;
}
- return `${LABEL} ${permission.value.label}${behalfOf}`;
}
return undefined;
}
@@ -432,11 +420,11 @@ export class GrAccessSection extends LitElement {
return;
}
if (this.section.value.added) {
- fireEvent(this, 'added-section-removed');
+ fire(this, 'added-section-removed', {});
}
this.deleted = true;
this.section.value.deleted = true;
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
}
_handleUndoRemove() {
@@ -533,6 +521,10 @@ export class GrAccessSection extends LitElement {
declare global {
interface HTMLElementEventMap {
+ /** Fired when the section has been modified or removed. */
+ 'access-modified': CustomEvent<{}>;
+ /** Fired when a section that was previously added was removed. */
+ 'added-section-removed': CustomEvent<{}>;
'section-changed': ValueChangedEvent<PermissionAccessSection>;
}
interface HTMLElementTagNameMap {
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
index 593a1ed558..2c397e0bdf 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.ts
@@ -355,7 +355,20 @@ suite('gr-access-section tests', () => {
assert.equal(
element.computePermissionName(permission),
- 'Label Code-Review(On Behalf Of)'
+ 'Label Code-Review (On Behalf Of)'
+ );
+
+ permission = {
+ id: 'removeLabel-Code-Review' as GitRef,
+ value: {
+ label: 'Code-Review',
+ rules: {},
+ },
+ };
+
+ assert.equal(
+ element.computePermissionName(permission),
+ 'Remove Label Code-Review'
);
});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
index 5d32d32a93..b30619e129 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -5,21 +5,24 @@
*/
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
import '../gr-create-group-dialog/gr-create-group-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GroupId, GroupInfo, GroupName} from '../../../types/common';
import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
import {fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {tableStyles} from '../../../styles/gr-table-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, query, property, state} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+ AdminChildView,
+ AdminViewState,
+ createAdminUrl,
+} from '../../../models/views/admin';
import {createGroupUrl} from '../../../models/views/group';
+import {whenVisible} from '../../../utils/dom-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
declare global {
interface HTMLElementTagNameMap {
@@ -29,9 +32,7 @@ declare global {
@customElement('gr-admin-group-list')
export class GrAdminGroupList extends LitElement {
- readonly path = '/admin/groups';
-
- @query('#createOverlay') private createOverlay?: GrOverlay;
+ @query('#createModal') private createModal?: HTMLDialogElement;
@query('#createNewModal') private createNewModal?: GrCreateGroupDialog;
@@ -41,34 +42,33 @@ export class GrAdminGroupList extends LitElement {
/**
* Offset of currently visible query results.
*/
- @state() private offset = 0;
+ @state() offset = 0;
- @state() private hasNewGroupName = false;
+ @state() hasNewGroupName = false;
- @state() private createNewCapability = false;
+ @state() createNewCapability = false;
- // private but used in test
@state() groups: GroupInfo[] = [];
- @state() private groupsPerPage = 25;
+ @state() groupsPerPage = 25;
- // private but used in test
@state() loading = true;
- @state() private filter = '';
+ @state() filter = '';
private readonly restApiService = getAppContext().restApiService;
override connectedCallback() {
super.connectedCallback();
this.getCreateGroupCapability();
- fireTitleChange(this, 'Groups');
+ fireTitleChange('Groups');
}
static override get styles() {
return [
tableStyles,
sharedStyles,
+ modalStyles,
css`
gr-list-view {
--generic-list-description-width: 70%;
@@ -86,7 +86,7 @@ export class GrAdminGroupList extends LitElement {
.itemsPerPage=${this.groupsPerPage}
.loading=${this.loading}
.offset=${this.offset}
- .path=${this.path}
+ .path=${createAdminUrl({adminView: AdminChildView.GROUPS})}
@create-clicked=${() => this.handleCreateClicked()}
>
<table id="list" class="genericList">
@@ -105,12 +105,12 @@ export class GrAdminGroupList extends LitElement {
</tbody>
<tbody class=${this.loading ? 'loading' : ''}>
${this.groups
- .slice(0, SHOWN_ITEMS_COUNT)
+ .slice(0, this.groupsPerPage)
.map(group => this.renderGroupList(group))}
</tbody>
</table>
</gr-list-view>
- <gr-overlay id="createOverlay" with-backdrop>
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
id="createDialog"
class="confirmDialog"
@@ -128,7 +128,7 @@ export class GrAdminGroupList extends LitElement {
></gr-create-group-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
@@ -156,7 +156,7 @@ export class GrAdminGroupList extends LitElement {
paramsChanged() {
this.filter = this.params?.filter ?? '';
this.offset = Number(this.params?.offset ?? 0);
- this.maybeOpenCreateOverlay(this.params);
+ this.maybeOpenCreateModal(this.params);
return this.getGroups(this.filter, this.groupsPerPage, this.offset);
}
@@ -166,18 +166,14 @@ export class GrAdminGroupList extends LitElement {
*
* private but used in test
*/
- maybeOpenCreateOverlay(params?: AdminViewState) {
+ async maybeOpenCreateModal(params?: AdminViewState) {
if (params?.openCreateModal) {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.open();
+ await this.updateComplete;
+ if (!this.createModal?.open) this.createModal?.showModal();
}
}
- /**
- * Generates groups link (/admin/groups/<uuid>)
- *
- * private but used in test
- */
+ // private but used in test
computeGroupUrl(encodedId: string) {
const groupId = decodeURIComponent(encodedId) as GroupId;
return createGroupUrl({groupId});
@@ -229,14 +225,15 @@ export class GrAdminGroupList extends LitElement {
// private but used in test
handleCloseCreate() {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.close();
+ assertIsDefined(this.createModal, 'createModal');
+ this.createModal.close();
}
// private but used in test
handleCreateClicked() {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.open().then(() => {
+ assertIsDefined(this.createModal, 'createModal');
+ this.createModal.showModal();
+ whenVisible(this.createModal, () => {
assertIsDefined(this.createNewModal, 'createNewModal');
this.createNewModal.focus();
});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
index e48448934b..fe5aa22972 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.ts
@@ -15,8 +15,6 @@ import {
import {GerritView} from '../../../services/router/router-model';
import {GrListView} from '../../shared/gr-list-view/gr-list-view';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {fixture, html, assert} from '@open-wc/testing';
import {AdminChildView, AdminViewState} from '../../../models/views/admin';
@@ -83,13 +81,7 @@ suite('gr-admin-group-list tests', () => {
<tbody class="loading"></tbody>
</table>
</gr-list-view>
- <gr-overlay
- aria-hidden="true"
- id="createOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
class="confirmDialog"
confirm-label="Create"
@@ -104,7 +96,7 @@ suite('gr-admin-group-list tests', () => {
</gr-create-group-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -124,21 +116,23 @@ suite('gr-admin-group-list tests', () => {
});
test('groups', () => {
- assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.groupsPerPage);
});
- test('maybeOpenCreateOverlay', () => {
- const overlayOpen = sinon.stub(
- queryAndAssert<GrOverlay>(element, '#createOverlay'),
- 'open'
+ test('maybeOpenCreateModal', async () => {
+ const modalOpen = sinon.stub(
+ queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+ 'showModal'
);
- element.maybeOpenCreateOverlay();
- assert.isFalse(overlayOpen.called);
- element.maybeOpenCreateOverlay(undefined);
- assert.isFalse(overlayOpen.called);
+ await element.maybeOpenCreateModal();
+ assert.isFalse(modalOpen.called);
+ await element.maybeOpenCreateModal(undefined);
+ assert.isFalse(modalOpen.called);
value.openCreateModal = true;
- element.maybeOpenCreateOverlay(value);
- assert.isTrue(overlayOpen.called);
+ await element.maybeOpenCreateModal(value);
+ assert.isTrue(modalOpen.called);
});
});
@@ -152,7 +146,9 @@ suite('gr-admin-group-list tests', () => {
});
test('groups', () => {
- assert.equal(element.groups.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.groupsPerPage);
});
});
@@ -205,9 +201,10 @@ suite('gr-admin-group-list tests', () => {
});
test('handleCreateClicked opens modal', () => {
- const openStub = sinon
- .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
- .returns(Promise.resolve());
+ const openStub = sinon.stub(
+ queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+ 'showModal'
+ );
element.handleCreateClicked();
assert.isTrue(openStub.called);
});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 088002c46c..8cadd23e31 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -17,15 +17,7 @@ import '../gr-repo-commands/gr-repo-commands';
import '../gr-repo-dashboards/gr-repo-dashboards';
import '../gr-repo-detail-list/gr-repo-detail-list';
import '../gr-repo-list/gr-repo-list';
-import {getBaseUrl} from '../../../utils/url-util';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {
- AdminNavLinksOption,
- getAdminLinks,
- NavLink,
- SubsectionInterface,
-} from '../../../utils/admin-nav-util';
import {
AccountDetailInfo,
GroupId,
@@ -34,7 +26,10 @@ import {
} from '../../../types/common';
import {GroupNameChangedDetail} from '../gr-group/gr-group';
import {getAppContext} from '../../../services/app-context';
-import {GerritView} from '../../../services/router/router-model';
+import {
+ GerritView,
+ routerModelToken,
+} from '../../../services/router/router-model';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -46,6 +41,10 @@ import {
AdminChildView,
adminViewModelToken,
AdminViewState,
+ AdminNavLinksOption,
+ getAdminLinks,
+ NavLink,
+ SubsectionInterface,
} from '../../../models/views/admin';
import {
GroupDetailView,
@@ -59,6 +58,7 @@ import {
} from '../../../models/views/repo';
import {resolve} from '../../../models/dependency';
import {subscribe} from '../../lit/subscription-controller';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
@@ -110,22 +110,23 @@ export class GrAdminView extends LitElement {
private reloading = false;
// private but used in the tests
- readonly jsAPI = getAppContext().jsApiService;
-
private readonly restApiService = getAppContext().restApiService;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
private readonly getAdminViewModel = resolve(this, adminViewModelToken);
private readonly getGroupViewModel = resolve(this, groupViewModelToken);
private readonly getRepoViewModel = resolve(this, repoViewModelToken);
- private readonly routerModel = getAppContext().routerModel;
+ private readonly getRouterModel = resolve(this, routerModelToken);
private readonly getNavigation = resolve(this, navigationToken);
constructor() {
super();
+ this.addEventListener('reload', () => window.location.reload());
subscribe(
this,
() => this.getAdminViewModel().state$,
@@ -152,7 +153,7 @@ export class GrAdminView extends LitElement {
);
subscribe(
this,
- () => this.routerModel.routerView$,
+ () => this.getRouterModel().routerView$,
view => {
this.view = view;
if (this.needsReload()) this.reload();
@@ -415,7 +416,10 @@ export class GrAdminView extends LitElement {
return html`
<div class="main breadcrumbs">
- <gr-repo-commands .repo=${this.repoViewState.repo}></gr-repo-commands>
+ <gr-repo-commands
+ .repo=${this.repoViewState.repo}
+ .createEdit=${this.repoViewState.createEdit}
+ ></gr-repo-commands>
</div>
`;
}
@@ -457,7 +461,7 @@ export class GrAdminView extends LitElement {
const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] =
[
this.restApiService.getAccount(),
- getPluginLoader().awaitPluginsLoaded(),
+ this.getPluginLoader().awaitPluginsLoaded(),
];
const result = await Promise.all(promises);
this.account = result[0];
@@ -487,7 +491,7 @@ export class GrAdminView extends LitElement {
}
return capabilities;
}),
- () => this.jsAPI.getAdminMenuLinks(),
+ () => this.getPluginLoader().jsApiService.getAdminMenuLinks(),
options
);
this.filteredLinks = res.links;
@@ -593,12 +597,7 @@ export class GrAdminView extends LitElement {
// private but used in test
computeLinkURL(link?: NavLink | SubsectionInterface) {
- if (!link || typeof link.url === 'undefined') return '';
-
- if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
- return link.url;
- }
- return `//${window.location.host}${getBaseUrl()}${link.url}`;
+ return link?.url || '';
}
private computeSelectedClass(
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
index d65d171a34..1d456c9bb6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.ts
@@ -6,8 +6,7 @@
import '../../../test/common-test-setup';
import './gr-admin-view';
import {AdminSubsectionLink, GrAdminView} from './gr-admin-view';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {stubBaseUrl, stubElement, stubRestApi} from '../../../test/test-utils';
+import {stubElement, stubRestApi} from '../../../test/test-utils';
import {GerritView} from '../../../services/router/router-model';
import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
import {GrRepoList} from '../gr-repo-list/gr-repo-list';
@@ -20,6 +19,10 @@ import {GroupDetailView} from '../../../models/views/group';
import {RepoDetailView} from '../../../models/views/repo';
import {testResolver} from '../../../test/common-test-setup';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+ PluginLoader,
+ pluginLoaderToken,
+} from '../../shared/gr-js-api-interface/gr-plugin-loader';
function createAdminCapabilities() {
return {
@@ -31,40 +34,22 @@ function createAdminCapabilities() {
suite('gr-admin-view tests', () => {
let element: GrAdminView;
+ let pluginLoader: PluginLoader;
setup(async () => {
element = await fixture(html`<gr-admin-view></gr-admin-view>`);
stubRestApi('getProjectConfig').returns(Promise.resolve(undefined));
const pluginsLoaded = Promise.resolve();
- sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
+ pluginLoader = testResolver(pluginLoaderToken);
+ sinon.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
await pluginsLoaded;
await element.updateComplete;
});
test('link URLs', () => {
- assert.equal(
- element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
- '//' + window.location.host + '/test'
- );
-
- stubBaseUrl('/foo');
- assert.equal(
- element.computeLinkURL({name: '', url: '/test', noBaseUrl: true}),
- '//' + window.location.host + '/foo/test'
- );
- assert.equal(
- element.computeLinkURL({name: '', url: '/test', noBaseUrl: false}),
- '/test'
- );
- assert.equal(
- element.computeLinkURL({
- name: '',
- url: '/test',
- target: '_blank',
- noBaseUrl: false,
- }),
- '/test'
- );
+ assert.equal(element.computeLinkURL({name: '', url: '/test'}), '/test');
+ assert.equal(element.computeLinkURL({name: '', url: ''}), '');
+ assert.equal(element.computeLinkURL(undefined), '');
});
test('current page gets selected and is displayed', async () => {
@@ -73,7 +58,6 @@ suite('gr-admin-view tests', () => {
name: 'Repositories',
url: '/admin/repos',
view: 'gr-repo-list' as GerritView,
- noBaseUrl: false,
},
];
@@ -131,8 +115,12 @@ suite('gr-admin-view tests', () => {
test('filteredLinks from plugin', () => {
stubRestApi('getAccount').returns(Promise.resolve(undefined));
- sinon.stub(element.jsAPI, 'getAdminMenuLinks').returns([
- {capability: null, text: 'internal link text', url: '/internal/link/url'},
+ sinon.stub(pluginLoader.jsApiService, 'getAdminMenuLinks').returns([
+ {
+ capability: null,
+ text: 'internal link text',
+ url: '/internal/link/url',
+ },
{
capability: null,
text: 'external link text',
@@ -145,7 +133,6 @@ suite('gr-admin-view tests', () => {
capability: undefined,
url: '/internal/link/url',
name: 'internal link text',
- noBaseUrl: true,
view: undefined,
viewableToAll: true,
target: null,
@@ -154,7 +141,6 @@ suite('gr-admin-view tests', () => {
capability: undefined,
url: 'http://external/link/url',
name: 'external link text',
- noBaseUrl: false,
view: undefined,
viewableToAll: true,
target: '_blank',
@@ -336,7 +322,6 @@ suite('gr-admin-view tests', () => {
const expectedFilteredLinks = [
{
name: 'Repositories',
- noBaseUrl: true,
url: '/admin/repos',
view: 'gr-repo-list' as GerritView,
viewableToAll: true,
@@ -386,7 +371,6 @@ suite('gr-admin-view tests', () => {
{
name: 'Groups',
section: 'Groups',
- noBaseUrl: true,
url: '/admin/groups',
view: 'gr-admin-group-list' as GerritView,
},
@@ -394,7 +378,6 @@ suite('gr-admin-view tests', () => {
name: 'Plugins',
capability: 'viewPlugins',
section: 'Plugins',
- noBaseUrl: true,
url: '/admin/plugins',
view: 'gr-plugin-list' as GerritView,
},
@@ -531,29 +514,17 @@ suite('gr-admin-view tests', () => {
<gr-page-nav class="navStyles">
<ul class="sectionContent">
<li class="sectionTitle selected">
- <a
- class="title"
- href="//localhost:9876/admin/repos"
- rel="noopener"
- >
+ <a class="title" href="/admin/repos" rel="noopener">
Repositories
</a>
</li>
<li class="sectionTitle">
- <a
- class="title"
- href="//localhost:9876/admin/groups"
- rel="noopener"
- >
+ <a class="title" href="/admin/groups" rel="noopener">
Groups
</a>
</li>
<li class="sectionTitle">
- <a
- class="title"
- href="//localhost:9876/admin/plugins"
- rel="noopener"
- >
+ <a class="title" href="/admin/plugins" rel="noopener">
Plugins
</a>
</li>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index 42ec988ca5..61fe0c44d2 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -7,6 +7,7 @@ import '../../shared/gr-dialog/gr-dialog';
import {sharedStyles} from '../../../styles/shared-styles';
import {css, html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubble} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -68,22 +69,12 @@ export class GrConfirmDeleteItemDialog extends LitElement {
_handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('confirm', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
_handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index cee0fa4a0e..b3f1e96f08 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -23,12 +23,13 @@ import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const SUGGESTIONS_LIMIT = 15;
const REF_PREFIX = 'refs/heads/';
@@ -37,6 +38,9 @@ declare global {
interface HTMLElementTagNameMap {
'gr-create-change-dialog': GrCreateChangeDialog;
}
+ interface HTMLElementEventMap {
+ 'can-create-change': CustomEvent<{}>;
+ }
}
@customElement('gr-create-change-dialog')
@@ -124,7 +128,7 @@ export class GrCreateChangeDialog extends LitElement {
.text=${this.branch}
.query=${this.query}
placeholder="Destination branch"
- @text-changed=${(e: CustomEvent) => {
+ @text-changed=${(e: ValueChangedEvent<BranchName>) => {
this.branch = e.detail.value;
}}
>
@@ -206,7 +210,7 @@ export class GrCreateChangeDialog extends LitElement {
}
private allowCreate() {
- fireEvent(this, 'can-create-change');
+ fire(this, 'can-create-change', {});
}
handleCreateChange(): Promise<void> {
@@ -241,7 +245,13 @@ export class GrCreateChangeDialog extends LitElement {
input = input.substring(REF_PREFIX.length);
}
return this.restApiService
- .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
+ .getRepoBranches(
+ input,
+ this.repoName,
+ SUGGESTIONS_LIMIT,
+ /* offset=*/ undefined,
+ throwingErrorCallback
+ )
.then(response => {
if (!response) return [];
const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
new file mode 100644
index 0000000000..0cfbaa4f28
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+ RepoName,
+ BranchName,
+ ChangeInfo,
+ PatchSetNumber,
+} from '../../../types/common';
+import {getAppContext} from '../../../services/app-context';
+import {LitElement, html, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {createEditUrl} from '../../../models/views/change';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {assertIsDefined} from '../../../utils/common-util';
+import {when} from 'lit/directives/when.js';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-file-edit-dialog': GrCreateFileEditDialog;
+ }
+}
+
+@customElement('gr-create-file-edit-dialog')
+export class GrCreateFileEditDialog extends LitElement {
+ @query('dialog')
+ modal?: HTMLDialogElement;
+
+ @query('gr-dialog')
+ grDialog?: GrDialog;
+
+ @property({type: String})
+ repo?: RepoName;
+
+ @property({type: String})
+ branch?: BranchName;
+
+ @property({type: String})
+ path?: string;
+
+ /**
+ * If this is set, then we show this message replacing all other content.
+ */
+ @state()
+ errorMessage?: string;
+
+ /**
+ * Triggers showing the dialog and kicks off creating a change.
+ */
+ @state()
+ active = false;
+
+ /**
+ * Indicates whether the REST API call for creating a change is in progress.
+ */
+ @state()
+ loading = false;
+
+ private readonly restApiService = getAppContext().restApiService;
+
+ private readonly getNavigation = resolve(this, navigationToken);
+
+ static override get styles() {
+ return [modalStyles];
+ }
+
+ override render() {
+ if (!this.active) return nothing;
+ return html`
+ <dialog tabindex="-1">
+ <gr-dialog
+ disabled
+ ?loading=${this.loading}
+ .loadingLabel=${'Creating change ...'}
+ @cancel=${() => this.deactivate()}
+ .confirmLabel=${this.loading ? 'Please wait ...' : 'Failed'}
+ .cancelLabel=${'Cancel'}
+ >
+ <div slot="header">
+ <span class="main-heading">Create Change from URL</span>
+ </div>
+ <div slot="main">
+ ${when(
+ this.errorMessage,
+ () => this.renderError(),
+ () => this.renderCreating()
+ )}
+ </div>
+ </gr-dialog>
+ </dialog>
+ `;
+ }
+
+ async activate() {
+ this.active = true;
+ this.createChange();
+ await this.updateComplete;
+ if (this.active && this.modal?.open === false) this.modal.showModal();
+ }
+
+ deactivate() {
+ this.active = false;
+ this.modal?.close();
+ }
+
+ private renderCreating() {
+ return html`
+ <div>
+ <span>
+ Creating a change in repository <b>${this.repo}</b> on branch
+ <b>${this.branch}</b>.
+ </span>
+ </div>
+ <div>
+ <span>
+ The page will then redirect to the file editor for
+ <b>${this.path}</b>
+ in the newly created change.
+ </span>
+ </div>
+ `;
+ }
+
+ private renderError() {
+ return html`<div>Error: ${this.errorMessage}</div>`;
+ }
+
+ private createChange() {
+ if (!this.repo || !this.branch || !this.path) {
+ this.errorMessage = 'repo, branch and path must be set';
+ return;
+ }
+ if (this.loading || this.errorMessage) return;
+ this.loading = true;
+ this.restApiService
+ .createChange(this.repo, this.branch, `Edit ${this.path}`)
+ .then(change => {
+ if (!this.active) return;
+ if (change) {
+ this.loading = false;
+ this.redirectToFileEdit(change);
+ this.deactivate();
+ } else {
+ this.errorMessage = 'Creating the change failed.';
+ }
+ })
+ .catch(() => {
+ this.errorMessage = 'Creating the change failed.';
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ }
+
+ private redirectToFileEdit(change: ChangeInfo) {
+ assertIsDefined(this.path, 'path');
+ const url = createEditUrl({
+ changeNum: change._number,
+ repo: change.project,
+ patchNum: 1 as PatchSetNumber,
+ editView: {path: this.path},
+ });
+ this.getNavigation().setUrl(url);
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
new file mode 100644
index 0000000000..e2da5d71a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-file-edit-dialog_test.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-create-file-edit-dialog';
+import {createChange} from '../../../test/test-data-generators';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCreateFileEditDialog} from './gr-create-file-edit-dialog';
+import {stubRestApi, waitUntilCalled} from '../../../test/test-utils';
+import {BranchName, RepoName} from '../../../api/rest-api';
+import {SinonStubbedMember} from 'sinon';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ NavigationService,
+ navigationToken,
+} from '../../core/gr-navigation/gr-navigation';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+
+suite('gr-create-file-edit-dialog', () => {
+ let element: GrCreateFileEditDialog;
+ let createChangeStub: SinonStubbedMember<RestApiService['createChange']>;
+ let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
+
+ setup(async () => {
+ createChangeStub = stubRestApi('createChange').resolves(createChange());
+ setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
+
+ element = await fixture(
+ html`<gr-create-file-edit-dialog></gr-create-file-edit-dialog>`
+ );
+ element.repo = 'test-repo' as RepoName;
+ element.branch = 'test-branch' as BranchName;
+ element.path = 'test-path';
+ await element.updateComplete;
+ });
+
+ test('render', async () => {
+ element.activate();
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <dialog tabindex="-1">
+ <gr-dialog disabled loading>
+ <div slot="header">
+ <span class="main-heading"> Create Change from URL </span>
+ </div>
+ <div slot="main">
+ <div>
+ <span>
+ Creating a change in repository
+ <b> test-repo </b>
+ on branch
+ <b> test-branch </b>
+ .
+ </span>
+ </div>
+ <div>
+ <span>
+ The page will then redirect to the file editor for
+ <b> test-path </b> in the newly created change.
+ </span>
+ </div>
+ </div>
+ </gr-dialog>
+ </dialog>
+ `
+ );
+ });
+
+ test('render error', async () => {
+ element.activate();
+ element.errorMessage = 'Failed.';
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <dialog tabindex="-1">
+ <gr-dialog disabled loading>
+ <div slot="header">
+ <span class="main-heading"> Create Change from URL </span>
+ </div>
+ <div slot="main">
+ <div>Error: Failed.</div>
+ </div>
+ </gr-dialog>
+ </dialog>
+ `
+ );
+ });
+
+ test('creates change', async () => {
+ element.activate();
+ await element.updateComplete;
+
+ assert.isTrue(createChangeStub.calledOnce);
+ await waitUntilCalled(setUrlStub, 'setUrl');
+ await element.updateComplete;
+ assert.shadowDom.equal(element, '');
+ });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
index 4808d00874..742872745e 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -6,8 +6,6 @@
import '@polymer/iron-input/iron-input';
import '../../../styles/gr-form-styles';
import '../../../styles/shared-styles';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
import {GroupName} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {formStyles} from '../../../styles/gr-form-styles';
@@ -15,12 +13,18 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, query, property} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
+import {createGroupUrl} from '../../../models/views/group';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
declare global {
interface HTMLElementTagNameMap {
'gr-create-group-dialog': GrCreateGroupDialog;
}
+ interface HTMLElementEventMap {
+ 'has-new-group-name': CustomEvent<{}>;
+ }
}
@customElement('gr-create-group-dialog')
@@ -32,6 +36,8 @@ export class GrCreateGroupDialog extends LitElement {
private readonly restApiService = getAppContext().restApiService;
+ private readonly getNavigation = resolve(this, navigationToken);
+
static override get styles() {
return [
formStyles,
@@ -72,11 +78,7 @@ export class GrCreateGroupDialog extends LitElement {
}
private updateGroupName() {
- fireEvent(this, 'has-new-group-name');
- }
-
- private computeGroupUrl(groupId: string) {
- return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
+ fire(this, 'has-new-group-name', {});
}
override focus() {
@@ -89,7 +91,7 @@ export class GrCreateGroupDialog extends LitElement {
if (groupRegistered.status !== 201) return;
return this.restApiService.getGroupConfig(name).then(group => {
if (!group) return;
- page.show(this.computeGroupUrl(String(group.group_id!)));
+ this.getNavigation().setUrl(createGroupUrl({groupId: group.id}));
});
});
}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
index 2a0b539d48..c5fbde3be6 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.ts
@@ -6,7 +6,6 @@
import '../../../test/common-test-setup';
import './gr-create-group-dialog';
import {GrCreateGroupDialog} from './gr-create-group-dialog';
-import {page} from '../../../utils/page-wrapper-utils';
import {
mockPromise,
queryAndAssert,
@@ -15,6 +14,8 @@ import {
import {IronInputElement} from '@polymer/iron-input';
import {GroupId} from '../../../types/common';
import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
suite('gr-create-group-dialog tests', () => {
let element: GrCreateGroupDialog;
@@ -68,9 +69,9 @@ suite('gr-create-group-dialog tests', () => {
Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
);
- const showStub = sinon.stub(page, 'show');
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
await element.handleCreateGroup();
- assert.isTrue(showStub.calledWith('/admin/groups/551'));
+ assert.isTrue(setUrlStub.calledWith('/admin/groups/testId551'));
});
test('test for unsuccessful group creation', async () => {
@@ -81,8 +82,8 @@ suite('gr-create-group-dialog tests', () => {
Promise.resolve({id: 'testId551' as GroupId, group_id: 551})
);
- const showStub = sinon.stub(page, 'show');
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
await element.handleCreateGroup();
- assert.isFalse(showStub.called);
+ assert.isFalse(setUrlStub.called);
});
});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
index 889a8592df..12f36eca56 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -6,8 +6,6 @@
import '@polymer/iron-input/iron-input';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
import {BranchName, RepoName} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {formStyles} from '../../../styles/gr-form-styles';
@@ -15,13 +13,16 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
import {RepoDetailView} from '../../../models/views/repo';
declare global {
interface HTMLElementTagNameMap {
'gr-create-pointer-dialog': GrCreatePointerDialog;
}
+ interface HTMLElementEventMap {
+ 'update-item-name': CustomEvent<{}>;
+ }
}
@customElement('gr-create-pointer-dialog')
@@ -115,7 +116,7 @@ export class GrCreatePointerDialog extends LitElement {
}
private updateItemName() {
- fireEvent(this, 'update-item-name');
+ fire(this, 'update-item-name', {});
}
handleCreateItem() {
@@ -126,13 +127,13 @@ export class GrCreatePointerDialog extends LitElement {
throw new Error('itemName name is not set');
}
const USE_HEAD = this.itemRevision ? this.itemRevision : 'HEAD';
- const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
if (this.itemDetail === RepoDetailView.BRANCHES) {
return this.restApiService
.createRepoBranch(this.repoName, this.itemName, {revision: USE_HEAD})
.then(itemRegistered => {
if (itemRegistered.status === 201) {
- page.show(`${url},branches`);
+ fireAlert(this, 'Branch created successfully. Reloading...');
+ fireReload(this);
}
});
} else if (this.itemDetail === RepoDetailView.TAGS) {
@@ -143,7 +144,8 @@ export class GrCreatePointerDialog extends LitElement {
})
.then(itemRegistered => {
if (itemRegistered.status === 201) {
- page.show(`${url},tags`);
+ fireAlert(this, 'Tag created successfully. Reloading...');
+ fireReload(this);
}
});
}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
index 158419eb5e..1a70f2bb1e 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -7,8 +7,6 @@ import '@polymer/iron-input/iron-input';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-select/gr-select';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
import {
BranchName,
GroupId,
@@ -22,22 +20,25 @@ import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, query, property, state} from 'lit/decorators.js';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl} from '../../../models/views/repo';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {ValueChangedEvent} from '../../../types/events';
declare global {
interface HTMLElementTagNameMap {
'gr-create-repo-dialog': GrCreateRepoDialog;
}
+ interface HTMLElementEventMap {
+ /** Fired when repostiory name is entered. */
+ 'new-repo-name': CustomEvent<{}>;
+ }
}
@customElement('gr-create-repo-dialog')
export class GrCreateRepoDialog extends LitElement {
- /**
- * Fired when repostiory name is entered.
- *
- * @event new-repo-name
- */
-
@query('input')
input?: HTMLInputElement;
@@ -70,6 +71,8 @@ export class GrCreateRepoDialog extends LitElement {
private readonly restApiService = getAppContext().restApiService;
+ private readonly getNavigation = resolve(this, navigationToken);
+
constructor() {
super();
this.query = (input: string) => this.getRepoSuggestions(input);
@@ -182,10 +185,6 @@ export class GrCreateRepoDialog extends LitElement {
`;
}
- _computeRepoUrl(repoName: string) {
- return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
- }
-
override focus() {
this.input?.focus();
}
@@ -198,23 +197,32 @@ export class GrCreateRepoDialog extends LitElement {
);
if (repoRegistered.status === 201) {
this.repoCreated = true;
- page.show(this._computeRepoUrl(this.repoConfig.name));
+ this.getNavigation().setUrl(createRepoUrl({repo: this.repoConfig.name}));
}
return repoRegistered;
}
private async getRepoSuggestions(input: string) {
- const response = await this.restApiService.getSuggestedProjects(input);
+ const response = await this.restApiService.getSuggestedRepos(
+ input,
+ /* n=*/ undefined,
+ throwingErrorCallback
+ );
const repos = [];
- for (const [name, project] of Object.entries(response ?? {})) {
- repos.push({name, value: project.id});
+ for (const [name, repo] of Object.entries(response ?? {})) {
+ repos.push({name, value: repo.id});
}
return repos;
}
private async getGroupSuggestions(input: string) {
- const response = await this.restApiService.getSuggestedGroups(input);
+ const response = await this.restApiService.getSuggestedGroups(
+ input,
+ /* project=*/ undefined,
+ /* n=*/ undefined,
+ throwingErrorCallback
+ );
const groups = [];
for (const [name, group] of Object.entries(response ?? {})) {
@@ -223,39 +231,41 @@ export class GrCreateRepoDialog extends LitElement {
return groups;
}
- private handleRightsTextChanged(e: CustomEvent) {
+ private handleRightsTextChanged(e: ValueChangedEvent) {
this.repoConfig.parent = e.detail.value as RepoName;
this.requestUpdate();
}
- private handleOwnerTextChanged(e: CustomEvent) {
+ private handleOwnerTextChanged(e: ValueChangedEvent) {
this.repoOwner = e.detail.value;
}
- private handleOwnerValueChanged(e: CustomEvent) {
+ private handleOwnerValueChanged(e: ValueChangedEvent) {
this.repoOwnerId = e.detail.value as GroupId;
}
- private handleNameBindValueChanged(e: CustomEvent) {
+ private handleNameBindValueChanged(e: ValueChangedEvent) {
this.repoConfig.name = e.detail.value as RepoName;
// nameChanged needs to be set before the event is fired,
// because when the event is fired, gr-repo-list gets
// the nameChanged value.
this.nameChanged = !!e.detail.value;
- fireEvent(this, 'new-repo-name');
+ fire(this, 'new-repo-name', {});
this.requestUpdate();
}
- private handleBranchNameBindValueChanged(e: CustomEvent) {
+ private handleBranchNameBindValueChanged(e: ValueChangedEvent) {
this.defaultBranch = e.detail.value as BranchName;
}
- private handleCreateEmptyCommitBindValueChanged(e: CustomEvent) {
+ private handleCreateEmptyCommitBindValueChanged(
+ e: ValueChangedEvent<boolean>
+ ) {
this.repoConfig.create_empty_commit = e.detail.value;
this.requestUpdate();
}
- private handlePermissionsOnlyBindValueChanged(e: CustomEvent) {
+ private handlePermissionsOnlyBindValueChanged(e: ValueChangedEvent<boolean>) {
this.repoConfig.permissions_only = e.detail.value;
this.requestUpdate();
}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
index e0c0d30a35..f4a7bf3188 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.ts
@@ -40,7 +40,7 @@ export class GrGroupAuditLog extends LitElement {
override connectedCallback() {
super.connectedCallback();
- fireTitleChange(this, 'Audit Log');
+ fireTitleChange('Audit Log');
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
index c716d65e08..cffde7a571 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -3,14 +3,11 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../shared/gr-account-label/gr-account-label';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
-import '../../shared/gr-overlay/gr-overlay';
import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
import {getBaseUrl} from '../../../utils/url-util';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {
GroupId,
AccountId,
@@ -18,6 +15,7 @@ import {
GroupInfo,
GroupName,
ServerInfo,
+ NumericChangeId,
} from '../../../types/common';
import {
AutocompleteQuery,
@@ -40,15 +38,19 @@ import {tableStyles} from '../../../styles/gr-table-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {ifDefined} from 'lit/directives/if-defined.js';
-import {getAccountSuggestions} from '../../../utils/account-util';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {ValueChangedEvent} from '../../../types/events';
+import {getAccountDisplayName} from '../../../utils/display-name-util';
const SAVING_ERROR_TEXT =
'Group may not exist, or you may not have ' + 'permission to add it';
const URL_REGEX = '^(?:[a-z]+:)?//';
+const SUGGESTIONS_LIMIT = 15;
export enum ItemType {
MEMBER = 'member',
@@ -63,7 +65,7 @@ declare global {
@customElement('gr-group-members')
export class GrGroupMembers extends LitElement {
- @query('#overlay') protected overlay!: GrOverlay;
+ @query('#modal') protected modal!: HTMLDialogElement;
@property({type: String})
groupId?: GroupId;
@@ -119,7 +121,7 @@ export class GrGroupMembers extends LitElement {
}
);
this.queryMembers = input =>
- getAccountSuggestions(input, this.restApiService, this.serverConfig);
+ this.getAccountSuggestions(input, this.serverConfig);
this.queryIncludedGroup = input => this.getGroupSuggestions(input);
}
@@ -127,7 +129,7 @@ export class GrGroupMembers extends LitElement {
super.connectedCallback();
this.loadGroupDetails();
- fireTitleChange(this, 'Members');
+ fireTitleChange('Members');
}
static override get styles() {
@@ -137,6 +139,7 @@ export class GrGroupMembers extends LitElement {
sharedStyles,
subpageStyles,
tableStyles,
+ modalStyles,
css`
.input {
width: 15em;
@@ -258,7 +261,7 @@ export class GrGroupMembers extends LitElement {
</div>
</div>
</div>
- <gr-overlay id="overlay" with-backdrop>
+ <dialog id="modal" tabindex="-1">
<gr-confirm-delete-item-dialog
class="confirmDialog"
.item=${this.itemName}
@@ -266,10 +269,37 @@ export class GrGroupMembers extends LitElement {
@confirm=${this.handleDeleteConfirm}
@cancel=${this.handleConfirmDialogCancel}
></gr-confirm-delete-item-dialog>
- </gr-overlay>
+ </dialog>
`;
}
+ getAccountSuggestions(
+ input: string,
+ config?: ServerInfo,
+ canSee?: NumericChangeId,
+ filterActive = false
+ ) {
+ return this.restApiService
+ .getSuggestedAccounts(
+ input,
+ SUGGESTIONS_LIMIT,
+ canSee,
+ filterActive,
+ throwingErrorCallback
+ )
+ .then(accounts => {
+ if (!accounts) return [];
+ const accountSuggestions = [];
+ for (const account of accounts) {
+ accountSuggestions.push({
+ name: getAccountDisplayName(config, account),
+ value: account._account_id?.toString(),
+ });
+ }
+ return accountSuggestions;
+ });
+ }
+
private renderGroupMember(member: AccountInfo, index: number) {
return html`
<tr>
@@ -411,7 +441,7 @@ export class GrGroupMembers extends LitElement {
if (!this.groupName) {
return Promise.reject(new Error('group name undefined'));
}
- this.overlay.close();
+ this.modal.close();
if (this.itemType === ItemType.MEMBER) {
return this.restApiService
.deleteGroupMember(this.groupName, this.itemId! as AccountId)
@@ -457,7 +487,7 @@ export class GrGroupMembers extends LitElement {
}
private handleConfirmDialogCancel() {
- this.overlay.close();
+ this.modal.close();
}
private handleDeleteMember(e: Event) {
@@ -472,7 +502,7 @@ export class GrGroupMembers extends LitElement {
this.itemName = item;
this.itemId = keys._account_id;
this.itemType = ItemType.MEMBER;
- this.overlay.open();
+ this.modal.showModal();
}
/* private but used in test */
@@ -490,7 +520,7 @@ export class GrGroupMembers extends LitElement {
if (errResponse) {
if (errResponse.status === 404) {
fireAlert(this, SAVING_ERROR_TEXT);
- return errResponse;
+ return;
}
throw Error(errResponse.statusText);
}
@@ -525,36 +555,43 @@ export class GrGroupMembers extends LitElement {
this.itemName = item;
this.itemId = id;
this.itemType = ItemType.INCLUDED_GROUP;
- this.overlay.open();
+ this.modal.showModal();
}
/* private but used in test */
getGroupSuggestions(input: string) {
- return this.restApiService.getSuggestedGroups(input).then(response => {
- const groups: AutocompleteSuggestion[] = [];
- for (const [name, group] of Object.entries(response ?? {})) {
- groups.push({name, value: decodeURIComponent(group.id)});
- }
- return groups;
- });
+ return this.restApiService
+ .getSuggestedGroups(
+ input,
+ /* project=*/ undefined,
+ /* n=*/ undefined,
+ throwingErrorCallback
+ )
+ .then(response => {
+ const groups: AutocompleteSuggestion[] = [];
+ for (const [name, group] of Object.entries(response ?? {})) {
+ groups.push({name, value: decodeURIComponent(group.id)});
+ }
+ return groups;
+ });
}
- private handleGroupMemberTextChanged(e: CustomEvent) {
+ private handleGroupMemberTextChanged(e: ValueChangedEvent) {
if (this.loading) return;
this.groupMemberSearchName = e.detail.value;
}
- private handleGroupMemberValueChanged(e: CustomEvent) {
+ private handleGroupMemberValueChanged(e: ValueChangedEvent<number>) {
if (this.loading) return;
this.groupMemberSearchId = e.detail.value;
}
- private handleIncludedGroupTextChanged(e: CustomEvent) {
+ private handleIncludedGroupTextChanged(e: ValueChangedEvent) {
if (this.loading) return;
this.includedGroupSearchName = e.detail.value;
}
- private handleIncludedGroupValueChanged(e: CustomEvent) {
+ private handleIncludedGroupValueChanged(e: ValueChangedEvent) {
if (this.loading) return;
this.includedGroupSearchId = e.detail.value;
}
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
index 6c65dd60ff..a3c7bbdd73 100644
--- a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.ts
@@ -25,9 +25,7 @@ import {
} from '../../../types/common';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
-import {EventType, PageErrorEvent} from '../../../types/events';
-import {getAccountSuggestions} from '../../../utils/account-util';
-import {getAppContext} from '../../../services/app-context';
+import {PageErrorEvent} from '../../../types/events';
import {fixture, html, assert} from '@open-wc/testing';
import {createServerInfo} from '../../../test/test-data-generators';
@@ -346,16 +344,10 @@ suite('gr-group-members tests', () => {
</div>
</div>
</div>
- <gr-overlay
- aria-hidden="true"
- id="overlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="modal" tabindex="-1">
<gr-confirm-delete-item-dialog class="confirmDialog">
</gr-confirm-delete-item-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -452,7 +444,7 @@ suite('gr-group-members tests', () => {
const memberName = 'bad-name';
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const errorResponse = {...new Response(), status: 404, ok: false};
stubRestApi('saveIncludedGroup').callsFake((_, _non, errFn) => {
if (errFn !== undefined) {
@@ -498,18 +490,16 @@ suite('gr-group-members tests', () => {
});
test('getAccountSuggestions empty', async () => {
- const accounts = await getAccountSuggestions(
+ const accounts = await element.getAccountSuggestions(
'nonexistent',
- getAppContext().restApiService,
createServerInfo()
);
assert.equal(accounts.length, 0);
});
test('getAccountSuggestions non-empty', async () => {
- const accounts = await getAccountSuggestions(
+ const accounts = await element.getAccountSuggestions(
'test-',
- getAppContext().restApiService,
createServerInfo()
);
assert.equal(accounts.length, 3);
@@ -540,12 +530,18 @@ suite('gr-group-members tests', () => {
deleteBtns[0].click();
assert.equal(element.itemId, 1000097 as AccountId);
assert.equal(element.itemName, 'jane');
+ queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
deleteBtns[1].click();
assert.equal(element.itemId, 1000096 as AccountId);
assert.equal(element.itemName, 'Test User');
+ queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
deleteBtns[2].click();
assert.equal(element.itemId, 1000095 as AccountId);
assert.equal(element.itemName, 'Gerrit');
+ queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
deleteBtns[3].click();
assert.equal(element.itemId, 1000098 as AccountId);
assert.equal(element.itemName, '1000098');
@@ -559,9 +555,13 @@ suite('gr-group-members tests', () => {
deleteBtns[0].click();
assert.equal(element.itemId, 'testId' as GroupId);
assert.equal(element.itemName, 'testName');
+ queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
deleteBtns[1].click();
assert.equal(element.itemId, 'testId2' as GroupId);
assert.equal(element.itemName, 'testName2');
+ queryAndAssert<HTMLDialogElement>(element, 'dialog').close();
+
deleteBtns[2].click();
assert.equal(element.itemId, 'testId3' as GroupId);
assert.equal(element.itemName, 'testName3');
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
index e65b16bd79..223a700710 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -13,17 +13,18 @@ import {
AutocompleteQuery,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {GroupId, GroupInfo, GroupName} from '../../../types/common';
-import {firePageError, fireTitleChange} from '../../../utils/event-util';
+import {fire, firePageError, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {convertToString} from '../../../utils/string-util';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {fontStyles} from '../../../styles/gr-font-styles';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {subpageStyles} from '../../../styles/gr-subpage-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
@@ -47,16 +48,13 @@ declare global {
interface HTMLElementTagNameMap {
'gr-group': GrGroup;
}
+ interface HTMLElementEventMap {
+ 'name-changed': CustomEvent<GroupNameChangedDetail>;
+ }
}
@customElement('gr-group')
export class GrGroup extends LitElement {
- /**
- * Fired when the group name changes.
- *
- * @event name-changed
- */
-
private readonly query: AutocompleteQuery;
@property({type: String})
@@ -344,7 +342,7 @@ export class GrGroup extends LitElement {
this.groupConfig = config;
this.originalOptionsVisibleToAll = config?.options?.visible_to_all;
- fireTitleChange(this, config.name);
+ fireTitleChange(config.name);
await Promise.all(promises);
this.loading = false;
@@ -372,13 +370,7 @@ export class GrGroup extends LitElement {
name: groupName,
external: !this.groupIsInternal,
};
- this.dispatchEvent(
- new CustomEvent('name-changed', {
- detail,
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'name-changed', detail);
this.requestUpdate();
}
@@ -427,13 +419,20 @@ export class GrGroup extends LitElement {
}
private getGroupSuggestions(input: string) {
- return this.restApiService.getSuggestedGroups(input).then(response => {
- const groups: AutocompleteSuggestion[] = [];
- for (const [name, group] of Object.entries(response ?? {})) {
- groups.push({name, value: decodeURIComponent(group.id)});
- }
- return groups;
- });
+ return this.restApiService
+ .getSuggestedGroups(
+ input,
+ /* project=*/ undefined,
+ /* n=*/ undefined,
+ throwingErrorCallback
+ )
+ .then(response => {
+ const groups: AutocompleteSuggestion[] = [];
+ for (const [name, group] of Object.entries(response ?? {})) {
+ groups.push({name, value: decodeURIComponent(group.id)});
+ }
+ return groups;
+ });
}
// private but used in test
@@ -447,25 +446,25 @@ export class GrGroup extends LitElement {
return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
}
- private handleNameTextChanged(e: CustomEvent) {
+ private handleNameTextChanged(e: ValueChangedEvent) {
if (!this.groupConfig || this.loading) return;
this.groupConfig.name = e.detail.value as GroupName;
this.requestUpdate();
}
- private handleOwnerTextChanged(e: CustomEvent) {
+ private handleOwnerTextChanged(e: ValueChangedEvent) {
if (!this.groupConfig || this.loading) return;
this.groupConfig.owner = e.detail.value;
this.requestUpdate();
}
- private handleOwnerValueChanged(e: CustomEvent) {
+ private handleOwnerValueChanged(e: ValueChangedEvent) {
if (this.loading) return;
this.groupConfigOwner = e.detail.value;
this.requestUpdate();
}
- private handleDescriptionTextChanged(e: CustomEvent) {
+ private handleDescriptionTextChanged(e: ValueChangedEvent) {
if (!this.groupConfig || this.loading) return;
this.groupConfig.description = e.detail.value;
this.requestUpdate();
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
index 35e50adfaf..7f03d1d5a9 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -26,21 +26,24 @@ import {
AutocompleteQuery,
GrAutocomplete,
AutocompleteSuggestion,
- AutocompleteCommitEvent,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {
EditablePermissionInfo,
EditablePermissionRuleInfo,
- EditableProjectAccessGroups,
+ EditableRepoAccessGroups,
} from '../gr-repo-access/gr-repo-access-interfaces';
import {getAppContext} from '../../../services/app-context';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {paperStyles} from '../../../styles/gr-paper-styles';
import {formStyles} from '../../../styles/gr-form-styles';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {when} from 'lit/directives/when.js';
-import {ValueChangedEvent} from '../../../types/events';
+import {
+ AutocompleteCommitEvent,
+ ValueChangedEvent,
+} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const MAX_AUTOCOMPLETE_RESULTS = 20;
@@ -63,16 +66,6 @@ interface GroupSuggestion {
value: GroupInfo;
}
-/**
- * Fired when the permission has been modified or removed.
- *
- * @event access-modified
- */
-/**
- * Fired when a permission that was previously added was removed.
- *
- * @event added-permission-removed
- */
@customElement('gr-permission')
export class GrPermission extends LitElement {
@property({type: String})
@@ -88,7 +81,7 @@ export class GrPermission extends LitElement {
permission?: PermissionArrayItem<EditablePermissionInfo>;
@property({type: Object})
- groups?: EditableProjectAccessGroups;
+ groups?: EditableRepoAccessGroups;
@property({type: String})
section?: GitRef;
@@ -360,7 +353,7 @@ export class GrPermission extends LitElement {
this.permission.value.modified = true;
this.permission.value.exclusive = (e.target as HTMLInputElement).checked;
// Allows overall access page to know a change has been made.
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
}
handleRemovePermission() {
@@ -368,11 +361,11 @@ export class GrPermission extends LitElement {
return;
}
if (this.permission.value.added) {
- fireEvent(this, 'added-permission-removed');
+ fire(this, 'added-permission-removed', {});
}
this.deleted = true;
this.permission.value.deleted = true;
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
}
private handleRulesChanged() {
@@ -458,7 +451,7 @@ export class GrPermission extends LitElement {
}
computeGroupName(
- groups: EditableProjectAccessGroups | undefined,
+ groups: EditableRepoAccessGroups | undefined,
groupId: GitRef
) {
return groups && groups[groupId] && groups[groupId].name
@@ -471,7 +464,8 @@ export class GrPermission extends LitElement {
.getSuggestedGroups(
this.groupFilter || '',
this.repo,
- MAX_AUTOCOMPLETE_RESULTS
+ MAX_AUTOCOMPLETE_RESULTS,
+ throwingErrorCallback
)
.then(response => {
const groups: GroupSuggestion[] = [];
@@ -540,7 +534,7 @@ export class GrPermission extends LitElement {
const value = this.rules[this.rules.length - 1].value;
value!.added = true;
this.permission.value.rules[groupId] = value!;
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
this.requestUpdate();
}
@@ -563,6 +557,11 @@ export class GrPermission extends LitElement {
e.preventDefault();
}
+ // TODO: Do not use generic `CustomEvent`.
+ // There is something fishy going on here though.
+ // `e.detail.value` is of type `Rule`, but `splice()` expects a `number`.
+ // Did not look closer, but this seems to be broken. Should `e.detail.value`
+ // be replaced by `1` maybe??
private handleRuleChanged(e: CustomEvent, index: number) {
this.rules!.splice(index, e.detail.value);
this.handleRulesChanged();
@@ -572,6 +571,8 @@ export class GrPermission extends LitElement {
declare global {
interface HTMLElementEventMap {
+ /** Fired when a permission that was previously added was removed. */
+ 'added-permission-removed': CustomEvent<{}>;
'permission-changed': ValueChangedEvent<
PermissionArrayItem<EditablePermissionInfo>
>;
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
index b6bc3ed50c..46f6ac4750 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.ts
@@ -9,15 +9,13 @@ import {GrPermission} from './gr-permission';
import {query, stubRestApi, waitEventLoop} from '../../../test/test-utils';
import {GitRef, GroupId, GroupName} from '../../../types/common';
import {PermissionAction} from '../../../constants/constants';
-import {
- AutocompleteCommitEventDetail,
- GrAutocomplete,
-} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
import {queryAndAssert} from '../../../test/test-utils';
import {GrRuleEditor} from '../gr-rule-editor/gr-rule-editor';
import {GrButton} from '../../shared/gr-button/gr-button';
import {fixture, html, assert} from '@open-wc/testing';
import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+import {AutocompleteCommitEventDetail} from '../../../types/events';
suite('gr-permission tests', () => {
let element: GrPermission;
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 54a83ee9c4..5afaa9dc13 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -15,21 +15,19 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
'gr-plugin-config-array-editor': GrPluginConfigArrayEditor;
}
+ interface HTMLElementEventMap {
+ 'plugin-config-option-changed': CustomEvent<PluginConfigOptionsChangedEventDetail>;
+ }
}
@customElement('gr-plugin-config-array-editor')
export class GrPluginConfigArrayEditor extends LitElement {
- /**
- * Fired when the plugin config option changes.
- *
- * @event plugin-config-option-changed
- */
-
// private but used in test
@state() newValue = '';
@@ -175,9 +173,7 @@ export class GrPluginConfigArrayEditor extends LitElement {
info: {...info, values},
notifyPath: `${_key}.values`,
};
- this.dispatchEvent(
- new CustomEvent('plugin-config-option-changed', {detail})
- );
+ fireNoBubbleNoCompose(this, 'plugin-config-option-changed', detail);
}
private handleBindValueChangedNewValue(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
index 383b4a7dfb..852f907bc6 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -9,12 +9,15 @@ import {firePageError, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {tableStyles} from '../../../styles/gr-table-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+ AdminChildView,
+ AdminViewState,
+ createAdminUrl,
+} from '../../../models/views/admin';
// Exported for tests
export interface PluginInfoWithName extends PluginInfo {
@@ -23,8 +26,6 @@ export interface PluginInfoWithName extends PluginInfo {
@customElement('gr-plugin-list')
export class GrPluginList extends LitElement {
- readonly path = '/admin/plugins';
-
/**
* URL params passed from the router.
*/
@@ -34,23 +35,21 @@ export class GrPluginList extends LitElement {
/**
* Offset of currently visible query results.
*/
- @state() private offset = 0;
+ @state() offset = 0;
- // private but used in test
@state() plugins?: PluginInfoWithName[];
- @state() private pluginsPerPage = 25;
+ @state() pluginsPerPage = 25;
- // private but used in test
@state() loading = true;
- @state() private filter = '';
+ @state() filter = '';
private readonly restApiService = getAppContext().restApiService;
override connectedCallback() {
super.connectedCallback();
- fireTitleChange(this, 'Plugins');
+ fireTitleChange('Plugins');
}
static override get styles() {
@@ -73,7 +72,7 @@ export class GrPluginList extends LitElement {
.items=${this.plugins}
.loading=${this.loading}
.offset=${this.offset}
- .path=${this.path}
+ .path=${createAdminUrl({adminView: AdminChildView.PLUGINS})}
>
<table id="list" class="genericList">
<tbody>
@@ -107,7 +106,7 @@ export class GrPluginList extends LitElement {
return html`
<tbody>
${this.plugins
- ?.slice(0, SHOWN_ITEMS_COUNT)
+ ?.slice(0, this.pluginsPerPage)
.map(plugin => this.renderPluginList(plugin))}
</tbody>
`;
@@ -176,7 +175,7 @@ export class GrPluginList extends LitElement {
}
private computePluginUrl(id: string) {
- return getBaseUrl() + '/' + encodeURL(id, true);
+ return getBaseUrl() + '/' + encodeURL(id);
}
}
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
index 4057e5243a..fa1a6a8372 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.ts
@@ -17,7 +17,6 @@ import {
import {PluginInfo} from '../../../types/common';
import {GerritView} from '../../../services/router/router-model';
import {PageErrorEvent} from '../../../types/events';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {fixture, html, assert} from '@open-wc/testing';
import {AdminChildView, AdminViewState} from '../../../models/views/admin';
@@ -334,7 +333,9 @@ suite('gr-plugin-list tests', () => {
});
test('plugins', () => {
- assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.pluginsPerPage);
});
});
@@ -348,7 +349,9 @@ suite('gr-plugin-list tests', () => {
});
test('plugins', () => {
- assert.equal(element.plugins!.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.pluginsPerPage);
});
});
@@ -387,7 +390,7 @@ suite('gr-plugin-list tests', () => {
test('fires page-error', async () => {
const response = {status: 404} as Response;
stubRestApi('getPlugins').callsFake(
- (_filter, _pluginsPerPage, _opt_offset, errFn) => {
+ (_filter, _pluginsPerPage, _offset, errFn) => {
if (errFn !== undefined) {
errFn(response);
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index c7bd36fe04..e9396b9abf 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -66,6 +66,6 @@ export type PermissionAccessSection =
export interface NewlyAddedGroupInfo {
name: string;
}
-export type EditableProjectAccessGroups = {
+export type EditableRepoAccessGroups = {
[uuid: string]: GroupInfo | NewlyAddedGroupInfo;
};
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 21ab184202..2809d6ee95 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
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../gr-access-section/gr-access-section';
-import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
+import {singleDecodeURL} from '../../../utils/url-util';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {toSortedPermissionsArray} from '../../../utils/access-util';
import {
@@ -15,7 +15,7 @@ import {
ProjectAccessInput,
GitRef,
UrlEncodedRepoName,
- ProjectAccessGroups,
+ RepoAccessGroups,
} from '../../../types/common';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrAccessSection} from '../gr-access-section/gr-access-section';
@@ -39,10 +39,15 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
-import {ValueChangedEvent} from '../../../types/events';
-import {ifDefined} from 'lit/directives/if-defined.js';
+import {
+ AutocompleteCommitEvent,
+ ValueChangedEvent,
+} from '../../../types/events';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {createRepoUrl, RepoDetailView} from '../../../models/views/repo';
+import '../../shared/gr-weblink/gr-weblink';
const NOTHING_TO_SAVE = 'No changes to save.';
@@ -79,7 +84,7 @@ export class GrRepoAccess extends LitElement {
@state() capabilities?: CapabilityInfoMap;
// private but used in test
- @state() groups?: ProjectAccessGroups;
+ @state() groups?: RepoAccessGroups;
// private but used in test
@state() inheritsFrom?: ProjectInfo;
@@ -141,7 +146,7 @@ export class GrRepoAccess extends LitElement {
min-height: 2em;
align-items: center;
}
- .weblink {
+ gr-weblink {
margin-right: var(--spacing-xs);
}
gr-access-section {
@@ -192,7 +197,7 @@ export class GrRepoAccess extends LitElement {
id="editInheritFromInput"
.text=${this.inheritFromFilter}
.query=${this.query}
- @commit=${(e: ValueChangedEvent) => {
+ @commit=${(e: AutocompleteCommitEvent) => {
this.handleUpdateInheritFrom(e);
}}
@bind-value-changed=${(e: ValueChangedEvent) => {
@@ -205,7 +210,9 @@ export class GrRepoAccess extends LitElement {
</h3>
<div class="weblinks ${this.weblinks?.length ? 'show' : ''}">
History:
- ${this.weblinks?.map(webLink => this.renderWebLinks(webLink))}
+ ${this.weblinks?.map(
+ info => html`<gr-weblink .info=${info}></gr-weblink>`
+ )}
</div>
${this.sections?.map((section, index) =>
this.renderPermissionSections(section, index)
@@ -249,19 +256,6 @@ export class GrRepoAccess extends LitElement {
`;
}
- private renderWebLinks(webLink: WebLinkInfo) {
- return html`
- <a
- class="weblink"
- href=${webLink.url}
- rel="noopener"
- target=${ifDefined(webLink.target)}
- >
- ${webLink.name}
- </a>
- `;
- }
-
private renderPermissionSections(
section: PermissionAccessSection,
index: number
@@ -318,7 +312,7 @@ export class GrRepoAccess extends LitElement {
this.editing = false;
- // Always reset sections when a project changes.
+ // Always reset sections when a repo changes.
this.sections = [];
const sectionsPromises = this.restApiService
.getRepoAccessRights(repo, errFn)
@@ -386,7 +380,7 @@ export class GrRepoAccess extends LitElement {
}
// private but used in test
- handleUpdateInheritFrom(e: ValueChangedEvent) {
+ handleUpdateInheritFrom(e: AutocompleteCommitEvent) {
this.inheritsFrom = {
...(this.inheritsFrom ?? {}),
id: e.detail.value as UrlEncodedRepoName,
@@ -397,19 +391,24 @@ export class GrRepoAccess extends LitElement {
private getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
return this.restApiService
- .getRepos(this.inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+ .getRepos(
+ this.inheritFromFilter,
+ MAX_AUTOCOMPLETE_RESULTS,
+ /* offset=*/ undefined,
+ throwingErrorCallback
+ )
.then(response => {
- const projects: AutocompleteSuggestion[] = [];
+ const repos: AutocompleteSuggestion[] = [];
if (!response) {
- return projects;
+ return repos;
}
for (const item of response) {
- projects.push({
+ repos.push({
name: item.name,
value: item.id,
});
}
- return projects;
+ return repos;
});
}
@@ -720,10 +719,10 @@ export class GrRepoAccess extends LitElement {
computeParentHref() {
if (!this.inheritsFrom?.name) return '';
- return `${getBaseUrl()}/admin/repos/${encodeURL(
- this.inheritsFrom.name,
- true
- )},access`;
+ return createRepoUrl({
+ repo: this.inheritsFrom.name,
+ detail: RepoDetailView.ACCESS,
+ });
}
private handleEditInheritFromTextChanged(e: ValueChangedEvent) {
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 85d5c21c8a..467857d9a1 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
@@ -22,12 +22,9 @@ import {
UrlEncodedRepoName,
} from '../../../types/common';
import {PermissionAction} from '../../../constants/constants';
-import {PageErrorEvent} from '../../../types/events';
+import {AutocompleteCommitEvent, PageErrorEvent} from '../../../types/events';
import {GrButton} from '../../shared/gr-button/gr-button';
-import {
- AutocompleteCommitEvent,
- GrAutocomplete,
-} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
import {GrAccessSection} from '../gr-access-section/gr-access-section';
import {GrPermission} from '../gr-permission/gr-permission';
import {createChange} from '../../../test/test-data-generators';
@@ -303,7 +300,7 @@ suite('gr-repo-access tests', () => {
};
await element.updateComplete;
- // When there is a parent project, the link should be displayed.
+ // When there is a parent repo, the link should be displayed.
assert.notEqual(
getComputedStyle(
queryAndAssert<HTMLHeadingElement>(element, '#inheritsFrom')
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
index 9867aa5bcb..553de0e66c 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -3,13 +3,12 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
import '../gr-create-change-dialog/gr-create-change-dialog';
+import '../gr-create-change-dialog/gr-create-file-edit-dialog';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {
BranchName,
@@ -17,7 +16,6 @@ import {
RevisionPatchSetNum,
RepoName,
} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
import {
fireAlert,
@@ -33,8 +31,10 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, query, property, state} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrCreateFileEditDialog} from '../gr-create-change-dialog/gr-create-file-edit-dialog';
const GC_MESSAGE = 'Garbage collection completed successfully.';
const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
@@ -52,15 +52,24 @@ declare global {
@customElement('gr-repo-commands')
export class GrRepoCommands extends LitElement {
- @query('#createChangeOverlay')
- private readonly createChangeOverlay?: GrOverlay;
+ @query('#createChangeModal')
+ private readonly createChangeModal?: HTMLDialogElement;
@query('#createNewChangeModal')
private readonly createNewChangeModal?: GrCreateChangeDialog;
+ @query('#createFileEditDialog')
+ private readonly createFileEditDialog?: GrCreateFileEditDialog;
+
@property({type: String})
repo?: RepoName;
+ @property({type: Object})
+ createEdit?: {
+ branch: BranchName;
+ path: string;
+ };
+
@state() private loading = true;
@state() private repoConfig?: ConfigInfo;
@@ -77,9 +86,12 @@ export class GrRepoCommands extends LitElement {
private readonly getNavigation = resolve(this, navigationToken);
+ /** Make sure that this dialog is only activated once. */
+ private createFileEditDialogWasActivated = false;
+
override connectedCallback() {
super.connectedCallback();
- fireTitleChange(this, 'Repo Commands');
+ fireTitleChange('Repo Commands');
}
static override get styles() {
@@ -88,6 +100,7 @@ export class GrRepoCommands extends LitElement {
formStyles,
subpageStyles,
sharedStyles,
+ modalStyles,
css`
#form h2,
h3 {
@@ -156,7 +169,7 @@ export class GrRepoCommands extends LitElement {
</div>
</div>
</div>
- <gr-overlay id="createChangeOverlay" with-backdrop>
+ <dialog id="createChangeModal" tabindex="-1">
<gr-dialog
id="createChangeDialog"
confirm-label="Create"
@@ -180,7 +193,13 @@ export class GrRepoCommands extends LitElement {
></gr-create-change-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
+ <gr-create-file-edit-dialog
+ id="createFileEditDialog"
+ .repo=${this.repo}
+ .branch=${this.createEdit?.branch}
+ .path=${this.createEdit?.path}
+ ></gr-create-file-edit-dialog>
`;
}
@@ -200,6 +219,15 @@ export class GrRepoCommands extends LitElement {
`;
}
+ override updated(changedProperties: PropertyValues) {
+ if (changedProperties.has('createEdit')) {
+ if (!this.createFileEditDialogWasActivated) {
+ this.createFileEditDialog?.activate();
+ this.createFileEditDialogWasActivated = true;
+ }
+ }
+ }
+
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('repo')) {
this.loadRepo();
@@ -242,8 +270,8 @@ export class GrRepoCommands extends LitElement {
// private but used in test
createNewChange() {
- assertIsDefined(this.createChangeOverlay, 'createChangeOverlay');
- this.createChangeOverlay.open();
+ assertIsDefined(this.createChangeModal, 'createChangeModal');
+ this.createChangeModal.showModal();
}
// private but used in test
@@ -258,8 +286,8 @@ export class GrRepoCommands extends LitElement {
// private but used in test
handleCloseCreateChange() {
- assertIsDefined(this.createChangeOverlay, 'createChangeOverlay');
- this.createChangeOverlay.close();
+ assertIsDefined(this.createChangeModal, 'createChangeModal');
+ this.createChangeModal.close();
}
/**
@@ -291,9 +319,9 @@ export class GrRepoCommands extends LitElement {
this.getNavigation().setUrl(
createEditUrl({
changeNum: change._number,
- project: change.project,
- path: CONFIG_PATH,
+ repo: change.project,
patchNum: INITIAL_PATCHSET,
+ editView: {path: CONFIG_PATH},
})
);
})
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
index 77caf5eb81..af2831a6bb 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.ts
@@ -12,9 +12,8 @@ import {
queryAndAssert,
stubRestApi,
} from '../../../test/test-utils';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {EventType, PageErrorEvent} from '../../../types/events';
+import {PageErrorEvent} from '../../../types/events';
import {RepoName} from '../../../types/common';
import {GrButton} from '../../shared/gr-button/gr-button';
import {fixture, html, assert} from '@open-wc/testing';
@@ -81,13 +80,7 @@ suite('gr-repo-commands tests', () => {
</div>
</div>
</div>
- <gr-overlay
- aria-hidden="true"
- id="createChangeOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="createChangeModal" tabindex="-1">
<gr-dialog
confirm-label="Create"
disabled=""
@@ -100,7 +93,9 @@ suite('gr-repo-commands tests', () => {
</gr-create-change-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
+ <gr-create-file-edit-dialog id="createFileEditDialog">
+ </gr-create-file-edit-dialog>
`,
{ignoreTags: ['p']}
);
@@ -109,8 +104,8 @@ suite('gr-repo-commands tests', () => {
suite('create new change dialog', () => {
test('createNewChange opens modal', () => {
const openStub = sinon.stub(
- queryAndAssert<GrOverlay>(element, '#createChangeOverlay'),
- 'open'
+ queryAndAssert<HTMLDialogElement>(element, '#createChangeModal'),
+ 'showModal'
);
element.createNewChange();
assert.isTrue(openStub.called);
@@ -152,7 +147,7 @@ suite('gr-repo-commands tests', () => {
handleSpy = sinon.spy(element, 'handleEditRepoConfig');
alertStub = sinon.stub();
element.repo = 'test' as RepoName;
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
});
test('successful creation of change', async () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
index 86d4bc5e9e..70403fdf36 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -9,11 +9,9 @@ import '../../shared/gr-button/gr-button';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-weblink/gr-weblink';
import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
-import {encodeURL} from '../../../utils/url-util';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
import {
BranchInfo,
@@ -27,24 +25,28 @@ import {
import {firePageError} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {formStyles} from '../../../styles/gr-form-styles';
import {tableStyles} from '../../../styles/gr-table-styles';
import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, PropertyValues, css, html} from 'lit';
+import {LitElement, PropertyValues, css, html, nothing} from 'lit';
import {customElement, query, property, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
import {assertIsDefined} from '../../../utils/common-util';
import {ifDefined} from 'lit/directives/if-defined.js';
-import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
+import {
+ createRepoUrl,
+ RepoDetailView,
+ RepoViewState,
+} from '../../../models/views/repo';
+import {modalStyles} from '../../../styles/gr-modal-styles';
const PGP_START = '-----BEGIN PGP SIGNATURE-----';
@customElement('gr-repo-detail-list')
export class GrRepoDetailList extends LitElement {
- @query('#overlay') private readonly overlay?: GrOverlay;
+ @query('#modal') private readonly modal?: HTMLDialogElement;
- @query('#createOverlay') private readonly createOverlay?: GrOverlay;
+ @query('#createModal') private readonly createModal?: HTMLDialogElement;
@query('#createNewModal')
private readonly createNewModal?: GrCreatePointerDialog;
@@ -52,36 +54,30 @@ export class GrRepoDetailList extends LitElement {
@property({type: Object})
params?: RepoViewState;
- // private but used in test
@state() detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
- // private but used in test
@state() isOwner = false;
- @state() private loggedIn = false;
+ @state() loggedIn = false;
- @state() private offset = 0;
+ @state() offset = 0;
- // private but used in test
@state() repo?: RepoName;
- // private but used in test
@state() items?: BranchInfo[] | TagInfo[];
- @state() private readonly itemsPerPage = 25;
+ @state() readonly itemsPerPage = 25;
- @state() private loading = true;
+ @state() loading = true;
- @state() private filter?: string;
+ @state() filter?: string;
- @state() private refName?: GitRef;
+ @state() refName?: GitRef;
- @state() private newItemName = false;
+ @state() newItemName = false;
- // private but used in test
@state() isEditing = false;
- // private but used in test
@state() revisedRef?: GitRef;
private readonly restApiService = getAppContext().restApiService;
@@ -91,6 +87,7 @@ export class GrRepoDetailList extends LitElement {
formStyles,
tableStyles,
sharedStyles,
+ modalStyles,
css`
.tags td.name {
min-width: 25em;
@@ -139,6 +136,8 @@ export class GrRepoDetailList extends LitElement {
}
override render() {
+ if (!this.repo) return nothing;
+ if (!this.detailType) return nothing;
return html`
<gr-list-view
.createNew=${this.loggedIn}
@@ -147,7 +146,7 @@ export class GrRepoDetailList extends LitElement {
.items=${this.items}
.loading=${this.loading}
.offset=${this.offset}
- .path=${this.getPath(this.repo, this.detailType)}
+ .path=${this.getPath()}
@create-clicked=${() => {
this.handleCreateClicked();
}}
@@ -185,11 +184,11 @@ export class GrRepoDetailList extends LitElement {
</tbody>
<tbody class=${this.loading ? 'loading' : ''}>
${this.items
- ?.slice(0, SHOWN_ITEMS_COUNT)
+ ?.slice(0, this.itemsPerPage)
.map((item, index) => this.renderItemList(item, index))}
</tbody>
</table>
- <gr-overlay id="overlay" with-backdrop>
+ <dialog id="modal" tabindex="-1">
<gr-confirm-delete-item-dialog
class="confirmDialog"
.item=${this.refName}
@@ -199,9 +198,9 @@ export class GrRepoDetailList extends LitElement {
this.handleConfirmDialogCancel();
}}
></gr-confirm-delete-item-dialog>
- </gr-overlay>
+ </dialog>
</gr-list-view>
- <gr-overlay id="createOverlay" with-backdrop>
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
id="createDialog"
?disabled=${!this.newItemName}
@@ -228,7 +227,7 @@ export class GrRepoDetailList extends LitElement {
></gr-create-pointer-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
@@ -337,12 +336,8 @@ export class GrRepoDetailList extends LitElement {
`;
}
- private renderWeblink(link: WebLinkInfo) {
- return html`
- <a href=${link.url} class="webLink" rel="noopener" target="_blank">
- (${link.name})
- </a>
- `;
+ private renderWeblink(info: WebLinkInfo) {
+ return html`<gr-weblink imageAndText .info=${info}></gr-weblink>`;
}
override willUpdate(changedProperties: PropertyValues) {
@@ -441,8 +436,10 @@ export class GrRepoDetailList extends LitElement {
return Promise.reject(new Error('unknown detail type'));
}
- private getPath(repo?: RepoName, detailType?: RepoDetailView) {
- return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
+ private getPath() {
+ if (!this.repo) return '';
+ if (!this.detailType) return '';
+ return createRepoUrl({repo: this.repo, detail: this.detailType});
}
private computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
@@ -531,8 +528,8 @@ export class GrRepoDetailList extends LitElement {
}
private handleDeleteItemConfirm() {
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.close();
+ assertIsDefined(this.modal, 'modal');
+ this.modal.close();
if (!this.repo || !this.refName) {
return Promise.reject(new Error('undefined repo or refName'));
}
@@ -569,20 +566,20 @@ export class GrRepoDetailList extends LitElement {
}
private handleConfirmDialogCancel() {
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.close();
+ assertIsDefined(this.modal, 'modal');
+ this.modal.close();
}
private handleDeleteItem(index: number) {
if (!this.items) return;
- assertIsDefined(this.overlay, 'overlay');
+ assertIsDefined(this.modal, 'modal');
const name = this.stripRefs(
this.items[index].ref,
this.detailType
) as GitRef;
if (!name) return;
this.refName = name;
- this.overlay.open();
+ this.modal.showModal();
}
// private but used in test
@@ -594,14 +591,14 @@ export class GrRepoDetailList extends LitElement {
// private but used in test
handleCloseCreate() {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.close();
+ assertIsDefined(this.createModal, 'createModal');
+ this.createModal.close();
}
// private but used in test
handleCreateClicked() {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.open();
+ assertIsDefined(this.createModal, 'createModal');
+ this.createModal.showModal();
}
private handleUpdateItemName() {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
index 13f6b2b0de..391a22aeab 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.ts
@@ -6,7 +6,6 @@
import '../../../test/common-test-setup';
import './gr-repo-detail-list';
import {GrRepoDetailList} from './gr-repo-detail-list';
-import {page} from '../../../utils/page-wrapper-utils';
import {
addListenerForTest,
mockPromise,
@@ -20,22 +19,21 @@ import {
GitRef,
GroupId,
GroupName,
- ProjectAccessGroups,
- ProjectAccessInfoMap,
+ RepoAccessGroups,
+ RepoAccessInfoMap,
RepoName,
TagInfo,
Timestamp,
- TimezoneOffset,
} from '../../../types/common';
import {GerritView} from '../../../services/router/router-model';
import {GrButton} from '../../shared/gr-button/gr-button';
import {PageErrorEvent} from '../../../types/events';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrListView} from '../../shared/gr-list-view/gr-list-view';
-import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
import {fixture, html, assert} from '@open-wc/testing';
import {RepoDetailView} from '../../../models/views/repo';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
function branchGenerator(counter: number) {
return {
@@ -74,7 +72,6 @@ function tagGenerator(counter: number) {
name: 'Test User',
email: 'test.user@gmail.com' as EmailAddress,
date: '2017-09-19 14:54:00.000000000' as Timestamp,
- tz: 540 as TimezoneOffset,
},
};
}
@@ -97,7 +94,7 @@ suite('gr-repo-detail-list', () => {
html`<gr-repo-detail-list></gr-repo-detail-list>`
);
element.detailType = RepoDetailView.BRANCHES;
- sinon.stub(page, 'show');
+ sinon.stub(testResolver(navigationToken), 'setUrl');
});
suite('list of repo branches', () => {
@@ -254,14 +251,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test0"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -330,14 +320,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test1"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -406,14 +389,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test2"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -482,14 +458,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test3"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -558,14 +527,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test4"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -634,14 +596,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test5"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -710,14 +665,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test6"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -786,14 +734,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test7"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -862,14 +803,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test8"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -938,14 +872,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test9"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1014,14 +941,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test10"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1090,14 +1010,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test11"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1166,14 +1079,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test12"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1242,14 +1148,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test13"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1318,14 +1217,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test14"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1394,14 +1286,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test15"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1470,14 +1355,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test16"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1546,14 +1424,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test17"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1622,14 +1493,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test18"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1698,14 +1562,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test19"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1774,14 +1631,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test20"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1850,14 +1700,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test21"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -1926,14 +1769,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test22"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -2002,14 +1838,7 @@ suite('gr-repo-detail-list', () => {
<td class="hideItem message"></td>
<td class="hideItem tagger"></td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://git.example.org/branch/test;refs/heads/test23"
- rel="noopener"
- target="_blank"
- >
- (diffusion)
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="delete">
<gr-button
@@ -2026,24 +1855,12 @@ suite('gr-repo-detail-list', () => {
</tr>
</tbody>
</table>
- <gr-overlay
- aria-hidden="true"
- id="overlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="modal" tabindex="-1">
<gr-confirm-delete-item-dialog class="confirmDialog">
</gr-confirm-delete-item-dialog>
- </gr-overlay>
+ </dialog>
</gr-list-view>
- <gr-overlay
- aria-hidden="true"
- id="createOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
confirm-label="Create"
disabled=""
@@ -2056,7 +1873,7 @@ suite('gr-repo-detail-list', () => {
</gr-create-pointer-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -2103,10 +1920,10 @@ suite('gr-repo-detail-list', () => {
url: 'test',
name: 'test' as GroupName,
},
- } as ProjectAccessGroups,
+ } as RepoAccessGroups,
config_web_links: [{name: 'gitiles', url: 'test'}],
},
- } as ProjectAccessInfoMap)
+ } as RepoAccessInfoMap)
);
await element.determineIfOwner('test' as RepoName);
assert.equal(element.isOwner, false);
@@ -2157,10 +1974,10 @@ suite('gr-repo-detail-list', () => {
url: 'test',
name: 'test' as GroupName,
},
- } as ProjectAccessGroups,
+ } as RepoAccessGroups,
config_web_links: [{name: 'gitiles', url: 'test'}],
},
- } as ProjectAccessInfoMap)
+ } as RepoAccessInfoMap)
);
const handleSaveRevisionStub = sinon.stub(
element,
@@ -2316,7 +2133,7 @@ suite('gr-repo-detail-list', () => {
test('fires page-error', async () => {
const response = {status: 404} as Response;
stubRestApi('getRepoBranches').callsFake(
- (_filter, _repo, _reposBranchesPerPage, _opt_offset, errFn) => {
+ (_filter, _repo, _reposBranchesPerPage, _offset, errFn) => {
if (errFn !== undefined) {
errFn(response);
}
@@ -2352,7 +2169,7 @@ suite('gr-repo-detail-list', () => {
html`<gr-repo-detail-list></gr-repo-detail-list>`
);
element.detailType = RepoDetailView.TAGS;
- sinon.stub(page, 'show');
+ sinon.stub(testResolver(navigationToken), 'setUrl');
});
suite('list of repo tags', () => {
@@ -2383,7 +2200,6 @@ suite('gr-repo-detail-list', () => {
name: 'Test User',
email: 'test.user@gmail.com' as EmailAddress,
date: '2017-09-19 14:54:00.000000000' as Timestamp,
- tz: 540 as TimezoneOffset,
};
assert.deepEqual((element.items as TagInfo[])![2].tagger, tagger);
@@ -2404,7 +2220,9 @@ suite('gr-repo-detail-list', () => {
});
test('items', () => {
- assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.itemsPerPage);
});
});
@@ -2424,7 +2242,9 @@ suite('gr-repo-detail-list', () => {
});
test('items', () => {
- assert.equal(element.items!.slice(0, SHOWN_ITEMS_COUNT)!.length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.itemsPerPage);
});
});
@@ -2447,6 +2267,18 @@ suite('gr-repo-detail-list', () => {
});
suite('create new', () => {
+ setup(async () => {
+ stubRestApi('getRepoBranches').resolves(createBranchesList(3));
+
+ element.params = {
+ view: GerritView.REPO,
+ repo: 'test' as RepoName,
+ detail: RepoDetailView.BRANCHES,
+ };
+ await element.paramsChanged();
+ await element.updateComplete;
+ });
+
test('handleCreateClicked called when create-click fired', () => {
const handleCreateClickedStub = sinon.stub(
element,
@@ -2462,10 +2294,10 @@ suite('gr-repo-detail-list', () => {
});
test('handleCreateClicked opens modal', () => {
- queryAndAssert<GrOverlay>(element, '#createOverlay');
+ queryAndAssert<HTMLDialogElement>(element, '#createModal');
const openStub = sinon.stub(
- queryAndAssert<GrOverlay>(element, '#createOverlay'),
- 'open'
+ queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+ 'showModal'
);
element.handleCreateClicked();
assert.isTrue(openStub.called);
@@ -2498,7 +2330,7 @@ suite('gr-repo-detail-list', () => {
test('fires page-error', async () => {
const response = {status: 404} as Response;
stubRestApi('getRepoTags').callsFake(
- (_filter, _repo, _reposTagsPerPage, _opt_offset, errFn) => {
+ (_filter, _repo, _reposTagsPerPage, _offset, errFn) => {
if (errFn !== undefined) {
errFn(response);
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
index 14fbe92c0c..53185008bd 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -5,25 +5,25 @@
*/
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-list-view/gr-list-view';
-import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-weblink/gr-weblink';
import '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {
- RepoName,
- ProjectInfoWithName,
- WebLinkInfo,
-} from '../../../types/common';
+import {ProjectInfoWithName, WebLinkInfo} from '../../../types/common';
import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
-import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../constants/constants';
import {fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util';
import {tableStyles} from '../../../styles/gr-table-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
-import {AdminViewState} from '../../../models/views/admin';
+import {
+ AdminChildView,
+ AdminViewState,
+ createAdminUrl,
+} from '../../../models/views/admin';
import {createSearchUrl} from '../../../models/views/search';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {createRepoUrl} from '../../../models/views/repo';
declare global {
interface HTMLElementTagNameMap {
@@ -33,32 +33,25 @@ declare global {
@customElement('gr-repo-list')
export class GrRepoList extends LitElement {
- readonly path = '/admin/repos';
-
- @query('#createOverlay') private createOverlay?: GrOverlay;
+ @query('#createModal') private createModal?: HTMLDialogElement;
@query('#createNewModal') private createNewModal?: GrCreateRepoDialog;
@property({type: Object})
params?: AdminViewState;
- // private but used in test
@state() offset = 0;
- @state() private newRepoName = false;
+ @state() newRepoName = false;
- @state() private createNewCapability = false;
+ @state() createNewCapability = false;
- // private but used in test
@state() repos: ProjectInfoWithName[] = [];
- // private but used in test
@state() reposPerPage = 25;
- // private but used in test
@state() loading = true;
- // private but used in test
@state() filter = '';
private readonly restApiService = getAppContext().restApiService;
@@ -66,14 +59,15 @@ export class GrRepoList extends LitElement {
override async connectedCallback() {
super.connectedCallback();
await this.getCreateRepoCapability();
- fireTitleChange(this, 'Repos');
- this.maybeOpenCreateOverlay(this.params);
+ fireTitleChange('Repos');
+ this.maybeOpenCreateModal(this.params);
}
static override get styles() {
return [
tableStyles,
sharedStyles,
+ modalStyles,
css`
.genericList tr td:last-of-type {
text-align: left;
@@ -103,7 +97,7 @@ export class GrRepoList extends LitElement {
.items=${this.repos}
.loading=${this.loading}
.offset=${this.offset}
- .path=${this.path}
+ .path=${createAdminUrl({adminView: AdminChildView.REPOS})}
@create-clicked=${() => this.handleCreateClicked()}
>
<table id="list" class="genericList">
@@ -127,7 +121,7 @@ export class GrRepoList extends LitElement {
</tbody>
</table>
</gr-list-view>
- <gr-overlay id="createOverlay" with-backdrop>
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
id="createDialog"
class="confirmDialog"
@@ -144,12 +138,12 @@ export class GrRepoList extends LitElement {
></gr-create-repo-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
private renderRepoList() {
- const shownRepos = this.repos.slice(0, SHOWN_ITEMS_COUNT);
+ const shownRepos = this.repos.slice(0, this.reposPerPage);
return shownRepos.map(item => this.renderRepo(item));
}
@@ -157,14 +151,14 @@ export class GrRepoList extends LitElement {
return html`
<tr class="table">
<td class="name">
- <a href=${this.computeRepoUrl(item.name)}>${item.name}</a>
+ <a href=${createRepoUrl({repo: item.name})}>${item.name}</a>
</td>
<td class="repositoryBrowser">${this.renderWebLinks(item)}</td>
<td class="changesLink">
- <a href=${this.computeChangesLink(item.name)}>view all</a>
+ <a href=${createSearchUrl({repo: item.name})}>view all</a>
</td>
<td class="readOnly">
- ${item.state === ProjectState.READ_ONLY ? 'Y' : ''}
+ ${item.state === RepoState.READ_ONLY ? 'Y' : ''}
</td>
<td class="description">${item.description}</td>
</tr>
@@ -176,12 +170,8 @@ export class GrRepoList extends LitElement {
return webLinks.map(link => this.renderWebLink(link));
}
- private renderWebLink(link: WebLinkInfo) {
- return html`
- <a href=${link.url} class="webLink" rel="noopener" target="_blank">
- ${link.name}
- </a>
- `;
+ private renderWebLink(info: WebLinkInfo) {
+ return html`<gr-weblink imageAndText .info=${info}></gr-weblink>`;
}
override willUpdate(changedProperties: PropertyValues) {
@@ -204,20 +194,12 @@ export class GrRepoList extends LitElement {
*
* private but used in test
*/
- maybeOpenCreateOverlay(params?: AdminViewState) {
+ maybeOpenCreateModal(params?: AdminViewState) {
if (params?.openCreateModal) {
- this.createOverlay?.open();
+ this.createModal?.showModal();
}
}
- private computeRepoUrl(name: string) {
- return `${getBaseUrl()}${this.path}/${encodeURL(name, true)}`;
- }
-
- private computeChangesLink(name: string) {
- return createSearchUrl({project: name as RepoName});
- }
-
private async getCreateRepoCapability() {
const account = await this.restApiService.getAccount();
@@ -268,14 +250,13 @@ export class GrRepoList extends LitElement {
// private but used in test
handleCloseCreate() {
- this.createOverlay?.close();
+ this.createModal?.close();
}
// private but used in test
handleCreateClicked() {
- this.createOverlay?.open().then(() => {
- this.createNewModal?.focus();
- });
+ this.createModal?.showModal();
+ this.createNewModal?.focus();
}
// private but used in test
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
index 752e5f2ee0..b80db4c349 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.ts
@@ -6,7 +6,6 @@
import '../../../test/common-test-setup';
import './gr-repo-list';
import {GrRepoList} from './gr-repo-list';
-import {page} from '../../../utils/page-wrapper-utils';
import {
mockPromise,
queryAndAssert,
@@ -17,19 +16,20 @@ import {
ProjectInfoWithName,
RepoName,
} from '../../../types/common';
-import {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {RepoState} from '../../../api/rest-api';
import {GerritView} from '../../../services/router/router-model';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrListView} from '../../shared/gr-list-view/gr-list-view';
import {fixture, html, assert} from '@open-wc/testing';
import {AdminChildView, AdminViewState} from '../../../models/views/admin';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
function createRepo(name: string, counter: number) {
return {
id: `${name}${counter}` as UrlEncodedRepoName,
name: `${name}` as RepoName,
- state: 'ACTIVE' as ProjectState,
+ state: 'ACTIVE' as RepoState,
web_links: [
{
name: 'diffusion',
@@ -52,7 +52,7 @@ suite('gr-repo-list tests', () => {
let repos: ProjectInfoWithName[];
setup(async () => {
- sinon.stub(page, 'show');
+ sinon.stub(testResolver(navigationToken), 'setUrl');
element = await fixture(html`<gr-repo-list></gr-repo-list>`);
});
@@ -90,14 +90,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test0"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -110,14 +103,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test1"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -130,14 +116,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test2"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -150,14 +129,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test3"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -170,14 +142,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test4"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -190,14 +155,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test5"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -210,14 +168,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test6"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -230,14 +181,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test7"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -250,14 +194,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test8"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -270,14 +207,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test9"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -290,14 +220,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test10"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -310,14 +233,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test11"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -330,14 +246,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test12"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -350,14 +259,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test13"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -370,14 +272,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test14"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -390,14 +285,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test15"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -410,14 +298,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test16"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -430,14 +311,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test17"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -450,14 +324,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test18"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -470,14 +337,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test19"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -490,14 +350,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test20"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -510,14 +363,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test21"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -530,14 +376,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test22"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -550,14 +389,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test23"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -570,14 +402,7 @@ suite('gr-repo-list tests', () => {
<a href="/admin/repos/test"> test </a>
</td>
<td class="repositoryBrowser">
- <a
- class="webLink"
- href="https://phabricator.example.org/r/project/test24"
- rel="noopener"
- target="_blank"
- >
- diffusion
- </a>
+ <gr-weblink imageAndText></gr-weblink>
</td>
<td class="changesLink">
<a href="/q/project:test"> view all </a>
@@ -588,13 +413,7 @@ suite('gr-repo-list tests', () => {
</tbody>
</table>
</gr-list-view>
- <gr-overlay
- aria-hidden="true"
- id="createOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
class="confirmDialog"
confirm-label="Create"
@@ -608,7 +427,7 @@ suite('gr-repo-list tests', () => {
</gr-create-repo-dialog>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -621,25 +440,27 @@ suite('gr-repo-list tests', () => {
});
test('shownRepos', () => {
- assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.reposPerPage);
});
- test('maybeOpenCreateOverlay', () => {
- const overlayOpen = sinon.stub(
- queryAndAssert<GrOverlay>(element, '#createOverlay'),
- 'open'
+ test('maybeOpenCreateModal', () => {
+ const modalOpen = sinon.stub(
+ queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+ 'showModal'
);
- element.maybeOpenCreateOverlay();
- assert.isFalse(overlayOpen.called);
- element.maybeOpenCreateOverlay(undefined);
- assert.isFalse(overlayOpen.called);
+ element.maybeOpenCreateModal();
+ assert.isFalse(modalOpen.called);
+ element.maybeOpenCreateModal(undefined);
+ assert.isFalse(modalOpen.called);
const params: AdminViewState = {
view: GerritView.ADMIN,
adminView: AdminChildView.REPOS,
openCreateModal: true,
};
- element.maybeOpenCreateOverlay(params);
- assert.isTrue(overlayOpen.called);
+ element.maybeOpenCreateModal(params);
+ assert.isTrue(modalOpen.called);
});
});
@@ -652,7 +473,9 @@ suite('gr-repo-list tests', () => {
});
test('shownRepos', () => {
- assert.equal(element.repos.slice(0, SHOWN_ITEMS_COUNT).length, 25);
+ const table = queryAndAssert(element, 'table');
+ const rows = table.querySelectorAll('tr.table');
+ assert.equal(rows.length, element.reposPerPage);
});
});
@@ -760,9 +583,10 @@ suite('gr-repo-list tests', () => {
});
test('handleCreateClicked opens modal', () => {
- const openStub = sinon
- .stub(queryAndAssert<GrOverlay>(element, '#createOverlay'), 'open')
- .returns(Promise.resolve());
+ const openStub = sinon.stub(
+ queryAndAssert<HTMLDialogElement>(element, '#createModal'),
+ 'showModal'
+ );
element.handleCreateClicked();
assert.isTrue(openStub.called);
});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index ad87d867f1..fabf93db58 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -25,8 +25,7 @@ import {
PluginOption,
} from './gr-repo-plugin-config-types';
import {paperStyles} from '../../../styles/gr-paper-styles';
-
-const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
+import {fire} from '../../../utils/event-util';
export interface ConfigChangeInfo {
_key: string; // parameterName of PluginParameterToConfigParameterInfoMap
@@ -256,14 +255,7 @@ export class GrRepoPluginConfig extends LitElement {
name,
config: {...config, [_key]: info},
};
-
- this.dispatchEvent(
- new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME, {
- detail,
- bubbles: true,
- composed: true,
- })
- );
+ fire(this, 'plugin-config-changed', detail);
}
/**
@@ -278,4 +270,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-repo-plugin-config': GrRepoPluginConfig;
}
+ interface HTMLElementEventMap {
+ 'plugin-config-changed': CustomEvent<PluginConfigChangeDetail>;
+ }
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
index 391aa55b3c..4bbd533f71 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -22,7 +22,7 @@ import {
} from '../../../types/common';
import {
InheritedBooleanInfoConfiguredValue,
- ProjectState,
+ RepoState,
SubmitType,
} from '../../../constants/constants';
import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
@@ -41,11 +41,13 @@ import {customElement, property, state} from 'lit/decorators.js';
import {when} from 'lit/directives/when.js';
import {subscribe} from '../../lit/subscription-controller';
import {createSearchUrl} from '../../../models/views/search';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
const STATES = {
- active: {value: ProjectState.ACTIVE, label: 'Active'},
- readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
- hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+ active: {value: RepoState.ACTIVE, label: 'Active'},
+ readOnly: {value: RepoState.READ_ONLY, label: 'Read Only'},
+ hidden: {value: RepoState.HIDDEN, label: 'Hidden'},
};
const SUBMIT_TYPES = {
@@ -111,7 +113,7 @@ export class GrRepo extends LitElement {
@state() private pluginConfigChanged = false;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly restApiService = getAppContext().restApiService;
@@ -119,7 +121,7 @@ export class GrRepo extends LitElement {
super();
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
prefs => {
if (prefs?.download_scheme) {
// Note (issue 5180): normalize the download scheme with lower-case.
@@ -132,7 +134,7 @@ export class GrRepo extends LitElement {
override connectedCallback() {
super.connectedCallback();
- fireTitleChange(this, `${this.repo}`);
+ fireTitleChange(`${this.repo}`);
}
static override get styles() {
@@ -1096,7 +1098,7 @@ export class GrRepo extends LitElement {
private computeChangesUrl(name?: RepoName) {
if (!name) return '';
- return createSearchUrl({project: name});
+ return createSearchUrl({repo: name});
}
// private but used in test
@@ -1130,7 +1132,7 @@ export class GrRepo extends LitElement {
if (this.repoConfig.state === e.detail.value) return;
this.repoConfig = {
...this.repoConfig,
- state: e.detail.value as ProjectState,
+ state: e.detail.value as RepoState,
};
this.requestUpdate();
}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
index ef99a346c6..4deb99a1c2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.ts
@@ -25,14 +25,14 @@ import {
InheritedBooleanInfo,
MaxObjectSizeLimitInfo,
PluginParameterToConfigParameterInfoMap,
- ProjectAccessGroups,
- ProjectAccessInfoMap,
+ RepoAccessGroups,
+ RepoAccessInfoMap,
RepoName,
} from '../../../types/common';
import {
ConfigParameterInfoType,
InheritedBooleanInfoConfiguredValue,
- ProjectState,
+ RepoState,
SubmitType,
} from '../../../constants/constants';
import {
@@ -52,7 +52,7 @@ suite('gr-repo tests', () => {
let repoStub: sinon.SinonStub;
const repoConf: ConfigInfo = {
- description: 'Access inherited by all other projects.',
+ description: 'Access inherited by all other repositories.',
use_contributor_agreements: {
value: false,
configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
@@ -537,10 +537,10 @@ suite('gr-repo tests', () => {
url: 'test',
name: 'test' as GroupName,
},
- } as ProjectAccessGroups,
+ } as RepoAccessGroups,
config_web_links: [{name: 'gitiles', url: 'test'}],
},
- } as ProjectAccessInfoMap)
+ } as RepoAccessInfoMap)
);
await element.loadRepo();
assert.isTrue(element.readOnly);
@@ -655,10 +655,10 @@ suite('gr-repo tests', () => {
url: 'test',
name: 'test' as GroupName,
},
- } as ProjectAccessGroups,
+ } as RepoAccessGroups,
config_web_links: [{name: 'gitiles', url: 'test'}],
},
- } as ProjectAccessInfoMap)
+ } as RepoAccessInfoMap)
);
});
@@ -674,10 +674,10 @@ suite('gr-repo tests', () => {
test('state gets set correctly', async () => {
await element.loadRepo();
- assert.equal(element.repoConfig!.state, ProjectState.ACTIVE);
+ assert.equal(element.repoConfig!.state, RepoState.ACTIVE);
assert.equal(
queryAndAssert<GrSelect>(element, '#stateSelect').bindValue,
- ProjectState.ACTIVE
+ RepoState.ACTIVE
);
});
@@ -710,7 +710,7 @@ suite('gr-repo tests', () => {
reject_empty_commit: InheritedBooleanInfoConfiguredValue.TRUE,
max_object_size_limit: '10' as MaxObjectSizeLimitInfo,
submit_type: SubmitType.FAST_FORWARD_ONLY,
- state: ProjectState.READ_ONLY,
+ state: RepoState.READ_ONLY,
enable_reviewer_by_email: InheritedBooleanInfoConfiguredValue.TRUE,
};
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
index 975dd3b5f6..4e41dfe082 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -8,28 +8,16 @@ import '../../shared/gr-button/gr-button';
import '../../shared/gr-select/gr-select';
import {encodeURL, getBaseUrl} from '../../../utils/url-util';
import {AccessPermissionId} from '../../../utils/access-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
import {ifDefined} from 'lit/directives/if-defined.js';
import {EditablePermissionRuleInfo} from '../gr-repo-access/gr-repo-access-interfaces';
import {PermissionAction} from '../../../constants/constants';
-/**
- * Fired when the rule has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a rule that was previously added was removed.
- *
- * @event added-rule-removed
- */
-
const PRIORITY_OPTIONS = [PermissionAction.BATCH, PermissionAction.INTERACTIVE];
const Action = {
@@ -81,6 +69,11 @@ declare global {
interface HTMLElementTagNameMap {
'gr-rule-editor': GrRuleEditor;
}
+ interface HTMLElementEventMap {
+ /** Fired when a rule that was previously added was removed. */
+ 'added-rule-removed': CustomEvent<{}>;
+ 'rule-changed': ValueChangedEvent<Rule | undefined>;
+ }
}
@customElement('gr-rule-editor')
@@ -343,7 +336,7 @@ export class GrRuleEditor extends LitElement {
// private but used in test
computeGroupPath(groupId?: string) {
if (!groupId) return;
- return `${getBaseUrl()}/admin/groups/${encodeURL(groupId, true)}`;
+ return `${getBaseUrl()}/admin/groups/${encodeURL(groupId)}`;
}
// private but used in test
@@ -431,14 +424,14 @@ export class GrRuleEditor extends LitElement {
private handleRemoveRule() {
if (!this.rule?.value) return;
if (this.rule.value.added) {
- fireEvent(this, 'added-rule-removed');
+ fire(this, 'added-rule-removed', {});
}
this.deleted = true;
this.rule.value.deleted = true;
this.handleRuleChange();
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
}
private handleUndoRemove() {
@@ -476,7 +469,7 @@ export class GrRuleEditor extends LitElement {
this.handleRuleChange();
// Allows overall access page to know a change has been made.
- fireEvent(this, 'access-modified');
+ fire(this, 'access-modified', {});
}
// private but used in test
@@ -537,13 +530,6 @@ export class GrRuleEditor extends LitElement {
private handleRuleChange() {
this.requestUpdate('rule');
-
- this.dispatchEvent(
- new CustomEvent('rule-changed', {
- detail: {value: this.rule},
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'rule-changed', {value: this.rule});
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
index 318a33b61e..a49a95e79a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar.ts
@@ -14,7 +14,7 @@ import '../gr-change-list-reviewer-flow/gr-change-list-reviewer-flow';
import '../gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow';
import '../gr-change-list-topic-flow/gr-change-list-topic-flow';
import '../gr-change-list-hashtag-flow/gr-change-list-hashtag-flow';
-
+import '../gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow';
/**
* An action bar for the top of a <gr-change-list-section> element. Assumes it
* will be used inside a <tr> element.
@@ -77,6 +77,7 @@ export class GrChangeListActionBar extends LitElement {
<gr-change-list-topic-flow></gr-change-list-topic-flow>
<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+ <gr-change-list-bulk-abandon-flow></gr-change-list-bulk-abandon-flow>
</div>
</div>
</td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
index 8badced1c0..4b7f0a3be3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-action-bar/gr-change-list-action-bar_test.ts
@@ -19,7 +19,7 @@ import {
} from '../../../test/test-utils';
import {ChangeInfo, NumericChangeId} from '../../../types/common';
import './gr-change-list-action-bar';
-import type {GrChangeListActionBar} from './gr-change-list-action-bar';
+import {GrChangeListActionBar} from './gr-change-list-action-bar';
const change1 = {...createChange(), _number: 1 as NumericChangeId, actions: {}};
const change2 = {...createChange(), _number: 2 as NumericChangeId, actions: {}};
@@ -68,6 +68,7 @@ suite('gr-change-list-action-bar tests', () => {
<gr-change-list-topic-flow></gr-change-list-topic-flow>
<gr-change-list-hashtag-flow></gr-change-list-hashtag-flow>
<gr-change-list-reviewer-flow></gr-change-list-reviewer-flow>
+ <gr-change-list-bulk-abandon-flow></gr-change-list-bulk-abandon-flow>
</div>
</div>
</td>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
index 1582b0a1e4..2ca7f364b8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow.ts
@@ -9,10 +9,10 @@ import {resolve} from '../../../models/dependency';
import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
import {NumericChangeId, ChangeInfo, ChangeStatus} from '../../../api/rest-api';
import {subscribe} from '../../lit/subscription-controller';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {ProgressStatus} from '../../../constants/constants';
import '../../shared/gr-dialog/gr-dialog';
import {fireAlert, fireReload} from '../../../utils/event-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
@customElement('gr-change-list-bulk-abandon-flow')
export class GrChangeListBulkAbandonFlow extends LitElement {
@@ -22,10 +22,11 @@ export class GrChangeListBulkAbandonFlow extends LitElement {
@state() progress: Map<NumericChangeId, ProgressStatus> = new Map();
- @query('#actionOverlay') actionOverlay!: GrOverlay;
+ @query('#actionModal') actionModal!: HTMLDialogElement;
static override get styles() {
return [
+ modalStyles,
css`
section {
padding: var(--spacing-l);
@@ -49,13 +50,13 @@ export class GrChangeListBulkAbandonFlow extends LitElement {
id="abandon"
flatten
.disabled=${!this.isEnabled()}
- @click=${() => this.actionOverlay.open()}
+ @click=${() => this.actionModal.showModal()}
>Abandon</gr-button
>
- <gr-overlay id="actionOverlay" with-backdrop="">
+ <dialog id="actionModal" tabindex="-1">
<gr-dialog
.disableCancel=${!this.isCancelEnabled()}
- .disabled=${!this.isConfirmEnabled()}
+ .disabled=${this.isDisabled()}
@confirm=${() => this.handleConfirm()}
@cancel=${() => this.handleClose()}
.cancelLabel=${'Close'}
@@ -86,7 +87,7 @@ export class GrChangeListBulkAbandonFlow extends LitElement {
</table>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
@@ -103,13 +104,13 @@ export class GrChangeListBulkAbandonFlow extends LitElement {
);
}
- private isConfirmEnabled() {
+ private isDisabled() {
// Action is allowed if none of the changes have any bulk action performed
// on them. In case an error happens then we keep the button disabled.
for (const status of this.progress.values()) {
- if (status !== ProgressStatus.NOT_STARTED) return false;
+ if (status !== ProgressStatus.NOT_STARTED) return true;
}
- return true;
+ return false;
}
private isCancelEnabled() {
@@ -144,9 +145,9 @@ export class GrChangeListBulkAbandonFlow extends LitElement {
}
private handleClose() {
- this.actionOverlay.close();
+ this.actionModal.close();
fireAlert(this, 'Reloading page..');
- fireReload(this, true);
+ fireReload(this);
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
index 4fc2cd8799..df7a6ceb6e 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-abandon-flow/gr-change-list-bulk-abandon-flow_test.ts
@@ -90,13 +90,7 @@ suite('gr-change-list-bulk-abandon-flow tests', () => {
>
Abandon
</gr-button>
- <gr-overlay
- aria-hidden="true"
- id="actionOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="actionModal" tabindex="-1">
<gr-dialog role="dialog">
<div slot="header">1 changes to abandon</div>
<div slot="main">
@@ -116,7 +110,7 @@ suite('gr-change-list-bulk-abandon-flow tests', () => {
</table>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
index 5728529dba..6d7045acc4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow.ts
@@ -5,7 +5,6 @@
*/
import {customElement, query, state} from 'lit/decorators.js';
import {LitElement, html, css, nothing} from 'lit';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {resolve} from '../../../models/dependency';
import {bulkActionsModelToken} from '../../../models/bulk-actions/bulk-actions-model';
import {subscribe} from '../../lit/subscription-controller';
@@ -39,12 +38,14 @@ import {pluralize} from '../../../utils/string-util';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {Interaction} from '../../../constants/reporting';
import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
@customElement('gr-change-list-bulk-vote-flow')
export class GrChangeListBulkVoteFlow extends LitElement {
private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly reportingService = getAppContext().reportingService;
@@ -52,7 +53,7 @@ export class GrChangeListBulkVoteFlow extends LitElement {
@state() progressByChange: Map<NumericChangeId, ProgressStatus> = new Map();
- @query('#actionOverlay') actionOverlay!: GrOverlay;
+ @query('#actionModal') actionModal!: HTMLDialogElement;
@query('gr-dialog') dialog?: GrDialog;
@@ -61,6 +62,7 @@ export class GrChangeListBulkVoteFlow extends LitElement {
static override get styles() {
return [
fontStyles,
+ modalStyles,
css`
gr-dialog {
width: 840px;
@@ -141,7 +143,7 @@ export class GrChangeListBulkVoteFlow extends LitElement {
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
account => (this.account = account)
);
}
@@ -153,13 +155,15 @@ export class GrChangeListBulkVoteFlow extends LitElement {
permittedLabels
).filter(label => !triggerLabels.some(l => l.name === label.name));
return html`
- <gr-button id="voteFlowButton" flatten @click=${this.openOverlay}
+ <gr-button id="voteFlowButton" flatten @click=${this.openModal}
>Vote</gr-button
>
- <gr-overlay id="actionOverlay" with-backdrop="">
+ <dialog id="actionModal" tabindex="-1">
<gr-dialog
.disableCancel=${!this.isCancelEnabled()}
- .disabled=${!this.isConfirmEnabled()}
+ .disabled=${this.isDisabled(
+ triggerLabels.length + nonTriggerLabels.length
+ )}
?loading=${this.isLoading()}
.loadingLabel=${'Voting in progress...'}
@confirm=${() => this.handleConfirm()}
@@ -185,7 +189,7 @@ export class GrChangeListBulkVoteFlow extends LitElement {
${this.renderErrors()}
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
@@ -223,12 +227,8 @@ export class GrChangeListBulkVoteFlow extends LitElement {
}
}
- private async openOverlay() {
- await this.actionOverlay.open();
- this.actionOverlay.setFocusStops({
- start: queryAndAssert(this.dialog, 'header'),
- end: queryAndAssert(this.dialog, 'footer'),
- });
+ private openModal() {
+ this.actionModal.showModal();
}
private renderErrors() {
@@ -291,11 +291,12 @@ export class GrChangeListBulkVoteFlow extends LitElement {
return getOverallStatus(this.progressByChange) === ProgressStatus.RUNNING;
}
- private isConfirmEnabled() {
+ private isDisabled(permittedLabelsCount: number) {
// Action is allowed if none of the changes have any bulk action performed
// on them. In case an error happens then we keep the button disabled.
- return (
- getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED
+ return !(
+ getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED &&
+ permittedLabelsCount > 0
);
}
@@ -304,10 +305,10 @@ export class GrChangeListBulkVoteFlow extends LitElement {
}
private handleClose() {
- this.actionOverlay.close();
+ this.actionModal.close();
if (getOverallStatus(this.progressByChange) === ProgressStatus.NOT_STARTED)
return;
- fireReload(this, true);
+ fireReload(this);
}
private async handleConfirm() {
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
index 0ca3976d0a..8a5bf47abe 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-bulk-vote-flow/gr-change-list-bulk-vote-flow_test.ts
@@ -147,12 +147,9 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
>
Vote
</gr-button>
- <gr-overlay
- aria-hidden="true"
- id="actionOverlay"
- style="outline: none; display: none;"
+ <dialog
+ id="actionModal"
tabindex="-1"
- with-backdrop=""
>
<gr-dialog role="dialog">
<div slot="header">
@@ -197,7 +194,7 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
</div>
</div>
</gr-dialog>
- </gr-overlay> `
+ </dialog> `
);
});
@@ -238,12 +235,9 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
>
Vote
</gr-button>
- <gr-overlay
- aria-hidden="true"
- id="actionOverlay"
- style="outline: none; display: none;"
+ <dialog
+ id="actionModal"
tabindex="-1"
- with-backdrop=""
>
<gr-dialog role="dialog">
<div slot="header">
@@ -292,7 +286,7 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
</div>
</div>
</gr-dialog>
- </gr-overlay> `
+ </dialog> `
);
});
@@ -313,17 +307,18 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
);
// No common label with change1 so button is disabled
- change2.labels = {
+ const c2 = {...change2}; // create copy so other tests are not affected
+ c2.labels = {
x: {value: null} as LabelInfo,
y: {value: null} as LabelInfo,
z: {value: null} as LabelInfo,
};
- change2.submit_requirements = [
+ c2.submit_requirements = [
createSubmitRequirementResultInfo('label:x=MAX'),
createSubmitRequirementResultInfo('label:y=MAX'),
createSubmitRequirementResultInfo('label:z=MAX'),
];
- changes.push({...change2});
+ changes.push({...c2});
getChangesStub.restore();
getChangesStub.returns(Promise.resolve(changes));
model.sync(changes);
@@ -367,10 +362,7 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
const saveChangeReview = mockPromise<Response>();
stubRestApi('saveChangeReview').returns(saveChangeReview);
- const stopsStub = sinon.stub(element.actionOverlay, 'setFocusStops');
-
queryAndAssert<GrButton>(element, '#voteFlowButton').click();
- await waitUntil(() => stopsStub.called);
await element.updateComplete;
@@ -493,6 +485,45 @@ suite('gr-change-list-bulk-vote-flow tests', () => {
assert.equal(dispatchEventStub.lastCall.args[0].type, 'reload');
});
+ test('button is disabled if no votes are possible', async () => {
+ const c2 = {...change2}; // create copy so other tests are not affected
+ c2.labels = {
+ x: {value: null} as LabelInfo,
+ y: {value: null} as LabelInfo,
+ z: {value: null} as LabelInfo,
+ };
+ c2.submit_requirements = [
+ createSubmitRequirementResultInfo('label:x=MAX'),
+ createSubmitRequirementResultInfo('label:y=MAX'),
+ createSubmitRequirementResultInfo('label:z=MAX'),
+ ];
+
+ const changes: ChangeInfo[] = [change1, c2];
+ getChangesStub.returns(Promise.resolve(changes));
+
+ stubRestApi('saveChangeReview').callsFake(
+ (_changeNum, _patchNum, _review, errFn) =>
+ Promise.resolve(new Response()).then(res => {
+ errFn && errFn();
+ return res;
+ })
+ );
+
+ model.sync(changes);
+ await waitUntilObserved(
+ model.loadingState$,
+ state => state === LoadingState.LOADED
+ );
+ await selectChange(change1);
+ await selectChange(c2);
+ await element.updateComplete;
+
+ assert.isTrue(
+ queryAndAssert<GrButton>(query(element, 'gr-dialog'), '#confirm')
+ .disabled
+ );
+ });
+
test('closing dialog does not trigger reload if no request made', async () => {
const changes: ChangeInfo[] = [change1, change2];
getChangesStub.returns(Promise.resolve(changes));
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
index 2b3c3df410..ea61bfccaa 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow.ts
@@ -15,7 +15,7 @@ import '../../shared/gr-autocomplete/gr-autocomplete';
import '@polymer/iron-dropdown/iron-dropdown';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
import {getAppContext} from '../../../services/app-context';
-import {notUndefined} from '../../../types/types';
+import {isDefined} from '../../../types/types';
import {unique} from '../../../utils/common-util';
import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
import {when} from 'lit/directives/when.js';
@@ -27,6 +27,7 @@ import {allSettled} from '../../../utils/async-util';
import {fireAlert} from '../../../utils/event-util';
import {pluralize} from '../../../utils/string-util';
import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@customElement('gr-change-list-hashtag-flow')
export class GrChangeListHashtagFlow extends LitElement {
@@ -158,7 +159,7 @@ export class GrChangeListHashtagFlow extends LitElement {
.horizontalAlign=${'auto'}
.verticalAlign=${'auto'}
.verticalOffset=${24}
- @opened-changed=${(e: CustomEvent) =>
+ @opened-changed=${(e: ValueChangedEvent<boolean>) =>
(this.isDropdownOpen = e.detail.value)}
>
${when(
@@ -215,7 +216,7 @@ export class GrChangeListHashtagFlow extends LitElement {
private renderExistingHashtags() {
const hashtags = this.selectedChanges
.flatMap(change => change.hashtags ?? [])
- .filter(notUndefined)
+ .filter(isDefined)
.filter(unique);
return html`
<div class="chips">
@@ -298,11 +299,12 @@ export class GrChangeListHashtagFlow extends LitElement {
query: string
): Promise<AutocompleteSuggestion[]> {
const suggestions = await this.restApiService.getChangesWithSimilarHashtag(
- query
+ query,
+ throwingErrorCallback
);
this.existingHashtagSuggestions = (suggestions ?? [])
.flatMap(change => change.hashtags ?? [])
- .filter(notUndefined)
+ .filter(isDefined)
.filter(unique);
return this.existingHashtagSuggestions.map(hashtag => {
return {name: hashtag, value: hashtag};
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
index f7a2531df6..af997a9487 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-hashtag-flow/gr-change-list-hashtag-flow_test.ts
@@ -29,11 +29,10 @@ import {
waitUntilObserved,
} from '../../../test/test-utils';
import {ChangeInfo, NumericChangeId, Hashtag} from '../../../types/common';
-import {EventType} from '../../../types/events';
import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
import {GrButton} from '../../shared/gr-button/gr-button';
import './gr-change-list-hashtag-flow';
-import type {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
+import {GrChangeListHashtagFlow} from './gr-change-list-hashtag-flow';
suite('gr-change-list-hashtag-flow tests', () => {
let element: GrChangeListHashtagFlow;
@@ -303,7 +302,7 @@ suite('gr-change-list-hashtag-flow tests', () => {
test('add hashtag from selected change', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
// selects "hashtag1"
queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
await element.updateComplete;
@@ -377,7 +376,7 @@ suite('gr-change-list-hashtag-flow tests', () => {
test('add multiple hashtag from selected change', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
// selects "hashtag1"
queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
await element.updateComplete;
@@ -425,7 +424,7 @@ suite('gr-change-list-hashtag-flow tests', () => {
test('add existing hashtag not on selected changes', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const getHashtagsStub = stubRestApi(
'getChangesWithSimilarHashtag'
@@ -481,7 +480,7 @@ suite('gr-change-list-hashtag-flow tests', () => {
test('add new hashtag', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const getHashtagsStub = stubRestApi(
'getChangesWithSimilarHashtag'
@@ -586,7 +585,7 @@ suite('gr-change-list-hashtag-flow tests', () => {
test('cannot add existing hashtag already on selected changes', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
// selects "sharedHashtag"
queryAll<HTMLButtonElement>(element, 'button.chip')[1].click();
await element.updateComplete;
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 2245a09588..19207bc412 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
@@ -17,18 +17,16 @@ import '../gr-change-list-column-requirement/gr-change-list-column-requirement';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {getDisplayName} from '../../../utils/display-name-util';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {getAppContext} from '../../../services/app-context';
import {truncatePath} from '../../../utils/path-list-util';
import {changeStatuses} from '../../../utils/change-util';
import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {
ChangeInfo,
ServerInfo,
AccountInfo,
Timestamp,
+ NumericChangeId,
} from '../../../types/common';
import {hasOwnProperty, assertIsDefined} from '../../../utils/common-util';
import {changeListStyles} from '../../../styles/gr-change-list-styles';
@@ -44,6 +42,8 @@ import {subscribe} from '../../lit/subscription-controller';
import {classMap} from 'lit/directives/class-map.js';
import {createSearchUrl} from '../../../models/views/search';
import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
enum ChangeSize {
XS = 10,
@@ -115,16 +115,16 @@ export class GrChangeListItem extends LitElement {
@state() private dynamicCellEndpoints?: string[];
- // Private but used in test.
- reporting: ReportingService = getAppContext().reportingService;
+ private readonly reporting = getAppContext().reportingService;
- // Private but used in test.
- userModel = getAppContext().userModel;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
private readonly getNavigation = resolve(this, navigationToken);
+ private readonly getUserModel = resolve(this, userModelToken);
+
@state() private isLoggedIn = false;
constructor() {
@@ -133,25 +133,25 @@ export class GrChangeListItem extends LitElement {
this,
() => this.getBulkActionsModel().selectedChangeNums$,
selectedChangeNums => {
- if (!this.change) return;
- this.checked = selectedChangeNums.includes(this.change._number);
+ this.updateCheckedState(selectedChangeNums);
}
);
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
isLoggedIn => (this.isLoggedIn = isLoggedIn)
);
}
override connectedCallback() {
super.connectedCallback();
- getPluginLoader()
+ this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
- this.dynamicCellEndpoints = getPluginEndpoints().getDynamicEndpoints(
- 'change-list-item-cell'
- );
+ this.dynamicCellEndpoints =
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-list-item-cell'
+ );
});
this.addEventListener('click', this.onItemClick);
}
@@ -166,6 +166,20 @@ export class GrChangeListItem extends LitElement {
if (this.selected && changedProperties.has('selected')) {
this.focus();
}
+
+ if (changedProperties.has('change')) {
+ this.updateCheckedState(
+ this.getBulkActionsModel().getState().selectedChangeNums
+ );
+ }
+ }
+
+ private updateCheckedState(selectedChangeNums: NumericChangeId[]) {
+ if (!this.change) {
+ this.checked = false;
+ return;
+ }
+ this.checked = selectedChangeNums.includes(this.change._number);
}
static override get styles() {
@@ -682,14 +696,14 @@ export class GrChangeListItem extends LitElement {
private computeRepoUrl() {
if (!this.change) return '';
- return createSearchUrl({project: this.change.project, statuses: ['open']});
+ return createSearchUrl({repo: this.change.project, statuses: ['open']});
}
private computeRepoBranchURL() {
if (!this.change) return '';
return createSearchUrl({
branch: this.change.branch,
- project: this.change.project,
+ repo: this.change.project,
});
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index a4c4a94968..5e31cc8734 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -44,6 +44,7 @@ import {
bulkActionsModelToken,
BulkActionsModel,
} from '../../../models/bulk-actions/bulk-actions-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
import {createTestAppContext} from '../../../test/test-app-context-init';
import {ColumnNames} from '../../../constants/constants';
import {testResolver} from '../../../test/common-test-setup';
@@ -59,11 +60,13 @@ suite('gr-change-list-item tests', () => {
let element: GrChangeListItem;
let bulkActionsModel: BulkActionsModel;
+ let userModel: UserModel;
setup(async () => {
bulkActionsModel = new BulkActionsModel(
createTestAppContext().restApiService
);
+ userModel = testResolver(userModelToken);
element = (
await fixture<DIProviderElement>(
wrapInProvider(
@@ -104,7 +107,7 @@ suite('gr-change-list-item tests', () => {
test('bulk actions checkboxes', async () => {
element.change = {...createChange(), _number: 1 as NumericChangeId};
bulkActionsModel.sync([element.change]);
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
@@ -137,7 +140,7 @@ suite('gr-change-list-item tests', () => {
element.globalIndex = 5;
element.change = {...createChange(), _number: 1 as NumericChangeId};
bulkActionsModel.sync([element.change]);
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
@@ -154,7 +157,7 @@ suite('gr-change-list-item tests', () => {
});
test('checkbox state updates with model updates', async () => {
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
@@ -164,10 +167,6 @@ suite('gr-change-list-item tests', () => {
element.change = {...createChange(), _number: 1 as NumericChangeId};
bulkActionsModel.sync([element.change]);
bulkActionsModel.addSelectedChangeNum(element.change._number);
- await waitUntilObserved(
- bulkActionsModel.selectedChangeNums$,
- s => s.length === 1
- );
await element.updateComplete;
const checkbox = queryAndAssert<HTMLInputElement>(
@@ -177,10 +176,35 @@ suite('gr-change-list-item tests', () => {
assert.isTrue(checkbox.checked);
bulkActionsModel.removeSelectedChangeNum(element.change._number);
- await waitUntilObserved(
- bulkActionsModel.selectedChangeNums$,
- s => s.length === 0
+ await element.updateComplete;
+
+ assert.isFalse(checkbox.checked);
+ });
+
+ test('checkbox state updates with change id update', async () => {
+ userModel.setAccount({
+ ...createAccountWithEmail('abc@def.com'),
+ registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+ });
+ element.requestUpdate();
+ await element.updateComplete;
+
+ const changes = [
+ {...createChange(), _number: 1 as NumericChangeId},
+ {...createChange(), _number: 2 as NumericChangeId},
+ ];
+ element.change = changes[0];
+ bulkActionsModel.sync(changes);
+ bulkActionsModel.addSelectedChangeNum(element.change._number);
+ await element.updateComplete;
+
+ const checkbox = queryAndAssert<HTMLInputElement>(
+ element,
+ '.selection > .selectionLabel > input'
);
+ assert.isTrue(checkbox.checked);
+
+ element.change = changes[1];
await element.updateComplete;
assert.isFalse(checkbox.checked);
@@ -352,15 +376,17 @@ suite('gr-change-list-item tests', () => {
});
test('renders', async () => {
- element.userModel.setAccount({
+ const change = createChange();
+ bulkActionsModel.sync([change]);
+ bulkActionsModel.addSelectedChangeNum(change._number);
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
element.showNumber = true;
element.account = createAccountWithId(1);
element.config = createServerInfo();
- element.change = createChange();
- element.checked = true;
+ element.change = change;
await element.updateComplete;
assert.isTrue(element.hasAttribute('checked'));
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
index 46d15af3ad..f72d1ef54b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow.ts
@@ -18,16 +18,14 @@ import {
SuggestedReviewerGroupInfo,
} from '../../../types/common';
import {subscribe} from '../../lit/subscription-controller';
-import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icon/gr-icon';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {getAppContext} from '../../../services/app-context';
import {
GrReviewerSuggestionsProvider,
ReviewerSuggestionsProvider,
-} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import '../../shared/gr-account-list/gr-account-list';
import {getOverallStatus} from '../../../utils/bulk-flow-util';
import {allSettled} from '../../../utils/async-util';
@@ -35,12 +33,14 @@ import {listForSentence, pluralize} from '../../../utils/string-util';
import {getDisplayName} from '../../../utils/display-name-util';
import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
import {getReplyByReason} from '../../../utils/attention-set-util';
-import {intersection, queryAndAssert} from '../../../utils/common-util';
+import {intersection} from '../../../utils/common-util';
import {AccountInput, accountKey, getUserId} from '../../../utils/account-util';
import {ValueChangedEvent} from '../../../types/events';
import {fireAlert, fireReload} from '../../../utils/event-util';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {Interaction} from '../../../constants/reporting';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
@customElement('gr-change-list-reviewer-flow')
export class GrChangeListReviewerFlow extends LitElement {
@@ -77,24 +77,26 @@ export class GrChangeListReviewerFlow extends LitElement {
[ReviewerState.CC, null],
]);
- @query('gr-overlay#flow') private overlay?: GrOverlay;
+ @query('dialog#flow') private modal?: HTMLDialogElement;
@query('gr-account-list#reviewer-list') private reviewerList?: GrAccountList;
@query('gr-account-list#cc-list') private ccList?: GrAccountList;
- @query('gr-overlay#confirm-reviewer')
- private reviewerConfirmOverlay?: GrOverlay;
+ @query('dialog#confirm-reviewer')
+ private reviewerConfirmModal?: HTMLDialogElement;
- @query('gr-overlay#confirm-cc') private ccConfirmOverlay?: GrOverlay;
+ @query('dialog#confirm-cc') private ccConfirmModal?: HTMLDialogElement;
@query('gr-dialog') dialog?: GrDialog;
private readonly reportingService = getAppContext().reportingService;
- private getBulkActionsModel = resolve(this, bulkActionsModelToken);
+ private readonly getBulkActionsModel = resolve(this, bulkActionsModelToken);
- private getConfigModel = resolve(this, configModelToken);
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
private restApiService = getAppContext().restApiService;
@@ -104,6 +106,7 @@ export class GrChangeListReviewerFlow extends LitElement {
static override get styles() {
return [
+ modalStyles,
css`
gr-dialog {
width: 60em;
@@ -140,8 +143,8 @@ export class GrChangeListReviewerFlow extends LitElement {
color: var(--orange-800);
font-size: 18px;
}
- gr-overlay#confirm-cc,
- gr-overlay#confirm-reviewer {
+ dialog#confirm-cc,
+ dialog#confirm-reviewer {
padding: var(--spacing-l);
text-align: center;
}
@@ -166,12 +169,12 @@ export class GrChangeListReviewerFlow extends LitElement {
);
subscribe(
this,
- () => getAppContext().userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
isLoggedIn => (this.isLoggedIn = isLoggedIn)
);
subscribe(
this,
- () => getAppContext().userModel.account$,
+ () => this.getUserModel().account$,
account => (this.account = account)
);
}
@@ -185,9 +188,9 @@ export class GrChangeListReviewerFlow extends LitElement {
@click=${() => this.openOverlay()}
>add reviewer/cc</gr-button
>
- <gr-overlay id="flow" with-backdrop>
+ <dialog id="flow" tabindex="-1">
${this.isOverlayOpen ? this.renderDialog() : nothing}
- </gr-overlay>
+ </dialog>
`;
}
@@ -259,9 +262,10 @@ export class GrChangeListReviewerFlow extends LitElement {
const suggestion =
this.groupPendingConfirmationByReviewerState.get(reviewerState);
return html`
- <gr-overlay
+ <dialog
+ tabindex="-1"
id=${id}
- @iron-overlay-canceled=${() => this.cancelPendingGroup(reviewerState)}
+ @close=${() => this.cancelPendingGroup(reviewerState)}
>
<div class="confirmation-text">
Group
@@ -281,7 +285,7 @@ export class GrChangeListReviewerFlow extends LitElement {
>No</gr-button
>
</div>
- </gr-overlay>
+ </dialog>
`;
}
@@ -375,16 +379,12 @@ export class GrChangeListReviewerFlow extends LitElement {
this.resetFlow();
this.isOverlayOpen = true;
// Must await the overlay opening because the dialog is lazily rendered.
- await this.overlay?.open();
- this.overlay?.setFocusStops({
- start: queryAndAssert(this.dialog, 'header'),
- end: queryAndAssert(this.dialog, 'footer'),
- });
+ await this.modal?.showModal();
}
private closeOverlay() {
this.isOverlayOpen = false;
- this.overlay?.close();
+ this.modal?.close();
}
private resetFlow() {
@@ -451,23 +451,23 @@ export class GrChangeListReviewerFlow extends LitElement {
this.requestUpdate();
await this.updateComplete;
- const overlay =
+ const modal =
reviewerState === ReviewerState.CC
- ? this.ccConfirmOverlay
- : this.reviewerConfirmOverlay;
+ ? this.ccConfirmModal
+ : this.reviewerConfirmModal;
if (ev.detail.value === null) {
- overlay?.close();
+ modal?.close();
} else {
- await overlay?.open();
+ await modal?.showModal();
}
}
private cancelPendingGroup(reviewerState: ReviewerState) {
- const overlay =
+ const modal =
reviewerState === ReviewerState.CC
- ? this.ccConfirmOverlay
- : this.reviewerConfirmOverlay;
- overlay?.close();
+ ? this.ccConfirmModal
+ : this.reviewerConfirmModal;
+ modal?.close();
this.groupPendingConfirmationByReviewerState.set(reviewerState, null);
this.requestUpdate();
}
@@ -488,10 +488,10 @@ export class GrChangeListReviewerFlow extends LitElement {
this.saveReviewers();
break;
case ProgressStatus.SUCCESSFUL:
- this.overlay?.close();
+ this.modal?.close();
break;
case ProgressStatus.FAILED:
- this.overlay?.close();
+ this.modal?.close();
break;
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
index a96085c767..d2f5fa2135 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-reviewer-flow/gr-change-list-reviewer-flow_test.ts
@@ -40,9 +40,8 @@ import {query} from '../../../utils/common-util';
import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import './gr-change-list-reviewer-flow';
-import type {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
+import {GrChangeListReviewerFlow} from './gr-change-list-reviewer-flow';
const accounts: AccountInfo[] = [
createAccountWithIdNameAndEmail(0),
@@ -122,13 +121,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
tabindex="0"
>add reviewer/cc</gr-button
>
- <gr-overlay
- id="flow"
- aria-hidden="true"
- with-backdrop=""
- tabindex="-1"
- style="outline: none; display: none;"
- ></gr-overlay>
+ <dialog id="flow" tabindex="-1"></dialog>
`
);
});
@@ -148,18 +141,21 @@ suite('gr-change-list-reviewer-flow tests', () => {
});
test('overlay hidden before flow button clicked', async () => {
- const overlay = queryAndAssert<GrOverlay>(element, 'gr-overlay');
- assert.isFalse(overlay.opened);
+ const dialog = queryAndAssert<HTMLDialogElement>(element, 'dialog');
+ const openStub = sinon.stub(dialog, 'showModal');
+ assert.isFalse(openStub.called);
});
test('flow button click shows overlay', async () => {
const button = queryAndAssert<GrButton>(element, 'gr-button#start-flow');
+ const dialog = queryAndAssert<HTMLDialogElement>(element, 'dialog');
+ const openStub = sinon.stub(dialog, 'showModal');
button.click();
+
await element.updateComplete;
- const overlay = queryAndAssert<GrOverlay>(element, 'gr-overlay');
- assert.isTrue(overlay.opened);
+ assert.isTrue(openStub.called);
});
suite('dialog flow', () => {
@@ -202,23 +198,14 @@ suite('gr-change-list-reviewer-flow tests', () => {
tabindex="0"
>add reviewer/cc</gr-button
>
- <gr-overlay
- id="flow"
- with-backdrop=""
- tabindex="-1"
- style="outline: none; display: none;"
- >
+ <dialog id="flow" open="" tabindex="-1">
<gr-dialog role="dialog">
<div slot="header">Add reviewer / CC</div>
<div slot="main">
<div class="grid">
<span>Reviewers</span>
<gr-account-list id="reviewer-list"></gr-account-list>
- <gr-overlay
- aria-hidden="true"
- id="confirm-reviewer"
- style="outline: none; display: none;"
- >
+ <dialog id="confirm-reviewer" tabindex="-1">
<div class="confirmation-text">
Group
<span class="groupName"></span>
@@ -244,14 +231,10 @@ suite('gr-change-list-reviewer-flow tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
<span>CC</span>
<gr-account-list id="cc-list"></gr-account-list>
- <gr-overlay
- aria-hidden="true"
- id="confirm-cc"
- style="outline: none; display: none;"
- >
+ <dialog id="confirm-cc" tabindex="-1">
<div class="confirmation-text">
Group
<span class="groupName"></span>
@@ -277,11 +260,12 @@ suite('gr-change-list-reviewer-flow tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
</div>
</div>
</gr-dialog>
- </gr-overlay>
+ <div id="gr-hovercard-container"></div>
+ </dialog>
`
);
});
@@ -645,14 +629,14 @@ suite('gr-change-list-reviewer-flow tests', () => {
tabindex="0"
>add reviewer/cc</gr-button
>
- <gr-overlay id="flow" with-backdrop="" tabindex="-1">
+ <dialog id="flow" open="" tabindex="-1">
<gr-dialog role="dialog">
<div slot="header">Add reviewer / CC</div>
<div slot="main">
<div class="grid">
<span>Reviewers</span>
<gr-account-list id="reviewer-list"></gr-account-list>
- <gr-overlay aria-hidden="true" id="confirm-reviewer">
+ <dialog tabindex="-1" id="confirm-reviewer">
<div class="confirmation-text">
Group
<span class="groupName"></span>
@@ -676,10 +660,10 @@ suite('gr-change-list-reviewer-flow tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
<span>CC</span>
<gr-account-list id="cc-list"></gr-account-list>
- <gr-overlay aria-hidden="true" id="confirm-cc">
+ <dialog tabindex="-1" id="confirm-cc">
<div class="confirmation-text">
Group
<span class="groupName"></span>
@@ -703,7 +687,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
</div>
<div class="warning">
<gr-icon icon="warning" filled role="img" aria-label="Warning"
@@ -721,11 +705,13 @@ suite('gr-change-list-reviewer-flow tests', () => {
</div>
</div>
</gr-dialog>
- </gr-overlay>
+ <div id="gr-hovercard-container">
+ </div>
+ </dialog>
`,
{
- // gr-overlay sizing seems to vary between local & CI
- ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+ // dialog sizing seems to vary between local & CI
+ ignoreAttributes: [{tags: ['dialog'], attributes: ['style']}],
}
);
});
@@ -759,10 +745,10 @@ suite('gr-change-list-reviewer-flow tests', () => {
>
add reviewer/cc
</gr-button>
- <gr-overlay
+ <dialog
id="flow"
tabindex="-1"
- with-backdrop=""
+ open=""
>
<gr-dialog role="dialog">
<div slot="header">Add reviewer / CC</div>
@@ -770,7 +756,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
<div class="grid">
<span> Reviewers </span>
<gr-account-list id="reviewer-list"> </gr-account-list>
- <gr-overlay aria-hidden="true" id="confirm-reviewer">
+ <dialog tabindex="-1" id="confirm-reviewer">
<div class="confirmation-text">
Group
<span class="groupName"> </span>
@@ -796,10 +782,10 @@ suite('gr-change-list-reviewer-flow tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
<span> CC </span>
<gr-account-list id="cc-list"> </gr-account-list>
- <gr-overlay aria-hidden="true" id="confirm-cc">
+ <dialog tabindex="-1" id="confirm-cc">
<div class="confirmation-text">
Group
<span class="groupName"> </span>
@@ -825,7 +811,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
</div>
<div class="error">
<gr-icon icon="error" filled role="img" aria-label="Error"></gr-icon>
@@ -833,11 +819,13 @@ suite('gr-change-list-reviewer-flow tests', () => {
</div>
</div>
</gr-dialog>
- </gr-overlay>
+ <div id="gr-hovercard-container">
+ </div>
+ </dialog>
`,
{
- // gr-overlay sizing seems to vary between local & CI
- ignoreAttributes: [{tags: ['gr-overlay'], attributes: ['style']}],
+ // dialog sizing seems to vary between local & CI
+ ignoreAttributes: [{tags: ['dialog'], attributes: ['style']}],
}
);
});
@@ -866,10 +854,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
await reviewerList.updateComplete;
await element.updateComplete;
- const confirmDialog = queryAndAssert(
- element,
- 'gr-overlay#confirm-reviewer'
- );
+ const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
await waitUntil(
() =>
getComputedStyle(confirmDialog).getPropertyValue('display') !== 'none'
@@ -906,12 +891,10 @@ suite('gr-change-list-reviewer-flow tests', () => {
).click();
await element.updateComplete;
- const confirmDialog = queryAndAssert(
- element,
- 'gr-overlay#confirm-reviewer'
- );
- assert.isTrue(
- getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
+ const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
+ await waitUntil(
+ () =>
+ getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
);
assert.deepEqual(reviewerList.accounts[1], {
@@ -947,10 +930,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
// triggers an update of ReviewerFlow
await reviewerList.updateComplete;
await element.updateComplete;
- const confirmDialog = queryAndAssert(
- element,
- 'gr-overlay#confirm-reviewer'
- );
+ const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
assert.isTrue(
getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
);
@@ -990,10 +970,7 @@ suite('gr-change-list-reviewer-flow tests', () => {
).click();
await element.updateComplete;
- const confirmDialog = queryAndAssert(
- element,
- 'gr-overlay#confirm-reviewer'
- );
+ const confirmDialog = queryAndAssert(element, 'dialog#confirm-reviewer');
assert.isTrue(
getComputedStyle(confirmDialog).getPropertyValue('display') === 'none'
);
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 86adc70794..61b276e174 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
@@ -15,12 +15,13 @@ import {fontStyles} from '../../../styles/gr-font-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {Metadata} from '../../../utils/change-metadata-util';
import {WAITING} from '../../../constants/constants';
-import {provide} from '../../../models/dependency';
+import {provide, resolve} from '../../../models/dependency';
import {
bulkActionsModelToken,
BulkActionsModel,
} from '../../../models/bulk-actions/bulk-actions-model';
import {createSearchUrl} from '../../../models/views/search';
+import {userModelToken} from '../../../models/user/user-model';
import {subscribe} from '../../lit/subscription-controller';
import {classMap} from 'lit/directives/class-map.js';
@@ -101,8 +102,7 @@ export class GrChangeListSection extends LitElement {
getAppContext().restApiService
);
- // Private but used in test.
- userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private isLoggedIn = false;
@@ -160,7 +160,7 @@ export class GrChangeListSection extends LitElement {
);
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
isLoggedIn => (this.isLoggedIn = isLoggedIn)
);
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
index eec8b1ad4e..63552c7294 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-section/gr-change-list-section_test.ts
@@ -28,11 +28,15 @@ import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
import {ChangeListSection} from '../gr-change-list/gr-change-list';
import {fixture, html, assert} from '@open-wc/testing';
import {ColumnNames} from '../../../constants/constants';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
suite('gr-change-list section', () => {
let element: GrChangeListSection;
+ let userModel: UserModel;
setup(async () => {
+ userModel = testResolver(userModelToken);
const changeSection: ChangeListSection = {
name: 'test',
query: 'test',
@@ -194,7 +198,7 @@ suite('gr-change-list section', () => {
],
emptyStateSlotName: 'test',
};
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
@@ -240,7 +244,7 @@ suite('gr-change-list section', () => {
],
emptyStateSlotName: 'test',
};
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
@@ -300,7 +304,7 @@ suite('gr-change-list section', () => {
],
emptyStateSlotName: 'test',
};
- element.userModel.setAccount(undefined);
+ userModel.setAccount(undefined);
await element.updateComplete;
const rows = queryAll(element, 'gr-change-list-item');
assert.lengthOf(rows, 2);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
index ac1ba237b3..4a01412973 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow.ts
@@ -15,7 +15,7 @@ import '../../shared/gr-autocomplete/gr-autocomplete';
import '@polymer/iron-dropdown/iron-dropdown';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
import {getAppContext} from '../../../services/app-context';
-import {notUndefined} from '../../../types/types';
+import {isDefined} from '../../../types/types';
import {unique} from '../../../utils/common-util';
import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
import {when} from 'lit/directives/when.js';
@@ -28,6 +28,7 @@ import {fireReload} from '../../../utils/event-util';
import {fireAlert} from '../../../utils/event-util';
import {pluralize} from '../../../utils/string-util';
import {Interaction} from '../../../constants/reporting';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@customElement('gr-change-list-topic-flow')
export class GrChangeListTopicFlow extends LitElement {
@@ -158,7 +159,7 @@ export class GrChangeListTopicFlow extends LitElement {
.horizontalAlign=${'auto'}
.verticalAlign=${'auto'}
.verticalOffset=${24}
- @opened-changed=${(e: CustomEvent) =>
+ @opened-changed=${(e: ValueChangedEvent<boolean>) =>
(this.isDropdownOpen = e.detail.value)}
>
${when(
@@ -190,7 +191,7 @@ export class GrChangeListTopicFlow extends LitElement {
private renderExistingTopicsMode() {
const topics = this.selectedChanges
.map(change => change.topic)
- .filter(notUndefined)
+ .filter(isDefined)
.filter(unique);
const removeDisabled =
this.selectedExistingTopics.size === 0 ||
@@ -343,11 +344,12 @@ export class GrChangeListTopicFlow extends LitElement {
query: string
): Promise<AutocompleteSuggestion[]> {
const suggestions = await this.restApiService.getChangesWithSimilarTopic(
- query
+ query,
+ throwingErrorCallback
);
this.existingTopicSuggestions = (suggestions ?? [])
.map(change => change.topic)
- .filter(notUndefined)
+ .filter(isDefined)
.filter(unique);
return this.existingTopicSuggestions.map(topic => {
return {name: topic, value: topic};
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
index 9125cfd151..489d8ee598 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-topic-flow/gr-change-list-topic-flow_test.ts
@@ -29,11 +29,10 @@ import {
waitUntilObserved,
} from '../../../test/test-utils';
import {ChangeInfo, NumericChangeId, TopicName} from '../../../types/common';
-import {EventType} from '../../../types/events';
import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
import {GrButton} from '../../shared/gr-button/gr-button';
import './gr-change-list-topic-flow';
-import type {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
+import {GrChangeListTopicFlow} from './gr-change-list-topic-flow';
suite('gr-change-list-topic-flow tests', () => {
let element: GrChangeListTopicFlow;
@@ -326,7 +325,7 @@ suite('gr-change-list-topic-flow tests', () => {
test('remove single topic', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
await element.updateComplete;
queryAndAssert<GrButton>(element, '#remove-topics-button').click();
@@ -387,7 +386,7 @@ suite('gr-change-list-topic-flow tests', () => {
test('shows error when remove topic fails', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
await element.updateComplete;
queryAndAssert<GrButton>(element, '#remove-topics-button').click();
@@ -435,7 +434,7 @@ suite('gr-change-list-topic-flow tests', () => {
test('applies topic to all changes', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
queryAll<HTMLButtonElement>(element, 'button.chip')[0].click();
await element.updateComplete;
@@ -589,7 +588,7 @@ suite('gr-change-list-topic-flow tests', () => {
test('create new topic', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
[]
);
@@ -639,7 +638,7 @@ suite('gr-change-list-topic-flow tests', () => {
test('shows error when create topic fails', async () => {
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const getTopicsStub = stubRestApi('getChangesWithSimilarTopic').resolves(
[]
);
@@ -682,7 +681,7 @@ suite('gr-change-list-topic-flow tests', () => {
{...createChange(), topic: 'foo' as TopicName},
]);
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const autocomplete = queryAndAssert<GrAutocomplete>(
element,
'gr-autocomplete'
@@ -732,7 +731,7 @@ suite('gr-change-list-topic-flow tests', () => {
{...createChange(), topic: 'foo' as TopicName},
]);
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
const autocomplete = queryAndAssert<GrAutocomplete>(
element,
'gr-autocomplete'
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
index 7abd7c004a..96b01e1d5f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -6,7 +6,6 @@
import '../gr-change-list/gr-change-list';
import '../gr-repo-header/gr-repo-header';
import '../gr-user-header/gr-user-header';
-import {page} from '../../../utils/page-wrapper-utils';
import {
AccountDetailInfo,
AccountId,
@@ -16,7 +15,7 @@ import {
RepoName,
} from '../../../types/common';
import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
+import {fireAlert, fire, fireTitleChange} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css, nothing} from 'lit';
@@ -27,17 +26,13 @@ import {
} from '../../../models/views/search';
import {resolve} from '../../../models/dependency';
import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
@customElement('gr-change-list-view')
export class GrChangeListView extends LitElement {
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
@query('#prevArrow') protected prevArrow?: HTMLAnchorElement;
@query('#nextArrow') protected nextArrow?: HTMLAnchorElement;
@@ -76,10 +71,12 @@ export class GrChangeListView extends LitElement {
private reporting = getAppContext().reportingService;
- private userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getViewModel = resolve(this, searchViewModelToken);
+ private readonly getNavigation = resolve(this, navigationToken);
+
constructor() {
super();
this.addEventListener('next-page', () => this.handleNextPage());
@@ -117,22 +114,22 @@ export class GrChangeListView extends LitElement {
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
x => (this.account = x)
);
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
x => (this.loggedIn = x)
);
subscribe(
this,
- () => this.userModel.preferenceChangesPerPage$,
+ () => this.getUserModel().preferenceChangesPerPage$,
x => (this.changesPerPage = x)
);
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
x => (this.preferences = x)
);
}
@@ -255,7 +252,7 @@ export class GrChangeListView extends LitElement {
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('query')) {
- fireTitleChange(this, this.query);
+ fireTitleChange(this.query);
}
}
@@ -280,13 +277,13 @@ export class GrChangeListView extends LitElement {
// private but used in test
handleNextPage() {
if (!this.nextArrow || !this.changesPerPage) return;
- page.show(this.computeNavLink(1));
+ this.getNavigation().setUrl(this.computeNavLink(1));
}
// private but used in test
handlePreviousPage() {
if (!this.prevArrow || !this.changesPerPage) return;
- page.show(this.computeNavLink(-1));
+ this.getNavigation().setUrl(this.computeNavLink(-1));
}
// private but used in test
@@ -313,7 +310,7 @@ export class GrChangeListView extends LitElement {
e.detail.change._number,
e.detail.starred
);
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index f4bd8bd6de..decc2537fc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -6,7 +6,6 @@
import '../../../test/common-test-setup';
import './gr-change-list-view';
import {GrChangeListView} from './gr-change-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
import {query, queryAndAssert} from '../../../test/test-utils';
import {createChange} from '../../../test/test-data-generators';
import {ChangeInfo} from '../../../api/rest-api';
@@ -14,6 +13,8 @@ import {fixture, html, waitUntil, assert} from '@open-wc/testing';
import {GrChangeList} from '../gr-change-list/gr-change-list';
import {GrChangeListSection} from '../gr-change-list-section/gr-change-list-section';
import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
suite('gr-change-list-view tests', () => {
let element: GrChangeListView;
@@ -158,7 +159,7 @@ suite('gr-change-list-view tests', () => {
});
test('handleNextPage', async () => {
- const showStub = sinon.stub(page, 'show');
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
element.changes = Array(25)
.fill(0)
.map(_ => createChange());
@@ -166,7 +167,7 @@ suite('gr-change-list-view tests', () => {
element.loading = false;
await element.updateComplete;
element.handleNextPage();
- assert.isFalse(showStub.called);
+ assert.isFalse(setUrlStub.called);
element.changes = Array(25)
.fill(0)
@@ -174,11 +175,11 @@ suite('gr-change-list-view tests', () => {
element.loading = false;
await element.updateComplete;
element.handleNextPage();
- assert.isTrue(showStub.called);
+ assert.isTrue(setUrlStub.called);
});
test('handlePreviousPage', async () => {
- const showStub = sinon.stub(page, 'show');
+ const setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
element.offset = 0;
element.changes = Array(25)
.fill(0)
@@ -187,11 +188,11 @@ suite('gr-change-list-view tests', () => {
element.loading = false;
await element.updateComplete;
element.handlePreviousPage();
- assert.isFalse(showStub.called);
+ assert.isFalse(setUrlStub.called);
element.offset = 25;
await element.updateComplete;
element.handlePreviousPage();
- assert.isTrue(showStub.called);
+ assert.isTrue(setUrlStub.called);
});
});
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 945ff6e111..117abd6312 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
@@ -10,8 +10,6 @@ import {GrChangeListItem} from '../gr-change-list-item/gr-change-list-item';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import {getAppContext} from '../../../services/app-context';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
import {
AccountInfo,
@@ -19,7 +17,7 @@ import {
ServerInfo,
PreferencesInput,
} from '../../../types/common';
-import {fire, fireEvent, fireReload} from '../../../utils/event-util';
+import {fire, fireReload} from '../../../utils/event-util';
import {ColumnNames, ScrollMode} from '../../../constants/constants';
import {getRequirements} from '../../../utils/label-util';
import {Key} from '../../../utils/dom-util';
@@ -36,6 +34,7 @@ import {Execution} from '../../../constants/reporting';
import {ValueChangedEvent} from '../../../types/events';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
export interface ChangeListSection {
countLabel?: string;
@@ -78,18 +77,6 @@ export function computeRelativeIndex(
@customElement('gr-change-list')
export class GrChangeList extends LitElement {
/**
- * Fired when next page key shortcut was pressed.
- *
- * @event next-page
- */
-
- /**
- * Fired when previous page key shortcut was pressed.
- *
- * @event previous-page
- */
-
- /**
* The logged-in user's account, or an empty object if no user is logged
* in.
*/
@@ -134,9 +121,6 @@ export class GrChangeList extends LitElement {
// private but used in test
@state() config?: ServerInfo;
- // Private but used in test.
- userModel = getAppContext().userModel;
-
private readonly flagsService = getAppContext().flagsService;
private readonly restApiService = getAppContext().restApiService;
@@ -145,6 +129,8 @@ export class GrChangeList extends LitElement {
private readonly shortcuts = new ShortcutController(this);
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
private readonly getNavigation = resolve(this, navigationToken);
private cursor = new GrCursorManager();
@@ -179,11 +165,13 @@ export class GrChangeList extends LitElement {
this.restApiService.getConfig().then(config => {
this.config = config;
});
- getPluginLoader()
+ this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this.dynamicHeaderEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-list-header');
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-list-header'
+ );
});
}
@@ -241,7 +229,7 @@ export class GrChangeList extends LitElement {
}
private calculateStartIndices(sections: ChangeListSection[]): number[] {
- const startIndices: number[] = new Array(sections.length).fill(0);
+ const startIndices = Array.from<number>({length: sections.length}).fill(0);
for (let i = 1; i < sections.length; ++i) {
startIndices[i] = startIndices[i - 1] + sections[i - 1].results.length;
}
@@ -415,11 +403,11 @@ export class GrChangeList extends LitElement {
}
private nextPage() {
- fireEvent(this, 'next-page');
+ fire(this, 'next-page', {});
}
private prevPage() {
- fireEvent(this, 'previous-page');
+ fire(this, 'previous-page', {});
}
private refreshChangeList() {
@@ -484,5 +472,9 @@ declare global {
}
interface HTMLElementEventMap {
'selected-index-changed': ValueChangedEvent<number>;
+ /** Fired when next page key shortcut was pressed. */
+ 'next-page': CustomEvent<{}>;
+ /** Fired when previous page key shortcut was pressed. */
+ 'previous-page': CustomEvent<{}>;
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
index 7511b575f7..e201ab4515 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.ts
@@ -34,12 +34,15 @@ import {fixture, assert} from '@open-wc/testing';
import {html} from 'lit';
import {testResolver} from '../../../test/common-test-setup';
import {Timestamp} from '../../../api/rest-api';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
suite('gr-change-list basic tests', () => {
let element: GrChangeList;
+ let userModel: UserModel;
setup(async () => {
element = await fixture(html`<gr-change-list></gr-change-list>`);
+ userModel = testResolver(userModelToken);
});
test('renders', async () => {
@@ -138,7 +141,10 @@ suite('gr-change-list basic tests', () => {
});
test('computeRelativeIndex', () => {
- element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+ element.sections = [
+ {results: Array.from({length: 1})},
+ {results: Array.from({length: 2})},
+ ];
let selectedChangeIndex = 0;
assert.equal(
@@ -225,7 +231,10 @@ suite('gr-change-list basic tests', () => {
test('keyboard shortcuts', async () => {
sinon.stub(element, 'computeLabelNames');
- element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+ element.sections = [
+ {results: Array.from({length: 1})},
+ {results: Array.from({length: 2})},
+ ];
element.selectedIndex = 0;
element.preferences = createDefaultPreferences();
element.config = createServerInfo();
@@ -287,7 +296,7 @@ suite('gr-change-list basic tests', () => {
});
test('toggle checkbox keyboard shortcut', async () => {
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
@@ -297,7 +306,10 @@ suite('gr-change-list basic tests', () => {
queryAndAssert<HTMLInputElement>(query(item, '.selection'), 'input');
sinon.stub(element, 'computeLabelNames');
- element.sections = [{results: new Array(1)}, {results: new Array(2)}];
+ element.sections = [
+ {results: Array.from({length: 1})},
+ {results: Array.from({length: 2})},
+ ];
element.selectedIndex = 0;
element.preferences = createDefaultPreferences();
element.config = createServerInfo();
@@ -521,7 +533,6 @@ suite('gr-change-list basic tests', () => {
test('obsolete column in preferences not visible', () => {
assert.isTrue(element.isColumnEnabled('Subject'));
- assert.isFalse(element.isColumnEnabled('Assignee'));
});
test('loggedIn and showNumber', async () => {
@@ -543,7 +554,7 @@ suite('gr-change-list basic tests', () => {
],
};
element.config = createServerInfo();
- element.userModel.setAccount(undefined);
+ userModel.setAccount(undefined);
await element.updateComplete;
const section = query<GrChangeListSection>(
element,
@@ -557,7 +568,7 @@ suite('gr-change-list basic tests', () => {
assert.isNotOk(query(query(section, 'gr-change-list-item'), '.star'));
assert.isNotOk(query(query(section, 'gr-change-list-item'), '.number'));
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
index 9c53feaa78..d9be9ca397 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement} from 'lit/decorators.js';
@@ -14,6 +14,10 @@ declare global {
interface HTMLElementTagNameMap {
'gr-create-change-help': GrCreateChangeHelp;
}
+ interface HTMLElementEventMap {
+ /** Fired when the "Create change" button is tapped. */
+ 'create-tap': CustomEvent<{}>;
+ }
}
@customElement('gr-create-change-help')
@@ -87,11 +91,8 @@ export class GrCreateChangeHelp extends LitElement {
`;
}
- /**
- * Fired when the "Create change" button is tapped.
- */
_handleCreateTap(e: Event) {
e.preventDefault();
- fireEvent(this, 'create-tap');
+ fire(this, 'create-tap', {});
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index 567c5084c6..cf5c26dabb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-shell-command/gr-shell-command';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
enum Commands {
CREATE = 'git commit',
@@ -25,8 +24,8 @@ declare global {
@customElement('gr-create-commands-dialog')
export class GrCreateCommandsDialog extends LitElement {
- @query('#commandsOverlay')
- commandsOverlay?: GrOverlay;
+ @query('#commandsModal')
+ commandsModal?: HTMLDialogElement;
@property({type: String})
branch?: string;
@@ -34,6 +33,7 @@ export class GrCreateCommandsDialog extends LitElement {
static override get styles() {
return [
sharedStyles,
+ modalStyles,
css`
ol {
list-style: decimal;
@@ -50,13 +50,13 @@ export class GrCreateCommandsDialog extends LitElement {
}
override render() {
- return html` <gr-overlay id="commandsOverlay" with-backdrop="">
+ return html` <dialog id="commandsModal" tabindex="-1">
<gr-dialog
id="commandsDialog"
confirm-label="Done"
cancel-label=""
confirm-on-enter=""
- @confirm=${() => this.commandsOverlay?.close()}
+ @confirm=${() => this.commandsModal?.close()}
>
<div class="header" slot="header">Create change commands</div>
<div class="main" slot="main">
@@ -90,10 +90,10 @@ export class GrCreateCommandsDialog extends LitElement {
</ol>
</div>
</gr-dialog>
- </gr-overlay>`;
+ </dialog>`;
}
open() {
- this.commandsOverlay?.open();
+ this.commandsModal?.showModal();
}
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index 96ec9eb641..3252e3d65f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -27,13 +27,7 @@ suite('gr-create-commands-dialog tests', () => {
assert.shadowDom.equal(
element,
/* prettier-ignore */ /* HTML */ `
- <gr-overlay
- aria-hidden="true"
- id="commandsOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="commandsModal" tabindex="-1">
<gr-dialog
cancel-label=""
confirm-label="Done"
@@ -71,7 +65,7 @@ suite('gr-create-commands-dialog tests', () => {
</ol>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
index 983a0d9b8a..16220ba4ff 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
@@ -4,15 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {RepoName, BranchName} from '../../../types/common';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html} from 'lit';
import {customElement, state, query} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
import {BindValueChangeEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
export interface CreateDestinationConfirmDetail {
repo?: RepoName;
@@ -28,25 +28,25 @@ export class GrCreateDestinationDialog extends LitElement {
* @event confirm
*/
- @query('#createOverlay') private createOverlay?: GrOverlay;
+ @query('#createModal') private createModal?: HTMLDialogElement;
@state() private repo?: RepoName;
@state() private branch?: BranchName;
static override get styles() {
- return [sharedStyles];
+ return [sharedStyles, modalStyles];
}
override render() {
return html`
- <gr-overlay id="createOverlay" with-backdrop>
+ <dialog id="createModal" tabindex="-1">
<gr-dialog
confirm-label="View commands"
@confirm=${this.pickerConfirm}
@cancel=${() => {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.close();
+ assertIsDefined(this.createModal, 'createModal');
+ this.createModal.close();
}}
?disabled=${!(this.repo && this.branch)}
>
@@ -67,20 +67,20 @@ export class GrCreateDestinationDialog extends LitElement {
</p>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
open() {
- assertIsDefined(this.createOverlay, 'createOverlay');
+ assertIsDefined(this.createModal, 'createModal');
this.repo = '' as RepoName;
this.branch = '' as BranchName;
- this.createOverlay.open();
+ this.createModal.showModal();
}
private pickerConfirm = (e: Event) => {
- assertIsDefined(this.createOverlay, 'createOverlay');
- this.createOverlay.close();
+ assertIsDefined(this.createModal, 'createModal');
+ this.createModal.close();
const detail: CreateDestinationConfirmDetail = {
repo: this.repo,
branch: this.branch,
@@ -89,7 +89,7 @@ export class GrCreateDestinationDialog extends LitElement {
// 'confirm' event here, so let's stop propagation of the bare event.
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+ fireNoBubbleNoCompose(this, 'confirm-destination', detail);
};
}
@@ -97,4 +97,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-create-destination-dialog': GrCreateDestinationDialog;
}
+ interface HTMLElementEventMap {
+ 'confirm-destination': CustomEvent<CreateDestinationConfirmDetail>;
+ }
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
index 44b3183c0c..cb27aae477 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog_test.ts
@@ -21,13 +21,7 @@ suite('gr-create-destination-dialog tests', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-overlay
- aria-hidden="true"
- id="createOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="createModal" tabindex="-1">
<gr-dialog confirm-label="View commands" disabled="" role="dialog">
<div class="header" slot="header">Create change</div>
<div class="main" slot="main">
@@ -37,7 +31,7 @@ suite('gr-create-destination-dialog tests', () => {
</p>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
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 f11fe1c4a8..a870835716 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
@@ -6,7 +6,6 @@
import '../gr-change-list/gr-change-list';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
-import '../../shared/gr-overlay/gr-overlay';
import '../gr-create-commands-dialog/gr-create-commands-dialog';
import '../gr-create-change-help/gr-create-change-help';
import '../gr-create-destination-dialog/gr-create-destination-dialog';
@@ -27,11 +26,10 @@ import {
CreateDestinationConfirmDetail,
GrCreateDestinationDialog,
} from '../gr-create-destination-dialog/gr-create-destination-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
import {
fireAlert,
- fireEvent,
+ fire,
firePageError,
fireTitleChange,
} from '../../../utils/event-util';
@@ -57,6 +55,9 @@ import {
UserDashboard,
YOUR_TURN,
} from '../../../utils/dashboard-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {Timing} from '../../../constants/reporting';
+import {modalStyles} from '../../../styles/gr-modal-styles';
const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
@@ -67,12 +68,6 @@ const slotNameBySectionName = new Map<string, string>([
@customElement('gr-dashboard-view')
export class GrDashboardView extends LitElement {
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
@query('#confirmDeleteDialog') protected confirmDeleteDialog?: GrDialog;
@query('#commandsDialog') protected commandsDialog?: GrCreateCommandsDialog;
@@ -80,7 +75,8 @@ export class GrDashboardView extends LitElement {
@query('#destinationDialog')
protected destinationDialog?: GrCreateDestinationDialog;
- @query('#confirmDeleteOverlay') protected confirmDeleteOverlay?: GrOverlay;
+ @query('#confirmDeleteModal')
+ protected confirmDeleteModal?: HTMLDialogElement;
@property({type: Object})
account?: AccountDetailInfo;
@@ -107,19 +103,29 @@ export class GrDashboardView extends LitElement {
private readonly restApiService = getAppContext().restApiService;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getViewModel = resolve(this, dashboardViewModelToken);
private lastVisibleTimestampMs = 0;
+ /**
+ * For `DASHBOARD_DISPLAYED` timing we can only rely on the router to have
+ * reset the timer properly when the dashboard loads for the first time.
+ * Later we won't have a guarantee that the timer was just reset. So we will
+ * just reset the timer at the beginning of `reload()`. The dashboard view
+ * is cached anyway, so there is unlikely a lot of time that has passed
+ * initiating the reload and the reload() method being executed.
+ */
+ private firstTimeLoad = true;
+
private readonly shortcuts = new ShortcutController(this);
constructor() {
super();
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
x => (this.account = x)
);
subscribe(
@@ -167,6 +173,7 @@ export class GrDashboardView extends LitElement {
return [
a11yStyles,
sharedStyles,
+ modalStyles,
css`
:host {
display: block;
@@ -208,7 +215,7 @@ export class GrDashboardView extends LitElement {
if (!this.viewState) return nothing;
return html`
${this.renderBanner()} ${this.renderContent()}
- <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+ <dialog id="confirmDeleteModal" tabindex="-1">
<gr-dialog
id="confirmDeleteDialog"
confirm-label="Delete"
@@ -216,7 +223,7 @@ export class GrDashboardView extends LitElement {
this.handleConfirmDelete();
}}
@cancel=${() => {
- this.closeConfirmDeleteOverlay();
+ this.closeConfirmDeleteModal();
}}
>
<div class="header" slot="header">Delete comments</div>
@@ -225,10 +232,12 @@ export class GrDashboardView extends LitElement {
changes? This action cannot be undone.
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
<gr-create-destination-dialog
id="destinationDialog"
- @confirm=${(e: CustomEvent<CreateDestinationConfirmDetail>) => {
+ @confirm-destination=${(
+ e: CustomEvent<CreateDestinationConfirmDetail>
+ ) => {
this.handleDestinationConfirm(e);
}}
></gr-create-destination-dialog>
@@ -329,8 +338,8 @@ export class GrDashboardView extends LitElement {
}
// private but used in test
- getProjectDashboard(
- project: RepoName,
+ getRepositoryDashboard(
+ repo: RepoName,
dashboard?: DashboardId
): Promise<UserDashboard | undefined> {
const errFn = (response?: Response | null) => {
@@ -338,7 +347,7 @@ export class GrDashboardView extends LitElement {
};
assertIsDefined(dashboard, 'project dashboard must have id');
return this.restApiService
- .getDashboard(project, dashboard, errFn)
+ .getDashboard(repo, dashboard, errFn)
.then(response => {
if (!response) {
return;
@@ -351,7 +360,7 @@ export class GrDashboardView extends LitElement {
name: section.name,
query: (section.query + suffix).replace(
PROJECT_PLACEHOLDER_PATTERN,
- project
+ repo
),
};
}),
@@ -374,11 +383,18 @@ export class GrDashboardView extends LitElement {
*/
reload() {
if (!this.viewState) return Promise.resolve();
+
+ // See `firstTimeLoad` comment above.
+ if (!this.firstTimeLoad) {
+ this.reporting.time(Timing.DASHBOARD_DISPLAYED);
+ }
+ this.firstTimeLoad = false;
+
this.loading = true;
const {project, dashboard, title, user, sections} = this.viewState;
const dashboardPromise: Promise<UserDashboard | undefined> = project
- ? this.getProjectDashboard(project, dashboard)
+ ? this.getRepositoryDashboard(project, dashboard)
: Promise.resolve(
getUserDashboard(user, sections, title || this.computeTitle(user))
);
@@ -388,7 +404,7 @@ export class GrDashboardView extends LitElement {
return dashboardPromise
.then(res => {
if (res && res.title) {
- fireTitleChange(this, res.title);
+ fireTitleChange(res.title);
}
return this.fetchDashboardChanges(res, checkForNewUser);
})
@@ -397,7 +413,7 @@ export class GrDashboardView extends LitElement {
this.reporting.dashboardDisplayed();
})
.catch(err => {
- fireTitleChange(this, title || this.computeTitle(user));
+ fireTitleChange(title || this.computeTitle(user));
this.reporting.error('Dashboard reload', err);
})
.finally(() => {
@@ -517,7 +533,7 @@ export class GrDashboardView extends LitElement {
e.detail.change._number,
e.detail.starred
);
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
if (e.detail.starred) {
this.reporting.reportInteraction('change-starred-from-dashboard');
}
@@ -564,8 +580,8 @@ export class GrDashboardView extends LitElement {
// private but used in test
handleOpenDeleteDialog() {
- assertIsDefined(this.confirmDeleteOverlay, 'confirmDeleteOverlay');
- this.confirmDeleteOverlay.open();
+ assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
+ this.confirmDeleteModal.showModal();
}
// private but used in test
@@ -573,14 +589,14 @@ export class GrDashboardView extends LitElement {
assertIsDefined(this.confirmDeleteDialog, 'confirmDeleteDialog');
this.confirmDeleteDialog.disabled = true;
return this.restApiService.deleteDraftComments('-is:open').then(() => {
- this.closeConfirmDeleteOverlay();
+ this.closeConfirmDeleteModal();
this.reload();
});
}
- private closeConfirmDeleteOverlay() {
- assertIsDefined(this.confirmDeleteOverlay, 'confirmDeleteOverlay');
- this.confirmDeleteOverlay.close();
+ private closeConfirmDeleteModal() {
+ assertIsDefined(this.confirmDeleteModal, 'confirmDeleteModal');
+ this.confirmDeleteModal.close();
}
private computeDraftsLink() {
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 e7aaa210b5..84a313987a 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
@@ -29,7 +29,6 @@ import {
RepoName,
Timestamp,
} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrCreateChangeHelp} from '../gr-create-change-help/gr-create-change-help';
import {PageErrorEvent} from '../../../types/events';
@@ -90,12 +89,9 @@ suite('gr-dashboard-view tests', () => {
</div>
</gr-change-list>
</div>
- <gr-overlay
- aria-hidden="true"
- id="confirmDeleteOverlay"
- style="outline: none; display: none;"
+ <dialog
+ id="confirmDeleteModal"
tabindex="-1"
- with-backdrop=""
>
<gr-dialog
confirm-label="Delete"
@@ -108,7 +104,7 @@ suite('gr-dashboard-view tests', () => {
changes? This action cannot be undone.
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
<gr-create-destination-dialog id="destinationDialog">
</gr-create-destination-dialog>
<gr-create-commands-dialog id="commandsDialog">
@@ -266,11 +262,14 @@ suite('gr-dashboard-view tests', () => {
);
// Open confirmation dialog and tap confirm button.
- await queryAndAssert<GrOverlay>(element, '#confirmDeleteOverlay').open();
- queryAndAssert<GrDialog>(
+ const modal = queryAndAssert<HTMLDialogElement>(
element,
- '#confirmDeleteDialog'
- ).confirmButton!.click();
+ '#confirmDeleteModal'
+ );
+ modal.showModal();
+ const dialog = queryAndAssert<GrDialog>(modal, '#confirmDeleteDialog');
+ await waitUntil(() => !!dialog.confirmButton);
+ dialog.confirmButton!.click();
await element.updateComplete;
assert.isTrue(deleteStub.calledWithExactly('-is:open'));
assert.isTrue(
@@ -397,7 +396,7 @@ suite('gr-dashboard-view tests', () => {
],
})
);
- const dashboard = await element.getProjectDashboard(
+ const dashboard = await element.getRepositoryDashboard(
'project' as RepoName,
'' as DashboardId
);
@@ -429,7 +428,7 @@ suite('gr-dashboard-view tests', () => {
],
})
);
- const dashboard = await element.getProjectDashboard(
+ const dashboard = await element.getRepositoryDashboard(
'project' as RepoName,
'' as DashboardId
);
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
index e27274b5fe..b743467dbc 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -12,6 +12,7 @@ import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {createRepoUrl} from '../../../models/views/repo';
+import '../../shared/gr-weblink/gr-weblink';
@customElement('gr-repo-header')
export class GrRepoHeader extends LitElement {
@@ -50,7 +51,7 @@ export class GrRepoHeader extends LitElement {
return html`<div>
<span class="browse">Browse:</span>
${webLinks.map(
- link => html`<a target="_blank" href=${link.url}>${link.name}</a> `
+ info => html`<gr-weblink imageAndText .info=${info}></gr-weblink>`
)}
</div> `;
}
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
index 57f9ee694a..3f9941624c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -117,10 +117,12 @@ export class GrUserHeader extends LitElement {
return;
}
- this.restApiService.getAccountDetails(userId).then(details => {
- this._accountDetails = details ?? undefined;
- this._status = details?.status ?? '';
- });
+ this.restApiService
+ .getAccountDetails(userId, () => {})
+ .then(details => {
+ this._accountDetails = details ?? undefined;
+ this._status = details?.status ?? '';
+ });
}
_computeDetail(
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 506e0862b5..18fcce110d 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
@@ -8,7 +8,6 @@ import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-overlay/gr-overlay';
import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
@@ -18,7 +17,6 @@ import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
import '../../../styles/shared-styles';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {getAppContext} from '../../../services/app-context';
import {
CURRENT,
@@ -36,12 +34,13 @@ import {
HttpMethod,
NotifyType,
} from '../../../constants/constants';
-import {EventType as PluginEventType, TargetElement} from '../../../api/plugin';
+import {TargetElement} from '../../../api/plugin';
import {
AccountInfo,
ActionInfo,
ActionNameToActionInfoMap,
BranchName,
+ ChangeActionDialog,
ChangeInfo,
ChangeViewChangeInfo,
CherryPickInput,
@@ -57,7 +56,6 @@ import {
ReviewInput,
} from '../../../types/common';
import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
@@ -81,15 +79,14 @@ import {
import {
fire,
fireAlert,
- fireEvent,
- fireReload,
+ fireError,
+ fireNoBubbleNoCompose,
} from '../../../utils/event-util';
import {
getApprovalInfo,
getVotingRange,
StandardLabels,
} from '../../../utils/label-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
import {
ActionPriority,
ActionType,
@@ -105,11 +102,16 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html, nothing} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {ifDefined} from 'lit/directives/if-defined.js';
-import {assertIsDefined, queryAll} from '../../../utils/common-util';
+import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
import {Interaction} from '../../../constants/reporting';
import {rootUrl} from '../../../utils/url-util';
import {createSearchUrl} from '../../../models/views/search';
import {createChangeUrl} from '../../../models/views/change';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {ShowRevisionActionsDetail} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {whenVisible} from '../../../utils/dom-util';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
import {subscribe} from '../../lit/subscription-controller';
const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
@@ -318,11 +320,6 @@ interface ActionPriorityOverride {
priority: ActionPriority;
}
-interface ChangeActionDialog extends HTMLElement {
- resetFocus?(): void;
- init?(): void;
-}
-
@customElement('gr-change-actions')
export class GrChangeActions
extends LitElement
@@ -340,21 +337,9 @@ export class GrChangeActions
* @event custom-tap - naming pattern: <action key>-tap
*/
- /**
- * Fires to show an alert when a send is attempted on the non-latest patch.
- *
- * @event show-alert
- */
-
- /**
- * Fires when a change action fails.
- *
- * @event show-error
- */
-
@query('#mainContent') mainContent?: Element;
- @query('#overlay') overlay?: GrOverlay;
+ @query('#actionsModal') actionsModal?: HTMLDialogElement;
@query('#confirmRebase') confirmRebase?: GrConfirmRebaseDialog;
@@ -392,13 +377,6 @@ export class GrChangeActions
RevisionActions = RevisionActions;
- private readonly reporting = getAppContext().reportingService;
-
- // Accessed in tests
- readonly jsAPI = getAppContext().jsApiService;
-
- private readonly getChangeModel = resolve(this, changeModelToken);
-
@property({type: Object})
change?: ChangeViewChangeInfo;
@@ -414,9 +392,6 @@ export class GrChangeActions
@property({type: Boolean})
disableEdit = false;
- @property({type: Boolean})
- _hasKnownChainState = false;
-
// private but used in test
@state() _hideQuickApproveAction = false;
@@ -432,9 +407,6 @@ export class GrChangeActions
@property({type: String})
commitNum?: CommitId;
- @property({type: Boolean})
- hasParent?: boolean;
-
@state() latestPatchNum?: PatchSetNumber;
@property({type: String})
@@ -457,6 +429,8 @@ export class GrChangeActions
// private but used in test
@state() actionLoadingMessage = '';
+ @state() private inProgressActionKeys = new Set<string>();
+
// _computeAllActions always returns an array
// private but used in test
@state() allActionValues: UIActionInfo[] = [];
@@ -545,18 +519,18 @@ export class GrChangeActions
private readonly restApiService = getAppContext().restApiService;
- private readonly storage = getAppContext().storageService;
+ private readonly reporting = getAppContext().reportingService;
+
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+ private readonly getChangeModel = resolve(this, changeModelToken);
+
+ private readonly getStorage = resolve(this, storageServiceToken);
private readonly getNavigation = resolve(this, navigationToken);
constructor() {
super();
- this.addEventListener('fullscreen-overlay-opened', () =>
- this.handleHideBackgroundContent()
- );
- this.addEventListener('fullscreen-overlay-closed', () =>
- this.handleShowBackgroundContent()
- );
subscribe(
this,
() => this.getChangeModel().latestPatchNum$,
@@ -576,13 +550,17 @@ export class GrChangeActions
override connectedCallback() {
super.connectedCallback();
- this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+ this.getPluginLoader().jsApiService.addElement(
+ TargetElement.CHANGE_ACTIONS,
+ this
+ );
this.handleLoadingComplete();
}
static override get styles() {
return [
sharedStyles,
+ modalStyles,
css`
:host {
display: flex;
@@ -689,15 +667,16 @@ export class GrChangeActions
<span id="moreMessage">More</span>
</gr-dropdown>
</div>
- <gr-overlay id="overlay" with-backdrop="">
+ <dialog id="actionsModal" tabindex="-1">
<gr-confirm-rebase-dialog
id="confirmRebase"
class="confirmDialog"
- .changeNumber=${this.change?._number}
- @confirm=${this.handleRebaseConfirm}
+ @confirm-rebase=${this.handleRebaseConfirm}
@cancel=${this.handleConfirmDialogCancel}
+ .disableActions=${this.inProgressActionKeys.has(
+ RevisionActions.REBASE
+ )}
.branch=${this.change?.branch}
- .hasParent=${this.hasParent}
.rebaseOnCurrent=${this.revisionRebaseAction
? !!this.revisionRebaseAction.enabled
: null}
@@ -728,7 +707,7 @@ export class GrChangeActions
<gr-confirm-revert-dialog
id="confirmRevertDialog"
class="confirmDialog"
- @confirm=${this.handleRevertDialogConfirm}
+ @confirm-revert=${this.handleRevertDialogConfirm}
@cancel=${this.handleConfirmDialogCancel}
></gr-confirm-revert-dialog>
<gr-confirm-abandon-dialog
@@ -788,7 +767,7 @@ export class GrChangeActions
Do you really want to delete the edit?
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
@@ -822,10 +801,6 @@ export class GrChangeActions
}
override willUpdate(changedProperties: PropertyValues) {
- if (changedProperties.has('hasParent')) {
- this.computeChainState();
- }
-
if (changedProperties.has('change')) {
this.reload();
this.actions = this.change?.actions ?? {};
@@ -903,17 +878,14 @@ export class GrChangeActions
}
private handleLoadingComplete() {
- getPluginLoader()
+ this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => (this.loading = false));
}
// private but used in test
- sendShowRevisionActions(detail: {
- change: ChangeInfo;
- revisionActions: ActionNameToActionInfoMap;
- }) {
- this.jsAPI.handleEvent(PluginEventType.SHOW_REVISION_ACTIONS, detail);
+ sendShowRevisionActions(detail: ShowRevisionActionsDetail) {
+ this.getPluginLoader().jsApiService.handleShowRevisionActions(detail);
}
addActionButton(type: ActionType, label: string) {
@@ -924,8 +896,7 @@ export class GrChangeActions
enabled: true,
label,
__type: type,
- __key:
- ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+ __key: ADDITIONAL_ACTION_KEY_PREFIX + uuid(),
};
this.additionalActions.push(action);
this.requestUpdate('additionalActions');
@@ -1033,23 +1004,17 @@ export class GrChangeActions
}
private actionsChanged() {
- this.hidden =
- Object.keys(this.actions).length === 0 &&
- Object.keys(this.revisionActions).length === 0 &&
- this.additionalActions.length === 0;
this.actionLoadingMessage = '';
this.disabledMenuActions = [];
- if (Object.keys(this.revisionActions).length !== 0) {
- if (!this.revisionActions.download) {
- this.revisionActions = {
- ...this.revisionActions,
- download: DOWNLOAD_ACTION,
- };
- fire(this, 'revision-actions-changed', {
- value: this.revisionActions,
- });
- }
+ if (!this.revisionActions.download) {
+ this.revisionActions = {
+ ...this.revisionActions,
+ download: DOWNLOAD_ACTION,
+ };
+ fire(this, 'revision-actions-changed', {
+ value: this.revisionActions,
+ });
}
if (
!this.actions.includedIn &&
@@ -1355,7 +1320,7 @@ export class GrChangeActions
if (!this.change) {
return false;
}
- return this.jsAPI.canSubmitChange(
+ return this.getPluginLoader().jsApiService.canSubmitChange(
this.change,
this.getRevision(this.change, this.latestPatchNum)
);
@@ -1374,7 +1339,6 @@ export class GrChangeActions
showRevertDialog() {
const change = this.change;
if (!change) return;
- // The search is still broken if there is a " in the topic.
const query = `submissionid: "${change.submission_id}"`;
/* A chromium plugin expects that the modifyRevertMsg hook will only
be called after the revert button is pressed, hence we populate the
@@ -1388,7 +1352,11 @@ export class GrChangeActions
return;
}
assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
- this.confirmRevertDialog.populate(change, this.commitMessage, changes);
+ this.confirmRevertDialog.populate(
+ change,
+ this.commitMessage,
+ changes.length
+ );
this.showActionDialog(this.confirmRevertDialog);
});
}
@@ -1556,22 +1524,10 @@ export class GrChangeActions
return key === '/' ? key : `/${key}`;
}
- /**
- * _hasKnownChainState set to true true if hasParent is defined (can be
- * either true or false). set to false otherwise.
- *
- * private but used in test
- */
- computeChainState() {
- this._hasKnownChainState = true;
- }
-
- // private but used in test
- calculateDisabled(action: UIActionInfo) {
- if (action.__key === 'rebase') {
- // Rebase button is only disabled when change has no parent(s).
- return this._hasKnownChainState === false;
- }
+ private calculateDisabled(action: UIActionInfo) {
+ // TODO(b/270972983): Remove this special casing once the backend is more
+ // aggressive about setting`enabled:true`.
+ if (action.__key === 'rebase') return false;
return !action.enabled;
}
@@ -1585,27 +1541,29 @@ export class GrChangeActions
for (const dialogEl of dialogEls) {
(dialogEl as HTMLElement).hidden = true;
}
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.close();
+ assertIsDefined(this.actionsModal, 'actionsModal');
+ this.actionsModal.close();
}
// private but used in test
handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
assertIsDefined(this.confirmRebase, 'confirmRebase');
- assertIsDefined(this.overlay, 'overlay');
- const el = this.confirmRebase;
+ assertIsDefined(this.actionsModal, 'actionsModal');
const payload = {
base: e.detail.base,
allow_conflicts: e.detail.allowConflicts,
+ on_behalf_of_uploader: e.detail.onBehalfOfUploader,
};
- this.overlay.close();
- el.hidden = true;
+ const rebaseChain = !!e.detail.rebaseChain;
this.fireAction(
- '/rebase',
+ rebaseChain ? '/rebase:chain' : '/rebase',
assertUIActionInfo(this.revisionActions.rebase),
- true,
+ rebaseChain ? false : true,
payload,
- {allow_conflicts: payload.allow_conflicts}
+ {
+ allow_conflicts: payload.allow_conflicts,
+ on_behalf_of_uploader: payload.on_behalf_of_uploader,
+ }
);
}
@@ -1621,7 +1579,7 @@ export class GrChangeActions
private handleCherryPickRestApi(conflicts: boolean) {
assertIsDefined(this.confirmCherrypick, 'confirmCherrypick');
- assertIsDefined(this.overlay, 'overlay');
+ assertIsDefined(this.actionsModal, 'actionsModal');
const el = this.confirmCherrypick;
if (!el.branch) {
fireAlert(this, ERR_BRANCH_EMPTY);
@@ -1631,7 +1589,7 @@ export class GrChangeActions
fireAlert(this, ERR_COMMIT_EMPTY);
return;
}
- this.overlay.close();
+ this.actionsModal.close();
el.hidden = true;
this.fireAction(
'/cherrypick',
@@ -1649,13 +1607,13 @@ export class GrChangeActions
// private but used in test
handleMoveConfirm() {
assertIsDefined(this.confirmMove, 'confirmMove');
- assertIsDefined(this.overlay, 'overlay');
+ assertIsDefined(this.actionsModal, 'actionsModal');
const el = this.confirmMove;
if (!el.branch) {
fireAlert(this, ERR_BRANCH_EMPTY);
return;
}
- this.overlay.close();
+ this.actionsModal.close();
el.hidden = true;
this.fireAction('/move', assertUIActionInfo(this.actions.move), false, {
destination_branch: el.branch,
@@ -1665,11 +1623,11 @@ export class GrChangeActions
private handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog');
- assertIsDefined(this.overlay, 'overlay');
+ assertIsDefined(this.actionsModal, 'actionsModal');
const revertType = e.detail.revertType;
const message = e.detail.message;
const el = this.confirmRevertDialog;
- this.overlay.close();
+ this.actionsModal.close();
el.hidden = true;
switch (revertType) {
case RevertType.REVERT_SINGLE_CHANGE:
@@ -1701,9 +1659,9 @@ export class GrChangeActions
// private but used in test
handleAbandonDialogConfirm() {
assertIsDefined(this.confirmAbandonDialog, 'confirmAbandonDialog');
- assertIsDefined(this.overlay, 'overlay');
+ assertIsDefined(this.actionsModal, 'actionsModal');
const el = this.confirmAbandonDialog;
- this.overlay.close();
+ this.actionsModal.close();
el.hidden = true;
this.fireAction(
'/abandon',
@@ -1722,8 +1680,8 @@ export class GrChangeActions
}
private handleCloseCreateFollowUpChange() {
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.close();
+ assertIsDefined(this.actionsModal, 'actionsModal');
+ this.actionsModal.close();
}
private handleDeleteConfirm() {
@@ -1740,7 +1698,7 @@ export class GrChangeActions
// We need to make sure that all cached version of a change
// edit are deleted.
- this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+ this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
this.fireAction(
'/edit',
@@ -1769,7 +1727,9 @@ export class GrChangeActions
}
// private but used in test
- setLoadingOnButtonWithKey(type: string, key: string) {
+ setLoadingOnButtonWithKey(action: UIActionInfo) {
+ const key = action.__key;
+ this.inProgressActionKeys.add(key);
this.actionLoadingMessage = this.computeLoadingLabel(key);
let buttonKey = key;
// TODO(dhruvsri): clean this up later
@@ -1780,12 +1740,14 @@ export class GrChangeActions
}
// If the action appears in the overflow menu.
- if (this.getActionOverflowIndex(type, buttonKey) !== -1) {
+ if (this.getActionOverflowIndex(action.__type, buttonKey) !== -1) {
this.disabledMenuActions.push(buttonKey === '/' ? 'delete' : buttonKey);
this.requestUpdate('disabledMenuActions');
return () => {
+ this.inProgressActionKeys.delete(key);
this.actionLoadingMessage = '';
this.disabledMenuActions = [];
+ this.requestUpdate();
};
}
@@ -1799,9 +1761,11 @@ export class GrChangeActions
buttonEl.setAttribute('loading', 'true');
buttonEl.disabled = true;
return () => {
+ this.inProgressActionKeys.delete(action.__key);
this.actionLoadingMessage = '';
buttonEl.removeAttribute('loading');
buttonEl.disabled = false;
+ this.requestUpdate();
};
}
@@ -1813,10 +1777,7 @@ export class GrChangeActions
payload?: RequestPayload,
toReport?: Object
) {
- const cleanupFn = this.setLoadingOnButtonWithKey(
- action.__type,
- action.__key
- );
+ const cleanupFn = this.setLoadingOnButtonWithKey(action);
this.reporting.reportInteraction(Interaction.CHANGE_ACTION_FIRED, {
endpoint,
toReport,
@@ -1837,8 +1798,9 @@ export class GrChangeActions
this.hideAllDialogs();
if (dialog.init) dialog.init();
dialog.hidden = false;
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.open().then(() => {
+ assertIsDefined(this.actionsModal, 'actionsModal');
+ this.actionsModal.showModal();
+ whenVisible(dialog, () => {
if (dialog.resetFocus) {
dialog.resetFocus();
}
@@ -1849,7 +1811,9 @@ export class GrChangeActions
// https://issues.gerritcodereview.com/issues/40004936 is resolved.
// private but used in test
setReviewOnRevert(newChangeId: NumericChangeId) {
- const review = this.jsAPI.getReviewPostRevert(this.change);
+ const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
+ this.change
+ );
if (!review) {
return Promise.resolve(undefined);
}
@@ -1861,6 +1825,7 @@ export class GrChangeActions
if (!response) {
return;
}
+ // response is guaranteed to be ok (due to semantics of rest-api methods)
return this.restApiService.getResponseObject(response).then(obj => {
switch (action.__key) {
case ChangeActions.REVERT: {
@@ -1894,7 +1859,10 @@ export class GrChangeActions
case ChangeActions.REBASE_EDIT:
case ChangeActions.REBASE:
case ChangeActions.SUBMIT:
- fireReload(this, true);
+ // Hide rebase dialog only if the action succeeds
+ this.actionsModal?.close();
+ this.hideAllDialogs();
+ this.getChangeModel().navigateToChangeResetReload();
break;
case ChangeActions.REVERT_SUBMISSION: {
const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
@@ -1906,12 +1874,11 @@ export class GrChangeActions
/* If there is only 1 change then gerrit will automatically
redirect to that change */
const topic = revertSubmistionInfo.revert_changes[0].topic;
- const query = `topic:${topic}`;
- if (topic) this.getNavigation().setUrl(createSearchUrl({query}));
+ this.getNavigation().setUrl(createSearchUrl({topic}));
break;
}
default:
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
break;
}
});
@@ -1925,13 +1892,7 @@ export class GrChangeActions
) {
if (!response) {
return Promise.resolve(() => {
- this.dispatchEvent(
- new CustomEvent('show-error', {
- detail: {message: `Could not perform action '${action.__key}'`},
- composed: true,
- bubbles: true,
- })
- );
+ fireError(this, `Could not perform action '${action.__key}'`);
});
}
if (action && action.__key === RevisionActions.CHERRYPICK) {
@@ -1949,13 +1910,7 @@ export class GrChangeActions
}
}
return response.text().then(errText => {
- this.dispatchEvent(
- new CustomEvent('show-error', {
- detail: {message: `Could not perform action: ${errText}`},
- composed: true,
- bubbles: true,
- })
- );
+ fireError(this, `Could not perform action: ${errText}`);
if (!errText.startsWith('Change is already up to date')) {
throw Error(errText);
}
@@ -1986,19 +1941,13 @@ export class GrChangeActions
.fetchChangeUpdates(change)
.then(result => {
if (!result.isLatest) {
- this.dispatchEvent(
- new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
- detail: {
- message:
- 'Cannot set label: a newer patch has been ' +
- 'uploaded to this change.',
- action: 'Reload',
- callback: () => fireReload(this, true),
- },
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'show-alert', {
+ message:
+ 'Cannot set label: a newer patch has been ' +
+ 'uploaded to this change.',
+ action: 'Reload',
+ callback: () => this.getChangeModel().navigateToChangeResetReload(),
+ });
// Because this is not a network error, call the cleanup function
// but not the error handler.
@@ -2060,12 +2009,12 @@ export class GrChangeActions
// private but used in test
handleDownloadTap() {
- fireEvent(this, 'download-tap');
+ fire(this, 'download-tap', {});
}
// private but used in test
handleIncludedInTap() {
- fireEvent(this, 'included-tap');
+ fire(this, 'included-tap', {});
}
// private but used in test
@@ -2097,7 +2046,7 @@ export class GrChangeActions
// We need to make sure that all cached version of a change
// edit are deleted.
- this.storage.eraseEditableContentItemsForChangeEdit(this.changeNum);
+ this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
this.fireAction(
'/edit:publish',
@@ -2118,18 +2067,6 @@ export class GrChangeActions
);
}
- // private but used in test
- handleHideBackgroundContent() {
- assertIsDefined(this.mainContent, 'mainContent');
- this.mainContent.classList.add('overlayOpen');
- }
-
- // private but used in test
- handleShowBackgroundContent() {
- assertIsDefined(this.mainContent, 'mainContent');
- this.mainContent.classList.remove('overlayOpen');
- }
-
/**
* Merge sources of change actions into a single ordered array of action
* values.
@@ -2266,17 +2203,21 @@ export class GrChangeActions
}
private handleEditTap() {
- this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+ fireNoBubbleNoCompose(this, 'edit-tap', {});
}
private handleStopEditTap() {
- this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+ fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
}
}
declare global {
interface HTMLElementEventMap {
+ 'download-tap': CustomEvent<{}>;
+ 'edit-tap': CustomEvent<{}>;
+ 'included-tap': CustomEvent<{}>;
'revision-actions-changed': CustomEvent<{value: ActionNameToActionInfoMap}>;
+ 'stop-edit-tap': CustomEvent<{}>;
}
interface HTMLElementTagNameMap {
'gr-change-actions': GrChangeActions;
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 7c04f7df76..946191b6a9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -6,7 +6,6 @@
import '../../../test/common-test-setup';
import './gr-change-actions';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {
createAccountWithId,
createApproval,
@@ -22,7 +21,6 @@ import {
query,
queryAll,
queryAndAssert,
- spyStorage,
stubReporting,
stubRestApi,
} from '../../../test/test-utils';
@@ -42,27 +40,33 @@ import {
TopicName,
} from '../../../types/common';
import {ActionType} from '../../../api/change-actions';
-import {SinonFakeTimers} from 'sinon';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {getAppContext} from '../../../services/app-context';
import {fixture, html, assert} from '@open-wc/testing';
import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
import {GrConfirmRebaseDialog} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
-import {EventType} from '../../../types/events';
import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+ ChangeModel,
+ changeModelToken,
+} from '../../../models/change/change-model';
// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
suite('gr-change-actions tests', () => {
let element: GrChangeActions;
+ let navigateResetStub: SinonStubbedMember<
+ ChangeModel['navigateToChangeResetReload']
+ >;
suite('basic tests', () => {
setup(async () => {
@@ -119,7 +123,7 @@ suite('gr-change-actions tests', () => {
});
sinon
- .stub(getPluginLoader(), 'awaitPluginsLoaded')
+ .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
.returns(Promise.resolve());
element = await fixture<GrChangeActions>(html`
@@ -142,6 +146,10 @@ suite('gr-change-actions tests', () => {
_account_id: 123 as AccountId,
};
stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+ navigateResetStub = sinon.stub(
+ testResolver(changeModelToken),
+ 'navigateToChangeResetReload'
+ );
await element.updateComplete;
await element.reload();
@@ -180,14 +188,13 @@ suite('gr-change-actions tests', () => {
title="Rebase onto tip of branch or parent change"
>
<gr-button
- aria-disabled="true"
+ aria-disabled="false"
class="rebase"
data-action-key="rebase"
data-label="Rebase"
- disabled=""
link=""
role="button"
- tabindex="-1"
+ tabindex="0"
>
<gr-icon icon="rebase"> </gr-icon>
Rebase
@@ -207,13 +214,7 @@ suite('gr-change-actions tests', () => {
<span id="moreMessage"> More </span>
</gr-dropdown>
</div>
- <gr-overlay
- aria-hidden="true"
- id="overlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="actionsModal" tabindex="-1">
<gr-confirm-rebase-dialog class="confirmDialog" id="confirmRebase">
</gr-confirm-rebase-dialog>
<gr-confirm-cherrypick-dialog
@@ -279,7 +280,7 @@ suite('gr-change-actions tests', () => {
Do you really want to delete the edit?
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -479,9 +480,6 @@ suite('gr-change-actions tests', () => {
stubRestApi('getFromProjectLookup').returns(
Promise.resolve('test' as RepoName)
);
- sinon
- .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
- .returns(Promise.resolve());
element.change = {
...createChangeViewChange(),
revisions: {
@@ -518,9 +516,6 @@ suite('gr-change-actions tests', () => {
stubRestApi('getFromProjectLookup').returns(
Promise.resolve('test' as RepoName)
);
- sinon
- .stub(queryAndAssert<GrOverlay>(element, '#overlay'), 'open')
- .returns(Promise.resolve());
element.change = {
...createChangeViewChange(),
revisions: {
@@ -585,34 +580,6 @@ suite('gr-change-actions tests', () => {
assert.equal(fireActionStub.callCount, 0);
});
- test('chain state', async () => {
- assert.equal(element._hasKnownChainState, false);
- element.hasParent = true;
- await element.updateComplete;
- assert.equal(element._hasKnownChainState, true);
- });
-
- test('calculateDisabled', () => {
- const action = {
- __key: 'rebase',
- enabled: true,
- __type: ActionType.CHANGE,
- label: 'l',
- };
- element._hasKnownChainState = false;
- assert.equal(element.calculateDisabled(action), true);
-
- action.__key = 'delete';
- assert.equal(element.calculateDisabled(action), false);
-
- action.__key = 'rebase';
- element._hasKnownChainState = true;
- assert.equal(element.calculateDisabled(action), false);
-
- action.enabled = false;
- assert.equal(element.calculateDisabled(action), false);
- });
-
test('rebase change', async () => {
const fireActionStub = sinon.stub(element, 'fireAction');
const fetchChangesStub = sinon
@@ -621,7 +588,6 @@ suite('gr-change-actions tests', () => {
'fetchRecentChanges'
)
.returns(Promise.resolve([]));
- element._hasKnownChainState = true;
await element.updateComplete;
queryAndAssert<GrButton>(
element,
@@ -638,25 +604,30 @@ suite('gr-change-actions tests', () => {
};
assert.isTrue(fetchChangesStub.called);
element.handleRebaseConfirm(
- new CustomEvent('', {detail: {base: '1234', allowConflicts: false}})
+ new CustomEvent('', {
+ detail: {
+ base: '1234',
+ allowConflicts: false,
+ rebaseChain: false,
+ onBehalfOfUploader: true,
+ },
+ })
);
assert.deepEqual(fireActionStub.lastCall.args, [
'/rebase',
assertUIActionInfo(rebaseAction),
true,
- {base: '1234', allow_conflicts: false},
- {allow_conflicts: false},
+ {base: '1234', allow_conflicts: false, on_behalf_of_uploader: true},
+ {allow_conflicts: false, on_behalf_of_uploader: true},
]);
});
test('rebase change fires reload event', async () => {
- const eventStub = sinon.stub(element, 'dispatchEvent');
await element.handleResponse(
{__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
new Response()
);
- assert.isTrue(eventStub.called);
- assert.equal(eventStub.lastCall.args[0].type, 'reload');
+ assert.isTrue(navigateResetStub.called);
});
test("rebase dialog gets recent changes each time it's opened", async () => {
@@ -666,7 +637,6 @@ suite('gr-change-actions tests', () => {
'fetchRecentChanges'
)
.returns(Promise.resolve([]));
- element._hasKnownChainState = true;
await element.updateComplete;
const rebaseButton = queryAndAssert<GrButton>(
element,
@@ -691,7 +661,6 @@ suite('gr-change-actions tests', () => {
});
test('two dialogs are not shown at the same time', async () => {
- element._hasKnownChainState = true;
await element.updateComplete;
queryAndAssert<GrButton>(
element,
@@ -713,42 +682,15 @@ suite('gr-change-actions tests', () => {
);
});
- test('fullscreen-overlay-opened hides content', () => {
- const spy = sinon.spy(element, 'handleHideBackgroundContent');
- queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
- new CustomEvent('fullscreen-overlay-opened', {
- composed: true,
- bubbles: true,
- })
- );
- assert.isTrue(spy.called);
- assert.isTrue(
- queryAndAssert<Element>(element, '#mainContent').classList.contains(
- 'overlayOpen'
- )
- );
- });
-
- test('fullscreen-overlay-closed shows content', () => {
- const spy = sinon.spy(element, 'handleShowBackgroundContent');
- queryAndAssert<GrOverlay>(element, '#overlay').dispatchEvent(
- new CustomEvent('fullscreen-overlay-closed', {
- composed: true,
- bubbles: true,
- })
- );
- assert.isTrue(spy.called);
- assert.isFalse(
- queryAndAssert<Element>(element, '#mainContent').classList.contains(
- 'overlayOpen'
- )
- );
- });
-
test('setReviewOnRevert', () => {
const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
const changeId = 1234 as NumericChangeId;
- sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
+ sinon
+ .stub(
+ testResolver(pluginLoaderToken).jsApiService,
+ 'getReviewPostRevert'
+ )
+ .returns(review);
const saveStub = stubRestApi('saveChangeReview').returns(
Promise.resolve(new Response())
);
@@ -812,7 +754,7 @@ suite('gr-change-actions tests', () => {
element.editPatchsetLoaded = true;
await element.updateComplete;
- const storage = getAppContext().storageService;
+ const storage = testResolver(storageServiceToken);
storage.setEditableContentItem(
'c42_ps2_index.php',
'<?php\necho 42_ps_2'
@@ -835,7 +777,8 @@ suite('gr-change-actions tests', () => {
assert.isOk(storage.getEditableContentItem('c42_ps2_index.php')!);
assert.isNotOk(storage.getEditableContentItem('c50_psedit_index.php')!);
- const eraseEditableContentItemsForChangeEditSpy = spyStorage(
+ const eraseEditableContentItemsForChangeEditSpy = sinon.spy(
+ storage,
'eraseEditableContentItemsForChangeEdit'
);
sinon.stub(element, 'fireAction');
@@ -1308,18 +1251,28 @@ suite('gr-change-actions tests', () => {
await keyTapped;
});
- test('setLoadingOnButtonWithKey top-level', () => {
+ test('setLoadingOnButtonWithKey top-level', async () => {
const key = 'rebase';
- const type = 'revision';
- const cleanup = element.setLoadingOnButtonWithKey(type, key);
+ const type = ActionType.REVISION;
+ const cleanup = element.setLoadingOnButtonWithKey({
+ __type: type,
+ __key: key,
+ label: 'label',
+ });
assert.equal(element.actionLoadingMessage, 'Rebasing...');
const button = queryAndAssert<GrButton>(
element,
'[data-action-key="' + key + '"]'
);
+ const dialog = queryAndAssert<GrConfirmRebaseDialog>(
+ element,
+ 'gr-confirm-rebase-dialog'
+ );
assert.isTrue(button.hasAttribute('loading'));
assert.isTrue(button.disabled);
+ await dialog.updateComplete;
+ assert.isTrue(dialog.disableActions);
assert.isOk(cleanup);
assert.isFunction(cleanup);
@@ -1328,12 +1281,18 @@ suite('gr-change-actions tests', () => {
assert.isFalse(button.hasAttribute('loading'));
assert.isFalse(button.disabled);
assert.isNotOk(element.actionLoadingMessage);
+ await dialog.updateComplete;
+ assert.isFalse(dialog.disableActions);
});
test('setLoadingOnButtonWithKey overflow menu', () => {
const key = 'cherrypick';
- const type = 'revision';
- const cleanup = element.setLoadingOnButtonWithKey(type, key);
+ const type = ActionType.REVISION;
+ const cleanup = element.setLoadingOnButtonWithKey({
+ __type: type,
+ __key: key,
+ label: 'label',
+ });
assert.equal(element.actionLoadingMessage, 'Cherry-picking...');
assert.include(element.disabledMenuActions, 'cherrypick');
assert.isFunction(cleanup);
@@ -1461,6 +1420,39 @@ suite('gr-change-actions tests', () => {
await element.reload();
});
+ test('revert change payload', async () => {
+ await element.updateComplete;
+ queryAndAssert<GrButton>(
+ element,
+ 'gr-button[data-action-key="revert"]'
+ ).click();
+ const revertAction = {
+ __key: 'revert',
+ __type: 'change',
+ __primary: false,
+ method: HttpMethod.POST,
+ label: 'Revert',
+ title: 'Revert the change',
+ enabled: true,
+ };
+ queryAndAssert(element, 'gr-confirm-revert-dialog').dispatchEvent(
+ new CustomEvent('confirm-revert', {
+ detail: {
+ message: 'foo message',
+ revertType: 1,
+ },
+ })
+ );
+ assert.deepEqual(fireActionStub.lastCall.args, [
+ '/revert',
+ assertUIActionInfo(revertAction),
+ false,
+ {
+ message: 'foo message',
+ },
+ ]);
+ });
+
test('revert change with plugin hook', async () => {
const newRevertMsg = 'Modified revert msg';
sinon
@@ -1588,13 +1580,8 @@ suite('gr-change-actions tests', () => {
'Revert submission 199 0' +
'\n\n' +
'Reason for revert: <INSERT REASONING HERE>' +
- '\n' +
- 'Reverted Changes:' +
- '\n' +
- '1234567890:random' +
- '\n' +
- '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
- '\n';
+ '\n\n' +
+ 'Reverted changes: /q/submissionid:199+0\n';
assert.equal(confirmRevertDialog.message, expectedMsg);
const radioInputs = queryAll<HTMLInputElement>(
confirmRevertDialog,
@@ -1654,13 +1641,8 @@ suite('gr-change-actions tests', () => {
'Revert submission 199 0' +
'\n\n' +
'Reason for revert: <INSERT REASONING HERE>' +
- '\n' +
- 'Reverted Changes:' +
- '\n' +
- '1234567890:random' +
- '\n' +
- '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
- '\n';
+ '\n\n' +
+ 'Reverted changes: /q/submissionid:199+0\n';
const singleChangeMsg =
'Revert "random commit message"\n\nThis reverts ' +
'commit 2000.\n\nReason' +
@@ -2438,7 +2420,7 @@ suite('gr-change-actions tests', () => {
onShowError = sinon.stub();
element.addEventListener('show-error', onShowError);
onShowAlert = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, onShowAlert);
+ element.addEventListener('show-alert', onShowAlert);
});
suite('happy path', () => {
@@ -2533,7 +2515,7 @@ suite('gr-change-actions tests', () => {
new Response()
);
assert.isTrue(setUrlStub.called);
- assert.equal(setUrlStub.lastCall.args[0], '/q/topic:T');
+ assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
});
});
@@ -2572,7 +2554,7 @@ suite('gr-change-actions tests', () => {
);
assert.isFalse(showActionDialogStub.called);
assert.isTrue(setUrlStub.called);
- assert.equal(setUrlStub.lastCall.args[0], '/q/topic:T');
+ assert.equal(setUrlStub.lastCall.args[0], '/q/topic:"T"');
});
});
@@ -2684,7 +2666,7 @@ suite('gr-change-actions tests', () => {
stubRestApi('send').returns(Promise.reject(new Error('error')));
sinon
- .stub(getPluginLoader(), 'awaitPluginsLoaded')
+ .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
.returns(Promise.resolve());
element = await fixture<GrChangeActions>(html`
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index 228e7ce5d4..b7851a6ba5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -9,7 +9,6 @@ import '../../../styles/gr-change-metadata-shared-styles';
import '../../../styles/gr-change-view-integration-shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import '../../plugins/gr-external-style/gr-external-style';
import '../../shared/gr-account-chip/gr-account-chip';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-editable-label/gr-editable-label';
@@ -17,6 +16,7 @@ import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-limited-text/gr-limited-text';
import '../../shared/gr-linked-chip/gr-linked-chip';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-weblink/gr-weblink';
import '../gr-submit-requirements/gr-submit-requirements';
import '../gr-commit-info/gr-commit-info';
import '../gr-reviewer-list/gr-reviewer-list';
@@ -48,6 +48,7 @@ import {
RepoName,
RevisionInfo,
ServerInfo,
+ WebLinkInfo,
} from '../../../types/common';
import {assertIsDefined, assertNever, unique} from '../../../utils/common-util';
import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
@@ -58,10 +59,10 @@ import {
isSectionSet,
DisplayRules,
} from '../../../utils/change-metadata-util';
-import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
+import {fireAlert, fire, fireReload} from '../../../utils/event-util';
import {
EditRevisionInfo,
- notUndefined,
+ isDefined,
ParsedChangeInfo,
} from '../../../types/types';
import {
@@ -77,10 +78,10 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {fontStyles} from '../../../styles/gr-font-styles';
import {changeMetadataStyles} from '../../../styles/gr-change-metadata-shared-styles';
import {when} from 'lit/directives/when.js';
-import {ifDefined} from 'lit/directives/if-defined.js';
import {createSearchUrl} from '../../../models/views/search';
import {createChangeUrl} from '../../../models/views/change';
-import {GeneratedWebLink, getChangeWeblinks} from '../../../utils/weblink-util';
+import {getChangeWeblinks} from '../../../utils/weblink-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
@@ -125,6 +126,7 @@ export class GrChangeMetadata extends LitElement {
@property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
+ // TODO: Just use `revision.commit` instead.
@property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
@property({type: Object}) serverConfig?: ServerInfo;
@@ -182,7 +184,7 @@ export class GrChangeMetadata extends LitElement {
gr-editable-label {
max-width: 9em;
}
- .webLink {
+ gr-weblink {
display: block;
}
gr-account-chip[disabled],
@@ -279,7 +281,7 @@ export class GrChangeMetadata extends LitElement {
${this.renderNonOwner(ChangeRole.AUTHOR)}
${this.renderNonOwner(ChangeRole.COMMITTER)} ${this.renderReviewers()}
${this.renderCCs()} ${this.renderProjectBranch()} ${this.renderParent()}
- ${this.renderMergedAs()} ${this.renderShowReverCreatedAs()}
+ ${this.renderMergedAs()} ${this.renderShowRevertCreatedAs()}
${this.renderTopic()} ${this.renderCherryPickOf()}
${this.renderStrategy()} ${this.renderHashTags()}
${this.renderSubmitRequirements()} ${this.renderWeblinks()}
@@ -462,7 +464,14 @@ export class GrChangeMetadata extends LitElement {
this.computeShowRepoBranchTogether(),
() =>
html`<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
- <span class="title">Repo | Branch</span>
+ <span class="title">
+ <gr-tooltip-content
+ has-tooltip
+ title="Repository and branch that the change will be merged into if submitted."
+ >
+ Repo | Branch
+ </gr-tooltip-content>
+ </span>
<span class="value">
<a href=${this.computeProjectUrl(change.project)}
>${change.project}</a
@@ -474,10 +483,17 @@ export class GrChangeMetadata extends LitElement {
</span>
</section>`,
- () => html` <section
+ () => html`<section
class=${this.computeDisplayState(Metadata.REPO_BRANCH)}
>
- <span class="title">Repo</span>
+ <span class="title">
+ <gr-tooltip-content
+ has-tooltip
+ title="Repository that the change will be merged into if submitted."
+ >
+ Repo
+ </gr-tooltip-content>
+ </span>
<span class="value">
<a href=${this.computeProjectUrl(change.project)}>
<gr-limited-text
@@ -488,7 +504,14 @@ export class GrChangeMetadata extends LitElement {
</span>
</section>
<section class=${this.computeDisplayState(Metadata.REPO_BRANCH)}>
- <span class="title">Branch</span>
+ <span class="title">
+ <gr-tooltip-content
+ has-tooltip
+ title="Branch that the change will be merged into if submitted."
+ >
+ Branch
+ </gr-tooltip-content>
+ </span>
<span class="value">
<a href=${this.computeBranchUrl(change.project, change.branch)}>
<gr-limited-text
@@ -540,7 +563,7 @@ export class GrChangeMetadata extends LitElement {
</section>`;
}
- private renderShowReverCreatedAs() {
+ private renderShowRevertCreatedAs() {
if (!this.showRevertCreatedAs()) return nothing;
return html`<section
@@ -682,16 +705,7 @@ export class GrChangeMetadata extends LitElement {
return html`<section id="webLinks">
<span class="title">Links</span>
<span class="value">
- ${webLinks.map(
- link => html`<a
- href=${ifDefined(link.url)}
- class="webLink"
- rel="noopener"
- target="_blank"
- >
- ${link.name}
- </a>`
- )}
+ ${webLinks.map(info => html`<gr-weblink .info=${info}></gr-weblink>`)}
</span>
</section>`;
}
@@ -720,7 +734,7 @@ export class GrChangeMetadata extends LitElement {
}
// private but used in test
- computeWebLinks(): GeneratedWebLink[] {
+ computeWebLinks(): WebLinkInfo[] {
return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
}
@@ -748,7 +762,7 @@ export class GrChangeMetadata extends LitElement {
} finally {
this.settingTopic = false;
}
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
fireReload(this);
}
@@ -781,7 +795,7 @@ export class GrChangeMetadata extends LitElement {
await this.restApiService.setChangeHashtag(this.change._number, {
add: [newHashtag as Hashtag],
});
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
fireReload(this);
}
@@ -891,14 +905,14 @@ export class GrChangeMetadata extends LitElement {
private computeProjectUrl(project?: RepoName) {
if (!project) return '';
- return createSearchUrl({project});
+ return createSearchUrl({repo: project});
}
private computeBranchUrl(project?: RepoName, branch?: BranchName) {
if (!project || !branch || !this.change || !this.change.status) return '';
return createSearchUrl({
branch,
- project,
+ repo: project,
statuses:
this.change.status === ChangeStatus.NEW
? ['open']
@@ -916,7 +930,7 @@ export class GrChangeMetadata extends LitElement {
}
return createChangeUrl({
changeNum: change,
- project,
+ repo: project,
usp: 'metadata',
patchNum: patchset,
});
@@ -926,7 +940,7 @@ export class GrChangeMetadata extends LitElement {
return createSearchUrl({hashtag, statuses: ['open', 'merged']});
}
- private async handleTopicRemoved(e: CustomEvent) {
+ private async handleTopicRemoved(e: Event) {
assertIsDefined(this.change, 'change');
const target = e.composedPath()[0] as GrLinkedChip;
target.disabled = true;
@@ -936,12 +950,12 @@ export class GrChangeMetadata extends LitElement {
} finally {
target.disabled = false;
}
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
fireReload(this);
}
// private but used in test
- async handleHashtagRemoved(e: CustomEvent) {
+ async handleHashtagRemoved(e: Event) {
e.preventDefault();
assertIsDefined(this.change, 'change');
const target = e.target as GrLinkedChip;
@@ -954,7 +968,7 @@ export class GrChangeMetadata extends LitElement {
} finally {
target.disabled = false;
}
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
fireReload(this);
}
@@ -1120,11 +1134,11 @@ export class GrChangeMetadata extends LitElement {
input: string
): Promise<AutocompleteSuggestion[]> {
return this.restApiService
- .getChangesWithSimilarTopic(input)
+ .getChangesWithSimilarTopic(input, throwingErrorCallback)
.then(response =>
(response ?? [])
.map(change => change.topic)
- .filter(notUndefined)
+ .filter(isDefined)
.filter(unique)
.map(topic => {
return {name: topic, value: topic};
@@ -1136,11 +1150,11 @@ export class GrChangeMetadata extends LitElement {
input: string
): Promise<AutocompleteSuggestion[]> {
return this.restApiService
- .getChangesWithSimilarHashtag(input)
+ .getChangesWithSimilarHashtag(input, throwingErrorCallback)
.then(response =>
(response ?? [])
.flatMap(change => change.hashtags ?? [])
- .filter(notUndefined)
+ .filter(isDefined)
.filter(unique)
.map(hashtag => {
return {name: hashtag, value: hashtag};
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index 038c34aa06..c46fe2495e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -5,7 +5,7 @@
*/
import '../../../test/common-test-setup';
import './gr-change-metadata';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
import {
createServerInfo,
@@ -46,7 +46,6 @@ import {PluginApi} from '../../../api/plugin';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import {
queryAndAssert,
- resetPlugins,
stubRestApi,
waitUntilCalled,
} from '../../../test/test-utils';
@@ -55,7 +54,8 @@ import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
import {GrButton} from '../../shared/gr-button/gr-button';
import {nothing} from 'lit';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
suite('gr-change-metadata tests', () => {
let element: GrChangeMetadata;
@@ -164,7 +164,12 @@ suite('gr-change-metadata tests', () => {
</section>
<section>
<span class="title">
- Repo | Branch
+ <gr-tooltip-content
+ has-tooltip=""
+ title="Repository and branch that the change will be merged into if submitted."
+ >
+ Repo | Branch
+ </gr-tooltip-content>
</span>
<span class="value">
<a href="/q/project:test-project">
@@ -865,7 +870,7 @@ suite('gr-change-metadata tests', () => {
Promise.resolve(newTopic)
);
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
element.handleTopicChanged(new CustomEvent('test', {detail: newTopic}));
@@ -886,7 +891,7 @@ suite('gr-change-metadata tests', () => {
Promise.resolve(newTopic)
);
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
await element.updateComplete;
const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
const remove = queryAndAssert<GrButton>(chip, '#remove');
@@ -910,7 +915,7 @@ suite('gr-change-metadata tests', () => {
Promise.resolve(newHashtag)
);
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
element.handleHashtagChanged(
new CustomEvent('test', {detail: 'new hashtag'})
);
@@ -949,17 +954,12 @@ suite('gr-change-metadata tests', () => {
suite('plugin endpoints', () => {
setup(async () => {
- resetPlugins();
element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
element.change = createParsedChange();
element.revision = createRevision();
await element.updateComplete;
});
- teardown(() => {
- resetPlugins();
- });
-
test('endpoint params', async () => {
interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
plugin: PluginApi;
@@ -978,7 +978,7 @@ suite('gr-change-metadata tests', () => {
const hookEl = (await plugin!
.hook('change-metadata-item')
.getLastAttached()) as MetadataGrEndpointDecorator;
- getPluginLoader().loadPlugins([]);
+ testResolver(pluginLoaderToken).loadPlugins([]);
await element.updateComplete;
assert.strictEqual(hookEl.plugin, plugin!);
assert.strictEqual(hookEl.change, element.change);
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
index 73653bba4e..f6a40a2575 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary.ts
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import './gr-checks-chip';
-import './gr-summary-chip';
-import '../../shared/gr-avatar/gr-avatar-stack';
+import '../gr-comments-summary/gr-comments-summary';
import '../../shared/gr-icon/gr-icon';
import '../../checks/gr-checks-action';
import {LitElement, css, html, nothing} from 'lit';
@@ -29,34 +28,21 @@ import {
isRunningOrScheduled,
isRunningScheduledOrCompleted,
} from '../../../models/checks/checks-util';
-import {
- CommentThread,
- getFirstComment,
- getMentionedThreads,
- hasHumanReply,
- isResolved,
- isRobotThread,
- isUnresolved,
-} from '../../../utils/comment-util';
-import {pluralize} from '../../../utils/string-util';
-import {AccountInfo} from '../../../types/common';
-import {notUndefined} from '../../../types/types';
+import {getMentionedThreads, isUnresolved} from '../../../utils/comment-util';
+import {AccountInfo, CommentThread, DropdownLink} from '../../../types/common';
import {Tab} from '../../../constants/constants';
-import {ChecksTabState, CommentTabState} from '../../../types/events';
+import {ChecksTabState} from '../../../types/events';
import {spinnerStyles} from '../../../styles/gr-spinner-styles';
import {modifierPressed} from '../../../utils/dom-util';
-import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {resolve} from '../../../models/dependency';
import {checksModelToken} from '../../../models/checks/checks-model';
import {changeModelToken} from '../../../models/change/change-model';
import {Interaction} from '../../../constants/reporting';
import {roleDetails} from '../../../utils/change-util';
-
-import {SummaryChipStyles} from './gr-summary-chip';
import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {combineLatest} from 'rxjs';
+import {userModelToken} from '../../../models/user/user-model';
function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
if (modifierPressed(e)) return;
@@ -70,11 +56,16 @@ function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
DETAILS_QUOTA.set(Category.ERROR, 7);
DETAILS_QUOTA.set(Category.WARNING, 2);
+DETAILS_QUOTA.set(Category.INFO, 2);
+DETAILS_QUOTA.set(Category.SUCCESS, 2);
DETAILS_QUOTA.set(RunStatus.RUNNING, 2);
@customElement('gr-change-summary')
export class GrChangeSummary extends LitElement {
@state()
+ commentsLoading = true;
+
+ @state()
commentThreads?: CommentThread[];
@state()
@@ -109,11 +100,9 @@ export class GrChangeSummary extends LitElement {
private readonly showAllChips = new Map<RunStatus | Category, boolean>();
- // private but used in tests
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
- // private but used in tests
- readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getChecksModel = resolve(this, checksModelToken);
@@ -121,8 +110,6 @@ export class GrChangeSummary extends LitElement {
private readonly reporting = getAppContext().reportingService;
- private readonly flagsService = getAppContext().flagsService;
-
constructor() {
super();
subscribe(
@@ -167,32 +154,35 @@ export class GrChangeSummary extends LitElement {
);
subscribe(
this,
- () => this.getCommentsModel().threads$,
+ () => this.getCommentsModel().threadsSaved$,
x => (this.commentThreads = x)
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
x => (this.selfAccount = x)
);
- if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- subscribe(
- this,
- () =>
- combineLatest([
- this.userModel.account$,
- this.getCommentsModel().threads$,
- ]),
- ([selfAccount, threads]) => {
- if (!selfAccount || !selfAccount.email) return;
- const unresolvedThreadsMentioningSelf = getMentionedThreads(
- threads,
- selfAccount
- ).filter(isUnresolved);
- this.mentionCount = unresolvedThreadsMentioningSelf.length;
- }
- );
- }
+ subscribe(
+ this,
+ () => this.getCommentsModel().commentsLoading$,
+ x => (this.commentsLoading = x)
+ );
+ subscribe(
+ this,
+ () =>
+ combineLatest([
+ this.getUserModel().account$,
+ this.getCommentsModel().threadsSaved$,
+ ]),
+ ([selfAccount, threads]) => {
+ if (!selfAccount || !selfAccount.email) return;
+ const unresolvedThreadsMentioningSelf = getMentionedThreads(
+ threads,
+ selfAccount
+ ).filter(isUnresolved);
+ this.mentionCount = unresolvedThreadsMentioningSelf.length;
+ }
+ );
}
static override get styles() {
@@ -270,14 +260,6 @@ export class GrChangeSummary extends LitElement {
padding-bottom: var(--spacing-s);
line-height: calc(var(--line-height-normal) + var(--spacing-s));
}
- gr-avatar-stack {
- --avatar-size: var(--line-height-small, 16px);
- --stack-border-color: var(--warning-background);
- }
- .unresolvedIcon {
- font-size: var(--line-height-small);
- color: var(--warning-foreground);
- }
/* The basics of .loadingSpin are defined in shared styles. */
.loadingSpin {
width: calc(var(--line-height-normal) - 2px);
@@ -423,8 +405,27 @@ export class GrChangeSummary extends LitElement {
if (hasResultsOf(run, category)) return true;
return category === Category.SUCCESS && hasCompletedWithoutResults(run);
});
+ const hasRunning = this.runs.some(isRunningOrScheduled);
+ const hasWarning = this.runs.some(run =>
+ hasResultsOf(run, Category.WARNING)
+ );
+ const hasError = this.runs.some(run => hasResultsOf(run, Category.ERROR));
const count = (run: CheckRun) => getResultsOf(run, category);
- if (category === Category.SUCCESS || category === Category.INFO) {
+
+ // Sometimes INFO and SUCCESS results should not consume much UI space and
+ // not grab any attention, e.g. when there are errors. Then let's
+ // aggressively collapse them into one small chip. But if INFO and SUCCESS
+ // is all we have, then make use of the one line we have and show expanded
+ // chips.
+ if (
+ category === Category.SUCCESS &&
+ (hasRunning || hasError || hasWarning || runs.length > 3)
+ ) {
+ return this.renderChecksChipsCollapsed(runs, category, count);
+ } else if (
+ category === Category.INFO &&
+ (hasRunning || hasError || runs.length > 3)
+ ) {
return this.renderChecksChipsCollapsed(runs, category, count);
}
return this.renderChecksChipsExpanded(runs, category);
@@ -531,29 +532,23 @@ export class GrChangeSummary extends LitElement {
}
override render() {
- const commentThreads =
- this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
- [];
- const countResolvedComments = commentThreads.filter(isResolved).length;
- const unresolvedThreads = commentThreads.filter(isUnresolved);
- const countUnresolvedComments = unresolvedThreads.length;
- const unresolvedAuthors = this.getAccounts(unresolvedThreads);
return html`
<div>
<table>
<tr>
<td class="key">Comments</td>
<td class="value">
- ${this.renderZeroState(
- countResolvedComments,
- countUnresolvedComments
- )}
- ${this.renderDraftChip()} ${this.renderMentionChip()}
- ${this.renderUnresolvedCommentsChip(
- countUnresolvedComments,
- unresolvedAuthors
+ ${when(
+ this.commentsLoading,
+ () => html`<span class="loadingSpin"></span>`
)}
- ${this.renderResolvedCommentsChip(countResolvedComments)}
+ <gr-comments-summary
+ .commentThreads=${this.commentThreads}
+ .draftCount=${this.draftCount}
+ .mentionCount=${this.mentionCount}
+ showCommentCategoryName
+ clickableChips
+ ></gr-comments-summary>
</td>
</tr>
${this.renderChecksSummary()}
@@ -562,78 +557,6 @@ export class GrChangeSummary extends LitElement {
`;
}
- private renderZeroState(
- countResolvedComments: number,
- countUnresolvedComments: number
- ) {
- if (
- !!countResolvedComments ||
- !!this.draftCount ||
- !!countUnresolvedComments
- )
- return nothing;
- return html`<span class="zeroState"> No comments</span>`;
- }
-
- private renderMentionChip() {
- if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
- return nothing;
- if (!this.mentionCount) return nothing;
- return html` <gr-summary-chip
- class="mentionSummary"
- styleType=${SummaryChipStyles.WARNING}
- category=${CommentTabState.MENTIONS}
- icon="alternate_email"
- >
- ${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
- >`;
- }
-
- private renderDraftChip() {
- if (!this.draftCount) return nothing;
- return html` <gr-summary-chip
- styleType=${SummaryChipStyles.INFO}
- category=${CommentTabState.DRAFTS}
- icon="rate_review"
- iconFilled
- >
- ${pluralize(this.draftCount, 'draft')}</gr-summary-chip
- >`;
- }
-
- private renderUnresolvedCommentsChip(
- countUnresolvedComments: number,
- unresolvedAuthors: AccountInfo[]
- ) {
- if (!countUnresolvedComments) return nothing;
- return html` <gr-summary-chip
- styleType=${SummaryChipStyles.WARNING}
- category=${CommentTabState.UNRESOLVED}
- ?hidden=${!countUnresolvedComments}
- >
- <gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
- <gr-icon
- slot="fallback"
- icon="chat_bubble"
- filled
- class="unresolvedIcon"
- >
- </gr-icon>
- </gr-avatar-stack>
- ${countUnresolvedComments} unresolved</gr-summary-chip
- >`;
- }
-
- private renderResolvedCommentsChip(countResolvedComments: number) {
- if (!countResolvedComments) return nothing;
- return html` <gr-summary-chip
- styleType=${SummaryChipStyles.CHECK}
- category=${CommentTabState.SHOW_ALL}
- icon="mark_chat_read"
- >${countResolvedComments} resolved</gr-summary-chip
- >`;
- }
-
private renderChecksSummary() {
const hasNonRunningChip = this.runs.some(
run => hasCompletedWithoutResults(run) || hasResults(run)
@@ -665,13 +588,6 @@ export class GrChangeSummary extends LitElement {
</td>
</tr>`;
}
-
- getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
- return commentThreads
- .map(getFirstComment)
- .map(comment => comment?.author ?? this.selfAccount)
- .filter(notUndefined);
- }
}
declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
index 95846377ca..c3d9774a9d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_test.ts
@@ -6,21 +6,36 @@
import '../../../test/common-test-setup';
import {fixture, html, assert} from '@open-wc/testing';
import {GrChangeSummary} from './gr-change-summary';
-import {queryAndAssert} from '../../../utils/common-util';
+import {queryAll, queryAndAssert} from '../../../utils/common-util';
import {fakeRun0} from '../../../models/checks/checks-fakes';
import {
createAccountWithEmail,
+ createCheckResult,
createComment,
createCommentThread,
createDraft,
+ createRun,
} from '../../../test/test-data-generators';
-import {stubFlags} from '../../../test/test-utils';
import {Timestamp} from '../../../api/rest-api';
+import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+ CommentsModel,
+ commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {GrChecksChip} from './gr-checks-chip';
+import {CheckRun} from '../../../models/checks/checks-model';
+import {Category, RunStatus} from '../../../api/checks';
suite('gr-change-summary test', () => {
let element: GrChangeSummary;
+ let commentsModel: CommentsModel;
+ let userModel: UserModel;
+
setup(async () => {
element = await fixture(html`<gr-change-summary></gr-change-summary>`);
+ commentsModel = testResolver(commentsModelToken);
+ userModel = testResolver(userModelToken);
});
test('is defined', () => {
@@ -29,12 +44,13 @@ suite('gr-change-summary test', () => {
});
test('renders', async () => {
- element.getCommentsModel().setState({
+ commentsModel.setState({
drafts: {
a: [createDraft(), createDraft(), createDraft()],
},
discardedDrafts: [],
});
+ element.commentsLoading = false;
element.commentThreads = [
createCommentThread([createComment()]),
createCommentThread([{...createComment(), unresolved: true}]),
@@ -48,32 +64,10 @@ suite('gr-change-summary test', () => {
<tr>
<td class="key">Comments</td>
<td class="value">
- <gr-summary-chip
- category="drafts"
- icon="rate_review"
- iconFilled
- styletype="info"
- >
- 3 drafts
- </gr-summary-chip>
- <gr-summary-chip category="unresolved" styletype="warning">
- <gr-avatar-stack imageSize="32">
- <gr-icon
- class="unresolvedIcon"
- filled
- icon="chat_bubble"
- slot="fallback"
- ></gr-icon>
- </gr-avatar-stack>
- 1 unresolved
- </gr-summary-chip>
- <gr-summary-chip
- category="show all"
- icon="mark_chat_read"
- styletype="check"
- >
- 1 resolved
- </gr-summary-chip>
+ <gr-comments-summary
+ clickablechips=""
+ showcommentcategoryname=""
+ ></gr-comments-summary>
</td>
</tr>
</tbody>
@@ -106,13 +100,75 @@ suite('gr-change-summary test', () => {
);
});
- test('renders mentions summary', async () => {
- stubFlags('isEnabled').returns(true);
- // recreate element so that flag protected subscriptions are added
- element = await fixture(html`<gr-change-summary></gr-change-summary>`);
- await element.updateComplete;
+ suite('checks summary', () => {
+ const checkSummary = async (runs: CheckRun[], texts: string[]) => {
+ element.runs = runs;
+ element.showChecksSummary = true;
+ await element.updateComplete;
+ const chips = queryAll<GrChecksChip>(element, 'gr-checks-chip') ?? [];
+ assert.deepEqual(
+ [...chips].map(c => `${c.statusOrCategory} ${c.text}`),
+ texts
+ );
+ };
+
+ test('single success', async () => {
+ checkSummary([createRun()], ['SUCCESS test-name']);
+ });
+
+ test('single running', async () => {
+ checkSummary(
+ [createRun({status: RunStatus.RUNNING})],
+ ['RUNNING test-name']
+ );
+ });
- element.getCommentsModel().setState({
+ test('single info', async () => {
+ checkSummary(
+ [
+ createRun({
+ status: RunStatus.COMPLETED,
+ results: [createCheckResult({category: Category.INFO})],
+ }),
+ ],
+ ['INFO test-name']
+ );
+ });
+
+ test('single of each collapses INFO and SUCCESS', async () => {
+ checkSummary(
+ [
+ createRun({status: RunStatus.RUNNING}),
+ createRun({
+ status: RunStatus.COMPLETED,
+ results: [createCheckResult({category: Category.SUCCESS})],
+ }),
+ createRun({
+ status: RunStatus.COMPLETED,
+ results: [createCheckResult({category: Category.INFO})],
+ }),
+ createRun({
+ status: RunStatus.COMPLETED,
+ results: [createCheckResult({category: Category.WARNING})],
+ }),
+ createRun({
+ status: RunStatus.COMPLETED,
+ results: [createCheckResult({category: Category.ERROR})],
+ }),
+ ],
+ [
+ 'ERROR test-name',
+ 'WARNING test-name',
+ 'INFO 1',
+ 'SUCCESS 1',
+ 'RUNNING test-name',
+ ]
+ );
+ });
+ });
+
+ test('renders mentions summary', async () => {
+ commentsModel.setState({
drafts: {
a: [
{
@@ -139,12 +195,13 @@ suite('gr-change-summary test', () => {
},
discardedDrafts: [],
});
- element.userModel.setAccount({
+ userModel.setAccount({
...createAccountWithEmail('abc@def.com'),
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
});
await element.updateComplete;
- const mentionSummary = queryAndAssert(element, '.mentionSummary');
+ const commentsSummary = queryAndAssert(element, 'gr-comments-summary');
+ const mentionSummary = queryAndAssert(commentsSummary, '.mentionSummary');
// Only count occurrences in unresolved threads
// Resolved threads are ignored hence mention chip count is 2
assert.dom.equal(
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
index 34423b7c58..5588f4042d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip.ts
@@ -34,6 +34,9 @@ export class GrSummaryChip extends LitElement {
@property()
category?: CommentTabState;
+ @property({type: Boolean})
+ clickable?: Boolean;
+
private readonly reporting = getAppContext().reportingService;
static override get styles() {
@@ -63,7 +66,7 @@ export class GrSummaryChip extends LitElement {
border-color: var(--info-foreground);
background: var(--info-background);
}
- .summaryChip.info:hover {
+ button.summaryChip.info:hover {
background: var(--info-background-hover);
box-shadow: var(--elevation-level-1);
}
@@ -77,7 +80,7 @@ export class GrSummaryChip extends LitElement {
border-color: var(--warning-foreground);
background: var(--warning-background);
}
- .summaryChip.warning:hover {
+ button.summaryChip.warning:hover {
background: var(--warning-background-hover);
box-shadow: var(--elevation-level-1);
}
@@ -91,7 +94,7 @@ export class GrSummaryChip extends LitElement {
border-color: var(--gray-foreground);
background: var(--gray-background);
}
- .summaryChip.check:hover {
+ button.summaryChip.check:hover {
background: var(--gray-background-hover);
box-shadow: var(--elevation-level-1);
}
@@ -107,11 +110,19 @@ export class GrSummaryChip extends LitElement {
override render() {
const chipClass = `summaryChip font-small ${this.styleType}`;
- return html`<button class=${chipClass} @click=${this.handleClick}>
- ${this.icon &&
+ if (this.clickable) {
+ return html`<button class=${chipClass} @click=${this.handleClick}>
+ ${this.renderIconAndSlot()}
+ </button>`;
+ } else {
+ return html`<span class=${chipClass}>${this.renderIconAndSlot()}</span>`;
+ }
+ }
+
+ renderIconAndSlot() {
+ return html` ${this.icon &&
html`<gr-icon ?filled=${this.iconFilled} icon=${this.icon}></gr-icon>`}
- <slot></slot>
- </button>`;
+ <slot></slot>`;
}
private handleClick(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
index 9b2559190c..31f64c9abe 100644
--- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-summary-chip_test.ts
@@ -12,8 +12,9 @@ suite('gr-summary-chip test', () => {
let element: GrSummaryChip;
setup(async () => {
element = await fixture(html`<gr-summary-chip
- styleType=${SummaryChipStyles.WARNING}
- category=${CommentTabState.DRAFTS}
+ .styleType=${SummaryChipStyles.WARNING}
+ .category=${CommentTabState.DRAFTS}
+ clickable
></gr-summary-chip>`);
});
test('is defined', () => {
@@ -29,4 +30,17 @@ suite('gr-summary-chip test', () => {
</button>`
);
});
+
+ test('renders as not clickable', async () => {
+ const element = await fixture(html`<gr-summary-chip
+ .styleType=${SummaryChipStyles.CHECK}
+ .category=${CommentTabState.SHOW_ALL}
+ ></gr-summary-chip>`);
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `<span class="check font-small summaryChip">
+ <slot> </slot>
+ </span>`
+ );
+ });
});
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index 6600c42546..abd3ff0798 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -16,7 +16,6 @@ import '../../shared/gr-change-star/gr-change-star';
import '../../shared/gr-change-status/gr-change-status';
import '../../shared/gr-editable-content/gr-editable-content';
import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../gr-change-actions/gr-change-actions';
import '../gr-change-summary/gr-change-summary';
@@ -33,92 +32,68 @@ import '../gr-reply-dialog/gr-reply-dialog';
import '../gr-thread-list/gr-thread-list';
import '../../checks/gr-checks-tab';
import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
import {GrEditConstants} from '../../edit/gr-edit-constants';
import {pluralize} from '../../../utils/string-util';
-import {querySelectorAll, whenVisible} from '../../../utils/dom-util';
+import {untilRendered, whenVisible} from '../../../utils/dom-util';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
-import {
- ChangeStatus,
- DefaultBase,
- Tab,
- DiffViewMode,
-} from '../../../constants/constants';
+import {ChangeStatus, Tab, DiffViewMode} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
import {
computeAllPatchSets,
computeLatestPatchNum,
- findEdit,
- findEditParentRevision,
PatchSet,
} from '../../../utils/patch-set-util';
import {
- changeIsAbandoned,
- changeIsMerged,
- changeIsOpen,
changeStatuses,
isInvolved,
roleDetails,
} from '../../../utils/change-util';
-import {EventType as PluginEventType} from '../../../api/plugin';
import {customElement, property, query, state} from 'lit/decorators.js';
import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
import {
AccountDetailInfo,
ActionNameToActionInfoMap,
BasePatchSetNum,
- ChangeId,
ChangeInfo,
- CommitId,
- CommitInfo,
+ CommentThread,
ConfigInfo,
DetailedLabelInfo,
EDIT,
LabelNameToInfoMap,
NumericChangeId,
PARENT,
- PatchRange,
PatchSetNum,
- PatchSetNumber,
- PreferencesInfo,
QuickLabelInfo,
- RelatedChangeAndCommitInfo,
- RelatedChangesInfo,
RevisionInfo,
RevisionPatchSetNum,
ServerInfo,
UrlEncodedCommentId,
+ isRobot,
} from '../../../types/common';
import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
-import {assertIsDefined, assert, queryAll} from '../../../utils/common-util';
-import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
import {
- CommentThread,
- isRobot,
- isUnresolved,
- DraftInfo,
-} from '../../../utils/comment-util';
+ assertIsDefined,
+ assert,
+ queryAll,
+ queryAndAssert,
+} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {isUnresolved} from '../../../utils/comment-util';
import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
import {GrFileList} from '../gr-file-list/gr-file-list';
import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
import {
- CloseFixPreviewEvent,
EditableContentSaveEvent,
- EventType,
+ FileActionTapEvent,
OpenFixPreviewEvent,
- ShowAlertEventDetail,
+ ShowReplyDialogEvent,
SwitchTabEvent,
TabState,
ValueChangedEvent,
@@ -129,20 +104,17 @@ import {GrThreadList} from '../gr-thread-list/gr-thread-list';
import {
fireAlert,
fireDialogChange,
- fireEvent,
+ fire,
fireReload,
- fireTitleChange,
} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
import {
debounce,
DelayedTask,
throttleWrap,
until,
+ waitUntil,
} from '../../../utils/async-util';
-import {Interaction, Timing, Execution} from '../../../constants/reporting';
-import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
import {
getAddedByReason,
getRemovedByReason,
@@ -158,7 +130,7 @@ import {commentsModelToken} from '../../../models/comments/comments-model';
import {resolve} from '../../../models/dependency';
import {checksModelToken} from '../../../models/checks/checks-model';
import {changeModelToken} from '../../../models/change/change-model';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
import {a11yStyles} from '../../../styles/gr-a11y-styles';
import {paperStyles} from '../../../styles/gr-paper-styles';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -168,31 +140,26 @@ import {ShortcutController} from '../../lit/shortcut-controller';
import {FilesExpandedState} from '../gr-file-list-constants';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
-import {filesModelToken} from '../../../models/change/files-model';
import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
import {
+ ChangeChildView,
changeViewModelToken,
ChangeViewState,
createChangeUrl,
+ createEditUrl,
} from '../../../models/views/change';
import {rootUrl} from '../../../utils/url-util';
-import {createEditUrl} from '../../../models/views/edit';
-
-const CHANGE_ID_ERROR = {
- MISMATCH: 'mismatch',
- MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN =
- /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
const REVIEWERS_REGEX = /^(R|CC)=/gm;
const MIN_CHECK_INTERVAL_SECS = 0;
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
@@ -210,17 +177,9 @@ const ReloadToastMessage = {
// Making the tab names more unique in case a plugin adds one with same name
const ROBOT_COMMENTS_LIMIT = 10;
-export type ChangeViewPatchRange = Partial<PatchRange>;
-
@customElement('gr-change-view')
export class GrChangeView extends LitElement {
/**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
* Fired if an error occurs when fetching the change data.
*
* @event page-error
@@ -240,15 +199,15 @@ export class GrChangeView extends LitElement {
@query('#commitMessageEditor') commitMessageEditor?: GrEditableContent;
- @query('#includedInOverlay') includedInOverlay?: GrOverlay;
+ @query('#includedInModal') includedInModal?: HTMLDialogElement;
@query('#includedInDialog') includedInDialog?: GrIncludedInDialog;
- @query('#downloadOverlay') downloadOverlay?: GrOverlay;
+ @query('#downloadModal') downloadModal?: HTMLDialogElement;
@query('#downloadDialog') downloadDialog?: GrDownloadDialog;
- @query('#replyOverlay') replyOverlay?: GrOverlay;
+ @query('#replyModal') replyModal?: HTMLDialogElement;
@query('#replyDialog') replyDialog?: GrReplyDialog;
@@ -276,35 +235,18 @@ export class GrChangeView extends LitElement {
@query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
- private _viewState?: ChangeViewState;
-
- @property({type: Object})
- get viewState() {
- return this._viewState;
- }
-
- set viewState(viewState: ChangeViewState | undefined) {
- if (this._viewState === viewState) return;
- const oldViewState = this._viewState;
- this._viewState = viewState;
- this.viewStateChanged();
- this.requestUpdate('viewState', oldViewState);
- }
+ @state()
+ viewState?: ChangeViewState;
@property({type: String})
backPage?: string;
@state()
- private hasParent?: boolean;
-
- // Private but used in tests.
- @state()
commentThreads?: CommentThread[];
// Don't use, use serverConfig instead.
private _serverConfig?: ServerInfo;
- // Private but used in tests.
@state()
get serverConfig() {
return this._serverConfig;
@@ -321,10 +263,6 @@ export class GrChangeView extends LitElement {
@state()
private account?: AccountDetailInfo;
- // Private but used in tests.
- @state()
- prefs?: PreferencesInfo;
-
canStartReview() {
return !!(
this.change &&
@@ -352,50 +290,19 @@ export class GrChangeView extends LitElement {
// Private but used in tests.
@state()
- commitInfo?: CommitInfo;
-
- // Private but used in tests.
- @state()
changeNum?: NumericChangeId;
- // Private but used in tests.
- @state()
- diffDrafts?: {[path: string]: DraftInfo[]} = {};
-
@state()
private editingCommitMessage = false;
@state()
- private latestCommitMessage: string | null = '';
+ private latestCommitMessage = '';
- // Use patchRange getter/setter.
- private _patchRange?: ChangeViewPatchRange;
+ @state() basePatchNum: BasePatchSetNum = PARENT;
- // Private but used in tests.
- @state()
- get patchRange() {
- return this._patchRange;
- }
-
- set patchRange(patchRange: ChangeViewPatchRange | undefined) {
- if (this._patchRange === patchRange) return;
- const oldPatchRange = this._patchRange;
- this._patchRange = patchRange;
- this.patchNumChanged();
- this.requestUpdate('patchRange', oldPatchRange);
- }
-
- // Private but used in tests.
- @state()
- selectedRevision?: RevisionInfo | EditRevisionInfo;
+ @state() patchNum?: RevisionPatchSetNum;
- @state()
- get changeIdCommitMessageError() {
- return this.computeChangeIdCommitMessageError(
- this.latestCommitMessage,
- this.change
- );
- }
+ @state() revision?: RevisionInfo | EditRevisionInfo;
/**
* <gr-change-actions> populates this via two-way data binding.
@@ -423,30 +330,14 @@ export class GrChangeView extends LitElement {
// Private but used in tests.
@state()
- initialLoadComplete = false;
-
- // Private but used in tests.
- @state()
replyDisabled = true;
- // Private but used in tests.
- @state()
- changeStatuses: ChangeStates[] = [];
-
@state()
private updateCheckTimerHandle?: number | null;
// Private but used in tests.
- getEditMode() {
- if (!this.patchRange || !this.viewState) {
- return false;
- }
-
- if (this.viewState.edit) {
- return true;
- }
-
- return this.patchRange.patchNum === EDIT;
+ getEditMode(): boolean {
+ return !!this.viewState?.edit || this.patchNum === EDIT;
}
isSubmitEnabled(): boolean {
@@ -457,9 +348,7 @@ export class GrChangeView extends LitElement {
);
}
- // Private but used in tests.
- @state()
- mergeable: boolean | null = null;
+ @state() mergeable?: boolean;
/**
* Plugins can provide (multiple) tabs. For each plugin tab we render an
@@ -508,7 +397,7 @@ export class GrChangeView extends LitElement {
private showRobotCommentsButton = false;
@state()
- private draftCount = 0;
+ draftCount = 0;
private throttledToggleChangeStar?: (e: KeyboardEvent) => void;
@@ -526,45 +415,41 @@ export class GrChangeView extends LitElement {
private tabState?: TabState;
@state()
- private revertedChange?: ChangeInfo;
+ private revertingChange?: ChangeInfo;
// Private but used in tests.
@state()
scrollCommentId?: UrlEncodedCommentId;
- /** Just reflects the `opened` prop of the overlay. */
+ /** Reflects the `opened` state of the reply dialog. */
@state()
- private replyOverlayOpened = false;
+ replyModalOpened = false;
// Accessed in tests.
readonly reporting = getAppContext().reportingService;
- readonly jsAPI = getAppContext().jsApiService;
-
private readonly getChecksModel = resolve(this, checksModelToken);
readonly restApiService = getAppContext().restApiService;
- // Private but used in tests.
- readonly userModel = getAppContext().userModel;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
- // Private but used in tests.
- readonly getChangeModel = resolve(this, changeModelToken);
+ private readonly getUserModel = resolve(this, userModelToken);
- private readonly routerModel = getAppContext().routerModel;
+ private readonly getChangeModel = resolve(this, changeModelToken);
- // Private but used in tests.
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getConfigModel = resolve(this, configModelToken);
- private readonly getFilesModel = resolve(this, filesModelToken);
-
private readonly getViewModel = resolve(this, changeViewModelToken);
- private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
+ private readonly getRelatedChangesModel = resolve(
+ this,
+ relatedChangesModelToken
+ );
- private replyRefitTask?: DelayedTask;
+ private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
private scrollTask?: DelayedTask;
@@ -588,7 +473,7 @@ export class GrChangeView extends LitElement {
/** Simply reflects the router-model value. */
// visible for testing
- routerPatchNum?: RevisionPatchSetNum;
+ viewModelPatchNum?: RevisionPatchSetNum;
private readonly shortcutsController = new ShortcutController(this);
@@ -602,18 +487,6 @@ export class GrChangeView extends LitElement {
}
private setupListeners() {
- this.addEventListener(
- // When an overlay is opened in a mobile viewport, the overlay has a full
- // screen view. When it has a full screen view, we do not want the
- // background to be scrollable. This will eliminate background scroll by
- // hiding most of the contents on the screen upon opening, and showing
- // again upon closing.
- 'fullscreen-overlay-opened',
- () => this.handleHideBackgroundContent()
- );
- this.addEventListener('fullscreen-overlay-closed', () =>
- this.handleShowBackgroundContent()
- );
this.addEventListener('open-reply-dialog', () => this.openReplyDialog());
this.addEventListener('change-message-deleted', () => fireReload(this));
this.addEventListener('editable-content-save', e =>
@@ -623,15 +496,7 @@ export class GrChangeView extends LitElement {
this.handleCommitMessageCancel()
);
this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
- this.addEventListener('close-fix-preview', e => this.onCloseFixPreview(e));
-
- this.addEventListener(EventType.SHOW_TAB, e => this.setActiveTab(e));
- this.addEventListener('reload', e => {
- this.loadData(
- /* isLocationChange= */ false,
- /* clearPatchset= */ e.detail && e.detail.clearPatchset
- );
- });
+ this.addEventListener('show-tab', e => this.setActiveTab(e));
}
private setupShortcuts() {
@@ -639,7 +504,7 @@ export class GrChangeView extends LitElement {
this.shortcutsController.addAbstract(Shortcut.EMOJI_DROPDOWN, () => {}); // docOnly
this.shortcutsController.addAbstract(Shortcut.MENTIONS_DROPDOWN, () => {}); // docOnly
this.shortcutsController.addAbstract(Shortcut.REFRESH_CHANGE, () =>
- fireReload(this, true)
+ this.getChangeModel().navigateToChangeResetReload()
);
this.shortcutsController.addAbstract(Shortcut.OPEN_REPLY_DIALOG, () =>
this.handleOpenReplyDialog()
@@ -709,7 +574,21 @@ export class GrChangeView extends LitElement {
subscribe(
this,
() => this.getViewModel().tab$,
- t => (this.activeTab = t ?? Tab.FILES)
+ t => (this.activeTab = t)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().commentId$,
+ commentId => (this.scrollCommentId = commentId)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().openReplyDialog$,
+ openReplyDialog => {
+ // Here we are relying on `this.loggedIn` being set *before*
+ // `openReplyDialog`, but that is fine for this feature.
+ if (openReplyDialog && this.loggedIn) this.handleOpenReplyDialog();
+ }
);
subscribe(
this,
@@ -727,28 +606,26 @@ export class GrChangeView extends LitElement {
);
subscribe(
this,
- () => this.routerModel.routerView$,
- view => {
- this.isViewCurrent = view === GerritView.CHANGE;
+ () => this.getViewModel().childView$,
+ childView => {
+ this.isViewCurrent = childView === ChangeChildView.OVERVIEW;
+ // When coming back from ChangeChildView.DIFF we want to restore the
+ // scroll position to what it was before leaving the OVERVIEW page.
+ if (this.isViewCurrent) {
+ document.documentElement.scrollTop = this.scrollPosition ?? 0;
+ }
}
);
subscribe(
this,
- () => this.routerModel.routerPatchNum$,
+ () => this.getViewModel().patchNum$,
patchNum => {
- this.routerPatchNum = patchNum;
- }
- );
- subscribe(
- this,
- () => this.getCommentsModel().drafts$,
- drafts => {
- this.diffDrafts = {...drafts};
+ this.viewModelPatchNum = patchNum;
}
);
subscribe(
this,
- () => this.userModel.preferenceDiffViewMode$,
+ () => this.getUserModel().preferenceDiffViewMode$,
diffViewMode => {
this.diffViewMode = diffViewMode;
}
@@ -762,7 +639,7 @@ export class GrChangeView extends LitElement {
);
subscribe(
this,
- () => this.getCommentsModel().threads$,
+ () => this.getCommentsModel().threadsSaved$,
threads => {
this.commentThreads = threads;
}
@@ -778,14 +655,57 @@ export class GrChangeView extends LitElement {
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getChangeModel().changeNum$,
+ changeNum => {
+ // The change view is tied to a specific change number, so don't update
+ // changeNum to undefined and only set it once.
+ if (changeNum && !this.changeNum) this.changeNum = changeNum;
+ }
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().patchNum$,
+ patchNum => (this.patchNum = patchNum)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().basePatchNum$,
+ basePatchNum => (this.basePatchNum = basePatchNum)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().mergeable$,
+ mergeable => (this.mergeable = mergeable)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().revision$,
+ revision => (this.revision = revision)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().changeLoadingStatus$,
+ status => (this.loading = status !== LoadingStatus.LOADED)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().latestRevision$,
+ revision => {
+ this.latestCommitMessage = this.prepareCommitMsgForLinkify(
+ revision?.commit?.message ?? ''
+ );
+ }
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
account => {
this.account = account;
}
);
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
loggedIn => {
this.loggedIn = loggedIn;
}
@@ -805,6 +725,13 @@ export class GrChangeView extends LitElement {
this.projectConfig = config;
}
);
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().revertingChange$,
+ revertingChange => {
+ this.revertingChange = revertingChange;
+ }
+ );
}
override connectedCallback() {
@@ -819,6 +746,8 @@ export class GrChangeView extends LitElement {
}
override firstUpdated() {
+ this.maybeScrollToMessage(window.location.hash);
+ this.maybeShowRevertDialog();
// _onTabSizingChanged is called when iron-items-changed event is fired
// from iron-selectable but that is called before the element is present
// in view which whereas the method requires paper tabs already be visible
@@ -837,13 +766,17 @@ export class GrChangeView extends LitElement {
if (!this.isFirstConnection) return;
this.isFirstConnection = false;
- getPluginLoader()
+ this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
this.pluginTabsHeaderEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-view-tab-header'
+ );
this.pluginTabsContentEndpoints =
- getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-view-tab-content'
+ );
if (
this.pluginTabsContentEndpoints.length !==
this.pluginTabsHeaderEndpoints.length
@@ -853,8 +786,7 @@ export class GrChangeView extends LitElement {
new Error('Mismatch of headers and content.')
);
}
- })
- .then(() => this.initActiveTab());
+ });
this.throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
this.handleToggleChangeStar()
@@ -867,7 +799,6 @@ export class GrChangeView extends LitElement {
this.handleVisibilityChange
);
document.removeEventListener('scroll', this.handleScroll);
- this.replyRefitTask?.cancel();
this.scrollTask?.cancel();
if (this.updateCheckTimerHandle) {
@@ -877,21 +808,12 @@ export class GrChangeView extends LitElement {
super.disconnectedCallback();
}
- protected override willUpdate(changedProperties: PropertyValues): void {
- if (
- changedProperties.has('change') ||
- changedProperties.has('mergeable') ||
- changedProperties.has('currentRevisionActions')
- ) {
- this.changeStatuses = this.computeChangeStatusChips();
- }
- }
-
static override get styles() {
return [
a11yStyles,
paperStyles,
sharedStyles,
+ modalStyles,
css`
.container:not(.loading) {
background-color: var(--background-color-tertiary);
@@ -906,7 +828,6 @@ export class GrChangeView extends LitElement {
border-bottom: 1px solid var(--border-color);
display: flex;
padding: var(--spacing-s) var(--spacing-l);
- z-index: 99; /* Less than gr-overlay's backdrop */
}
.header.editMode {
background-color: var(--edit-mode-background-color);
@@ -968,11 +889,6 @@ export class GrChangeView extends LitElement {
background-color: var(--background-color-secondary);
padding-right: var(--spacing-m);
}
- .changeId {
- color: var(--deemphasized-text-color);
- font-family: var(--font-family);
- margin-top: var(--spacing-l);
- }
section {
background-color: var(--view-background-color);
box-shadow: var(--elevation-level-1);
@@ -1084,7 +1000,7 @@ export class GrChangeView extends LitElement {
gr-thread-list {
min-height: 250px;
}
- #includedInOverlay {
+ #includedInModal {
width: 65em;
}
#uploadHelpOverlay {
@@ -1102,7 +1018,7 @@ export class GrChangeView extends LitElement {
.relatedChanges {
padding: 0;
}
- #relatedChanges {
+ .relatedChanges gr-related-changes-list {
padding-top: var(--spacing-l);
}
#commitAndRelated {
@@ -1175,19 +1091,11 @@ export class GrChangeView extends LitElement {
flex: initial;
margin: 0;
}
- /* Change actions are the only thing thant need to remain visible due
- to the fact that they may have the currently visible overlay open. */
- #mainContent.overlayOpen .hideOnMobileOverlay {
- display: none;
- }
gr-reply-dialog {
height: 100vh;
min-width: initial;
width: 100vw;
}
- #replyOverlay {
- z-index: var(--reply-overlay-z-index);
- }
}
.patch-set-dropdown {
margin: var(--spacing-m) 0 0 var(--spacing-m);
@@ -1231,33 +1139,24 @@ export class GrChangeView extends LitElement {
.change=${this.change}
.changeNum=${this.changeNum}
></gr-apply-fix-dialog>
- <gr-overlay id="downloadOverlay" with-backdrop="">
+ <dialog id="downloadModal" tabindex="-1">
<gr-download-dialog
id="downloadDialog"
.change=${this.change}
.config=${this.serverConfig?.download}
@close=${this.handleDownloadDialogClose}
></gr-download-dialog>
- </gr-overlay>
- <gr-overlay id="includedInOverlay" with-backdrop="">
+ </dialog>
+ <dialog id="includedInModal" tabindex="-1">
<gr-included-in-dialog
id="includedInDialog"
.changeNum=${this.changeNum}
@close=${this.handleIncludedInDialogClose}
></gr-included-in-dialog>
- </gr-overlay>
- <gr-overlay
- id="replyOverlay"
- class="scrollable"
- no-cancel-on-outside-click=""
- no-cancel-on-esc-key=""
- scroll-action="lock"
- with-backdrop=""
- @iron-overlay-canceled=${this.onReplyOverlayCanceled}
- @opened-changed=${this.onReplyOverlayOpenedChanged}
- >
+ </dialog>
+ <dialog id="replyModal" @close=${this.onReplyModalCanceled}>
${when(
- this.replyOverlayOpened && this.loggedIn,
+ this.replyModalOpened && this.loggedIn,
() => html`
<gr-reply-dialog
id="replyDialog"
@@ -1266,13 +1165,11 @@ export class GrChangeView extends LitElement {
.canBeStarted=${this.canStartReview()}
@send=${this.handleReplySent}
@cancel=${this.handleReplyCancel}
- @autogrow=${this.handleReplyAutogrow}
- @send-disabled-changed=${this.resetReplyOverlayFocusStops}
>
</gr-reply-dialog>
`
)}
- </gr-overlay>
+ </dialog>
`;
}
@@ -1290,13 +1187,14 @@ export class GrChangeView extends LitElement {
}
private renderHeaderTitle() {
- const resolveWeblinks = this.commitInfo?.resolve_conflicts_web_links ?? [];
+ const changeStatuses = this.computeChangeStatusChips();
+ const resolveWeblinks =
+ this.revision?.commit?.resolve_conflicts_web_links ?? [];
return html` <div class="headerTitle">
<div class="changeStatuses">
- ${this.changeStatuses.map(
+ ${changeStatuses.map(
status => html` <gr-change-status
- .change=${this.change}
- .revertedChange=${this.revertedChange}
+ .revertedChange=${this.revertingChange}
.status=${status}
.resolveWeblinks=${resolveWeblinks}
></gr-change-status>`
@@ -1307,7 +1205,14 @@ export class GrChangeView extends LitElement {
flatten
down-arrow
class="showCopyLinkDialogButton"
- @click=${() => this.copyLinksDropdown?.toggleDropdown()}
+ @click=${(e: MouseEvent) => {
+ // We don't want to handle clicks on the star or the <a> link.
+ // Calling `stopPropagation()` from the click handler of <a> is not an
+ // option, because then the click does not reach the top-level gr-page
+ // click handler and would result is a full page reload.
+ if ((e.target as HTMLElement)?.nodeName !== 'GR-BUTTON') return;
+ this.copyLinksDropdown?.toggleDropdown();
+ }}
><gr-change-star
id="changeStar"
.change=${this.change}
@@ -1319,7 +1224,6 @@ export class GrChangeView extends LitElement {
class="changeNumber"
aria-label=${`Change ${this.change?._number}`}
href=${ifDefined(this.computeChangeUrl(true))}
- @click=${(e: MouseEvent) => e.stopPropagation()}
>${this.change?._number}</a
>
</gr-button>
@@ -1389,11 +1293,10 @@ export class GrChangeView extends LitElement {
id="actions"
.change=${this.change}
.disableEdit=${false}
- .hasParent=${this.hasParent}
.account=${this.account}
.changeNum=${this.changeNum}
.changeStatus=${this.change?.status}
- .commitNum=${this.commitInfo?.commit}
+ .commitNum=${this.revision?.commit?.commit}
.commitMessage=${this.latestCommitMessage}
.editMode=${this.getEditMode()}
.privateByDefault=${this.projectConfig?.private_by_default}
@@ -1415,14 +1318,14 @@ export class GrChangeView extends LitElement {
this.getEditMode()
);
return html` <div class="changeInfo">
- <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+ <div class="changeInfo-column changeMetadata">
<gr-change-metadata
id="metadata"
.change=${this.change}
- .revertedChange=${this.revertedChange}
+ .revertedChange=${this.revertingChange}
.account=${this.account}
- .revision=${this.selectedRevision}
- .commitInfo=${this.commitInfo}
+ .revision=${this.revision}
+ .commitInfo=${this.revision?.commit}
.serverConfig=${this.serverConfig}
.parentIsCurrent=${this.isParentCurrent()}
.repoConfig=${this.projectConfig}
@@ -1431,7 +1334,7 @@ export class GrChangeView extends LitElement {
</gr-change-metadata>
</div>
<div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
- <div id="commitAndRelated" class="hideOnMobileOverlay">
+ <div id="commitAndRelated">
<div class="commitContainer">
<h3 class="assistive-tech-only">Commit Message</h3>
<div>
@@ -1462,42 +1365,22 @@ export class GrChangeView extends LitElement {
remove-zero-width-space=""
>
<gr-formatted-text
- .content=${this.latestCommitMessage ?? ''}
+ .content=${this.latestCommitMessage}
.markdown=${false}
></gr-formatted-text>
</gr-editable-content>
- <div class="changeId" ?hidden=${!this.changeIdCommitMessageError}>
- <hr />
- Change-Id:
- <span
- class=${this.computeChangeIdClass(
- this.changeIdCommitMessageError
- )}
- title=${this.computeTitleAttributeWarning(
- this.changeIdCommitMessageError
- )}
- >${this.change?.change_id}</span
- >
- </div>
</div>
<h3 class="assistive-tech-only">Comments and Checks Summary</h3>
<gr-change-summary></gr-change-summary>
<gr-endpoint-decorator name="commit-container">
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
- <gr-endpoint-param
- name="revision"
- .value=${this.selectedRevision}
- >
+ <gr-endpoint-param name="revision" .value=${this.revision}>
</gr-endpoint-param>
</gr-endpoint-decorator>
</div>
<div class="relatedChanges">
- <gr-related-changes-list
- id="relatedChanges"
- .change=${this.change}
- .mergeable=${this.mergeable}
- ></gr-related-changes-list>
+ <gr-related-changes-list></gr-related-changes-list>
</div>
<div class="emptySpace"></div>
</div>
@@ -1540,10 +1423,7 @@ export class GrChangeView extends LitElement {
<gr-endpoint-decorator name=${tabHeader}>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
- <gr-endpoint-param
- name="revision"
- .value=${this.selectedRevision}
- >
+ <gr-endpoint-param name="revision" .value=${this.revision}>
</gr-endpoint-param>
</gr-endpoint-decorator>
</paper-tab>
@@ -1579,7 +1459,7 @@ export class GrChangeView extends LitElement {
.account=${this.account}
.change=${this.change}
.changeNum=${this.changeNum}
- .commitInfo=${this.commitInfo}
+ .commitInfo=${this.revision?.commit}
.changeUrl=${this.computeChangeUrl()}
.editMode=${this.getEditMode()}
.loggedIn=${this.loggedIn}
@@ -1593,7 +1473,6 @@ export class GrChangeView extends LitElement {
</gr-file-list-header>
<gr-file-list
id="fileList"
- class="hideOnMobileOverlay"
.change=${this.change}
.changeNum=${this.changeNum}
.editMode=${this.getEditMode()}
@@ -1675,7 +1554,7 @@ export class GrChangeView extends LitElement {
<gr-endpoint-decorator .name=${pluginTabContentEndpoint}>
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
- <gr-endpoint-param name="revision" .value=${this.selectedRevision}></gr-endpoint-param>
+ <gr-endpoint-param name="revision" .value=${this.revision}></gr-endpoint-param>
</gr-endpoint-param>
</gr-endpoint-decorator>
`;
@@ -1686,7 +1565,7 @@ export class GrChangeView extends LitElement {
<gr-endpoint-decorator name="change-view-integration">
<gr-endpoint-param name="change" .value=${this.change}>
</gr-endpoint-param>
- <gr-endpoint-param name="revision" .value=${this.selectedRevision}>
+ <gr-endpoint-param name="revision" .value=${this.revision}>
</gr-endpoint-param>
</gr-endpoint-decorator>
@@ -1698,17 +1577,26 @@ export class GrChangeView extends LitElement {
<section class="changeLog">
<h2 class="assistive-tech-only">Change Log</h2>
<gr-messages-list
- class="hideOnMobileOverlay"
.labels=${this.change?.labels}
.messages=${this.change?.messages}
- .reviewerUpdates=${this.change?.reviewer_updates}
+ .reviewerUpdates=${this.change?.reviewer_updates ?? []}
@message-anchor-tap=${this.handleMessageAnchorTap}
- @reply=${this.handleMessageReply}
></gr-messages-list>
</section>
`;
}
+ override updated() {
+ const tabs = [...queryAll<HTMLElement>(this.tabs!, 'paper-tab')];
+ const tabIndex = tabs.findIndex(t => t.dataset['name'] === this.activeTab);
+
+ if (tabIndex !== -1 && this.tabs!.selected !== tabIndex) {
+ this.tabs!.selected = tabIndex;
+ }
+ this.reportChangeDisplayed();
+ this.reportFullyLoaded();
+ }
+
private readonly handleScroll = () => {
if (!this.isViewCurrent) return;
this.scrollTask = debounce(
@@ -1723,16 +1611,12 @@ export class GrChangeView extends LitElement {
this.applyFixDialog.open(e);
}
- private onCloseFixPreview(e: CloseFixPreviewEvent) {
- if (e.detail.fixApplied) fireReload(this);
- }
-
// Private but used in tests.
handleToggleDiffMode() {
if (this.diffViewMode === DiffViewMode.SIDE_BY_SIDE) {
- this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+ this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
} else {
- this.userModel.updatePreferences({
+ this.getUserModel().updatePreferences({
diff_view: DiffViewMode.SIDE_BY_SIDE,
});
}
@@ -1754,22 +1638,10 @@ export class GrChangeView extends LitElement {
}
setActiveTab(e: SwitchTabEvent) {
- if (!this.tabs) return;
- const tabs = [...queryAll<HTMLElement>(this.tabs, 'paper-tab')];
- if (!tabs) return;
-
const tab = e.detail.tab;
- const tabIndex = tabs.findIndex(t => t.dataset['name'] === tab);
- assert(tabIndex !== -1, `tab ${tab} not found`);
-
- if (this.tabs.selected !== tabIndex) {
- this.tabs.selected = tabIndex;
- }
-
this.getViewModel().updateState({tab});
-
if (e.detail.tabState) this.tabState = e.detail.tabState;
- if (e.detail.scrollIntoView) this.tabs.scrollIntoView({block: 'center'});
+ if (e.detail.scrollIntoView) this.tabs!.scrollIntoView({block: 'center'});
}
/**
@@ -1809,7 +1681,8 @@ export class GrChangeView extends LitElement {
}
private handleContentChanged(e: ValueChangedEvent) {
- this.latestCommitMessage = e.detail.value;
+ // optimistic update
+ this.latestCommitMessage = e.detail.value ?? '';
}
// Private but used in tests.
@@ -1821,7 +1694,10 @@ export class GrChangeView extends LitElement {
// Trim trailing whitespace from each line.
const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
- this.jsAPI.handleCommitMessage(this.change, message);
+ this.getPluginLoader().jsApiService.handleCommitMessage(
+ this.change,
+ message
+ );
this.commitMessageEditor.disabled = true;
this.restApiService
@@ -1833,9 +1709,8 @@ export class GrChangeView extends LitElement {
return;
}
- this.latestCommitMessage = this.prepareCommitMsgForLinkify(message);
this.editingCommitMessage = false;
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
})
.catch(() => {
assertIsDefined(this.commitMessageEditor);
@@ -1848,19 +1723,12 @@ export class GrChangeView extends LitElement {
}
private computeChangeStatusChips() {
- if (!this.change) {
- return [];
- }
-
- // Show no chips until mergeability is loaded.
- if (this.mergeable === null) {
- return [];
- }
+ if (!this.change || this.mergeable === undefined) return [];
const options = {
- includeDerived: true,
- mergeable: !!this.mergeable,
+ mergeable: this.mergeable,
submitEnabled: !!this.isSubmitEnabled(),
+ revertingChangeStatus: this.revertingChange?.status,
};
return changeStatuses(this.change as ChangeInfo, options);
}
@@ -1974,13 +1842,10 @@ export class GrChangeView extends LitElement {
this.openReplyDialog(FocusTarget.ANY);
}
- private onReplyOverlayCanceled() {
+ private onReplyModalCanceled() {
fireDialogChange(this, {canceled: true});
this.changeViewAriaHidden = false;
- }
-
- private onReplyOverlayOpenedChanged(e: ValueChangedEvent<boolean>) {
- this.replyOverlayOpened = e.detail.value;
+ this.replyModalOpened = false;
}
private handleOpenDiffPrefs() {
@@ -1990,92 +1855,60 @@ export class GrChangeView extends LitElement {
private handleOpenIncludedInDialog() {
assertIsDefined(this.includedInDialog);
- assertIsDefined(this.includedInOverlay);
- this.includedInDialog.loadData().then(() => {
- assertIsDefined(this.includedInOverlay);
- flush();
- this.includedInOverlay.refit();
- });
- this.includedInOverlay.open();
+ assertIsDefined(this.includedInModal);
+ this.includedInDialog.loadData();
+ this.includedInModal.showModal();
}
private handleIncludedInDialogClose() {
- assertIsDefined(this.includedInOverlay);
- this.includedInOverlay.close();
+ assertIsDefined(this.includedInModal);
+ this.includedInModal.close();
}
// Private but used in tests
handleOpenDownloadDialog() {
- assertIsDefined(this.downloadOverlay);
- this.downloadOverlay.open().then(() => {
- assertIsDefined(this.downloadOverlay);
+ assertIsDefined(this.downloadModal);
+ this.downloadModal.showModal();
+ whenVisible(this.downloadModal, () => {
+ assertIsDefined(this.downloadModal);
assertIsDefined(this.downloadDialog);
- this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops());
this.downloadDialog.focus();
+ const downloadCommands = queryAndAssert(
+ this.downloadDialog,
+ 'gr-download-commands'
+ );
+ const paperTabs = queryAndAssert<PaperTabsElement>(
+ downloadCommands,
+ 'paper-tabs'
+ );
+ // Paper Tabs normally listen to 'iron-resize' event to call this method.
+ // After migrating to Dialog element, this event is no longer fired
+ // which means this method is not called which ends up styling the
+ // selected paper tab with an underline.
+ paperTabs._onTabSizingChanged();
});
}
private handleDownloadDialogClose() {
- assertIsDefined(this.downloadOverlay);
- this.downloadOverlay.close();
- }
-
- // Private but used in tests.
- handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
- const msg: string = e.detail.message.message;
- const quoteStr =
- msg
- .split('\n')
- .map(line => '> ' + line)
- .join('\n') + '\n\n';
- this.openReplyDialog(FocusTarget.BODY, quoteStr);
- }
-
- // Private but used in tests.
- handleHideBackgroundContent() {
- assertIsDefined(this.mainContent);
- this.mainContent.classList.add('overlayOpen');
- }
-
- // Private but used in tests.
- handleShowBackgroundContent() {
- assertIsDefined(this.mainContent);
- this.mainContent.classList.remove('overlayOpen');
+ assertIsDefined(this.downloadModal);
+ this.downloadModal.close();
}
// Private but used in tests.
handleReplySent() {
- this.addEventListener(
- 'change-details-loaded',
- () => {
- this.reporting.timeEnd(Timing.SEND_REPLY);
- },
- {once: true}
- );
- assertIsDefined(this.replyOverlay);
- this.replyOverlay.cancel();
- fireReload(this);
+ assertIsDefined(this.replyModal);
+ this.replyModal.close();
+ this.getChangeModel().navigateToChangeResetReload();
}
private handleReplyCancel() {
- assertIsDefined(this.replyOverlay);
- this.replyOverlay.cancel();
- }
-
- private handleReplyAutogrow() {
- // If the textarea resizes, we need to re-fit the overlay.
- this.replyRefitTask = debounce(
- this.replyRefitTask,
- () => {
- assertIsDefined(this.replyOverlay);
- this.replyOverlay.refit();
- },
- REPLY_REFIT_DEBOUNCE_INTERVAL_MS
- );
+ assertIsDefined(this.replyModal);
+ this.replyModal.close();
+ this.onReplyModalCanceled();
}
// Private but used in tests.
- handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+ handleShowReplyDialog(e: ShowReplyDialogEvent) {
let target = FocusTarget.REVIEWERS;
if (e.detail.value && e.detail.value.ccsOnly) {
target = FocusTarget.CCS;
@@ -2116,179 +1949,14 @@ export class GrChangeView extends LitElement {
}
// Private but used in tests.
- hasPatchRangeChanged(viewState: ChangeViewState) {
- if (!this.patchRange) return false;
- if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
- return this.hasPatchNumChanged(viewState);
- }
-
- // Private but used in tests.
- hasPatchNumChanged(viewState: ChangeViewState) {
- if (!this.patchRange) return false;
- if (viewState.patchNum !== undefined) {
- return this.patchRange.patchNum !== viewState.patchNum;
- } else {
- // value.patchNum === undefined specifies the latest patchset
- return (
- this.patchRange.patchNum !== computeLatestPatchNum(this.allPatchSets)
- );
- }
- }
-
- // Private but used in tests.
- viewStateChanged() {
- if (this.viewState === undefined) {
- this.initialLoadComplete = false;
- querySelectorAll(this, 'gr-overlay').forEach(overlay =>
- (overlay as GrOverlay).close()
- );
- return;
- }
-
- if (this.isChangeObsolete()) {
- // Tell the app element that we are not going to handle the new change
- // number and that they have to create a new change view.
- fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
- return;
- }
-
- if (this.viewState.changeNum && this.viewState.project) {
- this.restApiService.setInProjectLookup(
- this.viewState.changeNum,
- this.viewState.project
- );
- }
-
- if (this.viewState.basePatchNum === undefined)
- this.viewState.basePatchNum = PARENT;
-
- const patchChanged = this.hasPatchRangeChanged(this.viewState);
- let patchNumChanged = this.hasPatchNumChanged(this.viewState);
-
- this.patchRange = {
- patchNum: this.viewState.patchNum,
- basePatchNum: this.viewState.basePatchNum,
- };
- this.scrollCommentId = this.viewState.commentId;
-
- const patchKnown =
- !this.patchRange.patchNum ||
- (this.allPatchSets ?? []).some(
- ps => ps.num === this.patchRange!.patchNum
- );
- // _allPatchsets does not know value.patchNum so force a reload.
- const forceReload = this.viewState.forceReload || !patchKnown;
-
- // If changeNum is defined that means the change has already been
- // rendered once before so a full reload is not required.
- if (this.changeNum !== undefined && !forceReload) {
- if (!this.patchRange.patchNum) {
- this.patchRange = {
- ...this.patchRange,
- patchNum: computeLatestPatchNum(this.allPatchSets),
- };
- patchNumChanged = true;
- }
- if (patchChanged) {
- // We need to collapse all diffs when viewState changes so that a non
- // existing diff is not requested. See Issue 125270 for more details.
- this.fileList?.resetFileState();
- this.fileList?.collapseAllDiffs();
- this.reloadPatchNumDependentResources(patchNumChanged);
- }
-
- // If there is no change in patchset or changeNum, such as when user goes
- // to the diff view and then comes back to change page then there is no
- // need to reload anything and we render the change view component as is.
- document.documentElement.scrollTop = this.scrollPosition ?? 0;
- this.reporting.reportInteraction('change-view-re-rendered');
- this.updateTitle(this.change);
- // We still need to check if post load tasks need to be done such as when
- // user wants to open the reply dialog when in the diff page, the change
- // page should open the reply dialog
- this.performPostLoadTasks();
- return;
- }
-
- // We need to collapse all diffs when viewState changes so that a non
- // existing diff is not requested. See Issue 125270 for more details.
- this.updateComplete.then(() => {
- assertIsDefined(this.fileList);
- this.fileList?.collapseAllDiffs();
- this.fileList?.resetFileState();
- });
-
- // If the change was loaded before, then we are firing a 'reload' event
- // instead of calling `loadData()` directly for two reasons:
- // 1. We want to avoid code such as `this.initialLoadComplete = false` that
- // is only relevant for the initial load of a change.
- // 2. We have to somehow trigger the change-model reloading. Otherwise
- // this.change is not updated.
- if (this.changeNum) {
- if (!this._patchRange?.patchNum) {
- this._patchRange = {
- basePatchNum: PARENT,
- patchNum: computeLatestPatchNum(this.allPatchSets),
- };
- }
- fireReload(this);
- return;
- }
-
- this.initialLoadComplete = false;
- this.changeNum = this.viewState.changeNum;
- this.loadData(true).then(() => {
- this.performPostLoadTasks();
- });
-
- getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- this.initActiveTab();
- });
- }
-
- private initActiveTab() {
- let tab = Tab.FILES;
- if (this.viewState?.tab) {
- tab = this.viewState?.tab as Tab;
- } else if (this.viewState?.commentId) {
- tab = Tab.COMMENT_THREADS;
- }
- this.setActiveTab(new CustomEvent(EventType.SHOW_TAB, {detail: {tab}}));
- }
-
- // Private but used in tests.
- sendShowChangeEvent() {
- assertIsDefined(this.patchRange, 'patchRange');
- this.jsAPI.handleEvent(PluginEventType.SHOW_CHANGE, {
- change: this.change,
- patchNum: this.patchRange.patchNum,
- info: {mergeable: this.mergeable},
- });
- }
-
- private performPostLoadTasks() {
- this.maybeShowReplyDialog();
- this.maybeShowRevertDialog();
-
- this.sendShowChangeEvent();
-
- this.updateComplete.then(() => {
- this.maybeScrollToMessage(window.location.hash);
- this.initialLoadComplete = true;
- });
- }
-
- // Private but used in tests.
handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
const hash = PREFIX + e.detail.id;
const url = createChangeUrl({
change: this.change,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
+ patchNum: this.patchNum,
+ basePatchNum: this.basePatchNum,
edit: this.getEditMode(),
messageHash: hash,
});
@@ -2296,9 +1964,10 @@ export class GrChangeView extends LitElement {
}
// Private but used in tests.
- maybeScrollToMessage(hash: string) {
- if (hash.startsWith(PREFIX) && this.messagesList) {
- this.messagesList.scrollToMessage(hash.substr(PREFIX.length));
+ async maybeScrollToMessage(hash: string) {
+ if (hash.startsWith(PREFIX)) {
+ await waitUntil(() => !!this.messagesList);
+ await this.messagesList!.scrollToMessage(hash.substr(PREFIX.length));
}
}
@@ -2321,37 +1990,16 @@ export class GrChangeView extends LitElement {
}
// Private but used in tests.
- maybeShowRevertDialog() {
- getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- if (
- !this.loggedIn ||
- !this.change ||
- this.change.status !== ChangeStatus.MERGED
- ) {
- // Do not display dialog if not logged-in or the change is not
- // merged.
- return;
- }
- if (this._getUrlParameter('revert')) {
- assertIsDefined(this.actions);
- this.actions.showRevertDialog();
- }
- });
- }
+ async maybeShowRevertDialog() {
+ if (!this._getUrlParameter('revert')) return;
- private maybeShowReplyDialog() {
- if (!this.loggedIn) return;
- if (this.viewState?.openReplyDialog) {
- this.openReplyDialog(FocusTarget.ANY);
- }
- }
+ await this.getPluginLoader().awaitPluginsLoaded();
+ await waitUntil(() => !!this.actions);
+ await waitUntil(() => !!this.change);
- private updateTitle(change?: ChangeInfo | ParsedChangeInfo) {
- if (!change) return;
- const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
- fireTitleChange(this, title);
+ if (this.change?.status === ChangeStatus.MERGED && this.loggedIn) {
+ this.actions!.showRevertDialog();
+ }
}
// Private but used in tests.
@@ -2367,60 +2015,11 @@ export class GrChangeView extends LitElement {
this.currentRobotCommentsPatchSet =
this.change.revisions[this.change.current_revision]._number;
}
- if (!this.change || !this.patchRange || !this.allPatchSets) {
- return;
- }
-
- // We get the parent first so we keep the original value for basePatchNum
- // and not the updated value.
- const parent = this.getBasePatchNum();
-
- this.patchRange = {
- ...this.patchRange,
- basePatchNum: parent,
- patchNum:
- this.patchRange.patchNum || computeLatestPatchNum(this.allPatchSets),
- };
- this.updateTitle(this.change);
}
/**
- * Gets base patch number, if it is a parent try and decide from
- * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
- * Private but used in tests.
+ * This is the URL equivalent of changeModel.navigateToChangeResetReload().
*/
- getBasePatchNum() {
- if (
- this.patchRange &&
- this.patchRange.basePatchNum &&
- this.patchRange.basePatchNum !== PARENT
- ) {
- return this.patchRange.basePatchNum;
- }
-
- const revisionInfo = this.getRevisionInfo();
- if (!revisionInfo) return PARENT;
-
- // TODO: It is a bit unclear why `1` is used here instead of
- // `patchRange.patchNum`. Maybe that is a bug? Maybe if one patchset
- // is a merge commit, then all patchsets are merge commits??
- const isMerge = revisionInfo.isMergeCommit(1 as PatchSetNumber);
- const preferFirst =
- this.prefs &&
- this.prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
-
- // TODO: I think checking `!patchRange.patchNum` here is a bug and means
- // that the feature is actually broken at the moment. Looking at the
- // `changeChanged` method, `patchRange.patchNum` is set before
- // `getBasePatchNum` is called, so it is unlikely that this method will
- // ever return -1.
- if (isMerge && preferFirst && !this.patchRange?.patchNum) {
- this.reporting.reportExecution(Execution.PREFER_MERGE_FIRST_PARENT);
- return -1 as BasePatchSetNum;
- }
- return PARENT;
- }
-
private computeChangeUrl(forceReload?: boolean) {
if (!this.change) return undefined;
return createChangeUrl({
@@ -2429,81 +2028,18 @@ export class GrChangeView extends LitElement {
});
}
- // private but used in test
- computeChangeIdClass(displayChangeId?: string | null) {
- if (displayChangeId) {
- return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
- }
- return '';
- }
-
- computeTitleAttributeWarning(displayChangeId?: string | null) {
- if (!displayChangeId) {
- return undefined;
- }
- if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
- return 'Change-Id mismatch';
- } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
- return 'No Change-Id in commit message';
- }
- return undefined;
- }
-
- computeChangeIdCommitMessageError(
- commitMessage: string | null,
- change?: ParsedChangeInfo
- ) {
- if (change === undefined) {
- return undefined;
- }
-
- if (!commitMessage) {
- return CHANGE_ID_ERROR.MISSING;
- }
-
- // Find the last match in the commit message:
- let changeId;
- let changeIdArr;
-
- while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
- changeId = changeIdArr[2];
- }
-
- if (changeId) {
- // A change-id is detected in the commit message.
-
- if (changeId === change.change_id) {
- // The change-id found matches the real change-id.
- return null;
- }
- // The change-id found does not match the change-id.
- return CHANGE_ID_ERROR.MISMATCH;
- }
- // There is no change-id in the commit message.
- return CHANGE_ID_ERROR.MISSING;
- }
-
// Private but used in tests.
computeReplyButtonLabel() {
- if (this.diffDrafts === undefined) {
- return 'Reply';
- }
-
- const draftCount = Object.keys(this.diffDrafts).reduce(
- (count, file) => count + this.diffDrafts![file].length,
- 0
- );
-
let label = this.canStartReview() ? 'Start Review' : 'Reply';
- if (draftCount > 0) {
- label += ` (${draftCount})`;
+ if (this.draftCount > 0) {
+ label += ` (${this.draftCount})`;
}
return label;
}
private handleOpenReplyDialog() {
if (!this.loggedIn) {
- fireEvent(this, 'show-auth-required');
+ fire(this, 'show-auth-required', {});
return;
}
this.openReplyDialog(FocusTarget.ANY);
@@ -2533,7 +2069,7 @@ export class GrChangeView extends LitElement {
reason
)
.then(() => {
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
});
} else {
const reason = getAddedByReason(this.account, this.serverConfig);
@@ -2550,7 +2086,7 @@ export class GrChangeView extends LitElement {
reason
)
.then(() => {
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
});
}
this.change = newChange;
@@ -2559,29 +2095,30 @@ export class GrChangeView extends LitElement {
// Private but used in tests.
handleDiffAgainstBase() {
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
- if (this.patchRange.basePatchNum === PARENT) {
+ assertIsDefined(this.patchNum, 'patchNum');
+ if (this.basePatchNum === PARENT) {
fireAlert(this, 'Base is already selected.');
return;
}
this.getNavigation().setUrl(
- createChangeUrl({change: this.change, patchNum: this.patchRange.patchNum})
+ createChangeUrl({change: this.change, patchNum: this.patchNum})
);
}
// Private but used in tests.
handleDiffBaseAgainstLeft() {
+ if (this.viewState?.childView !== ChangeChildView.OVERVIEW) return;
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
- if (this.patchRange.basePatchNum === PARENT) {
+ if (this.basePatchNum === PARENT) {
fireAlert(this, 'Left is already base.');
return;
}
this.getNavigation().setUrl(
createChangeUrl({
change: this.change,
- patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
+ patchNum: this.basePatchNum as RevisionPatchSetNum,
})
);
}
@@ -2589,9 +2126,9 @@ export class GrChangeView extends LitElement {
// Private but used in tests.
handleDiffAgainstLatest() {
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (this.patchRange.patchNum === latestPatchNum) {
+ if (this.patchNum === latestPatchNum) {
fireAlert(this, 'Latest is already selected.');
return;
}
@@ -2599,7 +2136,7 @@ export class GrChangeView extends LitElement {
createChangeUrl({
change: this.change,
patchNum: latestPatchNum,
- basePatchNum: this.patchRange.basePatchNum,
+ basePatchNum: this.basePatchNum,
})
);
}
@@ -2607,9 +2144,9 @@ export class GrChangeView extends LitElement {
// Private but used in tests.
handleDiffRightAgainstLatest() {
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (this.patchRange.patchNum === latestPatchNum) {
+ if (this.patchNum === latestPatchNum) {
fireAlert(this, 'Right is already latest.');
return;
}
@@ -2617,7 +2154,7 @@ export class GrChangeView extends LitElement {
createChangeUrl({
change: this.change,
patchNum: latestPatchNum,
- basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
+ basePatchNum: this.patchNum as BasePatchSetNum,
})
);
}
@@ -2625,12 +2162,9 @@ export class GrChangeView extends LitElement {
// Private but used in tests.
handleDiffBaseAgainstLatest() {
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (
- this.patchRange.patchNum === latestPatchNum &&
- this.patchRange.basePatchNum === PARENT
- ) {
+ if (this.patchNum === latestPatchNum && this.basePatchNum === PARENT) {
fireAlert(this, 'Already diffing base against latest.');
return;
}
@@ -2701,23 +2235,19 @@ export class GrChangeView extends LitElement {
return;
}
this.handleLabelRemoved(oldLabels, newLabels);
- this.jsAPI.handleEvent(PluginEventType.LABEL_CHANGE, {
+ this.getPluginLoader().jsApiService.handleLabelChange({
change: this.change,
});
}
- openReplyDialog(focusTarget?: FocusTarget, quote?: string) {
+ openReplyDialog(focusTarget?: FocusTarget) {
if (!this.change) return;
- assertIsDefined(this.replyOverlay);
- const overlay = this.replyOverlay;
- overlay.open().finally(() => {
- // the following code should be executed no matter open succeed or not
- const dialog = this.replyDialog;
- assertIsDefined(dialog, 'reply dialog');
- this.resetReplyOverlayFocusStops();
- dialog.open(focusTarget, quote);
- const observer = new ResizeObserver(() => overlay.center());
- observer.observe(dialog);
+ this.replyModalOpened = true;
+ assertIsDefined(this.replyModal);
+ this.replyModal.showModal();
+ whenVisible(this.replyModal, () => {
+ assertIsDefined(this.replyDialog, 'replyDialog');
+ this.replyDialog.open(focusTarget);
});
fireDialogChange(this, {opened: true});
this.changeViewAriaHidden = true;
@@ -2728,174 +2258,11 @@ export class GrChangeView extends LitElement {
// TODO(wyatta) switch linkify sequence, see issue 5526.
// This is a zero-with space. It is added to prevent the linkify library
// from including R= or CC= as part of the email address.
+ // TODO: Is this comment referring to the ba-linkify library that we are
+ // not using anymore? If so, then remove this hack.
return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
}
- /**
- * Utility function to make the necessary modifications to a change in the
- * case an edit exists.
- * Private but used in tests.
- */
- processEdit(change: ParsedChangeInfo) {
- const revisions = Object.values(change.revisions || {});
- const editRev = findEdit(revisions);
- const editParentRev = findEditParentRevision(revisions);
- if (
- !editRev &&
- this.patchRange?.patchNum === EDIT &&
- changeIsOpen(change)
- ) {
- fireAlert(this, 'Change edit not found. Please create a change edit.');
- fireReload(this, true);
- return;
- }
-
- if (
- !editRev &&
- (changeIsMerged(change) || changeIsAbandoned(change)) &&
- this.getEditMode()
- ) {
- fireAlert(
- this,
- 'Change edits cannot be created if change is merged or abandoned. Redirecting to non edit mode.'
- );
- fireReload(this, true);
- return;
- }
-
- if (!editRev) return;
- assertIsDefined(this.patchRange, 'patchRange');
- assertIsDefined(editRev.commit.commit, 'editRev.commit.commit');
- assertIsDefined(editParentRev, 'editParentRev');
-
- const latestPsNum = computeLatestPatchNum(computeAllPatchSets(change));
- // If the change was loaded without a specific patchset, then this normally
- // means that the *latest* patchset should be loaded. But if there is an
- // active edit, then automatically switch to that edit as the current
- // patchset.
- // TODO: This goes together with `change.current_revision` being set, which
- // is under change-model control. `patchRange.patchNum` should eventually
- // also be model managed, so we can reconcile these two code snippets into
- // one location.
- if (!this.routerPatchNum && latestPsNum === editParentRev._number) {
- this.patchRange = {...this.patchRange, patchNum: EDIT};
- // The file list is not reactive (yet) with regards to patch range
- // changes, so we have to actively trigger it.
- this.reloadPatchNumDependentResources();
- }
- }
-
- computeRevertSubmitted(change?: ChangeInfo | ParsedChangeInfo) {
- if (!change?.messages) return;
- Promise.all(
- getRevertCreatedChangeIds(change.messages).map(changeId =>
- this.restApiService.getChange(changeId)
- )
- ).then(changes => {
- // if a change is deleted then getChanges returns null for that changeId
- changes = changes.filter(
- change => change && change.status !== ChangeStatus.ABANDONED
- );
- if (!changes.length) return;
- const submittedRevert = changes.find(
- change => change?.status === ChangeStatus.MERGED
- );
- if (!this.changeStatuses) return;
- if (submittedRevert) {
- this.revertedChange = submittedRevert;
- this.changeStatuses = this.changeStatuses.concat([
- ChangeStates.REVERT_SUBMITTED,
- ]);
- } else {
- if (changes[0]) this.revertedChange = changes[0];
- this.changeStatuses = this.changeStatuses.concat([
- ChangeStates.REVERT_CREATED,
- ]);
- }
- });
- }
-
- private async untilModelLoaded() {
- // NOTE: Wait until this page is connected before determining whether the
- // model is loaded. This can happen when viewState changes when setting up
- // this view. It's unclear whether this issue is related to Polymer
- // specifically.
- if (!this.isConnected) {
- await until(this.connected$, connected => connected);
- }
- await until(
- this.getChangeModel().changeLoadingStatus$,
- status => status === LoadingStatus.LOADED
- );
- }
-
- /**
- * Process edits
- * Check if a revert of this change has been submitted
- * Calculate selected revision
- */
- // private but used in tests
- async performPostChangeLoadTasks() {
- assertIsDefined(this.changeNum, 'changeNum');
-
- const prefCompletes = this.restApiService.getPreferences();
- await this.untilModelLoaded();
-
- this.prefs = await prefCompletes;
-
- if (!this.change) return false;
-
- this.processEdit(this.change);
- // Issue 4190: Coalesce missing topics to null.
- // TODO(TS): code needs second thought,
- // it might be that nulls were assigned to trigger some bindings
- if (!this.change.topic) {
- this.change.topic = null as unknown as undefined;
- }
- if (!this.change.reviewer_updates) {
- this.change.reviewer_updates = null as unknown as undefined;
- }
- const latestRevisionSha = this.getLatestRevisionSHA(this.change);
- if (!latestRevisionSha)
- throw new Error('Could not find latest Revision Sha');
- const currentRevision = this.change.revisions[latestRevisionSha];
- if (currentRevision.commit && currentRevision.commit.message) {
- this.latestCommitMessage = this.prepareCommitMsgForLinkify(
- currentRevision.commit.message
- );
- } else {
- this.latestCommitMessage = null;
- }
-
- this.computeRevertSubmitted(this.change);
- if (
- !this.patchRange ||
- !this.patchRange.patchNum ||
- this.patchRange.patchNum === currentRevision._number
- ) {
- // CommitInfo.commit is optional, and may need patching.
- if (currentRevision.commit && !currentRevision.commit.commit) {
- currentRevision.commit.commit = latestRevisionSha as CommitId;
- }
- this.commitInfo = currentRevision.commit;
- this.selectedRevision = currentRevision;
- // TODO: Fetch and process files.
- } else {
- if (!this.change?.revisions || !this.patchRange) return false;
- this.selectedRevision = Object.values(this.change.revisions).find(
- revision => {
- // edit patchset is a special one
- const thePatchNum = this.patchRange!.patchNum;
- if (thePatchNum === EDIT) {
- return revision._number === thePatchNum;
- }
- return revision._number === Number(`${thePatchNum}`);
- }
- );
- }
- return true;
- }
-
private isParentCurrent() {
const revisionActions = this.currentRevisionActions;
if (revisionActions && revisionActions.rebase) {
@@ -2905,243 +2272,39 @@ export class GrChangeView extends LitElement {
}
}
- // Private but used in tests.
- getLatestCommitMessage() {
- assertIsDefined(this.changeNum, 'changeNum');
- const lastpatchNum = computeLatestPatchNum(this.allPatchSets);
- if (lastpatchNum === undefined)
- throw new Error('missing lastPatchNum property');
- return this.restApiService
- .getChangeCommitInfo(this.changeNum, lastpatchNum)
- .then(commitInfo => {
- if (!commitInfo) return;
- this.latestCommitMessage = this.prepareCommitMsgForLinkify(
- commitInfo.message
- );
- });
- }
-
- // Private but used in tests.
- getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
- if (change.current_revision) return change.current_revision;
- // current_revision may not be present in the case where the latest rev is
- // a draft and the user doesn’t have permission to view that rev.
- let latestRev = null;
- let latestPatchNum = -1 as PatchSetNum;
- for (const [rev, revInfo] of Object.entries(change.revisions ?? {})) {
- if (revInfo._number > latestPatchNum) {
- latestRev = rev;
- latestPatchNum = revInfo._number;
- }
+ private async reportChangeDisplayed() {
+ await waitUntil(() => !!this.metadata);
+ await untilRendered(this.metadata!);
+ if (this.activeTab === Tab.FILES) {
+ await waitUntil(() => !!this.fileList);
+ await untilRendered(this.fileList!);
}
- return latestRev;
- }
-
- // visible for testing
- loadAndSetCommitInfo() {
- assertIsDefined(this.changeNum, 'changeNum');
- assertIsDefined(this.patchRange?.patchNum, 'patchRange.patchNum');
- return this.restApiService
- .getChangeCommitInfo(this.changeNum, this.patchRange.patchNum)
- .then(commitInfo => {
- this.commitInfo = commitInfo;
- });
- }
-
- /**
- * Reload the change.
- *
- * @param isLocationChange Reloads the related changes
- * when true and ends reporting events that started on location change.
- * @param clearPatchset Reloads the change ignoring any patchset
- * choice made.
- * @return A promise that resolves when the core data has loaded.
- * Some non-core data loading may still be in-flight when the core data
- * promise resolves.
- */
- loadData(isLocationChange?: boolean, clearPatchset?: boolean) {
- if (this.isChangeObsolete()) return Promise.resolve();
- if (clearPatchset && this.change) {
- this.getNavigation().setUrl(
- createChangeUrl({change: this.change, forceReload: true})
- );
- return Promise.resolve();
- }
- this.loading = true;
- this.reporting.time(Timing.CHANGE_RELOAD);
- this.reporting.time(Timing.CHANGE_DATA);
-
- // Array to house all promises related to data requests.
- const allDataPromises: Promise<unknown>[] = [];
-
- // Resolves when the change detail and the edit patch set (if available)
- // are loaded.
- const detailCompletes = this.untilModelLoaded();
- allDataPromises.push(detailCompletes);
-
- // Resolves when the loading flag is set to false, meaning that some
- // change content may start appearing.
- const loadingFlagSet = detailCompletes.then(() => {
- this.loading = false;
- this.performPostChangeLoadTasks();
- });
-
- let coreDataPromise;
-
- // If the patch number is specified
- if (this.patchRange && this.patchRange.patchNum) {
- // Because a specific patchset is specified, reload the resources that
- // are keyed by patch number or patch range.
- const patchResourcesLoaded = this.reloadPatchNumDependentResources();
- allDataPromises.push(patchResourcesLoaded);
-
- // Promise resolves when the change detail and patch dependent resources
- // have loaded.
- coreDataPromise = Promise.all([patchResourcesLoaded, loadingFlagSet]);
- } else {
- const latestCommitMessageLoaded = loadingFlagSet.then(() => {
- // If the latest commit message is known, there is nothing to do.
- if (this.latestCommitMessage) {
- return Promise.resolve();
- }
- return this.getLatestCommitMessage();
- });
- allDataPromises.push(latestCommitMessageLoaded);
-
- coreDataPromise = loadingFlagSet;
- }
- const mergeabilityLoaded = coreDataPromise.then(() =>
- this.getMergeability()
- );
- allDataPromises.push(mergeabilityLoaded);
-
- coreDataPromise.then(() => {
- fireEvent(this, 'change-details-loaded');
- this.reporting.timeEnd(Timing.CHANGE_RELOAD);
- if (isLocationChange) {
- this.reporting.changeDisplayed(roleDetails(this.change, this.account));
- }
- });
-
- if (isLocationChange) {
- this.editingCommitMessage = false;
- }
- const relatedChangesLoaded = coreDataPromise.then(() => {
- let relatedChangesPromise:
- | Promise<RelatedChangesInfo | undefined>
- | undefined;
- const patchNum = computeLatestPatchNum(this.allPatchSets);
- if (this.change && patchNum) {
- relatedChangesPromise = this.restApiService
- .getRelatedChanges(this.change._number, patchNum)
- .then(response => {
- if (this.change && response) {
- this.hasParent = this.calculateHasParent(
- this.change.change_id,
- response.changes
- );
- }
- return response;
- });
- }
- return this.getRelatedChangesList()?.reload(relatedChangesPromise);
- });
- allDataPromises.push(relatedChangesLoaded);
- allDataPromises.push(this.filesLoaded());
-
- Promise.all(allDataPromises).then(() => {
- // Loading of commments data is no longer part of this reporting
- this.reporting.timeEnd(Timing.CHANGE_DATA);
- if (isLocationChange) {
- this.reporting.changeFullyLoaded();
- }
- });
-
- return coreDataPromise;
- }
-
- private async filesLoaded() {
- if (!this.isConnected) await until(this.connected$, connected => connected);
- await until(this.getFilesModel().files$, f => f.length > 0);
- }
-
- /**
- * Determines whether or not the given change has a parent change. If there
- * is a relation chain, and the change id is not the last item of the
- * relation chain, there is a parent.
- *
- * Private but used in tests.
- */
- calculateHasParent(
- currentChangeId: ChangeId,
- relatedChanges: RelatedChangeAndCommitInfo[]
- ) {
- return (
- relatedChanges.length > 0 &&
- relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
- );
- }
-
- /**
- * Kicks off requests for resources that rely on the patch range
- * (`this.patchRange`) being defined.
- */
- reloadPatchNumDependentResources(patchNumChanged?: boolean) {
- assertIsDefined(this.changeNum, 'changeNum');
- if (!this.patchRange?.patchNum) throw new Error('missing patchNum');
- const promises = [this.loadAndSetCommitInfo()];
- if (patchNumChanged) {
- promises.push(
- this.getCommentsModel().reloadPortedComments(
- this.changeNum,
- this.patchRange?.patchNum
- )
- );
- promises.push(
- this.getCommentsModel().reloadPortedDrafts(
- this.changeNum,
- this.patchRange?.patchNum
- )
- );
+ await waitUntil(() => !!this.messagesList);
+ await untilRendered(this.messagesList!);
+ // We are ending the timer after each change view update, because ending a
+ // timer that was not started is a no-op. :-)
+ if (this.change && this.isConnected && !this.isChangeObsolete()) {
+ this.reporting.changeDisplayed(roleDetails(this.change, this.account));
}
- return Promise.all(promises);
}
- // Private but used in tests
- getMergeability(): Promise<void> {
- if (!this.change) {
- this.mergeable = null;
- return Promise.resolve();
+ private async reportFullyLoaded() {
+ await waitUntil(() => !!this.metadata);
+ await untilRendered(this.metadata!);
+ if (this.activeTab === Tab.FILES) {
+ await waitUntil(() => !!this.fileList);
+ await untilRendered(this.fileList!);
}
- // If the change is closed, it is not mergeable. Note: already merged
- // changes are obviously not mergeable, but the mergeability API will not
- // answer for abandoned changes.
- if (
- this.change.status === ChangeStatus.MERGED ||
- this.change.status === ChangeStatus.ABANDONED
- ) {
- this.mergeable = false;
- return Promise.resolve();
+ await waitUntil(() => !!this.messagesList);
+ await untilRendered(this.messagesList!);
+ await waitUntil(() => this.mergeable !== undefined);
+ await until(this.getCommentsModel().comments$, c => c !== undefined);
+ await until(this.getCommentsModel().drafts$, c => c !== undefined);
+ // We are ending the timer after each change view update, because ending a
+ // timer that was not started is a no-op. :-)
+ if (this.change && this.isConnected && !this.isChangeObsolete()) {
+ this.reporting.changeFullyLoaded();
}
-
- if (!this.changeNum) {
- return Promise.reject(new Error('missing required changeNum property'));
- }
-
- // If mergeable bit was already returned in detail REST endpoint, use it.
- if (this.change.mergeable !== undefined) {
- this.mergeable = this.change.mergeable;
- return Promise.resolve();
- }
-
- this.mergeable = null;
- return this.restApiService
- .getMergeable(this.changeNum)
- .then(mergableInfo => {
- if (mergableInfo) {
- this.mergeable = mergableInfo.mergeable;
- }
- });
}
/**
@@ -3157,13 +2320,11 @@ export class GrChangeView extends LitElement {
);
}
- private computeCommitCollapsible() {
- if (!this.latestCommitMessage) {
- return false;
- }
+ private computeCommitCollapsible(): boolean {
return (
+ !!this.latestCommitMessage &&
this.latestCommitMessage.split('\n').length >=
- MIN_LINES_FOR_COMMIT_COLLAPSE
+ MIN_LINES_FOR_COMMIT_COLLAPSE
);
}
@@ -3218,20 +2379,14 @@ export class GrChangeView extends LitElement {
}
this.cancelUpdateCheckTimer();
- this.dispatchEvent(
- new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
- detail: {
- message: toastMessage,
- // Persist this alert.
- dismissOnNavigation: true,
- showDismiss: true,
- action: 'Reload',
- callback: () => fireReload(this, true),
- },
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'show-alert', {
+ message: toastMessage,
+ // Persist this alert.
+ dismissOnNavigation: true,
+ showDismiss: true,
+ action: 'Reload',
+ callback: () => this.getChangeModel().navigateToChangeResetReload(),
+ });
});
}, this.serverConfig.change.update_delay * 1000);
}
@@ -3260,7 +2415,7 @@ export class GrChangeView extends LitElement {
return classes.join(' ');
}
- private handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+ private handleFileActionTap(e: FileActionTapEvent) {
e.preventDefault();
assertIsDefined(this.fileListHeader);
const controls =
@@ -3269,7 +2424,7 @@ export class GrChangeView extends LitElement {
);
if (!controls) throw new Error('Missing edit controls');
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
const path = e.detail.path;
switch (e.detail.action) {
@@ -3277,13 +2432,13 @@ export class GrChangeView extends LitElement {
controls.openDeleteDialog(path);
break;
case GrEditConstants.Actions.OPEN.id:
- assertIsDefined(this.patchRange.patchNum, 'patchset number');
+ assertIsDefined(this.patchNum, 'patchset number');
this.getNavigation().setUrl(
createEditUrl({
changeNum: this.change._number,
- project: this.change.project,
- path,
- patchNum: this.patchRange.patchNum,
+ repo: this.change.project,
+ patchNum: this.patchNum,
+ editView: {path},
})
);
break;
@@ -3296,21 +2451,6 @@ export class GrChangeView extends LitElement {
}
}
- private patchNumChanged() {
- if (!this.selectedRevision || !this.patchRange?.patchNum) {
- return;
- }
- assertIsDefined(this.change, 'change');
-
- if (this.patchRange.patchNum === this.selectedRevision._number) {
- return;
- }
- if (!this.change.revisions) return;
- this.selectedRevision = Object.values(this.change.revisions).find(
- revision => revision._number === this.patchRange!.patchNum
- );
- }
-
/**
* If an edit exists already, load it. Otherwise, toggle edit mode via the
* navigation API.
@@ -3331,7 +2471,7 @@ export class GrChangeView extends LitElement {
this.getNavigation().setUrl(
createChangeUrl({
change: this.change,
- patchNum: this.routerPatchNum,
+ patchNum: this.viewModelPatchNum,
edit: true,
forceReload: true,
})
@@ -3340,24 +2480,16 @@ export class GrChangeView extends LitElement {
private handleStopEditTap() {
assertIsDefined(this.change, 'change');
- assertIsDefined(this.patchRange, 'patchRange');
+ assertIsDefined(this.patchNum, 'patchNum');
this.getNavigation().setUrl(
createChangeUrl({
change: this.change,
- patchNum: this.patchRange.patchNum,
+ patchNum: this.patchNum,
forceReload: true,
})
);
}
- private resetReplyOverlayFocusStops() {
- const dialog = this.replyDialog;
- const focusStops = dialog?.getFocusStops();
- if (!focusStops) return;
- assertIsDefined(this.replyOverlay);
- this.replyOverlay.setFocusStops(focusStops);
- }
-
// Private but used in tests.
async handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
if (e.detail.starred) {
@@ -3379,18 +2511,7 @@ export class GrChangeView extends LitElement {
e.detail.change._number,
e.detail.starred
);
- fireEvent(this, 'hide-alert');
- }
-
- private getRevisionInfo(): RevisionInfoClass | undefined {
- if (this.change === undefined) return undefined;
- return new RevisionInfoClass(this.change);
- }
-
- getRelatedChangesList() {
- return this.shadowRoot!.querySelector<GrRelatedChangesList>(
- '#relatedChanges'
- );
+ fire(this, 'hide-alert', {});
}
createTitle(shortcutName: Shortcut, section: ShortcutSection) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 0d2359e054..daf025764a 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -10,101 +10,81 @@ import './gr-change-view';
import {
ChangeStatus,
CommentSide,
- DefaultBase,
DiffViewMode,
- HttpMethod,
- MessageTag,
createDefaultPreferences,
Tab,
} from '../../../constants/constants';
import {GrEditConstants} from '../../edit/gr-edit-constants';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {EventType, PluginApi} from '../../../api/plugin';
+import {PluginApi} from '../../../api/plugin';
import {
mockPromise,
pressKey,
queryAndAssert,
stubFlags,
stubRestApi,
- stubUsers,
- waitEventLoop,
- waitQueryAndAssert,
waitUntil,
+ waitUntilVisible,
} from '../../../test/test-utils';
import {
createChangeViewState,
- createApproval,
- createChange,
createChangeMessages,
- createCommit,
- createMergeable,
- createPreferences,
createRevision,
createRevisions,
createServerInfo,
createUserConfig,
TEST_NUMERIC_CHANGE_ID,
TEST_PROJECT_NAME,
- createEditRevision,
- createAccountWithIdNameAndEmail,
createChangeViewChange,
- createRelatedChangeAndCommitInfo,
createAccountDetailWithId,
createParsedChange,
- createDraft,
} from '../../../test/test-data-generators';
import {GrChangeView} from './gr-change-view';
import {
AccountId,
- ApprovalInfo,
BasePatchSetNum,
- ChangeId,
- ChangeInfo,
CommitId,
EDIT,
NumericChangeId,
PARENT,
- RelatedChangeAndCommitInfo,
- ReviewInputTag,
- RevisionInfo,
RevisionPatchSetNum,
RobotId,
RobotCommentInfo,
Timestamp,
UrlEncodedCommentId,
- DetailedLabelInfo,
RepoName,
- QuickLabelInfo,
+ CommentThread,
+ SavingState,
} from '../../../types/common';
import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CommentThread} from '../../../utils/comment-util';
+import {SinonFakeTimers} from 'sinon';
import {GerritView} from '../../../services/router/router-model';
import {ParsedChangeInfo} from '../../../types/types';
-import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
-import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
-import {LoadingStatus} from '../../../models/change/change-model';
-import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+ ChangeModel,
+ changeModelToken,
+ LoadingStatus,
+} from '../../../models/change/change-model';
+import {FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
import {GrThreadList} from '../gr-thread-list/gr-thread-list';
import {assertIsDefined} from '../../../utils/common-util';
-import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list';
import {fixture, html, assert} from '@open-wc/testing';
-import {deepClone} from '../../../utils/deep-util';
import {Modifier} from '../../../utils/dom-util';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
import {rootUrl} from '../../../utils/url-util';
import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {commentsModelToken} from '../../../models/comments/comments-model';
suite('gr-change-view tests', () => {
let element: GrChangeView;
let setUrlStub: sinon.SinonStub;
+ let userModel: UserModel;
+ let changeModel: ChangeModel;
const ROBOT_COMMENTS_LIMIT = 10;
@@ -149,7 +129,7 @@ suite('gr-change-view tests', () => {
updated: '2018-02-13 22:48:48.018000000' as Timestamp,
message: 'draft',
unresolved: false,
- __draft: true,
+ savingState: SavingState.OK,
patch_set: 2 as RevisionPatchSetNum,
},
],
@@ -252,7 +232,7 @@ suite('gr-change-view tests', () => {
updated: '2018-02-15 22:48:48.018000000' as Timestamp,
message: 'resolved draft',
unresolved: false,
- __draft: true,
+ savingState: SavingState.OK,
patch_set: 2 as RevisionPatchSetNum,
},
],
@@ -327,8 +307,6 @@ suite('gr-change-view tests', () => {
];
setup(async () => {
- // Since pluginEndpoints are global, must reset state.
- _testOnly_resetEndpoints();
setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
stubRestApi('getConfig').returns(
@@ -347,7 +325,6 @@ suite('gr-change-view tests', () => {
stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
- getPluginLoader().loadPlugins([]);
window.Gerrit.install(
plugin => {
plugin.registerDynamicCustomComponent(
@@ -367,13 +344,16 @@ suite('gr-change-view tests', () => {
);
element.viewState = {
view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
changeNum: TEST_NUMERIC_CHANGE_ID,
- project: 'gerrit' as RepoName,
+ repo: 'gerrit' as RepoName,
};
await element.updateComplete.then(() => {
assertIsDefined(element.actions);
sinon.stub(element.actions, 'reload').returns(Promise.resolve());
});
+ userModel = testResolver(userModelToken);
+ changeModel = testResolver(changeModelToken);
});
teardown(async () => {
@@ -410,16 +390,16 @@ suite('gr-change-view tests', () => {
</gr-copy-clipboard>
</div>
<div class="commitActions">
- <gr-change-actions hidden="" id="actions"> </gr-change-actions>
+ <gr-change-actions id="actions"> </gr-change-actions>
</div>
</div>
<h2 class="assistive-tech-only">Change metadata</h2>
<div class="changeInfo">
- <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
+ <div class="changeInfo-column changeMetadata">
<gr-change-metadata id="metadata"> </gr-change-metadata>
</div>
<div class="changeInfo-column mainChangeInfo" id="mainChangeInfo">
- <div class="hideOnMobileOverlay" id="commitAndRelated">
+ <div id="commitAndRelated">
<div class="commitContainer">
<h3 class="assistive-tech-only">Commit Message</h3>
<div>
@@ -442,11 +422,6 @@ suite('gr-change-view tests', () => {
>
<gr-formatted-text></gr-formatted-text>
</gr-editable-content>
- <div class="changeId" hidden="">
- <hr />
- Change-Id:
- <span class="" title=""></span>
- </div>
</div>
<h3 class="assistive-tech-only">
Comments and Checks Summary
@@ -458,8 +433,7 @@ suite('gr-change-view tests', () => {
</gr-endpoint-decorator>
</div>
<div class="relatedChanges">
- <gr-related-changes-list id="relatedChanges">
- </gr-related-changes-list>
+ <gr-related-changes-list></gr-related-changes-list>
</div>
<div class="emptySpace"></div>
</div>
@@ -506,8 +480,7 @@ suite('gr-change-view tests', () => {
<section class="tabContent">
<div>
<gr-file-list-header id="fileListHeader"> </gr-file-list-header>
- <gr-file-list class="hideOnMobileOverlay" id="fileList">
- </gr-file-list>
+ <gr-file-list id="fileList"> </gr-file-list>
</div>
</section>
<gr-endpoint-decorator name="change-view-integration">
@@ -528,51 +501,25 @@ suite('gr-change-view tests', () => {
</paper-tabs>
<section class="changeLog">
<h2 class="assistive-tech-only">Change Log</h2>
- <gr-messages-list class="hideOnMobileOverlay"> </gr-messages-list>
+ <gr-messages-list> </gr-messages-list>
</section>
</div>
<gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
- <gr-overlay
- aria-hidden="true"
- id="downloadOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="downloadModal" tabindex="-1">
<gr-download-dialog id="downloadDialog" role="dialog">
</gr-download-dialog>
- </gr-overlay>
- <gr-overlay
- aria-hidden="true"
- id="includedInOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ </dialog>
+ <dialog id="includedInModal" tabindex="-1">
<gr-included-in-dialog id="includedInDialog"> </gr-included-in-dialog>
- </gr-overlay>
- <gr-overlay
- aria-hidden="true"
- class="scrollable"
- id="replyOverlay"
- no-cancel-on-esc-key=""
- no-cancel-on-outside-click=""
- scroll-action="lock"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
- </gr-overlay>
+ </dialog>
+ <dialog id="replyModal"></dialog>
`
);
});
test('handleMessageAnchorTap', async () => {
element.changeNum = 1 as NumericChangeId;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
element.change = createChangeViewChange();
await element.updateComplete;
const replaceStateStub = sinon.stub(history, 'replaceState');
@@ -588,10 +535,8 @@ suite('gr-change-view tests', () => {
...createChangeViewChange(),
revisions: createRevisions(10),
};
- element.patchRange = {
- patchNum: 3 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
+ element.basePatchNum = 1 as BasePatchSetNum;
+ element.patchNum = 3 as RevisionPatchSetNum;
element.handleDiffAgainstBase();
assert.isTrue(setUrlStub.called);
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3');
@@ -602,10 +547,8 @@ suite('gr-change-view tests', () => {
...createChangeViewChange(),
revisions: createRevisions(10),
};
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.basePatchNum = 1 as BasePatchSetNum;
+ element.patchNum = 3 as RevisionPatchSetNum;
element.handleDiffAgainstLatest();
assert.isTrue(setUrlStub.called);
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..10');
@@ -616,10 +559,8 @@ suite('gr-change-view tests', () => {
...createChangeViewChange(),
revisions: createRevisions(10),
};
- element.patchRange = {
- patchNum: 3 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
+ element.basePatchNum = 1 as BasePatchSetNum;
+ element.patchNum = 3 as RevisionPatchSetNum;
element.handleDiffBaseAgainstLeft();
assert.isTrue(setUrlStub.called);
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
@@ -630,10 +571,8 @@ suite('gr-change-view tests', () => {
...createChangeViewChange(),
revisions: createRevisions(10),
};
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.basePatchNum = 1 as BasePatchSetNum;
+ element.patchNum = 3 as RevisionPatchSetNum;
element.handleDiffRightAgainstLatest();
assert.isTrue(setUrlStub.called);
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/3..10');
@@ -644,10 +583,8 @@ suite('gr-change-view tests', () => {
...createChangeViewChange(),
revisions: createRevisions(10),
};
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.basePatchNum = 1 as BasePatchSetNum;
+ element.patchNum = 3 as RevisionPatchSetNum;
element.handleDiffBaseAgainstLatest();
assert.isTrue(setUrlStub.called);
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/10');
@@ -665,10 +602,8 @@ suite('gr-change-view tests', () => {
const removeFromAttentionSetStub = stubRestApi(
'removeFromAttentionSet'
).returns(Promise.resolve(new Response()));
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.basePatchNum = 1 as BasePatchSetNum;
+ element.patchNum = 3 as RevisionPatchSetNum;
await element.updateComplete;
assert.isNotOk(element.change.attention_set);
element.handleToggleAttentionSet();
@@ -723,19 +658,6 @@ suite('gr-change-view tests', () => {
assert.equal(element.activeTab, 'change-view-tab-header-url');
});
- test('param change should switch primary tab correctly', async () => {
- assert.equal(element.activeTab, Tab.FILES);
- // view is required
- element.changeNum = undefined;
- element.viewState = {
- ...createChangeViewState(),
- ...element.viewState,
- tab: Tab.COMMENT_THREADS,
- };
- await element.updateComplete;
- assert.equal(element.activeTab, Tab.COMMENT_THREADS);
- });
-
test('invalid param change should not switch primary tab', async () => {
assert.equal(element.activeTab, Tab.FILES);
// view is required
@@ -817,107 +739,59 @@ suite('gr-change-view tests', () => {
});
test('A fires an error event when not logged in', async () => {
- element.userModel.setAccount(undefined);
+ userModel.setAccount(undefined);
const loggedInErrorSpy = sinon.spy();
element.addEventListener('show-auth-required', loggedInErrorSpy);
pressKey(element, 'a');
await element.updateComplete;
- assertIsDefined(element.replyOverlay);
- assert.isFalse(element.replyOverlay.opened);
+ assertIsDefined(element.replyModal);
+ assert.isFalse(element.replyModalOpened);
assert.isTrue(loggedInErrorSpy.called);
});
test('shift A does not open reply overlay', async () => {
pressKey(element, 'a', Modifier.SHIFT_KEY);
await element.updateComplete;
- assertIsDefined(element.replyOverlay);
- assert.isFalse(element.replyOverlay.opened);
+ assertIsDefined(element.replyModal);
+ assert.isFalse(element.replyModalOpened);
});
test('A toggles overlay when logged in', async () => {
- element.change = {
+ // restore clock so that setTimeout in waitUntil() works as expected
+ clock.restore();
+ stubRestApi('getChangeDetail').returns(
+ Promise.resolve(createParsedChange())
+ );
+ const change = {
...createChangeViewChange(),
revisions: createRevisions(1),
messages: createChangeMessages(1),
};
- element.change.labels = {};
+ change.labels = {};
+ element.change = change;
+
+ changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
+ change,
+ });
+
await element.updateComplete;
const openSpy = sinon.spy(element, 'openReplyDialog');
pressKey(element, 'a');
await element.updateComplete;
- assertIsDefined(element.replyOverlay);
- assert.isTrue(element.replyOverlay.opened);
- element.replyOverlay.close();
- assert.isFalse(element.replyOverlay.opened);
+ assertIsDefined(element.replyModal);
+ assert.isTrue(element.replyModalOpened);
+ sinon.spy(element.replyDialog!, 'open');
+ await waitUntilVisible(element.replyDialog!);
+ element.replyModal.close();
assert(
openSpy.lastCall.calledWithExactly(FocusTarget.ANY),
'openReplyDialog should have been passed ANY'
);
assert.equal(openSpy.callCount, 1);
- });
-
- test('fullscreen-overlay-opened hides content', async () => {
- element.loggedIn = true;
- element.loading = false;
- element.change = {
- ...createChangeViewChange(),
- labels: {},
- actions: {
- abandon: {
- enabled: true,
- label: 'Abandon',
- method: HttpMethod.POST,
- title: 'Abandon',
- },
- },
- };
- await element.updateComplete;
- const handlerSpy = sinon.spy(element, 'handleHideBackgroundContent');
- const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
- overlay.dispatchEvent(
- new CustomEvent('fullscreen-overlay-opened', {
- composed: true,
- bubbles: true,
- })
- );
- await element.updateComplete;
- assert.isTrue(handlerSpy.called);
- assertIsDefined(element.mainContent);
- assertIsDefined(element.actions);
- assert.isTrue(element.mainContent.classList.contains('overlayOpen'));
- assert.equal(getComputedStyle(element.actions).display, 'flex');
- });
-
- test('fullscreen-overlay-closed shows content', async () => {
- element.loggedIn = true;
- element.loading = false;
- element.change = {
- ...createChangeViewChange(),
- labels: {},
- actions: {
- abandon: {
- enabled: true,
- label: 'Abandon',
- method: HttpMethod.POST,
- title: 'Abandon',
- },
- },
- };
- await element.updateComplete;
- const handlerSpy = sinon.spy(element, 'handleShowBackgroundContent');
- const overlay = queryAndAssert<GrOverlay>(element, '#replyOverlay');
- overlay.dispatchEvent(
- new CustomEvent('fullscreen-overlay-closed', {
- composed: true,
- bubbles: true,
- })
- );
- await element.updateComplete;
- assert.isTrue(handlerSpy.called);
- assertIsDefined(element.mainContent);
- assert.isFalse(element.mainContent.classList.contains('overlayOpen'));
+ await waitUntil(() => !element.replyModalOpened);
});
test('expand all messages when expand-diffs fired', () => {
@@ -967,10 +841,8 @@ suite('gr-change-view tests', () => {
});
test('d should open download overlay', () => {
- assertIsDefined(element.downloadOverlay);
- const stub = sinon
- .stub(element.downloadOverlay, 'open')
- .returns(Promise.resolve());
+ assertIsDefined(element.downloadModal);
+ const stub = sinon.stub(element.downloadModal, 'showModal');
pressKey(element, 'd');
assert.isTrue(stub.called);
});
@@ -990,14 +862,14 @@ suite('gr-change-view tests', () => {
});
test('m should toggle diff mode', async () => {
- const updatePreferencesStub = stubUsers('updatePreferences');
+ const updatePreferencesStub = sinon.stub(userModel, 'updatePreferences');
await element.updateComplete;
const prefs = {
...createDefaultPreferences(),
diff_view: DiffViewMode.SIDE_BY_SIDE,
};
- element.userModel.setPreferences(prefs);
+ userModel.setPreferences(prefs);
element.handleToggleDiffMode();
assert.isTrue(
updatePreferencesStub.calledWith({diff_view: DiffViewMode.UNIFIED})
@@ -1007,7 +879,7 @@ suite('gr-change-view tests', () => {
...createDefaultPreferences(),
diff_view: DiffViewMode.UNIFIED,
};
- element.userModel.setPreferences(newPrefs);
+ userModel.setPreferences(newPrefs);
await element.updateComplete;
element.handleToggleDiffMode();
assert.isTrue(
@@ -1019,10 +891,8 @@ suite('gr-change-view tests', () => {
suite('thread list and change log tabs', () => {
setup(() => {
element.changeNum = TEST_NUMERIC_CHANGE_ID;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
+ element.basePatchNum = PARENT;
+ element.patchNum = 1 as RevisionPatchSetNum;
element.change = {
...createChangeViewChange(),
revisions: {
@@ -1042,12 +912,6 @@ suite('gr-change-view tests', () => {
},
},
};
- const relatedChanges = element.shadowRoot!.querySelector(
- '#relatedChanges'
- ) as GrRelatedChangesList;
- sinon.stub(relatedChanges, 'reload');
- sinon.stub(element, 'loadData').returns(Promise.resolve());
- sinon.spy(element, 'viewStateChanged');
element.viewState = createChangeViewState();
});
});
@@ -1227,183 +1091,6 @@ suite('gr-change-view tests', () => {
);
});
- test('changeStatuses', async () => {
- element.loading = false;
- element.change = {
- ...createChangeViewChange(),
- revisions: {
- rev2: createRevision(2),
- rev1: createRevision(1),
- rev13: createRevision(13),
- rev3: createRevision(3),
- },
- current_revision: 'rev3' as CommitId,
- status: ChangeStatus.MERGED,
- labels: {
- test: {
- all: [],
- default_value: 0,
- values: {},
- approved: {},
- },
- },
- };
- element.mergeable = true;
- await element.updateComplete;
- const expectedStatuses = [ChangeStates.MERGED];
- assert.deepEqual(element.changeStatuses, expectedStatuses);
- const statusChips =
- element.shadowRoot!.querySelectorAll('gr-change-status');
- assert.equal(statusChips.length, 1);
- });
-
- suite('ChangeStatus revert', () => {
- test('do not show any chip if no revert created', async () => {
- const change = {
- ...createParsedChange(),
- messages: createChangeMessages(2),
- };
- const getChangeStub = stubRestApi('getChange');
- getChangeStub.onFirstCall().returns(
- Promise.resolve({
- ...createChange(),
- })
- );
- getChangeStub.onSecondCall().returns(
- Promise.resolve({
- ...createChange(),
- })
- );
- element.change = change;
- element.mergeable = true;
- element.currentRevisionActions = {submit: {enabled: true}};
- assert.isTrue(element.isSubmitEnabled());
- await element.updateComplete;
- element.computeRevertSubmitted(element.change);
- await element.updateComplete;
- assert.isFalse(
- element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
- );
- assert.isFalse(
- element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
- );
- });
-
- test('do not show any chip if all reverts are abandoned', async () => {
- const change = {
- ...createParsedChange(),
- messages: createChangeMessages(2),
- };
- change.messages[0].message = 'Created a revert of this change as 12345';
- change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
- change.messages[1].message = 'Created a revert of this change as 23456';
- change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
- const getChangeStub = stubRestApi('getChange');
- getChangeStub.onFirstCall().returns(
- Promise.resolve({
- ...createChange(),
- status: ChangeStatus.ABANDONED,
- })
- );
- getChangeStub.onSecondCall().returns(
- Promise.resolve({
- ...createChange(),
- status: ChangeStatus.ABANDONED,
- })
- );
- element.change = change;
- element.mergeable = true;
- element.currentRevisionActions = {submit: {enabled: true}};
- assert.isTrue(element.isSubmitEnabled());
- await element.updateComplete;
- element.computeRevertSubmitted(element.change);
- await element.updateComplete;
- assert.isFalse(
- element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
- );
- assert.isFalse(
- element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
- );
- });
-
- test('show revert created if no revert is merged', async () => {
- const change = {
- ...createParsedChange(),
- messages: createChangeMessages(2),
- };
- change.messages[0].message = 'Created a revert of this change as 12345';
- change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
- change.messages[1].message = 'Created a revert of this change as 23456';
- change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
-
- const getChangeStub = stubRestApi('getChange');
- getChangeStub.onFirstCall().returns(
- Promise.resolve({
- ...createChange(),
- })
- );
- getChangeStub.onSecondCall().returns(
- Promise.resolve({
- ...createChange(),
- })
- );
- element.change = change;
- element.mergeable = true;
- element.currentRevisionActions = {submit: {enabled: true}};
- assert.isTrue(element.isSubmitEnabled());
- await element.updateComplete;
- element.computeRevertSubmitted(element.change);
- // Wait for promises to settle.
- await waitEventLoop();
- await element.updateComplete;
- assert.isFalse(
- element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
- );
- assert.isTrue(
- element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
- );
- });
-
- test('show revert submitted if revert is merged', async () => {
- const change = {
- ...createParsedChange(),
- messages: createChangeMessages(2),
- };
- change.messages[0].message = 'Created a revert of this change as 12345';
- change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
- const getChangeStub = stubRestApi('getChange');
- getChangeStub.onFirstCall().returns(
- Promise.resolve({
- ...createChange(),
- status: ChangeStatus.MERGED,
- })
- );
- getChangeStub.onSecondCall().returns(
- Promise.resolve({
- ...createChange(),
- })
- );
- element.change = change;
- element.mergeable = true;
- element.currentRevisionActions = {submit: {enabled: true}};
- assert.isTrue(element.isSubmitEnabled());
- await element.updateComplete;
- element.computeRevertSubmitted(element.change);
- // Wait for promises to settle.
- await waitEventLoop();
- await element.updateComplete;
- assert.isFalse(
- element.changeStatuses?.includes(ChangeStates.REVERT_CREATED)
- );
- assert.isTrue(
- element.changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
- );
- });
- });
-
test('diff preferences open when open-diff-prefs is fired', async () => {
await element.updateComplete;
assertIsDefined(element.fileList);
@@ -1441,66 +1128,6 @@ suite('gr-change-view tests', () => {
assert.isTrue(element.isSubmitEnabled());
});
- test('reload is called when an approved label is removed', async () => {
- const vote: ApprovalInfo = {
- ...createApproval(),
- _account_id: 1 as AccountId,
- name: 'bojack',
- value: 1,
- };
- element.changeNum = TEST_NUMERIC_CHANGE_ID;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
- const change = {
- ...createParsedChange(),
- owner: createAccountWithIdNameAndEmail(),
- revisions: {
- rev2: createRevision(2),
- rev1: createRevision(1),
- rev13: createRevision(13),
- rev3: createRevision(3),
- },
- current_revision: 'rev3' as CommitId,
- status: ChangeStatus.NEW,
- labels: {
- test: {
- all: [vote],
- default_value: 0,
- values: {},
- approved: {},
- },
- },
- };
- element.change = change;
- await element.updateComplete;
- const reloadStub = sinon.stub(element, 'loadData');
- const newChange = {...element.change};
- (newChange.labels!.test! as DetailedLabelInfo).all = [];
- element.change = deepClone(newChange);
- await element.updateComplete;
- assert.isFalse(reloadStub.called);
-
- assert.isDefined(element.change);
- const testLabels: DetailedLabelInfo & QuickLabelInfo =
- newChange.labels!.test;
- assertIsDefined(testLabels);
- testLabels.all!.push(vote);
- testLabels.all!.push(vote);
- testLabels.approved = vote;
- element.change = deepClone(newChange);
- await element.updateComplete;
- assert.isFalse(reloadStub.called);
-
- assert.isDefined(element.change);
- (newChange.labels!.test! as DetailedLabelInfo).all = [];
- element.change = deepClone(newChange);
- await element.updateComplete;
- assert.isTrue(reloadStub.called);
- assert.isTrue(reloadStub.calledOnce);
- });
-
test('reply button has updated count when there are drafts', () => {
const getLabel = (canReview: boolean) => {
element.change!.actions!.ready = {enabled: canReview};
@@ -1508,205 +1135,19 @@ suite('gr-change-view tests', () => {
};
element.change = createParsedChange();
element.change.actions = {};
- element.diffDrafts = undefined;
+ element.draftCount = 0;
assert.equal(getLabel(false), 'Reply');
- assert.equal(getLabel(true), 'Reply');
+ assert.equal(getLabel(true), 'Start Review');
- element.diffDrafts = {};
+ element.draftCount = 0;
assert.equal(getLabel(false), 'Reply');
assert.equal(getLabel(true), 'Start Review');
- element.diffDrafts = {
- 'file1.txt': [createDraft()],
- 'file2.txt': [createDraft(), createDraft()],
- };
+ element.draftCount = 3;
assert.equal(getLabel(false), 'Reply (3)');
assert.equal(getLabel(true), 'Start Review (3)');
});
- test('change num change', async () => {
- const change = {
- ...createChangeViewChange(),
- labels: {},
- } as ParsedChangeInfo;
- element.changeNum = undefined;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 2 as RevisionPatchSetNum,
- };
- element.change = change;
- assertIsDefined(element.fileList);
- assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
- element.fileList.numFilesShown = 150;
- element.fileList.selectedIndex = 15;
- await element.updateComplete;
-
- element.changeNum = 2 as NumericChangeId;
- element.viewState = {
- ...createChangeViewState(),
- changeNum: 2 as NumericChangeId,
- };
- await element.updateComplete;
- assert.equal(element.fileList.numFilesShown, DEFAULT_NUM_FILES_SHOWN);
- assert.equal(element.fileList.selectedIndex, 0);
- });
-
- test('don’t reload entire page when patchRange changes', async () => {
- const reloadStub = sinon
- .stub(element, 'loadData')
- .callsFake(() => Promise.resolve());
- const reloadPatchDependentStub = sinon
- .stub(element, 'reloadPatchNumDependentResources')
- .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
- assertIsDefined(element.fileList);
- await element.fileList.updateComplete;
- const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
- const value: ChangeViewState = {
- ...createChangeViewState(),
- view: GerritView.CHANGE,
- patchNum: 1 as RevisionPatchSetNum,
- };
- element.changeNum = undefined;
- element.viewState = value;
- await element.updateComplete;
- assert.isTrue(reloadStub.calledOnce);
-
- element.initialLoadComplete = true;
- element.fileList.selectedIndex = 15;
- element.change = {
- ...createChangeViewChange(),
- revisions: {
- rev1: createRevision(1),
- rev2: createRevision(2),
- },
- };
-
- value.basePatchNum = 1 as BasePatchSetNum;
- value.patchNum = 2 as RevisionPatchSetNum;
- element.viewState = {...value};
- await element.updateComplete;
- await waitEventLoop();
- assert.equal(element.fileList.selectedIndex, 0);
- assert.isFalse(reloadStub.calledTwice);
- assert.isTrue(reloadPatchDependentStub.calledOnce);
- assert.isTrue(collapseStub.calledTwice);
- });
-
- test('reload ported comments when patchNum changes', async () => {
- assertIsDefined(element.fileList);
- sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
- sinon.stub(element, 'loadAndSetCommitInfo');
- await element.updateComplete;
- const reloadPortedCommentsStub = sinon.stub(
- element.getCommentsModel(),
- 'reloadPortedComments'
- );
- const reloadPortedDraftsStub = sinon.stub(
- element.getCommentsModel(),
- 'reloadPortedDrafts'
- );
- sinon.stub(element.fileList, 'collapseAllDiffs');
-
- const value: ChangeViewState = {
- ...createChangeViewState(),
- view: GerritView.CHANGE,
- patchNum: 1 as RevisionPatchSetNum,
- };
- element.viewState = value;
- await element.updateComplete;
-
- element.initialLoadComplete = true;
- element.change = {
- ...createChangeViewChange(),
- revisions: {
- rev1: createRevision(1),
- rev2: createRevision(2),
- },
- };
-
- value.basePatchNum = 1 as BasePatchSetNum;
- value.patchNum = 2 as RevisionPatchSetNum;
- element.viewState = {...value};
- await element.updateComplete;
- assert.isTrue(reloadPortedCommentsStub.calledOnce);
- assert.isTrue(reloadPortedDraftsStub.calledOnce);
- });
-
- test('do not reload entire page when patchRange doesnt change', async () => {
- assertIsDefined(element.fileList);
- const reloadStub = sinon
- .stub(element, 'loadData')
- .callsFake(() => Promise.resolve());
- const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
- const value: ChangeViewState = createChangeViewState();
- element.viewState = value;
- // change already loaded
- assert.isOk(element.changeNum);
- await element.updateComplete;
- assert.isFalse(reloadStub.calledOnce);
- element.initialLoadComplete = true;
- element.viewState = {...value};
- await element.updateComplete;
- assert.isFalse(reloadStub.calledTwice);
- assert.isFalse(collapseStub.calledTwice);
- });
-
- test('forceReload updates the change', async () => {
- assertIsDefined(element.fileList);
- const getChangeStub = stubRestApi('getChangeDetail').returns(
- Promise.resolve(createParsedChange())
- );
- const loadDataStub = sinon
- .stub(element, 'loadData')
- .callsFake(() => Promise.resolve());
- const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
- element.viewState = {...createChangeViewState(), forceReload: true};
- await element.updateComplete;
- assert.isTrue(getChangeStub.called);
- assert.isTrue(loadDataStub.called);
- assert.isTrue(collapseStub.called);
- // patchNum is set by changeChanged, so this verifies that change was set.
- assert.isOk(element.patchRange?.patchNum);
- });
-
- test('do not handle new change numbers', async () => {
- const recreateSpy = sinon.spy();
- element.addEventListener('recreate-change-view', recreateSpy);
-
- const value: ChangeViewState = createChangeViewState();
- element.viewState = value;
- await element.updateComplete;
- assert.isFalse(recreateSpy.calledOnce);
-
- value.changeNum = 555111333 as NumericChangeId;
- element.viewState = {...value};
- await element.updateComplete;
- assert.isTrue(recreateSpy.calledOnce);
- });
-
- test('related changes are updated when loadData is called', async () => {
- await element.updateComplete;
- const relatedChanges = element.shadowRoot!.querySelector(
- '#relatedChanges'
- ) as GrRelatedChangesList;
- const reloadStub = sinon.stub(relatedChanges, 'reload');
- stubRestApi('getMergeable').returns(
- Promise.resolve({...createMergeable(), mergeable: true})
- );
-
- element.viewState = createChangeViewState();
- element.getChangeModel().setState({
- loadingStatus: LoadingStatus.LOADED,
- change: {
- ...createChangeViewChange(),
- },
- });
-
- await element.loadData(true);
- assert.isFalse(setUrlStub.called);
- assert.isTrue(reloadStub.called);
- });
-
test('computeCopyTextForTitle', () => {
element.change = {
...createChangeViewChange(),
@@ -1724,26 +1165,6 @@ suite('gr-change-view tests', () => {
);
});
- test('get latest revision', () => {
- let change: ChangeInfo = {
- ...createChange(),
- revisions: {
- rev1: createRevision(1),
- rev3: createRevision(3),
- },
- current_revision: 'rev3' as CommitId,
- };
- assert.equal(element.getLatestRevisionSHA(change), 'rev3');
- change = {
- ...createChange(),
- revisions: {
- rev1: createRevision(1),
- },
- current_revision: undefined,
- };
- assert.equal(element.getLatestRevisionSHA(change), 'rev1');
- });
-
test('show commit message edit button', () => {
const change = createParsedChange();
const mergedChanged: ParsedChangeInfo = {
@@ -1766,6 +1187,7 @@ suite('gr-change-view tests', () => {
});
test('handleCommitMessageSave trims trailing whitespace', async () => {
+ element.changeNum = TEST_NUMERIC_CHANGE_ID;
element.change = createChangeViewChange();
// Response code is 500, because we want to avoid window reloading
const putStub = stubRestApi('putChangeCommitMessage').returns(
@@ -1785,182 +1207,6 @@ suite('gr-change-view tests', () => {
element.handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
});
- test('computeChangeIdCommitMessageError', () => {
- let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
- let change: ParsedChangeInfo = {
- ...createChangeViewChange(),
- change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
- };
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- null
- );
-
- change = {
- ...createChangeViewChange(),
- change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
- };
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- 'mismatch'
- );
-
- commitMessage = 'This is the greatest change.';
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- 'missing'
- );
- });
-
- test('multiple change Ids in commit message picks last', () => {
- const commitMessage = [
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
- ].join('\n');
- let change: ParsedChangeInfo = {
- ...createChangeViewChange(),
- change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
- };
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- null
- );
- change = {
- ...createChangeViewChange(),
- change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
- };
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- 'mismatch'
- );
- });
-
- test('does not count change Id that starts mid line', () => {
- const commitMessage = [
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
- ].join(' and ');
- let change: ParsedChangeInfo = {
- ...createChangeViewChange(),
- change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
- };
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- null
- );
- change = {
- ...createChangeViewChange(),
- change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
- };
- assert.equal(
- element.computeChangeIdCommitMessageError(commitMessage, change),
- 'mismatch'
- );
- });
-
- test('computeTitleAttributeWarning', () => {
- let changeIdCommitMessageError = 'missing';
- assert.equal(
- element.computeTitleAttributeWarning(changeIdCommitMessageError),
- 'No Change-Id in commit message'
- );
-
- changeIdCommitMessageError = 'mismatch';
- assert.equal(
- element.computeTitleAttributeWarning(changeIdCommitMessageError),
- 'Change-Id mismatch'
- );
- });
-
- test('computeChangeIdClass', () => {
- let changeIdCommitMessageError = 'missing';
- assert.equal(element.computeChangeIdClass(changeIdCommitMessageError), '');
-
- changeIdCommitMessageError = 'mismatch';
- assert.equal(
- element.computeChangeIdClass(changeIdCommitMessageError),
- 'warning'
- );
- });
-
- test('topic is coalesced to null', async () => {
- sinon.stub(element, 'changeChanged');
- element.getChangeModel().setState({
- loadingStatus: LoadingStatus.LOADED,
- change: {
- ...createChangeViewChange(),
- labels: {},
- current_revision: 'foo' as CommitId,
- revisions: {foo: createRevision()},
- },
- });
-
- await element.performPostChangeLoadTasks();
- assert.isNull(element.change!.topic);
- });
-
- test('commit sha is populated from getChangeDetail', async () => {
- element.getChangeModel().setState({
- loadingStatus: LoadingStatus.LOADED,
- change: {
- ...createChangeViewChange(),
- labels: {},
- current_revision: 'foo' as CommitId,
- revisions: {foo: createRevision()},
- },
- });
-
- await element.performPostChangeLoadTasks();
- assert.equal('foo', element.commitInfo!.commit);
- });
-
- test('getBasePatchNum', async () => {
- element.change = {
- ...createChangeViewChange(),
- revisions: {
- '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
- },
- };
- element.patchRange = {
- basePatchNum: PARENT,
- };
- await element.updateComplete;
- assert.equal(element.getBasePatchNum(), PARENT);
-
- element.prefs = {
- ...createPreferences(),
- default_base_for_merges: DefaultBase.FIRST_PARENT,
- };
-
- element.change = {
- ...createChangeViewChange(),
- revisions: {
- '98da160735fb81604b4c40e93c368f380539dd0e': {
- ...createRevision(1),
- commit: {
- ...createCommit(),
- parents: [
- {
- commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
- subject: 'test',
- },
- {
- commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
- subject: 'test3',
- },
- ],
- },
- },
- },
- };
- await element.updateComplete;
- assert.equal(element.getBasePatchNum(), -1 as BasePatchSetNum);
-
- element.patchRange.basePatchNum = PARENT;
- element.patchRange.patchNum = 1 as RevisionPatchSetNum;
- await element.updateComplete;
- assert.equal(element.getBasePatchNum(), PARENT);
- });
test('openReplyDialog called with `ANY` when coming from tap event', async () => {
await element.updateComplete;
@@ -1974,29 +1220,11 @@ suite('gr-change-view tests', () => {
assert.equal(openStub.callCount, 1);
});
- test(
- 'openReplyDialog called with `BODY` when coming from message reply' +
- 'event',
- async () => {
- await element.updateComplete;
- const openStub = sinon.stub(element, 'openReplyDialog');
- element.messagesList!.dispatchEvent(
- new CustomEvent('reply', {
- detail: {message: {message: 'text'}},
- composed: true,
- bubbles: true,
- })
- );
- assert.isTrue(openStub.calledOnce);
- assert.equal(openStub.lastCall.args[0], FocusTarget.BODY);
- }
- );
-
test('reply dialog focus can be controlled', () => {
const openStub = sinon.stub(element, 'openReplyDialog');
const e = new CustomEvent('show-reply-dialog', {
- detail: {value: {ccsOnly: false}},
+ detail: {value: {reviewersOnly: true, ccsOnly: false}},
});
element.handleShowReplyDialog(e);
assert(
@@ -2005,7 +1233,7 @@ suite('gr-change-view tests', () => {
);
assert.equal(openStub.callCount, 1);
- e.detail.value = {ccsOnly: true};
+ e.detail.value = {reviewersOnly: false, ccsOnly: true};
element.handleShowReplyDialog(e);
assert(
openStub.lastCall.calledWithExactly(FocusTarget.CCS),
@@ -2030,13 +1258,11 @@ suite('gr-change-view tests', () => {
test('revert dialog opened with revert param', async () => {
const awaitPluginsLoadedStub = sinon
- .stub(getPluginLoader(), 'awaitPluginsLoaded')
+ .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded')
.callsFake(() => Promise.resolve());
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 2 as RevisionPatchSetNum,
- };
+ element.basePatchNum = PARENT;
+ element.patchNum = 2 as RevisionPatchSetNum;
element.change = {
...createChangeViewChange(),
revisions: {
@@ -2090,21 +1316,6 @@ suite('gr-change-view tests', () => {
await element.updateComplete;
assert.isTrue(openReplyDialogStub.calledOnce);
});
-
- test('reply from comment adds quote text', async () => {
- const e = new CustomEvent('', {
- detail: {message: {message: 'quote text'}},
- });
- element.handleMessageReply(e);
- const dialog = await waitQueryAndAssert<GrReplyDialog>(
- element,
- '#replyDialog'
- );
- const openSpy = sinon.spy(dialog, 'open');
- await element.updateComplete;
- await waitUntil(() => openSpy.called && !!openSpy.lastCall.args[1]);
- assert.equal(openSpy.lastCall.args[1], '> quote text\n\n');
- });
});
test('header class computation', () => {
@@ -2115,14 +1326,18 @@ suite('gr-change-view tests', () => {
});
test('maybeScrollToMessage', async () => {
+ element.change = {
+ ...createChangeViewChange(),
+ messages: createChangeMessages(1),
+ };
await element.updateComplete;
const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
- element.maybeScrollToMessage('');
+ await element.maybeScrollToMessage('');
assert.isFalse(scrollStub.called);
- element.maybeScrollToMessage('message');
+ await element.maybeScrollToMessage('message');
assert.isFalse(scrollStub.called);
- element.maybeScrollToMessage('#message-TEST');
+ await element.maybeScrollToMessage('#message-TEST');
assert.isTrue(scrollStub.called);
assert.equal(scrollStub.lastCall.args[0], 'TEST');
});
@@ -2130,6 +1345,8 @@ suite('gr-change-view tests', () => {
test('computeEditMode', async () => {
const callCompute = async (viewState: ChangeViewState) => {
element.viewState = viewState;
+ element.patchNum = viewState.patchNum;
+ element.basePatchNum = viewState.basePatchNum ?? PARENT;
await element.updateComplete;
return element.getEditMode();
};
@@ -2157,46 +1374,8 @@ suite('gr-change-view tests', () => {
);
});
- test('processEdit', () => {
- element.patchRange = {};
- const change: ParsedChangeInfo = {
- ...createChangeViewChange(),
- current_revision: 'foo' as CommitId,
- revisions: {
- foo: {...createRevision()},
- },
- };
-
- // With no edit, nothing happens.
- element.processEdit(change);
- assert.equal(element.patchRange.patchNum, undefined);
-
- change.revisions['bar'] = {
- _number: EDIT,
- basePatchNum: 1 as BasePatchSetNum,
- commit: {
- ...createCommit(),
- commit: 'bar' as CommitId,
- },
- fetch: {},
- };
-
- // When edit is set, but not patchNum, then switch to edit ps.
- element.processEdit(change);
- assert.equal(element.patchRange.patchNum, EDIT);
-
- // When edit is set, but patchNum as well, then keep patchNum.
- element.patchRange.patchNum = 5 as RevisionPatchSetNum;
- element.routerPatchNum = 5 as RevisionPatchSetNum;
- element.processEdit(change);
- assert.equal(element.patchRange.patchNum, 5 as RevisionPatchSetNum);
- });
-
test('file-action-tap handling', async () => {
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
element.change = {
...createChangeViewChange(),
};
@@ -2267,107 +1446,6 @@ suite('gr-change-view tests', () => {
assert.isTrue(setUrlStub.called);
});
- test('selectedRevision updates when patchNum is changed', async () => {
- const revision1: RevisionInfo = createRevision(1);
- const revision2: RevisionInfo = createRevision(2);
- element.getChangeModel().setState({
- loadingStatus: LoadingStatus.LOADED,
- change: {
- ...createChangeViewChange(),
- revisions: {
- aaa: revision1,
- bbb: revision2,
- },
- labels: {},
- actions: {},
- current_revision: 'bbb' as CommitId,
- },
- });
- element.userModel.setPreferences(createPreferences());
-
- element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
- await element.performPostChangeLoadTasks();
- assert.strictEqual(element.selectedRevision, revision2);
-
- element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
- await element.updateComplete;
- assert.strictEqual(element.selectedRevision, revision1);
- });
-
- test('selectedRevision is assigned when patchNum is edit', async () => {
- const revision1 = createRevision(1);
- const revision2 = createRevision(2);
- const revision3 = createEditRevision();
- element.getChangeModel().setState({
- loadingStatus: LoadingStatus.LOADED,
- change: {
- ...createChangeViewChange(),
- revisions: {
- aaa: revision1,
- bbb: revision2,
- ccc: revision3,
- },
- labels: {},
- actions: {},
- current_revision: 'ccc' as CommitId,
- },
- });
- stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
-
- element.patchRange = {patchNum: EDIT};
- await element.performPostChangeLoadTasks();
- assert.strictEqual(element.selectedRevision, revision3);
- });
-
- test('sendShowChangeEvent', () => {
- const change = {...createChangeViewChange(), labels: {}};
- element.change = {...change};
- element.patchRange = {patchNum: 4 as RevisionPatchSetNum};
- element.mergeable = true;
- const showStub = sinon.stub(element.jsAPI, 'handleEvent');
- element.sendShowChangeEvent();
- assert.isTrue(showStub.calledOnce);
- assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
- assert.deepEqual(showStub.lastCall.args[1], {
- change,
- patchNum: 4,
- info: {mergeable: true},
- });
- });
-
- test('patch range changed', () => {
- element.patchRange = undefined;
- element.change = createChangeViewChange();
- element.change.revisions = createRevisions(4);
- element.change.current_revision = '1' as CommitId;
- element.change = {...element.change};
-
- const viewState = createChangeViewState();
-
- assert.isFalse(element.hasPatchRangeChanged(viewState));
- assert.isFalse(element.hasPatchNumChanged(viewState));
-
- viewState.basePatchNum = PARENT;
- // undefined means navigate to latest patchset
- viewState.patchNum = undefined;
-
- element.patchRange = {
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: PARENT,
- };
-
- assert.isTrue(element.hasPatchRangeChanged(viewState));
- assert.isTrue(element.hasPatchNumChanged(viewState));
-
- element.patchRange = {
- patchNum: 4 as RevisionPatchSetNum,
- basePatchNum: PARENT,
- };
-
- assert.isFalse(element.hasPatchRangeChanged(viewState));
- assert.isFalse(element.hasPatchNumChanged(viewState));
- });
-
suite('handleEditTap', () => {
let fireEdit: () => void;
@@ -2400,7 +1478,7 @@ suite('gr-change-view tests', () => {
const newChange = {...element.change};
newChange.revisions.rev2 = createRevision(2);
element.change = newChange;
- element.routerPatchNum = 1 as RevisionPatchSetNum;
+ element.viewModelPatchNum = 1 as RevisionPatchSetNum;
await element.updateComplete;
fireEdit();
@@ -2416,7 +1494,7 @@ suite('gr-change-view tests', () => {
const newChange = {...element.change};
newChange.revisions.rev2 = createRevision(2);
element.change = newChange;
- element.patchRange = {patchNum: 2 as RevisionPatchSetNum};
+ element.patchNum = 2 as RevisionPatchSetNum;
await element.updateComplete;
fireEdit();
@@ -2437,7 +1515,7 @@ suite('gr-change-view tests', () => {
assertIsDefined(element.actions);
sinon.stub(element.metadata, 'computeLabelNames');
- element.patchRange = {patchNum: 1 as RevisionPatchSetNum};
+ element.patchNum = 1 as RevisionPatchSetNum;
element.actions.dispatchEvent(
new CustomEvent('stop-edit-tap', {bubbles: false})
);
@@ -2452,7 +1530,7 @@ suite('gr-change-view tests', () => {
suite('plugin endpoints', () => {
test('endpoint params', async () => {
element.change = {...createChangeViewChange(), labels: {}};
- element.selectedRevision = createRevision();
+ element.revision = createRevision();
const promise = mockPromise();
window.Gerrit.install(
promise.resolve,
@@ -2466,43 +1544,7 @@ suite('gr-change-view tests', () => {
.getLastAttached();
assert.strictEqual((hookEl as any).plugin, plugin);
assert.strictEqual((hookEl as any).change, element.change);
- assert.strictEqual((hookEl as any).revision, element.selectedRevision);
- });
- });
-
- suite('getMergeability', () => {
- let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
- setup(() => {
- element.change = {...createChangeViewChange(), labels: {}};
- getMergeableStub = stubRestApi('getMergeable').returns(
- Promise.resolve({...createMergeable(), mergeable: true})
- );
- });
-
- test('merged change', () => {
- element.mergeable = null;
- element.change!.status = ChangeStatus.MERGED;
- return element.getMergeability().then(() => {
- assert.isFalse(element.mergeable);
- assert.isFalse(getMergeableStub.called);
- });
- });
-
- test('abandoned change', () => {
- element.mergeable = null;
- element.change!.status = ChangeStatus.ABANDONED;
- return element.getMergeability().then(() => {
- assert.isFalse(element.mergeable);
- assert.isFalse(getMergeableStub.called);
- });
- });
-
- test('open change', () => {
- element.mergeable = null;
- return element.getMergeability().then(() => {
- assert.isTrue(element.mergeable);
- assert.isTrue(getMergeableStub.called);
- });
+ assert.strictEqual((hookEl as any).revision, element.revision);
});
});
@@ -2524,18 +1566,8 @@ suite('gr-change-view tests', () => {
suite('gr-reporting tests', () => {
setup(() => {
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
- sinon
- .stub(element, 'performPostChangeLoadTasks')
- .returns(Promise.resolve(false));
- sinon.stub(element, 'getMergeability').returns(Promise.resolve());
- sinon.stub(element, 'getLatestCommitMessage').returns(Promise.resolve());
- sinon
- .stub(element, 'reloadPatchNumDependentResources')
- .returns(Promise.resolve([undefined, undefined, undefined]));
+ element.basePatchNum = PARENT;
+ element.patchNum = 1 as RevisionPatchSetNum;
});
test("don't report changeDisplayed on reply", async () => {
@@ -2553,7 +1585,8 @@ suite('gr-change-view tests', () => {
assert.isFalse(changeFullyLoadedStub.called);
});
- test('report changeDisplayed on viewStateChanged', async () => {
+ test('report changeDisplayed and changeFullyLoaded', async () => {
+ const commentsModel = testResolver(commentsModelToken);
stubRestApi('getChangeOrEditFiles').resolves({
'a-file.js': {},
});
@@ -2570,9 +1603,9 @@ suite('gr-change-view tests', () => {
element.viewState = {
...createChangeViewState(),
changeNum: TEST_NUMERIC_CHANGE_ID,
- project: TEST_PROJECT_NAME,
+ repo: TEST_PROJECT_NAME,
};
- element.getChangeModel().setState({
+ changeModel.setState({
loadingStatus: LoadingStatus.LOADED,
change: {
...createChangeViewChange(),
@@ -2581,30 +1614,21 @@ suite('gr-change-view tests', () => {
revisions: {foo: createRevision()},
},
});
- await element.updateComplete;
- await waitEventLoop();
- assert.isTrue(changeDisplayStub.called);
- assert.isTrue(changeFullyLoadedStub.called);
- });
- });
-
- test('calculateHasParent', () => {
- const changeId = '123' as ChangeId;
- const relatedChanges: RelatedChangeAndCommitInfo[] = [];
- assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
+ await waitUntil(() => changeDisplayStub.called);
+ assert.isTrue(changeDisplayStub.called);
+ assert.isFalse(changeFullyLoadedStub.called);
- relatedChanges.push({
- ...createRelatedChangeAndCommitInfo(),
- change_id: '123' as ChangeId,
- });
- assert.equal(element.calculateHasParent(changeId, relatedChanges), false);
+ element.mergeable = true;
+ commentsModel.setState({
+ comments: {},
+ drafts: {},
+ discardedDrafts: [],
+ });
- relatedChanges.push({
- ...createRelatedChangeAndCommitInfo(),
- change_id: '234' as ChangeId,
+ await waitUntil(() => changeFullyLoadedStub.called);
+ assert.isTrue(changeFullyLoadedStub.called);
});
- assert.equal(element.calculateHasParent(changeId, relatedChanges), true);
});
test('renders sha in copy links', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
new file mode 100644
index 0000000000..bbd6003617
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary.ts
@@ -0,0 +1,231 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-change-summary/gr-summary-chip';
+import '../../shared/gr-avatar/gr-avatar-stack';
+import '../../shared/gr-icon/gr-icon';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+ getFirstComment,
+ hasHumanReply,
+ isResolved,
+ isRobotThread,
+ isUnresolved,
+} from '../../../utils/comment-util';
+import {pluralize} from '../../../utils/string-util';
+import {AccountInfo, CommentThread} from '../../../types/common';
+import {isDefined} from '../../../types/types';
+import {CommentTabState} from '../../../types/events';
+import {SummaryChipStyles} from '../gr-change-summary/gr-summary-chip';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-comments-summary')
+export class GrCommentsSummary extends LitElement {
+ @property({type: Object})
+ commentThreads?: CommentThread[];
+
+ @property({type: Number})
+ draftCount = 0;
+
+ @property({type: Number})
+ mentionCount = 0;
+
+ @property({type: Boolean})
+ showCommentCategoryName = false;
+
+ @property({type: Boolean})
+ clickableChips = false;
+
+ @property({type: Boolean})
+ emptyWhenNoComments = false;
+
+ @property({type: Boolean})
+ showAvatarForResolved = false;
+
+ @state()
+ selfAccount?: AccountInfo;
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ x => (this.selfAccount = x)
+ );
+ }
+
+ static override get styles() {
+ return [
+ css`
+ .zeroState {
+ color: var(--deemphasized-text-color);
+ }
+ gr-avatar-stack {
+ --avatar-size: var(--line-height-small, 16px);
+ --stack-border-color: var(--warning-background);
+ }
+ .unresolvedIcon {
+ font-size: var(--line-height-small);
+ color: var(--warning-foreground);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ const commentThreads =
+ this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
+ [];
+ const countResolvedComments = commentThreads.filter(isResolved).length;
+ const unresolvedThreads = commentThreads.filter(isUnresolved);
+ const countUnresolvedComments = unresolvedThreads.length;
+ const unresolvedAuthors = this.getAccounts(unresolvedThreads);
+ const resolveAuthors = this.showAvatarForResolved
+ ? this.getAccounts(commentThreads.filter(isResolved))
+ : undefined;
+ return html`
+ ${this.renderZeroState(countResolvedComments, countUnresolvedComments)}
+ ${this.renderDraftChip()} ${this.renderMentionChip()}
+ ${this.renderUnresolvedCommentsChip(
+ countUnresolvedComments,
+ unresolvedAuthors
+ )}
+ ${this.renderResolvedCommentsChip(countResolvedComments, resolveAuthors)}
+ `;
+ }
+
+ private renderZeroState(
+ countResolvedComments: number,
+ countUnresolvedComments: number
+ ) {
+ if (
+ this.emptyWhenNoComments ||
+ !!countResolvedComments ||
+ !!this.draftCount ||
+ !!countUnresolvedComments
+ )
+ return nothing;
+ return html`<span class="zeroState"> No comments</span>`;
+ }
+
+ private renderMentionChip() {
+ if (!this.mentionCount) return nothing;
+ return html` <gr-summary-chip
+ class="mentionSummary"
+ styleType=${SummaryChipStyles.WARNING}
+ category=${CommentTabState.MENTIONS}
+ icon="alternate_email"
+ .clickable=${this.clickableChips}
+ >
+ ${pluralize(this.mentionCount, 'mention')}</gr-summary-chip
+ >`;
+ }
+
+ private renderDraftChip() {
+ if (!this.draftCount) return nothing;
+ return html` <gr-summary-chip
+ styleType=${SummaryChipStyles.INFO}
+ category=${CommentTabState.DRAFTS}
+ icon="rate_review"
+ iconFilled
+ .clickable=${this.clickableChips}
+ title=${this.showCommentCategoryName
+ ? nothing
+ : pluralize(this.draftCount, 'draft')}
+ >
+ ${this.showCommentCategoryName
+ ? pluralize(this.draftCount, 'draft')
+ : this.draftCount}</gr-summary-chip
+ >`;
+ }
+
+ private renderUnresolvedCommentsChip(
+ countUnresolvedComments: number,
+ unresolvedAuthors: AccountInfo[]
+ ) {
+ if (!countUnresolvedComments) return nothing;
+ return html` <gr-summary-chip
+ styleType=${SummaryChipStyles.WARNING}
+ category=${CommentTabState.UNRESOLVED}
+ ?hidden=${!countUnresolvedComments}
+ .clickable=${this.clickableChips}
+ title=${this.showCommentCategoryName
+ ? nothing
+ : `${countUnresolvedComments} unresolved`}
+ >
+ <gr-avatar-stack .accounts=${unresolvedAuthors} imageSize="32">
+ <gr-icon
+ slot="fallback"
+ icon="chat_bubble"
+ filled
+ class="unresolvedIcon"
+ >
+ </gr-icon>
+ </gr-avatar-stack>
+ ${this.showCommentCategoryName
+ ? `${countUnresolvedComments} unresolved`
+ : `${countUnresolvedComments}`}</gr-summary-chip
+ >`;
+ }
+
+ private renderResolvedCommentsChip(
+ countResolvedComments: number,
+ resolvedAuthors?: AccountInfo[]
+ ) {
+ if (!countResolvedComments) return nothing;
+ if (resolvedAuthors) {
+ return html` <gr-summary-chip
+ styleType=${SummaryChipStyles.CHECK}
+ category=${CommentTabState.SHOW_ALL}
+ .clickable=${this.clickableChips}
+ title=${this.showCommentCategoryName
+ ? nothing
+ : `${countResolvedComments} resolved`}
+ icon="mark_chat_read"
+ ><gr-avatar-stack .accounts=${resolvedAuthors} imageSize="32">
+ <gr-icon
+ slot="fallback"
+ icon="chat_bubble"
+ filled
+ class="unresolvedIcon"
+ >
+ </gr-icon> </gr-avatar-stack
+ >${this.showCommentCategoryName
+ ? `${countResolvedComments} resolved`
+ : `${countResolvedComments}`}</gr-summary-chip
+ >`;
+ }
+ return html` <gr-summary-chip
+ styleType=${SummaryChipStyles.CHECK}
+ category=${CommentTabState.SHOW_ALL}
+ .clickable=${this.clickableChips}
+ icon="mark_chat_read"
+ title=${this.showCommentCategoryName
+ ? nothing
+ : `${countResolvedComments} resolved`}
+ >${this.showCommentCategoryName
+ ? `${countResolvedComments} resolved`
+ : `${countResolvedComments}`}</gr-summary-chip
+ >`;
+ }
+
+ getAccounts(commentThreads: CommentThread[]): AccountInfo[] {
+ return commentThreads
+ .map(getFirstComment)
+ .map(comment => comment?.author ?? this.selfAccount)
+ .filter(isDefined);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-comments-summary': GrCommentsSummary;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts
new file mode 100644
index 0000000000..d8eb2469a9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-comments-summary/gr-comments-summary_test.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrCommentsSummary} from './gr-comments-summary';
+import {
+ createComment,
+ createCommentThread,
+} from '../../../test/test-data-generators';
+
+suite('gr-comments-summary test', () => {
+ let element: GrCommentsSummary;
+
+ setup(async () => {
+ element = await fixture(
+ html`<gr-comments-summary
+ showCommentCategoryName
+ clickableChips
+ ></gr-comments-summary>`
+ );
+ });
+
+ test('is defined', () => {
+ const el = document.createElement('gr-comments-summary');
+ assert.instanceOf(el, GrCommentsSummary);
+ });
+
+ test('renders', async () => {
+ element.commentThreads = [
+ createCommentThread([createComment()]),
+ createCommentThread([{...createComment(), unresolved: true}]),
+ ];
+ element.draftCount = 3;
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `<gr-summary-chip
+ category="drafts"
+ icon="rate_review"
+ iconFilled
+ styletype="info"
+ >
+ 3 drafts
+ </gr-summary-chip>
+ <gr-summary-chip category="unresolved" styletype="warning">
+ <gr-avatar-stack imageSize="32">
+ <gr-icon
+ class="unresolvedIcon"
+ filled
+ icon="chat_bubble"
+ slot="fallback"
+ ></gr-icon>
+ </gr-avatar-stack>
+ 1 unresolved
+ </gr-summary-chip>
+ <gr-summary-chip
+ category="show all"
+ icon="mark_chat_read"
+ styletype="check"
+ >
+ 1 resolved
+ </gr-summary-chip>`
+ );
+ });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index d6a53274dc..4e6022835b 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -4,7 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {CommitInfo, ServerInfo} from '../../../types/common';
+import '../../shared/gr-weblink/gr-weblink';
+import {
+ CommitId,
+ CommitInfo,
+ ServerInfo,
+ WebLinkInfo,
+} from '../../../types/common';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
@@ -12,7 +18,7 @@ import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {createSearchUrl} from '../../../models/views/search';
-import {getPatchSetWeblink} from '../../../utils/weblink-util';
+import {getBrowseCommitWeblink} from '../../../utils/weblink-util';
declare global {
interface HTMLElementTagNameMap {
@@ -55,9 +61,7 @@ export class GrCommitInfo extends LitElement {
const commit = this.commitInfo?.commit;
if (!commit) return nothing;
return html` <div class="container">
- <a target="_blank" rel="noopener" href=${this.computeCommitLink()}
- >${this.getWeblink()?.name ?? ''}</a
- >
+ <gr-weblink imageAndText .info=${this.getWeblink(commit)}></gr-weblink>
<gr-copy-clipboard
hastooltip
.buttonTitle=${'Copy full SHA to clipboard'}
@@ -68,19 +72,19 @@ export class GrCommitInfo extends LitElement {
</div>`;
}
- getWeblink() {
- return getPatchSetWeblink(
- this.commitInfo?.commit,
+ /**
+ * Looks up the primary patchset weblink, but replaces its name by the
+ * shortened commit hash. And falls back to a search query, if no weblink
+ * is configured.
+ */
+ getWeblink(commit: CommitId): WebLinkInfo | undefined {
+ if (!commit) return undefined;
+ const name = commit.slice(0, 7);
+ const primaryLink = getBrowseCommitWeblink(
this.commitInfo?.web_links,
this.serverConfig
);
- }
-
- computeCommitLink() {
- const weblink = this.getWeblink();
- if (weblink?.url) return weblink.url;
-
- const hash = weblink?.name;
- return hash ? createSearchUrl({query: hash}) : '';
+ if (primaryLink) return {...primaryLink, name};
+ return {name, url: createSearchUrl({query: name})};
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
index d992ffe929..6481c26861 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
@@ -12,6 +12,7 @@ import {
} from '../../../test/test-data-generators';
import {CommitId} from '../../../types/common';
import {fixture, html, assert} from '@open-wc/testing';
+import {queryAndAssert} from '../../../utils/common-util';
suite('gr-commit-info tests', () => {
let element: GrCommitInfo;
@@ -40,11 +41,22 @@ suite('gr-commit-info tests', () => {
element,
/* HTML */ `
<div class="container">
- <a href="link-url" rel="noopener" target="_blank">sha4567</a>
+ <gr-weblink imageandtext=""> </gr-weblink>
<gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
</div>
`
);
+ const weblink = queryAndAssert(element, 'gr-weblink');
+ assert.shadowDom.equal(
+ weblink,
+ /* HTML */ `
+ <a href="link-url" rel="noopener" target="_blank">
+ <gr-tooltip-content>
+ <span> sha4567 </span>
+ </gr-tooltip-content>
+ </a>
+ `
+ );
});
test('web link fall back to search query', async () => {
@@ -58,10 +70,21 @@ suite('gr-commit-info tests', () => {
element,
/* HTML */ `
<div class="container">
- <a href="/q/sha4567" rel="noopener" target="_blank">sha4567</a>
+ <gr-weblink imageandtext=""> </gr-weblink>
<gr-copy-clipboard hastooltip="" hideinput=""> </gr-copy-clipboard>
</div>
`
);
+ const weblink = queryAndAssert(element, 'gr-weblink');
+ assert.shadowDom.equal(
+ weblink,
+ /* HTML */ `
+ <a href="/q/sha4567" rel="noopener" target="_blank">
+ <gr-tooltip-content>
+ <span> sha4567 </span>
+ </gr-tooltip-content>
+ </a>
+ `
+ );
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index ab15ae69ec..2129918be2 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -13,6 +13,8 @@ import {customElement, property, query} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
import {BindValueChangeEvent} from '../../../types/events';
import {ShortcutController} from '../../lit/shortcut-controller';
+import {ChangeActionDialog} from '../../../types/common';
+import {fireNoBubble} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -21,7 +23,10 @@ declare global {
}
@customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends LitElement {
+export class GrConfirmAbandonDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
/**
* Fired when the confirm button is pressed.
*
@@ -128,25 +133,14 @@ export class GrConfirmAbandonDialog extends LitElement {
// private but used in test
confirm() {
- this.dispatchEvent(
- new CustomEvent('confirm', {
- detail: {reason: this.message},
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
// private but used in test
handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
private handleBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
index 15f3e218e9..7723327213 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -6,10 +6,15 @@
import {css, html, LitElement} from 'lit';
import {customElement} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
+import {ChangeActionDialog} from '../../../types/common';
+import {fireNoBubble} from '../../../utils/event-util';
import '../../shared/gr-dialog/gr-dialog';
@customElement('gr-confirm-cherrypick-conflict-dialog')
-export class GrConfirmCherrypickConflictDialog extends LitElement {
+export class GrConfirmCherrypickConflictDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
/**
* Fired when the confirm button is pressed.
*
@@ -62,23 +67,13 @@ export class GrConfirmCherrypickConflictDialog extends LitElement {
handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('confirm', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
index 259154fc3c..891209ea3e 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -16,6 +16,8 @@ import {
RepoName,
CommitId,
ChangeInfoId,
+ TopicName,
+ ChangeActionDialog,
} from '../../../types/common';
import {customElement, property, query, state} from 'lit/decorators.js';
import {
@@ -28,7 +30,7 @@ import {
ChangeStatus,
ProgressStatus,
} from '../../../constants/constants';
-import {fireEvent} from '../../../utils/event-util';
+import {fire, fireNoBubble} from '../../../utils/event-util';
import {css, html, LitElement, PropertyValues} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {choose} from 'lit/directives/choose.js';
@@ -36,6 +38,8 @@ import {when} from 'lit/directives/when.js';
import {BindValueChangeEvent} from '../../../types/events';
import {resolve} from '../../../models/dependency';
import {createSearchUrl} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {uuid} from '../../../utils/common-util';
const SUGGESTIONS_LIMIT = 15;
const CHANGE_SUBJECT_LIMIT = 50;
@@ -58,7 +62,10 @@ declare global {
}
@customElement('gr-confirm-cherrypick-dialog')
-export class GrConfirmCherrypickDialog extends LitElement {
+export class GrConfirmCherrypickDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
/**
* Fired when the confirm button is pressed.
*
@@ -497,12 +504,12 @@ export class GrConfirmCherrypickDialog extends LitElement {
private handlecherryPickSingleChangeClicked() {
this.cherryPickType = CherryPickType.SINGLE_CHANGE;
- fireEvent(this, 'iron-resize');
+ fire(this, 'iron-resize', {});
}
private handlecherryPickTopicClicked() {
this.cherryPickType = CherryPickType.TOPIC;
- fireEvent(this, 'iron-resize');
+ fire(this, 'iron-resize', {});
}
private computeMessage() {
@@ -526,8 +533,7 @@ export class GrConfirmCherrypickDialog extends LitElement {
}
private generateRandomCherryPickTopic(change: ChangeInfo) {
- const randomString = Math.random().toString(36).substr(2, 10);
- const message = `cherrypick-${change.topic}-${randomString}`;
+ const message = `cherrypick-${change.topic}-${uuid()}`;
return message;
}
@@ -582,8 +588,9 @@ export class GrConfirmCherrypickDialog extends LitElement {
if (!failedOrPending) {
// This needs some more work, as the new topic may not always be
// created, instead we may end up creating a new patchset */
- const query = `topic: "${topic}"`;
- this.getNavigation().setUrl(createSearchUrl({query}));
+ this.getNavigation().setUrl(
+ createSearchUrl({topic: topic as TopicName})
+ );
}
});
});
@@ -598,23 +605,13 @@ export class GrConfirmCherrypickDialog extends LitElement {
return;
}
// Cherry pick single change
- this.dispatchEvent(
- new CustomEvent('confirm', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
resetFocus() {
@@ -629,7 +626,13 @@ export class GrConfirmCherrypickDialog extends LitElement {
input = input.substring('refs/heads/'.length);
}
return this.restApiService
- .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+ .getRepoBranches(
+ input,
+ this.project,
+ SUGGESTIONS_LIMIT,
+ /* offset=*/ undefined,
+ throwingErrorCallback
+ )
.then(response => {
if (!response) return [];
const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
index 7adc2cad64..6f82e8c897 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -6,7 +6,7 @@
import {css, html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
-import {BranchName, RepoName} from '../../../types/common';
+import {BranchName, ChangeActionDialog, RepoName} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-dialog/gr-dialog';
@@ -14,11 +14,16 @@ import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import {Key, Modifier} from '../../../utils/dom-util';
import {ValueChangedEvent} from '../../../types/events';
import {ShortcutController} from '../../lit/shortcut-controller';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {fireNoBubble} from '../../../utils/event-util';
const SUGGESTIONS_LIMIT = 15;
@customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends LitElement {
+export class GrConfirmMoveDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
/**
* Fired when the confirm button is pressed.
*
@@ -138,23 +143,13 @@ export class GrConfirmMoveDialog extends LitElement {
private handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('confirm', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
private getProjectBranchesSuggestions(input: string) {
@@ -163,7 +158,13 @@ export class GrConfirmMoveDialog extends LitElement {
input = input.substring('refs/heads/'.length);
}
return this.restApiService
- .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+ .getRepoBranches(
+ input,
+ this.project,
+ SUGGESTIONS_LIMIT,
+ /* offest=*/ undefined,
+ throwingErrorCallback
+ )
.then(response => {
if (!response) return [];
const branches: Array<{name: BranchName}> = [];
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
index 14cd5e84bb..6ad416ea75 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -3,9 +3,17 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import '../../shared/gr-account-chip/gr-account-chip';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
-import {NumericChangeId, BranchName} from '../../../types/common';
+import {when} from 'lit/directives/when.js';
+import {
+ NumericChangeId,
+ BranchName,
+ ChangeActionDialog,
+ AccountDetailInfo,
+ AccountInfo,
+} from '../../../types/common';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-autocomplete/gr-autocomplete';
import {
@@ -16,6 +24,13 @@ import {
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {ValueChangedEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
+import {subscribe} from '../../lit/subscription-controller';
export interface RebaseChange {
name: string;
@@ -25,10 +40,15 @@ export interface RebaseChange {
export interface ConfirmRebaseEventDetail {
base: string | null;
allowConflicts: boolean;
+ rebaseChain: boolean;
+ onBehalfOfUploader: boolean;
}
@customElement('gr-confirm-rebase-dialog')
-export class GrConfirmRebaseDialog extends LitElement {
+export class GrConfirmRebaseDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
/**
* Fired when the confirm button is pressed.
*
@@ -44,44 +64,91 @@ export class GrConfirmRebaseDialog extends LitElement {
@property({type: String})
branch?: BranchName;
- @property({type: Number})
- changeNumber?: NumericChangeId;
-
@property({type: Boolean})
- hasParent?: boolean;
+ rebaseOnCurrent?: boolean;
@property({type: Boolean})
- rebaseOnCurrent?: boolean;
+ disableActions = false;
+
+ @state()
+ changeNum?: NumericChangeId;
+
+ @state()
+ hasParent?: boolean;
@state()
text = '';
@state()
+ shouldRebaseChain = false;
+
+ @state()
private query: AutocompleteQuery;
@state()
recentChanges?: RebaseChange[];
+ @state()
+ allowConflicts = false;
+
@query('#rebaseOnParentInput')
- private rebaseOnParentInput!: HTMLInputElement;
+ private rebaseOnParentInput?: HTMLInputElement;
@query('#rebaseOnTipInput')
- private rebaseOnTipInput!: HTMLInputElement;
+ private rebaseOnTipInput?: HTMLInputElement;
@query('#rebaseOnOtherInput')
- rebaseOnOtherInput!: HTMLInputElement;
+ rebaseOnOtherInput?: HTMLInputElement;
@query('#rebaseAllowConflicts')
- private rebaseAllowConflicts!: HTMLInputElement;
+ private rebaseAllowConflicts?: HTMLInputElement;
+
+ @query('#rebaseChain')
+ private rebaseChain?: HTMLInputElement;
@query('#parentInput')
parentInput!: GrAutocomplete;
+ @state()
+ account?: AccountDetailInfo;
+
+ @state()
+ uploader?: AccountInfo;
+
private readonly restApiService = getAppContext().restApiService;
+ private readonly getChangeModel = resolve(this, changeModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
+ private readonly getRelatedChangesModel = resolve(
+ this,
+ relatedChangesModelToken
+ );
+
constructor() {
super();
this.query = input => this.getChangeSuggestions(input);
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ x => (this.account = x)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().latestUploader$,
+ x => (this.uploader = x)
+ );
+ subscribe(
+ this,
+ () => this.getChangeModel().changeNum$,
+ x => (this.changeNum = x)
+ );
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().hasParent$,
+ x => (this.hasParent = x)
+ );
}
override willUpdate(changedProperties: PropertyValues): void {
@@ -115,12 +182,15 @@ export class GrConfirmRebaseDialog extends LitElement {
display: block;
width: 100%;
}
- .rebaseAllowConflicts {
+ .rebaseCheckbox {
margin-top: var(--spacing-m);
}
.rebaseOption {
margin: var(--spacing-m) 0;
}
+ .rebaseOnBehalfMsg {
+ margin-top: var(--spacing-m);
+ }
`,
];
@@ -129,6 +199,7 @@ export class GrConfirmRebaseDialog extends LitElement {
<gr-dialog
id="confirmDialog"
confirm-label="Rebase"
+ .disabled=${this.disableActions}
@confirm=${this.handleConfirmTap}
@cancel=${this.handleCancelTap}
>
@@ -144,6 +215,9 @@ export class GrConfirmRebaseDialog extends LitElement {
Rebase on parent change
</label>
</div>
+ <div class="message" ?hidden=${this.hasParent !== undefined}>
+ Still loading parent information ...
+ </div>
<div
id="parentUpToDateMsg"
class="message"
@@ -164,7 +238,7 @@ export class GrConfirmRebaseDialog extends LitElement {
/>
<label id="rebaseOnTipLabel" for="rebaseOnTipInput">
Rebase on top of the ${this.branch} branch<span
- ?hidden=${!this.hasParent}
+ ?hidden=${!this.hasParent || this.shouldRebaseChain}
>
(breaks relation chain)
</span>
@@ -186,14 +260,15 @@ export class GrConfirmRebaseDialog extends LitElement {
/>
<label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
Rebase on a specific change, ref, or commit
- <span ?hidden=${!this.hasParent}> (breaks relation chain) </span>
+ <span ?hidden=${!this.hasParent || this.shouldRebaseChain}>
+ (breaks relation chain)
+ </span>
</label>
</div>
<div class="parentRevisionContainer">
<gr-autocomplete
id="parentInput"
.query=${this.query}
- no-debounce
.text=${this.text}
@text-changed=${(e: ValueChangedEvent) =>
(this.text = e.detail.value)}
@@ -203,12 +278,50 @@ export class GrConfirmRebaseDialog extends LitElement {
>
</gr-autocomplete>
</div>
- <div class="rebaseAllowConflicts">
- <input id="rebaseAllowConflicts" type="checkbox" />
+ <div class="rebaseCheckbox">
+ <input
+ id="rebaseAllowConflicts"
+ type="checkbox"
+ @change=${() => {
+ this.allowConflicts = !!this.rebaseAllowConflicts?.checked;
+ }}
+ />
<label for="rebaseAllowConflicts"
>Allow rebase with conflicts</label
>
</div>
+ ${when(
+ !this.isCurrentUserEqualToLatestUploader() && this.allowConflicts,
+ () =>
+ html`<span class="message"
+ >Rebase cannot be done on behalf of the uploader when allowing
+ conflicts.</span
+ >`
+ )}
+ ${when(
+ this.hasParent,
+ () =>
+ html`<div class="rebaseCheckbox">
+ <input
+ id="rebaseChain"
+ type="checkbox"
+ @change=${() => {
+ this.shouldRebaseChain = !!this.rebaseChain?.checked;
+ }}
+ />
+ <label for="rebaseChain">Rebase all ancestors</label>
+ </div>`
+ )}
+ ${when(
+ !this.isCurrentUserEqualToLatestUploader(),
+ () => html`<div class="rebaseOnBehalfMsg">Rebase will be done on behalf of${
+ !this.allowConflicts ? ' the uploader:' : ''
+ } <gr-account-chip
+ .account=${this.allowConflicts ? this.account : this.uploader}
+ .hideHovercard=${true}
+ ></gr-account-chip
+ ><span></div>`
+ )}
</div>
</gr-dialog>
`;
@@ -222,7 +335,13 @@ export class GrConfirmRebaseDialog extends LitElement {
// last time it was run.
fetchRecentChanges() {
return this.restApiService
- .getChanges(undefined, 'is:open -age:90d')
+ .getChanges(
+ undefined,
+ 'is:open -age:90d',
+ /* offset=*/ undefined,
+ /* options=*/ undefined,
+ throwingErrorCallback
+ )
.then(response => {
if (!response) return [];
const changes: RebaseChange[] = [];
@@ -237,6 +356,11 @@ export class GrConfirmRebaseDialog extends LitElement {
});
}
+ isCurrentUserEqualToLatestUploader() {
+ if (!this.account || !this.uploader) return true;
+ return this.account._account_id === this.uploader._account_id;
+ }
+
getRecentChanges() {
if (this.recentChanges) {
return Promise.resolve(this.recentChanges);
@@ -256,8 +380,7 @@ export class GrConfirmRebaseDialog extends LitElement {
): AutocompleteSuggestion[] {
return changes
.filter(
- change =>
- change.name.includes(input) && change.value !== this.changeNumber
+ change => change.name.includes(input) && change.value !== this.changeNum
)
.map(
change =>
@@ -288,10 +411,10 @@ export class GrConfirmRebaseDialog extends LitElement {
* should be rebased on top of its current parent.
*/
getSelectedBase() {
- if (this.rebaseOnParentInput.checked) {
+ if (this.rebaseOnParentInput?.checked) {
return null;
}
- if (this.rebaseOnTipInput.checked) {
+ if (this.rebaseOnTipInput?.checked) {
return '';
}
if (!this.text) {
@@ -307,16 +430,23 @@ export class GrConfirmRebaseDialog extends LitElement {
e.stopPropagation();
const detail: ConfirmRebaseEventDetail = {
base: this.getSelectedBase(),
- allowConflicts: this.rebaseAllowConflicts.checked,
+ allowConflicts: !!this.rebaseAllowConflicts?.checked,
+ rebaseChain: !!this.rebaseChain?.checked,
+ onBehalfOfUploader: this.rebaseOnBehalfOfUploader(),
};
- this.dispatchEvent(new CustomEvent('confirm', {detail}));
+ fireNoBubbleNoCompose(this, 'confirm-rebase', detail);
this.text = '';
}
+ private rebaseOnBehalfOfUploader() {
+ if (this.allowConflicts) return false;
+ return true;
+ }
+
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel'));
+ fireNoBubbleNoCompose(this, 'cancel', {});
this.text = '';
}
@@ -325,7 +455,7 @@ export class GrConfirmRebaseDialog extends LitElement {
}
private handleEnterChangeNumberClick() {
- this.rebaseOnOtherInput.checked = true;
+ if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
}
/**
@@ -339,11 +469,11 @@ export class GrConfirmRebaseDialog extends LitElement {
}
if (this.displayParentOption()) {
- this.rebaseOnParentInput.checked = true;
+ if (this.rebaseOnParentInput) this.rebaseOnParentInput.checked = true;
} else if (this.displayTipOption()) {
- this.rebaseOnTipInput.checked = true;
+ if (this.rebaseOnTipInput) this.rebaseOnTipInput.checked = true;
} else {
- this.rebaseOnOtherInput.checked = true;
+ if (this.rebaseOnOtherInput) this.rebaseOnOtherInput.checked = true;
}
}
}
@@ -352,4 +482,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-confirm-rebase-dialog': GrConfirmRebaseDialog;
}
+ interface HTMLElementEventMap {
+ 'confirm-rebase': CustomEvent<ConfirmRebaseEventDetail>;
+ }
}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index eba4bfecfd..24f8a34ed9 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
@@ -12,15 +12,31 @@ import {
stubRestApi,
waitUntil,
} from '../../../test/test-utils';
-import {NumericChangeId, BranchName} from '../../../types/common';
-import {createChangeViewChange} from '../../../test/test-data-generators';
+import {NumericChangeId, BranchName, Timestamp} from '../../../types/common';
+import {
+ createAccountWithEmail,
+ createChangeViewChange,
+} from '../../../test/test-data-generators';
import {fixture, html, assert} from '@open-wc/testing';
import {Key} from '../../../utils/dom-util';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
+import {
+ changeModelToken,
+ LoadingStatus,
+} from '../../../models/change/change-model';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
suite('gr-confirm-rebase-dialog tests', () => {
let element: GrConfirmRebaseDialog;
setup(async () => {
+ const userModel = testResolver(userModelToken);
+ userModel.setAccount({
+ ...createAccountWithEmail('abc@def.com'),
+ registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+ });
element = await fixture(
html`<gr-confirm-rebase-dialog></gr-confirm-rebase-dialog>`
);
@@ -28,6 +44,7 @@ suite('gr-confirm-rebase-dialog tests', () => {
test('render', async () => {
element.branch = 'test' as BranchName;
+ element.hasParent = false;
await element.updateComplete;
assert.shadowDom.equal(
element,
@@ -44,6 +61,9 @@ suite('gr-confirm-rebase-dialog tests', () => {
Rebase on parent change
</label>
</div>
+ <div class="message" hidden="">
+ Still loading parent information ...
+ </div>
<div class="message" hidden="" id="parentUpToDateMsg">
This change is up to date with its parent.
</div>
@@ -73,12 +93,11 @@ suite('gr-confirm-rebase-dialog tests', () => {
<gr-autocomplete
allow-non-suggested-values=""
id="parentInput"
- no-debounce=""
placeholder="Change number, ref, or commit hash"
>
</gr-autocomplete>
</div>
- <div class="rebaseAllowConflicts">
+ <div class="rebaseCheckbox">
<input id="rebaseAllowConflicts" type="checkbox" />
<label for="rebaseAllowConflicts">
Allow rebase with conflicts
@@ -89,6 +108,70 @@ suite('gr-confirm-rebase-dialog tests', () => {
);
});
+ suite('on behalf of uploader', () => {
+ let changeModel;
+ const change = {
+ ...createChangeViewChange(),
+ };
+ setup(async () => {
+ element.branch = 'test' as BranchName;
+ await element.updateComplete;
+ changeModel = testResolver(changeModelToken);
+ changeModel.setState({
+ loadingStatus: LoadingStatus.LOADED,
+ change,
+ });
+ });
+ test('for reviewer it shows message about on behalf', () => {
+ const rebaseOnBehalfMsg = queryAndAssert(element, '.rebaseOnBehalfMsg');
+ assert.dom.equal(
+ rebaseOnBehalfMsg,
+ /* HTML */ `<div class="rebaseOnBehalfMsg">
+ Rebase will be done on behalf of the uploader:
+ <gr-account-chip> </gr-account-chip> <span> </span>
+ </div>`
+ );
+ const accountChip: GrAccountChip = queryAndAssert(
+ rebaseOnBehalfMsg,
+ 'gr-account-chip'
+ );
+ assert.equal(
+ accountChip.account!,
+ change?.revisions[change.current_revision]?.uploader
+ );
+ });
+ test('allowConflicts', async () => {
+ element.allowConflicts = true;
+ await element.updateComplete;
+ const rebaseOnBehalfMsg = queryAndAssert(element, '.rebaseOnBehalfMsg');
+ assert.dom.equal(
+ rebaseOnBehalfMsg,
+ /* HTML */ `<div class="rebaseOnBehalfMsg">
+ Rebase will be done on behalf of
+ <gr-account-chip> </gr-account-chip> <span> </span>
+ </div>`
+ );
+ const accountChip: GrAccountChip = queryAndAssert(
+ rebaseOnBehalfMsg,
+ 'gr-account-chip'
+ );
+ assert.equal(accountChip.account, element.account);
+ });
+ });
+
+ test('disableActions property disables dialog confirm', async () => {
+ element.disableActions = false;
+ await element.updateComplete;
+
+ const dialog = queryAndAssert<GrDialog>(element, 'gr-dialog');
+ assert.isFalse(dialog.disabled);
+
+ element.disableActions = true;
+ await element.updateComplete;
+
+ assert.isTrue(dialog.disabled);
+ });
+
test('controls with parent and rebase on current available', async () => {
element.rebaseOnCurrent = true;
element.hasParent = true;
@@ -160,7 +243,7 @@ suite('gr-confirm-rebase-dialog tests', () => {
element.hasParent = false;
await element.updateComplete;
- assert.isTrue(element.rebaseOnOtherInput.checked);
+ assert.isTrue(element.rebaseOnOtherInput?.checked);
assert.isTrue(
queryAndAssert(element, '#rebaseOnParent').hasAttribute('hidden')
);
@@ -281,7 +364,7 @@ suite('gr-confirm-rebase-dialog tests', () => {
assert.equal(element.filterChanges('awesome', recentChanges).length, 3);
assert.equal(element.filterChanges('third', recentChanges).length, 1);
- element.changeNumber = 123 as NumericChangeId;
+ element.changeNum = 123 as NumericChangeId;
await element.updateComplete;
assert.equal(element.filterChanges('123', recentChanges).length, 0);
@@ -291,7 +374,6 @@ suite('gr-confirm-rebase-dialog tests', () => {
test('input text change triggers function', async () => {
const recentChangesSpy = sinon.spy(element, 'getRecentChanges');
- element.parentInput.noDebounce = true;
pressKey(
queryAndAssert(queryAndAssert(element, '#parentInput'), '#input'),
Key.ENTER
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index 5f4835a6c1..fedc37736b 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -8,14 +8,15 @@ import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import {LitElement, html, css, nothing} from 'lit';
import {customElement, state} from 'lit/decorators.js';
-import {ChangeInfo, CommitId} from '../../../types/common';
+import {ChangeActionDialog, ChangeInfo, CommitId} from '../../../types/common';
import {fire, fireAlert} from '../../../utils/event-util';
-import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {createSearchUrl} from '../../../models/views/search';
const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
// TODO(dhruvsri): clean up repeated definitions after moving to js modules
@@ -29,23 +30,11 @@ export interface ConfirmRevertEventDetail {
message?: string;
}
-export interface CancelRevertEventDetail {
- revertType: RevertType;
-}
-
-declare global {
- interface HTMLElementEventMap {
- /** Fired when the confirm button is pressed. */
- // prettier-ignore
- 'confirm': CustomEvent<ConfirmRevertEventDetail>;
- /** Fired when the cancel button is pressed. */
- // prettier-ignore
- 'cancel': CustomEvent<CancelRevertEventDetail>;
- }
-}
-
@customElement('gr-confirm-revert-dialog')
-export class GrConfirmRevertDialog extends LitElement {
+export class GrConfirmRevertDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
/* The revert message updated by the user
The default value is set by the dialog */
@state()
@@ -57,8 +46,9 @@ export class GrConfirmRevertDialog extends LitElement {
@state()
private showRevertSubmission = false;
+ // Value supplied by populate(). Non-private for access in tests.
@state()
- private changesCount?: number;
+ changesCount?: number;
@state()
showErrorMessage = false;
@@ -73,6 +63,8 @@ export class GrConfirmRevertDialog extends LitElement {
@state()
private revertMessages: string[] = [];
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
static override styles = [
sharedStyles,
css`
@@ -170,8 +162,6 @@ export class GrConfirmRevertDialog extends LitElement {
`;
}
- private readonly jsAPI = getAppContext().jsApiService;
-
private computeIfSingleRevert() {
return this.revertType === RevertType.REVERT_SINGLE_CHANGE;
}
@@ -181,18 +171,22 @@ export class GrConfirmRevertDialog extends LitElement {
}
modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
- return this.jsAPI.modifyRevertMsg(change, message, commitMessage);
+ return this.getPluginLoader().jsApiService.modifyRevertMsg(
+ change,
+ message,
+ commitMessage
+ );
}
- populate(change: ChangeInfo, commitMessage: string, changes: ChangeInfo[]) {
- this.changesCount = changes.length;
+ populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+ this.changesCount = changesCount;
// The option to revert a single change is always available
this.populateRevertSingleChangeMessage(
change,
commitMessage,
change.current_revision
);
- this.populateRevertSubmissionMessage(change, changes, commitMessage);
+ this.populateRevertSubmissionMessage(change, commitMessage);
}
populateRevertSingleChangeMessage(
@@ -220,44 +214,34 @@ export class GrConfirmRevertDialog extends LitElement {
this.originalRevertMessages[this.revertType] = this.message;
}
- private getTrimmedChangeSubject(subject: string) {
- if (!subject) return '';
- if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
- return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
- }
-
private modifyRevertSubmissionMsg(
change: ChangeInfo,
msg: string,
commitMessage: string
) {
- return this.jsAPI.modifyRevertSubmissionMsg(change, msg, commitMessage);
+ return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
+ change,
+ msg,
+ commitMessage
+ );
}
- populateRevertSubmissionMessage(
- change: ChangeInfo,
- changes: ChangeInfo[],
- commitMessage: string
- ) {
+ populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
// Follow the same convention of the revert
const commitHash = change.current_revision;
if (!commitHash) {
fireAlert(this, ERR_COMMIT_NOT_FOUND);
return;
}
- if (!changes || changes.length <= 1) return;
- const revertTitle = `Revert submission ${change.submission_id}`;
- let message =
- revertTitle +
+ if (this.changesCount! <= 1) return;
+ const message =
+ `Revert submission ${change.submission_id}` +
'\n\n' +
'Reason for revert: <INSERT ' +
- 'REASONING HERE>\n';
- message += 'Reverted Changes:\n';
- changes.forEach(change => {
- message +=
- `${change.change_id.substring(0, 10)}:` +
- `${this.getTrimmedChangeSubject(change.subject)}\n`;
- });
+ 'REASONING HERE>\n\n' +
+ 'Reverted changes: ' +
+ createSearchUrl({query: `submissionid:${change.submission_id}`}) +
+ '\n';
this.message = this.modifyRevertSubmissionMsg(
change,
message,
@@ -303,16 +287,13 @@ export class GrConfirmRevertDialog extends LitElement {
revertType: this.revertType,
message: this.message,
};
- fire(this, 'confirm', detail);
+ fire(this, 'confirm-revert', detail);
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- const detail: ConfirmRevertEventDetail = {
- revertType: this.revertType,
- };
- fire(this, 'cancel', detail);
+ fire(this, 'cancel', {});
}
}
@@ -320,4 +301,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-confirm-revert-dialog': GrConfirmRevertDialog;
}
+ interface HTMLElementEventMap {
+ 'confirm-revert': CustomEvent<ConfirmRevertEventDetail>;
+ }
}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 59416dab0e..904285faf1 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -6,8 +6,7 @@
import {fixture, html, assert} from '@open-wc/testing';
import '../../../test/common-test-setup';
import {createChange} from '../../../test/test-data-generators';
-import {CommitId} from '../../../types/common';
-import {EventType} from '../../../types/events';
+import {ChangeSubmissionId, CommitId} from '../../../types/common';
import './gr-confirm-revert-dialog';
import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
@@ -47,7 +46,7 @@ suite('gr-confirm-revert-dialog tests', () => {
test('no match', () => {
assert.isNotOk(element.message);
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
element.populateRevertSingleChangeMessage(
createChange(),
'not a commitHash in sight',
@@ -111,4 +110,22 @@ suite('gr-confirm-revert-dialog tests', () => {
'Reason for revert: <INSERT REASONING HERE>\n';
assert.equal(element.message, expected);
});
+
+ test('revert submission', () => {
+ element.changesCount = 3;
+ element.populateRevertSubmissionMessage(
+ {
+ ...createChange(),
+ submission_id: '5545' as ChangeSubmissionId,
+ current_revision: 'abcd123' as CommitId,
+ },
+ 'one line commit\n\nChange-Id: abcdefg\n'
+ );
+
+ const expected =
+ 'Revert submission 5545\n\n' +
+ 'Reason for revert: <INSERT REASONING HERE>\n\n' +
+ 'Reverted changes: /q/submissionid:5545\n';
+ assert.equal(element.message, expected);
+ });
});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
index cf66ecf559..44e237b91d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog.ts
@@ -8,10 +8,15 @@ import '../../shared/gr-icon/gr-icon';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../gr-thread-list/gr-thread-list';
-import {ActionInfo, EDIT} from '../../../types/common';
+import {
+ ActionInfo,
+ ChangeActionDialog,
+ CommentThread,
+ EDIT,
+} from '../../../types/common';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
import {pluralize} from '../../../utils/string-util';
-import {CommentThread, isUnresolved} from '../../../utils/comment-util';
+import {isUnresolved} from '../../../utils/comment-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
@@ -21,9 +26,13 @@ import {ParsedChangeInfo} from '../../../types/types';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {resolve} from '../../../models/dependency';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
@customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends LitElement {
+export class GrConfirmSubmitDialog
+ extends LitElement
+ implements ChangeActionDialog
+{
@query('#dialog')
dialog?: GrDialog;
@@ -90,7 +99,7 @@ export class GrConfirmSubmitDialog extends LitElement {
);
subscribe(
this,
- () => this.getCommentsModel().threads$,
+ () => this.getCommentsModel().threadsSaved$,
x => (this.unresolvedThreads = x.filter(isUnresolved))
);
}
@@ -127,7 +136,7 @@ export class GrConfirmSubmitDialog extends LitElement {
return html`
<gr-icon icon="warning" filled class="warningBeforeSubmit"></gr-icon>
Your unpublished edit will not be submitted. Did you forget to click
- <b>PUBLISH</b>
+ <b>PUBLISH</b> after pressing <b>EDIT</b>?
`;
}
@@ -190,13 +199,13 @@ export class GrConfirmSubmitDialog extends LitElement {
private handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
+ fireNoBubbleNoCompose(this, 'confirm', {});
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
+ fireNoBubbleNoCompose(this, 'cancel', {});
}
}
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 2e84143605..11dc890f70 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
@@ -8,19 +8,13 @@ import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
import {GrButton} from '../../shared/gr-button/gr-button';
-import {
- copyToClipbard,
- hasOwnProperty,
- queryAndAssert,
-} from '../../../utils/common-util';
-import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
-import {fireEvent} from '../../../utils/event-util';
+import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
import {fontStyles} from '../../../styles/gr-font-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state, query} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
-import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
import {BindValueChangeEvent} from '../../../types/events';
import {ShortcutController} from '../../lit/shortcut-controller';
import {subscribe} from '../../lit/subscription-controller';
@@ -239,7 +233,7 @@ export class GrDownloadDialog extends LitElement {
commands[index].command,
`${commands[index].title} command`
);
- fireEvent(this, 'close');
+ fire(this, 'close', {});
}
override focus() {
@@ -252,19 +246,6 @@ export class GrDownloadDialog extends LitElement {
}
}
- getFocusStops(): GrOverlayStops {
- assertIsDefined(this.downloadCommands, 'downloadCommands');
- assertIsDefined(this.closeButton, 'closeButton');
- const downloadTabs = queryAndAssert<PaperTabsElement>(
- this.downloadCommands,
- '#downloadTabs'
- );
- return {
- start: downloadTabs,
- end: this.closeButton,
- };
- }
-
private computeDownloadCommands() {
let commandObj;
if (!this.change || !this.selectedScheme) return [];
@@ -342,7 +323,7 @@ export class GrDownloadDialog extends LitElement {
private handleCloseTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- fireEvent(this, 'close');
+ fire(this, 'close', {});
}
private schemesChanged() {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
index 5debc0c1c4..fd3ddacdad 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -25,8 +25,8 @@ import {
import {DiffPreferencesInfo} from '../../../types/diff';
import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
import {GrButton} from '../../shared/gr-button/gr-button';
-import {fireEvent} from '../../../utils/event-util';
-import {css, html, LitElement} from 'lit';
+import {fire, fireNoBubbleNoCompose} from '../../../utils/event-util';
+import {css, html, LitElement, nothing} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {when} from 'lit/directives/when.js';
import {ifDefined} from 'lit/directives/if-defined.js';
@@ -36,30 +36,16 @@ import {
shortcutsServiceToken,
} from '../../../services/shortcuts/shortcuts-service';
import {resolve} from '../../../models/dependency';
-import {getAppContext} from '../../../services/app-context';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
import {createChangeUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
import {changeModelToken} from '../../../models/change/change-model';
+import {PatchRangeChangeEvent} from '../../diff/gr-patch-range-select/gr-patch-range-select';
+import {classMap} from 'lit/directives/class-map.js';
@customElement('gr-file-list-header')
export class GrFileListHeader extends LitElement {
- /**
- * @event expand-diffs
- */
-
- /**
- * @event collapse-diffs
- */
-
- /**
- * @event open-diff-prefs
- */
-
- /**
- * @event open-download-dialog
- */
-
@property({type: Object})
account: AccountInfo | undefined;
@@ -113,7 +99,7 @@ export class GrFileListHeader extends LitElement {
// 'hide diffs' buttons still be functional.
private readonly maxFilesForBulkActions = 225;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getNavigation = resolve(this, navigationToken);
@@ -123,7 +109,7 @@ export class GrFileListHeader extends LitElement {
super();
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
diffPreferences => {
if (!diffPreferences) return;
this.diffPrefs = diffPreferences;
@@ -176,15 +162,6 @@ export class GrFileListHeader extends LitElement {
display: flex;
flex-wrap: wrap;
}
- .patchInfo-header .container.latestPatchContainer {
- display: none;
- }
- .patchInfoOldPatchSet .container.latestPatchContainer {
- display: initial;
- }
- .editMode.patchInfoOldPatchSet .container.latestPatchContainer {
- display: none;
- }
.latestPatchContainer a {
text-decoration: none;
}
@@ -233,16 +210,7 @@ export class GrFileListHeader extends LitElement {
margin: 0;
--gr-button-padding: 2px 4px;
}
- .editMode .hideOnEdit {
- display: none;
- }
- .showOnEdit {
- display: none;
- }
- .editMode .showOnEdit {
- display: initial;
- }
- .editMode .showOnEdit.flexContainer {
+ .flexContainer {
align-items: center;
display: flex;
}
@@ -269,15 +237,14 @@ export class GrFileListHeader extends LitElement {
if (!this.change || !this.diffPrefs) {
return;
}
- const editModeClass = this.computeEditModeClass(this.editMode);
- const patchInfoClass = this.computePatchInfoClass();
const expandedClass = this.computeExpandedClass(this.filesExpanded);
- const prefsButtonHidden = this.computePrefsButtonHidden(
- this.diffPrefs,
- this.loggedIn
- );
return html`
- <div class="patchInfo-header ${editModeClass} ${patchInfoClass}">
+ <div
+ class=${classMap({
+ 'patchInfo-header': true,
+ patchInfoOldPatchSet: this.patchNum !== this.latestPatchNum,
+ })}
+ >
<div class="patchInfo-left">
<div class="patchInfoContent">
<gr-patch-range-select
@@ -287,17 +254,14 @@ export class GrFileListHeader extends LitElement {
</gr-patch-range-select>
<span class="separator"></span>
<gr-commit-info .commitInfo=${this.commitInfo}></gr-commit-info>
- <span class="container latestPatchContainer">
- <span class="separator"></span>
- <a href=${ifDefined(this.changeUrl)}>Go to latest patch set</a>
- </span>
+ ${this.renderLatestPatchContainer()}
</div>
</div>
<div class="rightControls ${expandedClass}">
${when(
this.editMode,
() => html`
- <span class="showOnEdit flexContainer">
+ <span class="flexContainer">
<gr-edit-controls
id="editControls"
.patchNum=${this.patchNum}
@@ -307,28 +271,20 @@ export class GrFileListHeader extends LitElement {
</span>
`
)}
- <div class="fileViewActions">
- <span class="fileViewActionsLabel">Diff view:</span>
- <gr-diff-mode-selector
- id="modeSelect"
- .saveOnChange=${this.loggedIn ?? false}
- ></gr-diff-mode-selector>
- <span
- id="diffPrefsContainer"
- class="hideOnEdit"
- ?hidden=${prefsButtonHidden}
- >
- <gr-tooltip-content has-tooltip title="Diff preferences">
- <gr-button
- link
- class="prefsButton desktop"
- @click=${this.handlePrefsTap}
- ><gr-icon icon="settings" filled></gr-icon
- ></gr-button>
- </gr-tooltip-content>
- </span>
- <span class="separator"></span>
- </div>
+ ${when(
+ this.loggedIn && this.diffPrefs,
+ () => html`
+ <div class="fileViewActions">
+ <span class="fileViewActionsLabel">Diff view:</span>
+ <gr-diff-mode-selector
+ id="modeSelect"
+ .saveOnChange=${true}
+ ></gr-diff-mode-selector>
+ ${this.renderDiffPrefsContainer()}
+ <span class="separator"></span>
+ </div>
+ `
+ )}
<span class="downloadContainer desktop">
<gr-tooltip-content
has-tooltip
@@ -380,12 +336,38 @@ export class GrFileListHeader extends LitElement {
`;
}
+ private renderLatestPatchContainer() {
+ if (this.editMode || this.patchNum === this.latestPatchNum) return nothing;
+ return html`
+ <span class="container latestPatchContainer">
+ <span class="separator"></span>
+ <a href=${ifDefined(this.changeUrl)}>Go to latest patch set</a>
+ </span>
+ `;
+ }
+
+ private renderDiffPrefsContainer() {
+ if (this.editMode) return nothing;
+ return html`
+ <span id="diffPrefsContainer">
+ <gr-tooltip-content has-tooltip title="Diff preferences">
+ <gr-button
+ link
+ class="prefsButton desktop"
+ @click=${this.handleDiffPrefsTap}
+ ><gr-icon icon="settings" filled></gr-icon
+ ></gr-button>
+ </gr-tooltip-content>
+ </span>
+ `;
+ }
+
private expandAllDiffs() {
- fireEvent(this, 'expand-diffs');
+ fire(this, 'expand-diffs', {});
}
private collapseAllDiffs() {
- fireEvent(this, 'collapse-diffs');
+ fire(this, 'collapse-diffs', {});
}
private computeExpandedClass(filesExpanded?: FilesExpandedState) {
@@ -400,13 +382,6 @@ export class GrFileListHeader extends LitElement {
return classes.join(' ');
}
- private computePrefsButtonHidden(
- prefs: DiffPreferencesInfo,
- loggedIn?: boolean
- ) {
- return !loggedIn || !prefs;
- }
-
private fileListActionsVisible(
shownFileCount: number,
maxFilesForBulkActions: number
@@ -414,7 +389,7 @@ export class GrFileListHeader extends LitElement {
return shownFileCount <= maxFilesForBulkActions;
}
- handlePatchChange(e: CustomEvent) {
+ handlePatchChange(e: PatchRangeChangeEvent) {
const {basePatchNum, patchNum} = e.detail;
if (
(basePatchNum === this.basePatchNum && patchNum === this.patchNum) ||
@@ -427,28 +402,15 @@ export class GrFileListHeader extends LitElement {
);
}
- private handlePrefsTap(e: Event) {
+ private handleDiffPrefsTap(e: Event) {
e.preventDefault();
- fireEvent(this, 'open-diff-prefs');
+ fire(this, 'open-diff-prefs', {});
}
private handleDownloadTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('open-download-dialog', {bubbles: false})
- );
- }
-
- private computeEditModeClass(editMode?: boolean) {
- return editMode ? 'editMode' : '';
- }
-
- computePatchInfoClass() {
- if (this.patchNum === this.latestPatchNum) {
- return '';
- }
- return 'patchInfoOldPatchSet';
+ fireNoBubbleNoCompose(this, 'open-download-dialog', {});
}
private createTitle(shortcutName: Shortcut, section: ShortcutSection) {
@@ -460,4 +422,10 @@ declare global {
interface HTMLElementTagNameMap {
'gr-file-list-header': GrFileListHeader;
}
+ interface HTMLElementEventMap {
+ 'collapse-diffs': CustomEvent<{}>;
+ 'expand-diffs': CustomEvent<{}>;
+ 'open-diff-prefs': CustomEvent<{}>;
+ 'open-download-dialog': CustomEvent<{}>;
+ }
}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
index f2121b3309..e2c3304509 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.ts
@@ -51,6 +51,7 @@ suite('gr-file-list-header tests', () => {
.shownFileCount=${3}
></gr-file-list-header>`
);
+ element.loggedIn = true;
element.diffPrefs = createDefaultDiffPrefs();
await element.updateComplete;
});
@@ -65,17 +66,13 @@ suite('gr-file-list-header tests', () => {
<gr-patch-range-select id="rangeSelect"> </gr-patch-range-select>
<span class="separator"> </span>
<gr-commit-info> </gr-commit-info>
- <span class="container latestPatchContainer">
- <span class="separator"> </span>
- <a> Go to latest patch set </a>
- </span>
</div>
</div>
<div class="rightControls">
<div class="fileViewActions">
<span class="fileViewActionsLabel"> Diff view: </span>
<gr-diff-mode-selector id="modeSelect"> </gr-diff-mode-selector>
- <span class="hideOnEdit" hidden="" id="diffPrefsContainer">
+ <span id="diffPrefsContainer">
<gr-tooltip-content has-tooltip="" title="Diff preferences">
<gr-button
aria-disabled="false"
@@ -108,7 +105,7 @@ suite('gr-file-list-header tests', () => {
</span>
<gr-tooltip-content
has-tooltip=""
- title="Show/hide all inline diffs (shortcut: I)"
+ title="Show/hide all inline diffs (shortcut: Shift+i)"
>
<gr-button
aria-disabled="false"
@@ -122,7 +119,7 @@ suite('gr-file-list-header tests', () => {
</gr-tooltip-content>
<gr-tooltip-content
has-tooltip=""
- title="Show/hide all inline diffs (shortcut: I)"
+ title="Show/hide all inline diffs (shortcut: Shift+i)"
>
<gr-button
aria-disabled="false"
@@ -141,17 +138,13 @@ suite('gr-file-list-header tests', () => {
});
test('Diff preferences hidden when no prefs', async () => {
- assert.isTrue(
- queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
- );
+ assert.isOk(query<HTMLElement>(element, '#diffPrefsContainer'));
- element.diffPrefs = createDefaultDiffPrefs();
+ element.diffPrefs = undefined;
element.loggedIn = true;
await element.updateComplete;
- assert.isFalse(
- queryAndAssert<HTMLElement>(element, '#diffPrefsContainer').hidden
- );
+ assert.isNotOk(query<HTMLElement>(element, '#diffPrefsContainer'));
});
test('expandAllDiffs called when expand button clicked', async () => {
@@ -251,17 +244,20 @@ suite('gr-file-list-header tests', () => {
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1..3');
});
- test('class is applied to file list on old patch set', () => {
+ test('class is applied to file list on old patch set', async () => {
element.latestPatchNum = 4 as PatchSetNumber;
element.patchNum = 1 as PatchSetNumber;
- assert.equal(element.computePatchInfoClass(), 'patchInfoOldPatchSet');
+ await element.updateComplete;
+ assert.isTrue(Boolean(query(element, '.patchInfoOldPatchSet')));
element.patchNum = 2 as PatchSetNumber;
- assert.equal(element.computePatchInfoClass(), 'patchInfoOldPatchSet');
+ await element.updateComplete;
+ assert.isTrue(Boolean(query(element, '.patchInfoOldPatchSet')));
element.patchNum = 4 as PatchSetNumber;
- assert.equal(element.computePatchInfoClass(), '');
+ await element.updateComplete;
+ assert.isFalse(Boolean(query(element, '.patchInfoOldPatchSet')));
});
suite('editMode behavior', () => {
@@ -273,17 +269,11 @@ suite('gr-file-list-header tests', () => {
test('patch specific elements', async () => {
element.editMode = true;
await element.updateComplete;
-
- assert.isFalse(
- isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
- );
+ assert.isFalse(Boolean(query(element, '#diffPrefsContainer')));
element.editMode = false;
await element.updateComplete;
-
- assert.isTrue(
- isVisible(queryAndAssert<HTMLElement>(element, '#diffPrefsContainer'))
- );
+ assert.isTrue(Boolean(query(element, '#diffPrefsContainer')));
});
test('edit-controls visibility', async () => {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 9813845a4a..f9568f4c78 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -21,8 +21,6 @@ import {asyncForeach} from '../../../utils/async-util';
import {FilesExpandedState} from '../gr-file-list-constants';
import {diffFilePaths, pluralize} from '../../../utils/string-util';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {getAppContext} from '../../../services/app-context';
import {
DiffViewMode,
@@ -53,7 +51,7 @@ import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
-import {Interaction, Timing} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
import {RevisionInfo} from '../../shared/revision-info/revision-info';
import {select} from '../../../utils/observable-util';
import {resolve} from '../../../models/dependency';
@@ -62,7 +60,14 @@ import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {filesModelToken} from '../../../models/change/files-model';
import {ShortcutController} from '../../lit/shortcut-controller';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {
+ css,
+ html,
+ LitElement,
+ nothing,
+ PropertyValues,
+ TemplateResult,
+} from 'lit';
import {Shortcut} from '../../../services/shortcuts/shortcuts-config';
import {fire} from '../../../utils/event-util';
import {a11yStyles} from '../../../styles/gr-a11y-styles';
@@ -73,10 +78,14 @@ import {when} from 'lit/directives/when.js';
import {classMap} from 'lit/directives/class-map.js';
import {incrementalRepeat} from '../../lit/incremental-repeat';
import {ifDefined} from 'lit/directives/if-defined.js';
-import {HtmlPatched} from '../../../utils/lit-util';
-import {createDiffUrl} from '../../../models/views/diff';
-import {createEditUrl} from '../../../models/views/edit';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+ createDiffUrl,
+ createEditUrl,
+ createChangeUrl,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {FileMode, fileModeToString} from '../../../utils/file-util';
export const DEFAULT_NUM_FILES_SHOWN = 200;
@@ -164,11 +173,6 @@ declare global {
}
@customElement('gr-file-list')
export class GrFileList extends LitElement {
- /**
- * @event files-expanded-changed
- * @event files-shown-changed
- * @event diff-prefs-changed
- */
@query('#diffPreferencesDialog')
diffPreferencesDialog?: GrDiffPreferencesDialog;
@@ -259,10 +263,6 @@ export class GrFileList extends LitElement {
// Private but used in tests.
@state()
- displayLine?: boolean;
-
- // Private but used in tests.
- @state()
showSizeBars = true;
// For merge commits vs Auto Merge, an extra file row is shown detailing the
@@ -296,7 +296,9 @@ export class GrFileList extends LitElement {
private readonly restApiService = getAppContext().restApiService;
- private readonly userModel = getAppContext().userModel;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
@@ -306,13 +308,6 @@ export class GrFileList extends LitElement {
private readonly getBrowserModel = resolve(this, browserModelToken);
- private readonly patched = new HtmlPatched(key => {
- this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
- component: this.tagName,
- key: key.substring(0, 300),
- });
- });
-
shortcutsController = new ShortcutController(this);
private readonly getNavigation = resolve(this, navigationToken);
@@ -600,6 +595,16 @@ export class GrFileList extends LitElement {
top: 2px;
display: block;
}
+ .file-mode-warning {
+ font-size: 16px;
+ position: relative;
+ top: 2px;
+ color: var(--warning-foreground);
+ }
+ .file-mode-content {
+ display: inline-block;
+ color: var(--deemphasized-text-color);
+ }
@media screen and (max-width: 1200px) {
gr-endpoint-decorator.extra-col {
@@ -722,9 +727,6 @@ export class GrFileList extends LitElement {
this.shortcutsController.addAbstract(Shortcut.TOGGLE_LEFT_PANE, _ =>
this.handleToggleLeftPane()
);
- this.shortcutsController.addGlobal({key: Key.ESC}, _ =>
- this.handleEscKey()
- );
this.shortcutsController.addAbstract(
Shortcut.EXPAND_ALL_COMMENT_THREADS,
_ => {}
@@ -749,7 +751,7 @@ export class GrFileList extends LitElement {
);
subscribe(
this,
- () => this.getFilesModel().filesWithUnmodified$,
+ () => this.getFilesModel().filesIncludingUnmodified$,
files => {
this.files = [...files];
}
@@ -777,7 +779,7 @@ export class GrFileList extends LitElement {
);
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
diffPreferences => {
this.diffPrefs = diffPreferences;
}
@@ -786,7 +788,7 @@ export class GrFileList extends LitElement {
this,
() =>
select(
- this.userModel.preferences$,
+ this.getUserModel().preferences$,
prefs => !!prefs?.size_bar_in_change_table
),
sizeBarInChangeTable => {
@@ -795,7 +797,7 @@ export class GrFileList extends LitElement {
);
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
loggedIn => {
this.loggedIn = loggedIn;
}
@@ -821,6 +823,13 @@ export class GrFileList extends LitElement {
override willUpdate(changedProperties: PropertyValues): void {
if (
+ changedProperties.has('patchNum') ||
+ changedProperties.has('basePatchNum')
+ ) {
+ this.resetFileState();
+ this.collapseAllDiffs();
+ }
+ if (
changedProperties.has('diffPrefs') ||
changedProperties.has('diffViewMode')
) {
@@ -843,26 +852,29 @@ export class GrFileList extends LitElement {
override connectedCallback() {
super.connectedCallback();
- getPluginLoader()
+ this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
- this.dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
- 'change-view-file-list-header'
- );
- this.dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
- 'change-view-file-list-content'
- );
+ this.dynamicHeaderEndpoints =
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-view-file-list-header'
+ );
+ this.dynamicContentEndpoints =
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-view-file-list-content'
+ );
this.dynamicPrependedHeaderEndpoints =
- getPluginEndpoints().getDynamicEndpoints(
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-header-prepend'
);
this.dynamicPrependedContentEndpoints =
- getPluginEndpoints().getDynamicEndpoints(
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
'change-view-file-list-content-prepend'
);
- this.dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
- 'change-view-file-list-summary'
- );
+ this.dynamicSummaryEndpoints =
+ this.getPluginLoader().pluginEndPoints.getDynamicEndpoints(
+ 'change-view-file-list-summary'
+ );
if (
this.dynamicHeaderEndpoints.length !==
@@ -953,7 +965,10 @@ export class GrFileList extends LitElement {
<div class="path" role="columnheader">File</div>
<div class="comments desktop" role="columnheader">Comments</div>
<div class="comments mobile" role="columnheader" title="Comments">C</div>
- <div class="sizeBars desktop" role="columnheader">Size</div>
+ ${when(
+ this.showSizeBars,
+ () => html`<div class="sizeBars desktop" role="columnheader">Size</div>`
+ )}
<div class="header-stats" role="columnheader">Delta</div>
<!-- endpoint: change-view-file-list-header -->
${when(showDynamicColumns, () => this.renderDynamicHeaderEndpoints())}
@@ -999,25 +1014,12 @@ export class GrFileList extends LitElement {
);
}
- // for DIFF_AUTOCLOSE logging purposes only
- private shownFilesOld: NormalizedFileInfo[] = this.shownFiles;
-
private renderShownFiles() {
const showDynamicColumns = this.computeShowDynamicColumns();
const showPrependedDynamicColumns =
this.computeShowPrependedDynamicColumns();
const sizeBarLayout = this.computeSizeBarLayout();
- // for DIFF_AUTOCLOSE logging purposes only
- if (
- this.shownFilesOld.length > 0 &&
- this.shownFiles !== this.shownFilesOld
- ) {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_SHOWN_FILES_CHANGED
- );
- }
- this.shownFilesOld = this.shownFiles;
return incrementalRepeat({
values: this.shownFiles,
mapFn: (f, i) =>
@@ -1049,6 +1051,7 @@ export class GrFileList extends LitElement {
data-file=${JSON.stringify(patchSetFile)}
tabindex="-1"
role="row"
+ aria-label=${file.__path}
>
<!-- endpoint: change-view-file-list-content-prepend -->
${when(showPrependedDynamicColumns, () =>
@@ -1067,11 +1070,10 @@ export class GrFileList extends LitElement {
</div>
${when(
this.isFileExpanded(file.__path),
- () => this.patched.html`
+ () => html`
<gr-diff-host
?noAutoRender=${true}
?showLoadFailure=${true}
- .displayLine=${this.displayLine}
.changeNum=${this.changeNum}
.change=${this.change}
.patchRange=${this.patchRange}
@@ -1120,10 +1122,14 @@ export class GrFileList extends LitElement {
</div>`;
}
- private renderDivWithTooltip(content: string, tooltip: string) {
+ private renderDivWithTooltip(
+ content: TemplateResult | string,
+ tooltip: string,
+ cssClass = 'content'
+ ) {
return html`
<gr-tooltip-content title=${tooltip} has-tooltip>
- <div class="content">${content}</div>
+ <div class=${cssClass}>${content}</div>
</gr-tooltip-content>
`;
}
@@ -1163,12 +1169,18 @@ export class GrFileList extends LitElement {
private renderFileStatusLeft(path?: string) {
if (this.filesLeftBase.length === 0) return nothing;
+ const arrow = html`
+ <gr-icon
+ icon="arrow_right_alt"
+ class="file-status-arrow"
+ aria-label="then"
+ ></gr-icon>
+ `;
// no path means "header row"
const psNum = this.basePatchNum;
if (!path) {
return html`
- ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)}
- <gr-icon icon="arrow_right_alt" class="file-status-arrow"></gr-icon>
+ ${this.renderDivWithTooltip(`${psNum}`, `Patchset ${psNum}`)} ${arrow}
`;
}
if (isMagicPath(path)) return nothing;
@@ -1185,7 +1197,7 @@ export class GrFileList extends LitElement {
.status=${status}
.labelPostfix=${postfix}
></gr-file-status>
- <gr-icon icon="arrow_right_alt" class="file-status-arrow"></gr-icon>
+ ${arrow}
`;
}
@@ -1202,6 +1214,7 @@ export class GrFileList extends LitElement {
>
${computeTruncatedPath(file.__path)}
</span>
+ ${this.renderFileMode(file)}
<gr-copy-clipboard
?hideInput=${true}
.text=${file.__path}
@@ -1223,6 +1236,34 @@ export class GrFileList extends LitElement {
`;
}
+ private renderFileMode(file: NormalizedFileInfo) {
+ const {old_mode, new_mode} = file;
+
+ // For added, modified or deleted regular files we do not want to render
+ // anything. Only if a file changed from something else to regular, then let
+ // the user know.
+ if (new_mode === undefined) return nothing;
+ let newModeStr = fileModeToString(new_mode, false);
+ if (new_mode === FileMode.REGULAR_FILE) {
+ if (old_mode === undefined) return nothing;
+ if (old_mode === FileMode.REGULAR_FILE) return nothing;
+ newModeStr = `non-${fileModeToString(old_mode, false)}`;
+ }
+
+ const changed = old_mode !== undefined && old_mode !== new_mode;
+ const icon = changed
+ ? html`<gr-icon icon="warning" class="file-mode-warning"></gr-icon> `
+ : '';
+ const action = changed
+ ? `changed from ${fileModeToString(old_mode)} to`
+ : 'is';
+ return this.renderDivWithTooltip(
+ html`${icon}(${newModeStr})`,
+ `file mode ${action} ${fileModeToString(new_mode)}`,
+ 'file-mode-content'
+ );
+ }
+
private renderStyledPath(filePath: string, previousFilePath?: string) {
const {matchingFolders, newFolders, fileName} = diffFilePaths(
filePath,
@@ -1242,8 +1283,7 @@ export class GrFileList extends LitElement {
private renderFileComments(file: NormalizedFileInfo) {
return html` <div role="gridcell">
<div class="comments desktop">
- <span class="drafts">${this.computeDraftsString(file)}</span>
- <span>${this.computeCommentsString(file)}</span>
+ <span>${this.renderCommentsChips(file)}</span>
<span class="noCommentsScreenReaderText">
<!-- Screen readers read the following content only if 2 other
spans in the parent div is empty. The content is not visible on
@@ -1277,10 +1317,7 @@ export class GrFileList extends LitElement {
For example, without a nested div screen readers pronounce the
"Commit message" row content with incorrect column headers.
-->
- <div
- class=${this.computeSizeBarsClass(file.__path)}
- aria-label="A bar that represents the addition and deletion ratio for the current file"
- >
+ <div class=${this.computeSizeBarsClass(file.__path)} aria-hidden="true">
<svg width="61" height="8">
<rect
x=${this.computeBarAdditionX(file, sizeBarLayout)}
@@ -1312,7 +1349,7 @@ export class GrFileList extends LitElement {
<span
class="added"
tabindex="0"
- aria-label=${`${file.lines_inserted} lines added`}
+ aria-label=${`${file.lines_inserted} added`}
?hidden=${file.binary}
>
+${file.lines_inserted}
@@ -1320,7 +1357,7 @@ export class GrFileList extends LitElement {
<span
class="removed"
tabindex="0"
- aria-label=${`${file.lines_deleted} lines removed`}
+ aria-label=${`${file.lines_deleted} removed`}
?hidden=${file.binary}
>
-${file.lines_deleted}
@@ -1410,6 +1447,7 @@ export class GrFileList extends LitElement {
}
private renderShowHide(file: NormalizedFileInfo) {
+ const expanded = this.isFileExpanded(file.__path);
return html` <div class="show-hide" role="gridcell">
<!-- Do not use input type="checkbox" with hidden input and
visible label here. Screen readers don't read/interract
@@ -1422,7 +1460,10 @@ export class GrFileList extends LitElement {
role="switch"
tabindex="0"
aria-checked=${this.isFileExpandedStr(file.__path)}
- aria-label="Expand file"
+ aria-label=${expanded ? 'collapse' : 'expand'}
+ aria-description=${expanded
+ ? 'Collapse diff of this file'
+ : 'Expand diff of this file'}
@click=${this.expandedClick}
@keydown=${this.expandedClick}
>
@@ -1432,7 +1473,7 @@ export class GrFileList extends LitElement {
class="show-hide-icon"
tabindex="-1"
id="icon"
- icon=${this.computeShowHideIcon(file.__path)}
+ icon=${expanded ? 'expand_less' : 'expand_more'}
></gr-icon>
</span>
</div>`;
@@ -1597,22 +1638,31 @@ export class GrFileList extends LitElement {
</div>`;
}
+ renderCommentsChips(file?: NormalizedFileInfo) {
+ if (!this.changeComments || !this.patchRange || !file?.__path) {
+ return nothing;
+ }
+ const commentThreads = this.changeComments?.computeCommentsThreads(
+ this.patchRange,
+ file.__path,
+ file
+ );
+ const draftCount = this.changeComments?.computeDraftCountForFile(
+ this.patchRange,
+ file
+ );
+ return html`<gr-comments-summary
+ .commentThreads=${commentThreads}
+ .draftCount=${draftCount}
+ emptyWhenNoComments
+ ></gr-comments-summary>`;
+ }
+
protected override firstUpdated(): void {
this.detectChromiteButler();
this.reporting.fileListDisplayed();
}
- protected override updated(): void {
- // for DIFF_AUTOCLOSE logging purposes only
- const ids = this.diffs.map(d => d.uid);
- if (ids.length > 0) {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_FILE_LIST_UPDATED,
- {l: ids.length, ids: ids.slice(0, 10)}
- );
- }
- }
-
// TODO: Move into files-model.
// visible for testing
async updateCleanlyMergedPaths() {
@@ -1731,10 +1781,6 @@ export class GrFileList extends LitElement {
if (!this.diffs.length) {
return;
}
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_RELOAD_FILELIST_PREFS
- );
-
// Re-render all expanded diffs sequentially.
this.renderInOrder(this.expandedFiles, this.diffs);
}
@@ -1821,14 +1867,14 @@ export class GrFileList extends LitElement {
return '';
}
const commentThreadCount =
- this.changeComments.computeCommentThreadCount({
+ this.changeComments.computeCommentThreads({
patchNum: this.patchRange.basePatchNum,
path: file.__path,
- }) +
- this.changeComments.computeCommentThreadCount({
+ }).length +
+ this.changeComments.computeCommentThreads({
patchNum: this.patchRange.patchNum,
path: file.__path,
- });
+ }).length;
return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
}
@@ -1984,7 +2030,6 @@ export class GrFileList extends LitElement {
e.stopPropagation();
if (this.filesExpanded === FilesExpandedState.ALL) {
this.diffCursor?.moveDown();
- this.displayLine = true;
} else {
this.fileCursor.next({circular: true});
this.selectedIndex = this.fileCursor.index;
@@ -2004,7 +2049,6 @@ export class GrFileList extends LitElement {
e.stopPropagation();
if (this.filesExpanded === FilesExpandedState.ALL) {
this.diffCursor?.moveUp();
- this.displayLine = true;
} else {
this.fileCursor.previous({circular: true});
this.selectedIndex = this.fileCursor.index;
@@ -2075,9 +2119,9 @@ export class GrFileList extends LitElement {
this.getNavigation().setUrl(
createDiffUrl({
change: this.change,
- path: diff.path,
patchNum: this.patchRange.patchNum,
basePatchNum: this.patchRange.basePatchNum,
+ diffView: {path: diff.path},
})
);
}
@@ -2096,9 +2140,9 @@ export class GrFileList extends LitElement {
this.getNavigation().setUrl(
createDiffUrl({
change: this.change,
- path: this.files[this.fileCursor.index].__path,
patchNum: this.patchRange.patchNum,
basePatchNum: this.patchRange.basePatchNum,
+ diffView: {path: this.files[this.fileCursor.index].__path},
})
);
}
@@ -2129,17 +2173,17 @@ export class GrFileList extends LitElement {
if (this.editMode && path !== SpecialFilePath.MERGE_LIST) {
return createEditUrl({
changeNum: this.change._number,
- project: this.change.project,
- path,
+ repo: this.change.project,
patchNum: this.patchRange.patchNum,
+ editView: {path},
});
}
return createDiffUrl({
changeNum: this.change._number,
- project: this.change.project,
- path,
+ repo: this.change.project,
patchNum: this.patchRange.patchNum,
basePatchNum: this.patchRange.basePatchNum,
+ diffView: {path},
});
}
@@ -2190,10 +2234,6 @@ export class GrFileList extends LitElement {
return this.isFileExpanded(path) ? 'expanded' : '';
}
- private computeShowHideIcon(path: string | undefined) {
- return this.isFileExpanded(path) ? 'expand_less' : 'expand_more';
- }
-
private computeShowNumCleanlyMerged(): boolean {
return this.cleanlyMergedPaths.length > 0;
}
@@ -2218,13 +2258,7 @@ export class GrFileList extends LitElement {
const previousNumFilesShown = this.shownFiles ? this.shownFiles.length : 0;
const filesShown = this.files.slice(0, this.numFilesShown);
- this.dispatchEvent(
- new CustomEvent('files-shown-changed', {
- detail: {length: filesShown.length},
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'files-shown-changed', {length: filesShown.length});
// Start the timer for the rendering work here because this is where the
// shownFiles property is being set, and shownFiles is used in the
@@ -2460,11 +2494,6 @@ export class GrFileList extends LitElement {
return undefined;
}
- // Private but used in tests.
- handleEscKey() {
- this.displayLine = false;
- }
-
/**
* Compute size bar layout values from the file list.
* Private but used in tests.
@@ -2616,7 +2645,7 @@ export class GrFileList extends LitElement {
}
private handleReloadingDiffPreference() {
- this.userModel.getDiffPreferences();
+ this.getUserModel().getDiffPreferences();
}
private getOldPath(file: NormalizedFileInfo) {
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
index 529c05ef71..6033a252d9 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.ts
@@ -56,6 +56,7 @@ import {GrIcon} from '../../shared/gr-icon/gr-icon';
import {fixture, html, assert} from '@open-wc/testing';
import {Modifier} from '../../../utils/dom-util';
import {testResolver} from '../../../test/common-test-setup';
+import {FileMode} from '../../../utils/file-util';
suite('gr-diff a11y test', () => {
test('audit', async () => {
@@ -68,7 +69,7 @@ function createFiles(
fileInfo: FileInfo = {}
): NormalizedFileInfo[] {
const files = Array(count).fill({});
- return files.map((_, idx) => normalize(fileInfo, `'/file${idx}`));
+ return files.map((_, idx) => normalize(fileInfo, `path/file${idx}`));
}
suite('gr-file-list tests', () => {
@@ -82,9 +83,6 @@ suite('gr-file-list tests', () => {
stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
stubRestApi('getAccountCapabilities').returns(Promise.resolve({}));
- stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
- Promise.resolve()
- );
stubElement('gr-diff-host', 'reload').callsFake(() => Promise.resolve());
stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
@@ -110,6 +108,7 @@ suite('gr-file-list tests', () => {
.stub(element, '_saveReviewedState')
.callsFake(() => Promise.resolve());
await element.updateComplete;
+ element.showSizeBars = true;
// Wait for expandedFilesChanged to complete.
await waitEventLoop();
});
@@ -171,26 +170,33 @@ suite('gr-file-list tests', () => {
fileRows?.[0],
/* HTML */ `<div
class="file-row row"
- data-file='{"path":"&apos;/file0"}'
+ data-file='{"path":"path/file0"}'
role="row"
tabindex="-1"
+ aria-label="path/file0"
>
<div class="status" role="gridcell">
<gr-file-status></gr-file-status>
</div>
<span class="path" role="gridcell">
<a class="pathLink">
- <span class="fullFileName" title="'/file0">
- <span class="newFilePath"> '/ </span>
+ <span class="fullFileName" title="path/file0">
+ <span class="newFilePath"> path/ </span>
<span class="fileName"> file0 </span>
</span>
- <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+ <span class="truncatedFileName" title="path/file0">
+ …/file0
+ </span>
<gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
</a>
</span>
<div role="gridcell">
<div class="comments desktop">
- <span class="drafts"> </span> <span> </span>
+ <span
+ ><gr-comments-summary
+ emptywhennocomments=""
+ ></gr-comments-summary
+ ></span>
<span class="noCommentsScreenReaderText"> No comments </span>
</div>
<div class="comments mobile">
@@ -199,17 +205,12 @@ suite('gr-file-list tests', () => {
</div>
</div>
<div class="desktop" role="gridcell">
- <div
- aria-label="A bar that represents the addition and deletion ratio for the current file"
- class="hide sizeBars"
- ></div>
+ <div aria-hidden="true" class="sizeBars"></div>
</div>
<div class="stats" role="gridcell">
<div>
- <span aria-label="9 lines added" class="added" tabindex="0">
- +9
- </span>
- <span aria-label="0 lines removed" class="removed" tabindex="0">
+ <span aria-label="9 added" class="added" tabindex="0"> +9 </span>
+ <span aria-label="0 removed" class="removed" tabindex="0">
-0
</span>
<span hidden=""> +/-0 B </span>
@@ -241,10 +242,11 @@ suite('gr-file-list tests', () => {
<div class="show-hide" role="gridcell">
<span
aria-checked="false"
- aria-label="Expand file"
+ aria-label="expand"
+ aria-description="Expand diff of this file"
class="show-hide"
data-expand="true"
- data-path="'/file0"
+ data-path="path/file0"
role="switch"
tabindex="0"
>
@@ -270,11 +272,13 @@ suite('gr-file-list tests', () => {
/* HTML */ `
<span class="path" role="gridcell">
<a class="pathLink">
- <span class="fullFileName" title="'/file0">
- <span class="newFilePath"> '/ </span>
+ <span class="fullFileName" title="path/file0">
+ <span class="newFilePath"> path/ </span>
<span class="fileName"> file0 </span>
</span>
- <span class="truncatedFileName" title="'/file0"> …/file0 </span>
+ <span class="truncatedFileName" title="path/file0">
+ …/file0
+ </span>
<gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
</a>
</span>
@@ -286,11 +290,13 @@ suite('gr-file-list tests', () => {
/* HTML */ `
<span class="path" role="gridcell">
<a class="pathLink">
- <span class="fullFileName" title="'/file1">
- <span class="matchingFilePath"> '/ </span>
+ <span class="fullFileName" title="path/file1">
+ <span class="matchingFilePath"> path/ </span>
<span class="fileName"> file1 </span>
</span>
- <span class="truncatedFileName" title="'/file1"> …/file1 </span>
+ <span class="truncatedFileName" title="path/file1">
+ …/file1
+ </span>
<gr-copy-clipboard hideinput=""> </gr-copy-clipboard>
</a>
</span>
@@ -309,13 +315,55 @@ suite('gr-file-list tests', () => {
/* HTML */ `
<div class="extended status" role="gridcell">
<gr-file-status></gr-file-status>
- <gr-icon class="file-status-arrow" icon="arrow_right_alt"></gr-icon>
+ <gr-icon
+ aria-label="then"
+ class="file-status-arrow"
+ icon="arrow_right_alt"
+ ></gr-icon>
<gr-file-status></gr-file-status>
</div>
`
);
});
+ test('renders file mode', async () => {
+ element.files = createFiles(1, {
+ old_mode: FileMode.REGULAR_FILE,
+ new_mode: FileMode.EXECUTABLE_FILE,
+ });
+ await element.updateComplete;
+ const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+ const fileMode = queryAndAssert(
+ fileRows?.[0],
+ '.path gr-tooltip-content'
+ );
+ assert.dom.equal(
+ fileMode,
+ /* HTML */ `
+ <gr-tooltip-content
+ has-tooltip=""
+ title="file mode changed from regular (100644) to executable (100755)"
+ >
+ <div class="file-mode-content">
+ <gr-icon class="file-mode-warning" icon="warning"> </gr-icon>
+ (executable)
+ </div>
+ </gr-tooltip-content>
+ `
+ );
+ });
+
+ test('renders file mode, but not for regular files', async () => {
+ element.files = createFiles(3, {
+ old_mode: FileMode.REGULAR_FILE,
+ new_mode: FileMode.REGULAR_FILE,
+ });
+ await element.updateComplete;
+ const fileRows = queryAll<HTMLDivElement>(element, '.file-row');
+ const fileMode = query(fileRows?.[0], '.path gr-tooltip-content');
+ assert.notOk(fileMode);
+ });
+
test('renders file status column header', async () => {
element.files = createFiles(1, {lines_inserted: 9});
element.filesLeftBase = createFiles(1, {lines_inserted: 9});
@@ -330,7 +378,11 @@ suite('gr-file-list tests', () => {
<gr-tooltip-content has-tooltip="" title="Patchset 1">
<div class="content">1</div>
</gr-tooltip-content>
- <gr-icon class="file-status-arrow" icon="arrow_right_alt"></gr-icon>
+ <gr-icon
+ aria-label="then"
+ class="file-status-arrow"
+ icon="arrow_right_alt"
+ ></gr-icon>
<gr-tooltip-content has-tooltip="" title="Patchset 2">
<div class="content">2</div>
</gr-tooltip-content>
@@ -2030,9 +2082,6 @@ suite('gr-file-list tests', () => {
stubRestApi('getDiffComments').returns(Promise.resolve({}));
stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
- stubElement('gr-date-formatter', 'loadTimeFormat').callsFake(() =>
- Promise.resolve()
- );
stubRestApi('getDiff').callsFake(() => Promise.resolve(createDiff()));
stubElement('gr-diff-host', 'prefetchDiff').callsFake(() => {});
@@ -2262,22 +2311,6 @@ suite('gr-file-list tests', () => {
assert.isTrue(setUrlStub.calledOnce);
});
- test('displayLine', () => {
- element.filesExpanded = FilesExpandedState.ALL;
-
- element.displayLine = false;
- element.handleCursorNext(new KeyboardEvent('keydown'));
- assert.isTrue(element.displayLine);
-
- element.displayLine = false;
- element.handleCursorPrev(new KeyboardEvent('keydown'));
- assert.isTrue(element.displayLine);
-
- element.displayLine = true;
- element.handleEscKey();
- assert.isFalse(element.displayLine);
- });
-
suite('editMode behavior', () => {
test('reviewed checkbox', async () => {
reviewFileStub.restore();
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index fcfe209b9c..33dfe82a9b 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -12,6 +12,7 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubble} from '../../../utils/event-util';
interface DisplayGroup {
title: string;
@@ -197,12 +198,7 @@ export class GrIncludedInDialog extends LitElement {
private handleCloseTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('close', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'close', {});
}
}
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index 50c5cafe02..13662982db 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -18,21 +18,20 @@ import {
import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
import {Label} from '../../../utils/label-util';
import {LabelNameToValuesMap} from '../../../api/rest-api';
+import {fire} from '../../../utils/event-util';
+import {LabelsChangedDetail} from '../../../api/change-reply';
declare global {
interface HTMLElementTagNameMap {
'gr-label-score-row': GrLabelScoreRow;
}
+ interface HTMLElementEventMap {
+ 'labels-changed': CustomEvent<LabelsChangedDetail>;
+ }
}
@customElement('gr-label-score-row')
export class GrLabelScoreRow extends LitElement {
- /**
- * Fired when any label is changed.
- *
- * @event labels-changed
- */
-
@query('#labelSelector')
labelSelector?: IronSelectorElement;
@@ -169,7 +168,7 @@ export class GrLabelScoreRow extends LitElement {
// Render blank cells so that all same value votes are aligned
private renderBlankItems(position: string) {
const blankItemCount = this.computeBlankItemsCount(position);
- return new Array(blankItemCount)
+ return Array.from({length: blankItemCount})
.fill('')
.map(
() => html`
@@ -365,13 +364,7 @@ export class GrLabelScoreRow extends LitElement {
this.selectedValueText = selectedItem.getAttribute('title') || '';
const name = selectedItem.dataset['name'];
const value = selectedItem.dataset['value'];
- this.dispatchEvent(
- new CustomEvent('labels-changed', {
- detail: {name, value},
- bubbles: true,
- composed: true,
- })
- );
+ if (name && value) fire(this, 'labels-changed', {name, value});
};
private computePermittedLabelValues() {
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
index 850ae095f8..3a83fa199e 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -5,7 +5,7 @@
*/
import '../gr-label-score-row/gr-label-score-row';
import '../../../styles/shared-styles';
-import {LitElement, css, html} from 'lit';
+import {LitElement, css, html, nothing} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {
ChangeInfo,
@@ -49,10 +49,8 @@ export class GrLabelScores extends LitElement {
.abandonedMessage {
font-style: italic;
text-align: center;
- width: 100%;
}
.permissionMessage {
- width: 100%;
color: var(--deemphasized-text-color);
padding-left: var(--label-score-padding-left, 0);
}
@@ -109,8 +107,7 @@ export class GrLabelScores extends LitElement {
label => !this.permittedLabels || this.permittedLabels[label.name]
).length === 0
) {
- return html`<h3 class="heading-4">Trigger Votes</h3>
- <div class="permissionMessage">You don't have permission to vote</div>`;
+ return nothing;
}
return html`<h3 class="heading-4">Trigger Votes</h3>
${this.renderLabels(labels)}`;
diff --git a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
index 9799880df7..09ca036657 100644
--- a/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
+++ b/polygerrit-ui/app/elements/change/gr-message-scores/gr-message-scores.ts
@@ -8,12 +8,12 @@ import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {ChangeInfo} from '../../../api/rest-api';
import {
- ChangeMessage,
LabelExtreme,
PATCH_SET_PREFIX_PATTERN,
} from '../../../utils/comment-util';
import {hasOwnProperty} from '../../../utils/common-util';
import {getTriggerVotes} from '../../../utils/label-util';
+import {ChangeMessage} from '../../../types/common';
const VOTE_RESET_TEXT = '0 (vote reset)';
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
index a4da747bfd..4abfeff0c0 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -24,10 +24,10 @@ import {
AccountInfo,
BasePatchSetNum,
LabelNameToInfoMap,
+ CommentThread,
+ ChangeMessage,
} from '../../../types/common';
import {
- ChangeMessage,
- CommentThread,
isFormattedReviewerUpdate,
LabelExtreme,
PATCH_SET_PREFIX_PATTERN,
@@ -48,6 +48,8 @@ import {when} from 'lit/directives/when.js';
import {FormattedReviewerUpdateInfo} from '../../../types/types';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
+import {fire} from '../../../utils/event-util';
+import {ChangeMessageDeletedEventDetail} from '../../../types/events';
const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
@@ -55,6 +57,10 @@ declare global {
interface HTMLElementTagNameMap {
'gr-message': GrMessage;
}
+ interface HTMLElementEventMap {
+ 'message-anchor-tap': CustomEvent<MessageAnchorTapDetail>;
+ 'change-message-deleted': CustomEvent<ChangeMessageDeletedEventDetail>;
+ }
}
export interface MessageAnchorTapDetail {
@@ -70,12 +76,6 @@ export class GrMessage extends LitElement {
*/
/**
- * Fired when the message's timestamp is tapped.
- *
- * @event message-anchor-tap
- */
-
- /**
* Fired when a change message is deleted.
*
* @event change-message-deleted
@@ -123,9 +123,6 @@ export class GrMessage extends LitElement {
private readonly getNavigation = resolve(this, navigationToken);
- // for COMMENTS_AUTOCLOSE logging purposes only
- readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
constructor() {
super();
this.addEventListener('click', e => this.handleClick(e));
@@ -205,15 +202,11 @@ export class GrMessage extends LitElement {
margin: 0 -4px;
}
.collapsed gr-thread-list,
- .collapsed .replyBtn,
.collapsed .deleteBtn,
.collapsed .hideOnCollapsed,
.hideOnOpen {
display: none;
}
- .replyBtn {
- margin-right: var(--spacing-m);
- }
.collapsed .hideOnOpen {
display: block;
}
@@ -440,24 +433,18 @@ export class GrMessage extends LitElement {
}
private renderActionContainer() {
- if (!this.computeShowReplyButton()) return nothing;
+ if (!this.isAdmin || !this.loggedIn || this.computeIsAutomated()) {
+ return nothing;
+ }
return html` <div class="replyActionContainer">
- <gr-button class="replyBtn" link="" @click=${this.handleReplyTap}>
- Reply
+ <gr-button
+ ?disabled=${this.isDeletingChangeMsg}
+ class="deleteBtn"
+ link=""
+ @click=${this.handleDeleteMessage}
+ >
+ Delete
</gr-button>
- ${when(
- this.isAdmin,
- () => html`
- <gr-button
- ?disabled=${this.isDeletingChangeMsg}
- class="deleteBtn"
- link=""
- @click=${this.handleDeleteMessage}
- >
- Delete
- </gr-button>
- `
- )}
</div>`;
}
@@ -695,16 +682,6 @@ export class GrMessage extends LitElement {
);
}
- // private but used in tests.
- computeShowReplyButton() {
- return (
- !!this.message &&
- !!this.message.message &&
- this.loggedIn &&
- !this.computeIsAutomated()
- );
- }
-
private handleClick(e: Event) {
if (!this.message || this.message?.expanded) {
return;
@@ -746,29 +723,13 @@ export class GrMessage extends LitElement {
private handleAnchorClick(e: Event) {
e.preventDefault();
+ assertIsDefined(this.message, 'message');
// The element which triggers handleAnchorClick is rendered only if
// message.id defined: the element is wrapped in dom-if if="[[message.id]]"
const detail: MessageAnchorTapDetail = {
- id: this.message!.id,
+ id: this.message.id,
};
- this.dispatchEvent(
- new CustomEvent('message-anchor-tap', {
- bubbles: true,
- composed: true,
- detail,
- })
- );
- }
-
- private handleReplyTap(e: Event) {
- e.preventDefault();
- this.dispatchEvent(
- new CustomEvent('reply', {
- detail: {message: this.message},
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'message-anchor-tap', detail);
}
private handleDeleteMessage(e: Event) {
@@ -779,13 +740,10 @@ export class GrMessage extends LitElement {
.deleteChangeCommitMessage(this.changeNum, this.message.id)
.then(() => {
this.isDeletingChangeMsg = false;
- this.dispatchEvent(
- new CustomEvent('change-message-deleted', {
- detail: {message: this.message},
- composed: true,
- bubbles: true,
- })
- );
+ // TODO: Fix the type casting. Might actually be a bug.
+ fire(this, 'change-message-deleted', {
+ message: this.message as ChangeMessage,
+ });
});
}
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index 34292d6ac3..1ed2729594 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -5,7 +5,10 @@
*/
import '../../../test/common-test-setup';
import './gr-message';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+ NavigationService,
+ navigationToken,
+} from '../../core/gr-navigation/gr-navigation';
import {
createAccountWithIdNameAndEmail,
createChange,
@@ -20,7 +23,6 @@ import {
query,
queryAndAssert,
stubRestApi,
- waitEventLoop,
} from '../../../test/test-utils';
import {GrMessage} from './gr-message';
import {
@@ -32,14 +34,12 @@ import {
ReviewInputTag,
Timestamp,
UrlEncodedCommentId,
+ SavingState,
} from '../../../types/common';
-import {
- ChangeMessageDeletedEventDetail,
- ReplyEventDetail,
-} from '../../../types/events';
+import {ChangeMessageDeletedEventDetail} from '../../../types/events';
import {GrButton} from '../../shared/gr-button/gr-button';
import {CommentSide} from '../../../constants/constants';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
import {html} from 'lit';
import {fixture, assert} from '@open-wc/testing';
import {testResolver} from '../../../test/common-test-setup';
@@ -53,32 +53,6 @@ suite('gr-message tests', () => {
element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
});
- test('reply event', async () => {
- element.message = {
- ...createChangeMessage(),
- id: '47c43261_55aa2c41' as ChangeMessageId,
- author: {
- _account_id: 1115495 as AccountId,
- name: 'Andrew Bonventre',
- email: 'andybons@chromium.org' as EmailAddress,
- },
- date: '2016-01-12 20:24:49.448000000' as Timestamp,
- message: 'Uploaded patch set 1.',
- _revision_number: 1 as RevisionPatchSetNum,
- expanded: true,
- };
-
- const promise = mockPromise();
- element.addEventListener('reply', (e: CustomEvent<ReplyEventDetail>) => {
- assert.deepEqual(e.detail.message, element.message);
- promise.resolve();
- });
- await waitEventLoop();
- assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
- queryAndAssert<GrButton>(element, '.replyBtn').click();
- await promise;
- });
-
test('can see delete button', async () => {
element.message = {
...createChangeMessage(),
@@ -368,18 +342,6 @@ suite('gr-message tests', () => {
assert.shadowDom.equal(element, rendered);
});
- test('reply button hidden unless logged in', () => {
- element.message = {
- ...createChangeMessage(),
- message: 'Uploaded patch set 1.',
- expanded: false,
- };
- element.loggedIn = false;
- assert.isFalse(element.computeShowReplyButton());
- element.loggedIn = true;
- assert.isTrue(element.computeShowReplyButton());
- });
-
test('_computeShowOnBehalfOf', () => {
const message = {
...createChangeMessage(),
@@ -423,7 +385,7 @@ suite('gr-message tests', () => {
});
suite('uploaded patchset X message navigates to X - 1 vs X', () => {
- let setUrlStub: SinonStub;
+ let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
setup(() => {
element.change = {...createChange(), revisions: createRevisions(4)};
setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
@@ -814,7 +776,7 @@ suite('gr-message tests', () => {
message: 'n',
unresolved: false,
path: '/PATCHSET_LEVEL',
- __draft: true,
+ savingState: SavingState.OK,
},
],
patchNum: 1 as RevisionPatchSetNum,
@@ -830,59 +792,4 @@ suite('gr-message tests', () => {
);
});
});
-
- suite('when logged in but not admin', () => {
- setup(async () => {
- stubRestApi('getIsAdmin').returns(Promise.resolve(false));
- element = await fixture<GrMessage>(html`<gr-message></gr-message>`);
- });
-
- test('can see reply but not delete button', async () => {
- element.message = {
- ...createChangeMessage(),
- id: '47c43261_55aa2c41' as ChangeMessageId,
- author: {
- _account_id: 1115495 as AccountId,
- name: 'Andrew Bonventre',
- email: 'andybons@chromium.org' as EmailAddress,
- },
- date: '2016-01-12 20:24:49.448000000' as Timestamp,
- message: 'Uploaded patch set 1.',
- _revision_number: 1 as RevisionPatchSetNum,
- expanded: true,
- };
- await element.updateComplete;
-
- assert.isOk(query<HTMLElement>(element, '.replyActionContainer'));
- assert.isNotOk(query<HTMLElement>(element, '.deleteBtn'));
- });
-
- test('reply button shown when message is updated', async () => {
- element.message = undefined;
- await element.updateComplete;
-
- let replyEl = query(element, '.replyActionContainer');
- // We don't even expect the button to show up in the DOM when the message
- // is undefined.
- assert.isNotOk(replyEl);
-
- element.message = {
- ...createChangeMessage(),
- id: '47c43261_55aa2c41' as ChangeMessageId,
- author: {
- _account_id: 1115495 as AccountId,
- name: 'Andrew Bonventre',
- email: 'andybons@chromium.org' as EmailAddress,
- },
- date: '2016-01-12 20:24:49.448000000' as Timestamp,
- message: 'not empty',
- _revision_number: 1 as RevisionPatchSetNum,
- expanded: true,
- };
- await element.updateComplete;
-
- replyEl = queryAndAssert(element, '.replyActionContainer');
- assert.isOk(replyEl);
- });
- });
});
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
index 5417127521..d5da9c97ea 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -15,12 +15,15 @@ import {
ChangeId,
ChangeMessageId,
ChangeMessageInfo,
+ CommentThread,
LabelNameToInfoMap,
NumericChangeId,
PatchSetNum,
VotingRangeInfo,
+ isRobot,
+ EDIT,
+ PARENT,
} from '../../../types/common';
-import {CommentThread, isRobot} from '../../../utils/comment-util';
import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
import {getVotingRange} from '../../../utils/label-util';
import {
@@ -43,7 +46,7 @@ import {
shortcutsServiceToken,
} from '../../../services/shortcuts/shortcuts-service';
import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text';
-import {Interaction} from '../../../constants/reporting';
+import {waitUntil} from '../../../utils/async-util';
/**
* The content of the enum is also used in the UI for the button text.
@@ -154,13 +157,25 @@ function computeRevision(
message: CombinedMessage,
allMessages: CombinedMessage[]
): PatchSetNum | undefined {
- if (message._revision_number && message._revision_number > 0)
+ if (
+ message._revision_number !== undefined &&
+ message._revision_number !== 0 &&
+ message._revision_number !== PARENT &&
+ message._revision_number !== EDIT
+ ) {
return message._revision_number;
+ }
let revision: PatchSetNum = 0 as PatchSetNum;
for (const m of allMessages) {
if (m.date > message.date) break;
- if (m._revision_number && m._revision_number > revision)
+ if (
+ m._revision_number !== undefined &&
+ m._revision_number !== 0 &&
+ m._revision_number !== PARENT &&
+ m._revision_number !== EDIT
+ ) {
revision = m._revision_number;
+ }
}
return revision > 0 ? revision : undefined;
}
@@ -322,8 +337,7 @@ export class GrMessagesList extends LitElement {
@state()
private combinedMessages: CombinedMessage[] = [];
- // Private but used in tests.
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly changeModel = resolve(this, changeModelToken);
@@ -335,7 +349,7 @@ export class GrMessagesList extends LitElement {
super();
subscribe(
this,
- () => this.getCommentsModel().threads$,
+ () => this.getCommentsModel().threadsSaved$,
x => {
this.commentThreads = x;
}
@@ -354,21 +368,6 @@ export class GrMessagesList extends LitElement {
this.changeNum = x;
}
);
- // for COMMENTS_AUTOCLOSE logging purposes only
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED
- );
- }
-
- override updated(): void {
- // for COMMENTS_AUTOCLOSE logging purposes only
- const messages = this.shadowRoot!.querySelectorAll('gr-message');
- if (messages.length > 0) {
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED,
- {uid: messages[0].uid}
- );
- }
}
override willUpdate(changedProperties: PropertyValues): void {
@@ -441,6 +440,9 @@ export class GrMessagesList extends LitElement {
}
async scrollToMessage(messageID: string) {
+ await waitUntil(() => this.messages && this.messages.length > 0);
+ await this.updateComplete;
+
const selector = `[data-message-id="${messageID}"]`;
const el = this.shadowRoot!.querySelector(selector) as
| GrMessage
@@ -465,15 +467,7 @@ export class GrMessagesList extends LitElement {
await el.updateComplete;
await query<GrFormattedText>(el, 'gr-formatted-text.message')
?.updateComplete;
- let top = el.offsetTop;
- for (
- let offsetParent = el.offsetParent as HTMLElement | null;
- offsetParent;
- offsetParent = offsetParent.offsetParent as HTMLElement | null
- ) {
- top += offsetParent.offsetTop;
- }
- window.scrollTo(0, top);
+ el.scrollIntoView();
this.highlightEl(el);
}
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
index 2e62718801..df04f68f98 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.ts
@@ -27,11 +27,13 @@ import {
Timestamp,
UrlEncodedCommentId,
} from '../../../types/common';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assertIsDefined, uuid} from '../../../utils/common-util';
import {html} from 'lit';
import {fixture, assert} from '@open-wc/testing';
import {GrButton} from '../../shared/gr-button/gr-button';
import {PaperToggleButtonElement} from '@polymer/paper-toggle-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {commentsModelToken} from '../../../models/comments/comments-model';
const author = {
_account_id: 42 as AccountId,
@@ -51,17 +53,17 @@ const createComment = function () {
};
};
-const randomMessage = function (opt_params?: ChangeMessageInfo) {
- const params = opt_params || ({} as ChangeMessageInfo);
+const randomMessage = function (params?: ChangeMessageInfo) {
+ params = params || ({} as ChangeMessageInfo);
const author1 = {
_account_id: 1115495 as AccountId,
name: 'Andrew Bonventre',
email: 'andybons@chromium.org' as EmailAddress,
};
return {
- id: (params.id || Math.random().toString()) as ChangeMessageId,
+ id: (params.id || uuid()) as ChangeMessageId,
date: (params.date || '2016-01-12 20:28:33.038000') as Timestamp,
- message: params.message || Math.random().toString(),
+ message: params.message || uuid(),
_revision_number: (params._revision_number || 1) as PatchSetNum,
author: params.author || author1,
tag: params.tag,
@@ -69,7 +71,7 @@ const randomMessage = function (opt_params?: ChangeMessageInfo) {
};
function generateRandomMessages(count: number) {
- return new Array(count)
+ return Array.from({length: count})
.fill(undefined)
.map(() => randomMessage()) as ChangeMessageInfo[];
}
@@ -136,7 +138,9 @@ suite('gr-messages-list tests', () => {
element = await fixture<GrMessagesList>(
html`<gr-messages-list></gr-messages-list>`
);
- await element.getCommentsModel().reloadComments(0 as NumericChangeId);
+ await testResolver(commentsModelToken).reloadComments(
+ 0 as NumericChangeId
+ );
element.messages = messages;
await element.updateComplete;
});
@@ -191,9 +195,9 @@ suite('gr-messages-list tests', () => {
}
});
- test('expand/collapse from external keypress', () => {
+ test('expand/collapse from external keypress', async () => {
// Start with one expanded message. -> not all collapsed
- element.scrollToMessage(messages[1].id);
+ await element.scrollToMessage(messages[1].id);
assert.isFalse(
[...getMessages()].filter(m => m.message?.expanded).length === 0
);
@@ -229,7 +233,6 @@ suite('gr-messages-list tests', () => {
message.message = {...message.message, expanded: false};
}
- const scrollToStub = sinon.stub(window, 'scrollTo');
const highlightStub = sinon.stub(element, 'highlightEl');
await element.scrollToMessage('invalid');
@@ -243,6 +246,11 @@ suite('gr-messages-list tests', () => {
}
const messageID = messages[1].id;
+
+ const selector = `[data-message-id="${messageID}"]`;
+ const el = queryAndAssert<GrMessage>(element, selector);
+ const scrollToStub = sinon.stub(el, 'scrollIntoView');
+
await element.scrollToMessage(messageID);
assert.isTrue(
queryAndAssert<GrMessage>(element, `[data-message-id="${messageID}"]`)
@@ -254,14 +262,18 @@ suite('gr-messages-list tests', () => {
});
test('scroll to message offscreen', async () => {
- const scrollToStub = sinon.stub(window, 'scrollTo');
const highlightStub = sinon.stub(element, 'highlightEl');
element.messages = generateRandomMessages(25);
await element.updateComplete;
- assert.isFalse(scrollToStub.called);
assert.isFalse(highlightStub.called);
const messageID = element.messages[1].id;
+ const selector = `[data-message-id="${messageID}"]`;
+ const el = queryAndAssert<GrMessage>(element, selector);
+ const scrollToStub = sinon.stub(el, 'scrollIntoView');
+
+ assert.isFalse(scrollToStub.called);
+
await element.scrollToMessage(messageID);
assert.isTrue(scrollToStub.calledOnce);
assert.isTrue(highlightStub.calledOnce);
@@ -317,10 +329,11 @@ suite('gr-messages-list tests', () => {
await element.updateComplete;
const messageElements = getMessages();
// threads
- assert.equal(messageElements[0].message!.commentThreads.length, 3);
+ assertIsDefined(messageElements[0].message, 'message');
+ assert.equal(messageElements[0].message.commentThreads.length, 3);
// first thread contains 1 comment
assert.equal(
- messageElements[0].message!.commentThreads[0].comments.length,
+ messageElements[0].message.commentThreads[0].comments.length,
1
);
});
@@ -512,7 +525,8 @@ suite('gr-messages-list tests', () => {
await element.updateComplete;
const messageEls = getMessages();
assert.equal(messageEls.length, 1);
- assert.equal(messageEls[0].message!.message, messages[0].message);
+ assertIsDefined(messageEls[0].message, 'message');
+ assert.equal(messageEls[0].message.message, messages[0].message);
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 69fd142de8..90b05f627b 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -69,8 +69,8 @@ export class GrRelatedChange extends LitElement {
.notCurrent {
color: var(--warning-foreground);
}
- .indirectAncestor {
- color: var(--indirect-ancestor-text-color);
+ .indirectRelation {
+ color: var(--indirect-relation-text-color);
}
.submittableCheck {
padding-left: var(--spacing-s);
@@ -99,7 +99,7 @@ export class GrRelatedChange extends LitElement {
override render() {
const change = this.change;
if (!change) throw new Error('Missing change');
- const linkClass = this._computeLinkClass(change);
+ const linkClass = this.computeLinkClass(change);
return html`
<div class="changeContainer">
<a
@@ -118,16 +118,16 @@ export class GrRelatedChange extends LitElement {
>✓</span
>`
: ''}
- ${this.showChangeStatus && !isChangeInfo(change)
- ? html`<span class=${this._computeChangeStatusClass(change)}>
- (${this._computeChangeStatus(change)})
+ ${this.showChangeStatus
+ ? html`<span class=${this.computeChangeStatusClass(change)}>
+ (${this.computeChangeStatus(change)})
</span>`
: ''}
</div>
`;
}
- _computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
+ private computeLinkClass(change: ChangeInfo | RelatedChangeAndCommitInfo) {
const statuses = [];
if (change.status === ChangeStatus.ABANDONED) {
statuses.push('strikethrough');
@@ -138,12 +138,17 @@ export class GrRelatedChange extends LitElement {
return statuses.join(' ');
}
- _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+ private computeChangeStatusClass(
+ change: RelatedChangeAndCommitInfo | ChangeInfo
+ ) {
const classes = ['status'];
- if (change._revision_number !== change._current_revision_number) {
+ if (
+ !isChangeInfo(change) &&
+ change._revision_number !== change._current_revision_number
+ ) {
classes.push('notCurrent');
- } else if (this._isIndirectAncestor(change)) {
- classes.push('indirectAncestor');
+ } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+ classes.push('indirectRelation');
} else if (change.submittable) {
classes.push('submittable');
} else if (change.status === ChangeStatus.NEW) {
@@ -152,24 +157,27 @@ export class GrRelatedChange extends LitElement {
return classes.join(' ');
}
- _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+ private computeChangeStatus(change: RelatedChangeAndCommitInfo | ChangeInfo) {
switch (change.status) {
case ChangeStatus.MERGED:
return 'Merged';
case ChangeStatus.ABANDONED:
return 'Abandoned';
}
- if (change._revision_number !== change._current_revision_number) {
+ if (
+ !isChangeInfo(change) &&
+ change._revision_number !== change._current_revision_number
+ ) {
return 'Not current';
- } else if (this._isIndirectAncestor(change)) {
- return 'Indirect ancestor';
+ } else if (!isChangeInfo(change) && this.isIndirectRelation(change)) {
+ return 'Indirect relation';
} else if (change.submittable) {
return 'Submittable';
}
return '';
}
- _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+ private isIndirectRelation(change: RelatedChangeAndCommitInfo) {
return (
this.connectedRevisions &&
!this.connectedRevisions.includes(change.commit.commit)
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 5d6ab63727..2ba0cf8f18 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -11,31 +11,26 @@ import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
import '../../shared/gr-icon/gr-icon';
import {classMap} from 'lit/directives/class-map.js';
import {LitElement, css, html, TemplateResult} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {
ChangeInfo,
CommitId,
PatchSetNumber,
RelatedChangeAndCommitInfo,
- RelatedChangesInfo,
RevisionPatchSetNum,
SubmittedTogetherInfo,
} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
import {ParsedChangeInfo} from '../../../types/types';
import {truncatePath} from '../../../utils/path-list-util';
import {pluralize} from '../../../utils/string-util';
-import {
- changeIsOpen,
- getChangeNumber,
- getRevisionKey,
-} from '../../../utils/change-util';
+import {getChangeNumber, getRevisionKey} from '../../../utils/change-util';
import {DEFALT_NUM_CHANGES_WHEN_COLLAPSED} from './gr-related-collapse';
import {createChangeUrl} from '../../../models/views/change';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
export interface ChangeMarkersInList {
showCurrentChangeArrow: boolean;
@@ -54,12 +49,9 @@ export enum Section {
@customElement('gr-related-changes-list')
export class GrRelatedChangesList extends LitElement {
- @property({type: Object})
+ @state()
change?: ParsedChangeInfo;
- @property({type: Boolean})
- mergeable?: boolean;
-
@state()
latestPatchNum?: PatchSetNumber;
@@ -81,17 +73,50 @@ export class GrRelatedChangesList extends LitElement {
@state()
sameTopicChanges: ChangeInfo[] = [];
- private readonly restApiService = getAppContext().restApiService;
-
private readonly getChangeModel = resolve(this, changeModelToken);
+ private readonly getRelatedChangesModel = resolve(
+ this,
+ relatedChangesModelToken
+ );
+
constructor() {
super();
subscribe(
this,
+ () => this.getChangeModel().change$,
+ x => (this.change = x)
+ );
+ subscribe(
+ this,
() => this.getChangeModel().latestPatchNum$,
x => (this.latestPatchNum = x)
);
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().relatedChanges$,
+ x => (this.relatedChanges = x ?? [])
+ );
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().submittedTogether$,
+ x => (this.submittedTogether = x)
+ );
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().cherryPicks$,
+ x => (this.cherryPickChanges = x ?? [])
+ );
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().conflictingChanges$,
+ x => (this.conflictingChanges = x ?? [])
+ );
+ subscribe(
+ this,
+ () => this.getRelatedChangesModel().sameTopicChanges$,
+ x => (this.sameTopicChanges = x ?? [])
+ );
}
static override get styles() {
@@ -239,7 +264,7 @@ export class GrRelatedChangesList extends LitElement {
.href=${change?._change_number
? createChangeUrl({
changeNum: change._change_number,
- project: change.project,
+ repo: change.project,
usp: 'related-change',
patchNum: change._revision_number as RevisionPatchSetNum,
})
@@ -292,7 +317,7 @@ export class GrRelatedChangesList extends LitElement {
>
${this.renderMarkers(
submittedTogetherMarkersPredicate(index)
- )}${this.renderSubmittedTogetherLine(change, true)}
+ )}${this.renderSubmittedTogetherLine(change)}
</div>`
)}
</gr-related-collapse>
@@ -302,17 +327,14 @@ export class GrRelatedChangesList extends LitElement {
</section>`;
}
- private renderSubmittedTogetherLine(
- change: ChangeInfo,
- showSubmittabilityCheck: boolean
- ) {
+ private renderSubmittedTogetherLine(change: ChangeInfo) {
const truncatedRepo = truncatePath(change.project, 2);
return html`
<gr-related-change
.label=${this.renderChangeTitle(change)}
.change=${change}
.href=${createChangeUrl({change, usp: 'submitted-together'})}
- ?show-submittable-check=${showSubmittabilityCheck}
+ show-submittable-check
>${change.subject}</gr-related-change
>
<span class="repo" .title=${change.project}>${truncatedRepo}</span
@@ -351,7 +373,7 @@ export class GrRelatedChangesList extends LitElement {
>
${this.renderMarkers(
sameTopicMarkersPredicate(index)
- )}${this.renderSubmittedTogetherLine(change, false)}
+ )}${this.renderSubmittedTogetherLine(change)}
</div>`
)}
</gr-related-collapse>
@@ -432,6 +454,7 @@ export class GrRelatedChangesList extends LitElement {
)}<gr-related-change
.change=${change}
.href=${createChangeUrl({change, usp: 'cherry-pick'})}
+ show-change-status
>${change.branch}: ${change.subject}</gr-related-change
>
</div>`
@@ -580,72 +603,6 @@ export class GrRelatedChangesList extends LitElement {
return html`<span class="marker space"></span>`;
}
- reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
- const change = this.change;
- if (!change) return Promise.reject(new Error('change missing'));
- if (!this.latestPatchNum)
- return Promise.reject(new Error('latestPatchNum missing'));
- if (!getRelatedChanges) {
- getRelatedChanges = this.restApiService.getRelatedChanges(
- change._number,
- this.latestPatchNum
- );
- }
- const promises: Array<Promise<void>> = [
- getRelatedChanges.then(response => {
- if (!response) {
- throw new Error('getRelatedChanges returned undefined response');
- }
- this.relatedChanges = response?.changes ?? [];
- }),
- this.restApiService
- .getChangesSubmittedTogether(change._number)
- .then(response => {
- this.submittedTogether = response;
- }),
- this.restApiService
- .getChangeCherryPicks(change.project, change.change_id, change.branch)
- .then(response => {
- this.cherryPickChanges = response || [];
- }),
- ];
-
- // Get conflicts if change is open and is mergeable.
- // Mergeable is output of restApiServict.getMergeable from gr-change-view
- if (changeIsOpen(change) && this.mergeable) {
- promises.push(
- this.restApiService
- .getChangeConflicts(change._number)
- .then(response => {
- this.conflictingChanges = response ?? [];
- })
- );
- }
- if (change.topic) {
- const changeTopic = change.topic;
- promises.push(
- this.restApiService.getConfig().then(config => {
- if (config && !config.change.submit_whole_topic) {
- return this.restApiService
- .getChangesWithSameTopic(changeTopic, {
- openChangesOnly: true,
- changeToExclude: change._number,
- })
- .then(response => {
- if (changeTopic === this.change?.topic) {
- this.sameTopicChanges = response ?? [];
- }
- });
- }
- this.sameTopicChanges = [];
- return Promise.resolve();
- })
- );
- }
-
- return Promise.all(promises);
- }
-
/**
* Do the given objects describe the same change? Compares the changes by
* their numbers.
@@ -681,9 +638,7 @@ export class GrRelatedChangesList extends LitElement {
while (pos >= 0) {
const commit: CommitId = commits[pos].commit;
connected.push(commit);
- // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
- // eslint-disable-next-line eqeqeq
- if (commit == changeRevision) {
+ if (commit === changeRevision) {
break;
}
pos--;
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index c6921b2ff5..24a8217de2 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -4,11 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStubbedMember} from 'sinon';
import {PluginApi} from '../../../api/plugin';
-import {ChangeStatus} from '../../../constants/constants';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
import '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
import {
createChange,
createCommitInfoWithRequiredCommit,
@@ -18,13 +16,7 @@ import {
createRevision,
createSubmittedTogetherInfo,
} from '../../../test/test-data-generators';
-import {
- query,
- queryAndAssert,
- resetPlugins,
- stubRestApi,
- waitEventLoop,
-} from '../../../test/test-utils';
+import {query, queryAndAssert, waitEventLoop} from '../../../test/test-utils';
import {
ChangeId,
ChangeInfo,
@@ -38,7 +30,7 @@ import {
import {ParsedChangeInfo} from '../../../types/types';
import {getChangeNumber} from '../../../utils/change-util';
import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import './gr-related-changes-list';
import {
ChangeMarkersInList,
@@ -196,16 +188,10 @@ suite('gr-related-changes-list', () => {
});
test('render', async () => {
- stubRestApi('getRelatedChanges').returns(
- Promise.resolve(relatedChangeInfo)
- );
- stubRestApi('getChangesSubmittedTogether').returns(
- Promise.resolve(submittedTogether)
- );
- stubRestApi('getChangeCherryPicks').returns(
- Promise.resolve([createChange()])
- );
- await element.reload();
+ element.relatedChanges = relatedChangeInfo.changes;
+ element.submittedTogether = submittedTogether;
+ element.cherryPickChanges = [createChange()];
+ await element.updateComplete;
assert.shadowDom.equal(
element,
@@ -249,7 +235,7 @@ suite('gr-related-changes-list', () => {
<gr-related-collapse title="Cherry picks">
<div class="relatedChangeLine show-when-collapsed">
<span class="marker space"> </span>
- <gr-related-change>
+ <gr-related-change show-change-status="">
test-branch: Test subject
</gr-related-change>
</div>
@@ -262,10 +248,9 @@ suite('gr-related-changes-list', () => {
});
test('first list', async () => {
- stubRestApi('getRelatedChanges').returns(
- Promise.resolve(relatedChangeInfo)
- );
- await element.reload();
+ element.relatedChanges = relatedChangeInfo.changes;
+ await element.updateComplete;
+
const section = queryAndAssert<HTMLElement>(element, '#relatedChanges');
const relatedChanges = queryAndAssert<GrRelatedCollapse>(
section,
@@ -275,13 +260,10 @@ suite('gr-related-changes-list', () => {
});
test('first empty second non-empty', async () => {
- stubRestApi('getRelatedChanges').returns(
- Promise.resolve(createRelatedChangesInfo())
- );
- stubRestApi('getChangesSubmittedTogether').returns(
- Promise.resolve(submittedTogether)
- );
- await element.reload();
+ element.relatedChanges = createRelatedChangesInfo().changes;
+ element.submittedTogether = submittedTogether;
+ await element.updateComplete;
+
const relatedChanges = query<HTMLElement>(element, '#relatedChanges');
assert.notExists(relatedChanges);
const submittedTogetherSection = queryAndAssert<GrRelatedCollapse>(
@@ -292,16 +274,10 @@ suite('gr-related-changes-list', () => {
});
test('first non-empty second empty third non-empty', async () => {
- stubRestApi('getRelatedChanges').returns(
- Promise.resolve(relatedChangeInfo)
- );
- stubRestApi('getChangesSubmittedTogether').returns(
- Promise.resolve(createSubmittedTogetherInfo())
- );
- stubRestApi('getChangeCherryPicks').returns(
- Promise.resolve([createChange()])
- );
- await element.reload();
+ element.relatedChanges = relatedChangeInfo.changes;
+ element.submittedTogether = createSubmittedTogetherInfo();
+ element.cherryPickChanges = [createChange()];
+ await element.updateComplete;
const relatedChanges = queryAndAssert<GrRelatedCollapse>(
queryAndAssert<HTMLElement>(element, '#relatedChanges'),
@@ -364,67 +340,6 @@ suite('gr-related-changes-list', () => {
assert.equal(getChangeNumber(change2), 1);
});
- suite('get conflicts tests', () => {
- let element: GrRelatedChangesList;
- let conflictsStub: SinonStubbedMember<RestApiService['getChangeConflicts']>;
-
- setup(async () => {
- element = await fixture(
- html`<gr-related-changes-list></gr-related-changes-list>`
- );
- conflictsStub = stubRestApi('getChangeConflicts').returns(
- Promise.resolve(undefined)
- );
- });
-
- test('request conflicts if open and mergeable', () => {
- element.latestPatchNum = 7 as PatchSetNumber;
- element.change = {
- ...createParsedChange(),
- change_id: '123' as ChangeId,
- status: ChangeStatus.NEW,
- };
- element.mergeable = true;
- element.reload();
- assert.isTrue(conflictsStub.called);
- });
-
- test('does not request conflicts if closed and mergeable', () => {
- element.latestPatchNum = 7 as PatchSetNumber;
- element.change = {
- ...createParsedChange(),
- change_id: '123' as ChangeId,
- status: ChangeStatus.NEW,
- };
- element.reload();
- assert.isFalse(conflictsStub.called);
- });
-
- test('does not request conflicts if open and not mergeable', () => {
- element.latestPatchNum = 7 as PatchSetNumber;
- element.change = {
- ...createParsedChange(),
- change_id: '123' as ChangeId,
- status: ChangeStatus.NEW,
- };
- element.mergeable = false;
- element.reload();
- assert.isFalse(conflictsStub.called);
- });
-
- test('doesnt request conflicts if closed and not mergeable', () => {
- element.latestPatchNum = 7 as PatchSetNumber;
- element.change = {
- ...createParsedChange(),
- change_id: '123' as ChangeId,
- status: ChangeStatus.NEW,
- };
- element.mergeable = false;
- element.reload();
- assert.isFalse(conflictsStub.called);
- });
- });
-
test('connected revisions', () => {
const change: ParsedChangeInfo = {
...createParsedChange(),
@@ -646,16 +561,11 @@ suite('gr-related-changes-list', () => {
let element: GrRelatedChangesList;
setup(async () => {
- resetPlugins();
element = await fixture(
html`<gr-related-changes-list></gr-related-changes-list>`
);
});
- teardown(() => {
- resetPlugins();
- });
-
test('endpoint params', async () => {
element.change = {...createParsedChange(), labels: {}};
interface RelatedChangesListGrEndpointDecorator
@@ -676,7 +586,7 @@ suite('gr-related-changes-list', () => {
'0.1',
'http://some/plugins/url1.js'
);
- getPluginLoader().loadPlugins([]);
+ testResolver(pluginLoaderToken).loadPlugins([]);
await waitEventLoop();
assert.strictEqual(hookEl!.plugin, plugin!);
assert.strictEqual(hookEl!.change, element.change);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
index 9fb856da92..4f951f3094 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.ts
@@ -7,11 +7,11 @@ import '../../../test/common-test-setup';
import './gr-reply-dialog';
import {
queryAndAssert,
- resetPlugins,
stubRestApi,
waitEventLoop,
+ waitUntil,
} from '../../../test/test-utils';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+
import {GrReplyDialog} from './gr-reply-dialog';
import {fixture, html, assert} from '@open-wc/testing';
import {
@@ -22,6 +22,11 @@ import {
} from '../../../types/common';
import {createChange} from '../../../test/test-data-generators';
import {GrButton} from '../../shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {createNewPatchsetLevel} from '../../../utils/comment-util';
+import {commentsModelToken} from '../../../models/comments/comments-model';
suite('gr-reply-dialog-it tests', () => {
let element: GrReplyDialog;
@@ -59,6 +64,9 @@ suite('gr-reply-dialog-it tests', () => {
'Code-Review': ['-1', ' 0', '+1'],
Verified: ['-1', ' 0', '+1'],
};
+ testResolver(commentsModelToken).addNewDraft(
+ createNewPatchsetLevel(latestPatchNum, '', false)
+ );
};
setup(async () => {
@@ -80,10 +88,6 @@ suite('gr-reply-dialog-it tests', () => {
await element.updateComplete;
});
- teardown(() => {
- resetPlugins();
- });
-
test('submit blocked when invalid email is supplied to ccs', async () => {
const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
@@ -99,11 +103,15 @@ suite('gr-reply-dialog-it tests', () => {
});
test('lgtm plugin', async () => {
- resetPlugins();
+ const attachStub = sinon.stub();
+ const callbackStub = sinon.stub();
window.Gerrit.install(
plugin => {
const replyApi = plugin.changeReply();
+ const hook = plugin.hook('reply-text');
+ hook.onAttached(attachStub);
replyApi.addReplyTextChangedCallback(text => {
+ callbackStub(text);
const label = 'Code-Review';
const labelValue = replyApi.getLabelValue(label);
if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
@@ -116,17 +124,29 @@ suite('gr-reply-dialog-it tests', () => {
);
element = await fixture(html`<gr-reply-dialog></gr-reply-dialog>`);
setupElement(element);
- getPluginLoader().loadPlugins([]);
- await getPluginLoader().awaitPluginsLoaded();
- await waitEventLoop();
- await waitEventLoop();
+ const pluginLoader = testResolver(pluginLoaderToken);
+ pluginLoader.loadPlugins([]);
+ // This may seem a bit weird, but we have to somehow make sure that the
+ // event listener is actually installed, and apparently a `gr-comment` is
+ // attached twice inside the 'reply-text' endpoint. Could not find a better
+ // way to make sure that the callback is ready to receive events.
+ await waitUntil(() => attachStub.callCount === 2);
+
+ const comment = queryAndAssert<GrComment>(
+ element,
+ 'gr-comment#patchsetLevelComment'
+ );
+ comment.messageText = 'LGTM';
+
+ await waitUntil(() => callbackStub.calledWith('LGTM'));
+
const labelScoreRows = queryAndAssert(
element.getLabelScores(),
'gr-label-score-row[name="Code-Review"]'
);
const selectedBtn = queryAndAssert(
labelScoreRows,
- 'gr-button[data-value="+1"]'
+ 'gr-button[data-value="+1"].iron-selected'
);
assert.isOk(selectedBtn);
});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index e77593ba19..e5826075f5 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -10,12 +10,11 @@ import '../../shared/gr-account-chip/gr-account-chip';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-formatted-text/gr-formatted-text';
-import '../../shared/gr-overlay/gr-overlay';
import '../../shared/gr-account-list/gr-account-list';
import '../gr-label-scores/gr-label-scores';
import '../gr-thread-list/gr-thread-list';
import '../../../styles/shared-styles';
-import {GrReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GrReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {getAppContext} from '../../../services/app-context';
import {
ChangeStatus,
@@ -35,19 +34,16 @@ import {
removeServiceUsers,
toReviewInput,
} from '../../../utils/account-util';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
import {TargetElement} from '../../../api/plugin';
-import {
- FixIronA11yAnnouncer,
- notUndefined,
- ParsedChangeInfo,
-} from '../../../types/types';
+import {isDefined, ParsedChangeInfo} from '../../../types/types';
import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
import {
AccountId,
AccountInfo,
AttentionSetInput,
ChangeInfo,
+ CommentThread,
+ DraftInfo,
GroupInfo,
isAccount,
isDetailedLabelInfo,
@@ -61,6 +57,7 @@ import {
SuggestedReviewerGroupInfo,
Suggestion,
UserId,
+ isDraft,
} from '../../../types/common';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
@@ -73,16 +70,11 @@ import {
queryAndAssert,
} from '../../../utils/common-util';
import {
- CommentThread,
- DraftInfo,
getFirstComment,
- isDraft,
isPatchsetLevel,
isUnresolved,
- UnsavedInfo,
} from '../../../utils/comment-util';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
-import {GrOverlay, GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
import {
getApprovalInfo,
getMaxAccounts,
@@ -91,9 +83,10 @@ import {
import {pluralize} from '../../../utils/string-util';
import {
fireAlert,
- fireEvent,
+ fireError,
+ fire,
+ fireNoBubble,
fireIronAnnounce,
- fireReload,
fireServerError,
} from '../../../utils/event-util';
import {ErrorCallback} from '../../../api/rest';
@@ -111,24 +104,32 @@ import {
LabelNameToValuesMap,
PatchSetNumber,
} from '../../../api/rest-api';
-import {css, html, PropertyValues, LitElement} from 'lit';
+import {css, html, PropertyValues, LitElement, nothing} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {when} from 'lit/directives/when.js';
import {classMap} from 'lit/directives/class-map.js';
-import {ValueChangedEvent} from '../../../types/events';
+import {
+ AddReviewerEvent,
+ RemoveReviewerEvent,
+ ValueChangedEvent,
+} from '../../../types/events';
import {customElement, property, state, query} from 'lit/decorators.js';
import {subscribe} from '../../lit/subscription-controller';
import {configModelToken} from '../../../models/config/config-model';
-import {hasHumanReviewer, isOwner} from '../../../utils/change-util';
-import {KnownExperimentId} from '../../../services/flags/flags';
+import {hasHumanReviewer} from '../../../utils/change-util';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {
CommentEditingChangedDetail,
GrComment,
} from '../../shared/gr-comment/gr-comment';
import {ShortcutController} from '../../lit/shortcut-controller';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {userModelToken} from '../../../models/user/user-model';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
export enum FocusTarget {
ANY = 'any',
@@ -164,58 +165,13 @@ const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
@customElement('gr-reply-dialog')
export class GrReplyDialog extends LitElement {
- /**
- * Fired when a reply is successfully sent.
- *
- * @event send
- */
-
- /**
- * Fired when the user presses the cancel button.
- *
- * @event cancel
- */
-
- /**
- * Fired when the main textarea's value changes, which may have triggered
- * a change in size for the dialog.
- *
- * @event autogrow
- */
-
- /**
- * Fires to show an alert when a send is attempted on the non-latest patch.
- *
- * @event show-alert
- */
-
- /**
- * Fires when the reply dialog believes that the server side diff drafts
- * have been updated and need to be refreshed.
- *
- * @event comment-refresh
- */
-
- /**
- * Fires when the state of the send button (enabled/disabled) changes.
- *
- * @event send-disabled-changed
- */
-
- /**
- * Fired to reload the change page.
- *
- * @event reload
- */
-
FocusTarget = FocusTarget;
private readonly reporting = getAppContext().reportingService;
private readonly getChangeModel = resolve(this, changeModelToken);
- // Private but used in tests.
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
// TODO: update type to only ParsedChangeInfo
@property({type: Object})
@@ -236,6 +192,8 @@ export class GrReplyDialog extends LitElement {
@property({type: Object})
projectConfig?: ConfigInfo;
+ @query('#patchsetLevelComment') patchsetLevelGrComment?: GrComment;
+
@query('#reviewers') reviewersList?: GrAccountList;
@query('#ccs') ccsList?: GrAccountList;
@@ -246,8 +204,8 @@ export class GrReplyDialog extends LitElement {
@query('#labelScores') labelScores?: GrLabelScores;
- @query('#reviewerConfirmationOverlay')
- reviewerConfirmationOverlay?: GrOverlay;
+ @query('#reviewerConfirmationModal')
+ reviewerConfirmationModal?: HTMLDialogElement;
@state() latestPatchNum?: PatchSetNumber;
@@ -338,9 +296,6 @@ export class GrReplyDialog extends LitElement {
reviewerPendingConfirmation: SuggestedReviewerGroupInfo | null = null;
@state()
- sendButtonLabel?: string;
-
- @state()
savingComments = false;
@state()
@@ -373,24 +328,24 @@ export class GrReplyDialog extends LitElement {
newAttentionSet: Set<UserId> = new Set();
@state()
- sendDisabled?: boolean;
+ patchsetLevelDraftIsResolved = true;
@state()
- patchsetLevelDraftIsResolved = true;
+ patchsetLevelComment?: DraftInfo;
@state()
- patchsetLevelComment?: UnsavedInfo | DraftInfo;
+ isOwner = false;
private readonly restApiService: RestApiService =
getAppContext().restApiService;
- private readonly jsAPI = getAppContext().jsApiService;
-
- private readonly flagsService = getAppContext().flagsService;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
private readonly getConfigModel = resolve(this, configModelToken);
- private readonly accountsModel = getAppContext().accountsModel;
+ private readonly getAccountsModel = resolve(this, accountsModelToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
storeTask?: DelayedTask;
@@ -400,6 +355,7 @@ export class GrReplyDialog extends LitElement {
static override styles = [
sharedStyles,
+ modalStyles,
css`
:host {
background-color: var(--dialog-background-color);
@@ -464,7 +420,7 @@ export class GrReplyDialog extends LitElement {
flex-wrap: wrap;
flex: 1;
}
- #reviewerConfirmationOverlay {
+ #reviewerConfirmationModal {
padding: var(--spacing-l);
text-align: center;
}
@@ -586,7 +542,7 @@ export class GrReplyDialog extends LitElement {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
margin-top: var(--spacing-m);
- background-color: var(--assignee-highlight-color);
+ background-color: var(--line-item-highlight-color);
}
.attentionTip div gr-icon {
margin-right: var(--spacing-s);
@@ -602,6 +558,16 @@ export class GrReplyDialog extends LitElement {
.patchsetLevelContainer.unresolved {
background-color: var(--unresolved-comment-background-color);
}
+ .privateVisiblityInfo {
+ display: flex;
+ justify-content: center;
+ background-color: var(--info-background);
+ padding: var(--spacing-s) 0;
+ }
+ .privateVisiblityInfo gr-icon {
+ margin-right: var(--spacing-m);
+ color: var(--info-foreground);
+ }
`,
];
@@ -610,7 +576,6 @@ export class GrReplyDialog extends LitElement {
this.filterReviewerSuggestion =
this.filterReviewerSuggestionGenerator(false);
this.filterCCSuggestion = this.filterReviewerSuggestionGenerator(true);
- this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
this.shortcuts.addLocal({key: Key.ESC}, () => this.cancel());
this.shortcuts.addLocal(
@@ -624,7 +589,7 @@ export class GrReplyDialog extends LitElement {
subscribe(
this,
- () => getAppContext().userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
isLoggedIn => (this.isLoggedIn = isLoggedIn)
);
subscribe(
@@ -646,6 +611,11 @@ export class GrReplyDialog extends LitElement {
);
subscribe(
this,
+ () => this.getChangeModel().isOwner$,
+ x => (this.isOwner = x)
+ );
+ subscribe(
+ this,
() => this.getCommentsModel().mentionedUsersInDrafts$,
x => {
this.mentionedUsers = x;
@@ -657,9 +627,6 @@ export class GrReplyDialog extends LitElement {
this,
() => this.getCommentsModel().mentionedUsersInUnresolvedDrafts$,
x => {
- if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- return;
- }
this.mentionedUsersInUnresolvedDrafts = x.filter(
v => !this.isAlreadyReviewerOrCC(v)
);
@@ -672,7 +639,7 @@ export class GrReplyDialog extends LitElement {
);
subscribe(
this,
- () => this.getCommentsModel().draftThreads$,
+ () => this.getCommentsModel().draftThreadsSaved$,
threads =>
(this.draftCommentThreads = threads.filter(
t => !(isDraft(getFirstComment(t)) && isPatchsetLevel(t))
@@ -682,9 +649,13 @@ export class GrReplyDialog extends LitElement {
override connectedCallback() {
super.connectedCallback();
- (
- IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
- ).requestAvailability();
+ ironAnnouncerRequestAvailability();
+
+ this.getPluginLoader().jsApiService.addElement(
+ TargetElement.REPLY_DIALOG,
+ this
+ );
+
this.restApiService.getAccount().then(account => {
if (account) this.account = account;
});
@@ -709,17 +680,19 @@ export class GrReplyDialog extends LitElement {
// Plugins on reply-reviewers endpoint can take advantage of these
// events to add / remove reviewers
- this.addEventListener('add-reviewer', e => {
+ this.addEventListener('add-reviewer', (e: AddReviewerEvent) => {
+ const reviewer = e.detail.reviewer;
// Only support account type, see more from:
// elements/shared/gr-account-list/gr-account-list.js#addAccountItem
this.reviewersList?.addAccountItem({
- account: (e as CustomEvent).detail.reviewer,
+ account: reviewer,
count: 1,
});
});
- this.addEventListener('remove-reviewer', e => {
- this.reviewersList?.removeAccount((e as CustomEvent).detail.reviewer);
+ this.addEventListener('remove-reviewer', (e: RemoveReviewerEvent) => {
+ const reviewer = e.detail.reviewer;
+ this.reviewersList?.removeAccount(reviewer);
});
}
@@ -736,16 +709,6 @@ export class GrReplyDialog extends LitElement {
}
if (changedProperties.has('canBeStarted')) {
this.computeMessagePlaceholder();
- this.computeSendButtonLabel();
- }
- if (changedProperties.has('reviewFormatting')) {
- this.handleHeightChanged();
- }
- if (changedProperties.has('draftCommentThreads')) {
- this.handleHeightChanged();
- }
- if (changedProperties.has('sendDisabled')) {
- this.sendDisabledChanged();
}
if (changedProperties.has('attentionExpanded')) {
this.onAttentionExpandedChange();
@@ -773,7 +736,6 @@ export class GrReplyDialog extends LitElement {
override render() {
if (!this.change) return;
- this.sendDisabled = this.computeSendButtonDisabled();
return html`
<div tabindex="-1">
<section class="peopleContainer">
@@ -788,6 +750,7 @@ export class GrReplyDialog extends LitElement {
<gr-endpoint-slot name="below"></gr-endpoint-slot>
</gr-endpoint-decorator>
${this.renderCCList()} ${this.renderReviewConfirmation()}
+ ${this.renderPrivateVisiblityInfo()}
</section>
<section class="labelsContainer">${this.renderLabels()}</section>
<section class="newReplyDialog textareaContainer">
@@ -864,9 +827,10 @@ export class GrReplyDialog extends LitElement {
private renderReviewConfirmation() {
return html`
- <gr-overlay
- id="reviewerConfirmationOverlay"
- @iron-overlay-canceled=${this.cancelPendingReviewer}
+ <dialog
+ tabindex="-1"
+ id="reviewerConfirmationModal"
+ @close=${this.cancelPendingReviewer}
>
<div class="reviewerConfirmation">
Group
@@ -885,7 +849,23 @@ export class GrReplyDialog extends LitElement {
<gr-button @click=${this.confirmPendingReviewer}>Yes</gr-button>
<gr-button @click=${this.cancelPendingReviewer}>No</gr-button>
</div>
- </gr-overlay>
+ </dialog>
+ `;
+ }
+
+ private renderPrivateVisiblityInfo() {
+ const addedAccounts = [
+ ...(this.reviewersList?.additions() ?? []),
+ ...(this.ccsList?.additions() ?? []),
+ ];
+ if (!this.change?.is_private || !addedAccounts.length) return nothing;
+ return html`
+ <div class="privateVisiblityInfo">
+ <gr-icon icon="info"></gr-icon>
+ <div>
+ Adding a reviewer/CC will make this private change visible to them
+ </div>
+ </div>
`;
}
@@ -909,20 +889,8 @@ export class GrReplyDialog extends LitElement {
`;
}
- // TODO: move to comment-util
- private createDraft(): UnsavedInfo {
- return {
- patch_set: this.latestPatchNum,
- message: this.patchsetLevelDraftMessage,
- unresolved: !this.patchsetLevelDraftIsResolved,
- path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
- __unsaved: true,
- };
- }
-
private renderPatchsetLevelComment() {
- if (!this.patchsetLevelComment)
- this.patchsetLevelComment = this.createDraft();
+ if (!this.patchsetLevelComment) return nothing;
return html`
<gr-comment
id="patchsetLevelComment"
@@ -933,6 +901,8 @@ export class GrReplyDialog extends LitElement {
}}
@comment-text-changed=${(e: ValueChangedEvent<string>) => {
this.patchsetLevelDraftMessage = e.detail.value;
+ // See `addReplyTextChangedCallback` in `ChangeReplyPluginApi`.
+ fire(e.currentTarget as HTMLElement, 'value-changed', e.detail);
}}
.messagePlaceholder=${this.messagePlaceholder}
hide-header
@@ -962,7 +932,8 @@ export class GrReplyDialog extends LitElement {
}
private renderDraftsSection() {
- if (this.computeHideDraftList(this.draftCommentThreads)) return;
+ const threads = this.draftCommentThreads;
+ if (!threads || threads.length === 0) return;
return html`
<section class="draftsContainer">
<div class="includeComments">
@@ -973,17 +944,13 @@ export class GrReplyDialog extends LitElement {
?checked=${this.includeComments}
/>
<label for="includeComments"
- >Publish ${this.computeDraftsTitle(this.draftCommentThreads)}</label
+ >Publish ${this.computeDraftsTitle(threads)}</label
>
</div>
${when(
this.includeComments,
() => html`
- <gr-thread-list
- id="commentList"
- .threads=${this.draftCommentThreads}
- hide-dropdown
- >
+ <gr-thread-list id="commentList" .threads=${threads} hide-dropdown>
</gr-thread-list>
`
)}
@@ -1032,7 +999,7 @@ export class GrReplyDialog extends LitElement {
<gr-button
class="edit-attention-button"
@click=${this.handleAttentionModify}
- ?disabled=${this.sendDisabled}
+ ?disabled=${this.isSendDisabled()}
link
position-below
data-label="Edit"
@@ -1231,10 +1198,12 @@ export class GrReplyDialog extends LitElement {
<gr-button
id="sendButton"
primary
- ?disabled=${this.sendDisabled}
+ ?disabled=${this.isSendDisabled()}
class="action send"
- @click=${this.sendTapHandler}
- >${this.sendButtonLabel}
+ @click=${this.sendClickHandler}
+ >${this.canBeStarted
+ ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
+ : ButtonLabels.SEND}
</gr-button>
</gr-tooltip-content>
</div>
@@ -1248,7 +1217,7 @@ export class GrReplyDialog extends LitElement {
* change view for initializing the dialog after opening the overlay. Maybe it
* should be called `onOpened()` or `initialize()`?
*/
- open(focusTarget?: FocusTarget, quote?: string) {
+ open(focusTarget?: FocusTarget) {
assertIsDefined(this.change, 'change');
this.knownLatestState = LatestPatchState.CHECKING;
this.getChangeModel()
@@ -1260,14 +1229,10 @@ export class GrReplyDialog extends LitElement {
});
this.focusOn(focusTarget);
- if (quote?.length) {
- // If a reply quote has been provided, use it.
- this.patchsetLevelDraftMessage = quote;
- }
if (this.restApiService.hasPendingDiffDrafts()) {
this.savingComments = true;
this.restApiService.awaitPendingDiffDrafts().then(() => {
- fireEvent(this, 'comment-refresh');
+ fire(this, 'comment-refresh', {});
this.savingComments = false;
});
}
@@ -1284,15 +1249,6 @@ export class GrReplyDialog extends LitElement {
this.focusOn(FocusTarget.ANY);
}
- getFocusStops(): GrOverlayStops | undefined {
- const end = this.sendDisabled ? this.cancelButton : this.sendButton;
- if (!this.reviewersList?.focusStart || !end) return undefined;
- return {
- start: this.reviewersList.focusStart,
- end,
- };
- }
-
private handleIncludeCommentsChanged(e: Event) {
if (!(e.target instanceof HTMLInputElement)) return;
this.includeComments = e.target.checked;
@@ -1378,7 +1334,9 @@ export class GrReplyDialog extends LitElement {
);
}
+ // visible for testing
async send(includeComments: boolean, startReview: boolean) {
+ // The change model will end this timing when the change was reloaded.
this.reporting.time(Timing.SEND_REPLY);
const labels = this.getLabelScores().getLabelValues();
@@ -1391,6 +1349,14 @@ export class GrReplyDialog extends LitElement {
if (startReview) {
reviewInput.ready = true;
+ } else if (this.change?.work_in_progress) {
+ const addedAccounts = [
+ ...(this.reviewersList?.additions() ?? []),
+ ...(this.ccsList?.additions() ?? []),
+ ];
+ if (addedAccounts.length > 0) {
+ fireAlert(this, 'Reviewers are not notified for WIP changes');
+ }
}
this.disabled = true;
@@ -1406,27 +1372,24 @@ export class GrReplyDialog extends LitElement {
)
.filter(user => !this.currentAttentionSet.has(user))
.map(user => allAccounts.find(a => getUserId(a) === user))
- .filter(notUndefined);
+ .filter(isDefined);
const newAttentionSetUsers = (
await Promise.all(
- newAttentionSetAdditions.map(a => this.accountsModel.fillDetails(a))
+ newAttentionSetAdditions.map(a =>
+ this.getAccountsModel().fillDetails(a)
+ )
)
- ).filter(notUndefined);
+ ).filter(isDefined);
for (const user of newAttentionSetUsers) {
- let reason;
- if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- reason =
- getMentionedReason(
- this.draftCommentThreads,
- this.account,
- user,
- this.serverConfig
- ) ?? '';
- } else {
- reason = getReplyByReason(this.account, this.serverConfig);
- }
+ const reason =
+ getMentionedReason(
+ this.draftCommentThreads,
+ this.account,
+ user,
+ this.serverConfig
+ ) ?? '';
reviewInput.add_to_attention_set.push({user: getUserId(user), reason});
}
reviewInput.remove_from_attention_set = [];
@@ -1441,11 +1404,7 @@ export class GrReplyDialog extends LitElement {
reviewInput.remove_from_attention_set
);
- const patchsetLevelComment = queryAndAssert<GrComment>(
- this,
- '#patchsetLevelComment'
- );
- await patchsetLevelComment.save();
+ await this.patchsetLevelGrComment?.save();
assertIsDefined(this.change, 'change');
reviewInput.reviewers = this.computeReviewers();
@@ -1465,12 +1424,7 @@ export class GrReplyDialog extends LitElement {
this.patchsetLevelDraftMessage = '';
this.includeComments = true;
- this.dispatchEvent(
- new CustomEvent('send', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'send', {});
fireIronAnnounce(this, 'Reply sent');
return;
})
@@ -1489,26 +1443,25 @@ export class GrReplyDialog extends LitElement {
if (!section || section === FocusTarget.ANY) {
section = this.chooseFocusTarget();
}
- if (section === FocusTarget.REVIEWERS) {
- const reviewerEntry = this.reviewersList?.focusStart;
- setTimeout(() => reviewerEntry?.focus());
- } else if (section === FocusTarget.CCS) {
- const ccEntry = this.ccsList?.focusStart;
- setTimeout(() => ccEntry?.focus());
- }
+ whenVisible(this, () => {
+ if (section === FocusTarget.REVIEWERS) {
+ const reviewerEntry = this.reviewersList?.focusStart;
+ reviewerEntry?.focus();
+ } else if (section === FocusTarget.CCS) {
+ const ccEntry = this.ccsList?.focusStart;
+ ccEntry?.focus();
+ } else {
+ this.patchsetLevelGrComment?.focus();
+ }
+ });
}
chooseFocusTarget() {
- if (!isOwner(this.change, this.account)) return FocusTarget.BODY;
+ if (!this.isOwner) return FocusTarget.BODY;
if (hasHumanReviewer(this.change)) return FocusTarget.BODY;
return FocusTarget.REVIEWERS;
}
- isOwner(account?: AccountInfo, change?: ParsedChangeInfo | ChangeInfo) {
- if (!account || !change || !change.owner) return false;
- return account._account_id === change.owner._account_id;
- }
-
handle400Error(r?: Response | null) {
if (!r) throw new Error('Response is empty.');
let response: Response = r;
@@ -1547,10 +1500,6 @@ export class GrReplyDialog extends LitElement {
});
}
- computeHideDraftList(draftCommentThreads?: CommentThread[]) {
- return !draftCommentThreads || draftCommentThreads.length === 0;
- }
-
computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
const total = draftCommentThreads ? draftCommentThreads.length : 0;
return pluralize(total, 'Draft');
@@ -1580,11 +1529,11 @@ export class GrReplyDialog extends LitElement {
onAttentionExpandedChange() {
// If the attention-detail section is expanded without dispatching this
// event, then the dialog may expand beyond the screen's bottom border.
- fireEvent(this, 'iron-resize');
+ fire(this, 'iron-resize', {});
}
- computeAttentionButtonTitle(sendDisabled?: boolean) {
- return sendDisabled
+ computeAttentionButtonTitle() {
+ return this.isSendDisabled()
? 'Modify the attention set by adding a comment or use the account ' +
'hovercard in the change page.'
: 'Edit attention set changes';
@@ -1632,7 +1581,6 @@ export class GrReplyDialog extends LitElement {
? this.draftCommentThreads
: [];
const hasVote = !!this.labelsChanged;
- const isOwner = this.isOwner(this.account, this.change);
const isUploader = this.uploader?._account_id === this.account._account_id;
this.attentionCcsCount = removeServiceUsers(this.ccs).length;
@@ -1666,7 +1614,7 @@ export class GrReplyDialog extends LitElement {
.filter(
r =>
isAccountNewlyAdded(r, ReviewerState.REVIEWER, this.change) ||
- (this.canBeStarted && isOwner)
+ (this.canBeStarted && this.isOwner)
)
.filter(notIsReviewerAndHasDraftOrLabel)
.forEach(r => newAttention.add((r as AccountInfo)._account_id!));
@@ -1675,7 +1623,7 @@ export class GrReplyDialog extends LitElement {
if (this.uploader?._account_id && !isUploader) {
newAttention.add(this.uploader._account_id);
}
- if (this.change.owner?._account_id && !isOwner) {
+ if (this.change.owner?._account_id && !this.isOwner) {
newAttention.add(this.change.owner._account_id);
}
}
@@ -1703,18 +1651,11 @@ export class GrReplyDialog extends LitElement {
}
computeShowAttentionTip() {
- if (
- !this.account ||
- !this.change?.owner ||
- !this.currentAttentionSet ||
- !this.newAttentionSet
- )
- return false;
- const isOwner = this.account._account_id === this.change.owner._account_id;
+ if (!this.currentAttentionSet || !this.newAttentionSet) return false;
const addedIds = [...this.newAttentionSet].filter(
id => !this.currentAttentionSet.has(id)
);
- return isOwner && addedIds.length > 2;
+ return this.isOwner && addedIds.length > 2;
}
computeCommentAccounts(threads: CommentThread[]) {
@@ -1736,13 +1677,15 @@ export class GrReplyDialog extends LitElement {
}
computeShowNoAttentionUpdate() {
- return this.sendDisabled || this.computeNewAttentionAccounts().length === 0;
+ return (
+ this.isSendDisabled() || this.computeNewAttentionAccounts().length === 0
+ );
}
computeDoNotUpdateMessage() {
if (!this.currentAttentionSet || !this.newAttentionSet) return '';
if (
- this.sendDisabled ||
+ this.isSendDisabled() ||
areSetsEqual(this.currentAttentionSet, this.newAttentionSet)
) {
return 'No changes to the attention set.';
@@ -1849,53 +1792,36 @@ export class GrReplyDialog extends LitElement {
async cancel() {
assertIsDefined(this.change, 'change');
if (!this.change?.owner) throw new Error('missing required owner property');
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
- const patchsetLevelComment = queryAndAssert<GrComment>(
- this,
- '#patchsetLevelComment'
- );
- await patchsetLevelComment.save();
+ fireNoBubble(this, 'cancel', {});
+ await this.patchsetLevelGrComment?.save();
this.rebuildReviewerArrays();
}
- saveClickHandler(e: Event) {
+ private saveClickHandler(e: Event) {
e.preventDefault();
- if (!this.ccsList?.submitEntryText()) {
- // Do not proceed with the save if there is an invalid email entry in
- // the text field of the CC entry.
- return;
- }
- this.send(this.includeComments, false);
+ this.submit(false);
}
- sendTapHandler(e: Event) {
+ private sendClickHandler(e: Event) {
e.preventDefault();
- this.submit();
+ this.submit(this.canBeStarted);
}
- submit() {
+ private submit(startReview?: boolean) {
+ if (startReview === undefined) {
+ startReview = this.isOwner && this.canBeStarted;
+ }
if (!this.ccsList?.submitEntryText()) {
// Do not proceed with the send if there is an invalid email entry in
// the text field of the CC entry.
return;
}
- if (this.sendDisabled) {
+ if (this.isSendDisabled()) {
fireAlert(this, EMPTY_REPLY_MESSAGE);
return;
}
- return this.send(this.includeComments, this.canBeStarted).catch(err => {
- this.dispatchEvent(
- new CustomEvent('show-error', {
- bubbles: true,
- composed: true,
- detail: {message: `Error submitting review ${err}`},
- })
- );
+ return this.send(this.includeComments, startReview).catch(err => {
+ fireError(this, `Error submitting review ${err}`);
});
}
@@ -1912,15 +1838,16 @@ export class GrReplyDialog extends LitElement {
pendingConfirmationUpdated(reviewer: RawAccountInput | null) {
if (reviewer === null) {
- this.reviewerConfirmationOverlay?.close();
+ this.reviewerConfirmationModal?.close();
} else {
this.pendingConfirmationDetails =
this.ccPendingConfirmation || this.reviewerPendingConfirmation;
- this.reviewerConfirmationOverlay?.open();
+ this.reviewerConfirmationModal?.showModal();
}
}
confirmPendingReviewer() {
+ this.reviewerConfirmationModal?.close();
if (this.ccPendingConfirmation) {
this.ccsList?.confirmGroup(this.ccPendingConfirmation.group);
this.focusOn(FocusTarget.CCS);
@@ -1938,6 +1865,7 @@ export class GrReplyDialog extends LitElement {
}
cancelPendingReviewer() {
+ this.reviewerConfirmationModal?.close();
this.ccPendingConfirmation = null;
this.reviewerPendingConfirmation = null;
@@ -1968,10 +1896,6 @@ export class GrReplyDialog extends LitElement {
);
}
- handleHeightChanged() {
- fireEvent(this, 'autogrow');
- }
-
getLabelScores(): GrLabelScores {
return this.labelScores || queryAndAssert(this, 'gr-label-scores');
}
@@ -2004,16 +1928,10 @@ export class GrReplyDialog extends LitElement {
}
_reload() {
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
this.cancel();
}
- computeSendButtonLabel() {
- this.sendButtonLabel = this.canBeStarted
- ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
- : ButtonLabels.SEND;
- }
-
computeSendButtonTooltip(canBeStarted?: boolean, commentEditing?: boolean) {
if (commentEditing) {
return ButtonTooltips.DISABLED_COMMENT_EDITING;
@@ -2025,7 +1943,8 @@ export class GrReplyDialog extends LitElement {
return savingComments ? 'saving' : '';
}
- computeSendButtonDisabled() {
+ // visible for testing
+ isSendDisabled() {
if (
this.canBeStarted === undefined ||
this.patchsetLevelDraftMessage === undefined ||
@@ -2073,10 +1992,6 @@ export class GrReplyDialog extends LitElement {
this.pluginMessage = message;
}
- sendDisabledChanged() {
- this.dispatchEvent(new CustomEvent('send-disabled-changed'));
- }
-
getReviewerSuggestionsProvider(change?: ChangeInfo | ParsedChangeInfo) {
if (!change) return;
const provider = new GrReviewerSuggestionsProvider(
@@ -2130,4 +2045,19 @@ declare global {
interface HTMLElementTagNameMap {
'gr-reply-dialog': GrReplyDialog;
}
+ interface HTMLElementEventMap {
+ /** Fired when the user presses the cancel button. */
+ // prettier-ignore
+ 'cancel': CustomEvent<{}>;
+ /**
+ * Fires when the reply dialog believes that the server side diff drafts
+ * have been updated and need to be refreshed.
+ */
+ 'comment-refresh': CustomEvent<{}>;
+ /** Fired when a reply is successfully sent. */
+ // prettier-ignore
+ 'send': CustomEvent<{}>;
+ /** Fires when the state of the send button (enabled/disabled) changes. */
+ 'send-disabled-changed': CustomEvent<{}>;
+ }
}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 9eca525f78..c4a978cd3d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -13,10 +13,14 @@ import {
query,
queryAll,
queryAndAssert,
- stubFlags,
stubRestApi,
+ waitUntilVisible,
} from '../../../test/test-utils';
-import {ChangeStatus, ReviewerState} from '../../../constants/constants';
+import {
+ ChangeStatus,
+ DraftsAction,
+ ReviewerState,
+} from '../../../constants/constants';
import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
import {StandardLabels} from '../../../utils/label-util';
import {
@@ -33,6 +37,7 @@ import {GrReplyDialog} from './gr-reply-dialog';
import {
AccountId,
AccountInfo,
+ CommentThread,
CommitId,
DetailedLabelInfo,
EmailAddress,
@@ -49,19 +54,22 @@ import {
UrlEncodedCommentId,
UserId,
} from '../../../types/common';
-import {CommentThread} from '../../../utils/comment-util';
import {GrAccountList} from '../../shared/gr-account-list/gr-account-list';
import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
-import {GrThreadList} from '../gr-thread-list/gr-thread-list';
-import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
import {fixture, html, waitUntil, assert} from '@open-wc/testing';
import {accountKey} from '../../../utils/account-util';
import {GrButton} from '../../shared/gr-button/gr-button';
import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {Key, Modifier} from '../../../utils/dom-util';
import {GrComment} from '../../shared/gr-comment/gr-comment';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ CommentsModel,
+ commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {isOwner} from '../../../utils/change-util';
+import {createNewPatchsetLevel} from '../../../utils/comment-util';
function cloneableResponse(status: number, text: string) {
return {
@@ -87,6 +95,7 @@ suite('gr-reply-dialog tests', () => {
let element: GrReplyDialog;
let changeNum: NumericChangeId;
let latestPatchNum: PatchSetNumber;
+ let commentsModel: CommentsModel;
let lastId = 1;
const makeAccount = function () {
@@ -145,6 +154,10 @@ suite('gr-reply-dialog tests', () => {
Verified: ['-1', ' 0', '+1'],
};
element.draftCommentThreads = [];
+ commentsModel = testResolver(commentsModelToken);
+ commentsModel.addNewDraft(
+ createNewPatchsetLevel(latestPatchNum, '', false)
+ );
await element.updateComplete;
});
@@ -172,9 +185,9 @@ suite('gr-reply-dialog tests', () => {
);
}
- function interceptSaveReview() {
+ function interceptSaveReview(): Promise<ReviewInput> {
let resolver: (review: ReviewInput) => void;
- const promise = new Promise(resolve => {
+ const promise = new Promise<ReviewInput>(resolve => {
resolver = resolve;
});
stubSaveReview((review: ReviewInput) => {
@@ -203,11 +216,7 @@ suite('gr-reply-dialog tests', () => {
<div class="peopleListLabel">CC</div>
<gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
</div>
- <gr-overlay
- aria-hidden="true"
- id="reviewerConfirmationOverlay"
- style="outline: none; display: none;"
- >
+ <dialog tabindex="-1" id="reviewerConfirmationModal">
<div class="reviewerConfirmation">
Group
<span class="groupName"> </span>
@@ -225,7 +234,7 @@ suite('gr-reply-dialog tests', () => {
No
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
</section>
<section class="labelsContainer">
<gr-endpoint-decorator name="reply-label-scores">
@@ -256,7 +265,7 @@ suite('gr-reply-dialog tests', () => {
<span> No changes to the attention set. </span>
<gr-tooltip-content
has-tooltip=""
- title="Edit attention set changes"
+ title="Modify the attention set by adding a comment or use the account hovercard in the change page."
>
<gr-button
aria-disabled="true"
@@ -323,6 +332,123 @@ suite('gr-reply-dialog tests', () => {
);
});
+ test('renders private change info when reviewer is added', async () => {
+ element.change!.is_private = true;
+ element.requestUpdate();
+ await element.updateComplete;
+ const peopleContainer = queryAndAssert<HTMLDivElement>(
+ element,
+ '.peopleContainer'
+ );
+
+ // Info is rendered only if reviewer is added
+ assert.dom.equal(
+ peopleContainer,
+ `
+ <section class="peopleContainer">
+ <gr-endpoint-decorator name="reply-reviewers">
+ <gr-endpoint-param name="change"> </gr-endpoint-param>
+ <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+ <div class="peopleList">
+ <div class="peopleListLabel">Reviewers</div>
+ <gr-account-list id="reviewers"> </gr-account-list>
+ <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+ </div>
+ <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+ </gr-endpoint-decorator>
+ <div class="peopleList">
+ <div class="peopleListLabel">CC</div>
+ <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+ </div>
+ <dialog
+ tabindex="-1"
+ id="reviewerConfirmationModal"
+ >
+ <div class="reviewerConfirmation">
+ Group
+ <span class="groupName"> </span>
+ has
+ <span class="groupSize"> </span>
+ members.
+ <br />
+ Are you sure you want to add them all?
+ </div>
+ <div class="reviewerConfirmationButtons">
+ <gr-button aria-disabled="false" role="button" tabindex="0">
+ Yes
+ </gr-button>
+ <gr-button aria-disabled="false" role="button" tabindex="0">
+ No
+ </gr-button>
+ </div>
+ </dialog>
+ </section>
+ `
+ );
+
+ const account = createAccountWithId(22);
+ element.reviewersList!.accounts = [];
+ element.reviewersList!.addAccountItem({account, count: 1});
+ element.reviewersList!.dispatchEvent(
+ new CustomEvent('account-added', {
+ detail: {account},
+ })
+ );
+ element.requestUpdate();
+ await element.updateComplete;
+
+ assert.dom.equal(
+ peopleContainer,
+ `
+ <section class="peopleContainer">
+ <gr-endpoint-decorator name="reply-reviewers">
+ <gr-endpoint-param name="change"> </gr-endpoint-param>
+ <gr-endpoint-param name="reviewers"> </gr-endpoint-param>
+ <div class="peopleList">
+ <div class="peopleListLabel">Reviewers</div>
+ <gr-account-list id="reviewers"> </gr-account-list>
+ <gr-endpoint-slot name="right"> </gr-endpoint-slot>
+ </div>
+ <gr-endpoint-slot name="below"> </gr-endpoint-slot>
+ </gr-endpoint-decorator>
+ <div class="peopleList">
+ <div class="peopleListLabel">CC</div>
+ <gr-account-list allow-any-input="" id="ccs"> </gr-account-list>
+ </div>
+ <dialog
+ tabindex="-1"
+ id="reviewerConfirmationModal"
+ >
+ <div class="reviewerConfirmation">
+ Group
+ <span class="groupName"> </span>
+ has
+ <span class="groupSize"> </span>
+ members.
+ <br />
+ Are you sure you want to add them all?
+ </div>
+ <div class="reviewerConfirmationButtons">
+ <gr-button aria-disabled="false" role="button" tabindex="0">
+ Yes
+ </gr-button>
+ <gr-button aria-disabled="false" role="button" tabindex="0">
+ No
+ </gr-button>
+ </div>
+ </dialog>
+ <div class="privateVisiblityInfo">
+ <gr-icon icon="info">
+ </gr-icon>
+ <div>
+ Adding a reviewer/CC will make this private change visible to them
+ </div>
+ </div>
+ </section>
+ `
+ );
+ });
+
test('default to publishing draft comments with reply', async () => {
// Async tick is needed because iron-selector content is distributed and
// distributed content requires an observer to be set up.
@@ -341,21 +467,21 @@ suite('gr-reply-dialog tests', () => {
const review = await saveReviewPromise;
assert.deepEqual(review, {
- drafts: 'PUBLISH_ALL_REVISIONS',
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
labels: {
'Code-Review': 0,
Verified: 0,
},
reviewers: [],
add_to_attention_set: [
- {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+ {
+ reason: '<GERRIT_ACCOUNT_1> replied on the change',
+ user: 999 as UserId,
+ },
],
remove_from_attention_set: [],
ignore_automatic_attention_set_rules: true,
});
- assert.isFalse(
- queryAndAssert<GrThreadList>(element, '#commentList').hidden
- );
});
test('modified attention set', async () => {
@@ -374,13 +500,16 @@ suite('gr-reply-dialog tests', () => {
const review = await saveReviewPromise;
assert.deepEqual(review, {
- drafts: 'PUBLISH_ALL_REVISIONS',
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
labels: {
'Code-Review': 0,
Verified: 0,
},
add_to_attention_set: [
- {reason: '<GERRIT_ACCOUNT_123> replied on the change', user: 314},
+ {
+ reason: '<GERRIT_ACCOUNT_123> replied on the change',
+ user: 314 as UserId,
+ },
],
reviewers: [],
ready: true,
@@ -405,14 +534,17 @@ suite('gr-reply-dialog tests', () => {
const review = await saveReviewPromise;
assert.deepEqual(review, {
- drafts: 'PUBLISH_ALL_REVISIONS',
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
labels: {
'Code-Review': 0,
Verified: 0,
},
add_to_attention_set: [
// Name coming from createUserConfig in test-data-generator
- {reason: 'Name of user not set replied on the change', user: 314},
+ {
+ reason: 'Name of user not set replied on the change',
+ user: 314 as UserId,
+ },
],
reviewers: [],
ready: true,
@@ -480,6 +612,7 @@ suite('gr-reply-dialog tests', () => {
element._ccs = [];
element.draftCommentThreads = draftThreads;
element.includeComments = includeComments;
+ element.isOwner = isOwner(change, element.account);
await element.updateComplete;
@@ -949,6 +1082,7 @@ suite('gr-reply-dialog tests', () => {
// If the change is "work in progress" and the owner sends a reply, then
// add all reviewers.
element.canBeStarted = true;
+ element.isOwner = isOwner(element.change, element.account);
element.computeNewAttention();
await element.updateComplete;
assert.sameMembers(
@@ -958,6 +1092,7 @@ suite('gr-reply-dialog tests', () => {
// ... but not when someone else replies.
element.account = {_account_id: 4 as AccountId};
+ element.isOwner = isOwner(element.change, element.account);
element.computeNewAttention();
assert.sameMembers([...element.newAttentionSet], []);
});
@@ -1077,14 +1212,17 @@ suite('gr-reply-dialog tests', () => {
await waitUntil(() => element.disabled === false);
assert.equal(element.patchsetLevelDraftMessage.length, 0);
assert.deepEqual(review, {
- drafts: 'PUBLISH_ALL_REVISIONS',
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
labels: {
'Code-Review': -1,
Verified: -1,
},
reviewers: [],
add_to_attention_set: [
- {user: 999, reason: '<GERRIT_ACCOUNT_1> replied on the change'},
+ {
+ user: 999 as UserId,
+ reason: '<GERRIT_ACCOUNT_1> replied on the change',
+ },
],
remove_from_attention_set: [],
ignore_automatic_attention_set_rules: true,
@@ -1113,14 +1251,17 @@ suite('gr-reply-dialog tests', () => {
const review = await saveReviewPromise;
await element.updateComplete;
assert.deepEqual(review, {
- drafts: 'KEEP',
+ drafts: DraftsAction.KEEP,
labels: {
'Code-Review': 0,
Verified: 0,
},
reviewers: [],
add_to_attention_set: [
- {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+ {
+ reason: '<GERRIT_ACCOUNT_1> replied on the change',
+ user: 999 as UserId,
+ },
],
remove_from_attention_set: [],
ignore_automatic_attention_set_rules: true,
@@ -1163,40 +1304,6 @@ suite('gr-reply-dialog tests', () => {
});
});
- function getActiveElement() {
- return document.activeElement;
- }
-
- function overlayObserver(mode: string) {
- return new Promise(resolve => {
- function listener() {
- element.removeEventListener('iron-overlay-' + mode, listener);
- resolve(mode);
- }
- element.addEventListener('iron-overlay-' + mode, listener);
- });
- }
-
- function isFocusInsideElement(element: Element) {
- // In Polymer 2 focused element either <paper-input> or nested
- // native input <input> element depending on the current focus
- // in browser window.
- // For example, the focus is changed if the developer console
- // get a focus.
- let activeElement = getActiveElement();
- while (activeElement) {
- if (activeElement === element) {
- return true;
- }
- if (activeElement.parentElement) {
- activeElement = activeElement.parentElement;
- } else {
- activeElement = (activeElement.getRootNode() as ShadowRoot).host;
- }
- }
- return false;
- }
-
async function testConfirmationDialog(cc?: boolean) {
const yesButton = queryAndAssert<GrButton>(
element,
@@ -1211,11 +1318,9 @@ suite('gr-reply-dialog tests', () => {
element.reviewerPendingConfirmation = null;
await element.updateComplete;
assert.isFalse(
- isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+ isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
);
- // Cause the confirmation dialog to display.
- let observer = overlayObserver('opened');
const group = {
id: 'id' as GroupId,
name: 'name' as GroupName,
@@ -1224,13 +1329,13 @@ suite('gr-reply-dialog tests', () => {
element.ccPendingConfirmation = {
group,
confirm: false,
- count: 1,
+ count: 10,
};
} else {
element.reviewerPendingConfirmation = {
group,
confirm: false,
- count: 1,
+ count: 10,
};
}
await element.updateComplete;
@@ -1247,40 +1352,35 @@ suite('gr-reply-dialog tests', () => {
);
}
- await observer;
assert.isTrue(
- isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+ isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
);
- observer = overlayObserver('closed');
const expected = 'Group name has 10 members';
assert.notEqual(
queryAndAssert<HTMLElement>(
element,
- 'reviewerConfirmationOverlay'
+ '#reviewerConfirmationModal'
).innerText.indexOf(expected),
-1
);
- noButton.click(); // close the overlay
-
- await observer;
- assert.isFalse(
- isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+ noButton.click(); // close the dialog
+ await waitUntil(
+ () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
);
+ // TODO(dhruvsri): figure out why focus is not on the input element
// We should be focused on account entry input.
- const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
- assert.isTrue(
- isFocusInsideElement(
- queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
- )
- );
+ // const reviewersEntry = queryAndAssert<GrAccountList>(element, '#reviewers');
+ // assert.isTrue(
+ // isFocusInsideElement(
+ // queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+ // )
+ // );
// No reviewer/CC should have been added.
assert.equal(element.ccsList?.additions().length, 0);
assert.equal(element.reviewersList?.additions().length, 0);
- // Reopen confirmation dialog.
- observer = overlayObserver('opened');
if (cc) {
element.ccPendingConfirmation = {
group,
@@ -1294,46 +1394,47 @@ suite('gr-reply-dialog tests', () => {
count: 1,
};
}
+ await element.updateComplete;
- await observer;
assert.isTrue(
- isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+ isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
);
- observer = overlayObserver('closed');
- yesButton.click(); // Confirm the group.
- await observer;
- assert.isFalse(
- isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+ yesButton.click(); // Confirm the group.
+ await waitUntil(
+ () => !isVisible(queryAndAssert(element, '#reviewerConfirmationModal'))
);
const additions = cc
? element.ccsList?.additions()
: element.reviewersList?.additions();
assert.deepEqual(additions, [
{
+ confirmed: true,
+ id: 'id' as GroupId,
name: 'name' as GroupName,
},
]);
// We should be focused on account entry input.
- if (cc) {
- const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
- assert.isTrue(
- isFocusInsideElement(
- queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
- )
- );
- } else {
- const reviewersEntry = queryAndAssert<GrAccountList>(
- element,
- '#reviewers'
- );
- assert.isTrue(
- isFocusInsideElement(
- queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
- )
- );
- }
+ // TODO(dhruvsri): figure out why focus is not on the input element
+ // if (cc) {
+ // const ccsEntry = queryAndAssert<GrAccountList>(element, '#ccs');
+ // assert.isTrue(
+ // isFocusInsideElement(
+ // queryAndAssert<GrAutocomplete>(ccsEntry.entry, '#input').input!
+ // )
+ // );
+ // } else {
+ // const reviewersEntry = queryAndAssert<GrAccountList>(
+ // element,
+ // '#reviewers'
+ // );
+ // assert.isTrue(
+ // isFocusInsideElement(
+ // queryAndAssert<GrAutocomplete>(reviewersEntry.entry, '#input').input!
+ // )
+ // );
+ // }
}
test('cc confirmation', async () => {
@@ -1344,6 +1445,70 @@ suite('gr-reply-dialog tests', () => {
testConfirmationDialog(false);
});
+ suite('reviewer toast for WIP changes', () => {
+ let fireStub: sinon.SinonStub;
+ setup(() => {
+ fireStub = sinon.stub(element, 'dispatchEvent');
+ });
+
+ test('toast not fired if change is already active', async () => {
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.NEW,
+ };
+ element.send(false, false);
+
+ await waitUntil(() => fireStub.called);
+
+ const events = fireStub.args.map(arg => arg[0].type || '');
+ assert.isFalse(events.includes('show-alert'));
+ });
+
+ test('toast is not fired if change is WIP and becomes active', async () => {
+ const account = createAccountWithId(22);
+ element.reviewersList!.accounts = [];
+ element.reviewersList!.addAccountItem({account, count: 1});
+ element.reviewersList!.dispatchEvent(
+ new CustomEvent('account-added', {
+ detail: {account},
+ })
+ );
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.NEW,
+ work_in_progress: true,
+ };
+ element.send(false, true);
+
+ await waitUntil(() => fireStub.called);
+
+ const events = fireStub.args.map(arg => arg[0].type || '');
+ assert.isFalse(events.includes('show-alert'));
+ });
+
+ test('toast is fired if change is WIP and becomes active and reviewer added', async () => {
+ const account = createAccountWithId(22);
+ element.reviewersList!.accounts = [];
+ element.reviewersList!.addAccountItem({account, count: 1});
+ element.reviewersList!.dispatchEvent(
+ new CustomEvent('account-added', {
+ detail: {account},
+ })
+ );
+ element.change = {
+ ...createChange(),
+ status: ChangeStatus.NEW,
+ work_in_progress: true,
+ };
+ element.send(false, false);
+
+ await waitUntil(() => fireStub.called);
+
+ const events = fireStub.args.map(arg => arg[0].type || '');
+ assert.isTrue(events.includes('show-alert'));
+ });
+ });
+
test('reviewersMutated when account-text-change is fired from ccs', () => {
assert.isFalse(element.reviewersMutated);
assert.isTrue(queryAndAssert<GrAccountList>(element, '#ccs').allowAnyInput);
@@ -1444,14 +1609,10 @@ suite('gr-reply-dialog tests', () => {
test('focusOn', async () => {
await element.updateComplete;
- const clock = sinon.useFakeTimers();
const chooseFocusTargetSpy = sinon.spy(element, 'chooseFocusTarget');
element.focusOn();
- // element.focus() is called after a setTimeout(). The focusOn() method
- // does not trigger any changes in the element hence element.updateComplete
- // resolves immediately and cannot be used here, hence tick the clock here
- // explicitly instead
- clock.tick(1);
+ await waitUntilVisible(element); // let whenVisible resolve
+
assert.equal(chooseFocusTargetSpy.callCount, 1);
assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
assert.equal(
@@ -1460,7 +1621,8 @@ suite('gr-reply-dialog tests', () => {
);
element.focusOn(element.FocusTarget.ANY);
- clock.tick(1);
+ await waitUntilVisible(element); // let whenVisible resolve
+
assert.equal(chooseFocusTargetSpy.callCount, 2);
assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
assert.equal(
@@ -1469,7 +1631,8 @@ suite('gr-reply-dialog tests', () => {
);
element.focusOn(element.FocusTarget.BODY);
- clock.tick(1);
+ await waitUntilVisible(element); // let whenVisible resolve
+
assert.equal(chooseFocusTargetSpy.callCount, 2);
assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-COMMENT');
assert.equal(
@@ -1478,30 +1641,28 @@ suite('gr-reply-dialog tests', () => {
);
element.focusOn(element.FocusTarget.REVIEWERS);
- clock.tick(1);
+ await waitUntilVisible(element); // let whenVisible resolve
+
assert.equal(chooseFocusTargetSpy.callCount, 2);
- assert.equal(
- element?.shadowRoot?.activeElement?.tagName,
- 'GR-ACCOUNT-LIST'
+ await waitUntil(
+ () => element?.shadowRoot?.activeElement?.tagName === 'GR-ACCOUNT-LIST'
);
assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
element.focusOn(element.FocusTarget.CCS);
- clock.tick(1);
assert.equal(chooseFocusTargetSpy.callCount, 2);
assert.equal(
element?.shadowRoot?.activeElement?.tagName,
'GR-ACCOUNT-LIST'
);
- assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
- clock.restore();
+ await waitUntil(() => element?.shadowRoot?.activeElement?.id === 'ccs');
});
test('chooseFocusTarget', () => {
- element.account = undefined;
+ element.isOwner = false;
assert.equal(element.chooseFocusTarget(), element.FocusTarget.BODY);
- element.account = element.change!.owner;
+ element.isOwner = true;
assert.equal(element.chooseFocusTarget(), element.FocusTarget.REVIEWERS);
element.change!.reviewers.REVIEWER = [createAccountWithId(314)];
@@ -1748,7 +1909,7 @@ suite('gr-reply-dialog tests', () => {
// Remove and add to other field.
reviewers.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: reviewer1},
composed: true,
bubbles: true,
@@ -1765,14 +1926,14 @@ suite('gr-reply-dialog tests', () => {
})
);
ccs.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: cc1},
composed: true,
bubbles: true,
})
);
ccs.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: cc3},
composed: true,
bubbles: true,
@@ -1836,13 +1997,13 @@ suite('gr-reply-dialog tests', () => {
const mapReviewer = function (
reviewer: AccountInfo,
- opt_state?: ReviewerState
+ state?: ReviewerState
) {
const result: ReviewerInput = {
reviewer: reviewer._account_id as AccountId,
};
- if (opt_state) {
- result.state = opt_state;
+ if (state) {
+ result.state = state;
}
return result;
};
@@ -1949,16 +2110,26 @@ suite('gr-reply-dialog tests', () => {
pressKey(element, Key.ENTER);
});
- test('emit send on ctrl+enter key', async () => {
- // required so that "Send" button is enabled
+ test('send and start review on ctrl+enter for owner', async () => {
element.canBeStarted = true;
+ element.isOwner = true;
await element.updateComplete;
- stubSaveReview(() => undefined);
- const promise = mockPromise();
- element.addEventListener('send', () => promise.resolve());
+ const savePromise = interceptSaveReview();
pressKey(element, Key.ENTER, Modifier.CTRL_KEY);
- await promise;
+ const reviewInput = await savePromise;
+ assert.isTrue(reviewInput.ready);
+ });
+
+ test('save on ctrl+enter for reviewer', async () => {
+ element.canBeStarted = true;
+ element.isOwner = false;
+ await element.updateComplete;
+
+ const savePromise = interceptSaveReview();
+ pressKey(element, Key.ENTER, Modifier.CTRL_KEY);
+ const reviewInput = await savePromise;
+ assert.isUndefined(reviewInput.ready);
});
test('computeMessagePlaceholder', async () => {
@@ -1974,14 +2145,14 @@ suite('gr-reply-dialog tests', () => {
);
});
- test('computeSendButtonLabel', async () => {
+ test('sendButton text', async () => {
element.canBeStarted = false;
await element.updateComplete;
- assert.equal(element.sendButtonLabel, 'Send');
+ assert.equal(element.sendButton?.innerText, 'SEND');
element.canBeStarted = true;
await element.updateComplete;
- assert.equal(element.sendButtonLabel, 'Send and Start review');
+ assert.equal(element.sendButton?.innerText, 'SEND AND START REVIEW');
});
test('handle400Error reviewers and CCs', async () => {
@@ -2018,17 +2189,6 @@ suite('gr-reply-dialog tests', () => {
await promise;
});
- test('fires height change when the drafts comments load', async () => {
- // Flush DOM operations before binding to the autogrow event so we don't
- // catch the events fired from the initial layout.
- await element.updateComplete;
- const autoGrowHandler = sinon.stub();
- element.addEventListener('autogrow', autoGrowHandler);
- element.draftCommentThreads = [];
- await element.updateComplete;
- assert.isTrue(autoGrowHandler.called);
- });
-
suite('start review and save buttons', () => {
let sendStub: sinon.SinonStub;
@@ -2112,7 +2272,7 @@ suite('gr-reply-dialog tests', () => {
});
});
- test('computeSendButtonDisabled_canBeStarted', () => {
+ test('isSendDisabled_canBeStarted', () => {
// Mock canBeStarted
element.canBeStarted = true;
element.draftCommentThreads = [];
@@ -2123,10 +2283,10 @@ suite('gr-reply-dialog tests', () => {
element.disabled = false;
element.commentEditing = false;
element.account = makeAccount();
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
});
- test('computeSendButtonDisabled_allFalse', () => {
+ test('isSendDisabled_allFalse', () => {
// Mock everything false
element.canBeStarted = false;
element.draftCommentThreads = [];
@@ -2137,10 +2297,10 @@ suite('gr-reply-dialog tests', () => {
element.disabled = false;
element.commentEditing = false;
element.account = makeAccount();
- assert.isTrue(element.computeSendButtonDisabled());
+ assert.isTrue(element.isSendDisabled());
});
- test('computeSendButtonDisabled_draftCommentsSend', () => {
+ test('isSendDisabled_draftCommentsSend', () => {
// Mock nonempty comment draft array; with sending comments.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2151,10 +2311,10 @@ suite('gr-reply-dialog tests', () => {
element.disabled = false;
element.commentEditing = false;
element.account = makeAccount();
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
});
- test('computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+ test('isSendDisabled_draftCommentsDoNotSend', () => {
// Mock nonempty comment draft array; without sending comments.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2166,10 +2326,10 @@ suite('gr-reply-dialog tests', () => {
element.commentEditing = false;
element.account = makeAccount();
- assert.isTrue(element.computeSendButtonDisabled());
+ assert.isTrue(element.isSendDisabled());
});
- test('computeSendButtonDisabled_changeMessage', () => {
+ test('isSendDisabled_changeMessage', () => {
// Mock nonempty change message.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2181,10 +2341,10 @@ suite('gr-reply-dialog tests', () => {
element.commentEditing = false;
element.account = makeAccount();
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
});
- test('computeSendButtonDisabledreviewersChanged', () => {
+ test('isSendDisabledreviewersChanged', () => {
// Mock reviewers mutated.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2196,10 +2356,10 @@ suite('gr-reply-dialog tests', () => {
element.commentEditing = false;
element.account = makeAccount();
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
});
- test('computeSendButtonDisabled_labelsChanged', () => {
+ test('isSendDisabled_labelsChanged', () => {
// Mock labels changed.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2211,10 +2371,10 @@ suite('gr-reply-dialog tests', () => {
element.commentEditing = false;
element.account = makeAccount();
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
});
- test('computeSendButtonDisabled_dialogDisabled', () => {
+ test('isSendDisabled_dialogDisabled', () => {
// Whole dialog is disabled.
element.canBeStarted = false;
element.draftCommentThreads = [{...createCommentThread([createComment()])}];
@@ -2226,10 +2386,10 @@ suite('gr-reply-dialog tests', () => {
element.commentEditing = false;
element.account = makeAccount();
- assert.isTrue(element.computeSendButtonDisabled());
+ assert.isTrue(element.isSendDisabled());
});
- test('computeSendButtonDisabled_existingVote', async () => {
+ test('isSendDisabled_existingVote', async () => {
const account = createAccountWithId();
(
element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
@@ -2245,7 +2405,7 @@ suite('gr-reply-dialog tests', () => {
element.account = account;
// User has already voted.
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
});
test('_submit blocked when no mutations exist', async () => {
@@ -2301,19 +2461,19 @@ suite('gr-reply-dialog tests', () => {
});
test('send button updates state as text is typed in patchset comment', async () => {
- assert.isTrue(element.computeSendButtonDisabled());
+ assert.isTrue(element.isSendDisabled());
queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
'hello';
await waitUntil(() => element.patchsetLevelDraftMessage === 'hello');
- assert.isFalse(element.computeSendButtonDisabled());
+ assert.isFalse(element.isSendDisabled());
queryAndAssert<GrComment>(element, '#patchsetLevelComment').messageText =
'';
await waitUntil(() => element.patchsetLevelDraftMessage === '');
- assert.isTrue(element.computeSendButtonDisabled());
+ assert.isTrue(element.isSendDisabled());
});
test('sending patchset level comment', async () => {
@@ -2341,14 +2501,17 @@ suite('gr-reply-dialog tests', () => {
assert.deepEqual(autoSaveStub.callCount, 1);
assert.deepEqual(review, {
- drafts: 'PUBLISH_ALL_REVISIONS',
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
labels: {
'Code-Review': 0,
Verified: 0,
},
reviewers: [],
add_to_attention_set: [
- {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+ {
+ reason: '<GERRIT_ACCOUNT_1> replied on the change',
+ user: 999 as UserId,
+ },
],
remove_from_attention_set: [],
ignore_automatic_attention_set_rules: true,
@@ -2381,7 +2544,7 @@ suite('gr-reply-dialog tests', () => {
test('replies to patchset level comments are not filtered out', async () => {
const draft = {...createDraft(), in_reply_to: '1' as UrlEncodedCommentId};
- element.getCommentsModel().setState({
+ commentsModel.setState({
drafts: {
'abc.txt': [draft],
},
@@ -2398,9 +2561,6 @@ suite('gr-reply-dialog tests', () => {
suite('mention users', () => {
setup(async () => {
- stubFlags('isEnabled')
- .withArgs(KnownExperimentId.MENTION_USERS)
- .returns(true);
element.account = createAccountWithId(1);
element.requestUpdate();
await element.updateComplete;
@@ -2417,7 +2577,7 @@ suite('gr-reply-dialog tests', () => {
...createDraft(),
message: 'hey @abcd@def take a look at this',
};
- element.getCommentsModel().setState({
+ commentsModel.setState({
comments: {},
robotComments: {},
drafts: {
@@ -2452,7 +2612,7 @@ suite('gr-reply-dialog tests', () => {
message: 'hey @abcd@def.com take a look at this',
unresolved: true,
};
- element.getCommentsModel().setState({
+ commentsModel.setState({
comments: {},
robotComments: {},
drafts: {
@@ -2492,7 +2652,7 @@ suite('gr-reply-dialog tests', () => {
message: 'hey @abcd@def.com take a look at this',
unresolved: true,
};
- element.getCommentsModel().setState({
+ commentsModel.setState({
comments: {},
robotComments: {},
drafts: {
@@ -2545,7 +2705,7 @@ suite('gr-reply-dialog tests', () => {
};
stubRestApi('getAccountDetails').returns(Promise.resolve(account));
- element.getCommentsModel().setState({
+ commentsModel.setState({
comments: {},
robotComments: {},
drafts: {
@@ -2582,7 +2742,7 @@ suite('gr-reply-dialog tests', () => {
registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
};
stubRestApi('getAccountDetails').returns(Promise.resolve(account));
- element.getCommentsModel().setState({
+ commentsModel.setState({
comments: {},
robotComments: {},
drafts: {
@@ -2616,36 +2776,6 @@ suite('gr-reply-dialog tests', () => {
});
});
- test('getFocusStops', async () => {
- // Setting draftCommentThreads to an empty object causes _sendDisabled to be
- // computed to false.
- element.draftCommentThreads = [];
- await element.updateComplete;
-
- assert.equal(
- element.getFocusStops()!.end,
- queryAndAssert<GrButton>(element, '#cancelButton')
- );
- element.draftCommentThreads = [
- {
- ...createCommentThread([
- {
- ...createDraft(),
- path: 'test',
- line: 1,
- patch_set: 1 as RevisionPatchSetNum,
- },
- ]),
- },
- ];
- await element.updateComplete;
-
- assert.equal(
- element.getFocusStops()!.end,
- queryAndAssert<GrButton>(element, '#sendButton')
- );
- });
-
test('setPluginMessage', async () => {
element.setPluginMessage('foo');
await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index 9408b82fca..db19329158 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -23,15 +23,11 @@ import {sortReviewers} from '../../../utils/attention-set-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {css} from 'lit';
import {nothing} from 'lit';
+import {fire} from '../../../utils/event-util';
+import {ShowReplyDialogEvent} from '../../../types/events';
@customElement('gr-reviewer-list')
export class GrReviewerList extends LitElement {
- /**
- * Fired when the "Add reviewer..." button is tapped.
- *
- * @event show-reply-dialog
- */
-
@property({type: Object}) change?: ChangeInfo;
@property({type: Object}) account?: AccountDetailInfo;
@@ -203,22 +199,10 @@ export class GrReviewerList extends LitElement {
handleAddTap(e: Event) {
e.preventDefault();
const value = {
- reviewersOnly: false,
- ccsOnly: false,
+ reviewersOnly: this.reviewersOnly,
+ ccsOnly: this.ccsOnly,
};
- if (this.reviewersOnly) {
- value.reviewersOnly = true;
- }
- if (this.ccsOnly) {
- value.ccsOnly = true;
- }
- this.dispatchEvent(
- new CustomEvent('show-reply-dialog', {
- detail: {value},
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'show-reply-dialog', {value});
}
}
@@ -226,4 +210,8 @@ declare global {
interface HTMLElementTagNameMap {
'gr-reviewer-list': GrReviewerList;
}
+ interface HTMLElementEventMap {
+ /** Fired when the "Add reviewer..." button is tapped. */
+ 'show-reply-dialog': ShowReplyDialogEvent;
+ }
}
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
index 66ad38c8d9..98f65c0633 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -10,13 +10,12 @@ import {customElement, property} from 'lit/decorators.js';
import {
AccountInfo,
ChangeStatus,
- isDetailedLabelInfo,
SubmitRequirementExpressionInfo,
SubmitRequirementResultInfo,
SubmitRequirementStatus,
} from '../../../api/rest-api';
import {
- canVote,
+ canReviewerVote,
extractAssociatedLabels,
getApprovalInfo,
hasVotes,
@@ -204,7 +203,7 @@ export class GrSubmitRequirementHovercard extends base {
if (requirementLabels.includes(label)) {
const labelInfo = allLabels[label];
const canSomeoneVote = (this.change?.reviewers['REVIEWER'] ?? []).some(
- reviewer => canVote(labelInfo, reviewer)
+ reviewer => canReviewerVote(labelInfo, reviewer)
);
if (hasVotes(labelInfo) || canSomeoneVote) {
labels.push(label);
@@ -280,15 +279,17 @@ export class GrSubmitRequirementHovercard extends base {
labelName: string,
type: 'override' | 'submittability'
) {
+ if (!this.account) return;
+ const votes = this.change?.permitted_labels?.[labelName];
+ if (!votes || votes.length < 1) return;
+ const maxVote = Number(votes[votes.length - 1]);
+ if (maxVote <= 0) return;
+
const labels = this.change?.labels ?? {};
const labelInfo = labels[labelName];
- if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
- if (!this.account || !canVote(labelInfo, this.account)) return;
-
const approvalInfo = getApprovalInfo(labelInfo, this.account);
- const maxVote = approvalInfo?.permitted_voting_range?.max;
- if (!maxVote || maxVote <= 0) return;
if (approvalInfo?.value === maxVote) return; // Already voted maxVote
+
return html` <div class="button quickApprove">
<gr-button
link=""
@@ -326,7 +327,7 @@ export class GrSubmitRequirementHovercard extends base {
review
)
.then(() => {
- fireReload(this, true);
+ fireReload(this);
});
}
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
index 4e78e8a179..18f8e4c9da 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard_test.ts
@@ -253,6 +253,9 @@ suite('gr-submit-requirement-hovercard tests', () => {
const change: ParsedChangeInfo = {
...createParsedChange(),
status: ChangeStatus.NEW,
+ permitted_labels: {
+ Verified: ['-1', ' 0', '+1', '+2'],
+ },
labels: {
Verified: {
...createDetailedLabelInfo(),
@@ -351,6 +354,9 @@ suite('gr-submit-requirement-hovercard tests', () => {
const change: ParsedChangeInfo = {
...createParsedChange(),
status: ChangeStatus.NEW,
+ permitted_labels: {
+ 'Build-Cop': ['-1', ' 0', '+1', '+2'],
+ },
labels: {
'Build-Cop': {
...createDetailedLabelInfo(),
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
index fa12eaa7a9..212394c697 100644
--- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements_test.ts
@@ -16,8 +16,8 @@ import {
createSubmitRequirementExpressionInfo,
createSubmitRequirementResultInfo,
createNonApplicableSubmitRequirementResultInfo,
- createRunResult,
createCheckResult,
+ createRun,
} from '../../../test/test-data-generators';
import {
SubmitRequirementResultInfo,
@@ -163,7 +163,7 @@ suite('gr-submit-requirements tests', () => {
test('checks', async () => {
element.runs = [
{
- ...createRunResult(),
+ ...createRun(),
labelName: 'Verified',
results: [createCheckResult()],
},
@@ -184,7 +184,7 @@ suite('gr-submit-requirements tests', () => {
test('running checks', async () => {
element.runs = [
{
- ...createRunResult(),
+ ...createRun(),
status: RunStatus.RUNNING,
labelName: 'Verified',
results: [createCheckResult()],
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
index 27b50975d2..75845f66bf 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -10,16 +10,16 @@ import {SpecialFilePath} from '../../../constants/constants';
import {
AccountDetailInfo,
AccountInfo,
+ CommentThread,
NumericChangeId,
UrlEncodedCommentId,
+ isDraft,
} from '../../../types/common';
import {ChangeMessageId} from '../../../api/rest-api';
import {
- CommentThread,
getCommentAuthors,
getMentionedThreads,
hasHumanReply,
- isDraft,
isDraftThread,
isMentionedThread,
isRobotThread,
@@ -28,7 +28,11 @@ import {
} from '../../../utils/comment-util';
import {pluralize} from '../../../utils/string-util';
import {assertIsDefined} from '../../../utils/common-util';
-import {CommentTabState, TabState} from '../../../types/events';
+import {
+ CommentTabState,
+ TabState,
+ ValueChangedEvent,
+} from '../../../types/events';
import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
import {css, html, LitElement, PropertyValues} from 'lit';
@@ -38,12 +42,10 @@ import {subscribe} from '../../lit/subscription-controller';
import {ParsedChangeInfo} from '../../../types/types';
import {repeat} from 'lit/directives/repeat.js';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
-import {getAppContext} from '../../../services/app-context';
import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
-import {Interaction} from '../../../constants/reporting';
-import {KnownExperimentId} from '../../../services/flags/flags';
-import {HtmlPatched} from '../../../utils/lit-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {specialFilePathCompare} from '../../../utils/path-list-util';
enum SortDropdownState {
TIMESTAMP = 'Latest timestamp',
@@ -91,7 +93,7 @@ export function compareThreads(
if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
return 1;
}
- return c1.path.localeCompare(c2.path);
+ return specialFilePathCompare(c1.path, c2.path);
}
// Convert 'FILE' and 'LOST' to undefined.
@@ -201,18 +203,7 @@ export class GrThreadList extends LitElement {
private readonly getChangeModel = resolve(this, changeModelToken);
- private readonly reporting = getAppContext().reportingService;
-
- private readonly flagsService = getAppContext().flagsService;
-
- private readonly userModel = getAppContext().userModel;
-
- private readonly patched = new HtmlPatched(key => {
- this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
- component: this.tagName,
- key: key.substring(0, 300),
- });
- });
+ private readonly getUserModel = resolve(this, userModelToken);
constructor() {
super();
@@ -228,13 +219,9 @@ export class GrThreadList extends LitElement {
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
x => (this.account = x)
);
- // for COMMENTS_AUTOCLOSE logging purposes only
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED
- );
}
override willUpdate(changed: PropertyValues) {
@@ -338,17 +325,6 @@ export class GrThreadList extends LitElement {
];
}
- override updated(): void {
- // for COMMENTS_AUTOCLOSE logging purposes only
- const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread');
- if (threads.length > 0) {
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED,
- {uid: threads[0].uid}
- );
- }
- }
-
override render() {
return html`
${this.renderDropdown()}
@@ -366,7 +342,7 @@ export class GrThreadList extends LitElement {
<gr-dropdown-list
id="sortDropdown"
.value=${this.sortDropdownValue}
- @value-change=${(e: CustomEvent) =>
+ @value-change=${(e: ValueChangedEvent<SortDropdownState>) =>
(this.sortDropdownValue = e.detail.value)}
.items=${this.getSortDropdownEntries()}
>
@@ -422,16 +398,16 @@ export class GrThreadList extends LitElement {
index === 0 || threads[index - 1].path !== threads[index].path;
const separator =
index !== 0 && isFirst
- ? this.patched.html`<div class="thread-separator"></div>`
+ ? html`<div class="thread-separator"></div>`
: undefined;
const commentThread = this.renderCommentThread(thread, isFirst);
- return this.patched.html`${separator}${commentThread}`;
+ return html`${separator}${commentThread}`;
}
);
}
private renderCommentThread(thread: CommentThread, isFirst: boolean) {
- return this.patched.html`
+ return html`
<gr-comment-thread
.thread=${thread}
show-file-path
@@ -493,14 +469,10 @@ export class GrThreadList extends LitElement {
value: CommentTabState.UNRESOLVED,
});
if (this.account) {
- if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- items.push({
- text: `Mentions (${
- getMentionedThreads(threads, this.account).length
- })`,
- value: CommentTabState.MENTIONS,
- });
- }
+ items.push({
+ text: `Mentions (${getMentionedThreads(threads, this.account).length})`,
+ value: CommentTabState.MENTIONS,
+ });
items.push({
text: `Drafts (${threads.filter(isDraftThread).length})`,
value: CommentTabState.DRAFTS,
@@ -526,7 +498,7 @@ export class GrThreadList extends LitElement {
}
// private, but visible for testing
- handleCommentsDropdownValueChange(e: CustomEvent) {
+ handleCommentsDropdownValueChange(e: ValueChangedEvent<CommentTabState>) {
const value = e.detail.value;
switch (value) {
case CommentTabState.UNRESOLVED:
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
index 16fa813860..a4357bbf38 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.ts
@@ -32,12 +32,15 @@ import {
RobotId,
UrlEncodedCommentId,
RevisionPatchSetNum,
+ CommentThread,
+ isDraft,
+ SavingState,
} from '../../../types/common';
-import {CommentThread, isDraft} from '../../../utils/comment-util';
import {query, queryAndAssert} from '../../../utils/common-util';
import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label';
import {GrDropdownList} from '../../shared/gr-dropdown-list/gr-dropdown-list';
import {fixture, html, assert} from '@open-wc/testing';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
suite('gr-thread-list tests', () => {
let element: GrThreadList;
@@ -73,7 +76,7 @@ suite('gr-thread-list tests', () => {
updated: '2015-12-01 15:16:15.000000000' as Timestamp,
message: 'draft',
unresolved: true,
- __draft: true,
+ savingState: SavingState.OK,
patch_set: '2' as RevisionPatchSetNum,
},
],
@@ -160,7 +163,7 @@ suite('gr-thread-list tests', () => {
updated: '2015-12-05 15:16:15.000000000' as Timestamp,
message: 'resolved draft',
unresolved: false,
- __draft: true,
+ savingState: SavingState.OK,
patch_set: '2' as RevisionPatchSetNum,
},
],
@@ -290,6 +293,40 @@ suite('gr-thread-list tests', () => {
assert.sameOrderedMembers(actual, expected);
});
+ test('respects special cases for ordering', async () => {
+ element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+ element.threads = [
+ {
+ ...createThread(createComment({path: '/app/test.cc'})),
+ path: '/app/test.cc',
+ },
+ {
+ ...createThread(createComment({path: '/app/test.h'})),
+ path: '/app/test.h',
+ },
+ {
+ ...createThread(
+ createComment({path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS})
+ ),
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ },
+ ];
+ await element.updateComplete;
+
+ const paths = Array.from(
+ queryAll<GrCommentThread>(element, 'gr-comment-thread')
+ ).map(threadElement => threadElement.thread?.path);
+
+ // Patchset comment is always first, then we have a special case where .h
+ // files should appear above other files of the same name regardless of
+ // their alphabetical ordering.
+ assert.sameOrderedMembers(paths, [
+ SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+ '/app/test.h',
+ '/app/test.cc',
+ ]);
+ });
+
test('sort all threads by timestamp', () => {
element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
assert.equal(element.getDisplayedThreads().length, 9);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 848948f05d..65165c15f9 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -28,7 +28,7 @@ import {
Tag,
} from '../../api/checks';
import {sharedStyles} from '../../styles/shared-styles';
-import {CheckRun, RunResult} from '../../models/checks/checks-model';
+import {CheckRun, RunResult, runResult} from '../../models/checks/checks-model';
import {
ALL_ATTEMPTS,
AttemptChoice,
@@ -54,14 +54,17 @@ import {durationString} from '../../utils/date-util';
import {charsOnly} from '../../utils/string-util';
import {isAttemptSelected, matches} from './gr-checks-util';
import {ChecksTabState, ValueChangedEvent} from '../../types/events';
-import {LabelNameToInfoMap, PatchSetNumber} from '../../types/common';
+import {
+ DropdownLink,
+ LabelNameToInfoMap,
+ PatchSetNumber,
+} from '../../types/common';
import {spinnerStyles} from '../../styles/gr-spinner-styles';
import {
getLabelStatus,
getRepresentativeValue,
valueString,
} from '../../utils/label-util';
-import {DropdownLink} from '../shared/gr-dropdown/gr-dropdown';
import {subscribe} from '../lit/subscription-controller';
import {fontStyles} from '../../styles/gr-font-styles';
import {fire} from '../../utils/event-util';
@@ -72,12 +75,9 @@ import {Deduping} from '../../api/reporting';
import {changeModelToken} from '../../models/change/change-model';
import {getAppContext} from '../../services/app-context';
import {when} from 'lit/directives/when.js';
-import {KnownExperimentId} from '../../services/flags/flags';
-import {HtmlPatched} from '../../utils/lit-util';
import {DropdownItem} from '../shared/gr-dropdown-list/gr-dropdown-list';
import './gr-checks-attempt';
-import {createDiffUrl} from '../../models/views/diff';
-import {changeViewModelToken} from '../../models/views/change';
+import {createDiffUrl, changeViewModelToken} from '../../models/views/change';
/**
* Firing this event sets the regular expression of the results filter.
@@ -125,8 +125,6 @@ export class GrResultRow extends LitElement {
private readonly reporting = getAppContext().reportingService;
- private readonly flags = getAppContext().flagsService;
-
constructor() {
super();
subscribe(
@@ -326,10 +324,18 @@ export class GrResultRow extends LitElement {
override updated(changedProperties: PropertyValues) {
if (changedProperties.has('result')) {
- this.isExpandable = !!this.result?.summary && !!this.result?.message;
+ this.isExpandable = this.computeIsExpandable();
}
}
+ private computeIsExpandable() {
+ const hasSummary = !!this.result?.summary;
+ const hasMessage = !!this.result?.message;
+ const hasMultipleLinks = (this.result?.links ?? []).length > 1;
+ const hasPointers = (this.result?.codePointers ?? []).length > 0;
+ return hasSummary && (hasMessage || hasMultipleLinks || hasPointers);
+ }
+
override focus() {
if (this.nameEl) this.nameEl.focus();
}
@@ -536,10 +542,8 @@ export class GrResultRow extends LitElement {
private renderActions() {
const actions = [...(this.result?.actions ?? [])];
- if (this.flags.isEnabled(KnownExperimentId.CHECKS_FIXES)) {
- const fixAction = createFixAction(this, this.result);
- if (fixAction) actions.unshift(fixAction);
- }
+ const fixAction = createFixAction(this, this.result);
+ if (fixAction) actions.unshift(fixAction);
if (actions.length === 0) return;
const overflowItems = actions.slice(2).map(action => {
return {...action, id: action.name};
@@ -711,10 +715,9 @@ class GrResultExpanded extends LitElement {
tooltip: `${path}${rangeText}`,
url: createDiffUrl({
changeNum: change._number,
- project: change.project,
- path,
+ repo: change.project,
patchNum: patchset,
- lineNum: line,
+ diffView: {path, lineNum: line},
}),
primary: true,
};
@@ -817,13 +820,6 @@ export class GrChecksResults extends LitElement {
private readonly reporting = getAppContext().reportingService;
- private readonly patched = new HtmlPatched(key => {
- this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
- component: this.tagName,
- key: key.substring(0, 300),
- });
- });
-
constructor() {
super();
subscribe(
@@ -1501,7 +1497,7 @@ export class GrChecksResults extends LitElement {
${repeat(
filtered,
result => result.internalResultId,
- (result?: RunResult) => this.patched.html`
+ (result?: RunResult) => html`
<gr-result-row
class=${charsOnly(result!.checkName)}
.result=${result}
@@ -1533,26 +1529,23 @@ export class GrChecksResults extends LitElement {
this.requestUpdate();
}
- computeRunResults(category: Category, run: CheckRun) {
+ computeRunResults(category: Category, run: CheckRun): RunResult[] {
if (category === Category.SUCCESS && hasCompletedWithoutResults(run)) {
return [this.computeSuccessfulRunResult(run)];
}
return (
run.results
?.filter(result => result.category === category)
- .map(result => {
- return {...run, ...result};
- }) ?? []
+ .map(result => runResult(run, result)) ?? []
);
}
computeSuccessfulRunResult(run: CheckRun): RunResult {
- const adaptedRun: RunResult = {
+ const adaptedRun: RunResult = runResult(run, {
internalResultId: run.internalRunId + '-0',
category: Category.SUCCESS,
summary: run.statusDescription ?? '',
- ...run,
- };
+ });
if (!run.statusDescription) {
const start = run.scheduledTimestamp ?? run.startedTimestamp;
const end = run.finishedTimestamp;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index 934e9585dd..385bde7db5 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -117,7 +117,7 @@ suite('gr-result-row test', () => {
aria-checked="false"
aria-label="Expand result row"
class="show-hide"
- hidden=""
+ hidden
role="switch"
tabindex="0"
>
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index 128a9b0a0e..8ba989524b 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -634,7 +634,7 @@ export class GrChecksRuns extends LitElement {
return Object.entries(this.errorMessages).map(([plugin, message]) => {
const msg = this.collapsed
? 'Error'
- : `Error while fetching results for ${plugin}:<br />${message}`;
+ : html`Error while fetching results for ${plugin}:<br />${message}`;
return html`
<div class="error">
<div class="left">
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
index 4bd3446039..a858e4ddff 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -22,6 +22,7 @@ suite('gr-checks-runs test', () => {
);
const getChecksModel = resolve(element, checksModelToken);
setAllFakeRuns(getChecksModel());
+ element.errorMessages = {'test-plugin-name': 'test-error-message'};
await element.updateComplete;
});
@@ -57,6 +58,17 @@ suite('gr-checks-runs test', () => {
</gr-button>
</gr-tooltip-content>
</h2>
+ <div class="error">
+ <div class="left">
+ <gr-icon filled="" icon="error"> </gr-icon>
+ </div>
+ <div class="right">
+ <div class="message">
+ Error while fetching results for test-plugin-name: <br />
+ test-error-message
+ </div>
+ </div>
+ </div>
<input
id="filterInput"
placeholder="Filter runs by regular expression"
@@ -121,6 +133,14 @@ suite('gr-checks-runs test', () => {
</gr-button>
</gr-tooltip-content>
</h2>
+ <div class="error">
+ <div class="left">
+ <gr-icon filled="" icon="error"> </gr-icon>
+ </div>
+ <div class="right">
+ <div class="message">Error</div>
+ </div>
+ </div>
<input
hidden
id="filterInput"
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index c7477c4eeb..f1a3fb99e3 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -9,6 +9,7 @@ import {
AttemptChoice,
LATEST_ATTEMPT,
} from '../../models/checks/checks-util';
+import {fire} from '../../utils/event-util';
export interface RunSelectedEventDetail {
checkName?: string;
@@ -23,13 +24,7 @@ declare global {
}
export function fireRunSelected(target: EventTarget, checkName: string) {
- target.dispatchEvent(
- new CustomEvent('run-selected', {
- detail: {reset: false, checkName},
- composed: true,
- bubbles: true,
- })
- );
+ fire(target, 'run-selected', {checkName});
}
export function isAttemptSelected(
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 0aca71f8a8..efc6efe6df 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -8,13 +8,21 @@ import '../shared/gr-icon/gr-icon';
import {LitElement, css, html, PropertyValues, nothing} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {RunResult} from '../../models/checks/checks-model';
-import {createFixAction, iconFor} from '../../models/checks/checks-util';
+import {
+ createFixAction,
+ createPleaseFixComment,
+ iconFor,
+} from '../../models/checks/checks-util';
import {modifierPressed} from '../../utils/dom-util';
import './gr-checks-results';
import './gr-hovercard-run';
import {fontStyles} from '../../styles/gr-font-styles';
-import {KnownExperimentId} from '../../services/flags/flags';
-import {getAppContext} from '../../services/app-context';
+import {Action} from '../../api/checks';
+import {assertIsDefined} from '../../utils/common-util';
+import {resolve} from '../../models/dependency';
+import {commentsModelToken} from '../../models/comments/comments-model';
+import {subscribe} from '../lit/subscription-controller';
+import {changeModelToken} from '../../models/change/change-model';
@customElement('gr-diff-check-result')
export class GrDiffCheckResult extends LitElement {
@@ -34,7 +42,12 @@ export class GrDiffCheckResult extends LitElement {
@state()
isExpandable = false;
- private readonly flags = getAppContext().flagsService;
+ @state()
+ isOwner = false;
+
+ private readonly getChangeModel = resolve(this, changeModelToken);
+
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
static override get styles() {
return [
@@ -118,6 +131,15 @@ export class GrDiffCheckResult extends LitElement {
];
}
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getChangeModel().isOwner$,
+ x => (this.isOwner = x)
+ );
+ }
+
override render() {
if (!this.result) return;
const cat = this.result.category.toLowerCase();
@@ -186,15 +208,39 @@ export class GrDiffCheckResult extends LitElement {
private renderActions() {
if (!this.isExpanded) return nothing;
- return html`<div class="actions">${this.renderFixButton()}</div>`;
+ return html`<div class="actions">
+ ${this.renderPleaseFixButton()}${this.renderShowFixButton()}
+ </div>`;
+ }
+
+ private renderPleaseFixButton() {
+ if (this.isOwner) return nothing;
+ const action: Action = {
+ name: 'Please Fix',
+ callback: () => {
+ assertIsDefined(this.result, 'result');
+ this.getCommentsModel().saveDraft(createPleaseFixComment(this.result));
+ return undefined;
+ },
+ };
+ return html`
+ <gr-checks-action
+ id="please-fix"
+ context="diff-fix"
+ .action=${action}
+ ></gr-checks-action>
+ `;
}
- private renderFixButton() {
- if (!this.flags.isEnabled(KnownExperimentId.CHECKS_FIXES)) return nothing;
+ private renderShowFixButton() {
const action = createFixAction(this, this.result);
if (!action) return nothing;
return html`
- <gr-checks-action context="diff-fix" .action=${action}></gr-checks-action>
+ <gr-checks-action
+ id="show-fix"
+ context="diff-fix"
+ .action=${action}
+ ></gr-checks-action>
`;
}
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
index 3892c9a68f..0377e0e514 100644
--- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_test.ts
@@ -7,6 +7,7 @@ import {assert} from '@open-wc/testing';
import {fakeRun1} from '../../models/checks/checks-fakes';
import {RunResult} from '../../models/checks/checks-model';
import '../../test/common-test-setup';
+import {queryAndAssert} from '../../utils/common-util';
import './gr-diff-check-result';
import {GrDiffCheckResult} from './gr-diff-check-result';
@@ -50,4 +51,30 @@ suite('gr-diff-check-result tests', () => {
`
);
});
+
+ test('renders expanded', async () => {
+ element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult;
+ element.isExpanded = true;
+ await element.updateComplete;
+
+ const details = queryAndAssert(element, 'div.details');
+ assert.dom.equal(
+ details,
+ /* HTML */ `
+ <div class="details">
+ <gr-result-expanded hidecodepointers=""></gr-result-expanded>
+ <div class="actions">
+ <gr-checks-action
+ id="please-fix"
+ context="diff-fix"
+ ></gr-checks-action>
+ <gr-checks-action
+ id="show-fix"
+ context="diff-fix"
+ ></gr-checks-action>
+ </div>
+ </div>
+ `
+ );
+ });
});
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index 9aa837dda6..e78d131a39 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -38,7 +38,6 @@ export class GrHovercardRun extends base {
css`
#container {
min-width: 356px;
- max-width: 356px;
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
}
.row {
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
index b46d2b95b8..2916f75d36 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -6,13 +6,10 @@
import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-avatar/gr-avatar';
import {getUserName} from '../../../utils/display-name-util';
-import {AccountInfo, ServerInfo} from '../../../types/common';
+import {AccountInfo, DropdownLink, ServerInfo} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
-import {
- DropdownContent,
- DropdownLink,
-} from '../../shared/gr-dropdown/gr-dropdown';
+import {fire} from '../../../utils/event-util';
+import {DropdownContent} from '../../shared/gr-dropdown/gr-dropdown';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
@@ -23,6 +20,9 @@ declare global {
interface HTMLElementTagNameMap {
'gr-account-dropdown': GrAccountDropdown;
}
+ interface HTMLElementEventMap {
+ 'show-keyboard-shortcuts': CustomEvent<{}>;
+ }
}
@customElement('gr-account-dropdown')
@@ -136,7 +136,7 @@ export class GrAccountDropdown extends LitElement {
}
_handleShortcutsTap() {
- fireEvent(this, 'show-keyboard-shortcuts');
+ fire(this, 'show-keyboard-shortcuts', {});
}
private readonly handleLocationChange = () => {
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
index ab6523104e..ca0480c982 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -7,11 +7,16 @@ import '../../shared/gr-dialog/gr-dialog';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html, css} from 'lit';
import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
'gr-error-dialog': GrErrorDialog;
}
+ interface HTMLElementEventMap {
+ // prettier-ignore
+ 'dismiss': CustomEvent<{}>;
+ }
}
@customElement('gr-error-dialog')
@@ -86,6 +91,6 @@ export class GrErrorDialog extends LitElement {
}
private handleConfirm() {
- this.dispatchEvent(new CustomEvent('dismiss'));
+ fireNoBubbleNoCompose(this, 'dismiss', {});
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
index 539acfac43..c8a1c39f4c 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -3,21 +3,16 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-/* Import to get Gerrit interface */
-/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
import '../gr-error-dialog/gr-error-dialog';
import '../../shared/gr-alert/gr-alert';
-import '../../shared/gr-overlay/gr-overlay';
import {getBaseUrl} from '../../../utils/url-util';
import {getAppContext} from '../../../services/app-context';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
import {GrAlert} from '../../shared/gr-alert/gr-alert';
-import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
+import {ErrorType} from '../../../types/types';
import {AccountId} from '../../../types/common';
import {
- EventType,
+ AuthErrorEvent,
NetworkErrorEvent,
ServerErrorEvent,
ShowAlertEventDetail,
@@ -28,6 +23,10 @@ import {debounce, DelayedTask} from '../../../utils/async-util';
import {fireIronAnnounce} from '../../../utils/event-util';
import {LitElement, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
+import {authServiceToken} from '../../../services/gr-auth/gr-auth';
+import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {ironAnnouncerRequestAvailability} from '../../polymer-util';
const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
@@ -100,11 +99,11 @@ export class GrErrorManager extends LitElement {
@state() refreshingCredentials = false;
- @query('#noInteractionOverlay') noInteractionOverlay!: GrOverlay;
+ @query('#signInModal') signInModal!: HTMLDialogElement;
@query('#errorDialog') errorDialog!: GrErrorDialog;
- @query('#errorOverlay') errorOverlay!: GrOverlay;
+ @query('#errorModal') errorModal!: HTMLDialogElement;
/**
* The time (in milliseconds) since the most recent credential check.
@@ -119,11 +118,7 @@ export class GrErrorManager extends LitElement {
private readonly reporting = getAppContext().reportingService;
- private readonly _authService = getAppContext().authService;
-
- private readonly eventEmitter = getAppContext().eventEmitter;
-
- private authErrorHandlerDeregistrationHook?: Function;
+ private readonly getAuthService = resolve(this, authServiceToken);
private readonly restApiService = getAppContext().restApiService;
@@ -131,37 +126,23 @@ export class GrErrorManager extends LitElement {
override connectedCallback() {
super.connectedCallback();
- document.addEventListener(EventType.SERVER_ERROR, this.handleServerError);
- document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError);
- document.addEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+ document.addEventListener('server-error', this.handleServerError);
+ document.addEventListener('network-error', this.handleNetworkError);
+ document.addEventListener('show-alert', this.handleShowAlert);
document.addEventListener('hide-alert', this.hideAlert);
document.addEventListener('show-error', this.handleShowErrorDialog);
document.addEventListener('visibilitychange', this.handleVisibilityChange);
document.addEventListener('show-auth-required', this.handleAuthRequired);
+ document.addEventListener('auth-error', this.handleAuthError);
- this.authErrorHandlerDeregistrationHook = this.eventEmitter.on(
- 'auth-error',
- event => {
- this.handleAuthError(event.message, event.action);
- }
- );
-
- (
- IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
- ).requestAvailability();
+ ironAnnouncerRequestAvailability();
}
override disconnectedCallback() {
this.clearHideAlertHandle();
- document.removeEventListener(
- EventType.SERVER_ERROR,
- this.handleServerError
- );
- document.removeEventListener(
- EventType.NETWORK_ERROR,
- this.handleNetworkError
- );
- document.removeEventListener(EventType.SHOW_ALERT, this.handleShowAlert);
+ document.removeEventListener('server-error', this.handleServerError);
+ document.removeEventListener('network-error', this.handleNetworkError);
+ document.removeEventListener('show-alert', this.handleShowAlert);
document.removeEventListener('hide-alert', this.hideAlert);
document.removeEventListener('show-error', this.handleShowErrorDialog);
document.removeEventListener(
@@ -171,30 +152,45 @@ export class GrErrorManager extends LitElement {
document.removeEventListener('show-auth-required', this.handleAuthRequired);
this.checkLoggedInTask?.cancel();
- if (this.authErrorHandlerDeregistrationHook) {
- this.authErrorHandlerDeregistrationHook();
- }
+ document.removeEventListener('auth-error', this.handleAuthError);
super.disconnectedCallback();
}
+ static override get styles() {
+ return [modalStyles];
+ }
+
override render() {
return html`
- <gr-overlay with-backdrop="" id="errorOverlay">
+ <dialog id="errorModal" tabindex="-1">
<gr-error-dialog
id="errorDialog"
- @dismiss=${() => this.errorOverlay.close()}
+ @dismiss=${() => this.errorModal.close()}
.loginUrl=${this.loginUrl}
.loginText=${this.loginText}
></gr-error-dialog>
- </gr-overlay>
- <gr-overlay
- id="noInteractionOverlay"
- with-backdrop=""
- always-on-top=""
- no-cancel-on-esc-key=""
- no-cancel-on-outside-click=""
+ </dialog>
+ <dialog
+ id="signInModal"
+ @keydown=${(e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }}
+ tabindex="-1"
>
- </gr-overlay>
+ <gr-dialog
+ id="signInDialog"
+ confirm-label="Sign In"
+ @confirm=${() => {
+ this.createLoginPopup();
+ }}
+ cancel-label=""
+ >
+ <div class="header" slot="header">Refresh Credentials</div>
+ </gr-dialog>
+ </dialog>
`;
}
@@ -209,11 +205,10 @@ export class GrErrorManager extends LitElement {
);
};
- private handleAuthError(msg: string, action: string) {
- this.noInteractionOverlay.open().then(() => {
- this.showAuthErrorAlert(msg, action);
- });
- }
+ private handleAuthError = (event: AuthErrorEvent) => {
+ this.signInModal.showModal();
+ this.showAuthErrorAlert(event.detail.message, event.detail.action);
+ };
private readonly handleServerError = (e: ServerErrorEvent) => {
const {request, response} = e.detail;
@@ -222,7 +217,7 @@ export class GrErrorManager extends LitElement {
const {status, statusText} = response;
if (
response.status === 403 &&
- !this._authService.isAuthed &&
+ !this.getAuthService().isAuthed &&
errorText === AUTHENTICATION_REQUIRED
) {
// if not authed previously, this is trying to access auth required APIs
@@ -230,13 +225,13 @@ export class GrErrorManager extends LitElement {
this.handleAuthRequired();
} else if (
response.status === 403 &&
- this._authService.isAuthed &&
+ this.getAuthService().isAuthed &&
errorText === AUTHENTICATION_REQUIRED
) {
// The app was logged at one point and is now getting auth errors.
// This indicates the auth token may no longer valid.
// Re-check on auth
- this._authService.clearCache();
+ this.getAuthService().clearCache();
this.restApiService.getLoggedIn();
} else if (!this.shouldSuppressError(errorText)) {
const trace =
@@ -358,7 +353,7 @@ export class GrErrorManager extends LitElement {
el.show(text, actionText, actionCallback);
this.alertElement = el;
fireIronAnnounce(this, `Alert: ${text}`);
- this.reporting.reportInteraction(EventType.SHOW_ALERT, {text});
+ this.reporting.reportInteraction('show-alert', {text});
}
private readonly hideAlert = () => {
@@ -449,7 +444,7 @@ export class GrErrorManager extends LitElement {
// force to refetch account info
this.restApiService.invalidateAccountsCache();
- this._authService.clearCache();
+ this.getAuthService().clearCache();
this.restApiService.getLoggedIn().then(isLoggedIn => {
if (!this.refreshingCredentials) return;
@@ -508,10 +503,10 @@ export class GrErrorManager extends LitElement {
this.refreshingCredentials = false;
this.hideAlert();
this._showAlert('Credentials refreshed.');
- this.noInteractionOverlay.close();
+ this.signInModal.close();
// Clear the cache for auth
- this._authService.clearCache();
+ this.getAuthService().clearCache();
}
private readonly handleWindowFocus = () => {
@@ -527,7 +522,10 @@ export class GrErrorManager extends LitElement {
this.reporting.reportErrorDialog(message);
this.errorDialog.text = message;
this.errorDialog.showSignInButton = !!options && !!options.showSignInButton;
- this.errorOverlay.open();
+ if (this.errorModal.hasAttribute('open')) {
+ this.errorModal.close();
+ }
+ this.errorModal.showModal();
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
index e0de50752e..fff69efdbf 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -11,7 +11,6 @@ import {
__testOnly_ErrorType,
} from './gr-error-manager';
import {
- stubAuth,
stubReporting,
stubRestApi,
waitEventLoop,
@@ -25,7 +24,8 @@ import {AccountId} from '../../../types/common';
import {waitUntil} from '../../../test/test-utils';
import {fixture, assert} from '@open-wc/testing';
import {html} from 'lit';
-import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {authServiceToken} from '../../../services/gr-auth/gr-auth';
suite('gr-error-manager tests', () => {
let element: GrErrorManager;
@@ -37,9 +37,9 @@ suite('gr-error-manager tests', () => {
let appContext: AppContext;
setup(async () => {
- fetchStub = stubAuth('fetch').returns(
- Promise.resolve({...new Response(), ok: true, status: 204})
- );
+ fetchStub = sinon
+ .stub(testResolver(authServiceToken), 'fetch')
+ .returns(Promise.resolve({...new Response(), ok: true, status: 204}));
appContext = getAppContext();
getLoggedInStub = stubRestApi('getLoggedIn').callsFake(() =>
appContext.authService.authCheck()
@@ -65,26 +65,19 @@ suite('gr-error-manager tests', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-overlay
- aria-hidden="true"
- id="errorOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="errorModal" tabindex="-1">
<gr-error-dialog id="errorDialog"> </gr-error-dialog>
- </gr-overlay>
- <gr-overlay
- always-on-top=""
- aria-hidden="true"
- id="noInteractionOverlay"
- no-cancel-on-esc-key=""
- no-cancel-on-outside-click=""
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
- </gr-overlay>
+ </dialog>
+ <dialog id="signInModal" tabindex="-1">
+ <gr-dialog
+ id="signInDialog"
+ confirm-label="Sign In"
+ role="dialog"
+ cancel-label=""
+ >
+ <div class="header" slot="header">Refresh Credentials</div>
+ </gr-dialog>
+ </dialog>
`
);
});
@@ -354,17 +347,11 @@ suite('gr-error-manager tests', () => {
assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
- // noInteractionOverlay
- const noInteractionOverlay = element.noInteractionOverlay;
- assert.isOk(noInteractionOverlay);
- const noInteractionOverlayCloseSpy = sinon.spy(
- noInteractionOverlay,
- 'close'
- );
- assert.equal(
- noInteractionOverlay.backdropElement.getAttribute('opened'),
- ''
- );
+ // signInModal
+ const signInModal = element.signInModal;
+ assert.isOk(signInModal);
+ const signInModalCloseSpy = sinon.spy(signInModal, 'close');
+ assert.isTrue(signInModal.hasAttribute('open'));
assert.isFalse(windowOpen.called);
toast.shadowRoot.querySelector('gr-button.action')!.click();
assert.isTrue(windowOpen.called);
@@ -392,7 +379,7 @@ suite('gr-error-manager tests', () => {
assert.include(toast.shadowRoot.textContent, 'Credentials refreshed');
// close overlay
- assert.isTrue(noInteractionOverlayCloseSpy.called);
+ assert.isTrue(signInModalCloseSpy.called);
});
test('auth toast should dismiss existing toast', async () => {
@@ -403,7 +390,7 @@ suite('gr-error-manager tests', () => {
// fake an alert
element.dispatchEvent(
- new CustomEvent(EventType.SHOW_ALERT, {
+ new CustomEvent('show-alert', {
detail: {message: 'test reload', action: 'reload'},
composed: true,
bubbles: true,
@@ -451,7 +438,7 @@ suite('gr-error-manager tests', () => {
// fake an alert
element.dispatchEvent(
- new CustomEvent(EventType.SHOW_ALERT, {
+ new CustomEvent('show-alert', {
detail: {message: 'test reload', action: 'reload'},
composed: true,
bubbles: true,
@@ -464,7 +451,7 @@ suite('gr-error-manager tests', () => {
// new alert
element.dispatchEvent(
- new CustomEvent(EventType.SHOW_ALERT, {
+ new CustomEvent('show-alert', {
detail: {message: 'second-test', action: 'reload'},
composed: true,
bubbles: true,
@@ -510,7 +497,7 @@ suite('gr-error-manager tests', () => {
// fake an alert
element.dispatchEvent(
- new CustomEvent(EventType.SHOW_ALERT, {
+ new CustomEvent('show-alert', {
detail: {
message: 'test-alert',
action: 'reload',
@@ -531,7 +518,7 @@ suite('gr-error-manager tests', () => {
const alertObj = {message: 'foo'};
const showAlertStub = sinon.stub(element, '_showAlert');
element.dispatchEvent(
- new CustomEvent(EventType.SHOW_ALERT, {
+ new CustomEvent('show-alert', {
detail: alertObj,
composed: true,
bubbles: true,
@@ -593,8 +580,8 @@ suite('gr-error-manager tests', () => {
});
test('show-error', async () => {
- const openStub = sinon.stub(element.errorOverlay, 'open');
- const closeStub = sinon.stub(element.errorOverlay, 'close');
+ const openStub = sinon.stub(element.errorModal, 'showModal');
+ const closeStub = sinon.stub(element.errorModal, 'close');
const reportStub = stubReporting('reportErrorDialog');
const message = 'test message';
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 25173b4370..a3590728eb 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -16,6 +16,7 @@ import {
ShortcutViewListener,
} from '../../../services/shortcuts/shortcuts-service';
import {resolve} from '../../../models/dependency';
+import {fireNoBubble} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -61,7 +62,6 @@ export class GrKeyboardShortcutsDialog extends LitElement {
display: block;
max-height: 100vh;
min-width: 60vw;
- overflow-y: auto;
}
main {
display: flex;
@@ -163,12 +163,7 @@ export class GrKeyboardShortcutsDialog extends LitElement {
private handleCloseTap(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('close', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'close', {});
}
onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
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 46e17d2283..2c6de04729 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
@@ -10,25 +10,26 @@ import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-icon/gr-icon';
import '../gr-account-dropdown/gr-account-dropdown';
import '../gr-smart-search/gr-smart-search';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import {getAdminLinks, NavLink} from '../../../models/views/admin';
import {
AccountDetailInfo,
+ DropdownLink,
RequireProperties,
ServerInfo,
TopMenuEntryInfo,
TopMenuItemInfo,
} from '../../../types/common';
import {AuthType} from '../../../constants/constants';
-import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
import {getAppContext} from '../../../services/app-context';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
@@ -96,13 +97,13 @@ declare global {
interface HTMLElementTagNameMap {
'gr-main-header': GrMainHeader;
}
+ interface HTMLElementEventMap {
+ 'mobile-search': CustomEvent<{}>;
+ }
}
@customElement('gr-main-header')
export class GrMainHeader extends LitElement {
- @property({type: String})
- searchQuery = '';
-
@property({type: Boolean, reflect: true})
loggedIn?: boolean;
@@ -139,15 +140,13 @@ export class GrMainHeader extends LitElement {
// private but used in test
@state() feedbackURL = '';
- @state() private serverConfig?: ServerInfo;
-
private readonly restApiService = getAppContext().restApiService;
- private readonly jsAPI = getAppContext().jsApiService;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
- private readonly configModel = resolve(this, configModelToken);
+ private readonly getConfigModel = resolve(this, configModelToken);
private subscriptions: Subscription[] = [];
@@ -156,8 +155,8 @@ export class GrMainHeader extends LitElement {
this.loadAccount();
this.subscriptions.push(
- this.userModel.preferences$
- .pipe(
+ this.getUserModel()
+ .preferences$.pipe(
map(preferences => preferences?.my ?? []),
distinctUntilChanged()
)
@@ -166,12 +165,11 @@ export class GrMainHeader extends LitElement {
})
);
this.subscriptions.push(
- this.configModel().serverConfig$.subscribe(config => {
+ this.getConfigModel().serverConfig$.subscribe(config => {
if (!config) return;
- this.serverConfig = config;
this.retrieveFeedbackURL(config);
this.retrieveRegisterURL(config);
- getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
+ this.restApiService.getDocsBaseUrl(config).then(docBaseUrl => {
this.docBaseUrl = docBaseUrl;
});
})
@@ -206,18 +204,22 @@ export class GrMainHeader extends LitElement {
text-decoration: underline;
}
.titleText::before {
+ --icon-width: var(--header-icon-width, var(--header-icon-size, 0));
+ --icon-height: var(--header-icon-height, var(--header-icon-size, 0));
background-image: var(--header-icon);
- background-size: var(--header-icon-size) var(--header-icon-size);
+ background-size: var(--icon-width) var(--icon-height);
background-repeat: no-repeat;
content: '';
display: inline-block;
- height: var(--header-icon-size);
- margin-right: calc(var(--header-icon-size) / 4);
+ height: var(--icon-height);
+ /* If size or height are set, then use 'spacing-m', 0px otherwise. */
+ margin-right: clamp(0px, var(--icon-height), var(--spacing-m));
vertical-align: text-bottom;
- width: var(--header-icon-size);
+ width: var(--icon-width);
}
.titleText::after {
content: var(--header-title-content);
+ white-space: nowrap;
}
ul {
list-style: none;
@@ -370,15 +372,10 @@ export class GrMainHeader extends LitElement {
class="hideOnMobile"
name="header-small-banner"
></gr-endpoint-decorator>
- <gr-smart-search
- id="search"
- label="Search for changes"
- .searchQuery=${this.searchQuery}
- .serverConfig=${this.serverConfig}
- ></gr-smart-search>
+ <gr-smart-search id="search"></gr-smart-search>
<gr-endpoint-decorator
class="hideOnMobile"
- name="header-browse-source"
+ name="header-top-right"
></gr-endpoint-decorator>
<gr-endpoint-decorator class="feedbackButton" name="header-feedback">
${this.renderFeedback()}
@@ -575,7 +572,7 @@ export class GrMainHeader extends LitElement {
return Promise.all([
this.restApiService.getAccount(),
this.restApiService.getTopMenus(),
- getPluginLoader().awaitPluginsLoaded(),
+ this.getPluginLoader().awaitPluginsLoaded(),
]).then(result => {
const account = result[0];
this.account = account;
@@ -592,7 +589,7 @@ export class GrMainHeader extends LitElement {
}
return capabilities;
}),
- () => this.jsAPI.getAdminMenuLinks()
+ () => this.getPluginLoader().jsApiService.getAdminMenuLinks()
).then(res => {
this.adminLinks = res.links;
});
@@ -639,6 +636,6 @@ export class GrMainHeader extends LitElement {
private onMobileSearchTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- fireEvent(this, 'mobile-search');
+ fire(this, 'mobile-search', {});
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
index 7eb19f0657..955846f67c 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -17,7 +17,7 @@ import {
createGerritInfo,
createServerInfo,
} from '../../../test/test-data-generators';
-import {NavLink} from '../../../utils/admin-nav-util';
+import {NavLink} from '../../../models/views/admin';
import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
import {AuthType} from '../../../constants/constants';
import {fixture, html, assert} from '@open-wc/testing';
@@ -61,12 +61,8 @@ suite('gr-main-header tests', () => {
name="header-small-banner"
>
</gr-endpoint-decorator>
- <gr-smart-search id="search" label="Search for changes">
- </gr-smart-search>
- <gr-endpoint-decorator
- class="hideOnMobile"
- name="header-browse-source"
- >
+ <gr-smart-search id="search"> </gr-smart-search>
+ <gr-endpoint-decorator class="hideOnMobile" name="header-top-right">
</gr-endpoint-decorator>
<gr-endpoint-decorator
class="feedbackButton"
@@ -162,7 +158,6 @@ suite('gr-main-header tests', () => {
{
name: 'Repos',
url: '/repos',
- noBaseUrl: true,
view: undefined,
},
];
@@ -236,7 +231,6 @@ suite('gr-main-header tests', () => {
{
name: 'Repos',
url: '/repos',
- noBaseUrl: true,
view: undefined,
},
];
@@ -283,7 +277,6 @@ suite('gr-main-header tests', () => {
{
name: 'Repos',
url: '/repos',
- noBaseUrl: true,
view: undefined,
},
];
@@ -335,7 +328,6 @@ suite('gr-main-header tests', () => {
{
name: 'Repos',
url: '/repos',
- noBaseUrl: true,
view: undefined,
},
];
@@ -497,7 +489,6 @@ suite('gr-main-header tests', () => {
{
name: 'Repos',
url: '/repos',
- noBaseUrl: true,
view: undefined,
},
];
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 4af24cceda..94241eaab1 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -26,4 +26,22 @@ export interface NavigationService {
* page.redirect() eventually just calls `window.history.replaceState()`.
*/
replaceUrl(url: string): void;
+
+ /**
+ * You can ask the router to block all navigation to other pages for a while,
+ * e.g. while there are unsaved comments. You must make sure to call
+ * `releaseNavigation()` with the same string shortly after to unblock the
+ * router.
+ *
+ * The provided reason must be non-empty.
+ */
+ blockNavigation(reason: string): void;
+
+ /**
+ * See `blockNavigation()`.
+ *
+ * This API is not counting. If you block navigation with the same reason
+ * multiple times, then one release call will unblock.
+ */
+ releaseNavigation(reason: string): void;
}
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
new file mode 100644
index 0000000000..ab95711084
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, state} from 'lit/decorators.js';
+import {resolve} from '../../../models/dependency';
+import {serviceWorkerInstallerToken} from '../../../services/service-worker-installer';
+import {subscribe} from '../../lit/subscription-controller';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {navigationToken} from '../gr-navigation/gr-navigation';
+import {createSettingsUrl} from '../../../models/views/settings';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icon/gr-icon';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-notifications-prompt': GrNotificationsPrompt;
+ }
+}
+
+@customElement('gr-notifications-prompt')
+export class GrNotificationsPrompt extends LitElement {
+ @state() private hideNotificationsPrompt = false;
+
+ @state() private shouldShowPrompt = false;
+
+ private readonly serviceWorkerInstaller = resolve(
+ this,
+ serviceWorkerInstallerToken
+ );
+
+ private readonly getNavigation = resolve(this, navigationToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.serviceWorkerInstaller().shouldShowPrompt$,
+ shouldShowPrompt => {
+ this.shouldShowPrompt = !!shouldShowPrompt;
+ }
+ );
+ }
+
+ static override get styles() {
+ return [
+ fontStyles,
+ css`
+ #notificationsPrompt {
+ position: absolute;
+ right: 30px;
+ top: 50px;
+ z-index: 150; /* Less than gr-hovercard's, higher than rest */
+ display: flex;
+ background-color: var(--background-color-primary);
+ padding: var(--spacing-l);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ box-shadow: var(--elevation-level-5);
+ }
+ h3 {
+ margin: 0;
+ padding: 0;
+ }
+ .icon {
+ flex: 0 0 30px;
+ }
+ .content {
+ width: 300px;
+ }
+ div.section {
+ margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+ display: flex;
+ align-items: center;
+ }
+ div.sectionIcon {
+ flex: 0 0 30px;
+ }
+ .message {
+ margin: var(--spacing-m) 0;
+ }
+ div.sectionIcon gr-icon {
+ position: relative;
+ }
+ b {
+ font-weight: var(--font-weight-bold);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (this.hideNotificationsPrompt) return nothing;
+ if (!this.shouldShowPrompt) return nothing;
+ return html`<div id="notificationsPrompt" role="dialog">
+ <div class="icon">
+ <gr-icon icon="info"></gr-icon>
+ </div>
+ <div class="content">
+ <h3 class="heading-3">Missing your turn notifications?</h3>
+ <div class="message">
+ Get notified whenever it's your turn on a change. Gerrit needs
+ permission to send notifications. To turn on notifications, click
+ <b>Continue</b> and then <b>Allow</b> when prompted by your browser.
+ </div>
+ <div class="buttons">
+ <gr-button
+ primary=""
+ @click=${() => {
+ this.hideNotificationsPrompt = true;
+ this.serviceWorkerInstaller().requestPermission();
+ }}
+ >Continue</gr-button
+ >
+ <gr-button
+ @click=${() => {
+ this.hideNotificationsPrompt = true;
+ this.getNavigation().setUrl(createSettingsUrl());
+ }}
+ >Disable in settings</gr-button
+ >
+ </div>
+ </div>
+ <div class="icon">
+ <gr-button
+ @click=${() => {
+ this.hideNotificationsPrompt = true;
+ }}
+ link
+ >
+ <gr-icon icon="close"></gr-icon>
+ </gr-button>
+ </div>
+ </div>`;
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
new file mode 100644
index 0000000000..d06b4059fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-notifications-prompt/gr-notifications-prompt_test.ts
@@ -0,0 +1,92 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-notifications-prompt';
+import {GrNotificationsPrompt} from './gr-notifications-prompt';
+import {fixture, html, assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ ServiceWorkerInstaller,
+ serviceWorkerInstallerToken,
+} from '../../../services/service-worker-installer';
+import {waitUntilObserved} from '../../../test/test-utils';
+import {createDefaultPreferences} from '../../../constants/constants';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-notifications-prompt tests', () => {
+ let element: GrNotificationsPrompt;
+ let serviceWorkerInstaller: ServiceWorkerInstaller;
+
+ setup(async () => {
+ sinon
+ .stub(window.navigator.serviceWorker, 'register')
+ .returns(Promise.resolve({} as ServiceWorkerRegistration));
+ const flagsService = getAppContext().flagsService;
+ sinon.stub(flagsService, 'isEnabled').returns(true);
+ const userModel = testResolver(userModelToken);
+ const prefs = {
+ ...createDefaultPreferences(),
+ allow_browser_notifications: true,
+ };
+ userModel.setPreferences(prefs);
+ await waitUntilObserved(
+ userModel.preferences$,
+ pref => pref.allow_browser_notifications === true
+ );
+ await waitUntilObserved(
+ userModel.preferences$,
+ pref => pref.allow_browser_notifications === true
+ );
+ serviceWorkerInstaller = testResolver(serviceWorkerInstallerToken);
+ // Since we cannot stub Notification.permission, we stub shouldShowPrompt.
+ sinon.stub(serviceWorkerInstaller, 'shouldShowPrompt').returns(true);
+ element = await fixture(
+ html`<gr-notifications-prompt></gr-notifications-prompt>`
+ );
+ await waitUntilObserved(
+ serviceWorkerInstaller.shouldShowPrompt$,
+ shouldShowPrompt => shouldShowPrompt === true
+ );
+ await element.updateComplete;
+ });
+
+ test('renders', () => {
+ assert.shadowDom.equal(
+ element, // cannot format with HTML because test will not pass.
+ `<div id="notificationsPrompt" role="dialog">
+ <div class="icon"><gr-icon icon="info"> </gr-icon></div>
+ <div class="content">
+ <h3 class="heading-3">Missing your turn notifications?</h3>
+ <div class="message">
+ Get notified whenever it's your turn on a change. Gerrit needs
+ permission to send notifications. To turn on notifications, click
+ <b> Continue </b> and then <b> Allow </b>
+ when prompted by your browser.
+ </div>
+ <div class="buttons">
+ <gr-button
+ aria-disabled="false"
+ primary=""
+ role="button"
+ tabindex="0"
+ >
+ Continue
+ </gr-button>
+ <gr-button aria-disabled="false" role="button" tabindex="0">
+ Disable in settings
+ </gr-button>
+ </div>
+ </div>
+ <div class="icon">
+ <gr-button aria-disabled="false" link="" role="button" tabindex="0">
+ <gr-icon icon="close"> </gr-icon>
+ </gr-button>
+ </div>
+ </div>`
+ );
+ });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
new file mode 100644
index 0000000000..1d2a272c81
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page.ts
@@ -0,0 +1,376 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * This file was originally a copy of https://github.com/visionmedia/page.js.
+ * It was converted to TypeScript and stripped off lots of code that we don't
+ * need in Gerrit. Thus we reproduce the original LICENSE in js_licenses.txt.
+ */
+
+/**
+ * This is what registered routes have to provide, see `registerRoute()` and
+ * `registerExitRoute()`.
+ * `context` provides information about the matched parameters in the URL.
+ * Then you can decide to handle the route exclusively (not calling `next()`),
+ * or to pass it on to other registered routes. Normally you would not call
+ * `next()`, because your regex matching the URL was specific enough.
+ */
+export type PageCallback = (
+ context: PageContext,
+ next: PageNextCallback
+) => void;
+
+/** See comment on `PageCallback` above. */
+export type PageNextCallback = () => void;
+
+/** Options for starting the router. */
+export interface PageOptions {
+ /**
+ * Should the router inspect the current URL and dispatch it when the router
+ * is started? Default is `true`, but can be turned off for testing.
+ */
+ dispatch: boolean;
+
+ /**
+ * The base path of the application. For Gerrit this must be set to
+ * getBaseUrl().
+ */
+ base: string;
+}
+
+/**
+ * The browser `History` API allows `pushState()` to contain an arbitrary state
+ * object. Our router only sets `path` on the state and inspects it when
+ * handling `popstate` events. This interface is internal only.
+ */
+interface PageState {
+ path?: string;
+}
+
+const clickEvent = document.ontouchstart ? 'touchstart' : 'click';
+
+export class Page {
+ /**
+ * When a new URL is dispatched all these routes are called one after another.
+ * If a route decides that it wants to handle a URL, then it does not call
+ * next().
+ */
+ private entryRoutes: PageCallback[] = [];
+
+ /**
+ * Before a new URL is dispatched exit routes for the previous URL are called.
+ * They can clean up some state for example. But they could also prevent the
+ * user from navigating away (from within the app), if they don't call next().
+ */
+ private exitRoutes: PageCallback[] = [];
+
+ /**
+ * The path that is currently being dispatched. This is used, so that we can
+ * check whether a context is still valid, i.e. ctx.path === currentPath.
+ */
+ private currentPath = '';
+
+ /**
+ * The base path of the application. For Gerrit this must be set to
+ * getBaseUrl(). For example https://gerrit.wikimedia.org/ uses r/ as its
+ * base path.
+ */
+ private base = '';
+
+ /**
+ * Is set at the beginning of start() and stop(), so that you cannot start
+ * the routing twice.
+ */
+ private running = false;
+
+ /**
+ * Keeping around the previous context for being able to call exit routes
+ * after creating a new context.
+ */
+ private prevPageContext?: PageContext;
+
+ /**
+ * We don't want to handle popstate events before the document is loaded.
+ */
+ private documentLoaded = false;
+
+ start(options: PageOptions = {dispatch: true, base: ''}) {
+ if (this.running) return;
+ this.running = true;
+ this.base = options.base;
+
+ window.document.addEventListener(clickEvent, this.clickHandler);
+ window.addEventListener('load', this.loadHandler);
+ window.addEventListener('popstate', this.popStateHandler);
+ if (document.readyState === 'complete') this.documentLoaded = true;
+
+ if (options.dispatch) {
+ const loc = window.location;
+ this.replace(loc.pathname + loc.search + loc.hash);
+ }
+ }
+
+ stop() {
+ if (!this.running) return;
+ this.currentPath = '';
+ this.running = false;
+
+ window.document.removeEventListener(clickEvent, this.clickHandler);
+ window.removeEventListener('popstate', this.popStateHandler);
+ window.removeEventListener('load', this.loadHandler);
+ }
+
+ show(path: string, push = true) {
+ const ctx = new PageContext(path, {}, this.base);
+ const prev = this.prevPageContext;
+ this.prevPageContext = ctx;
+ this.currentPath = ctx.path;
+ this.dispatch(ctx, prev);
+ if (push && !ctx.preventPush) ctx.pushState();
+ }
+
+ redirect(to: string) {
+ setTimeout(() => this.replace(to), 0);
+ }
+
+ replace(path: string, state: PageState = {}, dispatch = true) {
+ const ctx = new PageContext(path, state, this.base);
+ const prev = this.prevPageContext;
+ this.prevPageContext = ctx;
+ this.currentPath = ctx.path;
+ ctx.replaceState(); // replace before dispatching, which may redirect
+ if (dispatch) this.dispatch(ctx, prev);
+ }
+
+ dispatch(ctx: PageContext, prev?: PageContext) {
+ let j = 0;
+ const nextExit = () => {
+ const fn = this.exitRoutes[j++];
+ // First call the exit routes of the previous context. Then proceed
+ // to the entry routes for the new context.
+ if (!fn) {
+ nextEnter();
+ return;
+ }
+ fn(prev!, nextExit);
+ };
+
+ let i = 0;
+ const nextEnter = () => {
+ const fn = this.entryRoutes[i++];
+
+ // Concurrency protection. The context is not valid anymore.
+ // Stop calling any further route handlers.
+ if (ctx.path !== this.currentPath) {
+ ctx.preventPush = true;
+ return;
+ }
+
+ // You must register a route that handles everything (.*) and does not
+ // call next().
+ if (!fn) throw new Error('No route has handled the URL.');
+
+ fn(ctx, nextEnter);
+ };
+
+ if (prev) {
+ nextExit();
+ } else {
+ nextEnter();
+ }
+ }
+
+ registerRoute(re: RegExp, fn: PageCallback) {
+ this.entryRoutes.push(createRoute(re, fn));
+ }
+
+ registerExitRoute(re: RegExp, fn: PageCallback) {
+ this.exitRoutes.push(createRoute(re, fn));
+ }
+
+ loadHandler = () => {
+ setTimeout(() => (this.documentLoaded = true), 0);
+ };
+
+ clickHandler = (e: MouseEvent | TouchEvent) => {
+ if ((e as MouseEvent).button !== 0) return;
+ if (e.metaKey || e.ctrlKey || e.shiftKey) return;
+ if (e.defaultPrevented) return;
+
+ let el = e.target as HTMLAnchorElement;
+ const eventPath = e.composedPath();
+ if (eventPath) {
+ for (let i = 0; i < eventPath.length; i++) {
+ const pathEl = eventPath[i] as HTMLAnchorElement;
+ if (!pathEl.nodeName) continue;
+ if (pathEl.nodeName.toUpperCase() !== 'A') continue;
+ if (!pathEl.href) continue;
+
+ el = pathEl;
+ break;
+ }
+ }
+
+ while (el && 'A' !== el.nodeName.toUpperCase())
+ el = el.parentNode as HTMLAnchorElement;
+ if (!el || 'A' !== el.nodeName.toUpperCase()) return;
+
+ if (el.hasAttribute('download') || el.getAttribute('rel') === 'external')
+ return;
+ const link = el.getAttribute('href');
+ if (samePath(el) && (el.hash || '#' === link)) return;
+ if (link && link.indexOf('mailto:') > -1) return;
+ if (el.target) return;
+ if (!sameOrigin(el.href)) return;
+
+ let path = el.pathname + el.search + (el.hash ?? '');
+ path = path[0] !== '/' ? '/' + path : path;
+
+ const orig = path;
+ if (path.indexOf(this.base) === 0) {
+ path = path.substr(this.base.length);
+ }
+ if (this.base && orig === path && window.location.protocol !== 'file:') {
+ return;
+ }
+ e.preventDefault();
+ this.show(orig);
+ };
+
+ popStateHandler = (e: PopStateEvent) => {
+ if (!this.documentLoaded) return;
+ if (e.state) {
+ const path = e.state.path;
+ this.replace(path, e.state);
+ } else {
+ const loc = window.location;
+ this.show(loc.pathname + loc.search + loc.hash, /* push */ false);
+ }
+ };
+}
+
+function sameOrigin(href: string) {
+ if (!href) return false;
+ const url = new URL(href, window.location.toString());
+ const loc = window.location;
+ return (
+ loc.protocol === url.protocol &&
+ loc.hostname === url.hostname &&
+ loc.port === url.port
+ );
+}
+
+function samePath(url: HTMLAnchorElement) {
+ const loc = window.location;
+ return url.pathname === loc.pathname && url.search === loc.search;
+}
+
+function escapeRegExp(s: string) {
+ return s.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1');
+}
+
+function decodeURIComponentString(val: string | undefined | null) {
+ if (!val) return '';
+ return decodeURIComponent(val.replace(/\+/g, ' '));
+}
+
+export class PageContext {
+ /**
+ * Includes everything: base, path, query and hash.
+ * NOT decoded.
+ */
+ canonicalPath = '';
+
+ /**
+ * Does not include base path.
+ * Does not include hash.
+ * Includes query string.
+ * NOT decoded.
+ */
+ path = '';
+
+ /** Decoded. Does not include hash. */
+ querystring = '';
+
+ /** Decoded. */
+ hash = '';
+
+ /**
+ * Regular expression matches of capturing groups. The first entry params[0]
+ * corresponds to the first capturing group. The entire matched string is not
+ * returned in this array.
+ * Each param is double decoded.
+ */
+ params: string[] = [];
+
+ /**
+ * Prevents `show()` from eventually calling `pushState()`. For example if
+ * the current context is not "valid" anymore, i.e. the URL has changed in the
+ * meantime.
+ *
+ * This is router internal state. Do not use it from routes.
+ */
+ preventPush = false;
+
+ private title = '';
+
+ constructor(
+ path: string,
+ private readonly state: PageState = {},
+ pageBase = ''
+ ) {
+ this.title = window.document.title;
+
+ if ('/' === path[0] && 0 !== path.indexOf(pageBase)) path = pageBase + path;
+ this.canonicalPath = path;
+ const re = new RegExp('^' + escapeRegExp(pageBase));
+ this.path = path.replace(re, '') || '/';
+ this.state.path = path;
+
+ const i = path.indexOf('?');
+ this.querystring =
+ i !== -1 ? decodeURIComponentString(path.slice(i + 1)) : '';
+
+ // Does the path include a hash? If yes, then remove it from path and
+ // querystring.
+ if (this.path.indexOf('#') === -1) return;
+ const parts = this.path.split('#');
+ this.path = parts[0];
+ this.hash = decodeURIComponentString(parts[1]) || '';
+ this.querystring = this.querystring.split('#')[0];
+ }
+
+ pushState() {
+ window.history.pushState(this.state, this.title, this.canonicalPath);
+ }
+
+ replaceState() {
+ window.history.replaceState(this.state, this.title, this.canonicalPath);
+ }
+
+ match(re: RegExp) {
+ const qsIndex = this.path.indexOf('?');
+ const pathname = qsIndex !== -1 ? this.path.slice(0, qsIndex) : this.path;
+ const matches = re.exec(decodeURIComponent(pathname));
+ if (matches) {
+ this.params = matches
+ .slice(1)
+ .map(match => decodeURIComponentString(match));
+ }
+ return !!matches;
+ }
+}
+
+function createRoute(re: RegExp, fn: Function) {
+ return (ctx: PageContext, next: Function) => {
+ const matches = ctx.match(re);
+ if (matches) {
+ fn(ctx, next);
+ } else {
+ next();
+ }
+ };
+}
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
new file mode 100644
index 0000000000..d194bf5547
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-page_test.ts
@@ -0,0 +1,118 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {html, assert, fixture, waitUntil} from '@open-wc/testing';
+import './gr-router';
+import {Page, PageContext} from './gr-page';
+
+suite('gr-page tests', () => {
+ let page: Page;
+
+ setup(() => {
+ page = new Page();
+ page.start({dispatch: false, base: ''});
+ });
+
+ teardown(() => {
+ page.stop();
+ });
+
+ test('click handler', async () => {
+ const spy = sinon.spy();
+ page.registerRoute(/\/settings/, spy);
+ const link = await fixture<HTMLAnchorElement>(
+ html`<a href="/settings"></a>`
+ );
+ link.click();
+ assert.isTrue(spy.calledOnce);
+ });
+
+ test('register route and exit', () => {
+ const handleA = sinon.spy();
+ const handleAExit = sinon.stub();
+ page.registerRoute(/\/A/, handleA);
+ page.registerExitRoute(/\/A/, handleAExit);
+
+ page.show('/A');
+ assert.equal(handleA.callCount, 1);
+ assert.equal(handleAExit.callCount, 0);
+
+ page.show('/B');
+ assert.equal(handleA.callCount, 1);
+ assert.equal(handleAExit.callCount, 1);
+ });
+
+ test('register, show, replace', () => {
+ const handleA = sinon.spy();
+ const handleB = sinon.spy();
+ page.registerRoute(/\/A/, handleA);
+ page.registerRoute(/\/B/, handleB);
+
+ page.show('/A');
+ assert.equal(handleA.callCount, 1);
+ assert.equal(handleB.callCount, 0);
+
+ page.show('/B');
+ assert.equal(handleA.callCount, 1);
+ assert.equal(handleB.callCount, 1);
+
+ page.replace('/A');
+ assert.equal(handleA.callCount, 2);
+ assert.equal(handleB.callCount, 1);
+
+ page.replace('/B');
+ assert.equal(handleA.callCount, 2);
+ assert.equal(handleB.callCount, 2);
+ });
+
+ test('popstate browser back', async () => {
+ const handleA = sinon.spy();
+ const handleB = sinon.spy();
+ page.registerRoute(/\/A/, handleA);
+ page.registerRoute(/\/B/, handleB);
+
+ page.show('/A');
+ assert.equal(handleA.callCount, 1);
+ assert.equal(handleB.callCount, 0);
+
+ page.show('/B');
+ assert.equal(handleA.callCount, 1);
+ assert.equal(handleB.callCount, 1);
+
+ window.history.back();
+ await waitUntil(() => window.location.href.includes('/A'));
+ assert.equal(handleA.callCount, 2);
+ assert.equal(handleB.callCount, 1);
+ });
+
+ test('register pattern, check context', async () => {
+ let context: PageContext;
+ const handler = (ctx: PageContext) => (context = ctx);
+ page.registerRoute(/\/asdf\/(.*)\/qwer\/(.*)\//, handler);
+ page.stop();
+ page.start({dispatch: false, base: '/base'});
+
+ page.show('/base/asdf/1234/qwer/abcd/');
+
+ await waitUntil(() => !!context);
+ assert.equal(context!.canonicalPath, '/base/asdf/1234/qwer/abcd/');
+ assert.equal(context!.path, '/asdf/1234/qwer/abcd/');
+ assert.equal(context!.querystring, '');
+ assert.equal(context!.hash, '');
+ assert.equal(context!.params[0], '1234');
+ assert.equal(context!.params[1], 'abcd');
+
+ page.show('/asdf//qwer////?a=b#go');
+
+ await waitUntil(() => !!context);
+ assert.equal(context!.canonicalPath, '/base/asdf//qwer////?a=b#go');
+ assert.equal(context!.path, '/asdf//qwer////?a=b');
+ assert.equal(context!.querystring, 'a=b');
+ assert.equal(context!.hash, 'go');
+ assert.equal(context!.params[0], '');
+ assert.equal(context!.params[1], '//');
+ });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
index c676a4a9bf..aa6bb7a4cc 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -3,18 +3,17 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- page,
- PageContext,
- PageNextCallback,
-} from '../../../utils/page-wrapper-utils';
+import {Page, PageOptions, PageContext, PageNextCallback} from './gr-page';
import {NavigationService} from '../gr-navigation/gr-navigation';
import {getAppContext} from '../../../services/app-context';
-import {convertToPatchSetNum} from '../../../utils/patch-set-util';
-import {assertIsDefined} from '../../../utils/common-util';
+import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
import {
BasePatchSetNum,
- DashboardId,
GroupId,
NumericChangeId,
RevisionPatchSetNum,
@@ -22,13 +21,15 @@ import {
UrlEncodedCommentId,
PARENT,
PatchSetNumber,
+ BranchName,
} from '../../../types/common';
import {AppElement, AppElementParams} from '../../gr-app-types';
import {LocationChangeEventDetail} from '../../../types/events';
import {GerritView, RouterModel} from '../../../services/router/router-model';
-import {firePageError} from '../../../utils/event-util';
+import {fire, fireAlert, firePageError} from '../../../utils/event-util';
import {windowLocationReload} from '../../../utils/dom-util';
import {
+ encodeURL,
getBaseUrl,
PatchRangeParams,
toPath,
@@ -44,6 +45,7 @@ import {
AdminChildView,
AdminViewModel,
AdminViewState,
+ PLUGIN_LIST_ROUTE,
} from '../../../models/views/admin';
import {
AgreementViewModel,
@@ -55,20 +57,22 @@ import {
RepoViewState,
} from '../../../models/views/repo';
import {
+ createGroupUrl,
GroupDetailView,
GroupViewModel,
GroupViewState,
} from '../../../models/views/group';
-import {DiffViewModel, DiffViewState} from '../../../models/views/diff';
import {
+ ChangeChildView,
ChangeViewModel,
ChangeViewState,
- createChangeUrl,
+ createChangeViewUrl,
+ createDiffUrl,
} from '../../../models/views/change';
-import {EditViewModel, EditViewState} from '../../../models/views/edit';
import {
DashboardViewModel,
DashboardViewState,
+ PROJECT_DASHBOARD_ROUTE,
} from '../../../models/views/dashboard';
import {
SettingsViewModel,
@@ -86,13 +90,28 @@ import {PluginViewModel, PluginViewState} from '../../../models/views/plugin';
import {SearchViewModel, SearchViewState} from '../../../models/views/search';
import {DashboardSection} from '../../../utils/dashboard-util';
import {Subscription} from 'rxjs';
+import {
+ addPath,
+ findComment,
+ getPatchRangeForCommentUrl,
+ isInBaseOfPatchRange,
+} from '../../../utils/comment-util';
+import {isFileUnchanged} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {Route, ViewState} from '../../../models/views/base';
+import {Model} from '../../../models/model';
+import {
+ InteractivePromise,
+ interactivePromise,
+ timeoutPromise,
+} from '../../../utils/async-util';
+// TODO: Move all patterns to view model files and use the `Route` interface,
+// which will enforce using `RegExp` in its `urlPattern` property.
const RoutePattern = {
- ROOT: '/',
+ ROOT: /^\/$/,
DASHBOARD: /^\/dashboard\/(.+)$/,
CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
- PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
AGREEMENTS: /^\/settings\/agreements\/?/,
@@ -101,7 +120,9 @@ const RoutePattern = {
// Pattern for login and logout URLs intended to be passed-through. May
// include a return URL.
- LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+ // TODO: Maybe this pattern and its handler can just be removed, because
+ // passing through is what the default router would eventually do anyway.
+ LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
// Pattern for a catchall route when no other pattern is matched.
DEFAULT: /.*/,
@@ -122,11 +143,6 @@ const RoutePattern = {
// Matches /admin/groups/[uuid-]<group>,members
GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
- // Matches /admin/groups[,<offset>][/].
- GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
- GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
- GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
-
// Matches /admin/create-project
LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
@@ -141,6 +157,10 @@ const RoutePattern = {
// Matches /admin/repos/<repo>,commands.
REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+ // For creating a change, and going directly into editing mode for one file.
+ REPO_EDIT_FILE:
+ /^\/admin\/repos\/edit\/repo\/(.+)\/branch\/(.+)\/file\/(.+)$/,
+
REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
// Matches /admin/repos/<repos>,access.
@@ -149,32 +169,21 @@ const RoutePattern = {
// Matches /admin/repos/<repos>,access.
REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
- // Matches /admin/repos[,<offset>][/].
- REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
- REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
- REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
-
- // Matches /admin/repos/<repo>,branches[,<offset>].
- BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
- BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
- BRANCH_LIST_FILTER_OFFSET:
- '/admin/repos/:repo,branches/q/filter::filter,:offset',
-
- // Matches /admin/repos/<repo>,tags[,<offset>].
- TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
- TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
- TAG_LIST_FILTER_OFFSET: '/admin/repos/:repo,tags/q/filter::filter,:offset',
-
PLUGINS: /^\/plugins\/(.+)$/,
- PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
-
- // Matches /admin/plugins[,<offset>][/].
- PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
- PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
- PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+ // Matches /admin/plugins with optional filter and offset.
+ PLUGIN_LIST: /^\/admin\/plugins\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+ // Matches /admin/groups with optional filter and offset.
+ GROUP_LIST: /^\/admin\/groups\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+ // Matches /admin/repos with optional filter and offset.
+ REPO_LIST: /^\/admin\/repos\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+ // Matches /admin/repos/$REPO,branches with optional filter and offset.
+ BRANCH_LIST:
+ /^\/admin\/repos\/(.+),branches\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
+ // Matches /admin/repos/$REPO,tags with optional filter and offset.
+ TAG_LIST: /^\/admin\/repos\/(.+),tags\/?(?:\/q\/filter:(.*?))?(?:,(\d+))?$/,
- QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+ QUERY: /^\/q\/(.+?)(,(\d+))?$/,
/**
* Support vestigial params from GWT UI.
@@ -234,13 +243,11 @@ const RoutePattern = {
PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
- DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+ DOCUMENTATION_SEARCH_FILTER: /^\/Documentation\/q\/filter:(.*)$/,
DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
};
-export const _testOnly_RoutePattern = RoutePattern;
-
/**
* Pattern to recognize and parse the diff line locations as they appear in
* the hash of diff URLs. In this format, a number on its own indicates that
@@ -289,6 +296,18 @@ export class GrRouter implements Finalizable, NavigationService {
private view?: GerritView;
+ // While this set is not empty, the router will refuse to navigate to
+ // other pages, but instead show an alert. It will also install a
+ // `beforeUnload` handler that prevents the browser from closing the tab.
+ private navigationBlockers: Set<string> = new Set<string>();
+
+ // While navigationBlockers is not empty, this promise will continuously
+ // check for navigationBlockers to become empty again.
+ // This is undefined, iff navigationBlockers is empty.
+ private navigationBlockerPromise?: InteractivePromise<void>;
+
+ readonly page = new Page();
+
constructor(
private readonly reporting: ReportingService,
private readonly routerModel: RouterModel,
@@ -297,9 +316,7 @@ export class GrRouter implements Finalizable, NavigationService {
private readonly agreementViewModel: AgreementViewModel,
private readonly changeViewModel: ChangeViewModel,
private readonly dashboardViewModel: DashboardViewModel,
- private readonly diffViewModel: DiffViewModel,
private readonly documentationViewModel: DocumentationViewModel,
- private readonly editViewModel: EditViewModel,
private readonly groupViewModel: GroupViewModel,
private readonly pluginViewModel: PluginViewModel,
private readonly repoViewModel: RepoViewModel,
@@ -316,26 +333,60 @@ export class GrRouter implements Finalizable, NavigationService {
// So this check is slightly fragile, but should work.
if (this.view !== GerritView.CHANGE) return;
const browserUrl = new URL(window.location.toString());
- const stateUrl = new URL(createChangeUrl(state), browserUrl);
+ const stateUrl = new URL(createChangeViewUrl(state), browserUrl);
+
+ // Keeping the hash and certain parameters are stop-gap solution. We
+ // should find better ways of maintaining an overall consistent URL
+ // state.
stateUrl.hash = browserUrl.hash;
+ for (const p of browserUrl.searchParams.entries()) {
+ if (p[0] === 'experiment') stateUrl.searchParams.append(p[0], p[1]);
+ }
+
if (browserUrl.toString() !== stateUrl.toString()) {
- page.replace(
- stateUrl.toString(),
- null,
- /* init: */ false,
- /* dispatch: */ false
- );
+ this.page.replace(stateUrl.toString(), {}, /* dispatch: */ false);
}
}),
this.routerModel.routerView$.subscribe(view => (this.view = view)),
];
}
+ blockNavigation(reason: string): void {
+ assert(!!reason, 'empty reason is not allowed');
+ this.navigationBlockers.add(reason);
+ if (this.navigationBlockers.size === 1) {
+ this.navigationBlockerPromise = interactivePromise();
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
+ }
+ }
+
+ releaseNavigation(reason: string): void {
+ assert(!!reason, 'empty reason is not allowed');
+ this.navigationBlockers.delete(reason);
+ if (this.navigationBlockers.size === 0) {
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
+ this.navigationBlockerPromise?.resolve();
+ }
+ }
+
+ private beforeUnloadHandler = (event: BeforeUnloadEvent) => {
+ const reason = [...this.navigationBlockers][0];
+ if (!reason) return;
+
+ event.preventDefault(); // Cancel the event (per the standard).
+ event.returnValue = reason; // Chrome requires returnValue to be set.
+ // Note that we could as well just use '' instead of `reason`. Browsers will
+ // just show a generic message anyway.
+ return reason;
+ };
+
finalize(): void {
for (const subscription of this.subscriptions) {
subscription.unsubscribe();
}
this.subscriptions = [];
+ this.page.stop();
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
}
start() {
@@ -346,20 +397,18 @@ export class GrRouter implements Finalizable, NavigationService {
}
setState(state: AppElementParams) {
- if (
- 'project' in state &&
- state.project !== undefined &&
- 'changeNum' in state
- )
- this.restApiService.setInProjectLookup(state.changeNum, state.project);
-
- this.routerModel.setState({
- view: state.view,
- changeNum: 'changeNum' in state ? state.changeNum : undefined,
- patchNum: 'patchNum' in state ? state.patchNum ?? undefined : undefined,
- basePatchNum:
- 'basePatchNum' in state ? state.basePatchNum ?? undefined : undefined,
- });
+ // TODO: Move this logic into the change model.
+ if ('repo' in state && state.repo !== undefined && 'changeNum' in state)
+ this.restApiService.setInProjectLookup(state.changeNum, state.repo);
+
+ this.routerModel.setState({view: state.view});
+ // We are trying to reset the change (view) model when navigating to other
+ // views, because we don't trust our reset logic at the moment. The models
+ // singletons and might unintentionally keep state from one change to
+ // another. TODO: Let's find some way to avoid that.
+ if (state.view !== GerritView.CHANGE) {
+ this.changeViewModel.setState(undefined);
+ }
this.appElement().params = state;
}
@@ -380,7 +429,7 @@ export class GrRouter implements Finalizable, NavigationService {
redirect(url: string) {
this._isRedirecting = true;
- page.redirect(url);
+ this.page.redirect(url);
}
/**
@@ -409,11 +458,13 @@ export class GrRouter implements Finalizable, NavigationService {
*/
redirectToLogin(returnUrl: string) {
const basePath = getBaseUrl() || '';
- page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+ this.setUrl(
+ '/login/' + encodeURIComponent(returnUrl.substring(basePath.length))
+ );
}
/**
- * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
+ * Hashes parsed by gr-page exclude "inner" hashes, so a URL like "/a#b#c"
* is parsed to have a hash of "b" rather than "b#c". Instead, this method
* parses hashes correctly. Will return an empty string if there is no hash.
*
@@ -442,18 +493,18 @@ export class GrRouter implements Finalizable, NavigationService {
* @return A promise yielding the original route ctx
* (if it resolves).
*/
- redirectIfNotLoggedIn(ctx: PageContext) {
+ redirectIfNotLoggedIn(path: string) {
return this.restApiService.getLoggedIn().then(loggedIn => {
if (loggedIn) {
return Promise.resolve();
} else {
- this.redirectToLogin(ctx.canonicalPath);
+ this.redirectToLogin(path);
return Promise.reject(new Error());
}
});
}
- /** Page.js middleware that warms the REST API's logged-in cache line. */
+ /** gr-page middleware that warms the REST API's logged-in cache line. */
private loadUserMiddleware(_: PageContext, next: PageNextCallback) {
this.restApiService.getLoggedIn().then(() => {
next();
@@ -463,35 +514,62 @@ export class GrRouter implements Finalizable, NavigationService {
/**
* Map a route to a method on the router.
*
- * @param pattern The page.js pattern for the route.
+ * @param pattern The regex pattern for the route.
* @param handlerName The method name for the handler. If the
* route is matched, the handler will be executed with `this` referring
- * to the component. Its return value will be discarded so that it does
- * not interfere with page.js.
+ * to the component. Its return value will be discarded.
+ * TODO: Get rid of this parameter. This is really not something that the
+ * router wants to be concerned with. The reporting service and the view
+ * models should figure that out between themselves.
* @param authRedirect If true, then auth is checked before
* executing the handler. If the user is not logged in, it will redirect
* to the login flow and the handler will not be executed. The login
* redirect specifies the matched URL to be used after successful auth.
*/
mapRoute(
- pattern: string | RegExp,
+ pattern: RegExp,
handlerName: string,
handler: (ctx: PageContext) => void,
authRedirect?: boolean
) {
- page(
- pattern,
- (ctx, next) => this.loadUserMiddleware(ctx, next),
- ctx => {
- this.reporting.locationChanged(handlerName);
- const promise = authRedirect
- ? this.redirectIfNotLoggedIn(ctx)
- : Promise.resolve();
- promise.then(() => {
- handler(ctx);
- });
- }
+ this.page.registerRoute(pattern, (ctx, next) =>
+ this.loadUserMiddleware(ctx, next)
);
+ this.page.registerRoute(pattern, ctx => {
+ this.reporting.locationChanged(handlerName);
+ const promise = authRedirect
+ ? this.redirectIfNotLoggedIn(ctx.canonicalPath)
+ : Promise.resolve();
+ promise.then(() => {
+ handler(ctx);
+ });
+ });
+ }
+
+ /**
+ * Convenience wrapper of `mapRoute()` for when you have a `Route` object that
+ * can deal with state creation. Takes care of setting the view model state,
+ * which is currently duplicated lots of times for direct callers of
+ * `mapRoute()`.
+ */
+ mapRouteState<T extends ViewState>(
+ route: Route<T>,
+ viewModel: Model<T | undefined>,
+ handlerName: string,
+ authRedirect?: boolean
+ ) {
+ const handler = (ctx: PageContext) => {
+ const state = route.createState(ctx);
+ // Note that order is important: `this.setState()` must be called before
+ // `viewModel.setState()`. Otherwise the chain of model subscriptions
+ // would be very different. Some views may want app element to swap the
+ // top level view first. Also, `this.setState()` has some special change
+ // view model resetting logic. Eventually the order might not be important
+ // anymore, but be careful! :-)
+ this.setState(state as AppElementParams);
+ viewModel.setState(state);
+ };
+ this.mapRoute(route.urlPattern, handlerName, handler, authRedirect);
}
/**
@@ -504,14 +582,14 @@ export class GrRouter implements Finalizable, NavigationService {
* page.show() eventually just calls `window.history.pushState()`.
*/
setUrl(url: string) {
- page.show(url);
+ this.page.show(url);
}
/**
* Navigate to this URL, but replace the current URL in the history instead of
* adding a new one (which is what `setUrl()` would do).
*
- * page.redirect() eventually just calls `window.history.replaceState()`.
+ * this.page.redirect() eventually just calls `window.history.replaceState()`.
*/
replaceUrl(url: string) {
this.redirect(url);
@@ -522,22 +600,15 @@ export class GrRouter implements Finalizable, NavigationService {
hash: window.location.hash,
pathname: window.location.pathname,
};
- document.dispatchEvent(
- new CustomEvent('location-change', {
- detail,
- composed: true,
- bubbles: true,
- })
- );
+ fire(document, 'location-change', detail);
}
- startRouter() {
- const base = getBaseUrl();
- if (base) {
- page.base(base);
- }
+ _testOnly_startRouter() {
+ this.startRouter({dispatch: false, base: getBaseUrl()});
+ }
- page.exit('*', (_, next) => {
+ startRouter(opts: PageOptions = {dispatch: true, base: getBaseUrl()}) {
+ this.page.registerExitRoute(/(.*)/, (_, next) => {
if (!this._isRedirecting) {
this.reporting.beforeLocationChanged();
}
@@ -548,7 +619,7 @@ export class GrRouter implements Finalizable, NavigationService {
// Remove the tracking param 'usp' (User Source Parameter) from the URL,
// just to have users look at cleaner URLs.
- page((ctx, next) => {
+ this.page.registerRoute(/(.*)/, (ctx, next) => {
if (window.URLSearchParams) {
const pathname = toPathname(ctx.canonicalPath);
const searchParams = toSearchParams(ctx.canonicalPath);
@@ -563,8 +634,32 @@ export class GrRouter implements Finalizable, NavigationService {
next();
});
+ // Block navigation while navigationBlockers exist. But wait 1 second for
+ // those blockers to resolve. If they do, then still navigate. We don't want
+ // to annoy users by forcing them to navigate twice only because it took
+ // another 200ms for a comment to save or something similar.
+ this.page.registerRoute(/(.*)/, (_, next) => {
+ if (this.navigationBlockers.size === 0) {
+ next();
+ return;
+ }
+
+ const msg = 'Waiting 1 second for navigation blockers to resolve ...';
+ fireAlert(document, msg);
+ Promise.race([this.navigationBlockerPromise, timeoutPromise(1000)]).then(
+ () => {
+ if (this.navigationBlockers.size === 0) {
+ next();
+ } else {
+ const reason = [...this.navigationBlockers][0];
+ fireAlert(document, `Navigation is blocked by: ${reason}`);
+ }
+ }
+ );
+ });
+
// Middleware
- page((ctx, next) => {
+ this.page.registerRoute(/(.*)/, (ctx, next) => {
document.body.scrollTop = 0;
if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
@@ -597,10 +692,10 @@ export class GrRouter implements Finalizable, NavigationService {
ctx => this.handleCustomDashboardRoute(ctx)
);
- this.mapRoute(
- RoutePattern.PROJECT_DASHBOARD,
- 'handleProjectDashboardRoute',
- ctx => this.handleProjectDashboardRoute(ctx)
+ this.mapRouteState(
+ PROJECT_DASHBOARD_ROUTE,
+ this.dashboardViewModel,
+ 'handleProjectDashboardRoute'
);
this.mapRoute(
@@ -631,23 +726,9 @@ export class GrRouter implements Finalizable, NavigationService {
);
this.mapRoute(
- RoutePattern.GROUP_LIST_OFFSET,
- 'handleGroupListOffsetRoute',
- ctx => this.handleGroupListOffsetRoute(ctx),
- true
- );
-
- this.mapRoute(
- RoutePattern.GROUP_LIST_FILTER_OFFSET,
- 'handleGroupListFilterOffsetRoute',
- ctx => this.handleGroupListFilterOffsetRoute(ctx),
- true
- );
-
- this.mapRoute(
- RoutePattern.GROUP_LIST_FILTER,
- 'handleGroupListFilterRoute',
- ctx => this.handleGroupListFilterRoute(ctx),
+ RoutePattern.GROUP_LIST,
+ 'handleGroupListRoute',
+ ctx => this.handleGroupListRoute(ctx),
true
);
@@ -676,6 +757,13 @@ export class GrRouter implements Finalizable, NavigationService {
true
);
+ this.mapRoute(
+ RoutePattern.REPO_EDIT_FILE,
+ 'handleRepoEditFileRoute',
+ ctx => this.handleRepoEditFileRoute(ctx),
+ true
+ );
+
this.mapRoute(RoutePattern.REPO_GENERAL, 'handleRepoGeneralRoute', ctx =>
this.handleRepoGeneralRoute(ctx)
);
@@ -690,40 +778,12 @@ export class GrRouter implements Finalizable, NavigationService {
ctx => this.handleRepoDashboardsRoute(ctx)
);
- this.mapRoute(
- RoutePattern.BRANCH_LIST_OFFSET,
- 'handleBranchListOffsetRoute',
- ctx => this.handleBranchListOffsetRoute(ctx)
- );
-
- this.mapRoute(
- RoutePattern.BRANCH_LIST_FILTER_OFFSET,
- 'handleBranchListFilterOffsetRoute',
- ctx => this.handleBranchListFilterOffsetRoute(ctx)
- );
-
- this.mapRoute(
- RoutePattern.BRANCH_LIST_FILTER,
- 'handleBranchListFilterRoute',
- ctx => this.handleBranchListFilterRoute(ctx)
- );
-
- this.mapRoute(
- RoutePattern.TAG_LIST_OFFSET,
- 'handleTagListOffsetRoute',
- ctx => this.handleTagListOffsetRoute(ctx)
- );
-
- this.mapRoute(
- RoutePattern.TAG_LIST_FILTER_OFFSET,
- 'handleTagListFilterOffsetRoute',
- ctx => this.handleTagListFilterOffsetRoute(ctx)
+ this.mapRoute(RoutePattern.BRANCH_LIST, 'handleBranchListRoute', ctx =>
+ this.handleBranchListRoute(ctx)
);
- this.mapRoute(
- RoutePattern.TAG_LIST_FILTER,
- 'handleTagListFilterRoute',
- ctx => this.handleTagListFilterRoute(ctx)
+ this.mapRoute(RoutePattern.TAG_LIST, 'handleTagListRoute', ctx =>
+ this.handleTagListRoute(ctx)
);
this.mapRoute(
@@ -740,22 +800,8 @@ export class GrRouter implements Finalizable, NavigationService {
true
);
- this.mapRoute(
- RoutePattern.REPO_LIST_OFFSET,
- 'handleRepoListOffsetRoute',
- ctx => this.handleRepoListOffsetRoute(ctx)
- );
-
- this.mapRoute(
- RoutePattern.REPO_LIST_FILTER_OFFSET,
- 'handleRepoListFilterOffsetRoute',
- ctx => this.handleRepoListFilterOffsetRoute(ctx)
- );
-
- this.mapRoute(
- RoutePattern.REPO_LIST_FILTER,
- 'handleRepoListFilterRoute',
- ctx => this.handleRepoListFilterRoute(ctx)
+ this.mapRoute(RoutePattern.REPO_LIST, 'handleRepoListRoute', ctx =>
+ this.handleRepoListRoute(ctx)
);
this.mapRoute(RoutePattern.REPO, 'handleRepoRoute', ctx =>
@@ -767,30 +813,16 @@ export class GrRouter implements Finalizable, NavigationService {
);
this.mapRoute(
- RoutePattern.PLUGIN_LIST_OFFSET,
- 'handlePluginListOffsetRoute',
- ctx => this.handlePluginListOffsetRoute(ctx),
- true
- );
-
- this.mapRoute(
- RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
- 'handlePluginListFilterOffsetRoute',
- ctx => this.handlePluginListFilterOffsetRoute(ctx),
- true
- );
-
- this.mapRoute(
- RoutePattern.PLUGIN_LIST_FILTER,
+ RoutePattern.PLUGIN_LIST,
'handlePluginListFilterRoute',
ctx => this.handlePluginListFilterRoute(ctx),
true
);
- this.mapRoute(
- RoutePattern.PLUGIN_LIST,
+ this.mapRouteState(
+ PLUGIN_LIST_ROUTE,
+ this.adminViewModel,
'handlePluginListRoute',
- ctx => this.handlePluginListRoute(ctx),
true
);
@@ -928,7 +960,7 @@ export class GrRouter implements Finalizable, NavigationService {
this.handleDefaultRoute()
);
- page.start();
+ this.page.start(opts);
}
/**
@@ -945,7 +977,7 @@ export class GrRouter implements Finalizable, NavigationService {
// For backward compatibility with GWT links.
if (hash) {
// In certain login flows the server may redirect to a hash without
- // a leading slash, which page.js doesn't handle correctly.
+ // a leading slash, which gr-page doesn't handle correctly.
if (hash[0] !== '/') {
hash = '/' + hash;
}
@@ -983,7 +1015,7 @@ export class GrRouter implements Finalizable, NavigationService {
if (ctx.params[0].toLowerCase() === 'self') {
this.redirectToLogin(ctx.canonicalPath);
} else {
- this.redirect('/q/owner:' + encodeURIComponent(ctx.params[0]));
+ this.redirect('/q/owner:' + encodeURL(ctx.params[0]));
}
} else {
const state: DashboardViewState = {
@@ -1033,25 +1065,13 @@ export class GrRouter implements Finalizable, NavigationService {
return Promise.resolve();
}
- handleProjectDashboardRoute(ctx: PageContext) {
- const project = ctx.params[0] as RepoName;
- const state: DashboardViewState = {
- view: GerritView.DASHBOARD,
- project,
- dashboard: decodeURIComponent(ctx.params[1]) as DashboardId,
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.dashboardViewModel.setState(state);
- this.reporting.setRepoName(project);
- }
-
handleLegacyProjectDashboardRoute(ctx: PageContext) {
this.redirect('/p/' + ctx.params[0] + '/+/dashboard/' + ctx.params[1]);
}
handleGroupInfoRoute(ctx: PageContext) {
- this.redirect('/admin/groups/' + encodeURIComponent(ctx.params[0]));
+ const groupId = ctx.params[0] as GroupId;
+ this.redirect(createGroupUrl({groupId}));
}
handleGroupSelfRedirectRoute(_: PageContext) {
@@ -1090,36 +1110,14 @@ export class GrRouter implements Finalizable, NavigationService {
this.groupViewModel.setState(state);
}
- handleGroupListOffsetRoute(ctx: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.GROUPS,
- offset: ctx.params[1] || 0,
- filter: null,
- openCreateModal: ctx.hash === 'create',
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
- handleGroupListFilterOffsetRoute(ctx: PageContext) {
+ handleGroupListRoute(ctx: PageContext) {
const state: AdminViewState = {
view: GerritView.ADMIN,
adminView: AdminChildView.GROUPS,
- offset: ctx.params['offset'],
- filter: ctx.params['filter'],
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
- handleGroupListFilterRoute(ctx: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.GROUPS,
- filter: ctx.params['filter'] || null,
+ offset: ctx.params[1] || '0',
+ filter: ctx.params[0] ?? null,
+ openCreateModal:
+ !ctx.params[0] && !ctx.params[1] && ctx.hash === 'create',
};
// Note that router model view must be updated before view models.
this.setState(state);
@@ -1135,6 +1133,8 @@ export class GrRouter implements Finalizable, NavigationService {
}
}
+ // TODO: Change the route pattern to match `repo` and `detailView`
+ // separately, and then use `createRepoUrl()` here.
this.redirect(`/admin/repos/${params}`);
}
@@ -1151,12 +1151,15 @@ export class GrRouter implements Finalizable, NavigationService {
this.reporting.setRepoName(repo);
}
- handleRepoGeneralRoute(ctx: PageContext) {
+ handleRepoEditFileRoute(ctx: PageContext) {
const repo = ctx.params[0] as RepoName;
+ const branch = ctx.params[1] as BranchName;
+ const path = ctx.params[2];
const state: RepoViewState = {
view: GerritView.REPO,
- detail: RepoDetailView.GENERAL,
+ detail: RepoDetailView.COMMANDS,
repo,
+ createEdit: {branch, path},
};
// Note that router model view must be updated before view models.
this.setState(state);
@@ -1164,11 +1167,11 @@ export class GrRouter implements Finalizable, NavigationService {
this.reporting.setRepoName(repo);
}
- handleRepoAccessRoute(ctx: PageContext) {
+ handleRepoGeneralRoute(ctx: PageContext) {
const repo = ctx.params[0] as RepoName;
const state: RepoViewState = {
view: GerritView.REPO,
- detail: RepoDetailView.ACCESS,
+ detail: RepoDetailView.GENERAL,
repo,
};
// Note that router model view must be updated before view models.
@@ -1177,11 +1180,11 @@ export class GrRouter implements Finalizable, NavigationService {
this.reporting.setRepoName(repo);
}
- handleRepoDashboardsRoute(ctx: PageContext) {
+ handleRepoAccessRoute(ctx: PageContext) {
const repo = ctx.params[0] as RepoName;
const state: RepoViewState = {
view: GerritView.REPO,
- detail: RepoDetailView.DASHBOARDS,
+ detail: RepoDetailView.ACCESS,
repo,
};
// Note that router model view must be updated before view models.
@@ -1190,112 +1193,53 @@ export class GrRouter implements Finalizable, NavigationService {
this.reporting.setRepoName(repo);
}
- handleBranchListOffsetRoute(ctx: PageContext) {
- const state: RepoViewState = {
- view: GerritView.REPO,
- detail: RepoDetailView.BRANCHES,
- repo: ctx.params[0] as RepoName,
- offset: ctx.params[2] || 0,
- filter: null,
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.repoViewModel.setState(state);
- }
-
- handleBranchListFilterOffsetRoute(ctx: PageContext) {
+ handleRepoDashboardsRoute(ctx: PageContext) {
+ const repo = ctx.params[0] as RepoName;
const state: RepoViewState = {
view: GerritView.REPO,
- detail: RepoDetailView.BRANCHES,
- repo: ctx.params['repo'] as RepoName,
- offset: ctx.params['offset'],
- filter: ctx.params['filter'],
+ detail: RepoDetailView.DASHBOARDS,
+ repo,
};
// Note that router model view must be updated before view models.
this.setState(state);
this.repoViewModel.setState(state);
+ this.reporting.setRepoName(repo);
}
- handleBranchListFilterRoute(ctx: PageContext) {
+ handleBranchListRoute(ctx: PageContext) {
const state: RepoViewState = {
view: GerritView.REPO,
detail: RepoDetailView.BRANCHES,
- repo: ctx.params['repo'] as RepoName,
- filter: ctx.params['filter'] || null,
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.repoViewModel.setState(state);
- }
-
- handleTagListOffsetRoute(ctx: PageContext) {
- const state: RepoViewState = {
- view: GerritView.REPO,
- detail: RepoDetailView.TAGS,
repo: ctx.params[0] as RepoName,
- offset: ctx.params[2] || 0,
- filter: null,
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.repoViewModel.setState(state);
- }
-
- handleTagListFilterOffsetRoute(ctx: PageContext) {
- const state: RepoViewState = {
- view: GerritView.REPO,
- detail: RepoDetailView.TAGS,
- repo: ctx.params['repo'] as RepoName,
- offset: ctx.params['offset'],
- filter: ctx.params['filter'],
+ offset: ctx.params[2] || '0',
+ filter: ctx.params[1] ?? null,
};
// Note that router model view must be updated before view models.
this.setState(state);
this.repoViewModel.setState(state);
}
- handleTagListFilterRoute(ctx: PageContext) {
+ handleTagListRoute(ctx: PageContext) {
const state: RepoViewState = {
view: GerritView.REPO,
detail: RepoDetailView.TAGS,
- repo: ctx.params['repo'] as RepoName,
- filter: ctx.params['filter'] || null,
+ repo: ctx.params[0] as RepoName,
+ offset: ctx.params[2] || '0',
+ filter: ctx.params[1] ?? null,
};
// Note that router model view must be updated before view models.
this.setState(state);
this.repoViewModel.setState(state);
}
- handleRepoListOffsetRoute(ctx: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- offset: ctx.params[1] || 0,
- filter: null,
- openCreateModal: ctx.hash === 'create',
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
- handleRepoListFilterOffsetRoute(ctx: PageContext) {
+ handleRepoListRoute(ctx: PageContext) {
const state: AdminViewState = {
view: GerritView.ADMIN,
adminView: AdminChildView.REPOS,
- offset: ctx.params['offset'],
- filter: ctx.params['filter'],
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
- handleRepoListFilterRoute(ctx: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- filter: ctx.params['filter'] || null,
+ offset: ctx.params[1] || '0',
+ filter: ctx.params[0] ?? null,
+ openCreateModal:
+ !ctx.params[0] && !ctx.params[1] && ctx.hash === 'create',
};
// Note that router model view must be updated before view models.
this.setState(state);
@@ -1318,45 +1262,12 @@ export class GrRouter implements Finalizable, NavigationService {
this.redirect(ctx.path + ',general');
}
- handlePluginListOffsetRoute(ctx: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
- offset: ctx.params[1] || 0,
- filter: null,
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
- handlePluginListFilterOffsetRoute(ctx: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
- offset: ctx.params['offset'],
- filter: ctx.params['filter'],
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
handlePluginListFilterRoute(ctx: PageContext) {
const state: AdminViewState = {
view: GerritView.ADMIN,
adminView: AdminChildView.PLUGINS,
- filter: ctx.params['filter'] || null,
- };
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.adminViewModel.setState(state);
- }
-
- handlePluginListRoute(_: PageContext) {
- const state: AdminViewState = {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
+ offset: ctx.params[1] || '0',
+ filter: ctx.params[0] ?? null,
};
// Note that router model view must be updated before view models.
this.setState(state);
@@ -1364,10 +1275,11 @@ export class GrRouter implements Finalizable, NavigationService {
}
handleQueryRoute(ctx: PageContext) {
- const state: Partial<SearchViewState> = {
+ const state: SearchViewState = {
view: GerritView.SEARCH,
query: ctx.params[0],
- offset: ctx.params[2],
+ offset: ctx.params[2] || '0',
+ loading: false,
};
// Note that router model view must be updated before view models.
this.setState(state as AppElementParams);
@@ -1378,10 +1290,11 @@ export class GrRouter implements Finalizable, NavigationService {
// TODO(pcc): This will need to indicate that this was a change ID query if
// standard queries gain the ability to search places like commit messages
// for change IDs.
- const state: Partial<SearchViewState> = {
+ const state: SearchViewState = {
view: GerritView.SEARCH,
query: ctx.params[0],
- offset: undefined,
+ offset: '0',
+ loading: false,
};
// Note that router model view must be updated before view models.
this.setState(state as AppElementParams);
@@ -1393,25 +1306,30 @@ export class GrRouter implements Finalizable, NavigationService {
}
handleChangeNumberLegacyRoute(ctx: PageContext) {
- this.redirect('/c/' + encodeURIComponent(ctx.params[0]));
+ this.redirect(
+ '/c/' +
+ ctx.params[0] +
+ (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
+ );
}
handleChangeRoute(ctx: PageContext) {
// Parameter order is based on the regex group number matched.
const changeNum = Number(ctx.params[1]) as NumericChangeId;
const state: ChangeViewState = {
- project: ctx.params[0] as RepoName,
+ repo: ctx.params[0] as RepoName,
changeNum,
basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
};
const queryMap = new URLSearchParams(ctx.querystring);
- if (queryMap.has('forceReload')) state.forceReload = true;
if (queryMap.has('openReplyDialog')) state.openReplyDialog = true;
const tab = queryMap.get('tab');
+ if (queryMap.has('forceReload')) state.forceReload = true;
if (tab) state.tab = tab;
const checksPatchset = Number(queryMap.get('checksPatchset'));
if (Number.isInteger(checksPatchset) && checksPatchset > 0) {
@@ -1426,8 +1344,8 @@ export class GrRouter implements Finalizable, NavigationService {
const selected = queryMap.get('checksRunsSelected');
if (selected) state.checksRunsSelected = new Set(selected.split(','));
- assertIsDefined(state.project, 'project');
- this.reporting.setRepoName(state.project);
+ assertIsDefined(state.repo, 'project');
+ this.reporting.setRepoName(state.repo);
this.reporting.setChangeId(changeNum);
this.normalizePatchRangeParams(state);
// Note that router model view must be updated before view models.
@@ -1435,33 +1353,79 @@ export class GrRouter implements Finalizable, NavigationService {
this.changeViewModel.setState(state);
}
- handleCommentRoute(ctx: PageContext) {
+ async handleCommentRoute(ctx: PageContext) {
const changeNum = Number(ctx.params[1]) as NumericChangeId;
- const state: DiffViewState = {
- project: ctx.params[0] as RepoName,
+ const repo = ctx.params[0] as RepoName;
+ const commentId = ctx.params[2] as UrlEncodedCommentId;
+
+ const [comments, robotComments, drafts, change] = await Promise.all([
+ this.restApiService.getDiffComments(changeNum),
+ this.restApiService.getDiffRobotComments(changeNum),
+ this.restApiService.getDiffDrafts(changeNum),
+ this.restApiService.getChangeDetail(changeNum),
+ ]);
+
+ const comment =
+ findComment(addPath(comments), commentId) ??
+ findComment(addPath(robotComments), commentId) ??
+ findComment(addPath(drafts), commentId);
+ const path = comment?.path;
+ const patchsets = computeAllPatchSets(change);
+ const latestPatchNum = computeLatestPatchNum(patchsets);
+ if (!comment || !path || !latestPatchNum) {
+ this.show404();
+ return;
+ }
+ let {basePatchNum, patchNum} = getPatchRangeForCommentUrl(
+ comment,
+ latestPatchNum
+ );
+
+ if (basePatchNum !== PARENT) {
+ const diff = await this.restApiService.getDiff(
+ changeNum,
+ basePatchNum,
+ patchNum,
+ path
+ );
+ if (diff && isFileUnchanged(diff)) {
+ fireAlert(
+ document,
+ `File is unchanged between Patchset ${basePatchNum} and ${patchNum}.
+ Showing diff of Base vs ${basePatchNum}.`
+ );
+ patchNum = basePatchNum as RevisionPatchSetNum;
+ basePatchNum = PARENT;
+ }
+ }
+
+ const diffUrl = createDiffUrl({
changeNum,
- commentId: ctx.params[2] as UrlEncodedCommentId,
- view: GerritView.DIFF,
- commentLink: true,
- };
- this.reporting.setRepoName(state.project ?? '');
- this.reporting.setChangeId(changeNum);
- this.normalizePatchRangeParams(state);
- // Note that router model view must be updated before view models.
- this.setState(state);
- this.diffViewModel.setState(state);
+ repo,
+ patchNum,
+ basePatchNum,
+ diffView: {
+ path,
+ lineNum: comment.line,
+ leftSide: isInBaseOfPatchRange(comment, {basePatchNum, patchNum}),
+ },
+ });
+ this.redirect(diffUrl);
}
handleCommentsRoute(ctx: PageContext) {
const changeNum = Number(ctx.params[1]) as NumericChangeId;
const state: ChangeViewState = {
- project: ctx.params[0] as RepoName,
+ repo: ctx.params[0] as RepoName,
changeNum,
commentId: ctx.params[2] as UrlEncodedCommentId,
view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
};
- assertIsDefined(state.project);
- this.reporting.setRepoName(state.project);
+ const queryMap = new URLSearchParams(ctx.querystring);
+ if (queryMap.has('forceReload')) state.forceReload = true;
+ assertIsDefined(state.repo);
+ this.reporting.setRepoName(state.repo);
this.reporting.setChangeId(changeNum);
this.normalizePatchRangeParams(state);
// Note that router model view must be updated before view models.
@@ -1472,25 +1436,28 @@ export class GrRouter implements Finalizable, NavigationService {
handleDiffRoute(ctx: PageContext) {
const changeNum = Number(ctx.params[1]) as NumericChangeId;
// Parameter order is based on the regex group number matched.
- const state: DiffViewState = {
- project: ctx.params[0] as RepoName,
+ const state: ChangeViewState = {
+ repo: ctx.params[0] as RepoName,
changeNum,
basePatchNum: convertToPatchSetNum(ctx.params[4]) as BasePatchSetNum,
patchNum: convertToPatchSetNum(ctx.params[6]) as RevisionPatchSetNum,
- path: ctx.params[8],
- view: GerritView.DIFF,
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.DIFF,
+ diffView: {path: ctx.params[8]},
};
+ const queryMap = new URLSearchParams(ctx.querystring);
+ if (queryMap.has('forceReload')) state.forceReload = true;
const address = this.parseLineAddress(ctx.hash);
if (address) {
- state.leftSide = address.leftSide;
- state.lineNum = address.lineNum;
+ state.diffView!.leftSide = address.leftSide;
+ state.diffView!.lineNum = address.lineNum;
}
- this.reporting.setRepoName(state.project ?? '');
+ this.reporting.setRepoName(state.repo ?? '');
this.reporting.setChangeId(changeNum);
this.normalizePatchRangeParams(state);
// Note that router model view must be updated before view models.
this.setState(state);
- this.diffViewModel.setState(state);
+ this.changeViewModel.setState(state);
}
handleChangeLegacyRoute(ctx: PageContext) {
@@ -1506,7 +1473,10 @@ export class GrRouter implements Finalizable, NavigationService {
this.show404();
return;
}
- this.redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
+ this.redirect(
+ `/c/${project}/+/${changeNum}/${ctx.params[1]}` +
+ (ctx.querystring.length > 0 ? `?${ctx.querystring}` : '')
+ );
});
}
@@ -1518,19 +1488,21 @@ export class GrRouter implements Finalizable, NavigationService {
// Parameter order is based on the regex group number matched.
const project = ctx.params[0] as RepoName;
const changeNum = Number(ctx.params[1]) as NumericChangeId;
- const state: EditViewState = {
- project,
+ const state: ChangeViewState = {
+ repo: project,
changeNum,
// for edit view params, patchNum cannot be undefined
patchNum: convertToPatchSetNum(ctx.params[2]) as RevisionPatchSetNum,
- path: ctx.params[3],
- lineNum: Number(ctx.hash),
- view: GerritView.EDIT,
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.EDIT,
+ editView: {path: ctx.params[3], lineNum: Number(ctx.hash)},
};
+ const queryMap = new URLSearchParams(ctx.querystring);
+ if (queryMap.has('forceReload')) state.forceReload = true;
this.normalizePatchRangeParams(state);
// Note that router model view must be updated before view models.
this.setState(state);
- this.editViewModel.setState(state);
+ this.changeViewModel.setState(state);
this.reporting.setRepoName(project);
this.reporting.setChangeId(changeNum);
}
@@ -1541,22 +1513,16 @@ export class GrRouter implements Finalizable, NavigationService {
const changeNum = Number(ctx.params[1]) as NumericChangeId;
const queryMap = new URLSearchParams(ctx.querystring);
const state: ChangeViewState = {
- project,
+ repo: project,
changeNum,
patchNum: convertToPatchSetNum(ctx.params[3]) as RevisionPatchSetNum,
view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
edit: true,
};
const tab = queryMap.get('tab');
if (tab) state.tab = tab;
- if (queryMap.has('forceReload')) {
- state.forceReload = true;
- history.replaceState(
- null,
- '',
- location.href.replace(/[?&]forceReload=true/, '')
- );
- }
+ if (queryMap.has('forceReload')) state.forceReload = true;
this.normalizePatchRangeParams(state);
// Note that router model view must be updated before view models.
this.setState(state);
@@ -1648,7 +1614,7 @@ export class GrRouter implements Finalizable, NavigationService {
handleDocumentationSearchRoute(ctx: PageContext) {
const state: DocumentationViewState = {
view: GerritView.DOCUMENTATION_SEARCH,
- filter: ctx.params['filter'] || null,
+ filter: ctx.params[0] ?? '',
};
// Note that router model view must be updated before view models.
this.setState(state);
@@ -1656,9 +1622,7 @@ export class GrRouter implements Finalizable, NavigationService {
}
handleDocumentationSearchRedirectRoute(ctx: PageContext) {
- this.redirect(
- '/Documentation/q/filter:' + encodeURIComponent(ctx.params[0])
- );
+ this.redirect('/Documentation/q/filter:' + encodeURL(ctx.params[0]));
}
handleDocumentationRedirectRoute(ctx: PageContext) {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
index d1d0c86511..234bf95bc3 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.ts
@@ -5,41 +5,64 @@
*/
import '../../../test/common-test-setup';
import './gr-router';
-import {page, PageContext} from '../../../utils/page-wrapper-utils';
+import {Page, PageContext} from './gr-page';
import {
stubBaseUrl,
stubRestApi,
addListenerForTest,
- waitEventLoop,
+ waitUntilCalled,
+ mockPromise,
+ MockPromise,
} from '../../../test/test-utils';
-import {GrRouter, routerToken, _testOnly_RoutePattern} from './gr-router';
+import {GrRouter, routerToken} from './gr-router';
import {GerritView} from '../../../services/router/router-model';
import {
BasePatchSetNum,
- GroupId,
NumericChangeId,
PARENT,
RepoName,
RevisionPatchSetNum,
UrlEncodedCommentId,
} from '../../../types/common';
-import {AppElementParams} from '../../gr-app-types';
+import {AppElementJustRegisteredParams} from '../../gr-app-types';
import {assert} from '@open-wc/testing';
-import {AdminChildView} from '../../../models/views/admin';
+import {AdminChildView, AdminViewState} from '../../../models/views/admin';
import {RepoDetailView} from '../../../models/views/repo';
import {GroupDetailView} from '../../../models/views/group';
-import {EditViewState} from '../../../models/views/edit';
-import {ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
import {PatchRangeParams} from '../../../utils/url-util';
-import {DependencyRequestEvent} from '../../../models/dependency';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ createAdminPluginsViewState,
+ createAdminReposViewState,
+ createChangeViewState,
+ createComment,
+ createDashboardViewState,
+ createDiff,
+ createDiffViewState,
+ createEditViewState,
+ createGroupViewState,
+ createParsedChange,
+ createRepoBranchesViewState,
+ createRepoTagsViewState,
+ createRepoViewState,
+ createRevision,
+ createSearchViewState,
+} from '../../../test/test-data-generators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {ViewState} from '../../../models/views/base';
suite('gr-router tests', () => {
let router: GrRouter;
+ let page: Page;
setup(() => {
- document.dispatchEvent(
- new DependencyRequestEvent(routerToken, x => (router = x))
- );
+ router = testResolver(routerToken);
+ page = router.page;
+ });
+
+ teardown(async () => {
+ router.finalize();
});
test('getHashFromCanonicalPath', () => {
@@ -97,14 +120,13 @@ suite('gr-router tests', () => {
});
});
- test('startRouter requires auth for the right handlers', () => {
+ test('startRouterForTesting requires auth for the right handlers', () => {
// This test encodes the lists of route handler methods that gr-router
// automatically checks for authentication before triggering.
const requiresAuth: any = {};
const doesNotRequireAuth: any = {};
sinon.stub(page, 'start');
- sinon.stub(page, 'base');
sinon
.stub(router, 'mapRoute')
.callsFake((_pattern, methodName, _method, usesAuth) => {
@@ -114,7 +136,7 @@ suite('gr-router tests', () => {
doesNotRequireAuth[methodName] = true;
}
});
- router.startRouter();
+ router._testOnly_startRouter();
const actualRequiresAuth = Object.keys(requiresAuth);
actualRequiresAuth.sort();
@@ -129,27 +151,22 @@ suite('gr-router tests', () => {
'handleDiffEditRoute',
'handleGroupAuditLogRoute',
'handleGroupInfoRoute',
- 'handleGroupListFilterOffsetRoute',
- 'handleGroupListFilterRoute',
- 'handleGroupListOffsetRoute',
+ 'handleGroupListRoute',
'handleGroupMembersRoute',
'handleGroupRoute',
'handleGroupSelfRedirectRoute',
'handleNewAgreementsRoute',
- 'handlePluginListFilterOffsetRoute',
'handlePluginListFilterRoute',
- 'handlePluginListOffsetRoute',
'handlePluginListRoute',
'handleRepoCommandsRoute',
+ 'handleRepoEditFileRoute',
'handleSettingsLegacyRoute',
'handleSettingsRoute',
];
assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
const unauthenticatedHandlers = [
- 'handleBranchListFilterOffsetRoute',
- 'handleBranchListFilterRoute',
- 'handleBranchListOffsetRoute',
+ 'handleBranchListRoute',
'handleChangeIdQueryRoute',
'handleChangeNumberLegacyRoute',
'handleChangeRoute',
@@ -170,16 +187,12 @@ suite('gr-router tests', () => {
'handleRepoAccessRoute',
'handleRepoDashboardsRoute',
'handleRepoGeneralRoute',
- 'handleRepoListFilterOffsetRoute',
- 'handleRepoListFilterRoute',
- 'handleRepoListOffsetRoute',
+ 'handleRepoListRoute',
'handleRepoRoute',
'handleQueryLegacySuffixRoute',
'handleQueryRoute',
'handleRegisterRoute',
- 'handleTagListFilterOffsetRoute',
- 'handleTagListFilterRoute',
- 'handleTagListOffsetRoute',
+ 'handleTagListRoute',
'handlePluginScreen',
];
@@ -200,20 +213,8 @@ suite('gr-router tests', () => {
test('redirectIfNotLoggedIn while logged in', () => {
stubRestApi('getLoggedIn').returns(Promise.resolve(true));
- const ctx = {
- save() {},
- handled: true,
- canonicalPath: '',
- path: '',
- querystring: '',
- pathname: '',
- state: '',
- title: '',
- hash: '',
- params: {test: 'test'},
- };
const redirectStub = sinon.stub(router, 'redirectToLogin');
- return router.redirectIfNotLoggedIn(ctx).then(() => {
+ return router.redirectIfNotLoggedIn('somepath').then(() => {
assert.isFalse(redirectStub.called);
});
});
@@ -221,21 +222,9 @@ suite('gr-router tests', () => {
test('redirectIfNotLoggedIn while logged out', () => {
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
const redirectStub = sinon.stub(router, 'redirectToLogin');
- const ctx = {
- save() {},
- handled: true,
- canonicalPath: '',
- path: '',
- querystring: '',
- pathname: '',
- state: '',
- title: '',
- hash: '',
- params: {test: 'test'},
- };
return new Promise(resolve => {
router
- .redirectIfNotLoggedIn(ctx)
+ .redirectIfNotLoggedIn('somepath')
.then(() => {
assert.isTrue(false, 'Should never execute');
})
@@ -267,85 +256,166 @@ suite('gr-router tests', () => {
});
});
+ suite('navigation blockers', () => {
+ let clock: sinon.SinonFakeTimers;
+ let redirectStub: sinon.SinonStub;
+ let urlPromise: MockPromise<string>;
+
+ setup(() => {
+ stubRestApi('setInProjectLookup');
+ urlPromise = mockPromise<string>();
+ redirectStub = sinon
+ .stub(router, 'redirect')
+ .callsFake(urlPromise.resolve);
+ router._testOnly_startRouter();
+ clock = sinon.useFakeTimers();
+ });
+
+ test('no blockers: normal redirect', async () => {
+ router.page.show('/settings/agreements');
+ const url = await urlPromise;
+ assert.isTrue(redirectStub.calledOnce);
+ assert.equal(url, '/settings/#Agreements');
+ });
+
+ test('redirect blocked', async () => {
+ const firstAlertPromise = mockPromise<Event>();
+ addListenerForTest(document, 'show-alert', firstAlertPromise.resolve);
+
+ router.blockNavigation('a good reason');
+ router.page.show('/settings/agreements');
+
+ const firstAlert = (await firstAlertPromise) as CustomEvent;
+ assert.equal(
+ firstAlert.detail.message,
+ 'Waiting 1 second for navigation blockers to resolve ...'
+ );
+
+ const secondAlertPromise = mockPromise<Event>();
+ addListenerForTest(document, 'show-alert', secondAlertPromise.resolve);
+
+ clock.tick(2000);
+
+ const secondAlert = (await secondAlertPromise) as CustomEvent;
+ assert.equal(
+ secondAlert.detail.message,
+ 'Navigation is blocked by: a good reason'
+ );
+
+ assert.isFalse(redirectStub.called);
+ });
+
+ test('redirect blocked, but resolved within one second', async () => {
+ const firstAlertPromise = mockPromise<Event>();
+ addListenerForTest(document, 'show-alert', firstAlertPromise.resolve);
+
+ router.blockNavigation('a good reason');
+ router.page.show('/settings/agreements');
+
+ const firstAlert = (await firstAlertPromise) as CustomEvent;
+ assert.equal(
+ firstAlert.detail.message,
+ 'Waiting 1 second for navigation blockers to resolve ...'
+ );
+
+ const secondAlertPromise = mockPromise<Event>();
+ addListenerForTest(document, 'show-alert', secondAlertPromise.resolve);
+
+ clock.tick(500);
+ router.releaseNavigation('a good reason');
+ clock.tick(2000);
+
+ await urlPromise;
+ assert.isTrue(redirectStub.calledOnce);
+ });
+ });
+
suite('route handlers', () => {
let redirectStub: sinon.SinonStub;
let setStateStub: sinon.SinonStub;
let handlePassThroughRoute: sinon.SinonStub;
+ let redirectToLoginStub: sinon.SinonStub;
- // Simple route handlers are direct mappings from parsed route ctx to a
- // new set of app.params. This test helper asserts that passing `ctx`
- // into `methodName` results in setting the params specified in `params`.
- function assertctxToParams(
- ctx: PageContext,
- methodName: string,
- params: AppElementParams
+ async function checkUrlToState<T extends ViewState>(
+ url: string,
+ state: T | AppElementJustRegisteredParams
) {
- (router as any)[methodName](ctx);
- assert.deepEqual(setStateStub.lastCall.args[0], params);
+ setStateStub.reset();
+ router.page.show(url);
+ await waitUntilCalled(setStateStub, 'setState');
+ assert.isTrue(setStateStub.calledOnce);
+ assert.deepEqual(setStateStub.lastCall.firstArg, state);
}
- function createPageContext(): PageContext {
- return {
- canonicalPath: '',
- path: '',
- querystring: '',
- pathname: '',
- hash: '',
- params: {},
- };
+ async function checkRedirect(fromUrl: string, toUrl: string) {
+ redirectStub.reset();
+ router.page.show(fromUrl);
+ await waitUntilCalled(redirectStub, 'redirect');
+ assert.isTrue(redirectStub.calledOnce);
+ assert.isFalse(setStateStub.called);
+ assert.equal(redirectStub.lastCall.firstArg, toUrl);
+ }
+
+ async function checkRedirectToLogin(fromUrl: string, toUrl: string) {
+ redirectToLoginStub.reset();
+ router.page.show(fromUrl);
+ await waitUntilCalled(redirectToLoginStub, 'redirectToLogin');
+ assert.isTrue(redirectToLoginStub.calledOnce);
+ assert.isFalse(redirectStub.called);
+ assert.isFalse(setStateStub.called);
+ assert.equal(redirectToLoginStub.lastCall.firstArg, toUrl);
+ }
+
+ async function checkUrlNotMatched(url: string) {
+ handlePassThroughRoute.reset();
+ router.page.show(url);
+ await waitUntilCalled(handlePassThroughRoute, 'handlePassThroughRoute');
}
setup(() => {
+ stubRestApi('setInProjectLookup');
redirectStub = sinon.stub(router, 'redirect');
+ redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
setStateStub = sinon.stub(router, 'setState');
handlePassThroughRoute = sinon.stub(router, 'handlePassThroughRoute');
+ router._testOnly_startRouter();
});
- test('handleLegacyProjectDashboardRoute', () => {
- const params = {
- ...createPageContext(),
- params: {0: 'gerrit/project', 1: 'dashboard:main'},
- };
- router.handleLegacyProjectDashboardRoute(params);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(
- redirectStub.lastCall.args[0],
+ test('LEGACY_PROJECT_DASHBOARD', async () => {
+ // LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+ await checkRedirect(
+ '/projects/gerrit/project,dashboards/dashboard:main',
'/p/gerrit/project/+/dashboard/dashboard:main'
);
});
- test('handleAgreementsRoute', () => {
- router.handleAgreementsRoute();
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/settings/#Agreements');
+ test('AGREEMENTS', async () => {
+ // AGREEMENTS: /^\/settings\/agreements\/?/,
+ await checkRedirect('/settings/agreements', '/settings/#Agreements');
});
- test('handleNewAgreementsRoute', () => {
- router.handleNewAgreementsRoute();
- assert.isTrue(setStateStub.calledOnce);
- assert.equal(setStateStub.lastCall.args[0].view, GerritView.AGREEMENTS);
- });
-
- test('handleSettingsLegacyRoute', () => {
- const ctx = {...createPageContext(), params: {0: 'my-token'}};
- assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
- view: GerritView.SETTINGS,
- emailToken: 'my-token',
+ test('NEW_AGREEMENTS', async () => {
+ // NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+ await checkUrlToState('/settings/new-agreement', {
+ view: GerritView.AGREEMENTS,
+ });
+ await checkUrlToState('/settings/new-agreement/', {
+ view: GerritView.AGREEMENTS,
});
});
- test('handleSettingsLegacyRoute with +', () => {
- const ctx = {...createPageContext(), params: {0: 'my-token test'}};
- assertctxToParams(ctx, 'handleSettingsLegacyRoute', {
+ test('SETTINGS', async () => {
+ // SETTINGS: /^\/settings\/?/,
+ // SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+ await checkUrlToState('/settings', {view: GerritView.SETTINGS});
+ await checkUrlToState('/settings/', {view: GerritView.SETTINGS});
+ await checkUrlToState('/settings/VE/asdf', {
view: GerritView.SETTINGS,
- emailToken: 'my-token+test',
+ emailToken: 'asdf',
});
- });
-
- test('handleSettingsRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handleSettingsRoute', {
+ await checkUrlToState('/settings/VE/asdf%40qwer', {
view: GerritView.SETTINGS,
+ emailToken: 'asdf@qwer',
});
});
@@ -365,10 +435,9 @@ suite('gr-router tests', () => {
) => {
onExit = _onExit;
};
- sinon.stub(page, 'exit').callsFake(onRegisteringExit);
+ sinon.stub(page, 'registerExitRoute').callsFake(onRegisteringExit);
sinon.stub(page, 'start');
- sinon.stub(page, 'base');
- router.startRouter();
+ router._testOnly_startRouter();
router.handleDefaultRoute();
@@ -378,90 +447,76 @@ suite('gr-router tests', () => {
assert.isTrue(handlePassThroughRoute.calledOnce);
});
- test('handleImproperlyEncodedPlusRoute', () => {
- const params = {
- ...createPageContext(),
- canonicalPath: '/c/test/%20/42',
- params: {0: 'test', 1: '42'},
- };
- // Regression test for Issue 7100.
- router.handleImproperlyEncodedPlusRoute(params);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42');
-
- sinon.stub(router, 'getHashFromCanonicalPath').returns('foo');
- router.handleImproperlyEncodedPlusRoute(params);
- assert.equal(redirectStub.lastCall.args[0], '/c/test/+/42#foo');
+ test('IMPROPERLY_ENCODED_PLUS', async () => {
+ // IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
+ await checkRedirect('/c/repo/ /42', '/c/repo/+/42');
+ await checkRedirect('/c/repo/%20/42', '/c/repo/+/42');
+ await checkRedirect('/c/repo/ /42#foo', '/c/repo/+/42#foo');
});
- test('handleQueryRoute', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- params: {0: 'project:foo/bar/baz'},
- };
- assertctxToParams(ctx, 'handleQueryRoute', {
- view: GerritView.SEARCH,
- query: 'project:foo/bar/baz',
- offset: undefined,
- } as AppElementParams);
-
- ctx.params[1] = '123';
- ctx.params[2] = '123';
- assertctxToParams(ctx, 'handleQueryRoute', {
- view: GerritView.SEARCH,
+ test('QUERY', async () => {
+ // QUERY: /^\/q\/(.+?)(,(\d+))?$/,
+ await checkUrlToState('/q/asdf', {
+ ...createSearchViewState(),
+ query: 'asdf',
+ });
+ await checkUrlToState('/q/project:foo/bar/baz', {
+ ...createSearchViewState(),
query: 'project:foo/bar/baz',
+ });
+ await checkUrlToState('/q/asdf,123', {
+ ...createSearchViewState(),
+ query: 'asdf',
+ offset: '123',
+ });
+ await checkUrlToState('/q/asdf,qwer', {
+ ...createSearchViewState(),
+ query: 'asdf,qwer',
+ });
+ await checkUrlToState('/q/asdf,qwer,123', {
+ ...createSearchViewState(),
+ query: 'asdf,qwer',
offset: '123',
- } as AppElementParams);
+ });
});
- test('handleQueryLegacySuffixRoute', () => {
- const params = {...createPageContext(), path: '/q/foo+bar,n,z'};
- router.handleQueryLegacySuffixRoute(params);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+ test('QUERY_LEGACY_SUFFIX', async () => {
+ // QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+ await checkRedirect('/q/foo+bar,n,z', '/q/foo+bar');
});
- test('handleChangeIdQueryRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {0: 'I0123456789abcdef0123456789abcdef01234567'},
- };
- assertctxToParams(ctx, 'handleChangeIdQueryRoute', {
- view: GerritView.SEARCH,
+ test('CHANGE_ID_QUERY', async () => {
+ // CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+ await checkUrlToState('/id/I0123456789abcdef0123456789abcdef01234567', {
+ ...createSearchViewState(),
query: 'I0123456789abcdef0123456789abcdef01234567',
- offset: undefined,
- } as AppElementParams);
+ });
});
- suite('handleRegisterRoute', () => {
- test('happy path', () => {
- const ctx = {...createPageContext(), params: {0: '/foo/bar'}};
- router.handleRegisterRoute(ctx);
- assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
- assert.isTrue(setStateStub.calledOnce);
- assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
+ test('REGISTER', async () => {
+ // REGISTER: /^\/register(\/.*)?$/,
+ await checkUrlToState('/register/foo/bar', {
+ justRegistered: true,
});
+ assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
- test('no param', () => {
- const ctx = createPageContext();
- router.handleRegisterRoute(ctx);
- assert.isTrue(redirectStub.calledWithExactly('/'));
- assert.isTrue(setStateStub.calledOnce);
- assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
+ await checkUrlToState('/register', {
+ justRegistered: true,
});
+ assert.isTrue(redirectStub.calledWithExactly('/'));
- test('prevent redirect', () => {
- const ctx = {...createPageContext(), params: {0: '/register'}};
- router.handleRegisterRoute(ctx);
- assert.isTrue(redirectStub.calledWithExactly('/'));
- assert.isTrue(setStateStub.calledOnce);
- assert.isTrue(setStateStub.lastCall.args[0].justRegistered);
+ await checkUrlToState('/register/register', {
+ justRegistered: true,
});
+ assert.isTrue(redirectStub.calledWithExactly('/'));
});
- suite('handleRootRoute', () => {
+ suite('ROOT', () => {
test('closes for closeAfterLogin', () => {
- const ctx = {...createPageContext(), querystring: 'closeAfterLogin'};
+ const ctx = {
+ querystring: 'closeAfterLogin',
+ canonicalPath: '',
+ } as PageContext;
const closeStub = sinon.stub(window, 'close');
const result = router.handleRootRoute(ctx);
assert.isNotOk(result);
@@ -469,870 +524,566 @@ suite('gr-router tests', () => {
assert.isFalse(redirectStub.called);
});
- test('redirects to dashboard if logged in', () => {
- const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
- const result = router.handleRootRoute(ctx);
- assert.isOk(result);
- return result!.then(() => {
- assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
- });
+ test('ROOT logged in', async () => {
+ stubRestApi('getLoggedIn').resolves(true);
+ await checkRedirect('/', '/dashboard/self');
});
- test('redirects to open changes if not logged in', () => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- const ctx = {...createPageContext(), canonicalPath: '/', path: '/'};
- const result = router.handleRootRoute(ctx);
- assert.isOk(result);
- return result!.then(() => {
- assert.isTrue(
- redirectStub.calledWithExactly('/q/status:open+-is:wip')
- );
- });
+ test('ROOT not logged in', async () => {
+ stubRestApi('getLoggedIn').resolves(false);
+ await checkRedirect('/', '/q/status:open+-is:wip');
});
- suite('GWT hash-path URLs', () => {
- test('redirects hash-path URLs', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/#/foo/bar/baz',
- hash: '/foo/bar/baz',
- };
- const result = router.handleRootRoute(ctx);
- assert.isNotOk(result);
- assert.isTrue(redirectStub.called);
- assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+ suite('ROOT GWT hash-path URLs', () => {
+ test('ROOT hash-path URLs', async () => {
+ await checkRedirect('/#/foo/bar/baz', '/foo/bar/baz');
});
- test('redirects hash-path URLs w/o leading slash', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/#foo/bar/baz',
- hash: 'foo/bar/baz',
- };
- const result = router.handleRootRoute(ctx);
- assert.isNotOk(result);
- assert.isTrue(redirectStub.called);
- assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+ test('ROOT hash-path URLs w/o leading slash', async () => {
+ await checkRedirect('/#foo/bar/baz', '/foo/bar/baz');
});
- test('normalizes "/ /" in hash to "/+/"', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/#/foo/bar/+/123/4',
- hash: '/foo/bar/ /123/4',
- };
- const result = router.handleRootRoute(ctx);
- assert.isNotOk(result);
- assert.isTrue(redirectStub.called);
- assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+ test('ROOT normalizes "/ /" in hash to "/+/"', async () => {
+ await checkRedirect('/#/foo/bar/+/123/4', '/foo/bar/+/123/4');
});
- test('prepends baseurl to hash-path', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/#/foo/bar',
- hash: '/foo/bar',
- };
+ test('ROOT prepends baseurl to hash-path', async () => {
stubBaseUrl('/baz');
- const result = router.handleRootRoute(ctx);
- assert.isNotOk(result);
- assert.isTrue(redirectStub.called);
- assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+ await checkRedirect('/#/foo/bar', '/baz/foo/bar');
});
- test('normalizes /VE/ settings hash-paths', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/#/VE/foo/bar',
- hash: '/VE/foo/bar',
- };
- const result = router.handleRootRoute(ctx);
- assert.isNotOk(result);
- assert.isTrue(redirectStub.called);
- assert.isTrue(redirectStub.calledWithExactly('/settings/VE/foo/bar'));
+ test('ROOT normalizes /VE/ settings hash-paths', async () => {
+ await checkRedirect('/#/VE/foo/bar', '/settings/VE/foo/bar');
});
- test('does not drop "inner hashes"', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/#/foo/bar#baz',
- hash: '/foo/bar',
- };
- const result = router.handleRootRoute(ctx);
- assert.isNotOk(result);
- assert.isTrue(redirectStub.called);
- assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+ test('ROOT does not drop "inner hashes"', async () => {
+ await checkRedirect('/#/foo/bar#baz', '/foo/bar#baz');
});
});
});
- suite('handleDashboardRoute', () => {
- let redirectToLoginStub: sinon.SinonStub;
-
- setup(() => {
- redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+ suite('DASHBOARD', () => {
+ test('DASHBOARD own dashboard but signed out redirects to login', async () => {
+ stubRestApi('getLoggedIn').resolves(false);
+ await checkRedirectToLogin('/dashboard/seLF', '/dashboard/seLF');
});
- test('own dashboard but signed out redirects to login', () => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: 'seLF'},
- };
- return router.handleDashboardRoute(ctx).then(() => {
- assert.isTrue(redirectToLoginStub.calledOnce);
- assert.isFalse(redirectStub.called);
- assert.isFalse(setStateStub.called);
- });
+ test('DASHBOARD non-self dashboard but signed out redirects', async () => {
+ stubRestApi('getLoggedIn').resolves(false);
+ await checkRedirect('/dashboard/foo', '/q/owner:foo');
});
- test('non-self dashboard but signed out does not redirect', () => {
- stubRestApi('getLoggedIn').returns(Promise.resolve(false));
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: 'foo'},
- };
- return router.handleDashboardRoute(ctx).then(() => {
- assert.isFalse(redirectToLoginStub.called);
- assert.isFalse(setStateStub.called);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
- });
- });
-
- test('dashboard while signed in sets params', () => {
- const ctx = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: 'foo'},
- };
- return router.handleDashboardRoute(ctx).then(() => {
- assert.isFalse(redirectToLoginStub.called);
- assert.isFalse(redirectStub.called);
- assert.isTrue(setStateStub.calledOnce);
- assert.deepEqual(setStateStub.lastCall.args[0], {
- view: GerritView.DASHBOARD,
- user: 'foo',
- });
+ test('DASHBOARD', async () => {
+ // DASHBOARD: /^\/dashboard\/(.+)$/,
+ await checkUrlToState('/dashboard/foo', {
+ ...createDashboardViewState(),
+ user: 'foo',
});
});
});
- suite('handleCustomDashboardRoute', () => {
- let redirectToLoginStub: sinon.SinonStub;
-
- setup(() => {
- redirectToLoginStub = sinon.stub(router, 'redirectToLogin');
+ suite('CUSTOM_DASHBOARD', () => {
+ test('CUSTOM_DASHBOARD no user specified', async () => {
+ await checkRedirect('/dashboard/', '/dashboard/self');
});
- test('no user specified', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: ''},
- querystring: '',
- };
- return router.handleCustomDashboardRoute(ctx).then(() => {
- assert.isFalse(setStateStub.called);
- assert.isTrue(redirectStub.called);
- assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+ test('CUSTOM_DASHBOARD', async () => {
+ // CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+ await checkUrlToState('/dashboard?title=Custom Dashboard&a=b&d=e', {
+ ...createDashboardViewState(),
+ sections: [
+ {name: 'a', query: 'b'},
+ {name: 'd', query: 'e'},
+ ],
+ title: 'Custom Dashboard',
});
- });
-
- test('custom dashboard without title', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: ''},
- querystring: '?a=b&c&d=e',
- };
- return router.handleCustomDashboardRoute(ctx).then(() => {
- assert.isFalse(redirectStub.called);
- assert.isTrue(setStateStub.calledOnce);
- assert.deepEqual(setStateStub.lastCall.args[0], {
- view: GerritView.DASHBOARD,
- user: 'self',
- sections: [
- {name: 'a', query: 'b'},
- {name: 'd', query: 'e'},
- ],
- title: 'Custom Dashboard',
- });
- });
- });
-
- test('custom dashboard with title', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: ''},
- querystring: '?a=b&c&d=&=e&title=t',
- };
- return router.handleCustomDashboardRoute(ctx).then(() => {
- assert.isFalse(redirectToLoginStub.called);
- assert.isFalse(redirectStub.called);
- assert.isTrue(setStateStub.calledOnce);
- assert.deepEqual(setStateStub.lastCall.args[0], {
- view: GerritView.DASHBOARD,
- user: 'self',
- sections: [{name: 'a', query: 'b'}],
- title: 't',
- });
- });
- });
-
- test('custom dashboard with foreach', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- canonicalPath: '/dashboard/',
- params: {0: ''},
- querystring: '?a=b&c&d=&=e&foreach=is:open',
- };
- return router.handleCustomDashboardRoute(ctx).then(() => {
- assert.isFalse(redirectToLoginStub.called);
- assert.isFalse(redirectStub.called);
- assert.isTrue(setStateStub.calledOnce);
- assert.deepEqual(setStateStub.lastCall.args[0], {
- view: GerritView.DASHBOARD,
- user: 'self',
- sections: [{name: 'a', query: 'is:open b'}],
- title: 'Custom Dashboard',
- });
+ await checkUrlToState('/dashboard?a=b&c&d=&=e&foreach=is:open', {
+ ...createDashboardViewState(),
+ sections: [{name: 'a', query: 'is:open b'}],
+ title: 'Custom Dashboard',
});
});
});
suite('group routes', () => {
- test('handleGroupInfoRoute', () => {
- const ctx = {...createPageContext(), params: {0: '1234'}};
- router.handleGroupInfoRoute(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+ test('GROUP_INFO', async () => {
+ // GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+ await checkRedirect('/admin/groups/1234,info', '/admin/groups/1234');
});
- test('handleGroupAuditLogRoute', () => {
- const ctx = {...createPageContext(), params: {0: '1234'}};
- assertctxToParams(ctx, 'handleGroupAuditLogRoute', {
- view: GerritView.GROUP,
+ test('GROUP_AUDIT_LOG', async () => {
+ // GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+ await checkUrlToState('/admin/groups/1234,audit-log', {
+ ...createGroupViewState(),
detail: GroupDetailView.LOG,
- groupId: '1234' as GroupId,
+ groupId: '1234',
});
});
- test('handleGroupMembersRoute', () => {
- const ctx = {...createPageContext(), params: {0: '1234'}};
- assertctxToParams(ctx, 'handleGroupMembersRoute', {
- view: GerritView.GROUP,
+ test('GROUP_MEMBERS', async () => {
+ // GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+ await checkUrlToState('/admin/groups/1234,members', {
+ ...createGroupViewState(),
detail: GroupDetailView.MEMBERS,
- groupId: '1234' as GroupId,
+ groupId: '1234',
});
});
- test('handleGroupListOffsetRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
+ test('GROUP_LIST', async () => {
+ // GROUP_LIST: /^\/admin\/groups(\/q\/filter:(.*?))?(,(\d+))?(\/)?$/,
+
+ const defaultState: AdminViewState = {
view: GerritView.ADMIN,
adminView: AdminChildView.GROUPS,
- offset: 0,
- filter: null,
+ offset: '0',
openCreateModal: false,
- });
+ filter: '',
+ };
- ctx.params[1] = '42';
- assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.GROUPS,
+ await checkUrlToState('/admin/groups', defaultState);
+ await checkUrlToState('/admin/groups/', defaultState);
+ await checkUrlToState('/admin/groups#create', {
+ ...defaultState,
+ openCreateModal: true,
+ });
+ await checkUrlToState('/admin/groups,42', {
+ ...defaultState,
offset: '42',
- filter: null,
- openCreateModal: false,
});
-
- ctx.hash = 'create';
- assertctxToParams(ctx, 'handleGroupListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.GROUPS,
+ // #create is ignored when there is an offset
+ await checkUrlToState('/admin/groups,42#create', {
+ ...defaultState,
offset: '42',
- filter: null,
- openCreateModal: true,
});
- });
- test('handleGroupListFilterOffsetRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {filter: 'foo', offset: '42'},
- };
- assertctxToParams(ctx, 'handleGroupListFilterOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.GROUPS,
- offset: '42',
+ await checkUrlToState('/admin/groups/q/filter:foo', {
+ ...defaultState,
filter: 'foo',
});
- });
-
- test('handleGroupListFilterRoute', () => {
- const ctx = {...createPageContext(), params: {filter: 'foo'}};
- assertctxToParams(ctx, 'handleGroupListFilterRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.GROUPS,
+ await checkUrlToState('/admin/groups/q/filter:foo/%2F%20%2525%252F', {
+ ...defaultState,
+ filter: 'foo// %/',
+ });
+ await checkUrlToState('/admin/groups/q/filter:foo,42', {
+ ...defaultState,
filter: 'foo',
+ offset: '42',
+ });
+ // #create is ignored when filtering
+ await checkUrlToState('/admin/groups/q/filter:foo,42#create', {
+ ...defaultState,
+ filter: 'foo',
+ offset: '42',
});
});
- test('handleGroupRoute', () => {
- const ctx = {...createPageContext(), params: {0: '4321'}};
- assertctxToParams(ctx, 'handleGroupRoute', {
- view: GerritView.GROUP,
- groupId: '4321' as GroupId,
+ test('GROUP', async () => {
+ // GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+ await checkUrlToState('/admin/groups/4321', {
+ ...createGroupViewState(),
+ groupId: '4321',
});
});
});
- suite('repo routes', () => {
- test('handleProjectsOldRoute', () => {
- const ctx = {...createPageContext(), params: {}};
- router.handleProjectsOldRoute(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/admin/repos/');
- });
-
- test('handleProjectsOldRoute test', () => {
- const ctx = {...createPageContext(), params: {1: 'test'}};
- router.handleProjectsOldRoute(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(redirectStub.lastCall.args[0], '/admin/repos/test');
- });
-
- test('handleProjectsOldRoute test,branches', () => {
- const ctx = {...createPageContext(), params: {1: 'test,branches'}};
- router.handleProjectsOldRoute(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(
- redirectStub.lastCall.args[0],
+ suite('REPO*', () => {
+ test('PROJECT_OLD', async () => {
+ // PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+ await checkRedirect('/admin/projects/', '/admin/repos/');
+ await checkRedirect('/admin/projects/test', '/admin/repos/test');
+ await checkRedirect(
+ '/admin/projects/test,branches',
'/admin/repos/test,branches'
);
});
- test('handleRepoRoute', () => {
- const ctx = {...createPageContext(), path: '/admin/repos/test'};
- router.handleRepoRoute(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.equal(
- redirectStub.lastCall.args[0],
- '/admin/repos/test,general'
- );
+ test('REPO', async () => {
+ // REPO: /^\/admin\/repos\/([^,]+)$/,
+ await checkRedirect('/admin/repos/test', '/admin/repos/test,general');
});
- test('handleRepoGeneralRoute', () => {
- const ctx = {...createPageContext(), params: {0: '4321'}};
- assertctxToParams(ctx, 'handleRepoGeneralRoute', {
- view: GerritView.REPO,
+ test('REPO_GENERAL', async () => {
+ // REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
+ await checkUrlToState('/admin/repos/4321,general', {
+ ...createRepoViewState(),
detail: RepoDetailView.GENERAL,
repo: '4321' as RepoName,
});
});
- test('handleRepoCommandsRoute', () => {
- const ctx = {...createPageContext(), params: {0: '4321'}};
- assertctxToParams(ctx, 'handleRepoCommandsRoute', {
- view: GerritView.REPO,
+ test('REPO_COMMANDS', async () => {
+ // REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+ await checkUrlToState('/admin/repos/4321,commands', {
+ ...createRepoViewState(),
detail: RepoDetailView.COMMANDS,
repo: '4321' as RepoName,
});
});
- test('handleRepoAccessRoute', () => {
- const ctx = {...createPageContext(), params: {0: '4321'}};
- assertctxToParams(ctx, 'handleRepoAccessRoute', {
- view: GerritView.REPO,
+ test('REPO_ACCESS', async () => {
+ // REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+ await checkUrlToState('/admin/repos/4321,access', {
+ ...createRepoViewState(),
detail: RepoDetailView.ACCESS,
repo: '4321' as RepoName,
});
});
- suite('branch list routes', () => {
- test('handleBranchListOffsetRoute', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- params: {0: '4321'},
- };
- assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.BRANCHES,
- repo: '4321' as RepoName,
- offset: 0,
- filter: null,
- });
-
- ctx.params[2] = '42';
- assertctxToParams(ctx, 'handleBranchListOffsetRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.BRANCHES,
- repo: '4321' as RepoName,
- offset: '42',
- filter: null,
- });
- });
-
- test('handleBranchListFilterOffsetRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {repo: '4321', filter: 'foo', offset: '42'},
- };
- assertctxToParams(ctx, 'handleBranchListFilterOffsetRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.BRANCHES,
- repo: '4321' as RepoName,
- offset: '42',
- filter: 'foo',
- });
- });
-
- test('handleBranchListFilterRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {repo: '4321', filter: 'foo'},
- };
- assertctxToParams(ctx, 'handleBranchListFilterRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.BRANCHES,
- repo: '4321' as RepoName,
- filter: 'foo',
- });
+ test('BRANCH_LIST', async () => {
+ await checkUrlToState('/admin/repos/4321,branches', {
+ ...createRepoBranchesViewState(),
+ repo: '4321' as RepoName,
});
- });
-
- suite('tag list routes', () => {
- test('handleTagListOffsetRoute', () => {
- const ctx = {...createPageContext(), params: {0: '4321'}};
- assertctxToParams(ctx, 'handleTagListOffsetRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.TAGS,
- repo: '4321' as RepoName,
- offset: 0,
- filter: null,
- });
+ await checkUrlToState('/admin/repos/4321,branches,42', {
+ ...createRepoBranchesViewState(),
+ repo: '4321' as RepoName,
+ offset: '42',
});
-
- test('handleTagListFilterOffsetRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {repo: '4321', filter: 'foo', offset: '42'},
- };
- assertctxToParams(ctx, 'handleTagListFilterOffsetRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.TAGS,
- repo: '4321' as RepoName,
- offset: '42',
- filter: 'foo',
- });
+ await checkUrlToState('/admin/repos/4321,branches/q/filter:foo,42', {
+ ...createRepoBranchesViewState(),
+ repo: '4321' as RepoName,
+ offset: '42',
+ filter: 'foo',
});
-
- test('handleTagListFilterRoute', () => {
- const ctx: PageContext = {
- ...createPageContext(),
- params: {repo: '4321'},
- };
- assertctxToParams(ctx, 'handleTagListFilterRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.TAGS,
- repo: '4321' as RepoName,
- filter: null,
- });
-
- ctx.params.filter = 'foo';
- assertctxToParams(ctx, 'handleTagListFilterRoute', {
- view: GerritView.REPO,
- detail: RepoDetailView.TAGS,
- repo: '4321' as RepoName,
- filter: 'foo',
- });
+ await checkUrlToState('/admin/repos/4321,branches/q/filter:foo', {
+ ...createRepoBranchesViewState(),
+ repo: '4321' as RepoName,
+ filter: 'foo',
});
+ await checkUrlToState(
+ '/admin/repos/asdf/%2F%20%2525%252Fqwer,branches/q/filter:foo/%2F%20%2525%252F',
+ {
+ ...createRepoBranchesViewState(),
+ repo: 'asdf// %/qwer' as RepoName,
+ filter: 'foo// %/',
+ }
+ );
});
- suite('repo list routes', () => {
- test('handleRepoListOffsetRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- offset: 0,
- filter: null,
- openCreateModal: false,
- });
-
- ctx.params[1] = '42';
- assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- offset: '42',
- filter: null,
- openCreateModal: false,
- });
-
- ctx.hash = 'create';
- assertctxToParams(ctx, 'handleRepoListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- offset: '42',
- filter: null,
- openCreateModal: true,
- });
+ test('TAG_LIST', async () => {
+ await checkUrlToState('/admin/repos/4321,tags', {
+ ...createRepoTagsViewState(),
+ repo: '4321' as RepoName,
});
-
- test('handleRepoListFilterOffsetRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {filter: 'foo', offset: '42'},
- };
- assertctxToParams(ctx, 'handleRepoListFilterOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- offset: '42',
- filter: 'foo',
- });
+ await checkUrlToState('/admin/repos/4321,tags,42', {
+ ...createRepoTagsViewState(),
+ repo: '4321' as RepoName,
+ offset: '42',
});
-
- test('handleRepoListFilterRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handleRepoListFilterRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- filter: null,
- });
-
- ctx.params.filter = 'foo';
- assertctxToParams(ctx, 'handleRepoListFilterRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.REPOS,
- filter: 'foo',
- });
+ await checkUrlToState('/admin/repos/4321,tags/q/filter:foo,42', {
+ ...createRepoTagsViewState(),
+ repo: '4321' as RepoName,
+ offset: '42',
+ filter: 'foo',
});
+ await checkUrlToState('/admin/repos/4321,tags/q/filter:foo', {
+ ...createRepoTagsViewState(),
+ repo: '4321' as RepoName,
+ filter: 'foo',
+ });
+ await checkUrlToState(
+ '/admin/repos/asdf/%2F%20%2525%252Fqwer,tags/q/filter:foo/%2F%20%2525%252F',
+ {
+ ...createRepoTagsViewState(),
+ repo: 'asdf// %/qwer' as RepoName,
+ filter: 'foo// %/',
+ }
+ );
});
- });
- suite('plugin routes', () => {
- test('handlePluginListOffsetRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
- offset: 0,
- filter: null,
+ test('REPO_LIST', async () => {
+ await checkUrlToState('/admin/repos', {
+ ...createAdminReposViewState(),
});
-
- ctx.params[1] = '42';
- assertctxToParams(ctx, 'handlePluginListOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
- offset: '42',
- filter: null,
+ await checkUrlToState('/admin/repos/', {
+ ...createAdminReposViewState(),
});
- });
-
- test('handlePluginListFilterOffsetRoute', () => {
- const ctx = {
- ...createPageContext(),
- params: {filter: 'foo', offset: '42'},
- };
- assertctxToParams(ctx, 'handlePluginListFilterOffsetRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
+ await checkUrlToState('/admin/repos,42', {
+ ...createAdminReposViewState(),
offset: '42',
+ });
+ await checkUrlToState('/admin/repos#create', {
+ ...createAdminReposViewState(),
+ openCreateModal: true,
+ });
+ await checkUrlToState('/admin/repos/q/filter:foo', {
+ ...createAdminReposViewState(),
filter: 'foo',
});
- });
-
- test('handlePluginListFilterRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handlePluginListFilterRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
- filter: null,
+ await checkUrlToState('/admin/repos/q/filter:foo/%2F%20%2525%252F', {
+ ...createAdminReposViewState(),
+ filter: 'foo// %/',
});
-
- ctx.params.filter = 'foo';
- assertctxToParams(ctx, 'handlePluginListFilterRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
+ await checkUrlToState('/admin/repos/q/filter:foo,42', {
+ ...createAdminReposViewState(),
filter: 'foo',
+ offset: '42',
});
});
+ });
- test('handlePluginListRoute', () => {
- const ctx = createPageContext();
- assertctxToParams(ctx, 'handlePluginListRoute', {
- view: GerritView.ADMIN,
- adminView: AdminChildView.PLUGINS,
- });
+ test('PLUGIN_LIST', async () => {
+ await checkUrlToState('/admin/plugins', {
+ ...createAdminPluginsViewState(),
+ });
+ await checkUrlToState('/admin/plugins/', {
+ ...createAdminPluginsViewState(),
+ });
+ await checkUrlToState('/admin/plugins,42', {
+ ...createAdminPluginsViewState(),
+ offset: '42',
+ });
+ await checkUrlToState('/admin/plugins/q/filter:foo', {
+ ...createAdminPluginsViewState(),
+ filter: 'foo',
+ });
+ await checkUrlToState('/admin/plugins/q/filter:foo%2F%20%2525%252F', {
+ ...createAdminPluginsViewState(),
+ filter: 'foo/ %/',
+ });
+ await checkUrlToState('/admin/plugins/q/filter:foo,42', {
+ ...createAdminPluginsViewState(),
+ offset: '42',
+ filter: 'foo',
+ });
+ await checkUrlToState('/admin/plugins/q/filter:foo,asdf', {
+ ...createAdminPluginsViewState(),
+ filter: 'foo,asdf',
});
});
- suite('change/diff routes', () => {
- test('handleChangeNumberLegacyRoute', () => {
- const ctx = {...createPageContext(), params: {0: '12345'}};
- router.handleChangeNumberLegacyRoute(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+ suite('CHANGE* / DIFF*', () => {
+ test('CHANGE_NUMBER_LEGACY', async () => {
+ // CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+ await checkRedirect('/12345', '/c/12345');
});
- test('handleChangeLegacyRoute', async () => {
- stubRestApi('getFromProjectLookup').returns(
- Promise.resolve('project' as RepoName)
- );
- const ctx = {
- ...createPageContext(),
- params: {0: '1234', 1: 'comment/6789'},
- };
- router.handleChangeLegacyRoute(ctx);
- await waitEventLoop();
- assert.isTrue(
- redirectStub.calledWithExactly('/c/project/+/1234' + '/comment/6789')
+ test('CHANGE_LEGACY', async () => {
+ // CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
+ stubRestApi('getFromProjectLookup').resolves('project' as RepoName);
+ await checkRedirect('/c/1234', '/c/project/+/1234/');
+ await checkRedirect(
+ '/c/1234/comment/6789',
+ '/c/project/+/1234/comment/6789'
);
});
- test('handleLegacyLinenum w/ @321', () => {
- const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@321'};
- router.handleLegacyLinenum(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.isTrue(
- redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#321')
+ test('DIFF_LEGACY_LINENUM', async () => {
+ await checkRedirect(
+ '/c/1234/3..8/foo/bar@321',
+ '/c/1234/3..8/foo/bar#321'
);
- });
-
- test('handleLegacyLinenum w/ @b123', () => {
- const ctx = {...createPageContext(), path: '/c/1234/3..8/foo/bar@b123'};
- router.handleLegacyLinenum(ctx);
- assert.isTrue(redirectStub.calledOnce);
- assert.isTrue(
- redirectStub.calledWithExactly('/c/1234/3..8/foo/bar#b123')
+ await checkRedirect(
+ '/c/1234/3..8/foo/bar@b321',
+ '/c/1234/3..8/foo/bar#b321'
);
});
- suite('handleChangeRoute', () => {
- function makeParams(_path: string, _hash: string): PageContext {
- return {
- ...createPageContext(),
- params: {
- 0: 'foo/bar', // 0 Project
- 1: '1234', // 1 Change number
- 2: '', // 2 Unused
- 3: '', // 3 Unused
- 4: '4', // 4 Base patch number
- 5: '', // 5 Unused
- 6: '7', // 6 Patch number
- },
- };
- }
-
- setup(() => {
- stubRestApi('setInProjectLookup');
+ test('CHANGE', async () => {
+ // CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+ await checkUrlToState('/c/test-project/+/42', {
+ ...createChangeViewState(),
+ basePatchNum: undefined,
+ patchNum: undefined,
});
-
- test('change view', () => {
- const ctx = makeParams('', '');
- assertctxToParams(ctx, 'handleChangeRoute', {
- view: GerritView.CHANGE,
- project: 'foo/bar' as RepoName,
- changeNum: 1234 as NumericChangeId,
- basePatchNum: 4 as BasePatchSetNum,
- patchNum: 7 as RevisionPatchSetNum,
- });
- assert.isFalse(redirectStub.called);
+ await checkUrlToState('/c/test-project/+/42/7', {
+ ...createChangeViewState(),
+ basePatchNum: PARENT,
+ patchNum: 7,
});
-
- test('params', () => {
- const ctx = makeParams('', '');
- const queryMap = new URLSearchParams();
- queryMap.set('tab', 'checks');
- queryMap.set('filter', 'fff');
- queryMap.set('select', 'sss');
- queryMap.set('attempt', '1');
- queryMap.set('checksRunsSelected', 'asdf,qwer');
- queryMap.set('checksResultsFilter', 'asdf.*qwer');
- ctx.querystring = queryMap.toString();
- assertctxToParams(ctx, 'handleChangeRoute', {
- view: GerritView.CHANGE,
- project: 'foo/bar' as RepoName,
- changeNum: 1234 as NumericChangeId,
- basePatchNum: 4 as BasePatchSetNum,
- patchNum: 7 as RevisionPatchSetNum,
+ await checkUrlToState('/c/test-project/+/42/4..7', {
+ ...createChangeViewState(),
+ basePatchNum: 4,
+ patchNum: 7,
+ });
+ await checkUrlToState(
+ '/c/test-project/+/42/4..7?tab=checks&filter=fff&attempt=1&checksRunsSelected=asdf,qwer&checksResultsFilter=asdf.*qwer',
+ {
+ ...createChangeViewState(),
+ basePatchNum: 4,
+ patchNum: 7,
attempt: 1,
filter: 'fff',
tab: 'checks',
checksRunsSelected: new Set(['asdf', 'qwer']),
checksResultsFilter: 'asdf.*qwer',
- });
- });
+ }
+ );
});
- suite('handleDiffRoute', () => {
- function makeParams(path: string, hash: string): PageContext {
- return {
- ...createPageContext(),
- hash,
- params: {
- 0: 'foo/bar', // 0 Project
- 1: '1234', // 1 Change number
- 2: '', // 2 Unused
- 3: '', // 3 Unused
- 4: '4', // 4 Base patch number
- 5: '', // 5 Unused
- 6: '7', // 6 Patch number
- 7: '', // 7 Unused,
- 8: path, // 8 Diff path
- },
- };
- }
-
- setup(() => {
- stubRestApi('setInProjectLookup');
- });
+ test('COMMENTS_TAB', async () => {
+ // COMMENTS_TAB: /^\/c\/(.+)\/\+\/(\d+)\/comments(?:\/)?(\w+)?\/?$/,
+ await checkUrlToState(
+ '/c/gerrit/+/264833/comments/00049681_f34fd6a9/',
+ {
+ ...createChangeViewState(),
+ repo: 'gerrit' as RepoName,
+ changeNum: 264833 as NumericChangeId,
+ commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
+ }
+ );
+ });
- test('diff view', () => {
- const ctx = makeParams('foo/bar/baz', 'b44');
- assertctxToParams(ctx, 'handleDiffRoute', {
- view: GerritView.DIFF,
- project: 'foo/bar' as RepoName,
- changeNum: 1234 as NumericChangeId,
+ suite('handleDiffRoute', () => {
+ test('DIFF', async () => {
+ // DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
+ await checkUrlToState('/c/test-project/+/42/4..7/foo/bar/baz#b44', {
+ ...createDiffViewState(),
basePatchNum: 4 as BasePatchSetNum,
patchNum: 7 as RevisionPatchSetNum,
- path: 'foo/bar/baz',
- leftSide: true,
- lineNum: 44,
+ diffView: {
+ path: 'foo/bar/baz',
+ lineNum: 44,
+ leftSide: true,
+ },
});
- assert.isFalse(redirectStub.called);
});
- test('comment route', () => {
- const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
- const groups = url.match(_testOnly_RoutePattern.COMMENT);
- assert.deepEqual(groups!.slice(1), [
- 'gerrit', // project
- '264833', // changeNum
- '00049681_f34fd6a9', // commentId
- ]);
- assertctxToParams(
- {params: groups!.slice(1)} as any,
- 'handleCommentRoute',
- {
- project: 'gerrit' as RepoName,
- changeNum: 264833 as NumericChangeId,
- commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
- commentLink: true,
- view: GerritView.DIFF,
- }
+ test('COMMENT base..1', async () => {
+ const change: ParsedChangeInfo = createParsedChange();
+ const repo = change.project;
+ const changeNum = change._number;
+ const ps = 1 as RevisionPatchSetNum;
+ const line = 23;
+ const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+ stubRestApi('getChangeDetail').resolves(change);
+ stubRestApi('getDiffComments').resolves({
+ filepath: [{...createComment(), id, patch_set: ps, line}],
+ });
+
+ await checkRedirect(
+ `/c/${repo}/+/${changeNum}/comment/${id}/`,
+ `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
);
});
- test('comments route', () => {
- const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
- const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
- assert.deepEqual(groups!.slice(1), [
- 'gerrit', // project
- '264833', // changeNum
- '00049681_f34fd6a9', // commentId
- ]);
- assertctxToParams(
- {params: groups!.slice(1)} as any,
- 'handleCommentsRoute',
- {
- project: 'gerrit' as RepoName,
- changeNum: 264833 as NumericChangeId,
- commentId: '00049681_f34fd6a9' as UrlEncodedCommentId,
- view: GerritView.CHANGE,
- }
+ test('COMMENT 1..2', async () => {
+ const change: ParsedChangeInfo = {
+ ...createParsedChange(),
+ revisions: {
+ abc: createRevision(1),
+ def: createRevision(2),
+ },
+ };
+ const repo = change.project;
+ const changeNum = change._number;
+ const ps = 1 as RevisionPatchSetNum;
+ const line = 23;
+ const id = '00049681_f34fd6a9' as UrlEncodedCommentId;
+
+ stubRestApi('getChangeDetail').resolves(change);
+ stubRestApi('getDiffComments').resolves({
+ filepath: [{...createComment(), id, patch_set: ps, line}],
+ });
+ const diffStub = stubRestApi('getDiff');
+
+ // If getDiff() returns a diff with changes, then we will compare
+ // the patchset of the comment (1) against latest (2).
+ diffStub.onFirstCall().resolves(createDiff());
+ await checkRedirect(
+ `/c/${repo}/+/${changeNum}/comment/${id}/`,
+ `/c/${repo}/+/${changeNum}/${ps}..2/filepath#b${line}`
+ );
+
+ // If getDiff() returns an unchanged diff, then we will compare
+ // the patchset of the comment (1) against base.
+ diffStub.onSecondCall().resolves({
+ ...createDiff(),
+ content: [],
+ });
+ await checkRedirect(
+ `/c/${repo}/+/${changeNum}/comment/${id}/`,
+ `/c/${repo}/+/${changeNum}/${ps}/filepath#${line}`
);
});
});
- test('handleDiffEditRoute', () => {
- stubRestApi('setInProjectLookup');
- const ctx = {
- ...createPageContext(),
- hash: '',
- params: {
- 0: 'foo/bar', // 0 Project
- 1: '1234', // 1 Change number
- 2: '3', // 2 Patch num
- 3: 'foo/bar/baz', // 3 File path
- },
- };
- const appParams: EditViewState = {
- project: 'foo/bar' as RepoName,
+ test('DIFF_EDIT', async () => {
+ // DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
+ await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit', {
+ ...createEditViewState(),
+ repo: 'foo/bar' as RepoName,
changeNum: 1234 as NumericChangeId,
- view: GerritView.EDIT,
- path: 'foo/bar/baz',
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.EDIT,
patchNum: 3 as RevisionPatchSetNum,
- lineNum: 0,
- };
-
- router.handleDiffEditRoute(ctx);
- assert.isFalse(redirectStub.called);
- assert.deepEqual(setStateStub.lastCall.args[0], appParams);
- });
-
- test('handleDiffEditRoute with lineNum', () => {
- stubRestApi('setInProjectLookup');
- const ctx = {
- ...createPageContext(),
- hash: '4',
- params: {
- 0: 'foo/bar', // 0 Project
- 1: '1234', // 1 Change number
- 2: '3', // 2 Patch num
- 3: 'foo/bar/baz', // 3 File path
- },
- };
- const appParams: EditViewState = {
- project: 'foo/bar' as RepoName,
+ editView: {path: 'foo/bar/baz', lineNum: 0},
+ });
+ await checkUrlToState('/c/foo/bar/+/1234/3/foo/bar/baz,edit#4', {
+ ...createEditViewState(),
+ repo: 'foo/bar' as RepoName,
changeNum: 1234 as NumericChangeId,
- view: GerritView.EDIT,
- path: 'foo/bar/baz',
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.EDIT,
patchNum: 3 as RevisionPatchSetNum,
- lineNum: 4,
- };
-
- router.handleDiffEditRoute(ctx);
- assert.isFalse(redirectStub.called);
- assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+ editView: {path: 'foo/bar/baz', lineNum: 4},
+ });
});
- test('handleChangeEditRoute', () => {
- stubRestApi('setInProjectLookup');
- const ctx = {
- ...createPageContext(),
- params: {
- 0: 'foo/bar', // 0 Project
- 1: '1234', // 1 Change number
- 2: '',
- 3: '3', // 3 Patch num
- },
- };
- const appParams: ChangeViewState = {
- project: 'foo/bar' as RepoName,
+ test('CHANGE_EDIT', async () => {
+ // CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
+ await checkUrlToState('/c/foo/bar/+/1234/3,edit', {
+ ...createChangeViewState(),
+ repo: 'foo/bar' as RepoName,
changeNum: 1234 as NumericChangeId,
view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
patchNum: 3 as RevisionPatchSetNum,
edit: true,
- };
-
- router.handleChangeEditRoute(ctx);
- assert.isFalse(redirectStub.called);
- assert.deepEqual(setStateStub.lastCall.args[0], appParams);
+ });
});
});
- test('handlePluginScreen', () => {
- const ctx = {...createPageContext(), params: {0: 'foo', 1: 'bar'}};
- assertctxToParams(ctx, 'handlePluginScreen', {
+ test('LOG_IN_OR_OUT pass through', async () => {
+ // LOG_IN_OR_OUT: /^\/log(in|out)(\/(.+))?$/,
+ await checkUrlNotMatched('/login/asdf');
+ await checkUrlNotMatched('/logout/asdf');
+ });
+
+ test('PLUGIN_SCREEN', async () => {
+ // PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+ await checkUrlToState('/x/foo/bar', {
view: GerritView.PLUGIN_SCREEN,
plugin: 'foo',
screen: 'bar',
});
- assert.isFalse(redirectStub.called);
+ });
+
+ test('DOCUMENTATION_SEARCH*', async () => {
+ // DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+ // DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+ await checkRedirect(
+ '/Documentation/q/asdf',
+ '/Documentation/q/filter:asdf'
+ );
+ await checkRedirect(
+ '/Documentation/q/as%3Fdf',
+ '/Documentation/q/filter:as%3Fdf'
+ );
+
+ await checkUrlToState('/Documentation/q/filter:', {
+ view: GerritView.DOCUMENTATION_SEARCH,
+ filter: '',
+ });
+ await checkUrlToState('/Documentation/q/filter:asdf', {
+ view: GerritView.DOCUMENTATION_SEARCH,
+ filter: 'asdf',
+ });
+ // Percent decoding works fine. gr-page decodes twice, so the only problem
+ // is having `%25` in the URL, because the first decoding pass will yield
+ // `%`, and then the second decoding pass will throw `URI malformed`.
+ await checkUrlToState('/Documentation/q/filter:as%20%2fdf', {
+ view: GerritView.DOCUMENTATION_SEARCH,
+ filter: 'as /df',
+ });
+ // We accept and process double-encoded values, but only *require* it for
+ // the percent symbol `%`.
+ await checkUrlToState('/Documentation/q/filter:as%252f%2525df', {
+ view: GerritView.DOCUMENTATION_SEARCH,
+ filter: 'as/%df',
+ });
});
});
});
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 17edc1945e..0856eedcaf 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -25,6 +25,11 @@ import {assertIsDefined} from '../../../utils/common-util';
import {configModelToken} from '../../../models/config/config-model';
import {resolve} from '../../../models/dependency';
import {subscribe} from '../../lit/subscription-controller';
+import {
+ AutocompleteCommitEvent,
+ ValueChangedEvent,
+} from '../../../types/events';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
// Possible static search options for auto complete, without negations.
const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -159,9 +164,6 @@ export class GrSearchBar extends LitElement {
@state()
mergeabilityComputationBehavior?: MergeabilityComputationBehavior;
- @property({type: String})
- label = '';
-
// private but used in test
@state() inputVal = '';
@@ -198,6 +200,9 @@ export class GrSearchBar extends LitElement {
return [
sharedStyles,
css`
+ gr-icon.searchIcon {
+ margin: 0 var(--spacing-xs);
+ }
form {
display: flex;
}
@@ -216,8 +221,7 @@ export class GrSearchBar extends LitElement {
<form>
<gr-autocomplete
id="searchInput"
- .label=${this.label}
- show-search-icon
+ label="Search for changes"
.text=${this.inputVal}
.query=${this.query}
allow-non-suggested-values
@@ -225,13 +229,14 @@ export class GrSearchBar extends LitElement {
.threshold=${this.threshold}
tab-complete
.verticalOffset=${30}
- @commit=${(e: Event) => {
+ @commit=${(e: AutocompleteCommitEvent) => {
this.handleInputCommit(e);
}}
- @text-changed=${(e: CustomEvent) => {
+ @text-changed=${(e: ValueChangedEvent) => {
this.handleSearchTextChanged(e);
}}
>
+ <gr-icon icon="search" class="searchIcon" slot="prefix"></gr-icon>
<a
class="help"
slot="suffix"
@@ -275,14 +280,14 @@ export class GrSearchBar extends LitElement {
// fallback to gerrit's official doc
let baseUrl =
this.docsBaseUrl ||
- 'https://gerrit-review.googlesource.com/documentation/';
+ 'https://gerrit-review.googlesource.com/Documentation/';
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.substring(0, baseUrl.length - 1);
}
return `${baseUrl}/user-search.html`;
}
- private handleInputCommit(e: Event) {
+ private handleInputCommit(e: AutocompleteCommitEvent) {
this.preventDefaultAndNavigateToInputVal(e);
}
@@ -292,7 +297,7 @@ export class GrSearchBar extends LitElement {
* - e.target is the gr-autocomplete widget (#searchInput)
* - e.target is the input element wrapped within #searchInput
*/
- private preventDefaultAndNavigateToInputVal(e: Event) {
+ private preventDefaultAndNavigateToInputVal(e: AutocompleteCommitEvent) {
e.preventDefault();
if (!this.inputVal) return;
const trimmedInput = this.inputVal.trim();
@@ -306,11 +311,7 @@ export class GrSearchBar extends LitElement {
const detail: SearchBarHandleSearchDetail = {
inputVal: this.inputVal,
};
- this.dispatchEvent(
- new CustomEvent('handle-search', {
- detail,
- })
- );
+ fireNoBubbleNoCompose(this, 'handle-search', detail);
}
}
@@ -422,7 +423,7 @@ export class GrSearchBar extends LitElement {
this.searchInput.selectAll();
}
- private handleSearchTextChanged(e: CustomEvent) {
+ private handleSearchTextChanged(e: ValueChangedEvent) {
this.inputVal = e.detail.value;
}
}
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
index dbb3db9eff..603ea7b202 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -6,10 +6,11 @@
import '../../../test/common-test-setup';
import './gr-search-bar';
import {GrSearchBar} from './gr-search-bar';
-import '../../../scripts/util';
+import '../../../utils/async-util';
import {
mockPromise,
pressKey,
+ stubRestApi,
waitUntil,
waitUntilObserved,
} from '../../../test/test-utils';
@@ -37,12 +38,13 @@ suite('gr-search-bar tests', () => {
let configModel: ConfigModel;
setup(async () => {
+ const serverConfig = createServerInfo();
+ serverConfig.gerrit.doc_url = 'https://mydocumentationurl.google.com/';
+ stubRestApi('getConfig').returns(Promise.resolve(serverConfig));
configModel = new ConfigModel(
testResolver(changeModelToken),
getAppContext().restApiService
);
- const serverConfig = createServerInfo();
- serverConfig.gerrit.doc_url = 'https://mydocumentationurl.google.com/';
configModel.updateServerConfig(serverConfig);
await waitUntilObserved(
configModel.docsBaseUrl$,
@@ -68,10 +70,11 @@ suite('gr-search-bar tests', () => {
<gr-autocomplete
allow-non-suggested-values=""
id="searchInput"
+ label="Search for changes"
multi=""
- show-search-icon=""
tab-complete=""
>
+ <gr-icon icon="search" class="searchIcon" slot="prefix"></gr-icon>
<a
class="help"
href="https://mydocumentationurl.google.com/user-search.html"
@@ -319,7 +322,7 @@ suite('gr-search-bar tests', () => {
await element.updateComplete;
assert.equal(
element.computeHelpDocLink(),
- 'https://gerrit-review.googlesource.com/documentation/' +
+ 'https://gerrit-review.googlesource.com/Documentation/' +
'user-search.html'
);
});
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
index 13116fd74f..8b4b52ffe0 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -14,11 +14,15 @@ import {
import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getAppContext} from '../../../services/app-context';
import {LitElement, html} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
-import {createSearchUrl} from '../../../models/views/search';
+import {
+ createSearchUrl,
+ searchViewModelToken,
+} from '../../../models/views/search';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const MAX_AUTOCOMPLETE_RESULTS = 10;
const SELF_EXPRESSION = 'self';
@@ -35,29 +39,31 @@ declare global {
@customElement('gr-smart-search')
export class GrSmartSearch extends LitElement {
- @property({type: String})
+ @state()
searchQuery = '';
@state()
serverConfig?: ServerInfo;
- @property({type: String})
- label = '';
-
private readonly restApiService = getAppContext().restApiService;
private readonly getConfigModel = resolve(this, configModelToken);
private readonly getNavigation = resolve(this, navigationToken);
+ private readonly getSearchViewModel = resolve(this, searchViewModelToken);
+
constructor() {
super();
subscribe(
this,
() => this.getConfigModel().serverConfig$,
- config => {
- this.serverConfig = config;
- }
+ config => (this.serverConfig = config)
+ );
+ subscribe(
+ this,
+ () => this.getSearchViewModel().query$,
+ query => (this.searchQuery = query ?? '')
);
}
@@ -71,7 +77,6 @@ export class GrSmartSearch extends LitElement {
return html`
<gr-search-bar
id="search"
- .label=${this.label}
.value=${this.searchQuery}
.projectSuggestions=${projectSuggestions}
.groupSuggestions=${groupSuggestions}
@@ -98,7 +103,11 @@ export class GrSmartSearch extends LitElement {
expression: string
): Promise<AutocompleteSuggestion[]> {
return this.restApiService
- .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .getSuggestedRepos(
+ expression,
+ MAX_AUTOCOMPLETE_RESULTS,
+ throwingErrorCallback
+ )
.then(projects => {
if (!projects) {
return [];
@@ -128,7 +137,12 @@ export class GrSmartSearch extends LitElement {
return Promise.resolve([]);
}
return this.restApiService
- .getSuggestedGroups(expression, undefined, MAX_AUTOCOMPLETE_RESULTS)
+ .getSuggestedGroups(
+ expression,
+ undefined,
+ MAX_AUTOCOMPLETE_RESULTS,
+ throwingErrorCallback
+ )
.then(groups => {
if (!groups) {
return [];
@@ -158,7 +172,13 @@ export class GrSmartSearch extends LitElement {
return Promise.resolve([]);
}
return this.restApiService
- .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .getSuggestedAccounts(
+ expression,
+ MAX_AUTOCOMPLETE_RESULTS,
+ /* canSee=*/ undefined,
+ /* filterActive=*/ undefined,
+ throwingErrorCallback
+ )
.then(accounts => {
if (!accounts) {
return [];
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
index a0d49c827f..7e3b896046 100644
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -94,7 +94,7 @@ suite('gr-smart-search tests', () => {
});
test('Autocompletes projects', () => {
- stubRestApi('getSuggestedProjects').callsFake(() =>
+ stubRestApi('getSuggestedRepos').callsFake(() =>
Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
);
return element.fetchProjects('project', 'pol').then(s => {
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 17d7516afb..b97f54f56e 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
@@ -6,7 +6,6 @@
import '../../../styles/shared-styles';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-icon/gr-icon';
-import '../../shared/gr-overlay/gr-overlay';
import '../../../embed/diff/gr-diff/gr-diff';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {
@@ -18,15 +17,13 @@ import {
FilePathToDiffInfoMap,
} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
import {OpenFixPreviewEvent} from '../../../types/events';
import {getAppContext} from '../../../services/app-context';
-import {fireCloseFixPreview} from '../../../utils/event-util';
import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
import {GrButton} from '../../shared/gr-button/gr-button';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {css, html, LitElement} from 'lit';
+import {css, html, LitElement, nothing} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {subscribe} from '../../lit/subscription-controller';
@@ -34,6 +31,13 @@ import {assert} from '../../../utils/common-util';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
+import {anyLineTooLong} from '../../../embed/diff/gr-diff/gr-diff-utils';
+import {fireReload} from '../../../utils/event-util';
+import {when} from 'lit/directives/when.js';
interface FilePreview {
filepath: string;
@@ -42,8 +46,8 @@ interface FilePreview {
@customElement('gr-apply-fix-dialog')
export class GrApplyFixDialog extends LitElement {
- @query('#applyFixOverlay')
- applyFixOverlay?: GrOverlay;
+ @query('#applyFixModal')
+ applyFixModal?: HTMLDialogElement;
@query('#applyFixDialog')
applyFixDialog?: GrDialog;
@@ -87,35 +91,50 @@ export class GrApplyFixDialog extends LitElement {
@state()
diffPrefs?: DiffPreferencesInfo;
+ @state()
+ loading = false;
+
+ @state()
+ onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[] = [];
+
private readonly restApiService = getAppContext().restApiService;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getNavigation = resolve(this, navigationToken);
+ private readonly syntaxLayer = new GrSyntaxLayerWorker(
+ resolve(this, highlightServiceToken),
+ () => getAppContext().reportingService
+ );
+
constructor() {
super();
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
preferences => {
+ const layers: DiffLayer[] = [this.syntaxLayer];
if (!preferences?.disable_token_highlighting) {
- this.layers = [new TokenHighlightLayer(this)];
+ layers.push(new TokenHighlightLayer(this));
}
+ this.layers = layers;
}
);
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
diffPreferences => {
if (!diffPreferences) return;
this.diffPrefs = diffPreferences;
+ this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
}
);
}
static override styles = [
sharedStyles,
+ modalStyles,
css`
.diffContainer {
padding: var(--spacing-l) 0;
@@ -140,9 +159,11 @@ export class GrApplyFixDialog extends LitElement {
override render() {
return html`
- <gr-overlay id="applyFixOverlay" with-backdrop="">
+ <dialog id="applyFixModal" tabindex="-1">
<gr-dialog
id="applyFixDialog"
+ ?loading=${this.loading}
+ .loadingLabel=${'Creating preview ...'}
.confirmLabel=${this.isApplyFixLoading ? 'Saving...' : 'Apply Fix'}
.confirmTooltip=${this.computeTooltip()}
?disabled=${this.computeDisableApplyFixButton()}
@@ -151,43 +172,14 @@ export class GrApplyFixDialog extends LitElement {
>
${this.renderHeader()} ${this.renderMain()} ${this.renderFooter()}
</gr-dialog>
- </gr-overlay>
+ </dialog>
`;
}
- override updated() {
- this.updateDialogObserver();
- }
-
override disconnectedCallback() {
- this.removeDialogObserver();
super.disconnectedCallback();
}
- private removeDialogObserver() {
- this.dialogObserver?.disconnect();
- this.dialogObserver = undefined;
- this.observedDialog = undefined;
- }
-
- private updateDialogObserver() {
- if (
- this.applyFixDialog === this.observedDialog &&
- this.dialogObserver !== undefined
- ) {
- return;
- }
-
- this.removeDialogObserver();
- if (!this.applyFixDialog) return;
-
- this.observedDialog = this.applyFixDialog;
- this.dialogObserver = new ResizeObserver(() => {
- this.applyFixOverlay?.refit();
- });
- this.dialogObserver.observe(this.observedDialog);
- }
-
private renderHeader() {
return html`
<div slot="header">${this.currentFix?.description ?? ''}</div>
@@ -200,44 +192,63 @@ export class GrApplyFixDialog extends LitElement {
<div class="file-name">
<span>${item.filepath}</span>
</div>
- <div class="diffContainer">
- <gr-diff
- .prefs=${this.overridePartialDiffPrefs()}
- .path=${item.filepath}
- .diff=${item.preview}
- .layers=${this.layers}
- ></gr-diff>
- </div>
+ <div class="diffContainer">${this.renderDiff(item)}</div>
`
);
return html`<div slot="main">${items}</div>`;
}
+ private renderDiff(preview: FilePreview) {
+ const diff = preview.preview;
+ if (!anyLineTooLong(diff)) {
+ this.syntaxLayer.process(diff);
+ }
+ return html`<gr-diff
+ .prefs=${this.overridePartialDiffPrefs()}
+ .path=${preview.filepath}
+ .diff=${diff}
+ .layers=${this.layers}
+ ></gr-diff>`;
+ }
+
private renderFooter() {
- const id = this.selectedFixIdx;
const fixCount = this.fixSuggestions?.length ?? 0;
- if (fixCount < 2) return;
+ const reasonForDisabledApplyButton = this.computeTooltip();
+ if (fixCount < 2 && !reasonForDisabledApplyButton) return nothing;
+ return html`<div slot="footer" class="fix-picker">
+ ${when(fixCount >= 2, () =>
+ this.renderNavForMultipleSuggestedFixes(fixCount)
+ )}
+ ${this.renderWarning(reasonForDisabledApplyButton)}
+ </div>`;
+ }
+
+ private renderNavForMultipleSuggestedFixes(fixCount: number) {
+ const id = this.selectedFixIdx;
return html`
- <div slot="footer" class="fix-picker">
- <span>Suggested fix ${id + 1} of ${fixCount}</span>
- <gr-button
- id="prevFix"
- @click=${this.onPrevFixClick}
- ?disabled=${id === 0}
- >
- <gr-icon icon="chevron_left"></gr-icon>
- </gr-button>
- <gr-button
- id="nextFix"
- @click=${this.onNextFixClick}
- ?disabled=${id === fixCount - 1}
- >
- <gr-icon icon="chevron_right"></gr-icon>
- </gr-button>
- </div>
+ <span>Suggested fix ${id + 1} of ${fixCount}</span>
+ <gr-button
+ id="prevFix"
+ @click=${this.onPrevFixClick}
+ ?disabled=${id === 0}
+ >
+ <gr-icon icon="chevron_left"></gr-icon>
+ </gr-button>
+ <gr-button
+ id="nextFix"
+ @click=${this.onNextFixClick}
+ ?disabled=${id === fixCount - 1}
+ >
+ <gr-icon icon="chevron_right"></gr-icon>
+ </gr-button>
`;
}
+ private renderWarning(message: string) {
+ if (!message) return nothing;
+ return html`<span><gr-icon icon="info"></gr-icon>${message}</span>`;
+ }
+
/**
* Given event with fixSuggestions, fetch diffs associated with first
* suggested fix and open dialog.
@@ -245,18 +256,18 @@ export class GrApplyFixDialog extends LitElement {
open(e: OpenFixPreviewEvent) {
this.patchNum = e.detail.patchNum;
this.fixSuggestions = e.detail.fixSuggestions;
+ this.onCloseFixPreviewCallbacks = e.detail.onCloseFixPreviewCallbacks;
assert(this.fixSuggestions.length > 0, 'no fix in the event');
this.selectedFixIdx = 0;
- const promises = [];
- promises.push(
- this.showSelectedFixSuggestion(this.fixSuggestions[0]),
- this.applyFixOverlay?.open()
- );
+ this.applyFixModal?.showModal();
+ return this.showSelectedFixSuggestion(this.fixSuggestions[0]);
}
private async showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
this.currentFix = fixSuggestion;
+ this.loading = true;
await this.fetchFixPreview(fixSuggestion);
+ this.loading = false;
}
private async fetchFixPreview(fixSuggestion: FixSuggestionInfo) {
@@ -333,8 +344,9 @@ export class GrApplyFixDialog extends LitElement {
this.currentPreviews = [];
this.isApplyFixLoading = false;
- fireCloseFixPreview(this, fixApplied);
- this.applyFixOverlay?.close();
+ this.onCloseFixPreviewCallbacks.forEach(fn => fn(fixApplied));
+ this.applyFixModal?.close();
+ if (fixApplied) fireReload(this);
}
private computeTooltip() {
@@ -342,7 +354,7 @@ export class GrApplyFixDialog extends LitElement {
const latestPatchNum =
this.change.revisions[this.change.current_revision]._number;
return latestPatchNum !== this.patchNum
- ? 'Fix can only be applied to the latest patchset'
+ ? 'You cannot apply this fix because it is from a previous patchset'
: '';
}
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 4d2d4543e5..267c5699c3 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
@@ -5,7 +5,10 @@
*/
import '../../../test/common-test-setup';
import './gr-apply-fix-dialog';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
+import {
+ NavigationService,
+ navigationToken,
+} from '../../core/gr-navigation/gr-navigation';
import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
import {GrApplyFixDialog} from './gr-apply-fix-dialog';
import {PatchSetNum} from '../../../types/common';
@@ -17,19 +20,15 @@ import {
} from '../../../test/test-data-generators';
import {createDefaultDiffPrefs} from '../../../constants/constants';
import {DiffInfo} from '../../../types/diff';
-import {
- CloseFixPreviewEventDetail,
- EventType,
- OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
import {GrButton} from '../../shared/gr-button/gr-button';
import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
import {testResolver} from '../../../test/common-test-setup';
suite('gr-apply-fix-dialog tests', () => {
let element: GrApplyFixDialog;
- let setUrlStub: SinonStub;
+ let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>;
const TWO_FIXES: OpenFixPreviewEventDetail = {
patchNum: 2 as PatchSetNum,
@@ -37,11 +36,13 @@ suite('gr-apply-fix-dialog tests', () => {
createFixSuggestionInfo('fix_1'),
createFixSuggestionInfo('fix_2'),
],
+ onCloseFixPreviewCallbacks: [],
};
const ONE_FIX: OpenFixPreviewEventDetail = {
patchNum: 2 as PatchSetNum,
fixSuggestions: [createFixSuggestionInfo('fix_1')],
+ onCloseFixPreviewCallbacks: [],
};
function getConfirmButton(): GrButton {
@@ -53,7 +54,7 @@ suite('gr-apply-fix-dialog tests', () => {
async function open(detail: OpenFixPreviewEventDetail) {
element.open(
- new CustomEvent<OpenFixPreviewEventDetail>(EventType.OPEN_FIX_PREVIEW, {
+ new CustomEvent<OpenFixPreviewEventDetail>('open-fix-preview', {
detail,
})
);
@@ -142,7 +143,7 @@ suite('gr-apply-fix-dialog tests', () => {
f2: diffInfo2,
})
);
- sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
+ sinon.stub(element.applyFixModal!, 'showModal');
});
test('dialog opens fetch and sets previews', async () => {
@@ -173,7 +174,7 @@ suite('gr-apply-fix-dialog tests', () => {
assert.isTrue(button.hasAttribute('disabled'));
assert.equal(
button.getAttribute('title'),
- 'Fix can only be applied to the latest patchset'
+ 'You cannot apply this fix because it is from a previous patchset'
);
});
});
@@ -183,8 +184,8 @@ suite('gr-apply-fix-dialog tests', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-overlay id="applyFixOverlay" tabindex="-1" with-backdrop="">
- <gr-dialog id="applyFixDialog" role="dialog">
+ <dialog id="applyFixModal" tabindex="-1" open="">
+ <gr-dialog id="applyFixDialog" role="dialog" loading="">
<div slot="header">Fix fix_1</div>
<div slot="main"></div>
<div class="fix-picker" slot="footer">
@@ -208,7 +209,7 @@ suite('gr-apply-fix-dialog tests', () => {
</gr-button>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`,
{ignoreAttributes: ['style']}
);
@@ -216,11 +217,12 @@ suite('gr-apply-fix-dialog tests', () => {
test('next button state updated when suggestions changed', async () => {
stubRestApi('getRobotCommentFixPreview').returns(Promise.resolve({}));
- sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
await open(ONE_FIX);
await element.updateComplete;
assert.notOk(element.nextFix);
+ element.applyFixModal?.close();
+
await open(TWO_FIXES);
assert.ok(element.nextFix);
assert.notOk(element.nextFix!.disabled);
@@ -245,11 +247,7 @@ suite('gr-apply-fix-dialog tests', () => {
element.currentFix = createFixSuggestionInfo('123');
const closeFixPreviewEventSpy = sinon.spy();
- // Element is recreated after each test, removeEventListener isn't required
- element.addEventListener(
- EventType.CLOSE_FIX_PREVIEW,
- closeFixPreviewEventSpy
- );
+ element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
await element.handleApplyFix(new CustomEvent('confirm'));
@@ -262,14 +260,7 @@ suite('gr-apply-fix-dialog tests', () => {
assert.isTrue(setUrlStub.called);
assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/2..edit');
- sinon.assert.calledOnceWithExactly(
- closeFixPreviewEventSpy,
- new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
- detail: {
- fixApplied: true,
- },
- })
- );
+ sinon.assert.calledOnceWithExactly(closeFixPreviewEventSpy, true);
// reset gr-apply-fix-dialog and close
assert.equal(element.currentFix, undefined);
assert.equal(element.currentPreviews.length, 0);
@@ -294,7 +285,7 @@ suite('gr-apply-fix-dialog tests', () => {
});
test('select fix forward and back of multiple suggested fixes', async () => {
- sinon.stub(element.applyFixOverlay!, 'open').returns(Promise.resolve());
+ sinon.stub(element.applyFixModal!, 'showModal');
await open(TWO_FIXES);
element.onNextFixClick(new CustomEvent('click'));
@@ -310,11 +301,7 @@ suite('gr-apply-fix-dialog tests', () => {
element.currentFix = createFixSuggestionInfo('fix_123');
const closeFixPreviewEventSpy = sinon.spy();
- // Element is recreated after each test, removeEventListener isn't required
- element.addEventListener(
- EventType.CLOSE_FIX_PREVIEW,
- closeFixPreviewEventSpy
- );
+ element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
let expectedError;
await element.handleApplyFix(new CustomEvent('click')).catch(e => {
@@ -327,19 +314,8 @@ suite('gr-apply-fix-dialog tests', () => {
test('onCancel fires close with correct parameters', () => {
const closeFixPreviewEventSpy = sinon.spy();
- // Element is recreated after each test, removeEventListener isn't required
- element.addEventListener(
- EventType.CLOSE_FIX_PREVIEW,
- closeFixPreviewEventSpy
- );
+ element.onCloseFixPreviewCallbacks.push(closeFixPreviewEventSpy);
element.onCancel(new CustomEvent('cancel'));
- sinon.assert.calledOnceWithExactly(
- closeFixPreviewEventSpy,
- new CustomEvent<CloseFixPreviewEventDetail>(EventType.CLOSE_FIX_PREVIEW, {
- detail: {
- fixApplied: false,
- },
- })
- );
+ sinon.assert.calledOnceWithExactly(closeFixPreviewEventSpy, false);
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index 7570ac5dc7..00e013b870 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -7,51 +7,46 @@ import {
PatchRange,
PatchSetNum,
RobotCommentInfo,
- UrlEncodedCommentId,
- PathToCommentsInfoMap,
FileInfo,
PARENT,
- CommentInfo,
-} from '../../../types/common';
-import {
+ CommentThread,
Comment,
CommentMap,
- CommentThread,
DraftInfo,
+ CommentInfo,
+} from '../../../types/common';
+import {
isUnresolved,
createCommentThreads,
isInPatchRange,
isDraftThread,
isPatchsetLevel,
addPath,
+ id,
} from '../../../utils/comment-util';
import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
import {CommentSide} from '../../../constants/constants';
import {pluralize} from '../../../utils/string-util';
import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
-export type CommentIdToCommentThreadMap = {
- [urlEncodedCommentId: string]: CommentThread;
-};
-
// TODO: Move file out of elements/ directory
export class ChangeComments {
- private readonly _comments: PathToCommentsInfoMap;
+ private readonly _comments: {[path: string]: CommentInfo[]};
private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
private readonly _drafts: {[path: string]: DraftInfo[]};
- private readonly _portedComments: PathToCommentsInfoMap;
+ private readonly _portedComments: {[path: string]: CommentInfo[]};
- private readonly _portedDrafts: PathToCommentsInfoMap;
+ private readonly _portedDrafts: {[path: string]: DraftInfo[]};
constructor(
- comments?: PathToCommentsInfoMap,
+ comments?: {[path: string]: CommentInfo[]},
robotComments?: {[path: string]: RobotCommentInfo[]},
drafts?: {[path: string]: DraftInfo[]},
- portedComments?: PathToCommentsInfoMap,
- portedDrafts?: PathToCommentsInfoMap
+ portedComments?: {[path: string]: CommentInfo[]},
+ portedDrafts?: {[path: string]: DraftInfo[]}
) {
this._comments = addPath(comments);
this._robotComments = addPath(robotComments);
@@ -64,26 +59,6 @@ export class ChangeComments {
return this._drafts;
}
- findCommentById(
- commentId?: UrlEncodedCommentId
- ): CommentInfo | DraftInfo | undefined {
- if (!commentId) return undefined;
- const findComment = (comments: {
- [path: string]: (CommentInfo | DraftInfo)[];
- }) => {
- let comment;
- for (const path of Object.keys(comments)) {
- comment = comment || comments[path].find(c => c.id === commentId);
- }
- return comment;
- };
- return (
- findComment(this._comments) ||
- findComment(this._robotComments) ||
- findComment(this._drafts)
- );
- }
-
/**
* Get an object mapping file paths to a boolean representing whether that
* path contains diff comments in the given patch set (including drafts and
@@ -127,7 +102,7 @@ export class ChangeComments {
*/
getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
const paths = this.getPaths();
- const publishedComments: {[path: string]: CommentInfo[]} = {};
+ const publishedComments: {[path: string]: Comment[]} = {};
for (const path of Object.keys(paths)) {
publishedComments[path] = this.getAllCommentsForPath(
path,
@@ -161,8 +136,8 @@ export class ChangeComments {
path: string,
patchNum?: PatchSetNum,
includeDrafts?: boolean
- ): CommentInfo[] {
- const comments: CommentInfo[] = this._comments[path] || [];
+ ): Comment[] {
+ const comments: Comment[] = this._comments[path] || [];
const robotComments = this._robotComments[path] || [];
let allComments = comments.concat(robotComments);
if (includeDrafts) {
@@ -217,7 +192,7 @@ export class ChangeComments {
*
* // TODO(taoalpha): maybe merge in *ForPath
*/
- getAllDraftsForFile(file: PatchSetFile): CommentInfo[] {
+ getAllDraftsForFile(file: PatchSetFile): DraftInfo[] {
let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
if (file.basePath) {
allDrafts = allDrafts.concat(
@@ -234,11 +209,9 @@ export class ChangeComments {
*
* @param patchRange The patch-range object containing patchNum
* and basePatchNum properties to represent the range.
- * @param projectConfig Optional project config object to
- * include in the meta sub-object.
*/
- getCommentsForPath(path: string, patchRange: PatchRange): CommentInfo[] {
- let comments: CommentInfo[] = [];
+ getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
+ let comments: Comment[] = [];
let drafts: DraftInfo[] = [];
let robotComments: RobotCommentInfo[] = [];
if (this._comments && this._comments[path]) {
@@ -296,7 +269,7 @@ export class ChangeComments {
file: PatchSetFile,
patchRange: PatchRange
): CommentThread[] {
- const portedComments = this._portedComments[file.path] || [];
+ const portedComments: Comment[] = this._portedComments[file.path] || [];
portedComments.push(...(this._portedDrafts[file.path] || []));
if (file.basePath) {
portedComments.push(...(this._portedComments[file.basePath] || []));
@@ -308,7 +281,7 @@ export class ChangeComments {
// ported comments will involve comments that may not belong to the
// current patchrange, so we need to form threads for them using all
// comments
- const allComments: CommentInfo[] = this.getAllCommentsForFile(file, true);
+ const allComments: Comment[] = this.getAllCommentsForFile(file, true);
return createCommentThreads(allComments).filter(thread => {
// Robot comments and drafts are not ported over. A human reply to
@@ -316,12 +289,12 @@ export class ChangeComments {
// have the root comment of the thread not be ported, hence loop over
// entire thread
const portedComment = portedComments.find(portedComment =>
- thread.comments.some(c => portedComment.id === c.id)
+ thread.comments.some(c => id(portedComment) === id(c))
);
if (!portedComment) return false;
const originalComment = thread.comments.find(
- comment => comment.id === portedComment.id
+ comment => id(comment) === id(portedComment)
)!;
// Original comment shown anyway? No need to port.
@@ -373,13 +346,8 @@ export class ChangeComments {
*
* @param patchRange The patch-range object containing patchNum
* and basePatchNum properties to represent the range.
- * @param projectConfig Optional project config object to
- * include in the meta sub-object.
*/
- getCommentsForFile(
- file: PatchSetFile,
- patchRange: PatchRange
- ): CommentInfo[] {
+ getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
const comments = this.getCommentsForPath(file.path, patchRange);
if (file.basePath) {
comments.push(...this.getCommentsForPath(file.basePath, patchRange));
@@ -395,24 +363,24 @@ export class ChangeComments {
}
/**
- * Computes the number of comment threads in a given file or patch.
+ * Computes the comment threads in a given file or patch.
*/
- computeCommentThreadCount(
+ computeCommentThreads(
file: PatchSetFile | PatchNumOnly,
ignorePatchsetLevelComments?: boolean
) {
- let comments: CommentInfo[] = [];
+ let comments: Comment[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
} else {
- comments = this._commentObjToArray<CommentInfo>(
+ comments = this._commentObjToArray<Comment>(
this.getAllPublishedComments(file.patchNum)
);
}
let threads = createCommentThreads(comments);
if (ignorePatchsetLevelComments)
threads = threads.filter(thread => !isPatchsetLevel(thread));
- return threads.length;
+ return threads;
}
/**
@@ -461,6 +429,23 @@ export class ChangeComments {
return getCommentForPath(file.__path) + getCommentForPath(file.old_path);
}
+ computeCommentsThreads(
+ patchRange: PatchRange,
+ path: string,
+ changeFileInfo?: FileInfo
+ ) {
+ const threads = this.getThreadsBySideForFile({path}, patchRange);
+ if (changeFileInfo?.old_path) {
+ threads.push(
+ ...this.getThreadsBySideForFile(
+ {path: changeFileInfo.old_path},
+ patchRange
+ )
+ );
+ }
+ return threads;
+ }
+
/**
* @param includeUnmodified Included unmodified status of the file in the
* comment string or not. For files we opt of chip instead of a string.
@@ -475,15 +460,11 @@ export class ChangeComments {
if (!path) return '';
if (!patchRange) return '';
- const threads = this.getThreadsBySideForFile({path}, patchRange);
- if (changeFileInfo?.old_path) {
- threads.push(
- ...this.getThreadsBySideForFile(
- {path: changeFileInfo.old_path},
- patchRange
- )
- );
- }
+ const threads = this.computeCommentsThreads(
+ patchRange,
+ path,
+ changeFileInfo
+ );
const commentThreadCount = threads.filter(
thread => !isDraftThread(thread)
).length;
@@ -516,8 +497,8 @@ export class ChangeComments {
file: PatchSetFile | PatchNumOnly,
ignorePatchsetLevelComments?: boolean
) {
- let comments: CommentInfo[] = [];
- let drafts: CommentInfo[] = [];
+ let comments: Comment[] = [];
+ let drafts: Comment[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
index b5ce128836..3e36ab52ff 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.ts
@@ -11,8 +11,6 @@ import {
isDraftThread,
isUnresolved,
createCommentThreads,
- DraftInfo,
- CommentThread,
} from '../../../utils/comment-util';
import {
createDraft,
@@ -26,10 +24,11 @@ import {CommentSide, FileInfoStatus} from '../../../constants/constants';
import {
BasePatchSetNum,
CommentInfo,
+ CommentThread,
+ DraftInfo,
PARENT,
PatchRange,
PatchSetNum,
- PathToCommentsInfoMap,
RevisionPatchSetNum,
RobotCommentInfo,
Timestamp,
@@ -49,7 +48,7 @@ suite('ChangeComments tests', () => {
});
suite('ported comments', () => {
- let portedComments: PathToCommentsInfoMap;
+ let portedComments: {[path: string]: CommentInfo[]};
const comment1: CommentInfo = {
...createComment(),
unresolved: true,
@@ -593,7 +592,7 @@ suite('ChangeComments tests', () => {
const robotComments: {[path: string]: RobotCommentInfo[]} = {
'file/one': [comments[0], comments[1]],
};
- const commentsByFile: PathToCommentsInfoMap = {
+ const commentsByFile: {[path: string]: CommentInfo[]} = {
'file/one': [comments[2], comments[3]],
'file/two': [comments[4], comments[5]],
'file/three': [comments[6], comments[7], comments[8]],
@@ -741,7 +740,7 @@ suite('ChangeComments tests', () => {
});
test('computeUnresolvedNum w/ non-linear thread', () => {
- const comments: PathToCommentsInfoMap = {
+ const comments: {[path: string]: CommentInfo[]} = {
path: [
{
id: '9c6ba3c6_28b7d467' as UrlEncodedCommentId,
@@ -911,30 +910,47 @@ suite('ChangeComments tests', () => {
);
});
- test('computeCommentThreadCount', () => {
+ test('computeCommentThreads - check length', () => {
assert.equal(
- changeComments.computeCommentThreadCount({
+ changeComments.computeCommentThreads({
patchNum: 2 as PatchSetNum,
path: 'file/one',
- }),
+ }).length,
3
);
- assert.equal(
- changeComments.computeCommentThreadCount({
+ assert.deepEqual(
+ changeComments.computeCommentThreads({
patchNum: 1 as PatchSetNum,
path: 'file/one',
}),
- 0
+ []
);
assert.equal(
- changeComments.computeCommentThreadCount({
+ changeComments.computeCommentThreads({
patchNum: 2 as PatchSetNum,
path: 'file/three',
- }),
+ }).length,
1
);
});
+ test('computeCommentThreads - check content', () => {
+ const expectedThreads: CommentThread[] = [
+ {
+ ...createCommentThread([{...comments[9], path: 'file/four'}]),
+ },
+ {
+ ...createCommentThread([{...comments[10], path: 'file/four'}]),
+ },
+ ];
+ assert.deepEqual(
+ changeComments.computeCommentThreads({
+ path: 'file/four',
+ }),
+ expectedThreads
+ );
+ });
+
test('computeDraftCount', () => {
assert.equal(
changeComments.computeDraftCount({
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index baf89c006e..6cc4048c2d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -21,21 +21,17 @@ import {
isNumber,
} from '../../../utils/patch-set-util';
import {
- CommentThread,
- equalLocation,
+ createNew,
isInBaseOfPatchRange,
isInRevisionOfPatchRange,
} from '../../../utils/comment-util';
-import {
- CommitRange,
- CoverageRange,
- DiffLayer,
- PatchSetFile,
-} from '../../../types/types';
+import {CoverageRange, DiffLayer, PatchSetFile} from '../../../types/types';
import {
Base64ImageFile,
BlameInfo,
ChangeInfo,
+ CommentThread,
+ DraftInfo,
EDIT,
NumericChangeId,
PARENT,
@@ -49,6 +45,7 @@ import {
DiffInfo,
DiffPreferencesInfo,
IgnoreWhitespaceType,
+ WebLinkInfo,
} from '../../../types/diff';
import {
CreateCommentEventDetail,
@@ -63,18 +60,20 @@ import {
firePageError,
fireAlert,
fireServerError,
- fireEvent,
- waitForEventOnce,
fire,
+ waitForEventOnce,
} from '../../../utils/event-util';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {assertIsDefined} from '../../../utils/common-util';
import {DiffContextExpandedEventDetail} from '../../../embed/diff/gr-diff-builder/gr-diff-builder';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
-import {Timing, Interaction} from '../../../constants/reporting';
+import {Timing} from '../../../constants/reporting';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
import {Subscription} from 'rxjs';
-import {DisplayLine, RenderPreferences} from '../../../api/diff';
+import {
+ DisplayLine,
+ LineSelectedEventDetail,
+ RenderPreferences,
+} from '../../../api/diff';
import {resolve} from '../../../models/dependency';
import {browserModelToken} from '../../../models/browser/browser-model';
import {commentsModelToken} from '../../../models/comments/comments-model';
@@ -84,7 +83,10 @@ import {distinctUntilChanged, map} from 'rxjs/operators';
import {deepEqual} from '../../../utils/deep-util';
import {Category} from '../../../api/checks';
import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
-import {CODE_MAX_LINES} from '../../../services/highlight/highlight-service';
+import {
+ CODE_MAX_LINES,
+ highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
import {html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {ValueChangedEvent} from '../../../types/events';
@@ -92,9 +94,11 @@ import {
debounceP,
DelayedPromise,
DELAYED_CANCELLATION,
+ noAwait,
} from '../../../utils/async-util';
import {subscribe} from '../../lit/subscription-controller';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
+import {userModelToken} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
const EMPTY_BLAME = 'No blame information for this diff.';
@@ -119,19 +123,19 @@ export interface LineInfo {
declare global {
interface HTMLElementEventMap {
- /* prettier-ignore */
- 'render': CustomEvent;
+ // prettier-ignore
+ 'render': CustomEvent<{}>;
'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
'create-comment': CustomEvent<CreateCommentEventDetail>;
'is-blame-loaded-changed': ValueChangedEvent<boolean>;
'diff-changed': ValueChangedEvent<DiffInfo | undefined>;
- 'edit-weblinks-changed': ValueChangedEvent<GeneratedWebLink[] | undefined>;
+ 'edit-weblinks-changed': ValueChangedEvent<WebLinkInfo[] | undefined>;
'files-weblinks-changed': ValueChangedEvent<FilesWebLinks | undefined>;
'is-image-diff-changed': ValueChangedEvent<boolean>;
// Fired when the user selects a line (See gr-diff).
- 'line-selected': CustomEvent;
+ 'line-selected': CustomEvent<LineSelectedEventDetail>;
// Fired if being logged in is required.
- 'show-auth-required': void;
+ 'show-auth-required': CustomEvent<{}>;
}
}
@@ -171,9 +175,6 @@ export class GrDiffHost extends LitElement {
@property({type: String})
projectName?: RepoName;
- @property({type: Boolean})
- displayLine = false;
-
@state()
private _isImageDiff = false;
@@ -187,17 +188,14 @@ export class GrDiffHost extends LitElement {
fire(this, 'is-image-diff-changed', {value: isImageDiff});
}
- @property({type: Object})
- commitRange?: CommitRange;
-
@state()
- private _editWeblinks?: GeneratedWebLink[];
+ private _editWeblinks?: WebLinkInfo[];
get editWeblinks() {
return this._editWeblinks;
}
- set editWeblinks(editWeblinks: GeneratedWebLink[] | undefined) {
+ set editWeblinks(editWeblinks: WebLinkInfo[] | undefined) {
if (this._editWeblinks === editWeblinks) return;
this._editWeblinks = editWeblinks;
fire(this, 'edit-weblinks-changed', {value: editWeblinks});
@@ -322,6 +320,8 @@ export class GrDiffHost extends LitElement {
private readonly getChecksModel = resolve(this, checksModelToken);
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
// visible for testing
readonly reporting = getAppContext().reportingService;
@@ -330,28 +330,19 @@ export class GrDiffHost extends LitElement {
private readonly restApiService = getAppContext().restApiService;
// visible for testing
- readonly userModel = getAppContext().userModel;
-
- // visible for testing
- readonly jsAPI = getAppContext().jsApiService;
+ readonly getUserModel = resolve(this, userModelToken);
// visible for testing
readonly syntaxLayer: GrSyntaxLayerWorker;
private checksSubscription?: Subscription;
- // for DIFF_AUTOCLOSE logging purposes only
- readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
constructor() {
super();
- this.syntaxLayer = new GrSyntaxLayerWorker();
- this.renderPrefs = {
- ...this.renderPrefs,
- use_lit_components: this.flags.isEnabled(
- KnownExperimentId.DIFF_RENDERING_LIT
- ),
- };
+ this.syntaxLayer = new GrSyntaxLayerWorker(
+ resolve(this, highlightServiceToken),
+ () => getAppContext().reportingService
+ );
this.addEventListener(
// These are named inconsistently for a reason:
// The create-comment event is fired to indicate that we should
@@ -372,7 +363,7 @@ export class GrDiffHost extends LitElement {
);
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
loggedIn => (this.loggedIn = loggedIn)
);
subscribe(
@@ -384,28 +375,11 @@ export class GrDiffHost extends LitElement {
);
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
diffPreferences => {
this.prefs = diffPreferences;
}
);
- this.logForDiffAutoClose();
- }
-
- // for DIFF_AUTOCLOSE logging purposes only
- private logForDiffAutoClose() {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_DIFF_HOST_CREATED,
- {uid: this.uid}
- );
- setTimeout(() => {
- if (!this.hasReloadBeenCalledOnce) {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_DIFF_HOST_NOT_RENDERING,
- {uid: this.uid}
- );
- }
- }, /* 10 seconds */ 10000);
}
override connectedCallback() {
@@ -480,7 +454,9 @@ export class GrDiffHost extends LitElement {
// this method calls getThreadEls which inspects the DOM. Also <gr-diff>
// only starts observing nodes (for thread element changes) after rendering
// is done.
- if (changedProperties.has('threads')) {
+ // Change in layers will likely cause gr-diff to update. Since we add
+ // threads manually we need to call threadsChanged in this case as well.
+ if (changedProperties.has('threads') || changedProperties.has('layers')) {
this.threadsChanged(this.threads);
}
}
@@ -525,7 +501,6 @@ export class GrDiffHost extends LitElement {
.noAutoRender=${this.noAutoRender}
.path=${this.path}
.prefs=${this.prefs}
- .displayLine=${this.displayLine}
.isImageDiff=${this.isImageDiff}
.noRenderOnPrefsChange=${this.noRenderOnPrefsChange}
.renderPrefs=${this.renderPrefs}
@@ -548,17 +523,16 @@ export class GrDiffHost extends LitElement {
async initLayers() {
const preferencesPromise = this.restApiService.getPreferences();
- await getPluginLoader().awaitPluginsLoaded();
const prefs = await preferencesPromise;
const enableTokenHighlight = !prefs?.disable_token_highlighting;
assertIsDefined(this.path, 'path');
- this.layers = this.getLayers(this.path, enableTokenHighlight);
+ this.layers = this.getLayers(enableTokenHighlight);
this.coverageRanges = [];
// We kick off fetching the data here, but we don't return the promise,
// so awaiting initLayers() will not wait for coverage data to be
// completely loaded.
- this.getCoverageData();
+ noAwait(this.getCoverageData());
}
/**
@@ -591,26 +565,19 @@ export class GrDiffHost extends LitElement {
return this.reloadPromise;
}
- // for DIFF_AUTOCLOSE logging purposes only
- private reloadOngoing = false;
-
- // for DIFF_AUTOCLOSE logging purposes only
- private hasReloadBeenCalledOnce = false;
-
async reloadInternal(shouldReportMetric?: boolean) {
- this.hasReloadBeenCalledOnce = true;
this.reporting.time(Timing.DIFF_TOTAL);
this.reporting.time(Timing.DIFF_LOAD);
+ // TODO: Find better names for these 3 clear/cancel methods. Ideally the
+ // <gr-diff-host> should not re-used at all for another diff rendering pass.
this.clear();
+ this.cancel();
+ this.clearDiffContent();
assertIsDefined(this.path, 'path');
assertIsDefined(this.changeNum, 'changeNum');
this.diff = undefined;
this.errorMessage = null;
const whitespaceLevel = this.getIgnoreWhitespace();
- if (this.reloadOngoing) {
- this.reporting.reportInteraction(Interaction.DIFF_AUTOCLOSE_DIFF_ONGOING);
- }
- this.reloadOngoing = true;
try {
// We are carefully orchestrating operations that have to wait for another
@@ -620,11 +587,6 @@ export class GrDiffHost extends LitElement {
// assets in parallel.
const layerPromise = this.initLayers();
const diff = await this.getDiff();
- if (diff === undefined) {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_DIFF_UNDEFINED
- );
- }
this.loadedWhitespaceLevel = whitespaceLevel;
this.reportDiff(diff);
@@ -671,7 +633,6 @@ export class GrDiffHost extends LitElement {
}
} finally {
this.reporting.timeEnd(Timing.DIFF_TOTAL, this.timingDetails());
- this.reloadOngoing = false;
}
}
@@ -705,19 +666,16 @@ export class GrDiffHost extends LitElement {
};
}
- private getLayers(path: string, enableTokenHighlight: boolean): DiffLayer[] {
+ private getLayers(enableTokenHighlight: boolean): DiffLayer[] {
const layers = [];
if (enableTokenHighlight) {
layers.push(new TokenHighlightLayer(this));
}
layers.push(this.syntaxLayer);
- // Get layers from plugins (if any).
- layers.push(...this.jsAPI.getDiffLayers(path));
return layers;
}
clear() {
- if (this.path) this.jsAPI.disposeDiffLayers(this.path);
this.layers = [];
}
@@ -762,9 +720,6 @@ export class GrDiffHost extends LitElement {
const idToEl = new Map<string, GrDiffCheckResult>();
const checkEls = this.getCheckEls();
const dontRemove = new Set<GrDiffCheckResult>();
- let createdCount = 0;
- let updatedCount = 0;
- let removedCount = 0;
const checksCount = checks.length;
const checkElsCount = checkEls.length;
if (checksCount === 0 && checkElsCount === 0) return;
@@ -779,23 +734,16 @@ export class GrDiffHost extends LitElement {
if (existingEl) {
existingEl.result = check;
dontRemove.add(existingEl);
- updatedCount++;
} else {
const newEl = this.createCheckEl(check);
dontRemove.add(newEl);
- createdCount++;
}
}
// Remove all check els that don't have a matching check anymore.
for (const el of checkEls) {
if (dontRemove.has(el)) continue;
el.remove();
- removedCount++;
}
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_CHECKS_UPDATED,
- {createdCount, updatedCount, removedCount, checksCount, checkElsCount}
- );
}
/**
@@ -832,7 +780,7 @@ export class GrDiffHost extends LitElement {
return el;
}
- private getCoverageData() {
+ private async getCoverageData() {
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.change, 'change');
assertIsDefined(this.path, 'path');
@@ -847,58 +795,39 @@ export class GrDiffHost extends LitElement {
const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
const patchNum = toNumberOnly(this.patchRange.patchNum);
- this.jsAPI
- .getCoverageAnnotationApis()
- .then(coverageAnnotationApis => {
- coverageAnnotationApis.forEach(coverageAnnotationApi => {
- const provider = coverageAnnotationApi.getCoverageProvider();
- if (!provider) return;
- provider(changeNum, path, basePatchNum, patchNum, change)
- .then(coverageRanges => {
- assertIsDefined(this.patchRange, 'patchRange');
- if (
- !coverageRanges ||
- changeNum !== this.changeNum ||
- change !== this.change ||
- path !== this.path ||
- basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
- patchNum !== toNumberOnly(this.patchRange.patchNum)
- ) {
- return;
- }
-
- const existingCoverageRanges = this.coverageRanges;
- this.coverageRanges = coverageRanges;
-
- // Notify with existing coverage ranges in case there is some
- // existing coverage data that needs to be removed
- existingCoverageRanges.forEach(range => {
- coverageAnnotationApi.notify(
- path,
- range.code_range.start_line,
- range.code_range.end_line,
- range.side
- );
- });
-
- // Notify with new coverage data
- coverageRanges.forEach(range => {
- coverageAnnotationApi.notify(
- path,
- range.code_range.start_line,
- range.code_range.end_line,
- range.side
- );
- });
- })
- .catch(err => {
- this.reporting.error('GrDiffHost Coverage', err);
- });
- });
- })
- .catch(err => {
- this.reporting.error('GrDiffHost Coverage', err);
- });
+ // We are simply waiting here for all plugins to be loaded. Ideally we would
+ // just react to state changes, but plugins are loaded quickly once at app
+ // startup, and coordinating incoming coverage providers with the reloading
+ // process seems to be complex enough to avoid it for the time being.
+ await this.getPluginLoader().awaitPluginsLoaded();
+ const plugins =
+ this.getPluginLoader().pluginsModel.getState().coveragePlugins;
+ const providers = plugins.map(p => p.provider);
+ for (const provider of providers) {
+ try {
+ const coverageRanges = await provider(
+ changeNum,
+ path,
+ basePatchNum,
+ patchNum,
+ change
+ );
+ assertIsDefined(this.patchRange, 'patchRange');
+ if (
+ !coverageRanges ||
+ changeNum !== this.changeNum ||
+ change !== this.change ||
+ path !== this.path ||
+ basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+ patchNum !== toNumberOnly(this.patchRange.patchNum)
+ ) {
+ continue;
+ }
+ this.coverageRanges = coverageRanges;
+ } catch (e) {
+ if (e instanceof Error) this.reporting.error('GrDiffHost Coverage', e);
+ }
+ }
}
private computeFileThreads(
@@ -1118,20 +1047,12 @@ export class GrDiffHost extends LitElement {
private threadsChanged(threads: CommentThread[]) {
const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
- const unsavedThreadEls: GrCommentThread[] = [];
const threadEls = this.getThreadEls();
for (const threadEl of threadEls) {
- if (threadEl.rootId) {
- rootIdToThreadEl.set(threadEl.rootId, threadEl);
- } else {
- // Unsaved thread els must have editing:true, just being defensive here.
- if (threadEl.editing) unsavedThreadEls.push(threadEl);
- }
+ assertIsDefined(threadEl.rootId, 'threadEl.rootId');
+ rootIdToThreadEl.set(threadEl.rootId, threadEl);
}
const dontRemove = new Set<GrCommentThread>();
- let createdCount = 0;
- let updatedCount = 0;
- let removedCount = 0;
const threadCount = threads.length;
const threadElCount = threadEls.length;
if (threadCount === 0 && threadElCount === 0) return;
@@ -1139,23 +1060,8 @@ export class GrDiffHost extends LitElement {
for (const thread of threads) {
// Let's find an existing DOM element matching the thread. Normally this
// is as simple as matching the rootIds.
- let existingThreadEl =
+ const existingThreadEl =
thread.rootId && rootIdToThreadEl.get(thread.rootId);
- // But unsaved threads don't have rootIds. The incoming thread might be
- // the saved version of the unsaved thread element. To verify that we
- // check that the thread only has one comment and that their location is
- // identical.
- // TODO(brohlfs): This matching is not perfect. You could quickly create
- // two new threads on the same line/range. Then this code just makes a
- // random guess.
- if (!existingThreadEl && thread.comments?.length === 1) {
- for (const unsavedThreadEl of unsavedThreadEls) {
- if (equalLocation(unsavedThreadEl.thread, thread)) {
- existingThreadEl = unsavedThreadEl;
- break;
- }
- }
- }
// There is a case possible where the rootIds match but the locations
// are different. Such as when a thread was originally attached on the
// right side of the diff but now should be attached on the left side of
@@ -1173,28 +1079,17 @@ export class GrDiffHost extends LitElement {
) {
existingThreadEl.thread = thread;
dontRemove.add(existingThreadEl);
- updatedCount++;
} else {
const threadEl = this.createThreadElement(thread);
this.attachThreadElement(threadEl);
dontRemove.add(threadEl);
- createdCount++;
}
}
// Remove all threads that are no longer existing.
for (const threadEl of this.getThreadEls()) {
if (dontRemove.has(threadEl)) continue;
- // The user may have opened a couple of comment boxes for editing. They
- // might be unsaved and thus not be reflected in `threads` yet, so let's
- // keep them open.
- if (threadEl.editing && threadEl.thread?.comments.length === 0) continue;
- removedCount++;
threadEl.remove();
}
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_THREADS_UPDATED,
- {createdCount, updatedCount, removedCount, threadCount, threadElCount}
- );
const portedThreadsCount = threads.filter(thread => thread.ported).length;
const portedThreadsWithoutRange = threads.filter(
thread => thread.ported && thread.rangeInfoLost
@@ -1247,24 +1142,21 @@ export class GrDiffHost extends LitElement {
assertIsDefined(path, 'path');
const parentIndex = this.computeParentIndex();
- const newThread: CommentThread = {
- rootId: undefined,
- comments: [],
- patchNum: patchNum as RevisionPatchSetNum,
- commentSide,
- // TODO: Maybe just compute from patchRange.base on the fly?
- mergeParentNum: parentIndex ?? undefined,
+ const draft: DraftInfo = {
+ ...createNew('', true),
+ patch_set: patchNum as RevisionPatchSetNum,
+ side: commentSide,
+ parent: parentIndex ?? undefined,
path,
- line: lineNum,
+ line: typeof lineNum === 'number' ? lineNum : undefined,
range,
};
- const el = this.createThreadElement(newThread);
- this.attachThreadElement(el);
+ this.getCommentsModel().addNewDraft(draft);
}
private canCommentOnPatchSetNum(patchNum: PatchSetNum) {
if (!this.loggedIn) {
- fireEvent(this, 'show-auth-required');
+ fire(this, 'show-auth-required', {});
return false;
}
if (!this.patchRange) {
@@ -1394,9 +1286,6 @@ export class GrDiffHost extends LitElement {
preferredWhitespaceLevel !== loadedWhitespaceLevel &&
!noRenderOnPrefsChange
) {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_RELOAD_ON_WHITESPACE
- );
return this.reload();
}
}
@@ -1415,9 +1304,6 @@ export class GrDiffHost extends LitElement {
if (oldPrefs?.syntax_highlighting === prefs.syntax_highlighting) return;
if (!noRenderOnPrefsChange) {
- this.reporting.reportInteraction(
- Interaction.DIFF_AUTOCLOSE_RELOAD_ON_SYNTAX
- );
return this.reload();
}
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 9d7736c0e2..43045f7cec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -33,6 +33,7 @@ import {
BasePatchSetNum,
BlameInfo,
CommentRange,
+ DraftInfo,
EDIT,
ImageInfo,
NumericChangeId,
@@ -46,18 +47,26 @@ import {GrDiffBuilderImage} from '../../../embed/diff/gr-diff-builder/gr-diff-bu
import {GrDiffHost, LineInfo} from './gr-diff-host';
import {DiffInfo, DiffViewMode, IgnoreWhitespaceType} from '../../../api/diff';
import {ErrorCallback} from '../../../api/rest';
-import {SinonStub} from 'sinon';
+import {SinonStub, SinonStubbedMember} from 'sinon';
import {RunResult} from '../../../models/checks/checks-model';
import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
import {assertIsDefined} from '../../../utils/common-util';
-import {GrAnnotationActionsInterface} from '../../shared/gr-js-api-interface/gr-annotation-actions-js-api';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken, UserModel} from '../../../models/user/user-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {
+ CommentsModel,
+ commentsModelToken,
+} from '../../../models/comments/comments-model';
suite('gr-diff-host tests', () => {
let element: GrDiffHost;
let account = createAccountDetailWithId(1);
- let getDiffRestApiStub: SinonStub;
+ let getDiffRestApiStub: SinonStubbedMember<RestApiService['getDiff']>;
+ let userModel: UserModel;
setup(async () => {
stubRestApi('getAccount').callsFake(() => Promise.resolve(account));
@@ -70,28 +79,7 @@ suite('gr-diff-host tests', () => {
// Fall back in case a test forgets to set one up
getDiffRestApiStub.returns(Promise.resolve(createDiff()));
await element.updateComplete;
- });
-
- suite('plugin layers', () => {
- let getDiffLayersStub: sinon.SinonStub;
- const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
- setup(async () => {
- element = await fixture(html`<gr-diff-host></gr-diff-host>`);
- getDiffLayersStub = sinon
- .stub(element.jsAPI, 'getDiffLayers')
- .returns(pluginLayers);
- element.changeNum = 123 as NumericChangeId;
- element.change = createChange();
- element.patchRange = createPatchRange();
- element.path = 'some/path';
- await element.updateComplete;
- });
-
- test('plugin layers requested', async () => {
- getDiffRestApiStub.returns(Promise.resolve(createDiff()));
- await element.reload();
- assert(getDiffLayersStub.called);
- });
+ userModel = testResolver(userModelToken);
});
suite('render reporting', () => {
@@ -592,7 +580,7 @@ suite('gr-diff-host tests', () => {
});
test('cannot create comments when not logged in', () => {
- element.userModel.setAccount(undefined);
+ userModel.setAccount(undefined);
element.patchRange = createPatchRange();
const showAuthRequireSpy = sinon.spy();
element.addEventListener('show-auth-required', showAuthRequireSpy);
@@ -681,7 +669,7 @@ suite('gr-diff-host tests', () => {
test('loadBlame', async () => {
const mockBlame: BlameInfo[] = [createBlame()];
const showAlertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+ element.addEventListener('show-alert', showAlertStub);
const getBlameStub = stubRestApi('getBlame').returns(
Promise.resolve(mockBlame)
);
@@ -713,7 +701,7 @@ suite('gr-diff-host tests', () => {
const mockBlame: BlameInfo[] = [];
const showAlertStub = sinon.stub();
const isBlameLoadedStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, showAlertStub);
+ element.addEventListener('show-alert', showAlertStub);
element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
stubRestApi('getBlame').returns(Promise.resolve(mockBlame));
const changeNum = 42 as NumericChangeId;
@@ -793,14 +781,6 @@ suite('gr-diff-host tests', () => {
assert.equal(element.diffElement.prefs, value);
});
- test('passes in displayLine', async () => {
- const value = true;
- element.displayLine = value;
- await element.updateComplete;
- assertIsDefined(element.diffElement);
- assert.equal(element.diffElement.displayLine, value);
- });
-
test('passes in hidden', async () => {
const value = true;
element.hidden = value;
@@ -843,7 +823,7 @@ suite('gr-diff-host tests', () => {
});
suite('reportDiff', () => {
- let reportStub: SinonStub;
+ let reportStub: SinonStubbedMember<ReportingService['reportInteraction']>;
setup(async () => {
element = await fixture(html`<gr-diff-host></gr-diff-host>`);
@@ -1024,7 +1004,12 @@ suite('gr-diff-host tests', () => {
});
suite('create-comment', () => {
+ let addDraftSpy: sinon.SinonSpy;
+
setup(async () => {
+ const commentsModel: CommentsModel = testResolver(commentsModelToken);
+ addDraftSpy = sinon.spy(commentsModel, 'addNewDraft');
+
account = createAccountDetailWithId(1);
element.disconnectedCallback();
element.connectedCallback();
@@ -1042,17 +1027,12 @@ suite('gr-diff-host tests', () => {
},
})
);
- assertIsDefined(element.diffElement);
- let threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- assert.equal(threads.length, 1);
- assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
- assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
- assert.equal(threads[0].thread?.range, undefined);
- assert.equal(threads[0].thread?.patchNum, 1 as RevisionPatchSetNum);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.PARENT);
+ assert.equal(draft1.range, undefined);
+ assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
// Try to fetch a thread with a different range.
const range = {
@@ -1074,17 +1054,11 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
-
- assert.equal(threads.length, 2);
- assert.equal(threads[0].thread?.commentSide, CommentSide.PARENT);
- assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
- assert.equal(threads[1].thread?.range, range);
- assert.equal(threads[1].thread?.patchNum, 1 as RevisionPatchSetNum);
+ assert.equal(addDraftSpy.callCount, 2);
+ const draft2: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft2.side, CommentSide.PARENT);
+ assert.equal(draft2.range, range);
+ assert.equal(draft2.patch_set, 1 as RevisionPatchSetNum);
});
test('should not be on parent if on the right', async () => {
@@ -1099,16 +1073,10 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- assert.equal(threads.length, 1);
- const threadEl = threads[0];
-
- assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
- assert.equal(threadEl.getAttribute('diff-side'), Side.RIGHT);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.REVISION);
+ assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
});
test('should be on parent if right and base is PARENT', () => {
@@ -1122,15 +1090,10 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- const threadEl = threads[0];
-
- assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
- assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.PARENT);
+ assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
});
test('should be on parent if right and base negative', () => {
@@ -1144,15 +1107,11 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- const threadEl = threads[0];
-
- assert.equal(threadEl.thread?.commentSide, CommentSide.PARENT);
- assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.PARENT);
+ assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
+ assert.equal(draft1.parent, 2);
});
test('should not be on parent otherwise', () => {
@@ -1165,15 +1124,10 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- const threadEl = threads[0];
-
- assert.equal(threadEl.thread?.commentSide, CommentSide.REVISION);
- assert.equal(threadEl.getAttribute('diff-side'), Side.LEFT);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.REVISION);
+ assert.equal(draft1.patch_set, 2 as RevisionPatchSetNum);
});
test(
@@ -1193,14 +1147,11 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- assert.equal(threads.length, 1);
- assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
- assert.equal(threads[0].thread?.path, element.file.basePath);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.REVISION);
+ assert.equal(draft1.patch_set, 2 as RevisionPatchSetNum);
+ assert.equal(draft1.path, element.file.basePath);
}
);
@@ -1221,15 +1172,11 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
-
- assert.equal(threads.length, 1);
- assert.equal(threads[0].getAttribute('diff-side'), Side.RIGHT);
- assert.equal(threads[0].thread?.path, element.file.path);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.REVISION);
+ assert.equal(draft1.patch_set, 3 as RevisionPatchSetNum);
+ assert.equal(draft1.path, element.file.path);
}
);
@@ -1282,48 +1229,6 @@ suite('gr-diff-host tests', () => {
assert.equal(threads.length, 2);
});
- test('unsaved thread changes to draft', async () => {
- element.patchRange = createPatchRange(2, 3);
- element.file = {basePath: 'file_renamed.txt', path: element.path ?? ''};
- element.threads = [];
- await element.updateComplete;
-
- element.dispatchEvent(
- new CustomEvent('create-comment', {
- detail: {
- side: Side.RIGHT,
- path: element.path,
- lineNum: 13,
- },
- })
- );
- await element.updateComplete;
- assert.equal(element.getThreadEls().length, 1);
- const threadEl = element.getThreadEls()[0];
- assert.equal(threadEl.thread?.line, 13);
- assert.isDefined(threadEl.unsavedComment);
- assert.equal(threadEl.thread?.comments.length, 0);
-
- const draftThread = createCommentThread([
- {
- path: element.path,
- patch_set: 3 as RevisionPatchSetNum,
- line: 13,
- __draft: true,
- },
- ]);
- element.threads = [draftThread];
- await element.updateComplete;
-
- // We expect that no additional thread element was created.
- assert.equal(element.getThreadEls().length, 1);
- // In fact the thread element must still be the same.
- assert.equal(element.getThreadEls()[0], threadEl);
- // But it must have been updated from unsaved to draft:
- assert.isUndefined(threadEl.unsavedComment);
- assert.equal(threadEl.thread?.comments.length, 1);
- });
-
test(
'thread should use new file path if first created ' +
'on patch set (left) but is base',
@@ -1341,21 +1246,17 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
-
- assert.equal(threads.length, 1);
- assert.equal(threads[0].getAttribute('diff-side'), Side.LEFT);
- assert.equal(threads[0].thread?.path, element.file.path);
+ assert.equal(addDraftSpy.callCount, 1);
+ const draft1: DraftInfo = addDraftSpy.lastCall.firstArg;
+ assert.equal(draft1.side, CommentSide.PARENT);
+ assert.equal(draft1.patch_set, 1 as RevisionPatchSetNum);
+ assert.equal(draft1.path, element.file.path);
}
);
test('cannot create thread on an edit', () => {
const alertSpy = sinon.spy();
- element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+ element.addEventListener('show-alert', alertSpy);
const diffSide = Side.RIGHT;
element.patchRange = {
@@ -1371,19 +1272,13 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
-
- assert.equal(threads.length, 0);
+ assert.isFalse(addDraftSpy.called);
assert.isTrue(alertSpy.called);
});
test('cannot create thread on an edit base', () => {
const alertSpy = sinon.spy();
- element.addEventListener(EventType.SHOW_ALERT, alertSpy);
+ element.addEventListener('show-alert', alertSpy);
const diffSide = Side.LEFT;
element.patchRange = {
@@ -1399,12 +1294,7 @@ suite('gr-diff-host tests', () => {
})
);
- assertIsDefined(element.diffElement);
- const threads =
- element.diffElement.querySelectorAll<GrCommentThread>(
- 'gr-comment-thread'
- );
- assert.equal(threads.length, 0);
+ assert.isFalse(addDraftSpy.called);
assert.isTrue(alertSpy.called);
});
});
@@ -1513,7 +1403,7 @@ suite('gr-diff-host tests', () => {
...createDiff(),
content: [
{
- a: [new Array(501).join('*')],
+ a: ['*'.repeat(501)],
},
],
})
@@ -1569,7 +1459,7 @@ suite('gr-diff-host tests', () => {
...createDiff(),
content: [
{
- a: [new Array(501).join('*')],
+ a: ['*'.repeat(501)],
},
],
})
@@ -1580,9 +1470,7 @@ suite('gr-diff-host tests', () => {
});
suite('coverage layer', () => {
- let notifyStub: SinonStub;
let coverageProviderStub: SinonStub;
- let getCoverageAnnotationApisStub: SinonStub;
const exampleRanges = [
{
type: CoverageType.COVERED,
@@ -1603,7 +1491,6 @@ suite('gr-diff-host tests', () => {
];
setup(async () => {
- notifyStub = sinon.stub();
coverageProviderStub = sinon
.stub()
.returns(Promise.resolve(exampleRanges));
@@ -1628,37 +1515,13 @@ suite('gr-diff-host tests', () => {
content: [{a: ['foo']}],
})
);
- getCoverageAnnotationApisStub = sinon
- .stub(element.jsAPI, 'getCoverageAnnotationApis')
- .returns(
- Promise.resolve([
- {
- notify: notifyStub,
- getCoverageProvider() {
- return coverageProviderStub;
- },
- } as unknown as GrAnnotationActionsInterface,
- ])
- );
+ testResolver(pluginLoaderToken).pluginsModel.coverageRegister({
+ pluginName: 'test-coverage-plugin',
+ provider: coverageProviderStub,
+ });
await element.reload();
});
- test('getCoverageAnnotationApis should be called', async () => {
- await element.waitForReloadToRender();
- assert.isTrue(getCoverageAnnotationApisStub.calledOnce);
- });
-
- test('coverageRangeChanged should be called', async () => {
- await element.waitForReloadToRender();
- assert.equal(notifyStub.callCount, 2);
- assert.isTrue(
- notifyStub.calledWithExactly('some/path', 1, 2, Side.RIGHT)
- );
- assert.isTrue(
- notifyStub.calledWithExactly('some/path', 3, 4, Side.RIGHT)
- );
- });
-
test('provider is called with appropriate params', async () => {
element.patchRange = createPatchRange(1, 3);
await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
index 17004d9231..d580127780 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -5,31 +5,27 @@
*/
import '../../shared/gr-button/gr-button';
import '../../shared/gr-diff-preferences/gr-diff-preferences';
-import '../../shared/gr-overlay/gr-overlay';
import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
-import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {assertIsDefined} from '../../../utils/common-util';
import {sharedStyles} from '../../../styles/shared-styles';
-import {LitElement, html, css, PropertyValues} from 'lit';
+import {LitElement, html, css} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {ValueChangedEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {fireNoBubble} from '../../../utils/event-util';
@customElement('gr-diff-preferences-dialog')
export class GrDiffPreferencesDialog extends LitElement {
@query('#diffPreferences') private diffPreferences?: GrDiffPreferences;
- @query('#saveButton') private saveButton?: GrButton;
-
- @query('#cancelButton') private cancelButton?: GrButton;
-
- @query('#diffPrefsOverlay') private diffPrefsOverlay?: GrOverlay;
+ @query('#diffPrefsModal') private diffPrefsModal?: HTMLDialogElement;
@state() diffPrefsChanged?: boolean;
static override get styles() {
return [
sharedStyles,
+ modalStyles,
css`
.diffHeader,
.diffActions {
@@ -48,7 +44,7 @@ export class GrDiffPreferencesDialog extends LitElement {
display: flex;
justify-content: flex-end;
}
- .diffPrefsOverlay gr-button {
+ .diffPrefsModal gr-button {
margin-left: var(--spacing-l);
}
div.edited:after {
@@ -65,7 +61,7 @@ export class GrDiffPreferencesDialog extends LitElement {
override render() {
return html`
- <gr-overlay id="diffPrefsOverlay" with-backdrop="">
+ <dialog id="diffPrefsModal" tabindex="-1">
<div role="dialog" aria-labelledby="diffPreferencesTitle">
<h3
class="heading-3 diffHeader ${this.diffPrefsChanged
@@ -100,26 +96,10 @@ export class GrDiffPreferencesDialog extends LitElement {
</gr-button>
</div>
</div>
- </gr-overlay>
+ </dialog>
`;
}
- override willUpdate(changedProperties: PropertyValues) {
- if (changedProperties.has('diffPrefsChanged')) {
- this.onDiffPrefsChanged();
- }
- }
-
- getFocusStops() {
- assertIsDefined(this.diffPreferences, 'diffPreferences');
- assertIsDefined(this.saveButton, 'saveButton');
- assertIsDefined(this.cancelButton, 'cancelbutton');
- return {
- start: this.diffPreferences.contextSelect!,
- end: this.saveButton.disabled ? this.cancelButton : this.saveButton,
- };
- }
-
resetFocus() {
assertIsDefined(this.diffPreferences, 'diffPreferences');
@@ -128,35 +108,21 @@ export class GrDiffPreferencesDialog extends LitElement {
private readonly handleCancelDiff = (e: MouseEvent) => {
e.stopPropagation();
- assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
- this.diffPrefsOverlay.close();
+ assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+ this.diffPrefsModal.close();
};
- private onDiffPrefsChanged() {
- assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
- this.diffPrefsOverlay.setFocusStops(this.getFocusStops());
- }
-
open() {
- assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
- this.diffPrefsOverlay.open().then(() => {
- const focusStops = this.getFocusStops();
- this.diffPrefsOverlay!.setFocusStops(focusStops);
- this.resetFocus();
- });
+ assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
+ this.diffPrefsModal.showModal();
}
private async handleSaveDiffPreferences() {
assertIsDefined(this.diffPreferences, 'diffPreferences');
- assertIsDefined(this.diffPrefsOverlay, 'diffPrefsOverlay');
+ assertIsDefined(this.diffPrefsModal, 'diffPrefsModal');
await this.diffPreferences.save();
- this.dispatchEvent(
- new CustomEvent('reload-diff-preference', {
- composed: true,
- bubbles: false,
- })
- );
- this.diffPrefsOverlay.close();
+ fireNoBubble(this, 'reload-diff-preference', {});
+ this.diffPrefsModal.close();
}
private readonly handleHasUnsavedChangesChanged = (
@@ -170,4 +136,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-diff-preferences-dialog': GrDiffPreferencesDialog;
}
+ interface HTMLElementEventMap {
+ 'reload-diff-preference': CustomEvent<{}>;
+ }
}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 1b484b07a0..7fc1044819 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -36,13 +36,7 @@ suite('gr-diff-preferences-dialog', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-overlay
- aria-hidden="true"
- id="diffPrefsOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="diffPrefsModal" tabindex="-1">
<div aria-labelledby="diffPreferencesTitle" role="dialog">
<h3 class="diffHeader heading-3" id="diffPreferencesTitle">
Diff Preferences
@@ -71,7 +65,7 @@ suite('gr-diff-preferences-dialog', () => {
</gr-button>
</div>
</div>
- </gr-overlay>
+ </dialog>
`
);
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index 057a20a743..bdb634b389 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -12,6 +12,7 @@ import '../../shared/gr-dropdown/gr-dropdown';
import '../../shared/gr-dropdown-list/gr-dropdown-list';
import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-select/gr-select';
+import '../../shared/gr-weblink/gr-weblink';
import '../../shared/revision-info/revision-info';
import '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
@@ -20,22 +21,12 @@ import '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
import '../gr-patch-range-select/gr-patch-range-select';
import '../../change/gr-download-dialog/gr-download-dialog';
-import '../../shared/gr-overlay/gr-overlay';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {getAppContext} from '../../../services/app-context';
+import {isMergeParent, getParentIndex} from '../../../utils/patch-set-util';
import {
- computeAllPatchSets,
- computeLatestPatchNum,
- PatchSet,
- isMergeParent,
- getParentIndex,
-} from '../../../utils/patch-set-util';
-import {
- addUnmodifiedFiles,
computeDisplayPath,
computeTruncatedPath,
isMagicPath,
- specialFilePathCompare,
} from '../../../utils/path-list-util';
import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -43,52 +34,36 @@ import {
DropdownItem,
GrDropdownList,
} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {CommentAnchorTapEventDetail} from '../../shared/gr-comment/gr-comment';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {
BasePatchSetNum,
- ChangeInfo,
- CommitId,
EDIT,
- FileInfo,
NumericChangeId,
PARENT,
PatchRange,
- PatchSetNum,
PatchSetNumber,
PreferencesInfo,
RepoName,
- RevisionInfo,
RevisionPatchSetNum,
ServerInfo,
+ CommentMap,
} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
+import {FileRange, ParsedChangeInfo} from '../../../types/types';
import {
- CommitRange,
- EditRevisionInfo,
- FileRange,
- ParsedChangeInfo,
-} from '../../../types/types';
-import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+ FilesWebLinks,
+ PatchRangeChangeEvent,
+} from '../gr-patch-range-select/gr-patch-range-select';
import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
-import {
- CommentMap,
- getPatchRangeForCommentUrl,
- isInBaseOfPatchRange,
-} from '../../../utils/comment-util';
-import {
- EventType,
- OpenFixPreviewEvent,
- ValueChangedEvent,
-} from '../../../types/events';
-import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
-import {assertIsDefined} from '../../../utils/common-util';
-import {Key, toggleClass} from '../../../utils/dom-util';
+import {OpenFixPreviewEvent, ValueChangedEvent} from '../../../types/events';
+import {fireAlert, fire} from '../../../utils/event-util';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {toggleClass, whenVisible} from '../../../utils/dom-util';
import {CursorMoveResult} from '../../../api/core';
-import {isFalse, throttleWrap, until} from '../../../utils/async-util';
+import {throttleWrap} from '../../../utils/async-util';
import {filter, take, switchMap} from 'rxjs/operators';
import {combineLatest} from 'rxjs';
import {
@@ -96,15 +71,12 @@ import {
ShortcutSection,
shortcutsServiceToken,
} from '../../../services/shortcuts/shortcuts-service';
-import {LoadingStatus} from '../../../models/change/change-model';
-import {DisplayLine} from '../../../api/diff';
+import {DisplayLine, LineSelectedEventDetail} from '../../../api/diff';
import {GrDownloadDialog} from '../../change/gr-download-dialog/gr-download-dialog';
-import {browserModelToken} from '../../../models/browser/browser-model';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {resolve} from '../../../models/dependency';
-import {BehaviorSubject} from 'rxjs';
-import {css, html, LitElement, PropertyValues} from 'lit';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {ShortcutController} from '../../lit/shortcut-controller';
import {subscribe} from '../../lit/subscription-controller';
import {customElement, property, query, state} from 'lit/decorators.js';
@@ -115,12 +87,17 @@ import {ifDefined} from 'lit/directives/if-defined.js';
import {when} from 'lit/directives/when.js';
import {
createDiffUrl,
- diffViewModelToken,
- DiffViewState,
-} from '../../../models/views/diff';
-import {createChangeUrl} from '../../../models/views/change';
-import {createEditUrl} from '../../../models/views/edit';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
+ ChangeChildView,
+ changeViewModelToken,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {GrDiffPreferencesDialog} from '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {
+ FileNameToNormalizedFileInfoMap,
+ filesModelToken,
+} from '../../../models/change/files-model';
const LOADING_BLAME = 'Loading blame...';
const LOADED_BLAME = 'Blame loaded';
@@ -130,23 +107,14 @@ const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
// visible for testing
export interface Files {
- sortedFileList: string[];
- changeFilesByPath: {[path: string]: FileInfo};
+ /** All file paths sorted by `specialFilePathCompare`. */
+ sortedPaths: string[];
+ changeFilesByPath: FileNameToNormalizedFileInfoMap;
}
-interface CommentSkips {
- previous: string | null;
- next: string | null;
-}
@customElement('gr-diff-view')
export class GrDiffView extends LitElement {
/**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
* Fired when user tries to navigate away while comments are pending save.
*
* @event show-alert
@@ -154,11 +122,11 @@ export class GrDiffView extends LitElement {
@query('#diffHost')
diffHost?: GrDiffHost;
- @query('#reviewed')
- reviewed?: HTMLInputElement;
+ @state()
+ reviewed = false;
- @query('#downloadOverlay')
- downloadOverlay?: GrOverlay;
+ @query('#downloadModal')
+ downloadModal?: HTMLDialogElement;
@query('#downloadDialog')
downloadDialog?: GrDownloadDialog;
@@ -170,35 +138,33 @@ export class GrDiffView extends LitElement {
applyFixDialog?: GrApplyFixDialog;
@query('#diffPreferencesDialog')
- diffPreferencesDialog?: GrOverlay;
-
- private _viewState: DiffViewState | undefined;
+ diffPreferencesDialog?: GrDiffPreferencesDialog;
+ // Private but used in tests.
@state()
- get viewState(): DiffViewState | undefined {
- return this._viewState;
- }
-
- set viewState(viewState: DiffViewState | undefined) {
- if (this._viewState === viewState) return;
- const oldViewState = this._viewState;
- this._viewState = viewState;
- this.viewStateChanged();
- this.requestUpdate('viewState', oldViewState);
+ get patchRange(): PatchRange | undefined {
+ if (!this.patchNum) return undefined;
+ return {
+ patchNum: this.patchNum,
+ basePatchNum: this.basePatchNum,
+ };
}
// Private but used in tests.
@state()
- patchRange?: PatchRange;
+ patchNum?: RevisionPatchSetNum;
// Private but used in tests.
@state()
- commitRange?: CommitRange;
+ basePatchNum: BasePatchSetNum = PARENT;
// Private but used in tests.
@state()
change?: ParsedChangeInfo;
+ @state()
+ latestPatchNum?: PatchSetNumber;
+
// Private but used in tests.
@state()
changeComments?: ChangeComments;
@@ -211,34 +177,19 @@ export class GrDiffView extends LitElement {
@state()
diff?: DiffInfo;
- // TODO: Move to using files-model.
// Private but used in tests.
@state()
- files: Files = {sortedFileList: [], changeFilesByPath: {}};
-
- // Private but used in tests
- // Use path getter/setter.
- _path?: string;
+ files: Files = {sortedPaths: [], changeFilesByPath: {}};
- get path() {
- return this._path;
- }
-
- set path(path: string | undefined) {
- if (this._path === path) return;
- const oldPath = this._path;
- this._path = path;
- this.pathChanged();
- this.requestUpdate('path', oldPath);
- }
+ @state() path?: string;
+ /** Allows us to react when the user switches to the DIFF view. */
// Private but used in tests.
- @state()
- loggedIn = false;
+ @state() isActiveChildView = false;
// Private but used in tests.
@state()
- loading = true;
+ loggedIn = false;
@property({type: Object})
prefs?: DiffPreferencesInfo;
@@ -254,79 +205,68 @@ export class GrDiffView extends LitElement {
private isImageDiff?: boolean;
@state()
- private editWeblinks?: GeneratedWebLink[];
+ private editWeblinks?: WebLinkInfo[];
@state()
private filesWeblinks?: FilesWebLinks;
// Private but used in tests.
@state()
- commentMap?: CommentMap;
-
- @state()
- private commentSkips?: CommentSkips;
-
- // Private but used in tests.
- @state()
isBlameLoaded?: boolean;
@state()
private isBlameLoading = false;
- @state()
- private allPatchSets?: PatchSet[] = [];
-
+ /** Directly reflects the view model property `diffView.lineNum`. */
// Private but used in tests.
@state()
focusLineNum?: number;
+ /** Directly reflects the view model property `diffView.leftSide`. */
+ @state()
+ leftSide = false;
+
// visible for testing
reviewedFiles = new Set<string>();
private readonly reporting = getAppContext().reportingService;
- private readonly restApiService = getAppContext().restApiService;
+ private readonly getUserModel = resolve(this, userModelToken);
- // Private but used in tests.
- readonly routerModel = getAppContext().routerModel;
+ private readonly getChangeModel = resolve(this, changeModelToken);
- // Private but used in tests.
- readonly userModel = getAppContext().userModel;
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
- // Private but used in tests.
- readonly getChangeModel = resolve(this, changeModelToken);
-
- // Private but used in tests.
- readonly getBrowserModel = resolve(this, browserModelToken);
-
- // Private but used in tests.
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getFilesModel = resolve(this, filesModelToken);
private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
private readonly getConfigModel = resolve(this, configModelToken);
- private readonly getViewModel = resolve(this, diffViewModelToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
@state()
cursor?: GrDiffCursor;
- private connected$ = new BehaviorSubject(false);
-
private readonly shortcutsController = new ShortcutController(this);
- private readonly getNavigation = resolve(this, navigationToken);
-
constructor() {
super();
this.setupKeyboardShortcuts();
this.setupSubscriptions();
subscribe(
this,
- () => this.getViewModel().state$,
- x => (this.viewState = x)
+ () => this.getFilesModel().filesIncludingUnmodified$,
+ files => {
+ const filesByPath: FileNameToNormalizedFileInfoMap = {};
+ for (const f of files) filesByPath[f.__path] = f;
+ this.files = {
+ sortedPaths: files.map(f => f.__path),
+ changeFilesByPath: filesByPath,
+ };
+ }
);
}
@@ -340,10 +280,10 @@ export class GrDiffView extends LitElement {
listen(Shortcut.PREV_LINE, _ => this.handlePrevLine());
listen(Shortcut.VISIBLE_LINE, _ => this.cursor?.moveToVisibleArea());
listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
- this.moveToNextFileWithComment()
+ this.moveToFileWithComment(1)
);
listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
- this.moveToPreviousFileWithComment()
+ this.moveToFileWithComment(-1)
);
listen(Shortcut.NEW_COMMENT, _ => this.handleNewComment());
listen(Shortcut.SAVE_COMMENT, _ => {});
@@ -356,7 +296,9 @@ export class GrDiffView extends LitElement {
listen(Shortcut.OPEN_REPLY_DIALOG, _ => this.handleOpenReplyDialog());
listen(Shortcut.TOGGLE_LEFT_PANE, _ => this.handleToggleLeftPane());
listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ => this.handleOpenDownloadDialog());
- listen(Shortcut.UP_TO_CHANGE, _ => this.handleUpToChange());
+ listen(Shortcut.UP_TO_CHANGE, _ =>
+ this.getChangeModel().navigateToChange()
+ );
listen(Shortcut.OPEN_DIFF_PREFS, _ => this.handleCommaKey());
listen(Shortcut.TOGGLE_DIFF_MODE, _ => this.handleToggleDiffMode());
listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
@@ -386,16 +328,12 @@ export class GrDiffView extends LitElement {
);
listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}); // docOnly
listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}); // docOnly
- this.shortcutsController.addGlobal({key: Key.ESC}, _ => {
- assertIsDefined(this.diffHost, 'diffHost');
- this.diffHost.displayLine = false;
- });
}
private setupSubscriptions() {
subscribe(
this,
- () => this.userModel.loggedIn$,
+ () => this.getUserModel().loggedIn$,
loggedIn => {
this.loggedIn = loggedIn;
}
@@ -416,14 +354,14 @@ export class GrDiffView extends LitElement {
);
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
preferences => {
this.userPrefs = preferences;
}
);
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
diffPreferences => {
this.prefs = diffPreferences;
}
@@ -439,6 +377,11 @@ export class GrDiffView extends LitElement {
);
subscribe(
this,
+ () => this.getChangeModel().latestPatchNum$,
+ latestPatchNum => (this.latestPatchNum = latestPatchNum)
+ );
+ subscribe(
+ this,
() => this.getChangeModel().reviewedFiles$,
reviewedFiles => {
this.reviewedFiles = new Set(reviewedFiles) ?? new Set();
@@ -446,45 +389,80 @@ export class GrDiffView extends LitElement {
);
subscribe(
this,
- () => this.getChangeModel().diffPath$,
+ () => this.getViewModel().changeNum$,
+ changeNum => {
+ if (!changeNum || this.changeNum === changeNum) return;
+
+ // We are only setting the changeNum of the diff view once.
+ // Everything in the diff view is tied to the change. It seems better to
+ // force the re-creation of the diff view when the change number changes.
+ // The parent element will make sure that a new change view is created
+ // when the change number changes (using the `keyed` directive).
+ if (!this.changeNum) this.changeNum = changeNum;
+ }
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().childView$,
+ childView => (this.isActiveChildView = childView === ChangeChildView.DIFF)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().diffPath$,
path => (this.path = path)
);
-
+ subscribe(
+ this,
+ () => this.getViewModel().diffLine$,
+ line => (this.focusLineNum = line)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().diffLeftSide$,
+ leftSide => (this.leftSide = leftSide)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().patchNum$,
+ patchNum => (this.patchNum = patchNum)
+ );
+ subscribe(
+ this,
+ () => this.getViewModel().basePatchNum$,
+ basePatchNum => (this.basePatchNum = basePatchNum ?? PARENT)
+ );
subscribe(
this,
() =>
combineLatest([
- this.getChangeModel().diffPath$,
+ this.getViewModel().diffPath$,
this.getChangeModel().reviewedFiles$,
]),
([path, files]) => {
- this.updateComplete.then(() => {
- assertIsDefined(this.reviewed, 'reviewed');
- this.reviewed.checked = !!path && !!files && files.includes(path);
- });
+ this.reviewed = !!path && !!files && files.includes(path);
}
);
- // When user initially loads the diff view, we want to autmatically mark
+ // When user initially loads the diff view, we want to automatically mark
// the file as reviewed if they have it enabled. We can't observe these
// properties since the method will be called anytime a property updates
// but we only want to call this on the initial load.
subscribe(
this,
() =>
- this.getChangeModel().diffPath$.pipe(
+ this.getViewModel().diffPath$.pipe(
filter(diffPath => !!diffPath),
switchMap(() =>
combineLatest([
this.getChangeModel().patchNum$,
- this.routerModel.routerView$,
- this.userModel.diffPreferences$,
+ this.getViewModel().childView$,
+ this.getUserModel().diffPreferences$,
this.getChangeModel().reviewedFiles$,
]).pipe(
filter(
- ([patchNum, routerView, diffPrefs, reviewedFiles]) =>
+ ([patchNum, childView, diffPrefs, reviewedFiles]) =>
!!patchNum &&
- routerView === GerritView.DIFF &&
+ childView === ChangeChildView.DIFF &&
!!diffPrefs &&
!!reviewedFiles
),
@@ -493,20 +471,18 @@ export class GrDiffView extends LitElement {
)
),
([patchNum, _routerView, diffPrefs]) => {
- this.setReviewedStatus(patchNum!, diffPrefs);
+ // `patchNum` must be defined, because of the `!!patchNum` filter above.
+ assertIsDefined(patchNum, 'patchNum');
+ this.setReviewedStatus(patchNum, diffPrefs);
}
);
- subscribe(
- this,
- () => this.getChangeModel().diffPath$,
- path => (this.path = path)
- );
}
static override get styles() {
return [
a11yStyles,
sharedStyles,
+ modalStyles,
css`
:host {
display: block;
@@ -692,48 +668,21 @@ export class GrDiffView extends LitElement {
override connectedCallback() {
super.connectedCallback();
- this.connected$.next(true);
this.throttledToggleFileReviewed = throttleWrap(_ =>
this.handleToggleFileReviewed()
);
this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e));
this.cursor = new GrDiffCursor();
+ if (this.diffHost) this.reInitCursor();
}
override disconnectedCallback() {
this.cursor?.dispose();
- this.connected$.next(false);
super.disconnectedCallback();
}
- protected override willUpdate(changedProperties: PropertyValues) {
- super.willUpdate(changedProperties);
- if (changedProperties.has('change')) {
- this.allPatchSets = computeAllPatchSets(this.change);
- }
- if (
- changedProperties.has('commentMap') ||
- changedProperties.has('files') ||
- changedProperties.has('path')
- ) {
- this.commentSkips = this.computeCommentSkips(
- this.commentMap,
- this.files?.sortedFileList,
- this.path
- );
- }
-
- if (
- changedProperties.has('changeNum') ||
- changedProperties.has('changeComments') ||
- changedProperties.has('patchRange')
- ) {
- this.fetchFiles();
- }
- }
-
private reInitCursor() {
- assertIsDefined(this.diffHost, 'diffHost');
+ if (!this.diffHost) return;
this.cursor?.replaceDiffs([this.diffHost]);
this.cursor?.reInitCursor();
}
@@ -741,16 +690,35 @@ export class GrDiffView extends LitElement {
protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (
+ changedProperties.has('change') ||
+ changedProperties.has('path') ||
+ changedProperties.has('patchNum') ||
+ changedProperties.has('basePatchNum')
+ ) {
+ this.reloadDiff();
+ } else if (
+ changedProperties.has('isActiveChildView') &&
+ this.isActiveChildView
+ ) {
+ this.initializePositions();
+ }
+ if (
+ changedProperties.has('focusLineNum') ||
+ changedProperties.has('leftSide')
+ ) {
+ this.initCursor();
+ }
+ if (
+ changedProperties.has('change') ||
changedProperties.has('changeComments') ||
changedProperties.has('path') ||
- changedProperties.has('patchRange') ||
+ changedProperties.has('patchNum') ||
+ changedProperties.has('basePatchNum') ||
changedProperties.has('files')
) {
- if (this.changeComments && this.path && this.patchRange) {
+ if (this.change && this.changeComments && this.path && this.patchRange) {
assertIsDefined(this.diffHost, 'diffHost');
- const file = this.files?.changeFilesByPath
- ? this.files.changeFilesByPath[this.path]
- : undefined;
+ const file = this.files?.changeFilesByPath?.[this.path];
this.diffHost.updateComplete.then(() => {
assertIsDefined(this.path);
assertIsDefined(this.patchRange);
@@ -766,23 +734,25 @@ export class GrDiffView extends LitElement {
}
override render() {
+ if (!this.isActiveChildView) return nothing;
+ if (!this.patchNum || !this.changeNum || !this.change || !this.path) {
+ return html`<div class="loading">Loading...</div>`;
+ }
const file = this.getFileRange();
return html`
${this.renderStickyHeader()}
- <div class="loading" ?hidden=${!this.loading}>Loading...</div>
<h2 class="assistive-tech-only">Diff view</h2>
<gr-diff-host
id="diffHost"
- ?hidden=${this.loading}
.changeNum=${this.changeNum}
.change=${this.change}
- .commitRange=${this.commitRange}
.patchRange=${this.patchRange}
.file=${file}
+ .lineOfInterest=${this.getLineOfInterest()}
.path=${this.path}
.projectName=${this.change?.project}
@is-blame-loaded-changed=${this.onIsBlameLoadedChanged}
- @comment-anchor-tap=${this.onLineSelected}
+ @comment-anchor-tap=${this.onCommentAnchorTap}
@line-selected=${this.onLineSelected}
@diff-changed=${this.onDiffChanged}
@edit-weblinks-changed=${this.onEditWeblinksChanged}
@@ -797,7 +767,7 @@ export class GrDiffView extends LitElement {
private renderStickyHeader() {
return html` <div
- class="stickyHeader ${this.computeEditMode() ? 'editMode' : ''}"
+ class="stickyHeader ${this.patchNum === EDIT ? 'editMode' : ''}"
>
<h1 class="assistive-tech-only">
Diff of ${this.path ? computeTruncatedPath(this.path) : ''}
@@ -823,7 +793,8 @@ export class GrDiffView extends LitElement {
const fileNum = this.computeFileNum(formattedFiles);
const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles);
return html` <div>
- <a href=${this.getChangePath()}>${this.changeNum}</a
+ <a href=${ifDefined(this.getChangeModel().changeUrl())}
+ >${this.changeNum}</a
><span class="changeNumberColon">:</span>
<span class="headerSubject">${this.change?.subject}</span>
<input
@@ -833,6 +804,7 @@ export class GrDiffView extends LitElement {
?hidden=${!this.loggedIn}
title="Toggle reviewed status of file"
aria-label="file reviewed"
+ .checked=${this.reviewed}
@change=${this.handleReviewedChange}
/>
<div class="jumpToFileContainer">
@@ -866,7 +838,7 @@ export class GrDiffView extends LitElement {
Shortcut.UP_TO_CHANGE,
ShortcutSection.NAVIGATION
)}
- href=${this.getChangePath()}
+ href=${ifDefined(this.getChangeModel().changeUrl())}
>Up</a
>
<span class="separator"></span>
@@ -943,26 +915,22 @@ export class GrDiffView extends LitElement {
() => html`
<span class="separator"></span>
${this.editWeblinks!.map(
- weblink => html`
- <a target="_blank" href=${ifDefined(weblink.url)}
- >${weblink.name}</a
- >
- `
+ weblink => html`<gr-weblink .info=${weblink}></gr-weblink>`
)}
`
)}
- <span class="separator"></span>
- <div class="diffModeSelector ${diffModeSelectorClass}">
- <span>Diff view:</span>
- <gr-diff-mode-selector
- id="modeSelect"
- .saveOnChange=${this.loggedIn}
- show-tooltip-below
- ></gr-diff-mode-selector>
- </div>
${when(
this.loggedIn && this.prefs,
() => html`
+ <span class="separator"></span>
+ <div class="diffModeSelector ${diffModeSelectorClass}">
+ <span>Diff view:</span>
+ <gr-diff-mode-selector
+ id="modeSelect"
+ .saveOnChange=${this.loggedIn}
+ show-tooltip-below
+ ></gr-diff-mode-selector>
+ </div>
<span id="diffPrefsContainer">
<span class="preferences desktop">
<gr-tooltip-content
@@ -1009,15 +977,15 @@ export class GrDiffView extends LitElement {
@reload-diff-preference=${this.handleReloadingDiffPreference}
>
</gr-diff-preferences-dialog>
- <gr-overlay id="downloadOverlay">
+ <dialog id="downloadModal" tabindex="-1">
<gr-download-dialog
id="downloadDialog"
.change=${this.change}
- .patchNum=${this.patchRange?.patchNum}
+ .patchNum=${this.patchNum}
.config=${this.serverConfig?.download}
@close=${this.handleDownloadDialogClose}
></gr-download-dialog>
- </gr-overlay>`;
+ </dialog>`;
}
/**
@@ -1038,36 +1006,12 @@ export class GrDiffView extends LitElement {
if (!this.files || !this.path) return;
const fileInfo = this.files.changeFilesByPath[this.path];
const fileRange: FileRange = {path: this.path};
- if (fileInfo && fileInfo.old_path) {
+ if (fileInfo?.old_path) {
fileRange.basePath = fileInfo.old_path;
}
return fileRange;
}
- // Private but used in tests.
- fetchFiles() {
- if (!this.changeNum || !this.patchRange || !this.changeComments) {
- return Promise.resolve();
- }
-
- if (!this.patchRange.patchNum) {
- return Promise.resolve();
- }
-
- return this.restApiService
- .getChangeFiles(this.changeNum, this.patchRange)
- .then(changeFiles => {
- if (!changeFiles) return;
- const commentedPaths = this.changeComments!.getPaths(this.patchRange);
- const files = {...changeFiles};
- addUnmodifiedFiles(files, commentedPaths);
- this.files = {
- sortedFileList: Object.keys(files).sort(specialFilePathCompare),
- changeFilesByPath: files,
- };
- });
- }
-
private handleReviewedChange(e: Event) {
const input = e.target as HTMLInputElement;
this.setReviewed(input.checked ?? false);
@@ -1076,12 +1020,14 @@ export class GrDiffView extends LitElement {
// Private but used in tests.
setReviewed(
reviewed: boolean,
- patchNum: RevisionPatchSetNum | undefined = this.patchRange?.patchNum
+ patchNum: RevisionPatchSetNum | undefined = this.patchNum
) {
- if (this.computeEditMode()) return;
+ if (this.patchNum === EDIT) return;
if (!patchNum || !this.path || !this.changeNum) return;
// if file is already reviewed then do not make a saveReview request
if (this.reviewedFiles.has(this.path) && reviewed) return;
+ // optimistic update
+ this.reviewed = reviewed;
this.getChangeModel().setReviewedFilesStatus(
this.changeNum,
patchNum,
@@ -1092,13 +1038,11 @@ export class GrDiffView extends LitElement {
// Private but used in tests.
handleToggleFileReviewed() {
- assertIsDefined(this.reviewed);
- this.setReviewed(!this.reviewed.checked);
+ this.setReviewed(!this.reviewed);
}
private handlePrevLine() {
assertIsDefined(this.diffHost, 'diffHost');
- this.diffHost.displayLine = true;
this.cursor?.moveUp();
}
@@ -1116,7 +1060,7 @@ export class GrDiffView extends LitElement {
}
private onEditWeblinksChanged(
- e: ValueChangedEvent<GeneratedWebLink[] | undefined>
+ e: ValueChangedEvent<WebLinkInfo[] | undefined>
) {
this.editWeblinks = e.detail.value;
}
@@ -1133,53 +1077,17 @@ export class GrDiffView extends LitElement {
private handleNextLine() {
assertIsDefined(this.diffHost, 'diffHost');
- this.diffHost.displayLine = true;
this.cursor?.moveDown();
}
// Private but used in tests.
- moveToPreviousFileWithComment() {
- if (!this.commentSkips) return;
- if (!this.change) return;
- if (!this.patchRange?.patchNum) return;
-
- // If there is no previous diff with comments, then return to the change
- // view.
- if (!this.commentSkips.previous) {
- this.navToChangeView();
- return;
- }
-
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.commentSkips.previous,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
- })
- );
- }
-
- // Private but used in tests.
- moveToNextFileWithComment() {
- if (!this.commentSkips) return;
- if (!this.change) return;
- if (!this.patchRange?.patchNum) return;
-
- // If there is no next diff with comments, then return to the change view.
- if (!this.commentSkips.next) {
- this.navToChangeView();
- return;
+ moveToFileWithComment(direction: -1 | 1) {
+ const path = this.findFileWithComment(direction);
+ if (!path) {
+ this.getChangeModel().navigateToChange();
+ } else {
+ this.getChangeModel().navigateToDiff({path});
}
-
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.commentSkips.next,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
- })
- );
}
private handleNewComment() {
@@ -1189,14 +1097,14 @@ export class GrDiffView extends LitElement {
private handlePrevFile() {
if (!this.path) return;
- if (!this.files?.sortedFileList) return;
- this.navToFile(this.files.sortedFileList, -1);
+ if (!this.files?.sortedPaths) return;
+ this.navToFile(this.files.sortedPaths, -1);
}
private handleNextFile() {
if (!this.path) return;
- if (!this.files?.sortedFileList) return;
- this.navToFile(this.files.sortedFileList, 1);
+ if (!this.files?.sortedPaths) return;
+ this.navToFile(this.files.sortedPaths, 1);
}
private handleNextChunk() {
@@ -1240,11 +1148,11 @@ export class GrDiffView extends LitElement {
private navigateToUnreviewedFile(direction: string) {
if (!this.path) return;
- if (!this.files?.sortedFileList) return;
+ if (!this.files?.sortedPaths) return;
if (!this.reviewedFiles) return;
// Ensure that the currently viewed file always appears in unreviewedFiles
// so we resolve the right "next" file.
- const unreviewedFiles = this.files.sortedFileList.filter(
+ const unreviewedFiles = this.files.sortedPaths.filter(
file => file === this.path || !this.reviewedFiles.has(file)
);
@@ -1265,10 +1173,10 @@ export class GrDiffView extends LitElement {
// Similar to gr-change-view.handleOpenReplyDialog
private handleOpenReplyDialog() {
if (!this.loggedIn) {
- fireEvent(this, 'show-auth-required');
+ fire(this, 'show-auth-required', {});
return;
}
- this.navToChangeView(true);
+ this.getChangeModel().navigateToChange(true);
}
private handleToggleLeftPane() {
@@ -1277,22 +1185,31 @@ export class GrDiffView extends LitElement {
}
private handleOpenDownloadDialog() {
- assertIsDefined(this.downloadOverlay, 'downloadOverlay');
- this.downloadOverlay.open().then(() => {
- assertIsDefined(this.downloadOverlay, 'downloadOverlay');
- assertIsDefined(this.downloadDialog, 'downloadOverlay');
- this.downloadOverlay.setFocusStops(this.downloadDialog.getFocusStops());
+ assertIsDefined(this.downloadModal, 'downloadModal');
+ this.downloadModal.showModal();
+ whenVisible(this.downloadModal, () => {
+ assertIsDefined(this.downloadModal, 'downloadModal');
+ assertIsDefined(this.downloadDialog, 'downloadDialog');
this.downloadDialog.focus();
+ const downloadCommands = queryAndAssert(
+ this.downloadDialog,
+ 'gr-download-commands'
+ );
+ const paperTabs = queryAndAssert<PaperTabsElement>(
+ downloadCommands,
+ 'paper-tabs'
+ );
+ // Paper Tabs normally listen to 'iron-resize' event to call this method.
+ // After migrating to Dialog element, this event is no longer fired
+ // which means this method is not called which ends up styling the
+ // selected paper tab with an underline.
+ paperTabs._onTabSizingChanged();
});
}
private handleDownloadDialogClose() {
- assertIsDefined(this.downloadOverlay, 'downloadOverlay');
- this.downloadOverlay.close();
- }
-
- private handleUpToChange() {
- this.navToChangeView();
+ assertIsDefined(this.downloadModal, 'downloadModal');
+ this.downloadModal.close();
}
private handleCommaKey() {
@@ -1305,28 +1222,15 @@ export class GrDiffView extends LitElement {
handleToggleDiffMode() {
if (!this.userPrefs) return;
if (this.userPrefs.diff_view === DiffViewMode.SIDE_BY_SIDE) {
- this.userModel.updatePreferences({diff_view: DiffViewMode.UNIFIED});
+ this.getUserModel().updatePreferences({diff_view: DiffViewMode.UNIFIED});
} else {
- this.userModel.updatePreferences({
+ this.getUserModel().updatePreferences({
diff_view: DiffViewMode.SIDE_BY_SIDE,
});
}
}
// Private but used in tests.
- navToChangeView(openReplyDialog = false) {
- if (!this.changeNum || !this.patchRange?.patchNum) {
- return;
- }
- this.navigateToChange(
- this.change,
- this.patchRange,
- this.change && this.change.revisions,
- openReplyDialog
- );
- }
-
- // Private but used in tests.
navToFile(
fileList: string[],
direction: -1 | 1,
@@ -1334,15 +1238,10 @@ export class GrDiffView extends LitElement {
) {
const newPath = this.getNavLinkPath(fileList, direction);
if (!newPath) return;
- if (!this.change) return;
if (!this.patchRange) return;
if (newPath.up) {
- this.navigateToChange(
- this.change,
- this.patchRange,
- this.change && this.change.revisions
- );
+ this.getChangeModel().navigateToChange();
return;
}
@@ -1353,15 +1252,7 @@ export class GrDiffView extends LitElement {
newPath.path,
this.patchRange
)?.[0].line;
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: newPath.path,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
- lineNum,
- })
- );
+ this.getChangeModel().navigateToDiff({path: newPath.path, lineNum});
}
/**
@@ -1372,35 +1263,25 @@ export class GrDiffView extends LitElement {
private computeNavLinkURL(direction?: -1 | 1) {
if (!this.change) return;
if (!this.path) return;
- if (!this.files?.sortedFileList) return;
+ if (!this.files?.sortedPaths) return;
if (!direction) return;
- const newPath = this.getNavLinkPath(this.files.sortedFileList, direction);
- if (!newPath) {
- return;
- }
-
- if (newPath.up) {
- return this.getChangePath();
- }
- return this.getDiffUrl(this.change, this.patchRange, newPath.path);
+ const newPath = this.getNavLinkPath(this.files.sortedPaths, direction);
+ if (!newPath) return;
+ if (newPath.up) return this.getChangeModel().changeUrl();
+ if (!newPath.path) return;
+ return this.getChangeModel().diffUrl({path: newPath.path});
}
private goToEditFile() {
- if (!this.change) return;
- if (!this.path) return;
- if (!this.patchRange) return;
+ assertIsDefined(this.path, 'path');
// TODO(taoalpha): add a shortcut for editing
const cursorAddress = this.cursor?.getAddress();
- const editUrl = createEditUrl({
- changeNum: this.change._number,
- project: this.change.project,
+ this.getChangeModel().navigateToEdit({
path: this.path,
- patchNum: this.patchRange.patchNum,
lineNum: cursorAddress?.number,
});
- this.getNavigation().setUrl(editUrl);
}
/**
@@ -1422,7 +1303,6 @@ export class GrDiffView extends LitElement {
if (!this.path || !fileList || fileList.length === 0) {
return null;
}
-
let idx = fileList.indexOf(this.path);
if (idx === -1) {
const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
@@ -1430,7 +1310,7 @@ export class GrDiffView extends LitElement {
}
idx += direction;
- // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+ // Redirect to the change view if noUp isn’t truthy and idx falls
// outside the bounds of [0, fileList.length).
if (idx < 0 || idx > fileList.length - 1) {
return {up: true};
@@ -1439,412 +1319,71 @@ export class GrDiffView extends LitElement {
return {path: fileList[idx]};
}
- // Private but used in tests.
- initLineOfInterestAndCursor(leftSide: boolean) {
- assertIsDefined(this.diffHost, 'diffHost');
- this.diffHost.lineOfInterest = this.getLineOfInterest(leftSide);
- this.initCursor(leftSide);
- }
-
- // Private but used in tests.
- displayDiffBaseAgainstLeftToast() {
- if (!this.patchRange) return;
- fireAlert(
- this,
- `Patchset ${this.patchRange.basePatchNum} vs ` +
- `${this.patchRange.patchNum} selected. Press v + \u2190 to view ` +
- `Base vs ${this.patchRange.basePatchNum}`
- );
- }
-
- private displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
- if (!this.patchRange) return;
- const leftPatchset =
- this.patchRange.basePatchNum === PARENT
- ? 'Base'
- : `Patchset ${this.patchRange.basePatchNum}`;
- fireAlert(
- this,
- `${leftPatchset} vs
- ${this.patchRange.patchNum} selected\n. Press v + \u2191 to view
- ${leftPatchset} vs Patchset ${latestPatchNum}`
- );
- }
-
- private displayToasts() {
- if (!this.patchRange) return;
- if (this.patchRange.basePatchNum !== PARENT) {
- this.displayDiffBaseAgainstLeftToast();
- return;
- }
- const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (this.patchRange.patchNum !== latestPatchNum) {
- this.displayDiffAgainstLatestToast(latestPatchNum);
- return;
- }
- }
-
- private initCommitRange() {
- let commit: CommitId | undefined;
- let baseCommit: CommitId | undefined;
- if (!this.change) return;
- if (!this.patchRange || !this.patchRange.patchNum) return;
- const revisions = this.change.revisions ?? {};
- for (const [commitSha, revision] of Object.entries(revisions)) {
- const patchNum = revision._number;
- if (patchNum === this.patchRange.patchNum) {
- commit = commitSha as CommitId;
- const commitObj = revision.commit;
- const parents = commitObj?.parents || [];
- if (this.patchRange.basePatchNum === PARENT && parents.length) {
- baseCommit = parents[parents.length - 1].commit;
- }
- } else if (patchNum === this.patchRange.basePatchNum) {
- baseCommit = commitSha as CommitId;
- }
- }
- this.commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
- }
-
private updateUrlToDiffUrl(lineNum?: number, leftSide?: boolean) {
if (!this.change) return;
- if (!this.patchRange) return;
+ if (!this.patchNum) return;
if (!this.changeNum) return;
if (!this.path) return;
const url = createDiffUrl({
changeNum: this.changeNum,
- project: this.change.project,
- path: this.path,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
- lineNum,
- leftSide,
+ repo: this.change.project,
+ patchNum: this.patchNum,
+ basePatchNum: this.basePatchNum,
+ diffView: {
+ path: this.path,
+ lineNum,
+ leftSide,
+ },
});
history.replaceState(null, '', url);
}
- // Private but used in tests.
- initPatchRange() {
- let leftSide = false;
- if (!this.change) return;
- if (this.viewState?.view !== GerritView.DIFF) return;
- if (this.viewState?.commentId) {
- const comment = this.changeComments?.findCommentById(
- this.viewState.commentId
- );
- if (!comment) {
- fireAlert(this, 'comment not found');
- this.getNavigation().setUrl(createChangeUrl({change: this.change}));
- return;
- }
- this.getChangeModel().updatePath(comment.path);
-
- const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (!latestPatchNum) throw new Error('Missing allPatchSets');
- this.patchRange = getPatchRangeForCommentUrl(comment, latestPatchNum);
- leftSide = isInBaseOfPatchRange(comment, this.patchRange);
-
- this.focusLineNum = comment.line;
- } else {
- if (this.viewState.path) {
- this.getChangeModel().updatePath(this.viewState.path);
- }
- if (this.viewState.patchNum) {
- this.patchRange = {
- patchNum: this.viewState.patchNum,
- basePatchNum: this.viewState.basePatchNum || PARENT,
- };
- }
- if (this.viewState.lineNum) {
- this.focusLineNum = this.viewState.lineNum;
- leftSide = !!this.viewState.leftSide;
- }
- }
- assertIsDefined(this.patchRange, 'patchRange');
- this.initLineOfInterestAndCursor(leftSide);
-
- if (this.viewState?.commentId) {
- // url is of type /comment/{commentId} which isn't meaningful
- this.updateUrlToDiffUrl(this.focusLineNum, leftSide);
- }
-
- this.commentMap = this.getPaths();
- }
-
- // Private but used in tests.
- isFileUnchanged(diff?: DiffInfo) {
- if (!diff || !diff.content) return false;
- return !diff.content.some(
- content =>
- (content.a && !content.common) || (content.b && !content.common)
- );
- }
-
- private isSameDiffLoaded(value: DiffViewState) {
- return (
- this.patchRange?.basePatchNum === value.basePatchNum &&
- this.patchRange?.patchNum === value.patchNum &&
- this.path === value.path
- );
+ async reloadDiff() {
+ if (!this.diffHost) return;
+ await this.diffHost.reload(true);
+ this.reporting.diffViewDisplayed();
+ if (this.isBlameLoaded) this.loadBlame();
}
- private async untilModelLoaded() {
- // NOTE: Wait until this page is connected before determining whether the
- // model is loaded. This can happen when params are changed when setting up
- // this view. It's unclear whether this issue is related to Polymer
- // specifically.
- if (!this.isConnected) {
- await until(this.connected$, connected => connected);
- }
- await until(
- this.getChangeModel().changeLoadingStatus$,
- status => status === LoadingStatus.LOADED
- );
- }
-
- // Private but used in tests.
- viewStateChanged() {
- if (this.viewState === undefined) return;
- const viewState = this.viewState;
-
+ /**
+ * (Re-initialize) the diff view without actually reloading the diff. The
+ * typical user journey is that the user comes back from the change page.
+ */
+ initializePositions() {
// The diff view is kept in the background once created. If the user
// scrolls in the change page, the scrolling is reflected in the diff view
// as well, which means the diff is scrolled to a random position based
// on how much the change view was scrolled.
// Hence, reset the scroll position here.
document.documentElement.scrollTop = 0;
-
- // Everything in the diff view is tied to the change. It seems better to
- // force the re-creation of the diff view when the change number changes.
- const changeChanged = this.changeNum !== viewState.changeNum;
- if (this.changeNum !== undefined && changeChanged) {
- fireEvent(this, EventType.RECREATE_DIFF_VIEW);
- return;
- } else if (
- this.changeNum !== undefined &&
- this.isSameDiffLoaded(viewState)
- ) {
- // changeNum has not changed, so check if there are changes in patchRange
- // path. If no changes then we can simply render the view as is.
- this.reporting.reportInteraction('diff-view-re-rendered');
- // Make sure to re-initialize the cursor because this is typically
- // done on the 'render' event which doesn't fire in this path as
- // rerendering is avoided.
- this.reInitCursor();
- this.diffHost?.initLayers();
- return;
- }
-
- this.files = {sortedFileList: [], changeFilesByPath: {}};
- if (this.isConnected) {
- this.getChangeModel().updatePath(undefined);
- }
- this.patchRange = undefined;
- this.commitRange = undefined;
- this.focusLineNum = undefined;
-
- if (viewState.changeNum && viewState.project) {
- this.restApiService.setInProjectLookup(
- viewState.changeNum,
- viewState.project
- );
- }
-
- this.changeNum = viewState.changeNum;
+ this.reInitCursor();
+ this.diffHost?.initLayers();
this.classList.remove('hideComments');
-
- // When navigating away from the page, there is a possibility that the
- // patch number is no longer a part of the URL (say when navigating to
- // the top-level change info view) and therefore undefined in `params`.
- // If route is of type /comment/<commentId>/ then no patchNum is present
- if (!viewState.patchNum && !viewState.commentLink) {
- this.reporting.error(
- 'GrDiffView',
- new Error(`Invalid diff view URL, no patchNum found: ${this.viewState}`)
- );
- return;
- }
-
- const promises: Promise<unknown>[] = [];
- if (!this.change) {
- promises.push(this.untilModelLoaded());
- }
- promises.push(this.waitUntilCommentsLoaded());
-
- if (this.diffHost) {
- this.diffHost.cancel();
- this.diffHost.clearDiffContent();
- }
- this.loading = true;
- return Promise.all(promises)
- .then(() => {
- this.loading = false;
- this.initPatchRange();
- this.initCommitRange();
- return this.updateComplete.then(() => this.diffHost!.reload(true));
- })
- .then(() => {
- this.reporting.diffViewDisplayed();
- })
- .then(() => {
- const fileUnchanged = this.isFileUnchanged(this.diff);
- if (fileUnchanged && viewState.commentLink) {
- assertIsDefined(this.change, 'change');
- assertIsDefined(this.path, 'path');
- assertIsDefined(this.patchRange, 'patchRange');
-
- if (this.patchRange.basePatchNum === PARENT) {
- // file is unchanged between Base vs X
- // hence should not show diff between Base vs Base
- return;
- }
-
- fireAlert(
- this,
- `File is unchanged between Patchset
- ${this.patchRange.basePatchNum} and
- ${this.patchRange.patchNum}. Showing diff of Base vs
- ${this.patchRange.basePatchNum}`
- );
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
- basePatchNum: PARENT,
- lineNum: this.focusLineNum,
- })
- );
- return;
- }
- if (viewState.commentLink) {
- this.displayToasts();
- }
- // If the blame was loaded for a previous file and user navigates to
- // another file, then we load the blame for this file too
- if (this.isBlameLoaded) this.loadBlame();
- });
- }
-
- private async waitUntilCommentsLoaded() {
- await until(this.connected$, c => c);
- await until(this.getCommentsModel().commentsLoading$, isFalse);
}
/**
* If the params specify a diff address then configure the diff cursor.
* Private but used in tests.
*/
- initCursor(leftSide: boolean) {
- if (this.focusLineNum === undefined) {
- return;
- }
+ initCursor() {
+ if (!this.focusLineNum) return;
if (!this.cursor) return;
- if (leftSide) {
- this.cursor.side = Side.LEFT;
- } else {
- this.cursor.side = Side.RIGHT;
- }
+ this.cursor.side = this.leftSide ? Side.LEFT : Side.RIGHT;
this.cursor.initialLineNumber = this.focusLineNum;
}
// Private but used in tests.
- getLineOfInterest(leftSide: boolean): DisplayLine | undefined {
+ getLineOfInterest(): DisplayLine | undefined {
// If there is a line number specified, pass it along to the diff so that
// it will not get collapsed.
- if (!this.focusLineNum) {
- return undefined;
- }
+ if (!this.focusLineNum) return undefined;
return {
lineNum: this.focusLineNum,
- side: leftSide ? Side.LEFT : Side.RIGHT,
+ side: this.leftSide ? Side.LEFT : Side.RIGHT,
};
}
- private pathChanged() {
- if (this.path) {
- fireTitleChange(this, computeTruncatedPath(this.path));
- }
- }
-
- private getDiffUrl(
- change?: ChangeInfo | ParsedChangeInfo,
- patchRange?: PatchRange,
- path?: string
- ) {
- if (!change || !patchRange || !path) return '';
- return createDiffUrl({
- changeNum: change._number,
- project: change.project,
- path,
- patchNum: patchRange.patchNum,
- basePatchNum: patchRange.basePatchNum,
- });
- }
-
- /**
- * When the latest patch of the change is selected (and there is no base
- * patch) then the patch range need not appear in the URL. Return a patch
- * range object with undefined values when a range is not needed.
- */
- private getChangeUrlRange(
- patchRange?: PatchRange,
- revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo}
- ) {
- let patchNum = undefined;
- let basePatchNum = undefined;
- let latestPatchNum = -1;
- for (const rev of Object.values(revisions || {})) {
- if (typeof rev._number === 'number') {
- latestPatchNum = Math.max(latestPatchNum, rev._number);
- }
- }
- if (!patchRange) return {patchNum, basePatchNum};
- if (
- patchRange.basePatchNum !== PARENT ||
- patchRange.patchNum !== latestPatchNum
- ) {
- patchNum = patchRange.patchNum;
- basePatchNum = patchRange.basePatchNum;
- }
- return {patchNum, basePatchNum};
- }
-
- private getChangePath() {
- if (!this.change) return '';
- if (!this.patchRange) return '';
-
- const range = this.getChangeUrlRange(
- this.patchRange,
- this.change.revisions
- );
- return createChangeUrl({
- change: this.change,
- patchNum: range.patchNum,
- basePatchNum: range.basePatchNum,
- });
- }
-
- // Private but used in tests.
- navigateToChange(
- change?: ChangeInfo | ParsedChangeInfo,
- patchRange?: PatchRange,
- revisions?: {[revisionId: string]: RevisionInfo | EditRevisionInfo},
- openReplyDialog?: boolean
- ) {
- if (!change) return;
- const range = this.getChangeUrlRange(patchRange, revisions);
- this.getNavigation().setUrl(
- createChangeUrl({
- change,
- patchNum: range.patchNum,
- basePatchNum: range.basePatchNum,
- openReplyDialog: !!openReplyDialog,
- })
- );
- }
-
// Private but used in tests
formatFilesForDropdown(): DropdownItem[] {
if (!this.files) return [];
@@ -1852,7 +1391,8 @@ export class GrDiffView extends LitElement {
if (!this.changeComments) return [];
const dropdownContent: DropdownItem[] = [];
- for (const path of this.files.sortedFileList) {
+ for (const path of this.files.sortedPaths) {
+ const file = this.files.changeFilesByPath[path];
dropdownContent.push({
text: computeDisplayPath(path),
mobileText: computeTruncatedPath(path),
@@ -1860,56 +1400,35 @@ export class GrDiffView extends LitElement {
bottomText: this.changeComments.computeCommentsString(
this.patchRange,
path,
- this.files.changeFilesByPath[path],
+ file,
/* includeUnmodified= */ true
),
- file: {...this.files.changeFilesByPath[path], __path: path},
+ file,
});
}
return dropdownContent;
}
// Private but used in tests.
- handleFileChange(e: CustomEvent) {
- if (!this.change) return;
- if (!this.patchRange) return;
-
- // This is when it gets set initially.
- const path = e.detail.value;
- if (path === this.path) {
- return;
- }
-
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path,
- patchNum: this.patchRange.patchNum,
- basePatchNum: this.patchRange.basePatchNum,
- })
- );
+ handleFileChange(e: ValueChangedEvent<string>) {
+ const path: string = e.detail.value;
+ if (path === this.path) return;
+ this.getChangeModel().navigateToDiff({path});
}
// Private but used in tests.
- handlePatchChange(e: CustomEvent) {
- if (!this.change) return;
+ handlePatchChange(e: PatchRangeChangeEvent) {
if (!this.path) return;
- if (!this.patchRange) return;
+ if (!this.patchNum) return;
const {basePatchNum, patchNum} = e.detail;
- if (
- basePatchNum === this.patchRange.basePatchNum &&
- patchNum === this.patchRange.patchNum
- ) {
+ if (basePatchNum === this.basePatchNum && patchNum === this.patchNum) {
return;
}
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum,
- basePatchNum,
- })
+ this.getChangeModel().navigateToDiff(
+ {path: this.path},
+ patchNum,
+ basePatchNum
);
}
@@ -1921,20 +1440,27 @@ export class GrDiffView extends LitElement {
}
// Private but used in tests.
- onLineSelected(e: CustomEvent) {
- // for on-comment-anchor-tap side can be PARENT/REVISIONS
- // for on-line-selected side can be left/right
+ onCommentAnchorTap(e: CustomEvent<CommentAnchorTapEventDetail>) {
+ const lineNumber = e.detail.number;
+ if (!Number.isInteger(lineNumber)) return;
this.updateUrlToDiffUrl(
- e.detail.number,
- e.detail.side === Side.LEFT || e.detail.side === CommentSide.PARENT
+ lineNumber as number,
+ e.detail.side === CommentSide.PARENT
);
}
// Private but used in tests.
+ onLineSelected(e: CustomEvent<LineSelectedEventDetail>) {
+ const lineNumber = e.detail.number;
+ if (!Number.isInteger(lineNumber)) return;
+ this.updateUrlToDiffUrl(lineNumber as number, e.detail.side === Side.LEFT);
+ }
+
+ // Private but used in tests.
computeDownloadDropdownLinks() {
if (!this.change?.project) return [];
if (!this.changeNum) return [];
- if (!this.patchRange?.patchNum) return [];
+ if (!this.patchRange) return [];
if (!this.path) return [];
const links = [
@@ -1982,9 +1508,10 @@ export class GrDiffView extends LitElement {
return links;
}
+ // TODO: Move to view-model or router.
// Private but used in tests.
computeDownloadFileLink(
- project: RepoName,
+ repo: RepoName,
changeNum: NumericChangeId,
patchRange: PatchRange,
path: string,
@@ -2003,69 +1530,40 @@ export class GrDiffView extends LitElement {
}
}
let url =
- changeBaseURL(project, changeNum, patchNum) +
+ changeBaseURL(repo, changeNum, patchNum) +
`/files/${encodeURIComponent(path)}/download`;
if (parent) url += `?parent=${parent}`;
return url;
}
+ // TODO: Move to view-model or router.
// Private but used in tests.
computeDownloadPatchLink(
- project: RepoName,
+ repo: RepoName,
changeNum: NumericChangeId,
patchRange: PatchRange,
path: string
) {
- let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+ let url = changeBaseURL(repo, changeNum, patchRange.patchNum);
url += '/patch?zip&path=' + encodeURIComponent(path);
return url;
}
// Private but used in tests.
- getPaths(): CommentMap {
- if (!this.changeComments) return {};
- return this.changeComments.getPaths(this.patchRange);
- }
-
- // Private but used in tests.
- computeCommentSkips(
- commentMap?: CommentMap,
- fileList?: string[],
- path?: string
- ): CommentSkips | undefined {
- if (!commentMap) return undefined;
- if (!fileList) return undefined;
- if (!path) return undefined;
-
- const skips: CommentSkips = {previous: null, next: null};
- if (!fileList.length) {
- return skips;
- }
- const pathIndex = fileList.indexOf(path);
-
- // Scan backward for the previous file.
- for (let i = pathIndex - 1; i >= 0; i--) {
- if (commentMap[fileList[i]]) {
- skips.previous = fileList[i];
- break;
- }
+ findFileWithComment(direction: -1 | 1): string | undefined {
+ const fileList = this.files?.sortedPaths;
+ const commentMap: CommentMap =
+ this.changeComments?.getPaths(this.patchRange) ?? {};
+ if (!fileList || fileList.length === 0) return undefined;
+ if (!this.path) return undefined;
+
+ const pathIndex = fileList.indexOf(this.path);
+ const stopIndex = direction === 1 ? fileList.length : -1;
+ for (let i = pathIndex + direction; i !== stopIndex; i += direction) {
+ if (commentMap[fileList[i]]) return fileList[i];
}
-
- // Scan forward for the next file.
- for (let i = pathIndex + 1; i < fileList.length; i++) {
- if (commentMap[fileList[i]]) {
- skips.next = fileList[i];
- break;
- }
- }
-
- return skips;
- }
-
- // Private but used in tests.
- computeEditMode() {
- return this.patchRange?.patchNum === EDIT;
+ return undefined;
}
// Private but used in tests.
@@ -2108,111 +1606,89 @@ export class GrDiffView extends LitElement {
// Private but used in tests.
handleDiffAgainstBase() {
- if (!this.change) return;
- if (!this.path) return;
- if (!this.patchRange) return;
+ if (!this.isActiveChildView) return;
+ assertIsDefined(this.path, 'path');
+ assertIsDefined(this.patchNum, 'patchNum');
- if (this.patchRange.basePatchNum === PARENT) {
+ if (this.basePatchNum === PARENT) {
fireAlert(this, 'Base is already selected.');
return;
}
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum: this.patchRange.patchNum,
- })
+ this.getChangeModel().navigateToDiff(
+ {path: this.path},
+ this.patchNum,
+ PARENT
);
}
// Private but used in tests.
handleDiffBaseAgainstLeft() {
- if (!this.change) return;
- if (!this.path) return;
- if (!this.patchRange) return;
+ if (!this.isActiveChildView) return;
+ assertIsDefined(this.path, 'path');
+ assertIsDefined(this.patchNum, 'patchNum');
- if (this.patchRange.basePatchNum === PARENT) {
+ if (this.basePatchNum === PARENT) {
fireAlert(this, 'Left is already base.');
return;
}
- const lineNum =
- this.viewState?.view === GerritView.DIFF && this.viewState?.commentLink
- ? this.focusLineNum
- : undefined;
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum: this.patchRange.basePatchNum as RevisionPatchSetNum,
- basePatchNum: PARENT,
- lineNum,
- })
+ this.getChangeModel().navigateToDiff(
+ {path: this.path},
+ this.basePatchNum as RevisionPatchSetNum,
+ PARENT
);
}
// Private but used in tests.
handleDiffAgainstLatest() {
- if (!this.change) return;
- if (!this.path) return;
- if (!this.patchRange) return;
+ if (!this.isActiveChildView) return;
+ assertIsDefined(this.path, 'path');
+ assertIsDefined(this.patchNum, 'patchNum');
- const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (this.patchRange.patchNum === latestPatchNum) {
+ if (this.patchNum === this.latestPatchNum) {
fireAlert(this, 'Latest is already selected.');
return;
}
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum: latestPatchNum,
- basePatchNum: this.patchRange.basePatchNum,
- })
+ this.getChangeModel().navigateToDiff(
+ {path: this.path},
+ this.latestPatchNum,
+ this.basePatchNum
);
}
// Private but used in tests.
handleDiffRightAgainstLatest() {
- if (!this.change) return;
- if (!this.path) return;
- if (!this.patchRange) return;
+ if (!this.isActiveChildView) return;
+ assertIsDefined(this.path, 'path');
+ assertIsDefined(this.patchNum, 'patchNum');
- const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (this.patchRange.patchNum === latestPatchNum) {
+ if (this.patchNum === this.latestPatchNum) {
fireAlert(this, 'Right is already latest.');
return;
}
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum: latestPatchNum,
- basePatchNum: this.patchRange.patchNum as BasePatchSetNum,
- })
+
+ this.getChangeModel().navigateToDiff(
+ {path: this.path},
+ this.latestPatchNum,
+ this.patchNum as BasePatchSetNum
);
}
// Private but used in tests.
handleDiffBaseAgainstLatest() {
- if (!this.change) return;
- if (!this.path) return;
- if (!this.patchRange) return;
+ if (!this.isActiveChildView) return;
+ assertIsDefined(this.path, 'path');
+ assertIsDefined(this.patchNum, 'patchNum');
- const latestPatchNum = computeLatestPatchNum(this.allPatchSets);
- if (
- this.patchRange.patchNum === latestPatchNum &&
- this.patchRange.basePatchNum === PARENT
- ) {
+ if (this.patchNum === this.latestPatchNum && this.basePatchNum === PARENT) {
fireAlert(this, 'Already diffing base against latest.');
return;
}
- this.getNavigation().setUrl(
- createDiffUrl({
- change: this.change,
- path: this.path,
- patchNum: latestPatchNum,
- })
+
+ this.getChangeModel().navigateToDiff(
+ {path: this.path},
+ this.latestPatchNum,
+ PARENT
);
}
@@ -2243,20 +1719,20 @@ export class GrDiffView extends LitElement {
private navigateToNextFileWithCommentThread() {
if (!this.path) return;
- if (!this.files?.sortedFileList) return;
- if (!this.patchRange) return;
+ if (!this.files?.sortedPaths) return;
+ const range = this.patchRange;
+ if (!range) return;
if (!this.change) return;
const hasComment = (path: string) =>
- this.changeComments?.getCommentsForPath(path, this.patchRange!)?.length ??
- 0 > 0;
- const filesWithComments = this.files.sortedFileList.filter(
+ this.changeComments?.getCommentsForPath(path, range)?.length ?? 0 > 0;
+ const filesWithComments = this.files.sortedPaths.filter(
file => file === this.path || hasComment(file)
);
this.navToFile(filesWithComments, 1, true);
}
private handleReloadingDiffPreference() {
- this.userModel.getDiffPreferences();
+ this.getUserModel().getDiffPreferences();
}
private computeCanEdit() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
index b6e26ab832..737e964100 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.ts
@@ -5,7 +5,6 @@
*/
import '../../../test/common-test-setup';
import './gr-diff-view';
-import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {
ChangeStatus,
DiffViewMode,
@@ -18,58 +17,66 @@ import {
query,
queryAll,
queryAndAssert,
- stubReporting,
stubRestApi,
- stubUsers,
waitEventLoop,
waitUntil,
} from '../../../test/test-utils';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {GerritView} from '../../../services/router/router-model';
import {
createRevisions,
createComment as createCommentGeneric,
- TEST_NUMERIC_CHANGE_ID,
createDiff,
- createPatchRange,
createServerInfo,
createConfig,
createParsedChange,
createRevision,
- createCommit,
createFileInfo,
+ createDiffViewState,
+ TEST_NUMERIC_CHANGE_ID,
} from '../../../test/test-data-generators';
import {
BasePatchSetNum,
CommentInfo,
- CommitId,
EDIT,
- FileInfo,
NumericChangeId,
PARENT,
- PatchRange,
PatchSetNum,
PatchSetNumber,
- PathToCommentsInfoMap,
RepoName,
RevisionPatchSetNum,
UrlEncodedCommentId,
} from '../../../types/common';
import {CursorMoveResult} from '../../../api/core';
-import {DiffInfo, Side} from '../../../api/diff';
+import {Side} from '../../../api/diff';
import {Files, GrDiffView} from './gr-diff-view';
import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
-import {SinonFakeTimers, SinonStub, SinonSpy} from 'sinon';
-import {LoadingStatus} from '../../../models/change/change-model';
-import {CommentMap} from '../../../utils/comment-util';
-import {ParsedChangeInfo} from '../../../types/types';
+import {SinonFakeTimers, SinonStub, SinonStubbedMember} from 'sinon';
+import {
+ changeModelToken,
+ ChangeModel,
+ LoadingStatus,
+} from '../../../models/change/change-model';
import {assertIsDefined} from '../../../utils/common-util';
import {GrDiffModeSelector} from '../../../embed/diff/gr-diff-mode-selector/gr-diff-mode-selector';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
-import {Key} from '../../../utils/dom-util';
import {GrButton} from '../../shared/gr-button/gr-button';
import {testResolver} from '../../../test/common-test-setup';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {
+ commentsModelToken,
+ CommentsModel,
+} from '../../../models/comments/comments-model';
+import {
+ BrowserModel,
+ browserModelToken,
+} from '../../../models/browser/browser-model';
+import {
+ ChangeViewModel,
+ changeViewModelToken,
+} from '../../../models/views/change';
+import {FileNameToNormalizedFileInfoMap} from '../../../models/change/files-model';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {GrDiffCursor} from '../../../embed/diff/gr-diff-cursor/gr-diff-cursor';
function createComment(
id: string,
@@ -91,22 +98,28 @@ suite('gr-diff-view tests', () => {
let element: GrDiffView;
let clock: SinonFakeTimers;
let diffCommentsStub;
- let getDiffRestApiStub: SinonStub;
- let setUrlStub: SinonStub;
+ let getDiffRestApiStub: SinonStubbedMember<RestApiService['getDiff']>;
+ let navToChangeStub: SinonStubbedMember<ChangeModel['navigateToChange']>;
+ let navToDiffStub: SinonStubbedMember<ChangeModel['navigateToDiff']>;
+ let navToEditStub: SinonStubbedMember<ChangeModel['navigateToEdit']>;
+ let changeModel: ChangeModel;
+ let viewModel: ChangeViewModel;
+ let commentsModel: CommentsModel;
+ let browserModel: BrowserModel;
+ let userModel: UserModel;
function getFilesFromFileList(fileList: string[]): Files {
const changeFilesByPath = fileList.reduce((files, path) => {
- files[path] = createFileInfo();
+ files[path] = createFileInfo(path);
return files;
- }, {} as {[path: string]: FileInfo});
+ }, {} as FileNameToNormalizedFileInfoMap);
return {
- sortedFileList: fileList,
+ sortedPaths: fileList,
changeFilesByPath,
};
}
setup(async () => {
- setUrlStub = sinon.stub(testResolver(navigationToken), 'setUrl');
stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
stubRestApi('getLoggedIn').returns(Promise.resolve(false));
stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
@@ -125,14 +138,17 @@ suite('gr-diff-view tests', () => {
stubRestApi('getPortedComments').returns(Promise.resolve({}));
element = await fixture(html`<gr-diff-view></gr-diff-view>`);
- element.changeNum = 42 as NumericChangeId;
+ viewModel = testResolver(changeViewModelToken);
+ viewModel.setState(createDiffViewState());
+ await waitUntil(() => element.changeNum === TEST_NUMERIC_CHANGE_ID);
element.path = 'some/path.txt';
element.change = createParsedChange();
element.diff = {...createDiff(), content: []};
getDiffRestApiStub = stubRestApi('getDiff');
// Delayed in case a test updates element.diff.
getDiffRestApiStub.callsFake(() => Promise.resolve(element.diff));
- element.patchRange = createPatchRange();
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.changeComments = new ChangeComments({
'/COMMIT_MSG': [
createComment('c1', 10, 2, '/COMMIT_MSG'),
@@ -140,8 +156,15 @@ suite('gr-diff-view tests', () => {
],
});
await element.updateComplete;
-
- element.getCommentsModel().setState({
+ commentsModel = testResolver(commentsModelToken);
+ changeModel = testResolver(changeModelToken);
+ browserModel = testResolver(browserModelToken);
+ userModel = testResolver(userModelToken);
+ navToChangeStub = sinon.stub(changeModel, 'navigateToChange');
+ navToDiffStub = sinon.stub(changeModel, 'navigateToDiff');
+ navToEditStub = sinon.stub(changeModel, 'navigateToEdit');
+
+ commentsModel.setState({
comments: {},
robotComments: {},
drafts: {},
@@ -156,279 +179,6 @@ suite('gr-diff-view tests', () => {
sinon.restore();
});
- test('viewState change triggers diffViewDisplayed()', () => {
- const diffViewDisplayedStub = stubReporting('diffViewDisplayed');
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
- sinon.stub(element, 'initPatchRange');
- sinon.stub(element, 'fetchFiles');
- const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- path: '/COMMIT_MSG',
- };
- element.path = '/COMMIT_MSG';
- element.patchRange = createPatchRange();
- return viewStateChangedSpy.returnValues[0]?.then(() => {
- assert.isTrue(diffViewDisplayedStub.calledOnce);
- });
- });
-
- suite('comment route', () => {
- let initLineOfInterestAndCursorStub: SinonStub;
- let replaceStateStub: SinonStub;
- let viewStateChangedSpy: SinonSpy;
- setup(() => {
- initLineOfInterestAndCursorStub = sinon.stub(
- element,
- 'initLineOfInterestAndCursor'
- );
- replaceStateStub = sinon.stub(history, 'replaceState');
- sinon.stub(element, 'fetchFiles');
- stubReporting('diffViewDisplayed');
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
- viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
- element.getChangeModel().setState({
- change: {
- ...createParsedChange(),
- revisions: createRevisions(11),
- },
- loadingStatus: LoadingStatus.LOADED,
- });
- });
-
- test('comment url resolves to comment.patch_set vs latest', () => {
- element.getCommentsModel().setState({
- comments: {
- '/COMMIT_MSG': [
- createComment('c1', 10, 2, '/COMMIT_MSG'),
- createComment('c3', 10, PARENT, '/COMMIT_MSG'),
- ],
- },
- robotComments: {},
- drafts: {},
- portedComments: {},
- portedDrafts: {},
- discardedDrafts: [],
- });
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- commentLink: true,
- commentId: 'c1' as UrlEncodedCommentId,
- path: 'abcd',
- patchNum: 1 as RevisionPatchSetNum,
- };
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(11),
- };
- return viewStateChangedSpy.returnValues[0].then(() => {
- assert.isTrue(
- initLineOfInterestAndCursorStub.calledWithExactly(true)
- );
- assert.equal(element.focusLineNum, 10);
- assert.equal(element.patchRange?.patchNum, 11 as RevisionPatchSetNum);
- assert.equal(element.patchRange?.basePatchNum, 2 as BasePatchSetNum);
- assert.isTrue(replaceStateStub.called);
- });
- });
- });
-
- test('viewState change causes blame to load if it was set to true', () => {
- // Blame loads for subsequent files if it was loaded for one file
- element.isBlameLoaded = true;
- stubReporting('diffViewDisplayed');
- const loadBlameStub = sinon.stub(element, 'loadBlame');
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
- const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
- sinon.stub(element, 'initPatchRange');
- sinon.stub(element, 'fetchFiles');
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- path: '/COMMIT_MSG',
- };
- element.path = '/COMMIT_MSG';
- element.patchRange = createPatchRange();
- return viewStateChangedSpy.returnValues[0]!.then(() => {
- assert.isTrue(element.isBlameLoaded);
- assert.isTrue(loadBlameStub.calledOnce);
- });
- });
-
- test('unchanged diff X vs latest from comment links navigates to base vs X', async () => {
- element.getCommentsModel().setState({
- comments: {
- '/COMMIT_MSG': [
- createComment('c1', 10, 2, '/COMMIT_MSG'),
- createComment('c3', 10, PARENT, '/COMMIT_MSG'),
- ],
- },
- robotComments: {},
- drafts: {},
- portedComments: {},
- portedDrafts: {},
- discardedDrafts: [],
- });
- stubReporting('diffViewDisplayed');
- sinon.stub(element, 'loadBlame');
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
- sinon.stub(element, 'isFileUnchanged').returns(true);
- const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
- element.getChangeModel().setState({
- change: {
- ...createParsedChange(),
- revisions: createRevisions(11),
- },
- loadingStatus: LoadingStatus.LOADED,
- });
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- path: '/COMMIT_MSG',
- commentLink: true,
- commentId: 'c1' as UrlEncodedCommentId,
- };
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(11),
- };
- await viewStateChangedSpy.returnValues[0];
- assert.isTrue(setUrlStub.calledOnce);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/2//COMMIT_MSG#10'
- );
- });
-
- test('unchanged diff Base vs latest from comment does not navigate', async () => {
- element.getCommentsModel().setState({
- comments: {
- '/COMMIT_MSG': [
- createComment('c1', 10, 2, '/COMMIT_MSG'),
- createComment('c3', 10, PARENT, '/COMMIT_MSG'),
- ],
- },
- robotComments: {},
- drafts: {},
- portedComments: {},
- portedDrafts: {},
- discardedDrafts: [],
- });
- stubReporting('diffViewDisplayed');
- sinon.stub(element, 'loadBlame');
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
- sinon.stub(element, 'isFileUnchanged').returns(true);
- const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
- element.getChangeModel().setState({
- change: {
- ...createParsedChange(),
- revisions: createRevisions(11),
- },
- loadingStatus: LoadingStatus.LOADED,
- });
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- path: '/COMMIT_MSG',
- commentLink: true,
- commentId: 'c3' as UrlEncodedCommentId,
- };
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(11),
- };
- await viewStateChangedSpy.returnValues[0];
- assert.isFalse(setUrlStub.calledOnce);
- });
-
- test('isFileUnchanged', () => {
- let diff: DiffInfo = {
- ...createDiff(),
- content: [
- {a: ['abcd'], ab: ['ef']},
- {b: ['ancd'], a: ['xx']},
- ],
- };
- assert.equal(element.isFileUnchanged(diff), false);
- diff = {
- ...createDiff(),
- content: [{ab: ['abcd']}, {ab: ['ancd']}],
- };
- assert.equal(element.isFileUnchanged(diff), true);
- diff = {
- ...createDiff(),
- content: [
- {a: ['abcd'], ab: ['ef'], common: true},
- {b: ['ancd'], ab: ['xx']},
- ],
- };
- assert.equal(element.isFileUnchanged(diff), false);
- diff = {
- ...createDiff(),
- content: [
- {a: ['abcd'], ab: ['ef'], common: true},
- {b: ['ancd'], ab: ['xx'], common: true},
- ],
- };
- assert.equal(element.isFileUnchanged(diff), true);
- });
-
- test('diff toast to go to latest is shown and not base', async () => {
- element.getCommentsModel().setState({
- comments: {
- '/COMMIT_MSG': [
- createComment('c1', 10, 2, '/COMMIT_MSG'),
- createComment('c3', 10, PARENT, '/COMMIT_MSG'),
- ],
- },
- robotComments: {},
- drafts: {},
- portedComments: {},
- portedDrafts: {},
- discardedDrafts: [],
- });
-
- stubReporting('diffViewDisplayed');
- sinon.stub(element, 'loadBlame');
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload').returns(Promise.resolve());
- const viewStateChangedSpy = sinon.spy(element, 'viewStateChanged');
- element.change = undefined;
- element.getChangeModel().setState({
- change: {
- ...createParsedChange(),
- revisions: createRevisions(11),
- },
- loadingStatus: LoadingStatus.LOADED,
- });
- element.patchRange = {
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
- sinon.stub(element, 'isFileUnchanged').returns(false);
- const toastStub = sinon.stub(element, 'displayDiffBaseAgainstLeftToast');
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- project: 'p' as RepoName,
- commentId: 'c1' as UrlEncodedCommentId,
- commentLink: true,
- };
- await viewStateChangedSpy.returnValues[0];
- assert.isTrue(toastStub.called);
- });
-
test('toggle left diff with a hotkey', () => {
assertIsDefined(element.diffHost);
const toggleLeftDiffStub = sinon.stub(element.diffHost, 'toggleLeftDiff');
@@ -437,20 +187,17 @@ suite('gr-diff-view tests', () => {
});
test('renders', async () => {
- clock = sinon.useFakeTimers();
- element.changeNum = 42 as NumericChangeId;
- element.getBrowserModel().setScreenWidth(0);
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 10 as RevisionPatchSetNum,
- };
- element.change = {
+ browserModel.setScreenWidth(0);
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
+ const change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
revisions: {
a: createRevision(10),
},
};
+ changeModel.updateStateChange(change);
element.files = getFilesFromFileList([
'chell.go',
'glados.txt',
@@ -602,20 +349,15 @@ suite('gr-diff-view tests', () => {
</a>
</div>
</div>
- <div class="loading">Loading...</div>
<h2 class="assistive-tech-only">Diff view</h2>
- <gr-diff-host hidden="" id="diffHost"> </gr-diff-host>
+ <gr-diff-host id="diffHost"> </gr-diff-host>
<gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog>
<gr-diff-preferences-dialog id="diffPreferencesDialog">
</gr-diff-preferences-dialog>
- <gr-overlay
- aria-hidden="true"
- id="downloadOverlay"
- style="outline: none; display: none;"
- >
+ <dialog id="downloadModal" tabindex="-1">
<gr-download-dialog id="downloadDialog" role="dialog">
</gr-download-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -623,11 +365,9 @@ suite('gr-diff-view tests', () => {
test('keyboard shortcuts', async () => {
clock = sinon.useFakeTimers();
element.changeNum = 42 as NumericChangeId;
- element.getBrowserModel().setScreenWidth(0);
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ browserModel.setScreenWidth(0);
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -643,51 +383,42 @@ suite('gr-diff-view tests', () => {
element.path = 'glados.txt';
element.loggedIn = true;
await element.updateComplete;
- setUrlStub.reset();
+ navToChangeStub.reset();
pressKey(element, 'u');
- assert.equal(setUrlStub.callCount, 1);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+ assert.isTrue(navToChangeStub.calledOnce);
await element.updateComplete;
pressKey(element, ']');
- assert.equal(setUrlStub.callCount, 2);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/10/wheatley.md'
- );
+ assert.equal(navToDiffStub.callCount, 1);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'wheatley.md', lineNum: undefined},
+ ]);
+
element.path = 'wheatley.md';
await element.updateComplete;
- assert.isTrue(element.loading);
-
pressKey(element, '[');
- assert.equal(setUrlStub.callCount, 3);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/10/glados.txt'
- );
+ assert.equal(navToDiffStub.callCount, 2);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'glados.txt', lineNum: undefined},
+ ]);
+
element.path = 'glados.txt';
await element.updateComplete;
- assert.isTrue(element.loading);
-
pressKey(element, '[');
- assert.equal(setUrlStub.callCount, 4);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/10/chell.go'
- );
+ assert.equal(navToDiffStub.callCount, 3);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'chell.go', lineNum: undefined},
+ ]);
+
element.path = 'chell.go';
await element.updateComplete;
- assert.isTrue(element.loading);
-
pressKey(element, '[');
- assert.equal(setUrlStub.callCount, 5);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+ assert.equal(navToChangeStub.callCount, 2);
await element.updateComplete;
- assert.isTrue(element.loading);
assertIsDefined(element.diffPreferencesDialog);
const showPrefsStub = sinon
@@ -727,22 +458,9 @@ suite('gr-diff-view tests', () => {
element.diffHost.diffElement.viewMode,
DiffViewMode.SIDE_BY_SIDE
);
- assert.isTrue(element.diffHost.diffElement.displayLine);
-
- pressKey(element, Key.ESC);
- await element.updateComplete;
- assert.equal(
- element.diffHost.diffElement.viewMode,
- DiffViewMode.SIDE_BY_SIDE
- );
- assert.isFalse(element.diffHost.diffElement.displayLine);
- // Note that stubbing setReviewed means that the value of the
- // `element.reviewed` checkbox is not flipped.
const setReviewedStub = sinon.stub(element, 'setReviewed');
const handleToggleSpy = sinon.spy(element, 'handleToggleFileReviewed');
- assertIsDefined(element.reviewed);
- element.reviewed.checked = false;
assert.isFalse(handleToggleSpy.called);
assert.isFalse(setReviewedStub.called);
@@ -768,14 +486,12 @@ suite('gr-diff-view tests', () => {
assertIsDefined(element.cursor);
sinon.stub(element.cursor, 'isAtEnd').returns(true);
element.changeNum = 42 as NumericChangeId;
- const comment: PathToCommentsInfoMap = {
+ const comment: {[path: string]: CommentInfo[]} = {
'wheatley.md': [createComment('c2', 21, 10, 'wheatley.md')],
};
element.changeComments = new ChangeComments(comment);
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -791,23 +507,21 @@ suite('gr-diff-view tests', () => {
element.path = 'glados.txt';
element.loggedIn = true;
await element.updateComplete;
- setUrlStub.reset();
+ navToDiffStub.reset();
pressKey(element, 'N');
await element.updateComplete;
- assert.equal(setUrlStub.callCount, 1);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/10/wheatley.md#21'
- );
+ assert.equal(navToDiffStub.callCount, 1);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'wheatley.md', lineNum: 21},
+ ]);
element.path = 'wheatley.md'; // navigated to next file
pressKey(element, 'N');
await element.updateComplete;
- assert.equal(setUrlStub.callCount, 2);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42');
+ assert.equal(navToChangeStub.callCount, 1);
});
test('shift+x shortcut toggles all diff context', async () => {
@@ -819,114 +533,76 @@ suite('gr-diff-view tests', () => {
});
test('diff against base', async () => {
- element.patchRange = {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = 5 as BasePatchSetNum;
await element.updateComplete;
element.handleDiffAgainstBase();
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/10/some/path.txt'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'some/path.txt'},
+ 10 as RevisionPatchSetNum,
+ PARENT,
+ ]);
});
test('diff against latest', async () => {
element.path = 'foo';
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(12),
- };
- element.patchRange = {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ element.latestPatchNum = 12 as PatchSetNumber;
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = 5 as BasePatchSetNum;
await element.updateComplete;
element.handleDiffAgainstLatest();
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/5..12/foo'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'foo'},
+ 12 as RevisionPatchSetNum,
+ 5 as BasePatchSetNum,
+ ]);
});
test('handleDiffBaseAgainstLeft', async () => {
element.path = 'foo';
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(10),
- };
- element.patchRange = {
+ element.latestPatchNum = 10 as PatchSetNumber;
+ element.patchNum = 3 as RevisionPatchSetNum;
+ element.basePatchNum = 1 as BasePatchSetNum;
+ viewModel.setState({
+ ...createDiffViewState(),
patchNum: 3 as RevisionPatchSetNum,
basePatchNum: 1 as BasePatchSetNum,
- };
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 3 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- path: 'foo',
- };
+ diffView: {path: 'foo'},
+ });
await element.updateComplete;
element.handleDiffBaseAgainstLeft();
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1/foo');
- });
-
- test('handleDiffBaseAgainstLeft when initially navigating to a comment', () => {
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(10),
- };
- element.patchRange = {
- patchNum: 3 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
- sinon.stub(element, 'viewStateChanged');
- element.viewState = {
- commentLink: true,
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- };
- element.focusLineNum = 10;
- element.handleDiffBaseAgainstLeft();
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/1/some/path.txt#10'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'foo'},
+ 1 as RevisionPatchSetNum,
+ PARENT,
+ ]);
});
test('handleDiffRightAgainstLatest', async () => {
element.path = 'foo';
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(10),
- };
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.latestPatchNum = 10 as PatchSetNumber;
+ element.patchNum = 3 as RevisionPatchSetNum;
+ element.basePatchNum = 1 as BasePatchSetNum;
await element.updateComplete;
element.handleDiffRightAgainstLatest();
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/3..10/foo'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'foo'},
+ 10 as RevisionPatchSetNum,
+ 3 as BasePatchSetNum,
+ ]);
});
test('handleDiffBaseAgainstLatest', async () => {
- element.change = {
- ...createParsedChange(),
- revisions: createRevisions(10),
- };
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.latestPatchNum = 10 as PatchSetNumber;
+ element.patchNum = 3 as RevisionPatchSetNum;
+ element.basePatchNum = 1 as BasePatchSetNum;
await element.updateComplete;
element.handleDiffBaseAgainstLatest();
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/10/some/path.txt'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'some/path.txt'},
+ 10 as RevisionPatchSetNum,
+ PARENT,
+ ]);
});
test('A fires an error event when not logged in', async () => {
@@ -935,16 +611,14 @@ suite('gr-diff-view tests', () => {
element.addEventListener('show-auth-required', loggedInErrorSpy);
pressKey(element, 'a');
await element.updateComplete;
- assert.isFalse(setUrlStub.calledOnce);
+ assert.isFalse(navToDiffStub.calledOnce);
assert.isTrue(loggedInErrorSpy.called);
});
test('A navigates to change with logged in', async () => {
element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = 5 as BasePatchSetNum;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -957,25 +631,20 @@ suite('gr-diff-view tests', () => {
await element.updateComplete;
const loggedInErrorSpy = sinon.spy();
element.addEventListener('show-auth-required', loggedInErrorSpy);
- setUrlStub.reset();
+ navToDiffStub.reset();
pressKey(element, 'a');
await element.updateComplete;
- assert.equal(setUrlStub.callCount, 1);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/5..10?openReplyDialog=true'
- );
+ assert.isTrue(navToChangeStub.calledOnce);
+ assert.deepEqual(navToChangeStub.lastCall.args, [true]);
assert.isFalse(loggedInErrorSpy.called);
});
test('A navigates to change with old patch number with logged in', async () => {
element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -989,20 +658,15 @@ suite('gr-diff-view tests', () => {
element.addEventListener('show-auth-required', loggedInErrorSpy);
pressKey(element, 'a');
await element.updateComplete;
- assert.isTrue(setUrlStub.calledOnce);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/1?openReplyDialog=true'
- );
+ assert.isTrue(navToChangeStub.calledOnce);
+ assert.deepEqual(navToChangeStub.lastCall.args, [true]);
assert.isFalse(loggedInErrorSpy.called);
});
test('keyboard shortcuts with patch range', () => {
element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = 5 as BasePatchSetNum;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -1019,55 +683,42 @@ suite('gr-diff-view tests', () => {
element.path = 'glados.txt';
pressKey(element, 'u');
- assert.equal(setUrlStub.callCount, 1);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
+ assert.equal(navToChangeStub.callCount, 1);
pressKey(element, ']');
- assert.isTrue(element.loading);
- assert.equal(setUrlStub.callCount, 2);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/5..10/wheatley.md'
- );
+ assert.equal(navToDiffStub.callCount, 1);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'wheatley.md', lineNum: undefined},
+ ]);
element.path = 'wheatley.md';
pressKey(element, '[');
- assert.isTrue(element.loading);
- assert.equal(setUrlStub.callCount, 3);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/5..10/glados.txt'
- );
+ assert.equal(navToDiffStub.callCount, 2);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'glados.txt', lineNum: undefined},
+ ]);
element.path = 'glados.txt';
pressKey(element, '[');
- assert.isTrue(element.loading);
- assert.equal(setUrlStub.callCount, 4);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/5..10/chell.go'
- );
+ assert.equal(navToDiffStub.callCount, 3);
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'chell.go', lineNum: undefined},
+ ]);
element.path = 'chell.go';
pressKey(element, '[');
- assert.isTrue(element.loading);
- assert.equal(setUrlStub.callCount, 5);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/5..10');
-
- assertIsDefined(element.downloadOverlay);
- const downloadOverlayStub = sinon
- .stub(element.downloadOverlay, 'open')
- .returns(Promise.resolve());
+ assert.equal(navToChangeStub.callCount, 2);
+
+ assertIsDefined(element.downloadModal);
+ const downloadModalStub = sinon.stub(element.downloadModal, 'showModal');
pressKey(element, 'd');
- assert.isTrue(downloadOverlayStub.called);
+ assert.isTrue(downloadModalStub.called);
});
- test('keyboard shortcuts with old patch number', () => {
+ test('keyboard shortcuts with old patch number', async () => {
element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -1084,53 +735,57 @@ suite('gr-diff-view tests', () => {
element.path = 'glados.txt';
pressKey(element, 'u');
- assert.isTrue(setUrlStub.calledOnce);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+ assert.isTrue(navToChangeStub.calledOnce);
pressKey(element, ']');
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/1/wheatley.md'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'wheatley.md', lineNum: undefined},
+ ]);
element.path = 'wheatley.md';
pressKey(element, '[');
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/1/glados.txt'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'glados.txt', lineNum: undefined},
+ ]);
element.path = 'glados.txt';
pressKey(element, '[');
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/test-project/+/42/1/chell.go'
- );
- element.path = 'chell.go';
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: 'chell.go', lineNum: undefined},
+ ]);
- setUrlStub.reset();
+ element.path = 'chell.go';
+ await element.updateComplete;
+ navToDiffStub.reset();
pressKey(element, '[');
- assert.isTrue(setUrlStub.calledOnce);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/test-project/+/42/1');
+ assert.equal(navToChangeStub.callCount, 2);
+ });
+
+ test('reloadDiff is called when patchNum changes', async () => {
+ const reloadStub = sinon.stub(element, 'reloadDiff');
+ element.patchNum = 5 as RevisionPatchSetNum;
+ await element.updateComplete;
+ assert.isTrue(reloadStub.called);
+ });
+
+ test('initializePositions is called when view becomes active', async () => {
+ const reloadStub = sinon.stub(element, 'reloadDiff');
+ const initializeStub = sinon.stub(element, 'initializePositions');
+
+ element.isActiveChildView = false;
+ await element.updateComplete;
+ element.isActiveChildView = true;
+ await element.updateComplete;
+
+ assert.isTrue(initializeStub.calledOnce);
+ assert.isFalse(reloadStub.called);
});
test('edit should redirect to edit page', async () => {
element.loggedIn = true;
element.path = 't.txt';
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
- element.change = {
- ...createParsedChange(),
- _number: 42 as NumericChangeId,
- project: 'gerrit' as RepoName,
- status: ChangeStatus.NEW,
- revisions: {
- a: createRevision(1),
- b: createRevision(2),
- },
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
await element.updateComplete;
const editBtn = queryAndAssert<GrButton>(
element,
@@ -1138,28 +793,18 @@ suite('gr-diff-view tests', () => {
);
assert.isTrue(!!editBtn);
editBtn.click();
- assert.equal(setUrlStub.callCount, 1);
- assert.equal(setUrlStub.lastCall.firstArg, '/c/gerrit/+/42/1/t.txt,edit');
+ assert.equal(navToEditStub.callCount, 1);
+ assert.deepEqual(navToEditStub.lastCall.args, [
+ {path: 't.txt', lineNum: undefined},
+ ]);
});
test('edit should redirect to edit page with line number', async () => {
const lineNumber = 42;
element.loggedIn = true;
element.path = 't.txt';
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
- element.change = {
- ...createParsedChange(),
- _number: 42 as NumericChangeId,
- project: 'gerrit' as RepoName,
- status: ChangeStatus.NEW,
- revisions: {
- a: createRevision(1),
- b: createRevision(2),
- },
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
assertIsDefined(element.cursor);
sinon
.stub(element.cursor, 'getAddress')
@@ -1171,11 +816,10 @@ suite('gr-diff-view tests', () => {
);
assert.isTrue(!!editBtn);
editBtn.click();
- assert.equal(setUrlStub.callCount, 1);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/gerrit/+/42/1/t.txt,edit#42'
- );
+ assert.equal(navToEditStub.callCount, 1);
+ assert.deepEqual(navToEditStub.lastCall.args, [
+ {path: 't.txt', lineNum: 42},
+ ]);
});
async function isEditVisibile({
@@ -1187,10 +831,8 @@ suite('gr-diff-view tests', () => {
}): Promise<boolean> {
element.loggedIn = loggedIn;
element.path = 't.txt';
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -1287,16 +929,10 @@ suite('gr-diff-view tests', () => {
});
suite('url parameters', () => {
- setup(() => {
- sinon.stub(element, 'fetchFiles');
- });
-
test('_formattedFiles', () => {
element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 10 as RevisionPatchSetNum,
- };
+ element.patchNum = 10 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
@@ -1369,18 +1005,19 @@ suite('gr-diff-view tests', () => {
});
test('prev/up/next links', async () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 10 as RevisionPatchSetNum,
- };
- element.change = {
+ viewModel.setState({
+ ...createDiffViewState(),
+ });
+ const change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
revisions: {
a: createRevision(10),
},
};
+ changeModel.updateStateChange(change);
+ await element.updateComplete;
+
element.files = getFilesFromFileList([
'chell.go',
'glados.txt',
@@ -1400,24 +1037,30 @@ suite('gr-diff-view tests', () => {
linkEls[2].getAttribute('href'),
'/c/test-project/+/42/10/wheatley.md'
);
+
element.path = 'wheatley.md';
await element.updateComplete;
+
assert.equal(
linkEls[0].getAttribute('href'),
'/c/test-project/+/42/10/glados.txt'
);
assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
assert.equal(linkEls[2].getAttribute('href'), '/c/test-project/+/42');
+
element.path = 'chell.go';
await element.updateComplete;
+
assert.equal(linkEls[0].getAttribute('href'), '/c/test-project/+/42');
assert.equal(linkEls[1].getAttribute('href'), '/c/test-project/+/42');
assert.equal(
linkEls[2].getAttribute('href'),
'/c/test-project/+/42/10/glados.txt'
);
+
element.path = 'not_a_real_file';
await element.updateComplete;
+
assert.equal(
linkEls[0].getAttribute('href'),
'/c/test-project/+/42/10/wheatley.md'
@@ -1430,26 +1073,30 @@ suite('gr-diff-view tests', () => {
});
test('prev/up/next links with patch range', async () => {
- element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
+ viewModel.setState({
+ ...createDiffViewState(),
basePatchNum: 5 as BasePatchSetNum,
patchNum: 10 as RevisionPatchSetNum,
- };
- element.change = {
+ diffView: {path: 'glados.txt'},
+ });
+ const change = {
...createParsedChange(),
_number: 42 as NumericChangeId,
revisions: {
a: createRevision(5),
b: createRevision(10),
+ c: createRevision(12),
},
};
+ changeModel.updateStateChange(change);
element.files = getFilesFromFileList([
'chell.go',
'glados.txt',
'wheatley.md',
]);
- element.path = 'glados.txt';
- await element.updateComplete;
+ await waitUntil(() => element.path === 'glados.txt');
+ await waitUntil(() => element.patchRange?.patchNum === 10);
+
const linkEls = queryAll(element, '.navLink');
assert.equal(linkEls.length, 3);
assert.equal(
@@ -1464,8 +1111,10 @@ suite('gr-diff-view tests', () => {
linkEls[2].getAttribute('href'),
'/c/test-project/+/42/5..10/wheatley.md'
);
- element.path = 'wheatley.md';
- await element.updateComplete;
+
+ viewModel.updateState({diffView: {path: 'wheatley.md'}});
+ await waitUntil(() => element.path === 'wheatley.md');
+
assert.equal(
linkEls[0].getAttribute('href'),
'/c/test-project/+/42/5..10/glados.txt'
@@ -1478,8 +1127,10 @@ suite('gr-diff-view tests', () => {
linkEls[2].getAttribute('href'),
'/c/test-project/+/42/5..10'
);
- element.path = 'chell.go';
- await element.updateComplete;
+
+ viewModel.updateState({diffView: {path: 'chell.go'}});
+ await waitUntil(() => element.path === 'chell.go');
+
assert.equal(
linkEls[0].getAttribute('href'),
'/c/test-project/+/42/5..10'
@@ -1496,40 +1147,32 @@ suite('gr-diff-view tests', () => {
});
test('handlePatchChange calls setUrl correctly', async () => {
- element.change = {
- ...createParsedChange(),
- _number: 321 as NumericChangeId,
- project: 'foo/bar' as RepoName,
- };
element.path = 'path/to/file.txt';
-
- element.patchRange = {
- basePatchNum: PARENT,
- patchNum: 3 as RevisionPatchSetNum,
- };
+ element.patchNum = 3 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
await element.updateComplete;
const detail = {
basePatchNum: PARENT,
patchNum: 1 as RevisionPatchSetNum,
};
-
queryAndAssert(element, '#rangeSelect').dispatchEvent(
new CustomEvent('patch-range-change', {detail, bubbles: false})
);
- assert.equal(
- setUrlStub.lastCall.firstArg,
- '/c/foo/bar/+/321/1/path/to/file.txt'
- );
+ assert.deepEqual(navToDiffStub.lastCall.args, [
+ {path: element.path},
+ detail.patchNum,
+ detail.basePatchNum,
+ ]);
});
test(
- '_prefs.manual_review true means set reviewed is not ' +
+ 'prefs.manual_review true means set reviewed is not ' +
'automatically called',
async () => {
const setReviewedFileStatusStub = sinon
- .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+ .stub(changeModel, 'setReviewedFilesStatus')
.callsFake(() => Promise.resolve());
const setReviewedStatusStub = sinon.spy(element, 'setReviewedStatus');
@@ -1541,39 +1184,29 @@ suite('gr-diff-view tests', () => {
...createDefaultDiffPrefs(),
manual_review: true,
};
- element.userModel.setDiffPreferences(diffPreferences);
- element.getChangeModel().setState({
+ userModel.setDiffPreferences(diffPreferences);
+ viewModel.updateState({diffView: {path: 'wheatley.md'}});
+ changeModel.setState({
change: createParsedChange(),
- diffPath: '/COMMIT_MSG',
reviewedFiles: [],
loadingStatus: LoadingStatus.LOADED,
});
- element.routerModel.setState({
- changeNum: TEST_NUMERIC_CHANGE_ID,
- view: GerritView.DIFF,
- patchNum: 2 as RevisionPatchSetNum,
- });
- element.patchRange = {
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
-
await waitUntil(() => setReviewedStatusStub.called);
assert.isFalse(setReviewedFileStatusStub.called);
// if prefs are updated then the reviewed status should not be set again
- element.userModel.setDiffPreferences(createDefaultDiffPrefs());
+ userModel.setDiffPreferences(createDefaultDiffPrefs());
await element.updateComplete;
assert.isFalse(setReviewedFileStatusStub.called);
}
);
- test('_prefs.manual_review false means set reviewed is called', async () => {
+ test('prefs.manual_review false means set reviewed is called', async () => {
const setReviewedFileStatusStub = sinon
- .stub(element.getChangeModel(), 'setReviewedFilesStatus')
+ .stub(changeModel, 'setReviewedFilesStatus')
.callsFake(() => Promise.resolve());
assertIsDefined(element.diffHost);
@@ -1583,70 +1216,54 @@ suite('gr-diff-view tests', () => {
...createDefaultDiffPrefs(),
manual_review: false,
};
- element.userModel.setDiffPreferences(diffPreferences);
- element.getChangeModel().setState({
+ userModel.setDiffPreferences(diffPreferences);
+ viewModel.updateState({diffView: {path: 'wheatley.md'}});
+ changeModel.setState({
change: createParsedChange(),
- diffPath: '/COMMIT_MSG',
reviewedFiles: [],
loadingStatus: LoadingStatus.LOADED,
});
- element.routerModel.setState({
- changeNum: TEST_NUMERIC_CHANGE_ID,
- view: GerritView.DIFF,
- patchNum: 22 as RevisionPatchSetNum,
- });
- element.patchRange = {
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
-
await waitUntil(() => setReviewedFileStatusStub.called);
assert.isTrue(setReviewedFileStatusStub.called);
});
test('file review status', async () => {
- element.getChangeModel().setState({
+ const saveReviewedStub = sinon
+ .stub(changeModel, 'setReviewedFilesStatus')
+ .callsFake(() => Promise.resolve());
+ userModel.setDiffPreferences(createDefaultDiffPrefs());
+ viewModel.updateState({
+ patchNum: 1 as RevisionPatchSetNum,
+ basePatchNum: PARENT,
+ diffView: {path: '/COMMIT_MSG'},
+ });
+ changeModel.setState({
change: createParsedChange(),
- diffPath: '/COMMIT_MSG',
reviewedFiles: [],
loadingStatus: LoadingStatus.LOADED,
});
element.loggedIn = true;
- const saveReviewedStub = sinon
- .stub(element.getChangeModel(), 'setReviewedFilesStatus')
- .callsFake(() => Promise.resolve());
+ await waitUntil(() => element.patchRange?.patchNum === 1);
+ await element.updateComplete;
assertIsDefined(element.diffHost);
sinon.stub(element.diffHost, 'reload');
- element.userModel.setDiffPreferences(createDefaultDiffPrefs());
-
- element.routerModel.setState({
- changeNum: TEST_NUMERIC_CHANGE_ID,
- view: GerritView.DIFF,
- patchNum: 2 as RevisionPatchSetNum,
- });
-
- element.patchRange = {
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
-
await waitUntil(() => saveReviewedStub.called);
- element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', true);
+ changeModel.updateStateFileReviewed('/COMMIT_MSG', true);
await element.updateComplete;
const reviewedStatusCheckBox = queryAndAssert<HTMLInputElement>(
element,
- 'input[type="checkbox"]'
+ 'input#reviewed'
);
assert.isTrue(reviewedStatusCheckBox.checked);
assert.deepEqual(saveReviewedStub.lastCall.args, [
42,
- 2,
+ 1,
'/COMMIT_MSG',
true,
]);
@@ -1655,30 +1272,29 @@ suite('gr-diff-view tests', () => {
assert.isFalse(reviewedStatusCheckBox.checked);
assert.deepEqual(saveReviewedStub.lastCall.args, [
42,
- 2,
+ 1,
'/COMMIT_MSG',
false,
]);
- element.getChangeModel().updateStateFileReviewed('/COMMIT_MSG', false);
+ changeModel.updateStateFileReviewed('/COMMIT_MSG', false);
await element.updateComplete;
reviewedStatusCheckBox.click();
assert.isTrue(reviewedStatusCheckBox.checked);
assert.deepEqual(saveReviewedStub.lastCall.args, [
42,
- 2,
+ 1,
'/COMMIT_MSG',
true,
]);
const callCount = saveReviewedStub.callCount;
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- project: 'test' as RepoName,
- };
+ viewModel.setState({
+ ...createDiffViewState(),
+ repo: 'test' as RepoName,
+ });
await element.updateComplete;
// saveReviewedState observer observes viewState, but should not fire when
@@ -1686,36 +1302,27 @@ suite('gr-diff-view tests', () => {
assert.equal(saveReviewedStub.callCount, callCount);
});
- test('file review status with edit loaded', async () => {
+ test('do not set file review status for EDIT patchset', async () => {
const saveReviewedStub = sinon.stub(
- element.getChangeModel(),
+ changeModel,
'setReviewedFilesStatus'
);
- element.patchRange = {
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: EDIT,
- };
+ element.patchNum = EDIT;
+ element.basePatchNum = 1 as BasePatchSetNum;
await waitEventLoop();
- assert.isTrue(element.computeEditMode());
element.setReviewed(true);
+
assert.isFalse(saveReviewedStub.called);
});
test('hash is determined from viewState', async () => {
assertIsDefined(element.diffHost);
sinon.stub(element.diffHost, 'reload');
- const initLineStub = sinon.stub(element, 'initLineOfInterestAndCursor');
+ const initLineStub = sinon.stub(element, 'initCursor');
- element.loggedIn = true;
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- path: '/COMMIT_MSG',
- };
+ element.focusLineNum = 123;
await element.updateComplete;
await waitEventLoop();
@@ -1730,9 +1337,9 @@ suite('gr-diff-view tests', () => {
...createDefaultPreferences(),
diff_view: DiffViewMode.SIDE_BY_SIDE,
};
- element.getBrowserModel().setScreenWidth(0);
+ browserModel.setScreenWidth(0);
- const userStub = stubUsers('updatePreferences');
+ const userStub = sinon.stub(userModel, 'updatePreferences');
await element.updateComplete;
// The mode selected in the view state reflects the selected option.
@@ -1763,115 +1370,56 @@ suite('gr-diff-view tests', () => {
assert.isTrue(diffModeSelector.classList.contains('hide'));
});
- suite('commitRange', () => {
- const change: ParsedChangeInfo = {
- ...createParsedChange(),
- _number: 42 as NumericChangeId,
- revisions: {
- 'commit-sha-1': {
- ...createRevision(1),
- commit: {
- ...createCommit(),
- parents: [{subject: 's1', commit: 'sha-1-parent' as CommitId}],
- },
- },
- 'commit-sha-2': createRevision(2),
- 'commit-sha-3': createRevision(3),
- 'commit-sha-4': createRevision(4),
- 'commit-sha-5': {
- ...createRevision(5),
- commit: {
- ...createCommit(),
- parents: [{subject: 's5', commit: 'sha-5-parent' as CommitId}],
- },
- },
- },
- };
- setup(async () => {
- assertIsDefined(element.diffHost);
- sinon.stub(element.diffHost, 'reload');
- sinon.stub(element, 'initCursor');
- element.change = change;
- await element.updateComplete;
- await element.diffHost.updateComplete;
- });
-
- test('uses the patchNum and basePatchNum ', async () => {
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 4 as RevisionPatchSetNum,
- basePatchNum: 2 as BasePatchSetNum,
- path: '/COMMIT_MSG',
- };
- element.change = change;
- await element.updateComplete;
- await waitEventLoop();
- assert.deepEqual(element.commitRange, {
- baseCommit: 'commit-sha-2' as CommitId,
- commit: 'commit-sha-4' as CommitId,
- });
- });
-
- test('uses the parent when there is no base patch num ', async () => {
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 5 as RevisionPatchSetNum,
- path: '/COMMIT_MSG',
- };
- element.change = change;
- await element.updateComplete;
- await waitEventLoop();
- assert.deepEqual(element.commitRange, {
- commit: 'commit-sha-5' as CommitId,
- baseCommit: 'sha-5-parent' as CommitId,
- });
- });
- });
-
test('initCursor', () => {
assertIsDefined(element.cursor);
assert.isNotOk(element.cursor.initialLineNumber);
// Does nothing when viewState specify no cursor address:
- element.initCursor(false);
+ element.leftSide = false;
+ element.initCursor();
assert.isNotOk(element.cursor.initialLineNumber);
// Does nothing when viewState specify side but no number:
- element.initCursor(true);
+ element.leftSide = true;
+ element.initCursor();
assert.isNotOk(element.cursor.initialLineNumber);
// Revision hash: specifies lineNum but not side.
element.focusLineNum = 234;
- element.initCursor(false);
+ element.leftSide = false;
+ element.initCursor();
assert.equal(element.cursor.initialLineNumber, 234);
assert.equal(element.cursor.side, Side.RIGHT);
// Base hash: specifies lineNum and side.
element.focusLineNum = 345;
- element.initCursor(true);
+ element.leftSide = true;
+ element.initCursor();
assert.equal(element.cursor.initialLineNumber, 345);
assert.equal(element.cursor.side, Side.LEFT);
// Specifies right side:
element.focusLineNum = 123;
- element.initCursor(false);
+ element.leftSide = false;
+ element.initCursor();
assert.equal(element.cursor.initialLineNumber, 123);
assert.equal(element.cursor.side, Side.RIGHT);
});
test('getLineOfInterest', () => {
- assert.isUndefined(element.getLineOfInterest(false));
+ element.leftSide = false;
+ assert.isUndefined(element.getLineOfInterest());
element.focusLineNum = 12;
- let result = element.getLineOfInterest(false);
+ element.leftSide = false;
+ let result = element.getLineOfInterest();
assert.isOk(result);
assert.equal(result!.lineNum, 12);
assert.equal(result!.side, Side.RIGHT);
- result = element.getLineOfInterest(true);
+ element.leftSide = true;
+ result = element.getLineOfInterest();
assert.isOk(result);
assert.equal(result!.lineNum, 12);
assert.equal(result!.side, Side.LEFT);
@@ -1890,10 +1438,8 @@ suite('gr-diff-view tests', () => {
_number: 321 as NumericChangeId,
project: 'foo/bar' as RepoName,
};
- element.patchRange = {
- basePatchNum: 3 as BasePatchSetNum,
- patchNum: 5 as RevisionPatchSetNum,
- };
+ element.patchNum = 5 as RevisionPatchSetNum;
+ element.basePatchNum = 3 as BasePatchSetNum;
const e = {detail: {number: 123, side: Side.RIGHT}} as CustomEvent;
element.onLineSelected(e);
@@ -1914,10 +1460,8 @@ suite('gr-diff-view tests', () => {
_number: 321 as NumericChangeId,
project: 'foo/bar' as RepoName,
};
- element.patchRange = {
- basePatchNum: 3 as BasePatchSetNum,
- patchNum: 5 as RevisionPatchSetNum,
- };
+ element.patchNum = 5 as RevisionPatchSetNum;
+ element.basePatchNum = 3 as BasePatchSetNum;
const e = {detail: {number: 123, side: Side.LEFT}} as CustomEvent;
element.onLineSelected(e);
@@ -1926,7 +1470,7 @@ suite('gr-diff-view tests', () => {
});
test('handleToggleDiffMode', () => {
- const userStub = stubUsers('updatePreferences');
+ const userStub = sinon.stub(userModel, 'updatePreferences');
element.userPrefs = {
...createDefaultPreferences(),
diff_view: DiffViewMode.SIDE_BY_SIDE,
@@ -1948,199 +1492,116 @@ suite('gr-diff-view tests', () => {
});
});
- suite('initPatchRange', () => {
- setup(async () => {
- getDiffRestApiStub.returns(Promise.resolve(createDiff()));
- element.viewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- patchNum: 3 as RevisionPatchSetNum,
- path: 'abcd',
- };
- await element.updateComplete;
- });
- test('empty', () => {
- sinon.stub(element, 'getPaths').returns({});
- element.initPatchRange();
- assert.equal(Object.keys(element.commentMap ?? {}).length, 0);
- });
-
- test('has paths', () => {
- sinon.stub(element, 'fetchFiles');
- sinon.stub(element, 'getPaths').returns({
- 'path/to/file/one.cpp': true,
- 'path-to/file/two.py': true,
- });
- element.changeNum = 42 as NumericChangeId;
- element.patchRange = {
- basePatchNum: 3 as BasePatchSetNum,
- patchNum: 5 as RevisionPatchSetNum,
- };
- element.initPatchRange();
- assert.deepEqual(Object.keys(element.commentMap ?? {}), [
- 'path/to/file/one.cpp',
- 'path-to/file/two.py',
- ]);
- });
- });
-
- suite('computeCommentSkips', () => {
+ suite('findFileWithComment', () => {
test('empty file list', () => {
- const commentMap = {
- 'path/one.jpg': true,
- 'path/three.wav': true,
- };
- const path = 'path/two.m4v';
- const result = element.computeCommentSkips(commentMap, [], path);
- assert.isOk(result);
- assert.isNotOk(result!.previous);
- assert.isNotOk(result!.next);
+ element.changeComments = new ChangeComments({
+ 'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+ 'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+ });
+ element.path = 'path/two.m4v';
+ assert.isUndefined(element.findFileWithComment(-1));
+ assert.isUndefined(element.findFileWithComment(1));
});
test('finds skips', () => {
const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
- let path = fileList[1];
- const commentMap: CommentMap = {};
- commentMap[fileList[0]] = true;
- commentMap[fileList[1]] = false;
- commentMap[fileList[2]] = true;
+ element.files = {sortedPaths: fileList, changeFilesByPath: {}};
+ element.path = fileList[1];
+ element.changeComments = new ChangeComments({
+ 'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+ 'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+ });
- let result = element.computeCommentSkips(commentMap, fileList, path);
- assert.isOk(result);
- assert.equal(result!.previous, fileList[0]);
- assert.equal(result!.next, fileList[2]);
+ assert.equal(element.findFileWithComment(-1), fileList[0]);
+ assert.equal(element.findFileWithComment(1), fileList[2]);
- commentMap[fileList[1]] = true;
+ element.changeComments = new ChangeComments({
+ 'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+ 'path/two.m4v': [createComment('c1', 1, 1, 'path/two.m4v')],
+ 'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+ });
- result = element.computeCommentSkips(commentMap, fileList, path);
- assert.isOk(result);
- assert.equal(result!.previous, fileList[0]);
- assert.equal(result!.next, fileList[2]);
+ assert.equal(element.findFileWithComment(-1), fileList[0]);
+ assert.equal(element.findFileWithComment(1), fileList[2]);
- path = fileList[0];
+ element.path = fileList[0];
- result = element.computeCommentSkips(commentMap, fileList, path);
- assert.isOk(result);
- assert.isNull(result!.previous);
- assert.equal(result!.next, fileList[1]);
+ assert.isUndefined(element.findFileWithComment(-1));
+ assert.equal(element.findFileWithComment(1), fileList[1]);
- path = fileList[2];
+ element.path = fileList[2];
- result = element.computeCommentSkips(commentMap, fileList, path);
- assert.isOk(result);
- assert.equal(result!.previous, fileList[1]);
- assert.isNull(result!.next);
+ assert.equal(element.findFileWithComment(-1), fileList[1]);
+ assert.isUndefined(element.findFileWithComment(1));
});
suite('skip next/previous', () => {
- let navToChangeStub: SinonStub;
-
setup(() => {
- navToChangeStub = sinon.stub(element, 'navToChangeView');
element.files = getFilesFromFileList([
'path/one.jpg',
'path/two.m4v',
'path/three.wav',
]);
- element.patchRange = {
- patchNum: 2 as RevisionPatchSetNum,
- basePatchNum: 1 as BasePatchSetNum,
- };
+ element.patchNum = 2 as RevisionPatchSetNum;
+ element.basePatchNum = 1 as BasePatchSetNum;
});
- suite('moveToPreviousFileWithComment', () => {
- test('no skips', () => {
- element.moveToPreviousFileWithComment();
- assert.isFalse(navToChangeStub.called);
- assert.isFalse(setUrlStub.called);
- });
-
+ suite('moveToFileWithComment previous', () => {
test('no previous', async () => {
- const commentMap: CommentMap = {};
- commentMap[element.files.sortedFileList[0]!] = false;
- commentMap[element.files.sortedFileList[1]!] = false;
- commentMap[element.files.sortedFileList[2]!] = true;
- element.commentMap = commentMap;
- element.path = element.files.sortedFileList[1];
+ element.changeComments = new ChangeComments({
+ 'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+ });
+ element.path = element.files.sortedPaths[1];
await element.updateComplete;
- element.moveToPreviousFileWithComment();
+ element.moveToFileWithComment(-1);
assert.isTrue(navToChangeStub.calledOnce);
- assert.isFalse(setUrlStub.called);
+ assert.isFalse(navToDiffStub.called);
});
test('w/ previous', async () => {
- const commentMap: CommentMap = {};
- commentMap[element.files.sortedFileList[0]!] = true;
- commentMap[element.files.sortedFileList[1]!] = false;
- commentMap[element.files.sortedFileList[2]!] = true;
- element.commentMap = commentMap;
- element.path = element.files.sortedFileList[1];
+ element.changeComments = new ChangeComments({
+ 'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+ 'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+ });
+ element.path = element.files.sortedPaths[1];
await element.updateComplete;
- element.moveToPreviousFileWithComment();
+ element.moveToFileWithComment(-1);
assert.isFalse(navToChangeStub.called);
- assert.isTrue(setUrlStub.calledOnce);
+ assert.isTrue(navToDiffStub.calledOnce);
});
});
- suite('moveToNextFileWithComment', () => {
- test('no skips', () => {
- element.moveToNextFileWithComment();
- assert.isFalse(navToChangeStub.called);
- assert.isFalse(setUrlStub.called);
- });
-
+ suite('moveToFileWithComment next', () => {
test('no previous', async () => {
- const commentMap: CommentMap = {};
- commentMap[element.files.sortedFileList[0]!] = true;
- commentMap[element.files.sortedFileList[1]!] = false;
- commentMap[element.files.sortedFileList[2]!] = false;
- element.commentMap = commentMap;
- element.path = element.files.sortedFileList[1];
+ element.changeComments = new ChangeComments({
+ 'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+ });
+ element.path = element.files.sortedPaths[1];
await element.updateComplete;
- element.moveToNextFileWithComment();
+ element.moveToFileWithComment(1);
assert.isTrue(navToChangeStub.calledOnce);
- assert.isFalse(setUrlStub.called);
+ assert.isFalse(navToDiffStub.called);
});
test('w/ previous', async () => {
- const commentMap: CommentMap = {};
- commentMap[element.files.sortedFileList[0]!] = true;
- commentMap[element.files.sortedFileList[1]!] = false;
- commentMap[element.files.sortedFileList[2]!] = true;
- element.commentMap = commentMap;
- element.path = element.files.sortedFileList[1];
+ element.changeComments = new ChangeComments({
+ 'path/one.jpg': [createComment('c1', 1, 1, 'path/one.jpg')],
+ 'path/three.wav': [createComment('c1', 1, 1, 'path/three.wav')],
+ });
+ element.path = element.files.sortedPaths[1];
await element.updateComplete;
- element.moveToNextFileWithComment();
+ element.moveToFileWithComment(1);
assert.isFalse(navToChangeStub.called);
- assert.isTrue(setUrlStub.calledOnce);
+ assert.isTrue(navToDiffStub.calledOnce);
});
});
});
});
- test('_computeEditMode', () => {
- const callCompute = (range: PatchRange) => {
- element.patchRange = range;
- return element.computeEditMode();
- };
- assert.isFalse(
- callCompute({
- basePatchNum: PARENT,
- patchNum: 1 as RevisionPatchSetNum,
- })
- );
- assert.isTrue(
- callCompute({
- basePatchNum: 1 as BasePatchSetNum,
- patchNum: EDIT,
- })
- );
- });
-
test('computeFileNum', () => {
element.path = '/foo';
assert.equal(
@@ -2207,25 +1668,32 @@ suite('gr-diff-view tests', () => {
test('reviewed checkbox', async () => {
sinon.stub(element, 'handlePatchChange');
- element.patchRange = createPatchRange();
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
await element.updateComplete;
- assertIsDefined(element.reviewed);
- // Reviewed checkbox should be shown.
- assert.isTrue(isVisible(element.reviewed));
- element.patchRange = {...element.patchRange, patchNum: EDIT};
+
+ let checkbox = queryAndAssert(element, '#reviewed');
+ assert.isTrue(isVisible(checkbox));
+
+ element.patchNum = EDIT;
await element.updateComplete;
- assert.isFalse(isVisible(element.reviewed));
+ checkbox = queryAndAssert(element, '#reviewed');
+ assert.isFalse(isVisible(checkbox));
});
});
suite('switching files', () => {
- let dispatchEventStub: SinonStub;
- let navToFileStub: SinonStub;
- let moveToPreviousChunkStub: SinonStub;
- let moveToNextChunkStub: SinonStub;
- let isAtStartStub: SinonStub;
- let isAtEndStub: SinonStub;
+ let dispatchEventStub: SinonStubbedMember<Element['dispatchEvent']>;
+ let navToFileStub: SinonStubbedMember<GrDiffView['navToFile']>;
+ let moveToPreviousChunkStub: SinonStubbedMember<
+ GrDiffCursor['moveToPreviousChunk']
+ >;
+ let moveToNextChunkStub: SinonStubbedMember<
+ GrDiffCursor['moveToNextChunk']
+ >;
+ let isAtStartStub: SinonStubbedMember<GrDiffCursor['isAtStart']>;
+ let isAtEndStub: SinonStubbedMember<GrDiffCursor['isAtEnd']>;
let nowStub: SinonStub;
setup(() => {
@@ -2249,10 +1717,7 @@ suite('gr-diff-view tests', () => {
pressKey(element, 'n');
assert.isTrue(moveToNextChunkStub.called);
- assert.equal(
- dispatchEventStub.lastCall.args[0].type,
- EventType.SHOW_ALERT
- );
+ assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
assert.isFalse(navToFileStub.called);
});
@@ -2292,10 +1757,7 @@ suite('gr-diff-view tests', () => {
pressKey(element, 'p');
assert.isTrue(moveToPreviousChunkStub.called);
- assert.equal(
- dispatchEventStub.lastCall.args[0].type,
- EventType.SHOW_ALERT
- );
+ assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
assert.isFalse(navToFileStub.called);
});
@@ -2360,48 +1822,43 @@ suite('gr-diff-view tests', () => {
test('File change should trigger setUrl once', async () => {
element.files = getFilesFromFileList(['file1', 'file2', 'file3']);
- sinon.stub(element, 'initLineOfInterestAndCursor');
+ sinon.stub(element, 'initCursor');
// Load file1
- element.viewState = {
- view: GerritView.DIFF,
- patchNum: 1 as RevisionPatchSetNum,
- changeNum: 101 as NumericChangeId,
- project: 'test-project' as RepoName,
- path: 'file1',
- };
- element.patchRange = {
+ viewModel.setState({
+ ...createDiffViewState(),
patchNum: 1 as RevisionPatchSetNum,
- basePatchNum: PARENT,
- };
+ repo: 'test-project' as RepoName,
+ diffView: {path: 'file1'},
+ });
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.change = {
...createParsedChange(),
revisions: createRevisions(1),
};
await element.updateComplete;
- assert.isFalse(setUrlStub.called);
+ assert.isFalse(navToDiffStub.called);
// Switch to file2
element.handleFileChange(
new CustomEvent('value-change', {detail: {value: 'file2'}})
);
- assert.isTrue(setUrlStub.calledOnce);
+ assert.isTrue(navToDiffStub.calledOnce);
+ assert.deepEqual(navToDiffStub.lastCall.firstArg, {path: 'file2'});
// This is to mock the param change triggered by above navigate
- element.viewState = {
- view: GerritView.DIFF,
+ viewModel.setState({
+ ...createDiffViewState(),
patchNum: 1 as RevisionPatchSetNum,
- changeNum: 101 as NumericChangeId,
- project: 'test-project' as RepoName,
- path: 'file2',
- };
- element.patchRange = {
- patchNum: 1 as RevisionPatchSetNum,
- basePatchNum: PARENT,
- };
+ repo: 'test-project' as RepoName,
+ diffView: {path: 'file2'},
+ });
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
// No extra call
- assert.isTrue(setUrlStub.calledOnce);
+ assert.isTrue(navToDiffStub.calledOnce);
});
test('_computeDownloadDropdownLinks', () => {
@@ -2423,10 +1880,8 @@ suite('gr-diff-view tests', () => {
element.change = createParsedChange();
element.change.project = 'test' as RepoName;
element.changeNum = 12 as NumericChangeId;
- element.patchRange = {
- patchNum: 1 as RevisionPatchSetNum,
- basePatchNum: PARENT,
- };
+ element.patchNum = 1 as RevisionPatchSetNum;
+ element.basePatchNum = PARENT;
element.path = 'index.php';
element.diff = createDiff();
assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
@@ -2455,10 +1910,8 @@ suite('gr-diff-view tests', () => {
element.change = createParsedChange();
element.change.project = 'test' as RepoName;
element.changeNum = 12 as NumericChangeId;
- element.patchRange = {
- patchNum: 3 as RevisionPatchSetNum,
- basePatchNum: 2 as BasePatchSetNum,
- };
+ element.patchNum = 3 as RevisionPatchSetNum;
+ element.basePatchNum = 2 as BasePatchSetNum;
element.path = 'index.php';
element.diff = diff;
assert.deepEqual(element.computeDownloadDropdownLinks(), downloadLinks);
@@ -2522,49 +1975,4 @@ suite('gr-diff-view tests', () => {
);
});
});
-
- suite('unmodified files with comments', () => {
- let element: GrDiffView;
-
- setup(async () => {
- const changedFiles = {
- 'file1.txt': createFileInfo(),
- 'a/b/test.c': createFileInfo(),
- };
- stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
- stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
- stubRestApi('getChangeFiles').returns(Promise.resolve(changedFiles));
- stubRestApi('saveFileReviewed').returns(Promise.resolve(new Response()));
- stubRestApi('getDiffComments').returns(Promise.resolve({}));
- stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
- stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
- stubRestApi('getReviewedFiles').returns(Promise.resolve([]));
- element = await fixture(html`<gr-diff-view></gr-diff-view>`);
- element.changeNum = 42 as NumericChangeId;
- });
-
- test('fetchFiles add files with comments without changes', () => {
- element.patchRange = {
- basePatchNum: 5 as BasePatchSetNum,
- patchNum: 10 as RevisionPatchSetNum,
- };
- element.changeComments = {
- getPaths: sinon.stub().returns({
- 'file2.txt': {},
- 'file1.txt': {},
- }),
- } as unknown as ChangeComments;
- element.changeNum = 23 as NumericChangeId;
- return element.fetchFiles().then(() => {
- assert.deepEqual(element.files, {
- sortedFileList: ['a/b/test.c', 'file1.txt', 'file2.txt'],
- changeFilesByPath: {
- 'file1.txt': createFileInfo(),
- 'file2.txt': {status: 'U'} as FileInfo,
- 'a/b/test.c': createFileInfo(),
- },
- });
- });
- });
- });
});
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
index 5881cc6b23..76d67afbec 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -5,6 +5,7 @@
*/
import '../../shared/gr-dropdown-list/gr-dropdown-list';
import '../../shared/gr-select/gr-select';
+import '../../shared/gr-weblink/gr-weblink';
import {convertToString, pluralize} from '../../../utils/string-util';
import {getAppContext} from '../../../services/app-context';
import {
@@ -13,7 +14,6 @@ import {
getParentIndex,
getRevisionByPatchNum,
isMergeParent,
- sortRevisions,
PatchSet,
convertToPatchSetNum,
} from '../../../utils/patch-set-util';
@@ -27,6 +27,7 @@ import {
RevisionInfo,
RevisionPatchSetNum,
Timestamp,
+ WebLinkInfo,
} from '../../../types/common';
import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
@@ -42,10 +43,10 @@ import {customElement, property, query, state} from 'lit/decorators.js';
import {subscribe} from '../../lit/subscription-controller';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {resolve} from '../../../models/dependency';
-import {ifDefined} from 'lit/directives/if-defined.js';
import {ValueChangedEvent} from '../../../types/events';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
import {changeModelToken} from '../../../models/change/change-model';
+import {changeViewModelToken} from '../../../models/views/change';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
@@ -55,15 +56,15 @@ function getShaForPatch(patch: PatchSet) {
}
export interface PatchRangeChangeDetail {
- patchNum?: PatchSetNum;
+ patchNum?: RevisionPatchSetNum;
basePatchNum?: BasePatchSetNum;
}
export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
export interface FilesWebLinks {
- meta_a: GeneratedWebLink[];
- meta_b: GeneratedWebLink[];
+ meta_a: WebLinkInfo[];
+ meta_b: WebLinkInfo[];
}
declare global {
@@ -72,6 +73,12 @@ declare global {
}
}
+declare global {
+ interface HTMLElementEventMap {
+ 'patch-range-change': PatchRangeChangeEvent;
+ }
+}
+
/**
* Fired when the patch range changes
*
@@ -116,14 +123,13 @@ export class GrPatchRangeSelect extends LitElement {
private readonly getChangeModel = resolve(this, changeModelToken);
- // Private but used in tests.
- readonly routerModel = getAppContext().routerModel;
+ private readonly getViewModel = resolve(this, changeViewModelToken);
constructor() {
super();
subscribe(
this,
- () => this.routerModel.routerChangeNum$,
+ () => this.getViewModel().changeNum$,
x => (this.changeNum = x)
);
subscribe(
@@ -149,7 +155,7 @@ export class GrPatchRangeSelect extends LitElement {
subscribe(
this,
() => this.getChangeModel().revisions$,
- x => (this.sortedRevisions = sortRevisions(Object.values(x || {})))
+ x => (this.sortedRevisions = x)
);
subscribe(
this,
@@ -178,6 +184,9 @@ export class GrPatchRangeSelect extends LitElement {
--trigger-style-text-color: var(--deemphasized-text-color);
--trigger-style-font-family: var(--font-family);
}
+ .filesWeblinks gr-weblink {
+ vertical-align: baseline;
+ }
@media screen and (max-width: 50em) {
.filesWeblinks {
display: none;
@@ -224,15 +233,11 @@ export class GrPatchRangeSelect extends LitElement {
`;
}
- private renderWeblinks(fileLinks?: GeneratedWebLink[]) {
+ private renderWeblinks(fileLinks?: WebLinkInfo[]) {
if (!fileLinks) return;
return html`<span class="filesWeblinks">
${fileLinks.map(
- weblink => html`
- <a target="_blank" rel="noopener" href=${ifDefined(weblink.url)}>
- ${weblink.name}
- </a>
- `
+ weblink => html`<gr-weblink .info=${weblink}></gr-weblink>`
)}</span
> `;
}
@@ -318,11 +323,7 @@ export class GrPatchRangeSelect extends LitElement {
}
private computeText(patchNum: PatchSetNum, prefix: string, sha: string) {
- return (
- `${prefix}${patchNum}` +
- `${this.computePatchSetCommentsString(patchNum)}` +
- ` | ${sha}`
- );
+ return `${prefix}${patchNum} | ${sha}`;
}
private createDropdownEntry(
@@ -336,6 +337,12 @@ export class GrPatchRangeSelect extends LitElement {
mobileText: this.computeMobileText(patchNum),
bottomText: `${this.computePatchSetDescription(patchNum)}`,
value: patchNum,
+ commentThreads: this.changeComments?.computeCommentThreads(
+ {
+ patchNum,
+ },
+ true
+ ),
};
const date = this.computePatchSetDate(patchNum);
if (date) {
@@ -410,12 +417,12 @@ export class GrPatchRangeSelect extends LitElement {
computePatchSetCommentsString(patchNum: PatchSetNum): string {
if (!this.changeComments) return '';
- const commentThreadCount = this.changeComments.computeCommentThreadCount(
+ const commentThreadCount = this.changeComments.computeCommentThreads(
{
patchNum,
},
true
- );
+ ).length;
const commentThreadString = pluralize(commentThreadCount, 'comment');
const unresolvedCount = this.changeComments.computeUnresolvedNum(
@@ -463,7 +470,9 @@ export class GrPatchRangeSelect extends LitElement {
basePatchNum: this.basePatchNum,
};
const target = e.target;
- const patchSetValue = convertToPatchSetNum(e.detail.value)!;
+ const patchSetValue = convertToPatchSetNum(
+ e.detail.value
+ ) as RevisionPatchSetNum;
const latestPatchNum = computeLatestPatchNum(this.availablePatches);
if (target === this.patchNumDropdown) {
if (detail.patchNum === patchSetValue) return;
@@ -471,9 +480,9 @@ export class GrPatchRangeSelect extends LitElement {
previous: detail.patchNum,
current: patchSetValue,
latest: latestPatchNum,
- commentCount: this.changeComments?.computeCommentThreadCount({
+ commentCount: this.changeComments?.computeCommentThreads({
patchNum: patchSetValue,
- }),
+ }).length,
});
detail.patchNum = patchSetValue;
} else {
@@ -481,15 +490,13 @@ export class GrPatchRangeSelect extends LitElement {
this.reporting.reportInteraction('left-patchset-changed', {
previous: detail.basePatchNum,
current: patchSetValue,
- commentCount: this.changeComments?.computeCommentThreadCount({
+ commentCount: this.changeComments?.computeCommentThreads({
patchNum: patchSetValue,
- }),
+ }).length,
});
detail.basePatchNum = patchSetValue as BasePatchSetNum;
}
- this.dispatchEvent(
- new CustomEvent('patch-range-change', {detail, bubbles: false})
- );
+ fireNoBubbleNoCompose(this, 'patch-range-change', detail);
}
}
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
index bc30b1d2c7..b4ab043281 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.ts
@@ -9,7 +9,7 @@ import './gr-patch-range-select';
import {GrPatchRangeSelect} from './gr-patch-range-select';
import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
import {ChangeComments} from '../gr-comment-api/gr-comment-api';
-import {stubReporting} from '../../../test/test-utils';
+import {queryAll, stubReporting} from '../../../test/test-utils';
import {
BasePatchSetNum,
EDIT,
@@ -20,7 +20,7 @@ import {
RevisionInfo,
Timestamp,
UrlEncodedCommentId,
- PathToCommentsInfoMap,
+ CommentInfo,
} from '../../../types/common';
import {EditRevisionInfo, ParsedChangeInfo} from '../../../types/types';
import {SpecialFilePath} from '../../../constants/constants';
@@ -30,7 +30,6 @@ import {
createParsedChange,
createRevision,
createRevisions,
- TEST_NUMERIC_CHANGE_ID,
} from '../../../test/test-data-generators';
import {PatchSet} from '../../../utils/patch-set-util';
import {
@@ -43,7 +42,6 @@ import {fixture, html, assert} from '@open-wc/testing';
import {testResolver} from '../../../test/common-test-setup';
import {changeViewModelToken} from '../../../models/views/change';
import {changeModelToken} from '../../../models/change/change-model';
-import {GerritView} from '../../../services/router/router-model';
type RevIdToRevisionInfo = {
[revisionId: string]: RevisionInfo | EditRevisionInfo;
@@ -67,11 +65,6 @@ suite('gr-patch-range-select tests', () => {
html`<gr-patch-range-select></gr-patch-range-select>`
);
- element.routerModel.setState({
- changeNum: TEST_NUMERIC_CHANGE_ID,
- view: GerritView.CHANGE,
- patchNum: 1 as RevisionPatchSetNum,
- });
const viewModel = testResolver(changeViewModelToken);
viewModel.setState({
...createChangeViewState(),
@@ -154,6 +147,7 @@ suite('gr-patch-range-select tests', () => {
mobileText: EDIT,
bottomText: '',
value: EDIT,
+ commentThreads: [],
},
{
disabled: true,
@@ -163,6 +157,7 @@ suite('gr-patch-range-select tests', () => {
bottomText: '',
value: 3,
date: '2020-02-01 01:02:03.000000000' as Timestamp,
+ commentThreads: [],
} as DropdownItem,
{
disabled: true,
@@ -172,6 +167,7 @@ suite('gr-patch-range-select tests', () => {
bottomText: '',
value: 2,
date: '2020-02-01 01:02:03.000000000' as Timestamp,
+ commentThreads: [],
} as DropdownItem,
{
disabled: true,
@@ -181,6 +177,7 @@ suite('gr-patch-range-select tests', () => {
bottomText: '',
value: 1,
date: '2020-02-01 01:02:03.000000000' as Timestamp,
+ commentThreads: [],
} as DropdownItem,
{
text: 'Base',
@@ -294,6 +291,7 @@ suite('gr-patch-range-select tests', () => {
mobileText: EDIT,
bottomText: '',
value: EDIT,
+ commentThreads: [],
},
{
disabled: false,
@@ -303,6 +301,7 @@ suite('gr-patch-range-select tests', () => {
bottomText: '',
value: 3,
date: '2020-02-01 01:02:03.000000000' as Timestamp,
+ commentThreads: [],
} as DropdownItem,
{
disabled: false,
@@ -312,6 +311,7 @@ suite('gr-patch-range-select tests', () => {
bottomText: 'description',
value: 2,
date: '2020-02-01 01:02:03.000000000' as Timestamp,
+ commentThreads: [],
} as DropdownItem,
{
disabled: true,
@@ -321,6 +321,7 @@ suite('gr-patch-range-select tests', () => {
bottomText: '',
value: 1,
date: '2020-02-01 01:02:03.000000000' as Timestamp,
+ commentThreads: [],
} as DropdownItem,
];
@@ -329,33 +330,16 @@ suite('gr-patch-range-select tests', () => {
test('filesWeblinks', async () => {
element.filesWeblinks = {
- meta_a: [
- {
- name: 'foo',
- url: 'f.oo',
- },
- ],
- meta_b: [
- {
- name: 'bar',
- url: 'ba.r',
- },
- ],
+ meta_a: [{name: 'foo', url: 'f.oo'}],
+ meta_b: [{name: 'bar', url: 'ba.r'}],
};
await element.updateComplete;
- assert.equal(
- queryAndAssert(element, 'a[href="f.oo"]').textContent!.trim(),
- 'foo'
- );
- assert.equal(
- queryAndAssert(element, 'a[href="ba.r"]').textContent!.trim(),
- 'bar'
- );
+ assert.equal(queryAll(element, 'gr-weblink').length, 2);
});
test('computePatchSetCommentsString', () => {
// Test string with unresolved comments.
- const comments: PathToCommentsInfoMap = {
+ const comments: {[path: string]: CommentInfo[]} = {
foo: [
{
id: '27dcee4d_f7b77cfa' as UrlEncodedCommentId,
@@ -476,10 +460,10 @@ suite('gr-patch-range-select tests', () => {
await element.updateComplete;
const stub = stubReporting('reportInteraction');
- fire(element.patchNumDropdown!, 'value-change', {value: '1'});
+ fire(element.patchNumDropdown, 'value-change', {value: '1'});
assert.isFalse(stub.called);
- fire(element.patchNumDropdown!, 'value-change', {value: '2'});
+ fire(element.patchNumDropdown, 'value-change', {value: '2'});
assert.isTrue(stub.called);
});
});
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
index 6dbdca638d..87fe27f372 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -14,7 +14,10 @@ import {LitElement, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {resolve} from '../../../models/dependency';
import {subscribe} from '../../lit/subscription-controller';
-import {documentationViewModelToken} from '../../../models/views/documentation';
+import {
+ createDocumentationUrl,
+ documentationViewModelToken,
+} from '../../../models/views/documentation';
@customElement('gr-documentation-search')
export class GrDocumentationSearch extends LitElement {
@@ -45,7 +48,7 @@ export class GrDocumentationSearch extends LitElement {
override connectedCallback() {
super.connectedCallback();
- fireTitleChange(this, 'Documentation Search');
+ fireTitleChange('Documentation Search');
}
static override get styles() {
@@ -57,7 +60,7 @@ export class GrDocumentationSearch extends LitElement {
.filter=${this.filter}
.offset=${0}
.loading=${this.loading}
- .path=${'/Documentation'}
+ .path=${createDocumentationUrl()}
>
<table id="list" class="genericList">
<tbody>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index 0092193211..ea5e9f30eb 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
@@ -6,10 +6,11 @@
import '../../../test/common-test-setup';
import './gr-documentation-search';
import {GrDocumentationSearch} from './gr-documentation-search';
-import {page} from '../../../utils/page-wrapper-utils';
import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
import {DocResult} from '../../../types/common';
import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
function documentationGenerator(counter: number) {
return {
@@ -31,7 +32,7 @@ suite('gr-documentation-search tests', () => {
let documentationSearches: DocResult[];
setup(async () => {
- sinon.stub(page, 'show');
+ sinon.stub(testResolver(navigationToken), 'setUrl');
element = await fixture(
html`<gr-documentation-search></gr-documentation-search>`
);
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 7229c635dc..25d3cf400d 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -6,11 +6,16 @@
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {ValueChangedEvent} from '../../../types/events';
declare global {
interface HTMLElementTagNameMap {
'gr-default-editor': GrDefaultEditor;
}
+ interface HTMLElementEventMap {
+ 'content-change': ValueChangedEvent;
+ }
}
@customElement('gr-default-editor')
@@ -56,12 +61,7 @@ export class GrDefaultEditor extends LitElement {
}
_handleTextareaInput(e: Event) {
- this.dispatchEvent(
- new CustomEvent('content-change', {
- detail: {value: (e.target as HTMLTextAreaElement).value},
- bubbles: true,
- composed: true,
- })
- );
+ const value = (e.target as HTMLTextAreaElement).value;
+ fire(this, 'content-change', {value});
}
}
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
index c0dd00be1a..5786112b09 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -8,7 +8,6 @@ import '../../shared/gr-autocomplete/gr-autocomplete';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
import '../../shared/gr-dropdown/gr-dropdown';
-import '../../shared/gr-overlay/gr-overlay';
import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {ChangeInfo, RevisionPatchSetNum} from '../../../types/common';
@@ -19,7 +18,7 @@ import {
GrAutocomplete,
} from '../../shared/gr-autocomplete/gr-autocomplete';
import {getAppContext} from '../../../services/app-context';
-import {fireAlert, fireReload} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
import {
assertIsDefined,
query as queryUtil,
@@ -29,17 +28,20 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html, css} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {IronInputElement} from '@polymer/iron-input/iron-input';
-import {createEditUrl} from '../../../models/views/edit';
+import {createEditUrl} from '../../../models/views/change';
import {resolve} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
+import {whenVisible} from '../../../utils/dom-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {changeModelToken} from '../../../models/change/change-model';
@customElement('gr-edit-controls')
export class GrEditControls extends LitElement {
// private but used in test
@query('#newPathIronInput') newPathIronInput?: IronInputElement;
- @query('#overlay') protected overlay?: GrOverlay;
+ @query('#modal') modal?: HTMLDialogElement;
// private but used in test
@query('#openDialog') openDialog?: GrDialog;
@@ -76,11 +78,14 @@ export class GrEditControls extends LitElement {
private readonly restApiService = getAppContext().restApiService;
+ private readonly getChangeModel = resolve(this, changeModelToken);
+
private readonly getNavigation = resolve(this, navigationToken);
static override get styles() {
return [
sharedStyles,
+ modalStyles,
css`
:host {
align-items: center;
@@ -137,10 +142,10 @@ export class GrEditControls extends LitElement {
override render() {
return html`
${this.actions.map(action => this.renderAction(action))}
- <gr-overlay id="overlay" with-backdrop="">
+ <dialog id="modal" tabindex="-1">
${this.renderOpenDialog()} ${this.renderDeleteDialog()}
${this.renderRenameDialog()} ${this.renderRestoreDialog()}
- </gr-overlay>
+ </dialog>
`;
}
@@ -309,7 +314,7 @@ export class GrEditControls extends LitElement {
this.path = path;
}
assertIsDefined(this.openDialog, 'openDialog');
- return this.showDialog(this.openDialog);
+ this.showDialog(this.openDialog);
}
openDeleteDialog(path?: string) {
@@ -317,7 +322,7 @@ export class GrEditControls extends LitElement {
this.path = path;
}
assertIsDefined(this.deleteDialog, 'deleteDialog');
- return this.showDialog(this.deleteDialog);
+ this.showDialog(this.deleteDialog);
}
openRenameDialog(path?: string) {
@@ -325,7 +330,7 @@ export class GrEditControls extends LitElement {
this.path = path;
}
assertIsDefined(this.renameDialog, 'renameDialog');
- return this.showDialog(this.renameDialog);
+ this.showDialog(this.renameDialog);
}
openRestoreDialog(path?: string) {
@@ -333,7 +338,7 @@ export class GrEditControls extends LitElement {
if (path) {
this.path = path;
}
- return this.showDialog(this.restoreDialog);
+ this.showDialog(this.restoreDialog);
}
/**
@@ -361,23 +366,20 @@ export class GrEditControls extends LitElement {
// private but used in test
showDialog(dialog: GrDialog) {
- assertIsDefined(this.overlay, 'overlay');
+ assertIsDefined(this.modal, 'modal');
// Some dialogs may not fire their on-close event when closed in certain
// ways (e.g. by clicking outside the dialog body). This call prevents
- // multiple dialogs from being shown in the same overlay.
+ // multiple dialogs from being shown in the same modal.
this.hideAllDialogs();
- return this.overlay.open().then(() => {
+ this.modal.showModal();
+ whenVisible(this.modal, () => {
dialog.classList.toggle('invisible', false);
const autocomplete = queryUtil<GrAutocomplete>(dialog, 'gr-autocomplete');
if (autocomplete) {
autocomplete.focus();
}
- setTimeout(() => {
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.center();
- }, 1);
});
}
@@ -412,8 +414,8 @@ export class GrEditControls extends LitElement {
dialog.classList.toggle('invisible', true);
- assertIsDefined(this.overlay, 'overlay');
- this.overlay.close();
+ assertIsDefined(this.modal, 'modal');
+ this.modal.close();
}
private readonly handleDialogCancel = (e: Event) => {
@@ -429,9 +431,9 @@ export class GrEditControls extends LitElement {
assertIsDefined(this.patchNum, 'patchset number');
const url = createEditUrl({
changeNum: this.change._number,
- project: this.change.project,
- path: this.path,
+ repo: this.change.project,
patchNum: this.patchNum,
+ editView: {path: this.path},
});
this.getNavigation().setUrl(url);
@@ -452,7 +454,7 @@ export class GrEditControls extends LitElement {
return;
}
this.closeDialog(this.openDialog);
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
});
}
@@ -472,7 +474,7 @@ export class GrEditControls extends LitElement {
return;
}
this.closeDialog(dialog);
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
});
};
@@ -490,7 +492,7 @@ export class GrEditControls extends LitElement {
return;
}
this.closeDialog(dialog);
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
});
};
@@ -508,7 +510,7 @@ export class GrEditControls extends LitElement {
return;
}
this.closeDialog(dialog);
- fireReload(this, true);
+ this.getChangeModel().navigateToChangeResetReload();
});
};
@@ -516,7 +518,12 @@ export class GrEditControls extends LitElement {
assertIsDefined(this.change, 'this.change');
assertIsDefined(this.patchNum, 'this.patchNum');
return this.restApiService
- .queryChangeFiles(this.change._number, this.patchNum, input)
+ .queryChangeFiles(
+ this.change._number,
+ this.patchNum,
+ input,
+ throwingErrorCallback
+ )
.then(res => {
if (!res)
throw new Error('Failed to retrieve files. Response not set.');
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
index b4469db008..0e6778aeae 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -7,7 +7,12 @@ import '../../../test/common-test-setup';
import './gr-edit-controls';
import {GrEditControls} from './gr-edit-controls';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {queryAll, stubRestApi, waitUntil} from '../../../test/test-utils';
+import {
+ queryAll,
+ stubRestApi,
+ waitUntil,
+ waitUntilVisible,
+} from '../../../test/test-utils';
import {createChange, createRevision} from '../../../test/test-data-generators';
import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
import {
@@ -21,8 +26,12 @@ import {queryAndAssert} from '../../../test/test-utils';
import {fixture, html, assert} from '@open-wc/testing';
import {GrButton} from '../../shared/gr-button/gr-button';
import '../../shared/gr-dialog/gr-dialog';
-import {waitForEventOnce} from '../../../utils/event-util';
import {testResolver} from '../../../test/common-test-setup';
+import {
+ ChangeModel,
+ changeModelToken,
+} from '../../../models/change/change-model';
+import {SinonStubbedMember} from 'sinon';
suite('gr-edit-controls tests', () => {
let element: GrEditControls;
@@ -31,6 +40,9 @@ suite('gr-edit-controls tests', () => {
let closeDialogSpy: sinon.SinonSpy;
let hideDialogStub: sinon.SinonStub;
let queryStub: sinon.SinonStub;
+ let navigateResetStub: SinonStubbedMember<
+ ChangeModel['navigateToChangeResetReload']
+ >;
setup(async () => {
element = await fixture<GrEditControls>(html`
@@ -42,6 +54,10 @@ suite('gr-edit-controls tests', () => {
closeDialogSpy = sinon.spy(element, 'closeDialog');
hideDialogStub = sinon.stub(element, 'hideAllDialogs');
queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
+ navigateResetStub = sinon.stub(
+ testResolver(changeModelToken),
+ 'navigateToChangeResetReload'
+ );
await element.updateComplete;
});
@@ -86,13 +102,7 @@ suite('gr-edit-controls tests', () => {
>
Restore
</gr-button>
- <gr-overlay
- aria-hidden="true"
- id="overlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="modal" tabindex="-1">
<gr-dialog
class="dialog invisible"
confirm-label="Confirm"
@@ -180,7 +190,7 @@ suite('gr-edit-controls tests', () => {
</iron-input>
</div>
</gr-dialog>
- </gr-overlay>
+ </dialog>
`
);
});
@@ -225,9 +235,13 @@ suite('gr-edit-controls tests', () => {
// Setup focused manually - in headless mode Chrome sometimes doesn't
// setup focus. waitEventLoop() doesn't help.
openAutoComplete.focused = true;
- openAutoComplete.noDebounce = true;
openAutoComplete.text = 'src/test.cpp';
+ // Focus happens after updateComplete, so we first wait for it explicitly.
+ await new Promise<void>(resolve => {
+ openAutoComplete.addEventListener('focus', () => resolve());
+ });
await element.updateComplete;
+ await openAutoComplete.latestSuggestionUpdateComplete;
assert.isTrue(queryStub.called);
await waitUntil(() => !element.openDialog!.disabled);
queryAndAssert<GrButton>(
@@ -241,17 +255,15 @@ suite('gr-edit-controls tests', () => {
test('cancel', async () => {
queryAndAssert<GrButton>(element, '#open').click();
- return showDialogSpy.lastCall.returnValue.then(async () => {
- assert.isTrue(element.openDialog!.disabled);
- openAutoComplete.noDebounce = true;
- openAutoComplete.text = 'src/test.cpp';
- await element.updateComplete;
- await waitUntil(() => !element.openDialog!.disabled);
- queryAndAssert<GrButton>(element.openDialog, 'gr-button').click();
- assert.isFalse(setUrlStub.called);
- await waitUntil(() => closeDialogSpy.called);
- assert.equal(element.path, '');
- });
+ await waitUntilVisible(element.modal!);
+ assert.isTrue(element.openDialog!.disabled);
+ openAutoComplete.text = 'src/test.cpp';
+ await element.updateComplete;
+ await waitUntil(() => !element.openDialog!.disabled);
+ queryAndAssert<GrButton>(element.openDialog, 'gr-button').click();
+ assert.isFalse(setUrlStub.called);
+ await waitUntil(() => closeDialogSpy.called);
+ assert.equal(element.path, '');
});
});
@@ -279,9 +291,13 @@ suite('gr-edit-controls tests', () => {
// Setup focused manually - in headless mode Chrome sometimes doesn't
// setup focus. waitEventLoop() doesn't help.
deleteAutocomplete.focused = true;
- deleteAutocomplete.noDebounce = true;
deleteAutocomplete.text = 'src/test.cpp';
+ // Focus happens after updateComplete, so we first wait for it explicitly.
+ await new Promise<void>(resolve => {
+ deleteAutocomplete.addEventListener('focus', () => resolve());
+ });
await element.updateComplete;
+ await deleteAutocomplete.latestSuggestionUpdateComplete;
assert.isTrue(queryStub.called);
await waitUntil(() => !element.deleteDialog!.disabled);
queryAndAssert<GrButton>(
@@ -293,7 +309,7 @@ suite('gr-edit-controls tests', () => {
assert.isTrue(deleteStub.called);
await deleteStub.lastCall.returnValue;
assert.equal(element.path, '');
- assert.equal(eventStub.firstCall.args[0].type, 'reload');
+ assert.equal(navigateResetStub.callCount, 1);
assert.isTrue(closeDialogSpy.called);
});
@@ -306,9 +322,13 @@ suite('gr-edit-controls tests', () => {
// Setup focused manually - in headless mode Chrome sometimes doesn't
// setup focus. waitEventLoop() doesn't help.
deleteAutocomplete.focused = true;
- deleteAutocomplete.noDebounce = true;
deleteAutocomplete.text = 'src/test.cpp';
+ // Focus happens after updateComplete, so we first wait for it explicitly.
+ await new Promise<void>(resolve => {
+ deleteAutocomplete.addEventListener('focus', () => resolve());
+ });
await element.updateComplete;
+ await deleteAutocomplete.latestSuggestionUpdateComplete;
assert.isTrue(queryStub.called);
await waitUntil(() => !element.deleteDialog!.disabled);
queryAndAssert<GrButton>(
@@ -324,21 +344,20 @@ suite('gr-edit-controls tests', () => {
assert.isFalse(closeDialogSpy.called);
});
- test('cancel', () => {
+ test('cancel', async () => {
queryAndAssert<GrButton>(element, '#delete').click();
- return showDialogSpy.lastCall.returnValue.then(async () => {
- assert.isTrue(element.deleteDialog!.disabled);
- queryAndAssert<GrAutocomplete>(
- element.deleteDialog,
- 'gr-autocomplete'
- ).text = 'src/test.cpp';
- await element.updateComplete;
- await waitUntil(() => !element.deleteDialog!.disabled);
- queryAndAssert<GrButton>(element.deleteDialog, 'gr-button').click();
- assert.isFalse(eventStub.called);
- assert.isTrue(closeDialogSpy.called);
- await waitUntil(() => element.path === '');
- });
+ await waitUntilVisible(element.modal!);
+ assert.isTrue(element.deleteDialog!.disabled);
+ queryAndAssert<GrAutocomplete>(
+ element.deleteDialog,
+ 'gr-autocomplete'
+ ).text = 'src/test.cpp';
+ await element.updateComplete;
+ await waitUntil(() => !element.deleteDialog!.disabled);
+ queryAndAssert<GrButton>(element.deleteDialog, 'gr-button').click();
+ assert.isFalse(eventStub.called);
+ assert.isTrue(closeDialogSpy.called);
+ await waitUntil(() => element.path === '');
});
});
@@ -366,9 +385,13 @@ suite('gr-edit-controls tests', () => {
// Setup focused manually - in headless mode Chrome sometimes doesn't
// setup focus. waitEventLoop() doesn't help.
renameAutocomplete.focused = true;
- renameAutocomplete.noDebounce = true;
renameAutocomplete.text = 'src/test.cpp';
+ // Focus happens after updateComplete, so we first wait for it explicitly.
+ await new Promise<void>(resolve => {
+ renameAutocomplete.addEventListener('focus', () => resolve());
+ });
await element.updateComplete;
+ await renameAutocomplete.latestSuggestionUpdateComplete;
assert.isTrue(queryStub.called);
assert.isTrue(element.renameDialog!.disabled);
@@ -385,7 +408,7 @@ suite('gr-edit-controls tests', () => {
await renameStub.lastCall.returnValue;
assert.equal(element.path, '');
- assert.equal(eventStub.firstCall.args[0].type, 'reload');
+ assert.equal(navigateResetStub.callCount, 1);
assert.isTrue(closeDialogSpy.called);
});
@@ -398,9 +421,13 @@ suite('gr-edit-controls tests', () => {
// Setup focused manually - in headless mode Chrome sometimes doesn't
// setup focus. waitEventLoop() doesn't help.
renameAutocomplete.focused = true;
- renameAutocomplete.noDebounce = true;
renameAutocomplete.text = 'src/test.cpp';
+ // Focus happens after updateComplete, so we first wait for it explicitly.
+ await new Promise<void>(resolve => {
+ renameAutocomplete.addEventListener('focus', () => resolve());
+ });
await element.updateComplete;
+ await renameAutocomplete.latestSuggestionUpdateComplete;
assert.isTrue(queryStub.called);
assert.isTrue(element.renameDialog!.disabled);
@@ -421,22 +448,21 @@ suite('gr-edit-controls tests', () => {
assert.isFalse(closeDialogSpy.called);
});
- test('cancel', () => {
+ test('cancel', async () => {
queryAndAssert<GrButton>(element, '#rename').click();
- return showDialogSpy.lastCall.returnValue.then(async () => {
- assert.isTrue(element.renameDialog!.disabled);
- queryAndAssert<GrAutocomplete>(
- element.renameDialog,
- 'gr-autocomplete'
- ).text = 'src/test.cpp';
- element.newPathIronInput!.bindValue = 'src/test.newPath';
- await element.updateComplete;
- assert.isFalse(element.renameDialog!.disabled);
- queryAndAssert<GrButton>(element.renameDialog, 'gr-button').click();
- assert.isFalse(eventStub.called);
- assert.isTrue(closeDialogSpy.called);
- await waitUntil(() => element.path === '');
- });
+ await waitUntilVisible(element.modal!);
+ assert.isTrue(element.renameDialog!.disabled);
+ queryAndAssert<GrAutocomplete>(
+ element.renameDialog,
+ 'gr-autocomplete'
+ ).text = 'src/test.cpp';
+ element.newPathIronInput!.bindValue = 'src/test.newPath';
+ await element.updateComplete;
+ assert.isFalse(element.renameDialog!.disabled);
+ queryAndAssert<GrButton>(element.renameDialog, 'gr-button').click();
+ assert.isFalse(eventStub.called);
+ assert.isTrue(closeDialogSpy.called);
+ await waitUntil(() => element.path === '');
});
});
@@ -455,56 +481,53 @@ suite('gr-edit-controls tests', () => {
);
});
- test('restore', () => {
+ test('restore', async () => {
restoreStub.returns(Promise.resolve({ok: true}));
element.path = 'src/test.cpp';
queryAndAssert<GrButton>(element, '#restore').click();
- return showDialogSpy.lastCall.returnValue.then(async () => {
- queryAndAssert<GrButton>(
- element.restoreDialog,
- 'gr-button[primary]'
- ).click();
- await element.updateComplete;
-
- assert.isTrue(restoreStub.called);
- assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
- return restoreStub.lastCall.returnValue.then(() => {
- assert.equal(element.path, '');
- assert.equal(eventStub.firstCall.args[0].type, 'reload');
- assert.isTrue(closeDialogSpy.called);
- });
+ await waitUntilVisible(element.modal!);
+ queryAndAssert<GrButton>(
+ element.restoreDialog,
+ 'gr-button[primary]'
+ ).click();
+ await element.updateComplete;
+
+ assert.isTrue(restoreStub.called);
+ assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+ return restoreStub.lastCall.returnValue.then(() => {
+ assert.equal(element.path, '');
+ assert.equal(navigateResetStub.callCount, 1);
+ assert.isTrue(closeDialogSpy.called);
});
});
- test('restore fails', () => {
+ test('restore fails', async () => {
restoreStub.returns(Promise.resolve({ok: false}));
element.path = 'src/test.cpp';
queryAndAssert<GrButton>(element, '#restore').click();
- return showDialogSpy.lastCall.returnValue.then(async () => {
- queryAndAssert<GrButton>(
- element.restoreDialog,
- 'gr-button[primary]'
- ).click();
- await element.updateComplete;
-
- assert.isTrue(restoreStub.called);
- assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
- return restoreStub.lastCall.returnValue.then(() => {
- assert.isFalse(eventStub.called);
- assert.isFalse(closeDialogSpy.called);
- });
+ await waitUntilVisible(element.modal!);
+ queryAndAssert<GrButton>(
+ element.restoreDialog,
+ 'gr-button[primary]'
+ ).click();
+ await element.updateComplete;
+
+ assert.isTrue(restoreStub.called);
+ assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+ return restoreStub.lastCall.returnValue.then(() => {
+ assert.isFalse(eventStub.called);
+ assert.isFalse(closeDialogSpy.called);
});
});
- test('cancel', () => {
+ test('cancel', async () => {
element.path = 'src/test.cpp';
queryAndAssert<GrButton>(element, '#restore').click();
- return showDialogSpy.lastCall.returnValue.then(() => {
- queryAndAssert<GrButton>(element.restoreDialog, 'gr-button').click();
- assert.isFalse(eventStub.called);
- assert.isTrue(closeDialogSpy.called);
- assert.equal(element.path, '');
- });
+ await waitUntilVisible(element.modal!);
+ queryAndAssert<GrButton>(element.restoreDialog, 'gr-button').click();
+ assert.isFalse(eventStub.called);
+ assert.isTrue(closeDialogSpy.called);
+ assert.equal(element.path, '');
});
});
@@ -541,17 +564,18 @@ suite('gr-edit-controls tests', () => {
assert.equal(fileStub.lastCall.args[0], 1);
assert.equal(fileStub.lastCall.args[1], 'test.php');
assert.equal(fileStub.lastCall.args[2], 'base64');
- await waitForEventOnce(element, 'reload');
+ await waitUntil(() => navigateResetStub.called);
+ assert.equal(navigateResetStub.callCount, 1);
});
});
test('openOpenDialog', async () => {
- await element.openOpenDialog('test/path.cpp');
+ element.openOpenDialog('test/path.cpp');
assert.isFalse(element.openDialog!.hasAttribute('hidden'));
- assert.equal(
- queryAndAssert<GrAutocomplete>(element.openDialog, 'gr-autocomplete')
- .text,
- 'test/path.cpp'
+ await waitUntil(
+ () =>
+ queryAndAssert<GrAutocomplete>(element.openDialog, 'gr-autocomplete')
+ .text === 'test/path.cpp'
);
});
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index c442aa6cef..7e49a1201c 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -6,8 +6,11 @@
import '../../shared/gr-dropdown/gr-dropdown';
import {GrEditConstants} from '../gr-edit-constants';
import {sharedStyles} from '../../../styles/shared-styles';
+import {FileActionTapEvent} from '../../../types/events';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
+import {fire} from '../../../utils/event-util';
+import {DropdownLink} from '../../../types/common';
interface EditAction {
label: string;
@@ -16,12 +19,6 @@ interface EditAction {
@customElement('gr-edit-file-controls')
export class GrEditFileControls extends LitElement {
- /**
- * Fired when an action in the overflow menu is tapped.
- *
- * @event file-action-tap
- */
-
@property({type: String})
filePath?: string;
@@ -64,23 +61,20 @@ export class GrEditFileControls extends LitElement {
>`;
}
- _handleActionTap(e: CustomEvent) {
+ _handleActionTap(e: CustomEvent<DropdownLink>) {
e.preventDefault();
e.stopPropagation();
- this._dispatchFileAction(e.detail.id, this.filePath);
+ const actionId = e.detail.id;
+ if (!actionId) return;
+ if (!this.filePath) return;
+ this._dispatchFileAction(actionId, this.filePath);
}
- _dispatchFileAction(action: EditAction, path?: string) {
- this.dispatchEvent(
- new CustomEvent('file-action-tap', {
- detail: {action, path},
- bubbles: true,
- composed: true,
- })
- );
+ _dispatchFileAction(action: string, path: string) {
+ fire(this, 'file-action-tap', {action, path});
}
- _computeFileActions(actions: EditAction[]) {
+ _computeFileActions(actions: EditAction[]): DropdownLink[] {
// TODO(kaspern): conditionally disable some actions based on file status.
return actions.map(action => {
return {
@@ -95,4 +89,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-edit-file-controls': GrEditFileControls;
}
+ interface HTMLElementEventMap {
+ 'file-action-tap': FileActionTapEvent;
+ }
}
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
index 804d5ea06f..f37a3d9d74 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -9,7 +9,6 @@ import '../../shared/gr-button/gr-button';
import '../../shared/gr-editable-label/gr-editable-label';
import '../gr-default-editor/gr-default-editor';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
-import {computeTruncatedPath} from '../../../utils/path-list-util';
import {
EditPreferencesInfo,
Base64FileContent,
@@ -17,11 +16,7 @@ import {
} from '../../../types/common';
import {ParsedChangeInfo} from '../../../types/types';
import {HttpMethod, NotifyType} from '../../../constants/constants';
-import {
- fireAlert,
- fireTitleChange,
- fireReload,
-} from '../../../utils/event-util';
+import {fireAlert} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {ErrorCallback} from '../../../api/rest';
import {assertIsDefined} from '../../../utils/common-util';
@@ -35,8 +30,15 @@ import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {changeModelToken} from '../../../models/change/change-model';
import {ShortcutController} from '../../lit/shortcut-controller';
-import {editViewModelToken, EditViewState} from '../../../models/views/edit';
-import {createChangeUrl} from '../../../models/views/change';
+import {
+ ChangeChildView,
+ changeViewModelToken,
+ ChangeViewState,
+ createChangeUrl,
+} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {isDarkTheme} from '../../../utils/theme-util';
const RESTORED_MESSAGE = 'Content restored from a previous edit.';
const SAVING_MESSAGE = 'Saving changes...';
@@ -50,18 +52,12 @@ const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
@customElement('gr-editor-view')
export class GrEditorView extends LitElement {
/**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
* Fired to notify the user of
*
* @event show-alert
*/
- @state() viewState?: EditViewState;
+ @state() viewState?: ChangeViewState;
// private but used in test
@state() change?: ParsedChangeInfo;
@@ -86,17 +82,19 @@ export class GrEditorView extends LitElement {
// private but used in test
@state() latestPatchsetNumber?: RevisionPatchSetNum;
- private readonly restApiService = getAppContext().restApiService;
+ @state() private darkMode = false;
- private readonly storage = getAppContext().storageService;
+ private readonly restApiService = getAppContext().restApiService;
private readonly reporting = getAppContext().reportingService;
- private readonly userModel = getAppContext().userModel;
+ private readonly getStorage = resolve(this, storageServiceToken);
+
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
- private readonly getEditViewModel = resolve(this, editViewModelToken);
+ private readonly getViewModel = resolve(this, changeViewModelToken);
private readonly getNavigation = resolve(this, navigationToken);
@@ -112,13 +110,20 @@ export class GrEditorView extends LitElement {
});
subscribe(
this,
- () => this.userModel.editPreferences$,
+ () => this.getChangeModel().change$,
+ x => (this.change = x)
+ );
+ subscribe(
+ this,
+ () => this.getUserModel().editPreferences$,
editPreferences => (this.editPrefs = editPreferences)
);
subscribe(
this,
- () => this.getEditViewModel().state$,
+ () => this.getViewModel().state$,
state => {
+ // TODO: Add a setter for `viewState` instead of relying on the
+ // `viewStateChanged()` call here.
this.viewState = state;
this.viewStateChanged();
}
@@ -128,6 +133,13 @@ export class GrEditorView extends LitElement {
() => this.getChangeModel().latestPatchNumWithEdit$,
x => (this.latestPatchsetNumber = x)
);
+ subscribe(
+ this,
+ () => this.getUserModel().preferenceTheme$,
+ theme => {
+ this.darkMode = isDarkTheme(theme);
+ }
+ );
this.shortcuts.addLocal({key: 's', modifiers: [Modifier.CTRL_KEY]}, () =>
this.handleSaveShortcut()
);
@@ -207,7 +219,7 @@ export class GrEditorView extends LitElement {
}
override render() {
- if (!this.viewState) return;
+ if (this.viewState?.childView !== ChangeChildView.EDIT) return nothing;
return html` ${this.renderHeader()} ${this.renderEndpoint()} `;
}
@@ -221,7 +233,7 @@ export class GrEditorView extends LitElement {
<span class="separator"></span>
<gr-editable-label
labelText="File path"
- .value=${this.viewState?.path}
+ .value=${this.viewState?.editView?.path}
placeholder="File path..."
@changed=${this.handlePathChanged}
></gr-editable-label>
@@ -278,7 +290,11 @@ export class GrEditorView extends LitElement {
></gr-endpoint-param>
<gr-endpoint-param
name="lineNum"
- .value=${this.viewState?.lineNum}
+ .value=${this.viewState?.editView?.lineNum}
+ ></gr-endpoint-param>
+ <gr-endpoint-param
+ name="darkMode"
+ .value=${this.darkMode}
></gr-endpoint-param>
<gr-default-editor
id="file"
@@ -299,34 +315,18 @@ export class GrEditorView extends LitElement {
}
get storageKey() {
- return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.path}`;
+ return `c${this.viewState?.changeNum}_ps${this.viewState?.patchNum}_${this.viewState?.editView?.path}`;
}
// private but used in test
viewStateChanged() {
- if (!this.viewState) return;
-
- // NOTE: This may be called before attachment (e.g. while parentElement is
- // null). Fire title-change in an async so that, if attachment to the DOM
- // has been queued, the event can bubble up to the handler in gr-app.
- setTimeout(() => {
- if (!this.viewState) return;
- const title = `Editing ${computeTruncatedPath(this.viewState.path)}`;
- fireTitleChange(this, title);
- });
+ if (this.viewState?.childView !== ChangeChildView.EDIT) return;
const promises = [];
- promises.push(this.getChangeDetail());
promises.push(this.getFileData());
return Promise.all(promises);
}
- private async getChangeDetail() {
- const changeNum = this.viewState?.changeNum;
- assertIsDefined(changeNum, 'change number');
- this.change = await this.restApiService.getChangeDetail(changeNum);
- }
-
private navigateToChangeIfEdit() {
if (!this.change) return;
if (!changeIsMerged(this.change) && !changeIsAbandoned(this.change)) return;
@@ -348,7 +348,7 @@ export class GrEditorView extends LitElement {
// private but used in test
async handlePathChanged(e: CustomEvent<string>): Promise<void> {
const changeNum = this.viewState?.changeNum;
- const currentPath = this.viewState?.path;
+ const currentPath = this.viewState?.editView?.path;
assertIsDefined(changeNum, 'change number');
assertIsDefined(currentPath, 'path');
@@ -377,12 +377,14 @@ export class GrEditorView extends LitElement {
getFileData() {
const changeNum = this.viewState?.changeNum;
const patchNum = this.viewState?.patchNum;
- const path = this.viewState?.path;
+ const path = this.viewState?.editView?.path;
assertIsDefined(changeNum, 'change number');
assertIsDefined(patchNum, 'patchset number');
assertIsDefined(path, 'path');
- const storedContent = this.storage.getEditableContentItem(this.storageKey);
+ const storedContent = this.getStorage().getEditableContentItem(
+ this.storageKey
+ );
return this.restApiService
.getFileContent(changeNum, path, patchNum)
@@ -415,13 +417,13 @@ export class GrEditorView extends LitElement {
// private but used in test
saveEdit() {
const changeNum = this.viewState?.changeNum;
- const path = this.viewState?.path;
+ const path = this.viewState?.editView?.path;
assertIsDefined(changeNum, 'change number');
assertIsDefined(path, 'path');
this.saving = true;
this.showAlert(SAVING_MESSAGE);
- this.storage.eraseEditableContentItem(this.storageKey);
+ this.getStorage().eraseEditableContentItem(this.storageKey);
if (!this.newContent)
return Promise.reject(new Error('new content undefined'));
return this.restApiService
@@ -488,15 +490,7 @@ export class GrEditorView extends LitElement {
)
.then(() => {
assertIsDefined(this.change, 'change');
- // TODO: `forceReload: true` does not seem to work as expected:
- // The patchset is not updated.
- // Thus we are also calling `fireReload()` here.
- // That can probably be cleaned up once the change-view was migrated
- // to fully relying on the change model.
- fireReload(this);
- this.getNavigation().setUrl(
- createChangeUrl({change: this.change, forceReload: true})
- );
+ this.getChangeModel().navigateToChangeResetReload();
});
});
};
@@ -508,9 +502,9 @@ export class GrEditorView extends LitElement {
const content = e.detail.value;
if (content) {
this.newContent = e.detail.value;
- this.storage.setEditableContentItem(this.storageKey, content);
+ this.getStorage().setEditableContentItem(this.storageKey, content);
} else {
- this.storage.eraseEditableContentItem(this.storageKey);
+ this.getStorage().eraseEditableContentItem(this.storageKey);
}
},
STORAGE_DEBOUNCE_INTERVAL_MS
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
index 390cfad41f..c86f02f656 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -13,7 +13,6 @@ import {
pressKey,
query,
stubRestApi,
- stubStorage,
} from '../../../test/test-utils';
import {
EDIT,
@@ -28,23 +27,23 @@ import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoi
import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
import {GrButton} from '../../shared/gr-button/gr-button';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
import {Modifier} from '../../../utils/dom-util';
import {testResolver} from '../../../test/common-test-setup';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {StorageService} from '../../../services/storage/gr-storage';
suite('gr-editor-view tests', () => {
let element: GrEditorView;
let savePathStub: sinon.SinonStub;
let saveFileStub: sinon.SinonStub;
- let changeDetailStub: sinon.SinonStub;
let navigateStub: sinon.SinonStub;
+ let storageService: StorageService;
setup(async () => {
element = await fixture(html`<gr-editor-view></gr-editor-view>`);
savePathStub = stubRestApi('renameFileInChangeEdit');
saveFileStub = stubRestApi('saveChangeEdit');
- changeDetailStub = stubRestApi('getChangeDetail');
navigateStub = sinon.stub(element, 'viewEditInChangeView');
element.viewState = {
...createEditViewState(),
@@ -52,6 +51,7 @@ suite('gr-editor-view tests', () => {
};
element.latestPatchsetNumber = 1 as RevisionPatchSetNum;
await element.updateComplete;
+ storageService = testResolver(storageServiceToken);
});
test('render', () => {
@@ -68,7 +68,7 @@ suite('gr-editor-view tests', () => {
labeltext="File path"
placeholder="File path..."
tabindex="0"
- title="${element.viewState?.path}"
+ title="${element.viewState?.editView?.path}"
>
</gr-editable-label>
</span>
@@ -115,6 +115,7 @@ suite('gr-editor-view tests', () => {
<gr-endpoint-param name="prefs"> </gr-endpoint-param>
<gr-endpoint-param name="fileType"> </gr-endpoint-param>
<gr-endpoint-param name="lineNum"> </gr-endpoint-param>
+ <gr-endpoint-param name="darkMode"> </gr-endpoint-param>
<gr-default-editor id="file"> </gr-default-editor>
</gr-endpoint-decorator>
</div>
@@ -124,7 +125,6 @@ suite('gr-editor-view tests', () => {
suite('viewStateChanged', () => {
test('good view state proceed', async () => {
- changeDetailStub.returns(Promise.resolve({}));
const fileStub = sinon.stub(element, 'getFileData').callsFake(() => {
element.content = 'text';
element.newContent = 'text';
@@ -137,8 +137,6 @@ suite('gr-editor-view tests', () => {
await element.updateComplete;
- const changeNum = 42 as NumericChangeId;
- assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
assert.isTrue(fileStub.called);
return promises?.then(() => {
@@ -177,7 +175,7 @@ suite('gr-editor-view tests', () => {
});
test('reacts to content-change event', async () => {
- const storageStub = stubStorage('setEditableContentItem');
+ const storageStub = sinon.stub(storageService, 'setEditableContentItem');
element.newContent = 'test';
await element.updateComplete;
query<GrEndpointDecorator>(element, '#editorEndpoint')!.dispatchEvent(
@@ -218,7 +216,7 @@ suite('gr-editor-view tests', () => {
test('file modification and save, !ok response', async () => {
const saveSpy = sinon.spy(element, 'saveEdit');
- const eraseStub = stubStorage('eraseEditableContentItem');
+ const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
const alertStub = sinon.stub(element, 'showAlert');
saveFileStub.returns(Promise.resolve({ok: false}));
element.newContent = newText;
@@ -354,7 +352,7 @@ suite('gr-editor-view tests', () => {
element.newContent = 'initial';
element.content = 'initial';
element.type = 'initial';
- stubStorage('getEditableContentItem').returns(null);
+ sinon.stub(storageService, 'getEditableContentItem').returns(null);
});
test('res.ok', () => {
@@ -369,7 +367,7 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: EDIT,
- path: 'test/path',
+ editView: {path: 'test/path'},
};
// Ensure no data is set with a bad response.
@@ -388,7 +386,7 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: EDIT,
- path: 'test/path',
+ editView: {path: 'test/path'},
};
// Ensure no data is set with a bad response.
@@ -411,7 +409,7 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: EDIT,
- path: 'test/path',
+ editView: {path: 'test/path'},
};
return element.getFileData().then(() => {
@@ -429,7 +427,7 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: EDIT,
- path: 'test/path',
+ editView: {path: 'test/path'},
};
return element.getFileData().then(() => {
@@ -442,7 +440,7 @@ suite('gr-editor-view tests', () => {
test('showAlert', async () => {
const promise = mockPromise();
- element.addEventListener(EventType.SHOW_ALERT, e => {
+ element.addEventListener('show-alert', e => {
assert.deepEqual(e.detail, {message: 'test message', showDismiss: true});
assert.isTrue(e.bubbles);
promise.resolve();
@@ -511,7 +509,7 @@ suite('gr-editor-view tests', () => {
suite('gr-storage caching', () => {
test('local edit exists', () => {
- stubStorage('getEditableContentItem').returns({
+ sinon.stub(storageService, 'getEditableContentItem').returns({
message: 'pending edit',
updated: 0,
});
@@ -526,11 +524,11 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: 1 as RevisionPatchSetNum,
- path: 'test',
+ editView: {path: 'test'},
};
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
return element.getFileData().then(async () => {
await element.updateComplete;
@@ -543,7 +541,7 @@ suite('gr-editor-view tests', () => {
});
test('local edit exists, is same as remote edit', () => {
- stubStorage('getEditableContentItem').returns({
+ sinon.stub(storageService, 'getEditableContentItem').returns({
message: 'pending edit',
updated: 0,
});
@@ -558,11 +556,11 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: 1 as RevisionPatchSetNum,
- path: 'test',
+ editView: {path: 'test'},
};
const alertStub = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, alertStub);
+ element.addEventListener('show-alert', alertStub);
return element.getFileData().then(async () => {
await element.updateComplete;
@@ -579,7 +577,7 @@ suite('gr-editor-view tests', () => {
...createEditViewState(),
changeNum: 1 as NumericChangeId,
patchNum: 1 as RevisionPatchSetNum,
- path: 'test',
+ editView: {path: 'test'},
};
assert.equal(element.storageKey, 'c1_ps1_test');
});
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index 58e445cfa5..f81640de71 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -23,21 +23,20 @@ import './edit/gr-editor-view/gr-editor-view';
import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import './plugins/gr-endpoint-param/gr-endpoint-param';
import './plugins/gr-endpoint-slot/gr-endpoint-slot';
-import './plugins/gr-external-style/gr-external-style';
import './plugins/gr-plugin-host/gr-plugin-host';
import './settings/gr-cla-view/gr-cla-view';
import './settings/gr-registration-dialog/gr-registration-dialog';
import './settings/gr-settings-view/gr-settings-view';
-import {navigationToken} from './core/gr-navigation/gr-navigation';
+import './core/gr-notifications-prompt/gr-notifications-prompt';
import {loginUrl} from '../utils/url-util';
+import {navigationToken} from './core/gr-navigation/gr-navigation';
import {getAppContext} from '../services/app-context';
import {routerToken} from './core/gr-router/gr-router';
-import {AccountDetailInfo, ServerInfo} from '../types/common';
+import {AccountDetailInfo, NumericChangeId, ServerInfo} from '../types/common';
import {
constructServerErrorMsg,
GrErrorManager,
} from './core/gr-error-manager/gr-error-manager';
-import {GrOverlay} from './shared/gr-overlay/gr-overlay';
import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
import {
AppElementJustRegisteredParams,
@@ -48,17 +47,15 @@ import {GrMainHeader} from './core/gr-main-header/gr-main-header';
import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
import {
DialogChangeEventDetail,
- EventType,
PageErrorEventDetail,
RpcLogEvent,
TitleChangeEventDetail,
} from '../types/events';
-import {GerritView} from '../services/router/router-model';
+import {GerritView, routerModelToken} from '../services/router/router-model';
import {LifeCycle} from '../constants/reporting';
import {fireIronAnnounce} from '../utils/event-util';
import {resolve} from '../models/dependency';
import {browserModelToken} from '../models/browser/browser-model';
-import {configModelToken} from '../models/config/config-model';
import {sharedStyles} from '../styles/shared-styles';
import {LitElement, PropertyValues, html, css, nothing} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
@@ -71,9 +68,14 @@ import {isDarkTheme, prefersDarkColorScheme} from '../utils/theme-util';
import {AppTheme} from '../constants/constants';
import {subscribe} from './lit/subscription-controller';
import {PluginViewState} from '../models/views/plugin';
-import {createSearchUrl, SearchViewState} from '../models/views/search';
+import {createSearchUrl} from '../models/views/search';
import {createSettingsUrl} from '../models/views/settings';
import {createDashboardUrl} from '../models/views/dashboard';
+import {userModelToken} from '../models/user/user-model';
+import {modalStyles} from '../styles/gr-modal-styles';
+import {AdminChildView, createAdminUrl} from '../models/views/admin';
+import {ChangeChildView, changeViewModelToken} from '../models/views/change';
+import {configModelToken} from '../models/config/config-model';
interface ErrorInfo {
text: string;
@@ -81,6 +83,12 @@ interface ErrorInfo {
moreInfo?: string;
}
+/**
+ * This is simple hacky way for allowing certain plugin screens to hide the
+ * header and the footer of the Gerrit page.
+ */
+const WHITE_LISTED_FULL_SCREEN_PLUGINS = ['git_source_editor/screen/edit'];
+
// TODO(TS): implement AppElement interface from gr-app-types.ts
@customElement('gr-app-element')
export class GrAppElement extends LitElement {
@@ -96,11 +104,11 @@ export class GrAppElement extends LitElement {
@query('#mainHeader') mainHeader?: GrMainHeader;
- @query('#registrationOverlay') registrationOverlay?: GrOverlay;
+ @query('#registrationModal') registrationModal?: HTMLDialogElement;
@query('#registrationDialog') registrationDialog?: GrRegistrationDialog;
- @query('#keyboardShortcuts') keyboardShortcuts?: GrOverlay;
+ @query('#keyboardShortcuts') keyboardShortcuts?: HTMLDialogElement;
@query('gr-settings-view') settingsView?: GrSettingsView;
@@ -109,12 +117,16 @@ export class GrAppElement extends LitElement {
@state() private account?: AccountDetailInfo;
- @state() private serverConfig?: ServerInfo;
-
@state() private version?: string;
@state() private view?: GerritView;
+ // TODO: Introduce a wrapper element for CHANGE, DIFF, EDIT view.
+ @state() private childView?: ChangeChildView;
+
+ // Used as a key for caching the CHANGE, DIFF, EDIT view.
+ @state() private changeNum?: NumericChangeId;
+
@state() private lastError?: ErrorInfo;
// private but used in test
@@ -138,15 +150,10 @@ export class GrAppElement extends LitElement {
// (e.g. shortcut dialog) is open
@state() private mainAriaHidden = false;
- // Triggers dom-if unsetting/setting restamp behaviour in lit
- @state() private invalidateChangeViewCache = false;
-
- // Triggers dom-if unsetting/setting restamp behaviour in lit
- @state() private invalidateDiffViewCache = false;
-
@state() private theme = AppTheme.AUTO;
- @state() private themeEndpoint = 'app-theme-light';
+ @state()
+ serverConfig?: ServerInfo;
readonly getRouter = resolve(this, routerToken);
@@ -160,34 +167,28 @@ export class GrAppElement extends LitElement {
private readonly shortcuts = new ShortcutController(this);
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
- private readonly routerModel = getAppContext().routerModel;
+ private readonly getRouterModel = resolve(this, routerModelToken);
+
+ private readonly getChangeViewModel = resolve(this, changeViewModelToken);
private readonly getConfigModel = resolve(this, configModelToken);
constructor() {
super();
- document.addEventListener(EventType.PAGE_ERROR, e => {
+ document.addEventListener('page-error', e => {
this.handlePageError(e);
});
- this.addEventListener(EventType.TITLE_CHANGE, e => {
+ document.addEventListener('title-change', e => {
this.handleTitleChange(e);
});
- this.addEventListener(EventType.DIALOG_CHANGE, e => {
+ this.addEventListener('dialog-change', e => {
this.handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
});
- document.addEventListener(EventType.LOCATION_CHANGE, () =>
- this.requestUpdate()
- );
- this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
- this.handleRecreateView()
- );
- this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
- this.handleRecreateView()
- );
- document.addEventListener(EventType.GR_RPC_LOG, e => this.handleRpcLog(e));
+ document.addEventListener('location-change', () => this.requestUpdate());
+ document.addEventListener('gr-rpc-log', e => this.handleRpcLog(e));
this.shortcuts.addAbstract(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, () =>
this.showKeyboardShortcuts()
);
@@ -208,6 +209,16 @@ export class GrAppElement extends LitElement {
createSearchUrl({query: 'is:watched is:open'})
)
);
+ this.shortcuts.addAbstract(Shortcut.GO_TO_REPOS, () =>
+ this.getNavigation().setUrl(
+ createAdminUrl({adminView: AdminChildView.REPOS})
+ )
+ );
+ this.shortcuts.addAbstract(Shortcut.GO_TO_GROUPS, () =>
+ this.getNavigation().setUrl(
+ createAdminUrl({adminView: AdminChildView.GROUPS})
+ )
+ );
subscribe(
this,
@@ -219,7 +230,7 @@ export class GrAppElement extends LitElement {
subscribe(
this,
- () => this.userModel.preferenceTheme$,
+ () => this.getUserModel().preferenceTheme$,
theme => {
this.theme = theme;
this.applyTheme();
@@ -227,12 +238,24 @@ export class GrAppElement extends LitElement {
);
subscribe(
this,
- () => this.routerModel.routerView$,
+ () => this.getRouterModel().routerView$,
view => {
this.view = view;
if (view) this.errorView?.classList.remove('show');
}
);
+ subscribe(
+ this,
+ () => this.getChangeViewModel().childView$,
+ childView => (this.childView = childView)
+ );
+ subscribe(
+ this,
+ () => this.getChangeViewModel().changeNum$,
+ changeNum => {
+ this.changeNum = changeNum;
+ }
+ );
prefersDarkColorScheme().addEventListener('change', () => {
if (this.theme === AppTheme.AUTO) {
@@ -270,6 +293,7 @@ export class GrAppElement extends LitElement {
static override get styles() {
return [
sharedStyles,
+ modalStyles,
css`
:host {
background-color: var(--background-color-tertiary);
@@ -353,21 +377,22 @@ export class GrAppElement extends LitElement {
return html`
<gr-css-mixins></gr-css-mixins>
<gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
- <gr-main-header
- id="mainHeader"
- .searchQuery=${(this.params as SearchViewState)?.query}
- @mobile-search=${this.mobileSearchToggle}
- @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
- .mobileSearchHidden=${!this.mobileSearch}
- .loginUrl=${loginUrl(this.serverConfig?.auth)}
- .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
- ?aria-hidden=${this.footerHeaderAriaHidden}
- >
- </gr-main-header>
+ ${this.renderHeader()}
<main ?aria-hidden=${this.mainAriaHidden}>
${this.renderMobileSearch()} ${this.renderChangeListView()}
- ${this.renderDashboardView()} ${this.renderChangeView()}
- ${this.renderEditorView()} ${this.renderDiffView()}
+ ${this.renderDashboardView()}
+ ${
+ // `keyed(this.changeNum, ...)` makes sure that these views are not
+ // re-used across changes, which is a precaution, because we have run
+ // into issue with that. That could be re-considered at some point.
+ keyed(
+ this.changeNum,
+ html`
+ ${this.renderChangeView()} ${this.renderEditorView()}
+ ${this.renderDiffView()}
+ `
+ )
+ }
${this.renderSettingsView()} ${this.renderAdminView()}
${this.renderPluginScreen()} ${this.renderCLAView()}
${this.renderDocumentationSearch()}
@@ -377,6 +402,38 @@ export class GrAppElement extends LitElement {
<div class="errorMoreInfo">${this.lastError?.moreInfo}</div>
</div>
</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"
+ .loginUrl=${loginUrl(this.serverConfig?.auth)}
+ .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
+ ></gr-error-manager>
+ <gr-plugin-host id="plugins"></gr-plugin-host>
+ `;
+ }
+
+ private renderHeader() {
+ if (this.hideHeaderAndFooter()) return nothing;
+ return html`
+ <gr-main-header
+ id="mainHeader"
+ @mobile-search=${this.mobileSearchToggle}
+ @show-keyboard-shortcuts=${this.showKeyboardShortcuts}
+ .mobileSearchHidden=${!this.mobileSearch}
+ .loginUrl=${loginUrl(this.serverConfig?.auth)}
+ .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
+ ?aria-hidden=${this.footerHeaderAriaHidden}
+ >
+ </gr-main-header>
+ `;
+ }
+
+ private renderFooter() {
+ if (this.hideHeaderAndFooter()) return nothing;
+ return html`
<footer ?aria-hidden=${this.footerHeaderAriaHidden}>
<div>
Powered by
@@ -394,35 +451,19 @@ export class GrAppElement extends LitElement {
<gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
</div>
</footer>
- ${this.renderKeyboardShortcutsDialog()} ${this.renderRegistrationDialog()}
- <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
- <gr-error-manager
- id="errorManager"
- .loginUrl=${loginUrl(this.serverConfig?.auth)}
- .loginText=${this.serverConfig?.auth.login_text ?? 'Sign in'}
- ></gr-error-manager>
- <gr-plugin-host id="plugins"></gr-plugin-host>
- <gr-external-style
- id="externalStyleForAll"
- name="app-theme"
- ></gr-external-style>
- <gr-external-style
- id="externalStyleForTheme"
- name=${this.themeEndpoint}
- ></gr-external-style>
`;
}
+ private hideHeaderAndFooter() {
+ return (
+ this.view === GerritView.PLUGIN_SCREEN &&
+ WHITE_LISTED_FULL_SCREEN_PLUGINS.includes(this.computePluginScreenName())
+ );
+ }
+
private renderMobileSearch() {
if (!this.mobileSearch) return nothing;
- return html`
- <gr-smart-search
- id="search"
- label="Search for changes"
- .searchQuery=${(this.params as SearchViewState)?.query}
- >
- </gr-smart-search>
- `;
+ return html`<gr-smart-search id="search"></gr-smart-search>`;
}
private renderChangeListView() {
@@ -442,39 +483,51 @@ export class GrAppElement extends LitElement {
}
private renderChangeView() {
- if (this.invalidateChangeViewCache) {
- this.updateComplete.then(() => (this.invalidateChangeViewCache = false));
- return nothing;
- }
+ // The `cache()` is required for re-using the change view when switching
+ // back and forth between change, diff and editor views.
return cache(
- this.view === GerritView.CHANGE ? this.changeViewTemplate() : nothing
+ this.isChangeView()
+ ? html`<gr-change-view
+ .backPage=${this.lastSearchPage}
+ ></gr-change-view>`
+ : nothing
);
}
- // Template as not to create duplicates, for renderChangeView() only.
- private changeViewTemplate() {
- return html`
- <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
- `;
+ private isChangeView() {
+ return (
+ this.view === GerritView.CHANGE &&
+ this.childView === ChangeChildView.OVERVIEW
+ );
}
private renderEditorView() {
- if (this.view !== GerritView.EDIT) return nothing;
- return html`<gr-editor-view></gr-editor-view>`;
+ // For some reason caching the editor view caused an issue (b/269308770).
+ // We did not bother to root cause that issue, but instead let's forgo
+ // caching of the editor view. It does not help much anyway.
+ return this.isEditorView()
+ ? html`<gr-editor-view></gr-editor-view>`
+ : nothing;
+ }
+
+ private isEditorView() {
+ return (
+ this.view === GerritView.CHANGE && this.childView === ChangeChildView.EDIT
+ );
}
private renderDiffView() {
- if (this.invalidateDiffViewCache) {
- this.updateComplete.then(() => (this.invalidateDiffViewCache = false));
- return nothing;
- }
+ // The `cache()` is required for re-using the diff view when switching
+ // back and forth between change, diff and editor views.
return cache(
- this.view === GerritView.DIFF ? this.diffViewTemplate() : nothing
+ this.isDiffView() ? html`<gr-diff-view></gr-diff-view>` : nothing
);
}
- private diffViewTemplate() {
- return html`<gr-diff-view></gr-diff-view>`;
+ private isDiffView() {
+ return (
+ this.view === GerritView.CHANGE && this.childView === ChangeChildView.DIFF
+ );
}
private renderSettingsView() {
@@ -527,22 +580,22 @@ export class GrAppElement extends LitElement {
private renderKeyboardShortcutsDialog() {
if (!this.loadKeyboardShortcutsDialog) return nothing;
return html`
- <gr-overlay
+ <dialog
id="keyboardShortcuts"
- with-backdrop=""
- @iron-overlay-canceled=${this.onOverlayCanceled}
+ tabindex="-1"
+ @close=${this.onModalCanceled}
>
<gr-keyboard-shortcuts-dialog
@close=${this.handleKeyboardShortcutDialogClose}
></gr-keyboard-shortcuts-dialog>
- </gr-overlay>
+ </dialog>
`;
}
private renderRegistrationDialog() {
if (!this.loadRegistrationDialog) return nothing;
return html`
- <gr-overlay id="registrationOverlay" with-backdrop="">
+ <dialog id="registrationModal" tabindex="-1">
<gr-registration-dialog
id="registrationDialog"
.settingsUrl=${this.settingsUrl}
@@ -550,7 +603,7 @@ export class GrAppElement extends LitElement {
@close=${this.handleRegistrationDialogClose}
>
</gr-registration-dialog>
- </gr-overlay>
+ </dialog>
`;
}
@@ -577,15 +630,6 @@ export class GrAppElement extends LitElement {
(this.account && this.account._account_id) || null;
}
- /**
- * Throws away the view and re-creates it. The view itself fires an event, if
- * it wants to be re-created.
- */
- private handleRecreateView() {
- this.invalidateChangeViewCache = true;
- this.invalidateDiffViewCache = true;
- }
-
private async viewChanged() {
if (
this.params &&
@@ -594,12 +638,10 @@ export class GrAppElement extends LitElement {
) {
this.loadRegistrationDialog = true;
await this.updateComplete;
- assertIsDefined(this.registrationOverlay, 'registrationOverlay');
+ assertIsDefined(this.registrationModal, 'registrationModal');
assertIsDefined(this.registrationDialog, 'registrationDialog');
- await this.registrationOverlay.open();
- await this.registrationDialog.loadData().then(() => {
- this.registrationOverlay!.refit();
- });
+ this.registrationModal.showModal();
+ await this.registrationDialog.loadData();
}
// To fix bug announce read after each new view, we reset announce with
// empty space
@@ -610,11 +652,12 @@ export class GrAppElement extends LitElement {
const showDarkTheme = isDarkTheme(this.theme);
document.documentElement.classList.toggle('darkTheme', showDarkTheme);
document.documentElement.classList.toggle('lightTheme', !showDarkTheme);
+ // TODO: Remove this code for adding/removing dark theme style. We should
+ // be able to just always add them once we have changed its css selector
+ // from `html` to `html.darkTheme`.
if (showDarkTheme) {
- this.themeEndpoint = 'app-theme-dark';
applyDarkTheme();
} else {
- this.themeEndpoint = 'app-theme-light';
removeDarkTheme();
}
}
@@ -677,13 +720,13 @@ export class GrAppElement extends LitElement {
await this.updateComplete;
assertIsDefined(this.keyboardShortcuts, 'keyboardShortcuts');
- if (this.keyboardShortcuts.opened) {
- this.keyboardShortcuts.cancel();
+ if (this.keyboardShortcuts.hasAttribute('open')) {
+ this.keyboardShortcuts.close();
return;
}
this.footerHeaderAriaHidden = true;
this.mainAriaHidden = true;
- await this.keyboardShortcuts.open();
+ this.keyboardShortcuts.showModal();
}
private handleKeyboardShortcutDialogClose() {
@@ -691,7 +734,7 @@ export class GrAppElement extends LitElement {
this.keyboardShortcuts.close();
}
- onOverlayCanceled() {
+ onModalCanceled() {
this.footerHeaderAriaHidden = false;
this.mainAriaHidden = false;
}
@@ -705,8 +748,8 @@ export class GrAppElement extends LitElement {
// The registration dialog is visible only if this.params is
// instanceof AppElementJustRegisteredParams
(this.params as AppElementJustRegisteredParams).justRegistered = false;
- assertIsDefined(this.registrationOverlay, 'registrationOverlay');
- this.registrationOverlay.close();
+ assertIsDefined(this.registrationModal, 'registrationModal');
+ this.registrationModal.close();
}
private computePluginScreenName() {
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
index 4132c0ea60..d6a14edc22 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -13,11 +13,34 @@
import {GrAnnotation} from '../embed/diff/gr-diff-highlight/gr-annotation';
import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
-import {AppContext} from '../services/app-context';
+import {AppContext, injectAppContext} from '../services/app-context';
+import {PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {
+ initVisibilityReporter,
+ initPerformanceReporter,
+ initErrorReporter,
+ initWebVitals,
+ initClickReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {Finalizable} from '../services/registry';
-export function initGlobalVariables(appContext: AppContext) {
+export function initGlobalVariables(
+ appContext: AppContext & Finalizable,
+ initializeReporting: boolean
+) {
+ injectAppContext(appContext);
+ if (initializeReporting) {
+ const reportingService = appContext.reportingService;
+ initVisibilityReporter(reportingService);
+ initPerformanceReporter(reportingService);
+ initWebVitals(reportingService);
+ initErrorReporter(reportingService);
+ initClickReporter(reportingService);
+ }
window.GrAnnotation = GrAnnotation;
window.GrPluginActionContext = GrPluginActionContext;
- initGerritPluginApi(appContext);
+}
+
+export function initGerrit(pluginLoader: PluginLoader) {
+ window.Gerrit = pluginLoader;
}
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 3008236b3a..68e73098d5 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -13,8 +13,6 @@ import {PluginViewState} from '../models/views/plugin';
import {SearchViewState} from '../models/views/search';
import {DashboardViewState} from '../models/views/dashboard';
import {ChangeViewState} from '../models/views/change';
-import {DiffViewState} from '../models/views/diff';
-import {EditViewState} from '../models/views/edit';
export interface AppElement extends HTMLElement {
params: AppElementParams;
@@ -30,6 +28,7 @@ export interface AppElementJustRegisteredParams {
justRegistered: boolean;
}
+// TODO: Get rid of this type. <gr-app-element> needs to be refactored for that.
export type AppElementParams =
| DashboardViewState
| GroupViewState
@@ -41,8 +40,6 @@ export type AppElementParams =
| SearchViewState
| SettingsViewState
| AgreementViewState
- | DiffViewState
- | EditViewState
| AppElementJustRegisteredParams;
export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 0cec8ddf37..40869a9e31 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -5,6 +5,7 @@
*/
import {safeTypesBridge} from '../utils/safe-types-util';
import './font-roboto-local-loader';
+import '../types/globals';
// Sets up global Polymer variable, because plugins requires it.
import '../scripts/bundled-polymer';
@@ -21,34 +22,32 @@ import {
setCancelSyntheticClickEvents(false);
setPassiveTouchGestures(true);
-import {initGlobalVariables} from './gr-app-global-var-init';
+import {initGerrit, initGlobalVariables} from './gr-app-global-var-init';
import './gr-app-element';
import {Finalizable} from '../services/registry';
-import {provide} from '../models/dependency';
+import {
+ DependencyError,
+ DependencyToken,
+ provide,
+ Provider,
+} from '../models/dependency';
import {installPolymerResin} from '../scripts/polymer-resin-install';
import {
createAppContext,
createAppDependencies,
+ Creator,
} from '../services/app-context-init';
-import {
- initVisibilityReporter,
- initPerformanceReporter,
- initErrorReporter,
- initWebVitals,
-} from '../services/gr-reporting/gr-reporting_impl';
-import {injectAppContext} from '../services/app-context';
import {html, LitElement} from 'lit';
import {customElement} from 'lit/decorators.js';
-import {ServiceWorkerInstaller} from '../services/service-worker-installer';
+import {
+ ServiceWorkerInstaller,
+ serviceWorkerInstallerToken,
+} from '../services/service-worker-installer';
+import {pluginLoaderToken} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {getAppContext} from '../services/app-context';
-const appContext = createAppContext();
-injectAppContext(appContext);
-const reportingService = appContext.reportingService;
-initVisibilityReporter(reportingService);
-initPerformanceReporter(reportingService);
-initWebVitals(reportingService);
-initErrorReporter(reportingService);
+initGlobalVariables(createAppContext(), true);
installPolymerResin(safeTypesBridge);
@@ -60,16 +59,47 @@ export class GrApp extends LitElement {
override connectedCallback() {
super.connectedCallback();
- const dependencies = createAppDependencies(appContext);
- for (const [token, service] of dependencies) {
- this.finalizables.push(service);
- provide(this, token, () => service);
+ const dependencies = new Map<DependencyToken<unknown>, Provider<unknown>>();
+
+ const injectDependency = <T>(
+ token: DependencyToken<T>,
+ creator: Creator<T>
+ ) => {
+ let service: (T & Finalizable) | undefined = undefined;
+ dependencies.set(token, () => {
+ if (service) return service;
+ service = creator();
+ this.finalizables.push(service);
+ return service;
+ });
+ };
+
+ const resolver = <T>(token: DependencyToken<T>): T => {
+ const provider = dependencies.get(token);
+ if (provider) {
+ return provider() as T;
+ } else {
+ throw new DependencyError(
+ token,
+ 'Forgot to set up dependency for gr-app'
+ );
+ }
+ };
+
+ for (const [token, creator] of createAppDependencies(
+ getAppContext(),
+ resolver
+ )) {
+ injectDependency(token, creator);
+ }
+ for (const [token, provider] of dependencies) {
+ provide(this, token, provider);
}
+
+ initGerrit(resolver(pluginLoaderToken));
+
if (!this.serviceWorkerInstaller) {
- this.serviceWorkerInstaller = new ServiceWorkerInstaller(
- appContext.flagsService,
- appContext.userModel
- );
+ this.serviceWorkerInstaller = resolver(serviceWorkerInstallerToken);
}
}
@@ -91,5 +121,3 @@ declare global {
'gr-app': GrApp;
}
}
-
-initGlobalVariables(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts
index 34ca39cad9..15cfda68e9 100644
--- a/polygerrit-ui/app/elements/gr-app_test.ts
+++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -16,8 +16,11 @@ import {
createServerInfo,
} from '../test/test-data-generators';
import {GrAppElement} from './gr-app-element';
-import {GrRouter} from './core/gr-router/gr-router';
+import {GrRouter, routerToken} from './core/gr-router/gr-router';
+import {resolve} from '../models/dependency';
+import {removeRequestDependencyListener} from '../test/common-test-setup';
import {ReactiveElement} from 'lit';
+
suite('gr-app callback tests', () => {
const requestUpdateStub = sinon.stub(
ReactiveElement.prototype,
@@ -30,6 +33,7 @@ suite('gr-app callback tests', () => {
setup(async () => {
await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
});
+
test("requestUpdate in reactive-element is called after dispatching 'location-change' event in gr-router", () => {
dispatchLocationChangeEventSpy();
assert.isTrue(requestUpdateStub.calledOnce);
@@ -52,9 +56,14 @@ suite('gr-app tests', () => {
stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
stubRestApi('getVersion').returns(Promise.resolve('42'));
stubRestApi('probePath').returns(Promise.resolve(false));
-
grApp = await fixture<GrApp>(html`<gr-app id="app"></gr-app>`);
- await grApp.updateComplete;
+ });
+
+ test('models resolve', () => {
+ // Verify that models resolve on grApp without falling back
+ // to the ones instantiated by the test-setup.
+ removeRequestDependencyListener();
+ assert.ok(resolve(grApp, routerToken)());
});
test('reporting', () => {
diff --git a/polygerrit-ui/app/elements/gr-css-mixins.ts b/polygerrit-ui/app/elements/gr-css-mixins.ts
index 733b386fb8..523a056cbf 100644
--- a/polygerrit-ui/app/elements/gr-css-mixins.ts
+++ b/polygerrit-ui/app/elements/gr-css-mixins.ts
@@ -77,6 +77,10 @@ export class GrCssMixins extends PolymerElement {
--paper-listbox: {
padding: 0;
};
+ --iron-autogrow-textarea: {
+ box-sizing: border-box;
+ padding: var(--spacing-s);
+ };
}
</style>
`;
diff --git a/polygerrit-ui/app/elements/integration_test.ts b/polygerrit-ui/app/elements/integration_test.ts
new file mode 100644
index 0000000000..a6e02be96b
--- /dev/null
+++ b/polygerrit-ui/app/elements/integration_test.ts
@@ -0,0 +1,74 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../test/common-test-setup';
+import './gr-app-element';
+import {testResolver} from '../test/common-test-setup';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrRouter, routerToken} from './core/gr-router/gr-router';
+import {
+ queryAndAssert,
+ queryAll,
+ stubRestApi,
+ waitQueryAndAssert,
+} from '../test/test-utils';
+import {GrAppElement} from './gr-app-element';
+import {LitElement} from 'lit';
+import {createSearchUrl} from '../models/views/search';
+import {createChange} from '../test/test-data-generators';
+import {NumericChangeId} from '../api/rest-api';
+import {createSettingsUrl} from '../models/views/settings';
+
+suite('integration tests', () => {
+ let appElement: GrAppElement;
+ let router: GrRouter;
+
+ const assertView = async function <T extends LitElement>(tagName: string) {
+ await appElement.updateComplete;
+ const view = await waitQueryAndAssert<T>(appElement, tagName);
+ assert.isOk(view);
+ return view;
+ };
+
+ const assertItems = function (el: HTMLElement) {
+ const list = queryAndAssert(el, 'gr-change-list');
+ const section = queryAndAssert(list, 'gr-change-list-section');
+ return queryAll(section, 'gr-change-list-item');
+ };
+
+ setup(async () => {
+ appElement = await fixture<GrAppElement>(
+ html`<gr-app-element id="app-element"></gr-app-element>`
+ );
+ router = testResolver(routerToken);
+ router._testOnly_startRouter();
+ await appElement.updateComplete;
+ });
+
+ teardown(async () => {
+ router.finalize();
+ });
+
+ test('navigate from search view page to settings page and back', async () => {
+ stubRestApi('getChanges').returns(
+ Promise.resolve([
+ createChange({_number: 1 as NumericChangeId}),
+ createChange({_number: 2 as NumericChangeId}),
+ createChange({_number: 3 as NumericChangeId}),
+ ])
+ );
+
+ router.setUrl(createSearchUrl({query: 'asdf'}));
+ let view = await assertView('gr-change-list-view');
+ assert.equal(assertItems(view).length, 3);
+
+ router.setUrl(createSettingsUrl());
+ await assertView('gr-settings-view');
+
+ window.history.back();
+ view = await assertView('gr-change-list-view');
+ assert.equal(assertItems(view).length, 3);
+ });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
index da0035eb09..033df494e8 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -5,7 +5,7 @@
*/
import {EventType, PluginApi} from '../../../api/plugin';
import {AdminPluginApi, MenuLink} from '../../../api/admin';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
/**
* GrAdminApi class.
@@ -16,9 +16,10 @@ export class GrAdminApi implements AdminPluginApi {
// TODO(TS): maybe define as enum if its a limited set
private menuLinks: MenuLink[] = [];
- private readonly reporting = getAppContext().reportingService;
-
- constructor(private readonly plugin: PluginApi) {
+ constructor(
+ private readonly reporting: ReportingService,
+ private readonly plugin: PluginApi
+ ) {
this.reporting.trackApi(this.plugin, 'admin', 'constructor');
this.plugin.on(EventType.ADMIN_MENU_LINKS, this);
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
index da7aa9e685..0d041d47ba 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.ts
@@ -7,8 +7,9 @@ import {assert} from '@open-wc/testing';
import {AdminPluginApi} from '../../../api/admin';
import {PluginApi} from '../../../api/plugin';
import '../../../test/common-test-setup';
+import {testResolver} from '../../../test/common-test-setup';
import '../../shared/gr-js-api-interface/gr-js-api-interface';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
suite('gr-admin-api tests', () => {
let adminApi: AdminPluginApi;
@@ -22,7 +23,7 @@ suite('gr-admin-api tests', () => {
'0.1',
'http://test.com/plugins/testplugin/static/test.js'
);
- getPluginLoader().loadPlugins([]);
+ testResolver(pluginLoaderToken).loadPlugins([]);
adminApi = plugin.admin();
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
index ab71255315..b34e1c3769 100644
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -5,17 +5,20 @@
*/
import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
import {PluginApi} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ValueChangedEvent} from '../../../types/events';
export class GrAttributeHelper implements AttributeHelperPluginApi {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private readonly _promises = new Map<string, Promise<any>>();
- private readonly reporting = getAppContext().reportingService;
-
// TODO(TS): Change any to something more like HTMLElement.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- constructor(readonly plugin: PluginApi, public element: any) {
+ constructor(
+ private readonly reporting: ReportingService,
+ readonly plugin: PluginApi,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public element: any
+ ) {
this.reporting.trackApi(this.plugin, 'attribute', 'constructor');
}
@@ -49,7 +52,7 @@ export class GrAttributeHelper implements AttributeHelperPluginApi {
bind(name: string, callback: (value: any) => void) {
this.reporting.trackApi(this.plugin, 'attribute', 'bind');
const attributeChangedEventName = this._getChangedEventName(name);
- const changedHandler = (e: CustomEvent) =>
+ const changedHandler = (e: ValueChangedEvent) =>
this._reportValue(callback, e.detail.value);
const unbind = () =>
this.element.removeEventListener(
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
index 4d59dc982a..51cddbe072 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -11,7 +11,8 @@ import {
CheckResult,
CheckRun,
} from '../../../api/checks';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
const DEFAULT_CONFIG: ChecksApiConfig = {
fetchPollingIntervalSeconds: 60,
@@ -32,11 +33,11 @@ enum State {
export class GrChecksApi implements ChecksPluginApi {
private state = State.NOT_REGISTERED;
- private readonly reporting = getAppContext().reportingService;
-
- private readonly pluginsModel = getAppContext().pluginsModel;
-
- constructor(readonly plugin: PluginApi) {
+ constructor(
+ private readonly reporting: ReportingService,
+ private readonly pluginsModel: PluginsModel,
+ readonly plugin: PluginApi
+ ) {
this.reporting.trackApi(this.plugin, 'checks', 'constructor');
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
index 011fbbfdcc..54197ea13d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {PluginApi} from '../../../api/plugin';
import {ChecksPluginApi} from '../../../api/checks';
import {assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
suite('gr-settings-api tests', () => {
let checksApi: ChecksPluginApi | undefined;
@@ -21,7 +22,7 @@ suite('gr-settings-api tests', () => {
'0.1',
'http://test.com/plugins/testplugin/static/test.js'
);
- getPluginLoader().loadPlugins([]);
+ testResolver(pluginLoaderToken).loadPlugins([]);
assert.isOk(pluginApi);
checksApi = pluginApi!.checks();
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
index 61279cfb72..9cacaeaddd 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -6,14 +6,15 @@
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {
- getPluginEndpoints,
+ EndpointType,
ModuleInfo,
} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {PluginApi} from '../../../api/plugin';
import {HookApi, PluginElement} from '../../../api/hook';
import {getAppContext} from '../../../services/app-context';
import {assertIsDefined} from '../../../utils/common-util';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {resolve} from '../../../models/dependency';
const INIT_PROPERTIES_TIMEOUT_MS = 10000;
@@ -38,6 +39,8 @@ export class GrEndpointDecorator extends LitElement {
private readonly reporting = getAppContext().reportingService;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
override render() {
return html`<slot></slot>`;
}
@@ -45,12 +48,17 @@ export class GrEndpointDecorator extends LitElement {
override connectedCallback() {
super.connectedCallback();
assertIsDefined(this.name);
- getPluginEndpoints().onNewEndpoint(this.name, this.initModule);
- getPluginLoader()
+ this.getPluginLoader().pluginEndPoints.onNewEndpoint(
+ this.name,
+ this.initModule
+ );
+ this.getPluginLoader()
.awaitPluginsLoaded()
.then(() => {
assertIsDefined(this.name);
- const modules = getPluginEndpoints().getDetails(this.name);
+ const modules = this.getPluginLoader().pluginEndPoints.getDetails(
+ this.name
+ );
for (const module of modules) {
this.initModule(module);
}
@@ -62,7 +70,10 @@ export class GrEndpointDecorator extends LitElement {
domHook.handleInstanceDetached(el);
}
assertIsDefined(this.name);
- getPluginEndpoints().onDetachedEndpoint(this.name, this.initModule);
+ this.getPluginLoader().pluginEndPoints.onDetachedEndpoint(
+ this.name,
+ this.initModule
+ );
super.disconnectedCallback();
}
@@ -187,10 +198,10 @@ export class GrEndpointDecorator extends LitElement {
}
let initPromise;
switch (type) {
- case 'decorate':
+ case EndpointType.DECORATE:
initPromise = this.initDecoration(moduleName, plugin, slot);
break;
- case 'replace':
+ case EndpointType.REPLACE:
initPromise = this.initReplacement(moduleName, plugin);
break;
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
index c3e6911ee7..57888fa20e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.ts
@@ -8,12 +8,7 @@ import './gr-endpoint-decorator';
import '../gr-endpoint-param/gr-endpoint-param';
import '../gr-endpoint-slot/gr-endpoint-slot';
import {fixture, html, assert} from '@open-wc/testing';
-import {
- mockPromise,
- queryAndAssert,
- resetPlugins,
-} from '../../../test/test-utils';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
import {GrEndpointDecorator} from './gr-endpoint-decorator';
import {PluginApi} from '../../../api/plugin';
import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param';
@@ -30,7 +25,6 @@ suite('gr-endpoint-decorator', () => {
let banana: GrEndpointDecorator;
setup(async () => {
- resetPlugins();
container = await fixture(
html`<div>
<gr-endpoint-decorator name="first">
@@ -100,18 +94,11 @@ suite('gr-endpoint-decorator', () => {
const replacementHookPromise = mockPromise();
replacementHook.onAttached(() => replacementHookPromise.resolve());
- // Mimic all plugins loaded.
- getPluginLoader().loadPlugins([]);
-
await decorationHookPromise;
await decorationHookSlotPromise;
await replacementHookPromise;
});
- teardown(() => {
- resetPlugins();
- });
-
test('imports plugin-provided modules into endpoints', () => {
const endpoints = Array.from(
container.querySelectorAll('gr-endpoint-decorator')
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
index e73aad6074..d3429fe63a 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -5,6 +5,7 @@
*/
import {LitElement, PropertyValues} from 'lit';
import {customElement, property} from 'lit/decorators.js';
+import {fireNoBubbleNoCompose} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -22,9 +23,7 @@ export class GrEndpointParam extends LitElement {
override willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has('value')) {
- this.dispatchEvent(
- new CustomEvent('value-changed', {detail: {value: this.value}})
- );
+ fireNoBubbleNoCompose(this, 'value-changed', {value: this.value});
}
}
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
index 9915d4c89b..641d87bf46 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -8,12 +8,14 @@ import {
UnsubscribeCallback,
} from '../../../api/event-helper';
import {PluginApi} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
export class GrEventHelper implements EventHelperPluginApi {
- private readonly reporting = getAppContext().reportingService;
-
- constructor(readonly plugin: PluginApi, readonly element: HTMLElement) {
+ constructor(
+ private readonly reporting: ReportingService,
+ readonly plugin: PluginApi,
+ readonly element: HTMLElement
+ ) {
this.reporting.trackApi(this.plugin, 'event', 'constructor');
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
deleted file mode 100644
index 43d9805a0d..0000000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {LitElement, html, PropertyValues} from 'lit';
-import {customElement, property} from 'lit/decorators.js';
-
-@customElement('gr-external-style')
-export class GrExternalStyle extends LitElement {
- // This is a required value for this component.
- @property({type: String, reflect: true})
- name!: string;
-
- // private but used in test
- stylesApplied: string[] = [];
-
- stylesElements: HTMLElement[] = [];
-
- override render() {
- return html`<slot></slot>`;
- }
-
- override willUpdate(changedProperties: PropertyValues) {
- if (changedProperties.has('name')) {
- // We remove all styles defined for different name.
- this.removeStyles();
- this.importAndApply();
- getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => this.importAndApply());
- }
- }
-
- // private but used in test
- applyStyle(name: string) {
- if (this.stylesApplied.includes(name)) {
- return;
- }
- this.stylesApplied.push(name);
-
- const s = document.createElement('style');
- s.setAttribute('include', name);
- const cs = document.createElement('custom-style');
- this.stylesElements.push(cs);
- cs.appendChild(s);
- // When using Shadow DOM <custom-style> must be added to the <body>.
- // Within <gr-external-style> itself the styles would have no effect.
- const topEl = document.getElementsByTagName('body')[0];
- topEl.insertBefore(cs, topEl.firstChild);
- updateStyles();
- }
-
- removeStyles() {
- this.stylesElements.forEach(el => el.remove());
- this.stylesElements = [];
- this.stylesApplied = [];
- }
-
- private importAndApply() {
- const moduleNames = getPluginEndpoints().getModules(this.name);
- for (const name of moduleNames) {
- this.applyStyle(name);
- }
- }
-}
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-external-style': GrExternalStyle;
- }
-}
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
deleted file mode 100644
index ce87acbb63..0000000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {mockPromise, MockPromise, resetPlugins} from '../../../test/test-utils';
-import './gr-external-style';
-import {GrExternalStyle} from './gr-external-style';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {PluginApi} from '../../../api/plugin';
-import {fixture, html, assert} from '@open-wc/testing';
-
-suite('gr-external-style integration tests', () => {
- const TEST_URL = 'http://some.com/plugins/url.js';
-
- let element: GrExternalStyle;
- let plugin: PluginApi;
- let pluginsLoaded: MockPromise<void>;
- let applyStyleSpy: sinon.SinonSpy;
-
- const installPlugin = () => {
- if (plugin) {
- return;
- }
- window.Gerrit.install(
- p => {
- plugin = p;
- },
- '0.1',
- TEST_URL
- );
- };
-
- const createElement = async () => {
- applyStyleSpy = sinon.spy(GrExternalStyle.prototype, 'applyStyle');
- element = await fixture(
- html`<gr-external-style .name=${'foo'}></gr-external-style>`
- );
- await element.updateComplete;
- };
-
- /**
- * Installs the plugin, creates the element, registers style module.
- */
- const lateRegister = async () => {
- installPlugin();
- await createElement();
- plugin.registerStyleModule('foo', 'some-module');
- };
-
- /**
- * Installs the plugin, registers style module, creates the element.
- */
- const earlyRegister = async () => {
- installPlugin();
- plugin.registerStyleModule('foo', 'some-module');
- await createElement();
- };
-
- setup(() => {
- pluginsLoaded = mockPromise();
- sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
- });
-
- teardown(() => {
- resetPlugins();
- document.body
- .querySelectorAll('custom-style')
- .forEach(style => style.remove());
- });
-
- test('applies plugin-provided styles', async () => {
- await lateRegister();
- pluginsLoaded.resolve();
- await element.updateComplete;
- assert.isTrue(applyStyleSpy.calledWith('some-module'));
- });
-
- test('does not double apply', async () => {
- await earlyRegister();
- await element.updateComplete;
- plugin.registerStyleModule('foo', 'some-module');
- await element.updateComplete;
- const stylesApplied = element.stylesApplied.filter(
- name => name === 'some-module'
- );
- assert.strictEqual(stylesApplied.length, 1);
- });
-
- test('loads and applies preloaded modules', async () => {
- await earlyRegister();
- await element.updateComplete;
- assert.isTrue(applyStyleSpy.calledWith('some-module'));
- });
-
- test('removes old custom-style if name is changed', async () => {
- installPlugin();
- plugin.registerStyleModule('bar', 'some-module');
- await earlyRegister();
- await element.updateComplete;
- let customStyles = document.body.querySelectorAll('custom-style');
- assert.strictEqual(customStyles.length, 1);
- element.name = 'bar';
- await element.updateComplete;
- customStyles = document.body.querySelectorAll('custom-style');
- assert.strictEqual(customStyles.length, 1);
- element.name = 'baz';
- await element.updateComplete;
- customStyles = document.body.querySelectorAll('custom-style');
- assert.strictEqual(customStyles.length, 0);
- });
-
- test('can apply more than one style', async () => {
- await earlyRegister();
- await element.updateComplete;
- plugin.registerStyleModule('foo', 'some-module2');
- pluginsLoaded.resolve();
- await element.updateComplete;
- assert.strictEqual(element.stylesApplied.length, 2);
- const customStyles = document.body.querySelectorAll('custom-style');
- assert.strictEqual(customStyles.length, 2);
- });
-});
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
index b0993b9beb..80765a8234 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -5,19 +5,20 @@
*/
import {LitElement} from 'lit';
import {customElement, state} from 'lit/decorators.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {ServerInfo} from '../../../types/common';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
+import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
@customElement('gr-plugin-host')
export class GrPluginHost extends LitElement {
@state()
config?: ServerInfo;
- // visible for testing
- readonly getConfigModel = resolve(this, configModelToken);
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
constructor() {
super();
@@ -31,7 +32,10 @@ export class GrPluginHost extends LitElement {
? [config.default_theme]
: [];
const instanceId = config?.gerrit?.instance_id;
- getPluginLoader().loadPlugins([...themes, ...jsPlugins], instanceId);
+ this.getPluginLoader().loadPlugins(
+ [...themes, ...jsPlugins],
+ instanceId
+ );
}
);
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
index bb89d126fe..e0b792feb4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.ts
@@ -5,29 +5,42 @@
*/
import '../../../test/common-test-setup';
import './gr-plugin-host';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
import {GrPluginHost} from './gr-plugin-host';
import {fixture, html, assert} from '@open-wc/testing';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
import {createServerInfo} from '../../../test/test-data-generators';
+import {
+ ConfigModel,
+ configModelToken,
+} from '../../../models/config/config-model';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ PluginLoader,
+ pluginLoaderToken,
+} from '../../shared/gr-js-api-interface/gr-plugin-loader';
suite('gr-plugin-host tests', () => {
let element: GrPluginHost;
- let loadPluginsStub: SinonStub;
+ let loadPluginsStub: SinonStubbedMember<PluginLoader['loadPlugins']>;
+ let configModel: ConfigModel;
setup(async () => {
- loadPluginsStub = sinon.stub(getPluginLoader(), 'loadPlugins');
+ loadPluginsStub = sinon.stub(
+ testResolver(pluginLoaderToken),
+ 'loadPlugins'
+ );
element = await fixture<GrPluginHost>(html`
<gr-plugin-host></gr-plugin-host>
`);
await element.updateComplete;
+ configModel = testResolver(configModelToken);
sinon.stub(document.body, 'appendChild');
});
test('load plugins should be called', async () => {
loadPluginsStub.reset();
- element.getConfigModel().updateServerConfig({
+ configModel.updateServerConfig({
...createServerInfo(),
plugin: {
has_avatars: false,
@@ -46,7 +59,7 @@ suite('gr-plugin-host tests', () => {
test('theme plugins should be loaded if enabled', async () => {
loadPluginsStub.reset();
- element.getConfigModel().updateServerConfig({
+ configModel.updateServerConfig({
...createServerInfo(),
default_theme: 'gerrit-theme.js',
plugin: {
@@ -69,7 +82,7 @@ suite('gr-plugin-host tests', () => {
loadPluginsStub.reset();
const config = createServerInfo();
config.gerrit.instance_id = 'test-id';
- element.getConfigModel().updateServerConfig(config);
+ configModel.updateServerConfig(config);
assert.isTrue(loadPluginsStub.calledOnce);
assert.isTrue(loadPluginsStub.calledWith([], 'test-id'));
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
index 135ae5131e..7c99f14bbc 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -3,11 +3,10 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '../../shared/gr-overlay/gr-overlay';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, html} from 'lit';
import {customElement, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
declare global {
interface HTMLElementTagNameMap {
@@ -17,27 +16,27 @@ declare global {
@customElement('gr-plugin-popup')
export class GrPluginPopup extends LitElement {
- @query('#overlay') protected overlay!: GrOverlay;
+ @query('#modal') protected modal!: HTMLDialogElement;
static override get styles() {
- return [sharedStyles];
+ return [sharedStyles, modalStyles];
}
override render() {
- return html`<gr-overlay id="overlay" with-backdrop="">
+ return html`<dialog id="modal">
<slot></slot>
- </gr-overlay>`;
+ </dialog>`;
}
get opened() {
- return this.overlay.opened;
+ return this.modal.hasAttribute('open');
}
open() {
- return this.overlay.open();
+ this.modal.showModal();
}
close() {
- this.overlay.close();
+ this.modal.close();
}
}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
index 86243c4938..8e7605db06 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
@@ -11,29 +11,27 @@ import {GrPluginPopup} from './gr-plugin-popup';
suite('gr-plugin-popup tests', () => {
let element: GrPluginPopup;
- let overlayOpen: sinon.SinonStub;
- let overlayClose: sinon.SinonStub;
+ let modalOpen: sinon.SinonStub;
+ let modalClose: sinon.SinonStub;
setup(async () => {
element = await fixture(html`<gr-plugin-popup></gr-plugin-popup>`);
await element.updateComplete;
- overlayOpen = stubElement('gr-overlay', 'open').callsFake(() =>
- Promise.resolve()
- );
- overlayClose = stubElement('gr-overlay', 'close');
+ modalOpen = stubElement('dialog', 'showModal');
+ modalClose = stubElement('dialog', 'close');
});
test('exists', () => {
assert.isOk(element);
});
- test('open uses open() from gr-overlay', async () => {
- await element.open();
- assert.isTrue(overlayOpen.called);
+ test('open uses open() from dialog', () => {
+ element.open();
+ assert.isTrue(modalOpen.called);
});
- test('close uses close() from gr-overlay', () => {
+ test('close uses close() from dialog', () => {
element.close();
- assert.isTrue(overlayClose.called);
+ assert.isTrue(modalClose.called);
});
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
index 9dbb231874..1ee5f5db94 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -28,7 +28,8 @@ export class GrPopupInterface implements PopupPluginApi {
constructor(
readonly plugin: PluginApi,
- private moduleName: string | null = null
+ // private but used in tests
+ readonly moduleName: string | null = null
) {
this.reporting.trackApi(this.plugin, 'popup', 'constructor');
}
@@ -65,7 +66,7 @@ export class GrPopupInterface implements PopupPluginApi {
}
this.popup = hookEl.appendChild(popup);
await this.popup.updateComplete;
- await this.popup.open();
+ this.popup.open();
return this;
});
}
diff --git a/polygerrit-ui/app/elements/polymer-util.ts b/polygerrit-ui/app/elements/polymer-util.ts
new file mode 100644
index 0000000000..d325b7b33e
--- /dev/null
+++ b/polygerrit-ui/app/elements/polymer-util.ts
@@ -0,0 +1,14 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+
+export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
+ requestAvailability(): void;
+}
+
+export function ironAnnouncerRequestAvailability() {
+ (IronA11yAnnouncer as unknown as FixIronA11yAnnouncer).requestAvailability();
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
index c9603f44a9..89513e35ce 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -3,15 +3,19 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '@polymer/iron-input/iron-input';
import '../../shared/gr-avatar/gr-avatar';
import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
import '../../../styles/gr-form-styles';
import '../../../styles/shared-styles';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-hovercard-account/gr-hovercard-account-contents';
import {AccountDetailInfo, ServerInfo} from '../../../types/common';
import {EditableAccountField} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {LitElement, css, html, nothing, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -21,12 +25,6 @@ import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
@customElement('gr-account-info')
export class GrAccountInfo extends LitElement {
- /**
- * Fired when account details are changed.
- *
- * @event account-detail-update
- */
-
// private but used in test
@state() nameMutable?: boolean;
@@ -75,12 +73,42 @@ export class GrAccountInfo extends LitElement {
div section.hide {
display: none;
}
+ gr-hovercard-account-contents {
+ display: block;
+ max-width: 600px;
+ margin-top: var(--spacing-m);
+ background: var(--dialog-background-color);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ box-shadow: var(--elevation-level-5);
+ }
+ iron-autogrow-textarea {
+ background-color: var(--view-background-color);
+ color: var(--primary-text-color);
+ }
+ .lengthCounter {
+ font-weight: var(--font-weight-normal);
+ }
+ p {
+ max-width: 65ch;
+ margin-bottom: var(--spacing-m);
+ }
`,
];
override render() {
if (!this.account || this.loading) return nothing;
return html`<div class="gr-form-styles">
+ <p>
+ All profile fields below may be publicly displayed to others, including
+ on changes you are associated with, as well as in search and
+ autocompletion.
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+ >Learn more</a
+ >
+ </p>
+ <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
<section>
<span class="title"></span>
<span class="value">
@@ -188,25 +216,43 @@ export class GrAccountInfo extends LitElement {
</span>
</section>
<section>
- <label class="title" for="statusInput">About me (e.g. employer)</label>
+ <span class="title">
+ <label for="statusInput">About me (e.g. employer)</label>
+ <div class="lengthCounter">
+ ${this.account.status?.length ?? 0}/140
+ </div>
+ </span>
<span class="value">
- <iron-input
- id="statusIronInput"
- @keydown=${this.handleKeydown}
- .bindValue=${this.account?.status}
+ <iron-autogrow-textarea
+ id="statusInput"
+ .name=${'statusInput'}
+ ?disabled=${this.saving}
+ maxlength="140"
+ .value=${this.account?.status}
@bind-value-changed=${(e: BindValueChangeEvent) => {
const oldAccount = this.account;
if (!oldAccount || oldAccount.status === e.detail.value) return;
this.account = {...oldAccount, status: e.detail.value};
this.hasStatusChange = true;
}}
+ ></iron-autogrow-textarea>
+ </span>
+ </section>
+ <section>
+ <span class="title">
+ <gr-tooltip-content
+ title="This is how you appear to others"
+ has-tooltip
+ show-icon
>
- <input
- id="statusInput"
- ?disabled=${this.saving}
- @keydown=${this.handleKeydown}
- />
- </iron-input>
+ Account preview
+ </gr-tooltip-content>
+ </span>
+ <span class="value">
+ <gr-account-chip .account=${this.account}></gr-account-chip>
+ <gr-hovercard-account-contents
+ .account=${this.account}
+ ></gr-hovercard-account-contents>
</span>
</section>
</div>`;
@@ -289,7 +335,7 @@ export class GrAccountInfo extends LitElement {
this.hasDisplayNameChange = false;
this.hasStatusChange = false;
this.saving = false;
- fireEvent(this, 'account-detail-update');
+ fire(this, 'account-detail-update', {});
});
}
@@ -358,6 +404,8 @@ export class GrAccountInfo extends LitElement {
declare global {
interface HTMLElementEventMap {
'unsaved-changes-changed': ValueChangedEvent<boolean>;
+ /** Fired when account details are changed. */
+ 'account-detail-update': CustomEvent<{}>;
}
interface HTMLElementTagNameMap {
'gr-account-info': GrAccountInfo;
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index 518828a2a6..f95496027f 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -5,7 +5,12 @@
*/
import '../../../test/common-test-setup';
import './gr-account-info';
-import {query, queryAll, stubRestApi} from '../../../test/test-utils';
+import {
+ query,
+ queryAll,
+ queryAndAssert,
+ stubRestApi,
+} from '../../../test/test-utils';
import {GrAccountInfo} from './gr-account-info';
import {AccountDetailInfo, ServerInfo} from '../../../types/common';
import {
@@ -20,6 +25,7 @@ import {SinonStubbedMember} from 'sinon';
import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
import {EditableAccountField} from '../../../api/rest-api';
import {fixture, html, assert} from '@open-wc/testing';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
suite('gr-account-info tests', () => {
let element!: GrAccountInfo;
@@ -63,6 +69,16 @@ suite('gr-account-info tests', () => {
element,
/* HTML */ `
<div class="gr-form-styles">
+ <p>
+ All profile fields below may be publicly displayed to others,
+ including on changes you are associated with, as well as in search
+ and autocompletion.
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-privacy.html"
+ >Learn more</a
+ >
+ </p>
+ <gr-endpoint-decorator name="profile"></gr-endpoint-decorator>
<section>
<span class="title"></span>
<span class="value">
@@ -100,17 +116,36 @@ suite('gr-account-info tests', () => {
</span>
</section>
<section>
- <label class="title" for="statusInput">
- About me (e.g. employer)
- </label>
+ <span class="title">
+ <label for="statusInput">About me (e.g. employer)</label>
+ <div class="lengthCounter">0/140</div>
+ </span>
<span class="value">
- <iron-input id="statusIronInput">
- <input id="statusInput" />
- </iron-input>
+ <iron-autogrow-textarea
+ aria-disabled="false"
+ id="statusInput"
+ maxlength="140"
+ />
+ </span>
+ </section>
+ <section>
+ <span class="title">
+ <gr-tooltip-content
+ has-tooltip=""
+ show-icon=""
+ title="This is how you appear to others"
+ >
+ Account preview
+ </gr-tooltip-content>
+ </span>
+ <span class="value"
+ ><gr-account-chip></gr-account-chip>
+ <gr-hovercard-account-contents></gr-hovercard-account-contents>
</span>
</section>
</div>
- `
+ `,
+ {ignoreChildren: ['p']}
);
});
@@ -261,8 +296,11 @@ suite('gr-account-info tests', () => {
test('status', async () => {
assert.isFalse(element.hasUnsavedChanges);
- const statusInputEl = queryIronInput('#statusIronInput');
- statusInputEl.bindValue = 'new status';
+ const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+ element,
+ '#statusInput'
+ );
+ statusTextarea.value = 'new status';
await element.updateComplete;
assert.isFalse(element.hasNameChange);
assert.isTrue(element.hasStatusChange);
@@ -305,8 +343,11 @@ suite('gr-account-info tests', () => {
await element.updateComplete;
assert.isTrue(element.hasNameChange);
- const statusInputEl = queryIronInput('#statusIronInput');
- statusInputEl.bindValue = 'new status';
+ const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+ element,
+ '#statusInput'
+ );
+ statusTextarea.value = 'new status';
await element.updateComplete;
assert.isTrue(element.hasStatusChange);
@@ -351,8 +392,11 @@ suite('gr-account-info tests', () => {
assert.equal(displaySpan.textContent, account.name);
assert.isUndefined(inputSpan);
- const inputEl = queryIronInput('#statusIronInput');
- inputEl.bindValue = 'new status';
+ const statusTextarea = queryAndAssert<IronAutogrowTextareaElement>(
+ element,
+ '#statusInput'
+ );
+ statusTextarea.value = 'new status';
await element.updateComplete;
assert.isTrue(element.hasStatusChange);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
index 2f835f7a4e..a2b61e010a 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -53,7 +53,7 @@ export class GrClaView extends LitElement {
super.connectedCallback();
this.loadData();
- fireTitleChange(this, 'New Contributor Agreement');
+ fireTitleChange('New Contributor Agreement');
}
static override get styles() {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
index f554ff080f..183425d171 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -7,7 +7,6 @@ import '@polymer/iron-input/iron-input';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-select/gr-select';
import {EditPreferencesInfo} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
import {formStyles} from '../../../styles/gr-form-styles';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -15,6 +14,8 @@ import {LitElement, html, css} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {convertToString} from '../../../utils/string-util';
import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
@customElement('gr-edit-preferences')
export class GrEditPreferences extends LitElement {
@@ -46,13 +47,13 @@ export class GrEditPreferences extends LitElement {
@state() private originalEditPrefs?: EditPreferencesInfo;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
constructor() {
super();
subscribe(
this,
- () => this.userModel.editPreferences$,
+ () => this.getUserModel().editPreferences$,
editPreferences => {
this.originalEditPrefs = editPreferences;
this.editPrefs = {...editPreferences};
@@ -307,7 +308,7 @@ export class GrEditPreferences extends LitElement {
async save() {
if (!this.editPrefs) return;
- await this.userModel.updateEditPreference(this.editPrefs);
+ await this.getUserModel().updateEditPreference(this.editPrefs);
}
}
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
index b2f8acd5d3..32b32e2cfc 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -6,10 +6,8 @@
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
import {GrButton} from '../../shared/gr-button/gr-button';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
import {getAppContext} from '../../../services/app-context';
import {css, html, LitElement} from 'lit';
@@ -19,6 +17,7 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {assertIsDefined} from '../../../utils/common-util';
import {BindValueChangeEvent} from '../../../types/events';
import {fire} from '../../../utils/event-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
declare global {
interface HTMLElementTagNameMap {
@@ -27,7 +26,7 @@ declare global {
}
@customElement('gr-gpg-editor')
export class GrGpgEditor extends LitElement {
- @query('#viewKeyOverlay') viewKeyOverlay?: GrOverlay;
+ @query('#viewKeyModal') viewKeyModal?: HTMLDialogElement;
@query('#addButton') addButton?: GrButton;
@@ -53,6 +52,7 @@ export class GrGpgEditor extends LitElement {
static override styles = [
formStyles,
sharedStyles,
+ modalStyles,
css`
.keyHeader {
width: 9em;
@@ -60,7 +60,7 @@ export class GrGpgEditor extends LitElement {
.userIdHeader {
width: 15em;
}
- #viewKeyOverlay {
+ #viewKeyModal {
padding: var(--spacing-xxl);
width: 50em;
}
@@ -97,7 +97,7 @@ export class GrGpgEditor extends LitElement {
${this.keys.map((key, index) => this.renderKey(key, index))}
</tbody>
</table>
- <gr-overlay id="viewKeyOverlay" with-backdrop="">
+ <dialog id="viewKeyModal" tabindex="-1">
<fieldset>
<section>
<span class="title">Status</span>
@@ -111,11 +111,11 @@ export class GrGpgEditor extends LitElement {
<gr-button
class="closeButton"
@click=${() => {
- this.viewKeyOverlay?.close();
+ this.viewKeyModal?.close();
}}
>Close</gr-button
>
- </gr-overlay>
+ </dialog>
<gr-button @click=${this.save} ?disabled=${!this.hasUnsavedChanges}
>Save changes</gr-button
>
@@ -201,7 +201,7 @@ export class GrGpgEditor extends LitElement {
private showKey(key: GpgKeyInfo) {
this.keyToView = key;
- this.viewKeyOverlay?.open();
+ this.viewKeyModal?.showModal();
}
private handleNewKeyChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
index 8e653fcae1..5be5b294d5 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.ts
@@ -138,13 +138,7 @@ suite('gr-gpg-editor tests', () => {
</tr>
</tbody>
</table>
- <gr-overlay
- aria-hidden="true"
- id="viewKeyOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="viewKeyModal" tabindex="-1">
<fieldset>
<section>
<span class="title"> Status </span> <span class="value"> </span>
@@ -161,7 +155,7 @@ suite('gr-gpg-editor tests', () => {
>
Close
</gr-button>
- </gr-overlay>
+ </dialog>
<gr-button
aria-disabled="true"
disabled=""
@@ -242,7 +236,7 @@ suite('gr-gpg-editor tests', () => {
});
test('show key', () => {
- const openSpy = sinon.spy(element.viewKeyOverlay!, 'open');
+ const openSpy = sinon.spy(element.viewKeyModal!, 'showModal');
// Get the show button for the last row.
const button = queryAndAssert<GrButton>(
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
index 9595391d79..16e262bd4b 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -5,13 +5,12 @@
*/
import '../../shared/gr-button/gr-button';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {getAppContext} from '../../../services/app-context';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
+import {modalStyles} from '../../../styles/gr-modal-styles';
declare global {
interface HTMLElementTagNameMap {
@@ -21,8 +20,8 @@ declare global {
@customElement('gr-http-password')
export class GrHttpPassword extends LitElement {
- @query('#generatedPasswordOverlay')
- generatedPasswordOverlay?: GrOverlay;
+ @query('#generatedPasswordModal')
+ generatedPasswordModal?: HTMLDialogElement;
@property({type: String})
_username?: string;
@@ -68,13 +67,14 @@ export class GrHttpPassword extends LitElement {
return [
sharedStyles,
formStyles,
+ modalStyles,
css`
.password {
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
}
- #generatedPasswordOverlay {
+ #generatedPasswordModal {
padding: var(--spacing-xxl);
width: 50em;
}
@@ -120,10 +120,10 @@ export class GrHttpPassword extends LitElement {
(opens in a new tab)
</span>
</div>
- <gr-overlay
- id="generatedPasswordOverlay"
- @iron-overlay-closed=${this._generatedPasswordOverlayClosed}
- with-backdrop
+ <dialog
+ tabindex="-1"
+ id="generatedPasswordModal"
+ @closed=${this._generatedPasswordModalClosed}
>
<div class="gr-form-styles">
<section id="generatedPasswordDisplay">
@@ -141,26 +141,26 @@ export class GrHttpPassword extends LitElement {
This password will not be displayed again.<br />
If you lose it, you will need to generate a new one.
</section>
- <gr-button link="" class="closeButton" @click=${this._closeOverlay}
+ <gr-button link="" class="closeButton" @click=${this._closeModal}
>Close</gr-button
>
</div>
- </gr-overlay>`;
+ </dialog>`;
}
_handleGenerateTap() {
this._generatedPassword = 'Generating...';
- this.generatedPasswordOverlay?.open();
+ this.generatedPasswordModal?.showModal();
this.restApiService.generateAccountHttpPassword().then(newPassword => {
this._generatedPassword = newPassword;
});
}
- _closeOverlay() {
- this.generatedPasswordOverlay?.close();
+ _closeModal() {
+ this.generatedPasswordModal?.close();
}
- _generatedPasswordOverlayClosed() {
+ _generatedPasswordModalClosed() {
this._generatedPassword = '';
}
}
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
index 116d349e72..a5820447bd 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -57,13 +57,7 @@ suite('gr-http-password tests', () => {
(opens in a new tab)
</span>
</div>
- <gr-overlay
- aria-hidden="true"
- id="generatedPasswordOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog tabindex="-1" id="generatedPasswordModal">
<div class="gr-form-styles">
<section id="generatedPasswordDisplay">
<span class="title"> New Password: </span>
@@ -90,7 +84,7 @@ suite('gr-http-password tests', () => {
Close
</gr-button>
</div>
- </gr-overlay>
+ </dialog>
`
);
});
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
index 4f5411d738..7f67ea80da 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -5,10 +5,8 @@
*/
import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
import '../../shared/gr-button/gr-button';
-import '../../shared/gr-overlay/gr-overlay';
import {getBaseUrl} from '../../../utils/url-util';
import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {getAppContext} from '../../../services/app-context';
import {AuthType} from '../../../constants/constants';
import {LitElement, css, html, PropertyValues} from 'lit';
@@ -18,12 +16,13 @@ import {formStyles} from '../../../styles/gr-form-styles';
import {classMap} from 'lit/directives/class-map.js';
import {when} from 'lit/directives/when.js';
import {assertIsDefined} from '../../../utils/common-util';
+import {modalStyles} from '../../../styles/gr-modal-styles';
const AUTH = [AuthType.OPENID, AuthType.OAUTH];
@customElement('gr-identities')
export class GrIdentities extends LitElement {
- @query('#overlay') overlay?: GrOverlay;
+ @query('#modal') modal?: HTMLDialogElement;
@state() private identities: AccountExternalIdInfo[] = [];
@@ -40,6 +39,7 @@ export class GrIdentities extends LitElement {
static override styles = [
sharedStyles,
formStyles,
+ modalStyles,
css`
tr th.emailAddressHeader,
tr th.identityHeader {
@@ -98,7 +98,7 @@ export class GrIdentities extends LitElement {
</fieldset>`
)}
</div>
- <gr-overlay id="overlay" with-backdrop>
+ <dialog id="modal" tabindex="-1">
<gr-confirm-delete-item-dialog
class="confirmDialog"
@confirm=${this.handleDeleteItemConfirm}
@@ -106,7 +106,7 @@ export class GrIdentities extends LitElement {
.item=${this.idName}
itemtypename="ID"
></gr-confirm-delete-item-dialog>
- </gr-overlay>`;
+ </dialog>`;
}
private renderIdentity(account: AccountExternalIdInfo, index: number) {
@@ -156,7 +156,7 @@ export class GrIdentities extends LitElement {
}
handleDeleteItemConfirm() {
- this.overlay?.close();
+ this.modal?.close();
assertIsDefined(this.idName);
return this.restApiService.deleteAccountIdentity([this.idName]).then(() => {
this.loadData();
@@ -164,12 +164,12 @@ export class GrIdentities extends LitElement {
}
private handleConfirmDialogCancel() {
- this.overlay?.close();
+ this.modal?.close();
}
private handleDeleteItem(name: string) {
this.idName = name;
- this.overlay?.open();
+ this.modal?.showModal();
}
// private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
index 84df178985..d52b423971 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -7,7 +7,7 @@ import '../../../test/common-test-setup';
import './gr-identities';
import {GrIdentities} from './gr-identities';
import {AuthType} from '../../../constants/constants';
-import {stubRestApi} from '../../../test/test-utils';
+import {stubRestApi, waitUntilVisible} from '../../../test/test-utils';
import {ServerInfo} from '../../../types/common';
import {createServerInfo} from '../../../test/test-data-generators';
import {queryAll, queryAndAssert} from '../../../test/test-utils';
@@ -96,19 +96,13 @@ suite('gr-identities tests', () => {
</table>
</fieldset>
</div>
- <gr-overlay
- aria-hidden="true"
- id="overlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="modal" tabindex="-1">
<gr-confirm-delete-item-dialog
class="confirmDialog"
itemtypename="ID"
>
- </gr-confirm-delete-item-dialog
- ></gr-overlay>`
+ </gr-confirm-delete-item-dialog>
+ </dialog>`
);
});
@@ -150,7 +144,7 @@ suite('gr-identities tests', () => {
const deleteBtn = queryAndAssert<GrButton>(element, '.deleteButton');
deleteBtn.click();
await element.updateComplete;
- assert.isTrue(element.overlay?.opened);
+ await waitUntilVisible(element.modal!);
});
test('computeShowLinkAnotherIdentity', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
index 460cc7ca5e..9c2385760b 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -12,12 +12,13 @@ import {formStyles} from '../../../styles/gr-form-styles';
import {state, customElement} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
import {subscribe} from '../../lit/subscription-controller';
-import {getAppContext} from '../../../services/app-context';
import {deepEqual} from '../../../utils/deep-util';
import {createDefaultPreferences} from '../../../constants/constants';
import {fontStyles} from '../../../styles/gr-font-styles';
import {classMap} from 'lit/directives/class-map.js';
import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
@customElement('gr-menu-editor')
export class GrMenuEditor extends LitElement {
@@ -33,13 +34,13 @@ export class GrMenuEditor extends LitElement {
@state()
newUrl = '';
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
constructor() {
super();
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
prefs => {
this.originalPrefs = prefs;
this.menuItems = [...prefs.my];
@@ -196,7 +197,7 @@ export class GrMenuEditor extends LitElement {
}
private handleSave() {
- this.userModel.updatePreferences({
+ this.getUserModel().updatePreferences({
...this.originalPrefs,
my: this.menuItems,
});
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
index a20c0ee75e..c6c023efd1 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -9,7 +9,7 @@ import '../../shared/gr-button/gr-button';
import {ServerInfo, AccountDetailInfo} from '../../../types/common';
import {EditableAccountField} from '../../../constants/constants';
import {getAppContext} from '../../../services/app-context';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -27,12 +27,6 @@ declare global {
@customElement('gr-registration-dialog')
export class GrRegistrationDialog extends LitElement {
/**
- * Fired when account details are changed.
- *
- * @event account-detail-update
- */
-
- /**
* Fired when the close button is pressed.
*
* @event close
@@ -293,7 +287,7 @@ export class GrRegistrationDialog extends LitElement {
return Promise.all(promises).then(() => {
this.saving = false;
- fireEvent(this, 'account-detail-update');
+ fire(this, 'account-detail-update', {});
});
}
@@ -309,7 +303,7 @@ export class GrRegistrationDialog extends LitElement {
private close() {
this.saving = true; // disable buttons indefinitely
- fireEvent(this, 'close');
+ fire(this, 'close', {});
}
// private but used in test
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
index 7f0f85d530..83ca1493d0 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
@@ -90,10 +90,10 @@ suite('gr-registration-dialog tests', () => {
return promise;
}
- function close(opt_action?: Function) {
+ function close(action?: Function) {
const promise = listen('close');
- if (opt_action) {
- opt_action();
+ if (action) {
+ action();
} else {
queryAndAssert<GrButton>(element, '#closeButton').click();
}
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 392f1360c6..fc61ba0ccd 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
@@ -11,6 +11,7 @@ import '../../shared/gr-button/gr-button';
import '../../shared/gr-diff-preferences/gr-diff-preferences';
import '../../shared/gr-page-nav/gr-page-nav';
import '../../shared/gr-select/gr-select';
+import '../../shared/gr-icon/gr-icon';
import '../gr-account-info/gr-account-info';
import '../gr-agreements-list/gr-agreements-list';
import '../gr-edit-preferences/gr-edit-preferences';
@@ -22,7 +23,6 @@ import '../gr-identities/gr-identities';
import '../gr-menu-editor/gr-menu-editor';
import '../gr-ssh-editor/gr-ssh-editor';
import '../gr-watched-projects-editor/gr-watched-projects-editor';
-import {getDocsBaseUrl} from '../../../utils/url-util';
import {GrAccountInfo} from '../gr-account-info/gr-account-info';
import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
import {GrGroupList} from '../gr-group-list/gr-group-list';
@@ -63,6 +63,7 @@ import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
import {settingsViewModelToken} from '../../../models/views/settings';
import {areNotificationsEnabled} from '../../../utils/worker-util';
+import {userModelToken} from '../../../models/user/user-model';
const GERRIT_DOCS_BASE_URL =
'https://gerrit-review.googlesource.com/' + 'Documentation';
@@ -80,12 +81,6 @@ enum CopyPrefsDirection {
@customElement('gr-settings-view')
export class GrSettingsView extends LitElement {
/**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
* Fired with email confirmation text, or when the page reloads.
*
* @event show-alert
@@ -201,7 +196,7 @@ export class GrSettingsView extends LitElement {
private readonly restApiService = getAppContext().restApiService;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
// private but used in test
readonly flagsService = getAppContext().flagsService;
@@ -220,14 +215,14 @@ export class GrSettingsView extends LitElement {
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
acc => {
this.account = acc;
}
);
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
prefs => {
if (!prefs) {
throw new Error('getPreferences returned undefined');
@@ -260,7 +255,7 @@ export class GrSettingsView extends LitElement {
// Polymer 2: anchor tag won't work on shadow DOM
// we need to manually calling scrollIntoView when hash changed
document.addEventListener('location-change', this.handleLocationChange);
- fireTitleChange(this, 'Settings');
+ fireTitleChange('Settings');
}
override firstUpdated() {
@@ -289,7 +284,7 @@ export class GrSettingsView extends LitElement {
}
configPromises.push(
- getDocsBaseUrl(config, this.restApiService).then(baseUrl => {
+ this.restApiService.getDocsBaseUrl(config).then(baseUrl => {
this.docsBaseUrl = baseUrl;
})
);
@@ -879,12 +874,26 @@ export class GrSettingsView extends LitElement {
private renderBrowserNotifications() {
if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS))
return nothing;
- if (!areNotificationsEnabled(this.account)) return nothing;
+ if (
+ !this.flagsService.isEnabled(
+ KnownExperimentId.PUSH_NOTIFICATIONS_DEVELOPER
+ ) &&
+ !areNotificationsEnabled(this.account)
+ )
+ return nothing;
return html`
<section id="allowBrowserNotificationsSection">
- <label class="title" for="allowBrowserNotifications"
- >Allow browser notifications</label
- >
+ <div class="title">
+ <label for="allowBrowserNotifications"
+ >Allow browser notifications</label
+ >
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+ target="_blank"
+ >
+ <gr-icon icon="help" title="read documentation"></gr-icon>
+ </a>
+ </div>
<span class="value">
<input
id="allowBrowserNotifications"
@@ -1113,7 +1122,7 @@ export class GrSettingsView extends LitElement {
// Use shadowRoot for Polymer 2
const elem = (this.shadowRoot || document).querySelector(urlHash);
if (elem) {
- elem.scrollIntoView();
+ setTimeout(() => elem.scrollIntoView(), 0);
}
}
};
@@ -1136,7 +1145,7 @@ export class GrSettingsView extends LitElement {
// private but used in test
handleSavePreferences() {
- return this.userModel.updatePreferences(this.localPrefs);
+ return this.getUserModel().updatePreferences(this.localPrefs);
}
// private but used in test
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 24a73b2394..0bcb09c71d 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
@@ -38,7 +38,6 @@ import {
} from '../../../test/test-data-generators';
import {GrSelect} from '../../shared/gr-select/gr-select';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
suite('gr-settings-view tests', () => {
let element: GrSettingsView;
@@ -525,9 +524,17 @@ suite('gr-settings-view tests', () => {
assert.dom.equal(
queryAndAssert(element, '#allowBrowserNotificationsSection'),
/* HTML */ `<section id="allowBrowserNotificationsSection">
- <label class="title" for="allowBrowserNotifications">
- Allow browser notifications
- </label>
+ <div class="title">
+ <label for="allowBrowserNotifications">
+ Allow browser notifications
+ </label>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html#_browser_notifications"
+ target="_blank"
+ >
+ <gr-icon icon="help" title="read documentation"> </gr-icon>
+ </a>
+ </div>
<span class="value">
<input checked="" id="allowBrowserNotifications" type="checkbox" />
</span>
@@ -537,10 +544,8 @@ suite('gr-settings-view tests', () => {
test('calls the title-change event', async () => {
const titleChangedStub = sinon.stub();
-
- // Create a new view.
const newElement = document.createElement('gr-settings-view');
- newElement.addEventListener('title-change', titleChangedStub);
+ document.addEventListener('title-change', titleChangedStub);
const div = await fixture(html`<div></div>`);
div.appendChild(newElement);
@@ -907,7 +912,7 @@ suite('gr-settings-view tests', () => {
await element._testOnly_loadingPromise;
assert.equal(
(dispatchEventSpy.lastCall.args[0] as CustomEvent).type,
- EventType.SHOW_ALERT
+ 'show-alert'
);
assert.deepEqual(
(dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
index a73170ace3..9c323aa142 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -6,11 +6,9 @@
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import '../../shared/gr-overlay/gr-overlay';
import {SshKeyInfo} from '../../../types/common';
import {GrButton} from '../../shared/gr-button/gr-button';
import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
import {getAppContext} from '../../../services/app-context';
import {LitElement, css, html, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
@@ -18,6 +16,7 @@ import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
import {fire} from '../../../utils/event-util';
import {BindValueChangeEvent} from '../../../types/events';
+import {modalStyles} from '../../../styles/gr-modal-styles';
declare global {
interface HTMLElementTagNameMap {
@@ -47,7 +46,7 @@ export class GrSshEditor extends LitElement {
@query('#newKey') newKeyEditor!: IronAutogrowTextareaElement;
- @query('#viewKeyOverlay') viewKeyOverlay!: GrOverlay;
+ @query('#viewKeyModal') viewKeyModal!: HTMLDialogElement;
private readonly restApiService = getAppContext().restApiService;
@@ -55,6 +54,7 @@ export class GrSshEditor extends LitElement {
return [
formStyles,
sharedStyles,
+ modalStyles,
css`
.statusHeader {
width: 4em;
@@ -62,7 +62,7 @@ export class GrSshEditor extends LitElement {
.keyHeader {
width: 7.5em;
}
- #viewKeyOverlay {
+ #viewKeyModal {
padding: var(--spacing-xxl);
width: 50em;
}
@@ -121,7 +121,7 @@ export class GrSshEditor extends LitElement {
${this.keys.map((key, index) => this.renderKey(key, index))}
</tbody>
</table>
- <gr-overlay id="viewKeyOverlay" with-backdrop="">
+ <dialog id="viewKeyModal" tabindex="-1">
<fieldset>
<section>
<span class="title">Algorithm</span>
@@ -140,10 +140,10 @@ export class GrSshEditor extends LitElement {
</fieldset>
<gr-button
class="closeButton"
- @click=${() => this.viewKeyOverlay.close()}
+ @click=${() => this.viewKeyModal.close()}
>Close</gr-button
>
- </gr-overlay>
+ </dialog>
<gr-button
@click=${() => this.save()}
?disabled=${!this.hasUnsavedChanges}
@@ -231,7 +231,7 @@ export class GrSshEditor extends LitElement {
const el = e.target as GrButton;
const index = Number(el.getAttribute('data-index')!);
this.keyToView = this.keys[index];
- this.viewKeyOverlay.open();
+ this.viewKeyModal.showModal();
}
private handleDeleteKey(e: Event) {
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
index c5641ff886..9528fb27f7 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.ts
@@ -127,13 +127,7 @@ suite('gr-ssh-editor tests', () => {
</tr>
</tbody>
</table>
- <gr-overlay
- aria-hidden="true"
- id="viewKeyOverlay"
- style="outline: none; display: none;"
- tabindex="-1"
- with-backdrop=""
- >
+ <dialog id="viewKeyModal" tabindex="-1">
<fieldset>
<section>
<span class="title"> Algorithm </span>
@@ -156,7 +150,7 @@ suite('gr-ssh-editor tests', () => {
>
Close
</gr-button>
- </gr-overlay>
+ </dialog>
<gr-button
aria-disabled="true"
disabled=""
@@ -227,7 +221,7 @@ suite('gr-ssh-editor tests', () => {
});
test('show key', () => {
- const openSpy = sinon.spy(element.viewKeyOverlay, 'open');
+ const openSpy = sinon.spy(element.viewKeyModal, 'showModal');
// Get the show button for the last row.
const button = query<GrButton>(
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
index fb38b598fe..2996e50fac 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -21,6 +21,7 @@ import {formStyles} from '../../../styles/gr-form-styles';
import {when} from 'lit/directives/when.js';
import {fire} from '../../../utils/event-util';
import {PropertiesOfType} from '../../../utils/type-util';
+import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
type NotificationKey = PropertiesOfType<Required<ProjectWatchInfo>, boolean>;
@@ -193,13 +194,15 @@ export class GrWatchedProjectsEditor extends LitElement {
// private but used in tests.
getProjectSuggestions(input: string) {
- return this.restApiService.getSuggestedProjects(input).then(response => {
- const projects: AutocompleteSuggestion[] = [];
- for (const [name, project] of Object.entries(response ?? {})) {
- projects.push({name, value: project.id});
- }
- return projects;
- });
+ return this.restApiService
+ .getSuggestedRepos(input, /* n=*/ undefined, throwingErrorCallback)
+ .then(response => {
+ const repos: AutocompleteSuggestion[] = [];
+ for (const [name, repo] of Object.entries(response ?? {})) {
+ repos.push({name, value: repo.id});
+ }
+ return repos;
+ });
}
private handleRemoveProject(project: ProjectWatchInfo) {
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index 1280d6e781..c608656150 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
@@ -44,7 +44,7 @@ suite('gr-watched-projects-editor tests', () => {
] as ProjectWatchInfo[];
stubRestApi('getWatchedProjects').returns(Promise.resolve(projects));
- suggestionStub = stubRestApi('getSuggestedProjects').callsFake(input => {
+ suggestionStub = stubRestApi('getSuggestedRepos').callsFake(input => {
if (input.startsWith('th')) {
return Promise.resolve({
'the project': {
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
index 31e4f5da35..d7c6c8b391 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -17,6 +17,8 @@ import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {getLabelStatus, hasVoted, LabelStatus} from '../../../utils/label-util';
+import {fire} from '../../../utils/event-util';
+import {RemoveAccountEvent} from '../../../types/events';
@customElement('gr-account-chip')
export class GrAccountChip extends LitElement {
@@ -196,13 +198,8 @@ export class GrAccountChip extends LitElement {
private handleRemoveTap(e: MouseEvent) {
e.preventDefault();
- this.dispatchEvent(
- new CustomEvent('remove', {
- detail: {account: this.account},
- composed: true,
- bubbles: true,
- })
- );
+ if (!this.account) return;
+ fire(this, 'remove-account', {account: this.account});
}
private getHasAvatars() {
@@ -232,4 +229,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-account-chip': GrAccountChip;
}
+ interface HTMLElementEventMap {
+ 'remove-account': RemoveAccountEvent;
+ }
}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
index 0509925def..2249b5d78b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -11,9 +11,14 @@ import {
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
-import {BindValueChangeEvent} from '../../../types/events';
+import {
+ AddAccountEvent,
+ AutocompleteCommitEvent,
+ BindValueChangeEvent,
+} from '../../../types/events';
import {SuggestedReviewerInfo} from '../../../types/common';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {fire} from '../../../utils/event-util';
/**
* gr-account-entry is an element for entering account
@@ -23,20 +28,6 @@ import {PaperInputElement} from '@polymer/paper-input/paper-input';
export class GrAccountEntry extends LitElement {
@query('#input') private input?: GrAutocomplete;
- /**
- * Fired when an account is entered.
- *
- * @event add
- */
-
- /**
- * When allowAnyInput is true, account-text-changed is fired when input text
- * changed. This is needed so that the reply dialog's save button can be
- * enabled for arbitrary cc's, which don't need a 'commit'.
- *
- * @event account-text-changed
- */
-
@property({type: Boolean})
allowAnyInput = false;
@@ -110,22 +101,14 @@ export class GrAccountEntry extends LitElement {
return this.input!.text;
}
- private handleInputCommit(e: CustomEvent) {
- this.dispatchEvent(
- new CustomEvent('add', {
- detail: {value: e.detail.value},
- composed: true,
- bubbles: true,
- })
- );
+ private handleInputCommit(e: AutocompleteCommitEvent) {
+ fire(this, 'add', {value: e.detail.value});
this.input!.focus();
}
private inputTextChanged() {
if (this.inputText.length && this.allowAnyInput) {
- this.dispatchEvent(
- new CustomEvent('account-text-changed', {bubbles: true, composed: true})
- );
+ fire(this, 'account-text-changed', {});
}
}
@@ -138,4 +121,15 @@ declare global {
interface HTMLElementTagNameMap {
'gr-account-entry': GrAccountEntry;
}
+ interface HTMLElementEventMap {
+ /** Fired when an account is entered. */
+ // prettier-ignore
+ 'add': AddAccountEvent;
+ /**
+ * When allowAnyInput is true, account-text-changed is fired when input text
+ * changed. This is needed so that the reply dialog's save button can be
+ * enabled for arbitrary cc's, which don't need a 'commit'.
+ */
+ 'account-text-changed': CustomEvent<{}>;
+ }
}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index aa5fd58e1d..cf7ff22090 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -13,15 +13,16 @@ import {getDisplayName} from '../../../utils/display-name-util';
import {isSelf, isServiceUser} from '../../../utils/account-util';
import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {isInvolved} from '../../../utils/change-util';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
import {LitElement, css, html, TemplateResult} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
import {ifDefined} from 'lit/directives/if-defined.js';
import {createSearchUrl} from '../../../models/views/search';
+import {accountsModelToken} from '../../../models/accounts-model/accounts-model';
+import {resolve} from '../../../models/dependency';
@customElement('gr-account-label')
export class GrAccountLabel extends LitElement {
@@ -97,7 +98,7 @@ export class GrAccountLabel extends LitElement {
private readonly restApiService = getAppContext().restApiService;
- private readonly accountsModel = getAppContext().accountsModel;
+ private readonly getAccountsModel = resolve(this, accountsModelToken);
static override get styles() {
return [
@@ -190,7 +191,7 @@ export class GrAccountLabel extends LitElement {
override async updated() {
assertIsDefined(this.account, 'account');
- const account = await this.accountsModel.fillDetails(this.account);
+ const account = await this.getAccountsModel().fillDetails(this.account);
if (account) this.account = account;
}
@@ -362,16 +363,10 @@ export class GrAccountLabel extends LitElement {
e.stopPropagation();
if (!this.account._account_id) return;
- this.dispatchEvent(
- new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
- detail: {
- message: 'Saving attention set update ...',
- dismissOnNavigation: true,
- },
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'show-alert', {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ });
// We are deliberately updating the UI before making the API call. It is a
// risk that we are taking to achieve a better UX for 99.9% of the cases.
@@ -392,7 +387,7 @@ export class GrAccountLabel extends LitElement {
reason
)
.then(() => {
- fireEvent(this, 'hide-alert');
+ fire(this, 'hide-alert', {});
});
}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
index e7c0536cfa..71f8391f6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -88,7 +88,7 @@ suite('gr-account-label tests', () => {
/* HTML */ `
<div class="container">
<gr-hovercard-account for="hovercardTarget"></gr-hovercard-account>
- <a class="ownerLink" href="/q/owner:user-31%2540" tabindex="-1">
+ <a class="ownerLink" href="/q/owner:user-31@" tabindex="-1">
<span class="hovercardTargetWrapper">
<gr-avatar hidden="" imagesize="32"> </gr-avatar>
<span
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
index 0e5a8409c0..965a9c4ddf 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -16,7 +16,7 @@ import {
SuggestedReviewerInfo,
isGroup,
} from '../../../types/common';
-import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
import {fire, fireAlert} from '../../../utils/event-util';
@@ -42,6 +42,7 @@ import {difference, queryAndAssert} from '../../../utils/common-util';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {IronInputElement} from '@polymer/iron-input';
import {ReviewerState} from '../../../api/rest-api';
+import {repeat} from 'lit/directives/repeat.js';
const VALID_EMAIL_ALERT = 'Please input a valid email.';
const VALID_USER_GROUP_ALERT = 'Please input a valid user or group.';
@@ -122,7 +123,7 @@ export class GrAccountList extends LitElement {
constructor() {
super();
this.querySuggestions = input => this.getSuggestions(input);
- this.addEventListener('remove', e =>
+ this.addEventListener('remove-account', e =>
this.handleRemove(e as CustomEvent<{account: AccountInput}>)
);
}
@@ -156,7 +157,9 @@ export class GrAccountList extends LitElement {
override render() {
return html`<div class="list">
- ${this.accounts.map(
+ ${repeat(
+ this.accounts,
+ account => getUserId(account),
account => html`
<gr-account-chip
.account=${account}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 368c8da983..eaf897417d 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
@@ -22,7 +22,7 @@ import {
queryAndAssert,
waitUntil,
} from '../../../test/test-utils';
-import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReviewerSuggestionsProvider} from '../../../services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
import {
AutocompleteSuggestion,
GrAutocomplete,
@@ -31,7 +31,6 @@ import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
import {createChange} from '../../../test/test-data-generators';
import {ReviewerState} from '../../../api/rest-api';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
import {AccountInfoInput, RawAccountInput} from '../../../utils/account-util';
class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
@@ -151,7 +150,7 @@ suite('gr-account-list tests', () => {
// Removed accounts are taken out of the list.
element.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: existingAccount1},
composed: true,
bubbles: true,
@@ -165,14 +164,14 @@ suite('gr-account-list tests', () => {
// Invalid remove is ignored.
element.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: existingAccount1},
composed: true,
bubbles: true,
})
);
element.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: newAccount},
composed: true,
bubbles: true,
@@ -194,7 +193,7 @@ suite('gr-account-list tests', () => {
// Removed groups are taken out of the list.
element.dispatchEvent(
- new CustomEvent('remove', {
+ new CustomEvent('remove-account', {
detail: {account: newGroup},
composed: true,
bubbles: true,
@@ -289,7 +288,7 @@ suite('gr-account-list tests', () => {
test('addAccountItem with invalid item', () => {
const toastHandler = sinon.stub();
element.allowAnyInput = false;
- element.addEventListener(EventType.SHOW_ALERT, toastHandler);
+ element.addEventListener('show-alert', toastHandler);
const result = element.addAccountItem('test');
assert.isFalse(result);
assert.isTrue(toastHandler.called);
@@ -407,8 +406,8 @@ suite('gr-account-list tests', () => {
);
input.text = 'newTest';
input.input!.focus();
- input.noDebounce = true;
await element.updateComplete;
+ await input.latestSuggestionUpdateComplete;
assert.isTrue(getSuggestionsStub.calledOnce);
assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
await waitUntil(() => makeSuggestionItemSpy.getCalls().length === 2);
@@ -431,7 +430,7 @@ suite('gr-account-list tests', () => {
test('toasts on invalid email', () => {
const toastHandler = sinon.stub();
- element.addEventListener(EventType.SHOW_ALERT, toastHandler);
+ element.addEventListener('show-alert', toastHandler);
handleAdd('test');
assert.isTrue(toastHandler.called);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
index 9b80282b05..9342715ca4 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -5,7 +5,6 @@
*/
import '../gr-button/gr-button';
import '../../../styles/shared-styles';
-import {getRootElement} from '../../../scripts/rootElement';
import {ErrorType} from '../../../types/types';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
@@ -170,14 +169,14 @@ export class GrAlert extends LitElement {
this.actionText = actionText;
this._hideActionButton = !actionText;
this._actionCallback = actionCallback;
- getRootElement().appendChild(this);
+ document.body.appendChild(this);
this.shown = true;
}
hide() {
this.shown = false;
if (this._hasZeroTransitionDuration()) {
- getRootElement().removeChild(this);
+ document.body.removeChild(this);
}
}
@@ -197,7 +196,7 @@ export class GrAlert extends LitElement {
return;
}
- getRootElement().removeChild(this);
+ document.body.removeChild(this);
}
_handleActionTap(e: MouseEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
index 3fd1b82521..4b27948a3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -6,11 +6,12 @@
import '../gr-cursor-manager/gr-cursor-manager';
import '../../../styles/shared-styles';
import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {Key} from '../../../utils/dom-util';
import {FitController} from '../../lit/fit-controller';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
+import {when} from 'lit/directives/when.js';
import {repeat} from 'lit/directives/repeat.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {ShortcutController} from '../../lit/shortcut-controller';
@@ -19,6 +20,9 @@ declare global {
interface HTMLElementTagNameMap {
'gr-autocomplete-dropdown': GrAutocompleteDropdown;
}
+ interface HTMLElementEventMap {
+ 'dropdown-closed': CustomEvent<{}>;
+ }
}
export interface Item {
@@ -29,11 +33,21 @@ export interface Item {
value?: string;
}
-export interface ItemSelectedEvent {
+export interface ItemSelectedEventDetail {
trigger: string;
selected: HTMLElement | null;
}
+export enum AutocompleteQueryStatusType {
+ LOADING = 'loading',
+ ERROR = 'error',
+}
+
+export interface AutocompleteQueryStatus {
+ type: AutocompleteQueryStatusType;
+ message: string;
+}
+
@customElement('gr-autocomplete-dropdown')
export class GrAutocompleteDropdown extends LitElement {
/**
@@ -54,6 +68,12 @@ export class GrAutocompleteDropdown extends LitElement {
@property({type: Boolean, reflect: true, attribute: 'is-hidden'})
isHidden = true;
+ /** If specified a single non-interactable line is shown instead of
+ * suggestions.
+ */
+ @property({type: Object})
+ queryStatus?: AutocompleteQueryStatus;
+
@property({type: Number})
verticalOffset = 0;
@@ -79,6 +99,11 @@ export class GrAutocompleteDropdown extends LitElement {
css`
:host {
z-index: 100;
+ box-shadow: var(--elevation-level-2);
+ overflow: auto;
+ background: var(--dropdown-background-color);
+ border-radius: var(--border-radius);
+ max-height: 50vh;
}
:host([is-hidden]) {
display: none;
@@ -105,12 +130,13 @@ export class GrAutocompleteDropdown extends LitElement {
li.selected {
background-color: var(--hover-background-color);
}
- .dropdown-content {
- background: var(--dropdown-background-color);
- box-shadow: var(--elevation-level-2);
- border-radius: var(--border-radius);
- max-height: 50vh;
- overflow: auto;
+ li.query-status {
+ background-color: var(--disabled-background);
+ cursor: default;
+ }
+ li.query-status.error {
+ color: var(--error-foreground);
+ white-space: pre-wrap;
}
@media only screen and (max-height: 35em) {
.dropdown-content {
@@ -128,21 +154,25 @@ export class GrAutocompleteDropdown extends LitElement {
];
}
+ private isSuggestionListInteractible() {
+ return !this.isHidden && !this.queryStatus;
+ }
+
constructor() {
super();
this.cursor.cursorTargetClass = 'selected';
this.cursor.focusOnMove = true;
- this.shortcuts.addLocal({key: Key.UP}, () => this.handleUp());
- this.shortcuts.addLocal({key: Key.DOWN}, () => this.handleDown());
+ this.shortcuts.addLocal({key: Key.UP, allowRepeat: true}, () =>
+ this.cursorUp()
+ );
+ this.shortcuts.addLocal({key: Key.DOWN, allowRepeat: true}, () =>
+ this.cursorDown()
+ );
this.shortcuts.addLocal({key: Key.ENTER}, () => this.handleEnter());
this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEscape());
this.shortcuts.addLocal({key: Key.TAB}, () => this.handleTab());
}
- override connectedCallback() {
- super.connectedCallback();
- }
-
override disconnectedCallback() {
this.cursor.unsetCursor();
super.disconnectedCallback();
@@ -157,7 +187,8 @@ export class GrAutocompleteDropdown extends LitElement {
override updated(changedProperties: PropertyValues) {
if (
changedProperties.has('suggestions') ||
- changedProperties.has('isHidden')
+ changedProperties.has('isHidden') ||
+ changedProperties.has('queryStatus')
) {
if (!this.isHidden) {
this.computeCursorStopsAndRefit();
@@ -165,32 +196,50 @@ export class GrAutocompleteDropdown extends LitElement {
}
}
- override render() {
+ private renderStatus() {
return html`
- <div
- class="dropdown-content"
- slot="dropdown-content"
- id="suggestions"
- role="listbox"
+ <li
+ tabindex="-1"
+ aria-label="autocomplete query status"
+ class="query-status ${this.queryStatus?.type}"
>
+ <span>${this.queryStatus?.message}</span>
+ <span class="label"
+ >${this.queryStatus?.type === AutocompleteQueryStatusType.ERROR
+ ? 'ERROR'
+ : ''}</span
+ >
+ </li>
+ `;
+ }
+
+ override render() {
+ return html`
+ <div class="dropdown-content" id="suggestions" role="listbox">
<ul>
- ${repeat(
- this.suggestions,
- (item, index) => html`
- <li
- data-index=${index}
- data-value=${item.dataValue ?? ''}
- tabindex="-1"
- aria-label=${item.name ?? ''}
- class="autocompleteOption"
- role="option"
- @click=${this.handleClickItem}
- >
- <span>${item.text}</span>
- <span class="label ${this.computeLabelClass(item)}"
- >${item.label}</span
- >
- </li>
+ ${when(
+ this.queryStatus,
+ () => this.renderStatus(),
+ () => html`
+ ${repeat(
+ this.suggestions,
+ (item, index) => html`
+ <li
+ data-index=${index}
+ data-value=${item.dataValue ?? ''}
+ tabindex="-1"
+ aria-label=${item.name ?? ''}
+ class="autocompleteOption"
+ role="option"
+ @click=${this.handleClickItem}
+ >
+ <span>${item.text}</span>
+ <span class="label ${this.computeLabelClass(item)}"
+ >${item.label}</span
+ >
+ </li>
+ `
+ )}
`
)}
</ul>
@@ -207,55 +256,42 @@ export class GrAutocompleteDropdown extends LitElement {
}
getCurrentText() {
- return this.getCursorTarget()?.dataset['value'] || '';
+ if (!this.queryStatus) {
+ return this.getCursorTarget()?.dataset['value'] || '';
+ }
+ return '';
}
setPositionTarget(target: HTMLElement) {
- this.fitController?.setPositionTarget(target);
- }
-
- private handleUp() {
- if (!this.isHidden) this.cursorUp();
- }
-
- private handleDown() {
- if (!this.isHidden) this.cursorDown();
+ this.fitController.setPositionTarget(target);
}
cursorDown() {
- if (!this.isHidden) this.cursor.next();
+ if (this.isSuggestionListInteractible()) this.cursor.next();
}
cursorUp() {
- if (!this.isHidden) this.cursor.previous();
+ if (this.isSuggestionListInteractible()) this.cursor.previous();
}
// private but used in tests
handleTab() {
- this.dispatchEvent(
- new CustomEvent<ItemSelectedEvent>('item-selected', {
- detail: {
- trigger: 'tab',
- selected: this.cursor.target,
- },
- composed: true,
- bubbles: true,
- })
- );
+ if (this.isSuggestionListInteractible()) {
+ fire(this, 'item-selected', {
+ trigger: 'tab',
+ selected: this.cursor.target,
+ });
+ }
}
// private but used in tests
handleEnter() {
- this.dispatchEvent(
- new CustomEvent<ItemSelectedEvent>('item-selected', {
- detail: {
- trigger: 'enter',
- selected: this.cursor.target,
- },
- composed: true,
- bubbles: true,
- })
- );
+ if (this.isSuggestionListInteractible()) {
+ fire(this, 'item-selected', {
+ trigger: 'enter',
+ selected: this.cursor.target,
+ });
+ }
}
private handleEscape() {
@@ -273,20 +309,14 @@ export class GrAutocompleteDropdown extends LitElement {
}
selected = selected.parentElement!;
}
- this.dispatchEvent(
- new CustomEvent<ItemSelectedEvent>('item-selected', {
- detail: {
- trigger: 'click',
- selected,
- },
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'item-selected', {
+ trigger: 'click',
+ selected,
+ });
}
private fireClose() {
- fireEvent(this, 'dropdown-closed');
+ fire(this, 'dropdown-closed', {});
}
getCursorTarget() {
@@ -296,13 +326,13 @@ export class GrAutocompleteDropdown extends LitElement {
computeCursorStopsAndRefit() {
if (this.suggestions.length > 0) {
this.cursor.stops = Array.from(
- this.suggestionsDiv?.querySelectorAll('li') ?? []
+ this.suggestionsDiv?.querySelectorAll('li.autocompleteOption') ?? []
);
this.resetCursorIndex();
} else {
this.cursor.stops = [];
}
- this.fitController?.refit();
+ this.fitController.refit();
}
private setIndex() {
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 641dd2d72b..10ba5d0eba 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -5,7 +5,10 @@
*/
import '../../../test/common-test-setup';
import './gr-autocomplete-dropdown';
-import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import {
+ AutocompleteQueryStatusType,
+ GrAutocompleteDropdown,
+} from './gr-autocomplete-dropdown';
import {
pressKey,
queryAll,
@@ -18,160 +21,261 @@ import {fixture, html, assert} from '@open-wc/testing';
import {Key} from '../../../utils/dom-util';
suite('gr-autocomplete-dropdown', () => {
- let element: GrAutocompleteDropdown;
-
- const suggestionsEl = () => queryAndAssert(element, '#suggestions');
-
- setup(async () => {
- element = await fixture(
- html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
- );
- element.open();
- element.suggestions = [
- {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
- {dataValue: 'test value 2', name: 'test name 2', text: '2'},
- ];
- await waitEventLoop();
- });
+ suite('suggestion tests', () => {
+ let element: GrAutocompleteDropdown;
- teardown(() => {
- element.close();
- });
+ const suggestionsEl = () => queryAndAssert(element, '#suggestions');
- test('renders', () => {
- assert.shadowDom.equal(
- element,
- /* HTML */ `
- <div
- class="dropdown-content"
- id="suggestions"
- role="listbox"
- slot="dropdown-content"
- >
- <ul>
- <li
- aria-label="test name 1"
- class="autocompleteOption selected"
- data-index="0"
- data-value="test value 1"
- role="option"
- tabindex="-1"
- >
- <span> 1 </span>
- <span class="label"> hi </span>
- </li>
- <li
- aria-label="test name 2"
- class="autocompleteOption"
- data-index="1"
- data-value="test value 2"
- role="option"
- tabindex="-1"
- >
- <span> 2 </span>
- <span class="hide label"> </span>
- </li>
- </ul>
- </div>
- `
- );
- });
+ setup(async () => {
+ element = await fixture(
+ html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+ );
+ element.open();
+ element.suggestions = [
+ {
+ dataValue: 'test value 1',
+ name: 'test name 1',
+ text: '1',
+ label: 'hi',
+ },
+ {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+ ];
+ await waitEventLoop();
+ });
- test('shows labels', () => {
- const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
- assert.equal(els[0].innerText.trim(), '1\nhi');
- assert.equal(els[1].innerText.trim(), '2');
- });
+ teardown(() => {
+ element.close();
+ });
- test('escape key', async () => {
- const closeSpy = sinon.spy(element, 'close');
- pressKey(element, Key.ESC);
- await waitEventLoop();
- assert.isTrue(closeSpy.called);
- });
+ test('renders', () => {
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="dropdown-content" id="suggestions" role="listbox">
+ <ul>
+ <li
+ aria-label="test name 1"
+ class="autocompleteOption selected"
+ data-index="0"
+ data-value="test value 1"
+ role="option"
+ tabindex="-1"
+ >
+ <span> 1 </span>
+ <span class="label"> hi </span>
+ </li>
+ <li
+ aria-label="test name 2"
+ class="autocompleteOption"
+ data-index="1"
+ data-value="test value 2"
+ role="option"
+ tabindex="-1"
+ >
+ <span> 2 </span>
+ <span class="hide label"> </span>
+ </li>
+ </ul>
+ </div>
+ `
+ );
+ });
- test('tab key', () => {
- const handleTabSpy = sinon.spy(element, 'handleTab');
- const itemSelectedStub = sinon.stub();
- element.addEventListener('item-selected', itemSelectedStub);
- pressKey(element, Key.TAB);
- assert.isTrue(handleTabSpy.called);
- assert.equal(element.cursor.index, 0);
- assert.isTrue(itemSelectedStub.called);
- assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
- trigger: 'tab',
- selected: element.getCursorTarget(),
+ test('shows labels', () => {
+ const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
+ assert.equal(els[0].innerText.trim(), '1\nhi');
+ assert.equal(els[1].innerText.trim(), '2');
});
- });
- test('enter key', () => {
- const handleEnterSpy = sinon.spy(element, 'handleEnter');
- const itemSelectedStub = sinon.stub();
- element.addEventListener('item-selected', itemSelectedStub);
- pressKey(element, Key.ENTER);
- assert.isTrue(handleEnterSpy.called);
- assert.equal(element.cursor.index, 0);
- assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
- trigger: 'enter',
- selected: element.getCursorTarget(),
+ test('escape key close suggestions', async () => {
+ const closeSpy = sinon.spy(element, 'close');
+ pressKey(element, Key.ESC);
+ await waitEventLoop();
+ assert.isTrue(closeSpy.called);
});
- });
- test('down key', () => {
- element.isHidden = true;
- const nextSpy = sinon.spy(element.cursor, 'next');
- pressKey(element, 'ArrowDown');
- assert.isFalse(nextSpy.called);
- assert.equal(element.cursor.index, 0);
- element.isHidden = false;
- pressKey(element, 'ArrowDown');
- assert.isTrue(nextSpy.called);
- assert.equal(element.cursor.index, 1);
- });
+ test('tab key', () => {
+ const handleTabSpy = sinon.spy(element, 'handleTab');
+ const itemSelectedStub = sinon.stub();
+ element.addEventListener('item-selected', itemSelectedStub);
+ pressKey(element, Key.TAB);
+ assert.isTrue(handleTabSpy.called);
+ assert.equal(element.cursor.index, 0);
+ assert.isTrue(itemSelectedStub.called);
+ assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+ trigger: 'tab',
+ selected: element.getCursorTarget(),
+ });
+ });
- test('up key', () => {
- element.isHidden = true;
- const prevSpy = sinon.spy(element.cursor, 'previous');
- pressKey(element, 'ArrowUp');
- assert.isFalse(prevSpy.called);
- assert.equal(element.cursor.index, 0);
- element.isHidden = false;
- element.cursor.setCursorAtIndex(1);
- assert.equal(element.cursor.index, 1);
- pressKey(element, 'ArrowUp');
- assert.isTrue(prevSpy.called);
- assert.equal(element.cursor.index, 0);
- });
+ test('enter key', () => {
+ const handleEnterSpy = sinon.spy(element, 'handleEnter');
+ const itemSelectedStub = sinon.stub();
+ element.addEventListener('item-selected', itemSelectedStub);
+ pressKey(element, Key.ENTER);
+ assert.isTrue(handleEnterSpy.called);
+ assert.equal(element.cursor.index, 0);
+ assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+ trigger: 'enter',
+ selected: element.getCursorTarget(),
+ });
+ });
- test('tapping selects item', async () => {
- const itemSelectedStub = sinon.stub();
- element.addEventListener('item-selected', itemSelectedStub);
+ test('down key', () => {
+ element.isHidden = true;
+ const nextSpy = sinon.spy(element.cursor, 'next');
+ pressKey(element, 'ArrowDown');
+ assert.isFalse(nextSpy.called);
+ assert.equal(element.cursor.index, 0);
+ element.isHidden = false;
+ pressKey(element, 'ArrowDown');
+ assert.isTrue(nextSpy.called);
+ assert.equal(element.cursor.index, 1);
+ });
- suggestionsEl().querySelectorAll('li')[1].click();
- await waitEventLoop();
- assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
- trigger: 'click',
- selected: suggestionsEl().querySelectorAll('li')[1],
+ test('up key', () => {
+ element.isHidden = true;
+ const prevSpy = sinon.spy(element.cursor, 'previous');
+ pressKey(element, 'ArrowUp');
+ assert.isFalse(prevSpy.called);
+ assert.equal(element.cursor.index, 0);
+ element.isHidden = false;
+ element.cursor.setCursorAtIndex(1);
+ assert.equal(element.cursor.index, 1);
+ pressKey(element, 'ArrowUp');
+ assert.isTrue(prevSpy.called);
+ assert.equal(element.cursor.index, 0);
+ });
+
+ test('tapping selects item', async () => {
+ const itemSelectedStub = sinon.stub();
+ element.addEventListener('item-selected', itemSelectedStub);
+
+ suggestionsEl().querySelectorAll('li')[1].click();
+ await waitEventLoop();
+ assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+ trigger: 'click',
+ selected: suggestionsEl().querySelectorAll('li')[1],
+ });
+ });
+
+ test('tapping child still selects item', async () => {
+ const itemSelectedStub = sinon.stub();
+ element.addEventListener('item-selected', itemSelectedStub);
+ const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
+ ?.lastElementChild;
+ assertIsDefined(lastElChild);
+ (lastElChild as HTMLSpanElement).click();
+ await waitEventLoop();
+ assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+ trigger: 'click',
+ selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+ });
});
- });
- test('tapping child still selects item', async () => {
- const itemSelectedStub = sinon.stub();
- element.addEventListener('item-selected', itemSelectedStub);
- const lastElChild = queryAll<HTMLLIElement>(suggestionsEl(), 'li')[0]
- ?.lastElementChild;
- assertIsDefined(lastElChild);
- (lastElChild as HTMLSpanElement).click();
- await waitEventLoop();
- assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
- trigger: 'click',
- selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
+ test('updated suggestions resets cursor stops', async () => {
+ const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
+ element.suggestions = [];
+ await waitUntil(() => resetStopsSpy.called);
});
});
- test('updated suggestions resets cursor stops', async () => {
- const resetStopsSpy = sinon.spy(element, 'computeCursorStopsAndRefit');
- element.suggestions = [];
- await waitUntil(() => resetStopsSpy.called);
+ suite('status tests', () => {
+ let element: GrAutocompleteDropdown;
+
+ setup(async () => {
+ element = await fixture(
+ html`<gr-autocomplete-dropdown></gr-autocomplete-dropdown>`
+ );
+ element.open();
+ element.queryStatus = {
+ type: AutocompleteQueryStatusType.ERROR,
+ message: 'Failed query error',
+ };
+ await waitEventLoop();
+ });
+
+ teardown(() => {
+ element.close();
+ });
+
+ test('renders error', () => {
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="dropdown-content" id="suggestions" role="listbox">
+ <ul>
+ <li
+ aria-label="autocomplete query status"
+ class="query-status error"
+ tabindex="-1"
+ >
+ <span>Failed query error</span>
+ <span class="label">ERROR</span>
+ </li>
+ </ul>
+ </div>
+ `
+ );
+ });
+
+ test('renders loading', async () => {
+ element.queryStatus = {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ };
+ await waitEventLoop();
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="dropdown-content" id="suggestions" role="listbox">
+ <ul>
+ <li
+ aria-label="autocomplete query status"
+ class="query-status loading"
+ tabindex="-1"
+ >
+ <span>Loading...</span>
+ <span class="label"></span>
+ </li>
+ </ul>
+ </div>
+ `
+ );
+ });
+
+ test('escape key close dropdown with error', async () => {
+ const closeSpy = sinon.spy(element, 'close');
+ pressKey(element, Key.ESC);
+ await waitEventLoop();
+ assert.isTrue(closeSpy.called);
+ });
+
+ test('tab key when error shown sends no event', () => {
+ const handleTabSpy = sinon.spy(element, 'handleTab');
+ const itemSelectedStub = sinon.stub();
+ element.addEventListener('item-selected', itemSelectedStub);
+ pressKey(element, Key.TAB);
+ assert.isTrue(handleTabSpy.called);
+ assert.isFalse(itemSelectedStub.called);
+ });
+
+ test('enter key when error shown sends no event', () => {
+ const handleEnterSpy = sinon.spy(element, 'handleEnter');
+ const itemSelectedStub = sinon.stub();
+ element.addEventListener('item-selected', itemSelectedStub);
+ pressKey(element, Key.ENTER);
+ assert.isTrue(handleEnterSpy.called);
+ assert.isFalse(itemSelectedStub.called);
+ });
+
+ test('up/down disabled when error', () => {
+ const nextSpy = sinon.spy(element.cursor, 'next');
+ const prevSpy = sinon.spy(element.cursor, 'previous');
+ pressKey(element, 'ArrowUp');
+ pressKey(element, 'ArrowDown');
+ assert.isFalse(nextSpy.called);
+ assert.isFalse(prevSpy.called);
+ });
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
index d554f7ca6b..fd1311cb26 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -6,11 +6,19 @@
import '@polymer/paper-input/paper-input';
import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-icon/gr-icon';
import '../../../styles/shared-styles';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
-import {fire, fireEvent} from '../../../utils/event-util';
-import {debounce, DelayedTask} from '../../../utils/async-util';
+import {
+ AutocompleteQueryStatus,
+ AutocompleteQueryStatusType,
+ GrAutocompleteDropdown,
+ ItemSelectedEventDetail,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {fire} from '../../../utils/event-util';
+import {
+ debounce,
+ DelayedTask,
+ ResolvedDelayedTaskStatus,
+} from '../../../utils/async-util';
import {PropertyType} from '../../../types/common';
import {modifierPressed} from '../../../utils/dom-util';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -44,13 +52,6 @@ export interface AutocompleteSuggestion<T = string> {
text?: string;
}
-export interface AutocompleteCommitEventDetail {
- value: string;
-}
-
-export type AutocompleteCommitEvent =
- CustomEvent<AutocompleteCommitEventDetail>;
-
@customElement('gr-autocomplete')
export class GrAutocomplete extends LitElement {
/**
@@ -66,13 +67,6 @@ export class GrAutocomplete extends LitElement {
*/
/**
- * Fired on keydown to allow for custom hooks into autocomplete textbox
- * behavior.
- *
- * @event input-keydown
- */
-
- /**
* Query for requesting autocomplete suggestions. The function should
* accept the input as a string parameter and return a promise. The
* promise yields an array of suggestion objects with "name", "label",
@@ -81,6 +75,9 @@ export class GrAutocomplete extends LitElement {
* next to the "name" as label text. The "value" property will be emitted
* if that suggestion is selected.
*
+ * If query fails, the function should return rejected promise containing
+ * an Error. The "message" property will be shown in a dropdown instead of
+ * rendering suggestions.
*/
@property({type: Object})
query?: AutocompleteQuery = () => Promise.resolve([]);
@@ -105,9 +102,6 @@ export class GrAutocomplete extends LitElement {
@property({type: Boolean})
disabled = false;
- @property({type: Boolean, attribute: 'show-search-icon'})
- showSearchIcon = false;
-
/**
* Vertical offset needed for an element with 20px line-height, 4px
* padding and 1px border (30px height total). Plus 1px spacing between
@@ -151,12 +145,6 @@ export class GrAutocomplete extends LitElement {
@property({type: Boolean, attribute: 'warn-uncommitted'})
warnUncommitted = false;
- /**
- * When true, querying for suggestions is not debounced w/r/t keypresses
- */
- @property({type: Boolean, attribute: 'no-debounce'})
- noDebounce = false;
-
@property({type: Boolean, attribute: 'show-blue-focus-border'})
showBlueFocusBorder = false;
@@ -169,6 +157,8 @@ export class GrAutocomplete extends LitElement {
@state() suggestions: AutocompleteSuggestion[] = [];
+ @state() queryStatus?: AutocompleteQueryStatus;
+
@state() index: number | null = null;
// Enabled to suppress showing/updating suggestions when changing properties
@@ -180,8 +170,32 @@ export class GrAutocomplete extends LitElement {
@state() selected: HTMLElement | null = null;
+ /**
+ * The query id that status or suggestions correspond to.
+ */
+ private activeQueryId = 0;
+
+ /**
+ * Last scheduled update suggestions task.
+ */
private updateSuggestionsTask?: DelayedTask;
+ // Generate ids for scheduled suggestion queries to easily distinguish them.
+ private static NEXT_QUERY_ID = 1;
+
+ private static getNextQueryId() {
+ return GrAutocomplete.NEXT_QUERY_ID++;
+ }
+
+ /**
+ * @return Promise that resolves when suggestions are update.
+ */
+ get latestSuggestionUpdateComplete():
+ | Promise<ResolvedDelayedTaskStatus>
+ | undefined {
+ return this.updateSuggestionsTask?.promise;
+ }
+
get nativeInput() {
return (this.input!.inputElement as IronInputElement)
.inputElement as HTMLInputElement;
@@ -190,15 +204,6 @@ export class GrAutocomplete extends LitElement {
static override styles = [
sharedStyles,
css`
- .searchIcon {
- display: none;
- }
- .searchIcon.showSearchIcon {
- display: inline-block;
- }
- gr-icon {
- margin: 0 var(--spacing-xs);
- }
paper-input.borderless {
border: none;
padding: 0;
@@ -262,14 +267,13 @@ export class GrAutocomplete extends LitElement {
}
override willUpdate(changedProperties: PropertyValues) {
- if (
- changedProperties.has('text') ||
- changedProperties.has('threshold') ||
- changedProperties.has('noDebounce')
- ) {
+ if (changedProperties.has('text') || changedProperties.has('threshold')) {
this.updateSuggestions();
}
- if (changedProperties.has('suggestions')) {
+ if (
+ changedProperties.has('suggestions') ||
+ changedProperties.has('queryStatus')
+ ) {
this.updateDropdownVisibility();
}
if (changedProperties.has('text')) {
@@ -288,7 +292,7 @@ export class GrAutocomplete extends LitElement {
class=${this.computeClass()}
?disabled=${this.disabled}
.value=${this.text}
- @value-changed=${(e: CustomEvent) => {
+ @value-changed=${(e: ValueChangedEvent) => {
this.text = e.detail.value;
}}
.placeholder=${this.placeholder}
@@ -299,12 +303,7 @@ export class GrAutocomplete extends LitElement {
.label=${this.label}
>
<div slot="prefix">
- <gr-icon
- icon="search"
- class="searchIcon ${this.computeShowSearchIconClass(
- this.showSearchIcon
- )}"
- ></gr-icon>
+ <slot name="prefix"></slot>
</div>
<div slot="suffix">
@@ -317,6 +316,7 @@ export class GrAutocomplete extends LitElement {
@item-selected=${this.handleItemSelect}
@dropdown-closed=${this.focusWithoutDisplayingSuggestions}
.suggestions=${this.suggestions}
+ .queryStatus=${this.queryStatus}
role="listbox"
.index=${this.index}
>
@@ -353,14 +353,16 @@ export class GrAutocomplete extends LitElement {
this.text = '';
}
- private handleItemSelectEnter(e: CustomEvent | KeyboardEvent) {
+ private handleItemSelectEnter(
+ e: CustomEvent<ItemSelectedEventDetail> | KeyboardEvent
+ ) {
this.handleInputCommit();
e.stopPropagation();
e.preventDefault();
this.focusWithoutDisplayingSuggestions();
}
- handleItemSelect(e: CustomEvent) {
+ handleItemSelect(e: CustomEvent<ItemSelectedEventDetail>) {
if (e.detail.trigger === 'click') {
this.selected = e.detail.selected;
this._commit();
@@ -413,17 +415,12 @@ export class GrAutocomplete extends LitElement {
}
updateSuggestions() {
- if (
- this.text === undefined ||
- this.threshold === undefined ||
- this.noDebounce === undefined
- )
- return;
+ if (this.text === undefined || this.threshold === undefined) return;
// Reset suggestions for every update
// This will also prevent from carrying over suggestions:
// @see Issue 12039
- this.suggestions = [];
+ this.resetQueryOutput();
// TODO(taoalpha): Also skip if text has not changed
@@ -431,8 +428,7 @@ export class GrAutocomplete extends LitElement {
return;
}
- const query = this.query;
- if (!query) {
+ if (!this.query) {
return;
}
@@ -445,32 +441,55 @@ export class GrAutocomplete extends LitElement {
return;
}
- const requestText = this.text;
- const update = () => {
- query(this.text).then(suggestions => {
- if (requestText !== this.text) {
- // Late response.
- return;
- }
- for (const suggestion of suggestions) {
- suggestion.text = suggestion?.name ?? '';
- }
- this.suggestions = suggestions;
- if (this.index === -1) {
- this.value = '';
+ const queryId = GrAutocomplete.getNextQueryId();
+ this.activeQueryId = queryId;
+ this.setQueryStatus({
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+ this.updateSuggestionsTask = debounce(
+ this.updateSuggestionsTask,
+ this.createUpdateTask(queryId, this.query, this.text),
+ DEBOUNCE_WAIT_MS
+ );
+ }
+
+ private createUpdateTask(
+ queryId: number,
+ query: AutocompleteQuery,
+ text: string
+ ): () => Promise<void> {
+ return async () => {
+ let suggestions: AutocompleteSuggestion[];
+ try {
+ suggestions = await query(text);
+ } catch (e) {
+ this.value = '';
+ if (typeof e === 'string') {
+ this.setQueryStatus({
+ type: AutocompleteQueryStatusType.ERROR,
+ message: e,
+ });
+ } else if (e instanceof Error) {
+ this.setQueryStatus({
+ type: AutocompleteQueryStatusType.ERROR,
+ message: e.message,
+ });
}
- });
+ return;
+ }
+ if (queryId !== this.activeQueryId) {
+ // Late response.
+ return;
+ }
+ for (const suggestion of suggestions) {
+ suggestion.text = suggestion?.name ?? '';
+ }
+ this.setSuggestions(suggestions);
+ if (this.index === -1) {
+ this.value = '';
+ }
};
-
- if (this.noDebounce) {
- update();
- } else {
- this.updateSuggestionsTask = debounce(
- this.updateSuggestionsTask,
- update,
- DEBOUNCE_WAIT_MS
- );
- }
}
setFocus(focused: boolean) {
@@ -479,8 +498,12 @@ export class GrAutocomplete extends LitElement {
this.updateDropdownVisibility();
}
+ private shouldShowDropdown() {
+ return (this.suggestions.length > 0 || this.queryStatus) && this.focused;
+ }
+
updateDropdownVisibility() {
- if (this.suggestions.length > 0 && this.focused) {
+ if (this.shouldShowDropdown()) {
this.suggestionsDropdown?.open();
return;
}
@@ -513,10 +536,26 @@ export class GrAutocomplete extends LitElement {
this.cancel();
break;
case 'Tab':
- if (this.suggestions.length > 0 && this.tabComplete) {
+ if (
+ this.queryStatus?.type === AutocompleteQueryStatusType.LOADING &&
+ this.tabComplete
+ ) {
+ e.preventDefault();
+ // Queue tab on load.
+ this.queryStatus = {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading... (Handle Tab on load)',
+ };
+ const queryId = this.activeQueryId;
+ this.latestSuggestionUpdateComplete?.then(() => {
+ if (queryId === this.activeQueryId) {
+ this.handleInputCommit(/* _tabComplete=*/ true);
+ }
+ });
+ } else if (this.suggestions.length > 0 && this.tabComplete) {
e.preventDefault();
+ this.handleInputCommit(/* _tabComplete=*/ true);
this.focus();
- this.handleInputCommit(true);
} else {
this.setFocus(false);
}
@@ -525,11 +564,24 @@ export class GrAutocomplete extends LitElement {
if (modifierPressed(e)) {
break;
}
- if (this.suggestions.length > 0) {
+ e.preventDefault();
+ if (this.queryStatus?.type === AutocompleteQueryStatusType.LOADING) {
+ // Queue enter on load.
+ this.queryStatus = {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading... (Handle Enter on load)',
+ };
+ const queryId = this.activeQueryId;
+ this.latestSuggestionUpdateComplete?.then(() => {
+ if (queryId === this.activeQueryId) {
+ this.handleItemSelectEnter(e);
+ }
+ });
+ } else if (this.suggestions.length > 0) {
// If suggestions are shown, act as if the keypress is in dropdown.
+ // suggestions length is 0 if error is shown.
this.handleItemSelectEnter(e);
} else {
- e.preventDefault();
this.handleInputCommit();
}
break;
@@ -542,29 +594,29 @@ export class GrAutocomplete extends LitElement {
// been based on a previous input. Clear them. This prevents an
// outdated suggestion from being used if the input keystroke is
// immediately followed by a commit keystroke. @see Issue 8655
- this.suggestions = [];
+ this.resetQueryOutput();
+ this.activeQueryId = 0;
}
- this.dispatchEvent(
- new CustomEvent('input-keydown', {
- detail: {key: e.key, input: this.input},
- composed: true,
- bubbles: true,
- })
- );
}
cancel() {
- if (this.suggestions.length) {
- this.suggestions = [];
+ if (this.shouldShowDropdown()) {
+ this.resetQueryOutput();
+ // If query is in flight by setting id to 0 we indicate that the results
+ // are outdated.
+ this.activeQueryId = 0;
this.requestUpdate();
} else {
- fireEvent(this, 'cancel');
+ fire(this, 'cancel', {});
}
}
handleInputCommit(_tabComplete?: boolean) {
- // Nothing to do if the dropdown is not open.
- if (!this.allowNonSuggestedValues && this.suggestionsDropdown?.isHidden) {
+ // Nothing to do if no suggestions.
+ if (
+ !this.allowNonSuggestedValues &&
+ (this.suggestionsDropdown?.isHidden || this.suggestions.length === 0)
+ ) {
return;
}
@@ -605,6 +657,7 @@ export class GrAutocomplete extends LitElement {
}
}
this.setFocus(false);
+ this.activeQueryId = 0;
};
/**
@@ -641,23 +694,30 @@ export class GrAutocomplete extends LitElement {
}
}
- this.suggestions = [];
+ this.resetQueryOutput();
// we need willUpdate to send text-changed event before we can send the
// 'commit' event
await this.updateComplete;
if (!silent) {
- this.dispatchEvent(
- new CustomEvent('commit', {
- detail: {value} as AutocompleteCommitEventDetail,
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'commit', {value});
}
}
- computeShowSearchIconClass(showSearchIcon: boolean) {
- return showSearchIcon ? 'showSearchIcon' : '';
+ // resetQueryOutput, setSuggestions and setQueryStatus insure that suggestions
+ // and queryStatus are never set at the same time.
+ private resetQueryOutput() {
+ this.suggestions = [];
+ this.queryStatus = undefined;
+ }
+
+ private setSuggestions(suggestions: AutocompleteSuggestion[]) {
+ this.suggestions = suggestions;
+ this.queryStatus = undefined;
+ }
+
+ private setQueryStatus(queryStatus: AutocompleteQueryStatus) {
+ this.suggestions = [];
+ this.queryStatus = queryStatus;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
index d99118064a..0cef331de5 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -6,8 +6,17 @@
import '../../../test/common-test-setup';
import './gr-autocomplete';
import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
-import {pressKey, queryAndAssert, waitUntil} from '../../../test/test-utils';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+ assertFails,
+ mockPromise,
+ pressKey,
+ queryAndAssert,
+ waitUntil,
+} from '../../../test/test-utils';
+import {
+ AutocompleteQueryStatusType,
+ GrAutocompleteDropdown,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {fixture, html, assert} from '@open-wc/testing';
import {Key, Modifier} from '../../../utils/dom-util';
@@ -25,9 +34,7 @@ suite('gr-autocomplete tests', () => {
const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
setup(async () => {
- element = await fixture(
- html`<gr-autocomplete no-debounce></gr-autocomplete>`
- );
+ element = await fixture(html`<gr-autocomplete></gr-autocomplete>`);
});
test('renders', () => {
@@ -41,7 +48,7 @@ suite('gr-autocomplete tests', () => {
tabindex="0"
>
<div slot="prefix">
- <gr-icon icon="search" class="searchIcon"></gr-icon>
+ <slot name="prefix"> </slot>
</div>
<div slot="suffix">
<slot name="suffix"> </slot>
@@ -91,7 +98,46 @@ suite('gr-autocomplete tests', () => {
tabindex="0"
>
<div slot="prefix">
- <gr-icon icon="search" class="searchIcon"></gr-icon>
+ <slot name="prefix"> </slot>
+ </div>
+ <div slot="suffix">
+ <slot name="suffix"> </slot>
+ </div>
+ </paper-input>
+ <gr-autocomplete-dropdown id="suggestions" role="listbox">
+ </gr-autocomplete-dropdown>
+ `,
+ {
+ // gr-autocomplete-dropdown sizing seems to vary between local & CI
+ ignoreAttributes: [
+ {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
+ ],
+ }
+ );
+ });
+
+ test('renders with error', async () => {
+ const queryStub = sinon.spy((input: string) =>
+ Promise.reject(new Error(`${input} not allowed`))
+ );
+ element.query = queryStub;
+
+ focusOnInput();
+ element.text = 'blah';
+ await waitUntil(() => queryStub.called);
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <paper-input
+ aria-disabled="false"
+ autocomplete="off"
+ id="input"
+ tabindex="0"
+ >
+ <div slot="prefix">
+ <slot name="prefix"> </slot>
</div>
<div slot="suffix">
<slot name="suffix"> </slot>
@@ -107,6 +153,10 @@ suite('gr-autocomplete tests', () => {
],
}
);
+ assert.equal(
+ element.suggestionsDropdown?.queryStatus?.message,
+ 'blah not allowed'
+ );
});
test('cursor starts on suggestions', async () => {
@@ -181,17 +231,52 @@ suite('gr-autocomplete tests', () => {
});
});
- test('emits commit and handles cursor movement', async () => {
+ test('esc key behavior on error', async () => {
let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
const queryStub = sinon.spy(
- (input: string) =>
- (promise = Promise.resolve([
- {name: input + ' 0', value: '0'},
- {name: input + ' 1', value: '1'},
- {name: input + ' 2', value: '2'},
- {name: input + ' 3', value: '3'},
- {name: input + ' 4', value: '4'},
- ] as AutocompleteSuggestion[]))
+ (_: string) => (promise = Promise.reject(new Error('Test error')))
+ );
+ element.query = queryStub;
+
+ assert.isTrue(suggestionsEl().isHidden);
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+
+ return assertFails(promise).then(async () => {
+ await element.latestSuggestionUpdateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+
+ const cancelHandler = sinon.spy();
+ element.addEventListener('cancel', cancelHandler);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.ERROR,
+ message: 'Test error',
+ });
+
+ pressKey(inputEl(), Key.ESC);
+ await waitUntil(() => suggestionsEl().isHidden);
+
+ assert.isFalse(cancelHandler.called);
+ assert.isUndefined(element.queryStatus);
+
+ pressKey(inputEl(), Key.ESC);
+ await element.updateComplete;
+
+ assert.isTrue(cancelHandler.called);
+ });
+ });
+
+ test('emits commit and handles cursor movement', async () => {
+ const queryStub = sinon.spy((input: string) =>
+ Promise.resolve([
+ {name: input + ' 0', value: '0'},
+ {name: input + ' 1', value: '1'},
+ {name: input + ' 2', value: '2'},
+ {name: input + ' 3', value: '3'},
+ {name: input + ' 4', value: '4'},
+ ] as AutocompleteSuggestion[])
);
element.query = queryStub;
await element.updateComplete;
@@ -202,7 +287,7 @@ suite('gr-autocomplete tests', () => {
element.text = 'blah';
await element.updateComplete;
- return promise.then(async () => {
+ return element.latestSuggestionUpdateComplete!.then(async () => {
await waitUntil(() => !suggestionsEl().isHidden);
const commitHandler = sinon.spy();
@@ -313,7 +398,6 @@ suite('gr-autocomplete tests', () => {
element.query = queryStub;
await element.updateComplete;
- element.noDebounce = false;
focusOnInput();
element.text = 'a';
@@ -335,7 +419,6 @@ suite('gr-autocomplete tests', () => {
test('empty text results in no suggestions', async () => {
element.text = '';
element.threshold = 0;
- element.noDebounce = false;
await element.updateComplete;
assert.equal(element.suggestions.length, 0);
});
@@ -380,29 +463,48 @@ suite('gr-autocomplete tests', () => {
});
test('suggestions should not carry over', async () => {
- let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
const queryStub = sinon
.stub()
- .returns(
- (promise = Promise.resolve([
- {name: 'suggestion', value: '0'},
- ] as AutocompleteSuggestion[]))
- );
+ .resolves([{name: 'suggestion', value: '0'}] as AutocompleteSuggestion[]);
element.query = queryStub;
focusOnInput();
element.text = 'bla';
await element.updateComplete;
- return promise.then(async () => {
+ return element.latestSuggestionUpdateComplete!.then(async () => {
await waitUntil(() => element.suggestions.length > 0);
assert.equal(element.suggestions.length, 1);
+
+ queryStub.resolves([] as AutocompleteSuggestion[]);
element.text = '';
element.threshold = 0;
- element.noDebounce = false;
await element.updateComplete;
+ await element.latestSuggestionUpdateComplete;
assert.equal(element.suggestions.length, 0);
});
});
+ test('error should not carry over', async () => {
+ let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+ const queryStub = sinon
+ .stub()
+ .returns((promise = Promise.reject(new Error('Test error'))));
+ element.query = queryStub;
+ focusOnInput();
+ element.text = 'bla';
+ await element.updateComplete;
+ return assertFails(promise).then(async () => {
+ await element.latestSuggestionUpdateComplete;
+ await waitUntil(() => element.queryStatus?.message === 'Test error');
+
+ queryStub.resolves([] as AutocompleteSuggestion[]);
+ element.text = '';
+ element.threshold = 0;
+ await element.updateComplete;
+ await element.latestSuggestionUpdateComplete;
+ assert.isUndefined(element.queryStatus);
+ });
+ });
+
test('multi completes only the last part of the query', async () => {
let promise;
const queryStub = sinon
@@ -421,6 +523,7 @@ suite('gr-autocomplete tests', () => {
return promise.then(async () => {
const commitHandler = sinon.spy();
element.addEventListener('commit', commitHandler);
+ await element.latestSuggestionUpdateComplete;
await waitUntil(() => element.suggestionsDropdown?.isHidden === false);
pressKey(inputEl(), Key.ENTER);
@@ -431,15 +534,24 @@ suite('gr-autocomplete tests', () => {
});
test('tabComplete flag functions', async () => {
+ element.query = sinon
+ .stub()
+ .resolves([
+ {name: 'tunnel snakes rule!', value: 'snakes'},
+ ] as AutocompleteSuggestion[]);
+
// commitHandler checks for the commit event, whereas commitSpy checks for
// the _commit function of the element.
const commitHandler = sinon.spy();
element.addEventListener('commit', commitHandler);
const commitSpy = sinon.spy(element, '_commit');
element.setFocus(true);
-
- element.suggestions = [{text: 'tunnel snakes rule!', name: ''}];
element.tabComplete = false;
+ element.text = 'text1';
+ await element.updateComplete;
+
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
pressKey(inputEl(), Key.TAB);
await element.updateComplete;
@@ -447,9 +559,12 @@ suite('gr-autocomplete tests', () => {
assert.isFalse(commitSpy.called);
assert.isFalse(element.focused);
+ element.setFocus(true);
element.tabComplete = true;
+ element.text = 'text2';
await element.updateComplete;
- element.setFocus(true);
+
+ await element.latestSuggestionUpdateComplete;
await element.updateComplete;
pressKey(inputEl(), Key.TAB);
@@ -466,20 +581,6 @@ suite('gr-autocomplete tests', () => {
assert.isTrue(element.focused);
});
- test('search icon shows with showSearchIcon property', async () => {
- assert.equal(
- getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
- 'none'
- );
- element.showSearchIcon = true;
- await element.updateComplete;
-
- assert.notEqual(
- getComputedStyle(queryAndAssert(element, 'gr-icon')).display,
- 'none'
- );
- });
-
test('vertical offset overridden by param if it exists', async () => {
assert.equal(suggestionsEl().verticalOffset, 31);
@@ -504,7 +605,7 @@ suite('gr-autocomplete tests', () => {
test(
'handleInputCommit with autocomplete hidden does nothing without' +
- 'without allowNonSuggestedValues',
+ ' allowNonSuggestedValues',
() => {
const commitStub = sinon.stub(element, '_commit');
suggestionsEl().isHidden = true;
@@ -514,6 +615,21 @@ suite('gr-autocomplete tests', () => {
);
test(
+ 'handleInputCommit with query error does nothing without' +
+ ' allowNonSuggestedValues',
+ () => {
+ const commitStub = sinon.stub(element, '_commit');
+ element.queryStatus = {
+ type: AutocompleteQueryStatusType.ERROR,
+ message: 'Error',
+ };
+ element.suggestions = [];
+ element.handleInputCommit();
+ assert.isFalse(commitStub.called);
+ }
+ );
+
+ test(
'handleInputCommit with autocomplete hidden with' +
'allowNonSuggestedValues',
() => {
@@ -525,9 +641,25 @@ suite('gr-autocomplete tests', () => {
}
);
+ test(
+ 'handleInputCommit with query error with' + 'allowNonSuggestedValues',
+ () => {
+ const commitStub = sinon.stub(element, '_commit');
+ element.allowNonSuggestedValues = true;
+ element.queryStatus = {
+ type: AutocompleteQueryStatusType.ERROR,
+ message: 'Error',
+ };
+ element.suggestions = [];
+ element.handleInputCommit();
+ assert.isTrue(commitStub.called);
+ }
+ );
+
test('handleInputCommit with autocomplete open calls commit', () => {
const commitStub = sinon.stub(element, '_commit');
suggestionsEl().isHidden = false;
+ element.suggestions = [{name: 'first suggestion'}];
element.handleInputCommit();
assert.isTrue(commitStub.calledOnce);
});
@@ -570,6 +702,215 @@ suite('gr-autocomplete tests', () => {
assert.equal(element.text, 'file:x');
});
+ test('render loading replace with suggestions when done', async () => {
+ const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+ element.query = (_: string) => queryPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
+
+ assert.equal(element.suggestions.length, 1);
+ assert.isUndefined(element.queryStatus);
+ });
+
+ test('render loading replace with error when done', async () => {
+ const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+ element.query = (_: string) => queryPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ queryPromise.reject(new Error('Test error'));
+ await assertFails(queryPromise);
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
+
+ assert.equal(element.suggestions.length, 0);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.ERROR,
+ message: 'Test error',
+ });
+ });
+
+ test('render loading esc cancels', async () => {
+ const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+ element.query = (_: string) => queryPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ const cancelHandler = sinon.spy();
+ element.addEventListener('cancel', cancelHandler);
+ pressKey(inputEl(), Key.ESC);
+ await waitUntil(() => suggestionsEl().isHidden);
+
+ assert.isFalse(cancelHandler.called);
+ assert.isUndefined(element.queryStatus);
+
+ pressKey(inputEl(), Key.ESC);
+ await element.updateComplete;
+
+ assert.isTrue(cancelHandler.called);
+ });
+
+ test('while loading queue enter commits', async () => {
+ const commitHandler = sinon.stub();
+ element.addEventListener('commit', commitHandler);
+ let resolvePromise: (value: AutocompleteSuggestion[]) => void;
+ const blockingPromise = new Promise<AutocompleteSuggestion[]>(resolve => {
+ resolvePromise = resolve;
+ });
+ element.query = (_: string) => blockingPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ pressKey(inputEl(), Key.ENTER);
+ await element.updateComplete;
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading... (Handle Enter on load)',
+ });
+
+ resolvePromise!([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
+
+ assert.equal(element.suggestions.length, 0);
+ assert.isUndefined(element.queryStatus);
+ assert.isTrue(commitHandler.called);
+ });
+
+ test('while loading queue tab completes', async () => {
+ element.tabComplete = true;
+ const commitHandler = sinon.stub();
+ element.addEventListener('commit', commitHandler);
+ const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+ element.query = (_: string) => queryPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ pressKey(inputEl(), Key.TAB);
+ await element.updateComplete;
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading... (Handle Tab on load)',
+ });
+
+ queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
+
+ assert.equal(element.suggestions.length, 0);
+ assert.isUndefined(element.queryStatus);
+ assert.isFalse(commitHandler.called);
+ assert.equal(element.text, 'suggestion 1');
+ });
+
+ test('while loading and queued update text cancels', async () => {
+ const commitHandler = sinon.stub();
+ element.addEventListener('commit', commitHandler);
+ const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+ element.query = (_: string) => queryPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ pressKey(inputEl(), Key.ENTER);
+ await element.updateComplete;
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading... (Handle Enter on load)',
+ });
+
+ element.text = 'more blah';
+ await element.updateComplete;
+
+ queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
+
+ // Commit for stale request is not called.
+ assert.isFalse(commitHandler.called);
+ });
+
+ test('while loading and queued esc cancels', async () => {
+ const commitHandler = sinon.stub();
+ element.addEventListener('commit', commitHandler);
+ const queryPromise = mockPromise<AutocompleteSuggestion[]>();
+ element.query = (_: string) => queryPromise;
+
+ element.setFocus(true);
+ element.text = 'blah';
+ await element.updateComplete;
+ await waitUntil(() => !suggestionsEl().isHidden);
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading...',
+ });
+
+ pressKey(inputEl(), Key.ENTER);
+ await element.updateComplete;
+ assert.deepEqual(element.queryStatus, {
+ type: AutocompleteQueryStatusType.LOADING,
+ message: 'Loading... (Handle Enter on load)',
+ });
+
+ pressKey(inputEl(), Key.ESC);
+ await element.updateComplete;
+
+ queryPromise.resolve([{name: 'suggestion 1'}] as AutocompleteSuggestion[]);
+ await element.latestSuggestionUpdateComplete;
+ await element.updateComplete;
+
+ // Commit for stale request is not called.
+ assert.isFalse(commitHandler.called);
+ // Query results and status are cleared
+ assert.equal(element.suggestions.length, 0);
+ assert.isUndefined(element.queryStatus);
+ });
+
suite('focus', () => {
let commitSpy: sinon.SinonSpy;
let focusSpy: sinon.SinonSpy;
@@ -587,6 +928,24 @@ suite('gr-autocomplete tests', () => {
await element.updateComplete;
assert.equal(element.suggestions.length, 0);
+ assert.isUndefined(element.queryStatus);
+ assert.isTrue(suggestionsEl().isHidden);
+ });
+
+ test('enter in input does not re-render error', async () => {
+ element.allowNonSuggestedValues = true;
+ element.queryStatus = {
+ type: AutocompleteQueryStatusType.ERROR,
+ message: 'Error message',
+ };
+
+ pressKey(inputEl(), Key.ENTER);
+
+ await waitUntil(() => commitSpy.called);
+ await element.updateComplete;
+
+ assert.equal(element.suggestions.length, 0);
+ assert.isUndefined(element.queryStatus);
assert.isTrue(suggestionsEl().isHidden);
});
@@ -704,27 +1063,14 @@ suite('gr-autocomplete tests', () => {
});
});
- test('input-keydown event fired', async () => {
- const listener = sinon.spy();
- element.addEventListener('input-keydown', listener);
- pressKey(inputEl(), Key.TAB);
- await element.updateComplete;
- assert.isTrue(listener.called);
- });
-
test('enter with modifier does not complete', async () => {
- const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
const commitStub = sinon.stub(element, 'handleInputCommit');
+
pressKey(inputEl(), Key.ENTER, Modifier.CTRL_KEY);
await element.updateComplete;
- assert.equal(dispatchEventStub.lastCall.args[0].type, 'input-keydown');
- assert.equal(
- (dispatchEventStub.lastCall.args[0] as CustomEvent).detail.key,
- Key.ENTER
- );
-
assert.isFalse(commitStub.called);
+
pressKey(inputEl(), Key.ENTER);
await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
index 4fa716b92c..1bfb55bb0a 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar-stack.ts
@@ -7,7 +7,10 @@ import './gr-avatar';
import {AccountInfo} from '../../../types/common';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
-import {uniqueDefinedAvatar} from '../../../utils/account-util';
+import {
+ uniqueAccountId,
+ uniqueDefinedAvatar,
+} from '../../../utils/account-util';
import {resolve} from '../../../models/dependency';
import {configModelToken} from '../../../models/config/config-model';
import {subscribe} from '../../lit/subscription-controller';
@@ -39,6 +42,15 @@ export class GrAvatarStack extends LitElement {
imageSize = 16;
/**
+ * In gr-app, gr-account-chip is in charge of loading a full account, so
+ * avatars will be set. However, code-owners will create gr-avatars with a
+ * bare account-id. To enable fetching of those avatars, a flag is added to
+ * gr-avatar that will disregard the absence of avatar urls.
+ */
+ @property({type: Boolean})
+ forceFetch = false;
+
+ /**
* Reflects plugins.has_avatars value of server configuration.
*/
@state() private hasAvatars = false;
@@ -74,9 +86,11 @@ export class GrAvatarStack extends LitElement {
}
override render() {
- const uniqueAvatarAccounts = this.accounts
- .filter(account => !!account?.avatars?.[0]?.url)
- .filter(uniqueDefinedAvatar);
+ const uniqueAvatarAccounts = this.forceFetch
+ ? this.accounts.filter(uniqueAccountId)
+ : this.accounts
+ .filter(account => !!account?.avatars?.[0]?.url)
+ .filter(uniqueDefinedAvatar);
if (
!this.hasAvatars ||
uniqueAvatarAccounts.length === 0 ||
@@ -86,7 +100,11 @@ export class GrAvatarStack extends LitElement {
}
return uniqueAvatarAccounts.map(
account =>
- html`<gr-avatar .account=${account} .imageSize=${this.imageSize}>
+ html`<gr-avatar
+ .forceFetch=${this.forceFetch}
+ .account=${account}
+ .imageSize=${this.imageSize}
+ >
</gr-avatar>`
);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
index b34724c76c..8cfe2d0291 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {getBaseUrl} from '../../../utils/url-util';
-import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
import {AccountInfo} from '../../../types/common';
import {getAppContext} from '../../../services/app-context';
import {LitElement, css, html} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
+import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
+import {resolve} from '../../../models/dependency';
/**
* The <gr-avatar> component works by updating its own background and visibility
@@ -24,8 +25,18 @@ export class GrAvatar extends LitElement {
@state() private hasAvatars = false;
+ // In gr-app, gr-account-chip is in charge of loading a full account, so
+ // avatars will be set. However, code-owners will create gr-avatars with a
+ // bare account-id. To enable fetching of those avatars, a flag is added to
+ // gr-avatar that will disregard the absence of avatar urls.
+
+ @property({type: Boolean})
+ forceFetch = false;
+
private readonly restApiService = getAppContext().restApiService;
+ private readonly getPluginLoader = resolve(this, pluginLoaderToken);
+
static override get styles() {
return [
css`
@@ -54,7 +65,7 @@ export class GrAvatar extends LitElement {
super.connectedCallback();
Promise.all([
this.restApiService.getConfig(),
- getPluginLoader().awaitPluginsLoaded(),
+ this.getPluginLoader().awaitPluginsLoaded(),
]).then(([cfg]) => {
this.hasAvatars = Boolean(cfg?.plugin?.has_avatars);
this.updateHostVisibilityAndImage();
@@ -87,7 +98,7 @@ export class GrAvatar extends LitElement {
const avatars = account.avatars || [];
// if there is no avatar url in account, there is no avatar set on server,
// and request /avatar?s will be 404.
- if (avatars.length === 0) {
+ if (avatars.length === 0 && !this.forceFetch) {
return '';
}
for (let i = 0; i < avatars.length; i++) {
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
index f33f7d8382..b44a16bb04 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -11,6 +11,7 @@ import {customElement, property} from 'lit/decorators.js';
import {addShortcut, getEventPath, Key} from '../../../utils/dom-util';
import {getAppContext} from '../../../services/app-context';
import {classMap} from 'lit/directives/class-map.js';
+import {Interaction} from '../../../constants/reporting';
declare global {
interface HTMLElementTagNameMap {
@@ -115,23 +116,6 @@ export class GrButton extends LitElement {
var(--background-color);
}
- /* Some mobile browsers treat focused element as hovered element.
- As a result, element remains hovered after click (has grey background in default theme).
- Use @media (hover:none) to remove background if
- user's primary input mechanism can't hover over elements.
- See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
-
- Note 1: not all browsers support this media query
- (see https://caniuse.com/#feat=css-media-interaction).
- If browser doesn't support it, then the whole content of @media .. is ignored.
- This is why the default behavior is placed outside of @media.
- */
- @media (hover: none) {
- paper-button:hover {
- background: transparent;
- }
- }
-
:host([primary]) {
--background-color: var(--primary-button-background-color);
--text-color: var(--primary-button-text-color);
@@ -245,6 +229,8 @@ export class GrButton extends LitElement {
return;
}
- this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
+ this.reporting.reportInteraction(Interaction.BUTTON_CLICK, {
+ path: getEventPath(e),
+ });
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 0bb451c60f..54fb825853 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -14,6 +14,7 @@ import {customElement, property} from 'lit/decorators.js';
import {resolve} from '../../../models/dependency';
import {shortcutsServiceToken} from '../../../services/shortcuts/shortcuts-service';
import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -93,12 +94,6 @@ export class GrChangeStar extends LitElement {
change: this.change,
starred: newVal,
};
- this.dispatchEvent(
- new CustomEvent('toggle-star', {
- bubbles: true,
- composed: true,
- detail,
- })
- );
+ fire(this, 'toggle-star', detail);
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index d2b9e2d67a..c93cc97e36 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -6,26 +6,11 @@
import '../gr-icon/gr-icon';
import '../gr-tooltip-content/gr-tooltip-content';
import '../../../styles/shared-styles';
-import {ChangeInfo} from '../../../types/common';
-import {ParsedChangeInfo} from '../../../types/types';
+import {ChangeInfo, ChangeStates, WebLinkInfo} from '../../../types/common';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {createSearchUrl} from '../../../models/views/search';
-import {GeneratedWebLink} from '../../../utils/weblink-util';
-
-export enum ChangeStates {
- ABANDONED = 'Abandoned',
- ACTIVE = 'Active',
- MERGE_CONFLICT = 'Merge Conflict',
- GIT_CONFLICT = 'Git Conflict',
- MERGED = 'Merged',
- PRIVATE = 'Private',
- READY_TO_SUBMIT = 'Ready to submit',
- REVERT_CREATED = 'Revert Created',
- REVERT_SUBMITTED = 'Revert Submitted',
- WIP = 'WIP',
-}
export const WIP_TOOLTIP =
"This change isn't ready to be reviewed or submitted. " +
@@ -50,9 +35,6 @@ export class GrChangeStatus extends LitElement {
@property({type: Boolean, reflect: true})
flat = false;
- @property({type: Object})
- change?: ChangeInfo | ParsedChangeInfo;
-
@property({type: String})
status?: ChangeStates;
@@ -64,7 +46,7 @@ export class GrChangeStatus extends LitElement {
revertedChange?: ChangeInfo;
@property({type: Object})
- resolveWeblinks?: GeneratedWebLink[] = [];
+ resolveWeblinks?: WebLinkInfo[] = [];
static override get styles() {
return [
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
index 4a046e70d7..89d30ee977 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -9,10 +9,11 @@ import {
TEST_NUMERIC_CHANGE_ID,
} from '../../../test/test-data-generators';
import './gr-change-status';
-import {ChangeStates, GrChangeStatus, WIP_TOOLTIP} from './gr-change-status';
+import {GrChangeStatus, WIP_TOOLTIP} from './gr-change-status';
import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
import {fixture, html, assert} from '@open-wc/testing';
import {queryAndAssert} from '../../../test/test-utils';
+import {ChangeStates} from '../../../types/common';
const PRIVATE_TOOLTIP =
'This change is only visible to its owner and ' +
@@ -79,7 +80,7 @@ suite('gr-change-status tests', () => {
);
assert.equal(element.tooltipText, '');
assert.isTrue(element.classList.contains('merged'));
- element.resolveWeblinks = [{url: 'http://google.com'}];
+ element.resolveWeblinks = [{name: 'browse', url: 'http://google.com'}];
element.status = ChangeStates.MERGED;
assert.isFalse(element.showResolveIcon());
});
@@ -116,7 +117,7 @@ suite('gr-change-status tests', () => {
test('merge conflict with resolve link', () => {
const status = ChangeStates.MERGE_CONFLICT;
const url = 'http://google.com';
- const weblinks = [{url}];
+ const weblinks = [{name: 'browse', url}];
element.revertedChange = undefined;
element.resolveWeblinks = weblinks;
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
index 9dfbc0431b..c76f04cb76 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -19,18 +19,11 @@ import {
} from 'lit/decorators.js';
import {
computeDiffFromContext,
- isDraft,
- isRobot,
- Comment,
- CommentThread,
getLastComment,
- UnsavedInfo,
- isDraftOrUnsaved,
- createUnsavedComment,
getFirstComment,
- createUnsavedReply,
- isUnsaved,
+ createNewReply,
NEWLINE_PATTERN,
+ id,
} from '../../../utils/comment-util';
import {ChangeMessageId} from '../../../api/rest-api';
import {getAppContext} from '../../../services/app-context';
@@ -41,7 +34,11 @@ import {
import {computeDisplayPath} from '../../../utils/path-list-util';
import {
AccountDetailInfo,
+ Comment,
CommentRange,
+ CommentThread,
+ isDraft,
+ isRobot,
NumericChangeId,
RepoName,
UrlEncodedCommentId,
@@ -51,7 +48,11 @@ import {FILE} from '../../../embed/diff/gr-diff/gr-diff-line';
import {GrButton} from '../gr-button/gr-button';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {DiffLayer, RenderPreferences} from '../../../api/diff';
-import {assertIsDefined, copyToClipbard} from '../../../utils/common-util';
+import {
+ assert,
+ assertIsDefined,
+ copyToClipbard,
+} from '../../../utils/common-util';
import {fire} from '../../../utils/event-util';
import {GrSyntaxLayerWorker} from '../../../embed/diff/gr-syntax-layer/gr-syntax-layer-worker';
import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
@@ -70,10 +71,9 @@ import {resolve} from '../../../models/dependency';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {changeModelToken} from '../../../models/change/change-model';
import {whenRendered} from '../../../utils/dom-util';
-import {Interaction} from '../../../constants/reporting';
-import {HtmlPatched} from '../../../utils/lit-util';
-import {createDiffUrl} from '../../../models/views/diff';
-import {createChangeUrl} from '../../../models/views/change';
+import {createChangeUrl, createDiffUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {highlightServiceToken} from '../../../services/highlight/highlight-service';
declare global {
interface HTMLElementEventMap {
@@ -129,13 +129,14 @@ export class GrCommentThread extends LitElement {
thread?: CommentThread;
/**
- * Id of the first comment and thus must not change. Will be derived from
+ * Id of the first comment, must not change. Will be derived from
* the `thread` property in the first willUpdate() cycle.
*
* The `rootId` property is also used in gr-diff for maintaining lists and
* maps of threads and their associated elements.
*
- * Only stays `undefined` for new threads that only have an unsaved comment.
+ * For newly created threads in this session the `client_id` property of the
+ * first comment will be used instead of the `id` property.
*/
@property({type: String})
rootId?: UrlEncodedCommentId;
@@ -191,15 +192,6 @@ export class GrCommentThread extends LitElement {
@property({type: Boolean, attribute: 'false'})
editing = false;
- /**
- * This can either be an unsaved reply to the last comment or the unsaved
- * content of a brand new comment thread (then `comments` is empty).
- * If set, then `thread.comments` must not contain a draft. A thread can only
- * contain *either* an unsaved comment *or* a draft, not both.
- */
- @state()
- unsavedComment?: UnsavedInfo;
-
@state()
changeNum?: NumericChangeId;
@@ -247,28 +239,18 @@ export class GrCommentThread extends LitElement {
@state()
saving = false;
- // Private but used in tests.
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
private readonly getChangeModel = resolve(this, changeModelToken);
- private readonly userModel = getAppContext().userModel;
-
- private readonly reporting = getAppContext().reportingService;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly shortcuts = new ShortcutController(this);
- private readonly syntaxLayer = new GrSyntaxLayerWorker();
-
- // for COMMENTS_AUTOCLOSE logging purposes only
- readonly uid = performance.now().toString(36) + Math.random().toString(36);
-
- private readonly patched = new HtmlPatched(key => {
- this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, {
- component: this.tagName,
- key: key.substring(0, 300),
- });
- });
+ private readonly syntaxLayer = new GrSyntaxLayerWorker(
+ resolve(this, highlightServiceToken),
+ () => getAppContext().reportingService
+ );
constructor() {
super();
@@ -281,7 +263,7 @@ export class GrCommentThread extends LitElement {
);
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
x => (this.account = x)
);
subscribe(
@@ -291,12 +273,12 @@ export class GrCommentThread extends LitElement {
);
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
x => this.syntaxLayer.setEnabled(!!x.syntax_highlighting)
);
subscribe(
this,
- () => this.userModel.preferences$,
+ () => this.getUserModel().preferences$,
prefs => {
const layers: DiffLayer[] = [this.syntaxLayer];
if (!prefs.disable_token_highlighting) {
@@ -307,7 +289,7 @@ export class GrCommentThread extends LitElement {
);
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
prefs => {
this.prefs = {
...prefs,
@@ -319,15 +301,6 @@ export class GrCommentThread extends LitElement {
);
}
- override disconnectedCallback() {
- if (this.editing) {
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_EDITING_THREAD_DISCONNECTED
- );
- }
- super.disconnectedCallback();
- }
-
static override get styles() {
return [
a11yStyles,
@@ -497,52 +470,53 @@ export class GrCommentThread extends LitElement {
renderComments() {
assertIsDefined(this.thread, 'thread');
const publishedComments = repeat(
- this.thread.comments.filter(c => !isDraftOrUnsaved(c)),
+ this.thread.comments.filter(c => !isDraft(c)),
comment => comment.id,
comment => this.renderComment(comment)
);
// We are deliberately not including the draft in the repeat directive,
// because we ran into spurious issues with <gr-comment> being destroyed
// and re-created when an unsaved draft transitions to 'saved' state.
- const draftComment = this.renderComment(this.getDraftOrUnsaved());
+ // TODO: Revisit this, because this transition should not cause issues
+ // anymore. Just put the draft into the `repeat` directive above and
+ // then use `id()` instead of `.id` above.
+ const draftComment = this.renderComment(this.getDraft());
return html`${publishedComments}${draftComment}`;
}
private renderComment(comment?: Comment) {
if (!comment) return nothing;
- const robotButtonDisabled = !this.account || this.isDraftOrUnsaved();
+ const robotButtonDisabled = !this.account || this.isDraft();
+ const isFirstComment = this.getFirstComment() === comment;
const initiallyCollapsed =
- !isDraftOrUnsaved(comment) &&
+ !isDraft(comment) &&
(this.messageId
? comment.change_message_id !== this.messageId
: !this.unresolved);
- return this.patched.html`
+ return html`
<gr-comment
.comment=${comment}
.comments=${this.thread!.comments}
?initially-collapsed=${initiallyCollapsed}
?robot-button-disabled=${robotButtonDisabled}
?show-patchset=${this.showPatchset}
- ?show-ported-comment=${
- this.showPortedComment && comment.id === this.rootId
- }
+ ?show-ported-comment=${this.showPortedComment && isFirstComment}
@reply-to-comment=${this.handleReplyToComment}
@copy-comment-link=${this.handleCopyLink}
@comment-editing-changed=${(
e: CustomEvent<CommentEditingChangedDetail>
) => {
- if (isDraftOrUnsaved(comment)) this.editing = e.detail.editing;
+ if (isDraft(comment)) this.editing = e.detail.editing;
}}
@comment-unresolved-changed=${(e: ValueChangedEvent<boolean>) => {
- if (isDraftOrUnsaved(comment)) this.unresolved = e.detail.value;
+ if (isDraft(comment)) this.unresolved = e.detail.value;
}}
></gr-comment>
`;
}
renderActions() {
- if (!this.account || this.isDraftOrUnsaved() || this.isRobotComment())
- return;
+ if (!this.account || this.isDraft() || this.isRobotComment()) return;
return html`
<div id="actionsContainer">
<span id="unresolvedLabel">${
@@ -634,9 +608,6 @@ export class GrCommentThread extends LitElement {
if (this.firstWillUpdateDone) return;
this.firstWillUpdateDone = true;
- if (this.getFirstComment() === undefined) {
- this.unsavedComment = createUnsavedComment(this.thread);
- }
this.unresolved = this.getLastComment()?.unresolved ?? true;
this.diff = this.computeDiff();
this.highlightRange = this.computeHighlightRange();
@@ -645,28 +616,18 @@ export class GrCommentThread extends LitElement {
override willUpdate(changed: PropertyValues) {
this.firstWillUpdate();
if (changed.has('thread')) {
- if (!this.isDraftOrUnsaved()) {
+ assertIsDefined(this.thread, 'thread');
+ assertIsDefined(this.getFirstComment(), 'first comment');
+ if (!this.isDraft()) {
// We can only do this for threads without draft, because otherwise we
// are relying on the <gr-comment> component for the draft to fire
// events about the *dirty* `unresolved` state.
this.unresolved = this.getLastComment()?.unresolved ?? true;
}
- this.hasDraft = this.isDraftOrUnsaved();
- this.rootId = this.getFirstComment()?.id;
- if (this.isDraft()) {
- this.unsavedComment = undefined;
- }
+ this.hasDraft = this.isDraft();
+ this.rootId = id(this.getFirstComment()!);
}
if (changed.has('editing')) {
- // changed.get('editing') contains the old value. We only want to trigger
- // when changing from editing to non-editing (user has cancelled/saved).
- // We do *not* want to trigger on first render (old value is `null`)
- if (!this.editing && changed.get('editing') === true) {
- this.unsavedComment = undefined;
- if (this.thread?.comments.length === 0) {
- this.remove();
- }
- }
fire(this, 'comment-thread-editing-changed', {value: this.editing});
}
}
@@ -691,24 +652,11 @@ export class GrCommentThread extends LitElement {
return isDraft(this.getLastComment());
}
- private isDraftOrUnsaved(): boolean {
- return this.isDraft() || this.isUnsaved();
- }
-
- private getDraftOrUnsaved(): Comment | undefined {
- if (this.unsavedComment) return this.unsavedComment;
+ private getDraft(): Comment | undefined {
if (this.isDraft()) return this.getLastComment();
return undefined;
}
- private isNewThread(): boolean {
- return this.thread?.comments.length === 0;
- }
-
- private isUnsaved(): boolean {
- return !!this.unsavedComment || this.thread?.comments.length === 0;
- }
-
private isPatchsetLevel() {
return this.thread?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
}
@@ -738,12 +686,11 @@ export class GrCommentThread extends LitElement {
if (!this.changeNum || !this.repoName || !this.thread?.path) {
return undefined;
}
- if (this.isNewThread()) return undefined;
return createDiffUrl({
changeNum: this.changeNum,
- project: this.repoName,
- path: this.thread.path,
+ repo: this.repoName,
patchNum: this.thread.patchNum,
+ diffView: {path: this.thread.path},
});
}
@@ -764,14 +711,12 @@ export class GrCommentThread extends LitElement {
// Does not work for patchset level comments
private getUrlForFileComment() {
- if (!this.repoName || !this.changeNum || this.isNewThread()) {
- return undefined;
- }
- assertIsDefined(this.rootId, 'rootId of comment thread');
+ const id = this.getFirstComment()?.id;
+ if (!id || !this.repoName || !this.changeNum) return undefined;
return createDiffUrl({
changeNum: this.changeNum,
- project: this.repoName,
- commentId: this.rootId,
+ repo: this.repoName,
+ commentId: id,
});
}
@@ -784,13 +729,13 @@ export class GrCommentThread extends LitElement {
if (this.isPatchsetLevel()) {
url = createChangeUrl({
changeNum: this.changeNum,
- project: this.repoName,
+ repo: this.repoName,
commentId: comment.id,
});
} else {
url = createDiffUrl({
changeNum: this.changeNum,
- project: this.repoName,
+ repo: this.repoName,
commentId: comment.id,
});
}
@@ -848,19 +793,14 @@ export class GrCommentThread extends LitElement {
const replyingTo = this.getLastComment();
assertIsDefined(this.thread, 'thread');
assertIsDefined(replyingTo, 'the comment that the user wants to reply to');
- if (isDraft(replyingTo)) {
- throw new Error('cannot reply to draft');
- }
- if (isUnsaved(replyingTo)) {
- throw new Error('cannot reply to unsaved comment');
- }
- const unsaved = createUnsavedReply(replyingTo, content, unresolved);
+ assert(!isDraft(replyingTo), 'cannot reply to draft');
+ const newReply = createNewReply(replyingTo, content, unresolved);
if (userWantsToEdit) {
- this.unsavedComment = unsaved;
+ this.getCommentsModel().addNewDraft(newReply);
} else {
try {
this.saving = true;
- await this.getCommentsModel().saveDraft(unsaved);
+ await this.getCommentsModel().saveDraft(newReply);
} finally {
this.saving = false;
}
@@ -880,7 +820,7 @@ export class GrCommentThread extends LitElement {
}
private handleCommentAck() {
- this.createReplyComment('Ack', false, false);
+ this.createReplyComment('Acknowledged', false, false);
}
private handleCommentDone() {
@@ -896,7 +836,7 @@ export class GrCommentThread extends LitElement {
const author = this.getFirstComment()?.author ?? this.account;
const user = getUserName(undefined, author);
const unresolvedStatus = this.unresolved ? 'Unresolved ' : '';
- const draftStatus = this.isDraftOrUnsaved() ? 'Draft ' : '';
+ const draftStatus = this.isDraft() ? 'Draft ' : '';
return `${unresolvedStatus}${draftStatus}Comment thread by ${user}`;
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
index 0a3df6da38..1027a623a5 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.ts
@@ -5,7 +5,7 @@
*/
import '../../../test/common-test-setup';
import './gr-comment-thread';
-import {DraftInfo, sortComments} from '../../../utils/comment-util';
+import {sortComments} from '../../../utils/comment-util';
import {GrCommentThread} from './gr-comment-thread';
import {
NumericChangeId,
@@ -13,6 +13,8 @@ import {
Timestamp,
CommentInfo,
RepoName,
+ DraftInfo,
+ SavingState,
} from '../../../types/common';
import {
mockPromise,
@@ -24,21 +26,27 @@ import {
import {
createAccountDetailWithId,
createThread,
+ createNewDraft,
} from '../../../test/test-data-generators';
-import {SinonStub} from 'sinon';
-import {fixture, html, waitUntil, assert} from '@open-wc/testing';
+import {SinonStubbedMember} from 'sinon';
+import {fixture, html, assert} from '@open-wc/testing';
import {GrButton} from '../gr-button/gr-button';
import {SpecialFilePath} from '../../../constants/constants';
import {GrIcon} from '../gr-icon/gr-icon';
+import {
+ CommentsModel,
+ commentsModelToken,
+} from '../../../models/comments/comments-model';
+import {testResolver} from '../../../test/common-test-setup';
-const c1 = {
+const c1: CommentInfo = {
author: {name: 'Kermit'},
id: 'the-root' as UrlEncodedCommentId,
message: 'start the conversation',
updated: '2021-11-01 10:11:12.000000000' as Timestamp,
};
-const c2 = {
+const c2: CommentInfo = {
author: {name: 'Ms Piggy'},
id: 'the-reply' as UrlEncodedCommentId,
message: 'keep it going',
@@ -46,13 +54,13 @@ const c2 = {
in_reply_to: 'the-root' as UrlEncodedCommentId,
};
-const c3 = {
+const c3: DraftInfo = {
author: {name: 'Kermit'},
id: 'the-draft' as UrlEncodedCommentId,
message: 'stop it',
updated: '2021-11-03 10:11:12.000000000' as Timestamp,
in_reply_to: 'the-reply' as UrlEncodedCommentId,
- __draft: true,
+ savingState: SavingState.OK,
};
const commentWithContext = {
@@ -120,23 +128,23 @@ suite('gr-comment-thread tests', () => {
});
test('renders unsaved', async () => {
- element.thread = createThread();
+ element.thread = createThread(createNewDraft());
await element.updateComplete;
assert.shadowDom.equal(
element,
/* HTML */ `
<div class="fileName">
- <span>test-path-comment-thread</span>
+ <a href="/c/test-repo-name/+/1/1/test-path-comment-thread">
+ test-path-comment-thread
+ </a>
<gr-copy-clipboard hideinput=""></gr-copy-clipboard>
</div>
<div class="pathInfo">
<span>#314</span>
</div>
<div id="container">
- <h3 class="assistive-tech-only">
- Unresolved Draft Comment thread by Yoda
- </h3>
- <div class="comment-box unresolved" tabindex="0">
+ <h3 class="assistive-tech-only">Draft Comment thread by Yoda</h3>
+ <div class="comment-box" tabindex="0">
<gr-comment robot-button-disabled="" show-patchset=""></gr-comment>
</div>
</div>
@@ -307,23 +315,25 @@ suite('gr-comment-thread tests', () => {
suite('action button clicks', () => {
let savePromise: MockPromise<DraftInfo>;
- let stub: SinonStub;
+ let stubSave: SinonStubbedMember<CommentsModel['saveDraft']>;
+ let stubAdd: SinonStubbedMember<CommentsModel['addNewDraft']>;
setup(async () => {
savePromise = mockPromise<DraftInfo>();
- stub = sinon
- .stub(element.getCommentsModel(), 'saveDraft')
+ stubSave = sinon
+ .stub(testResolver(commentsModelToken), 'saveDraft')
.returns(savePromise);
+ stubAdd = sinon.stub(testResolver(commentsModelToken), 'addNewDraft');
element.thread = createThread(c1, {...c2, unresolved: true});
await element.updateComplete;
});
- test('handle Ack', async () => {
+ test('handle Acknowledge', async () => {
queryAndAssert<GrButton>(element, '#ackBtn').click();
- waitUntilCalled(stub, 'saveDraft()');
- assert.equal(stub.lastCall.firstArg.message, 'Ack');
- assert.equal(stub.lastCall.firstArg.unresolved, false);
+ waitUntilCalled(stubSave, 'saveDraft()');
+ assert.equal(stubSave.lastCall.firstArg.message, 'Acknowledged');
+ assert.equal(stubSave.lastCall.firstArg.unresolved, false);
assert.isTrue(element.saving);
savePromise.resolve();
@@ -333,47 +343,23 @@ suite('gr-comment-thread tests', () => {
test('handle Done', async () => {
queryAndAssert<GrButton>(element, '#doneBtn').click();
- waitUntilCalled(stub, 'saveDraft()');
- assert.equal(stub.lastCall.firstArg.message, 'Done');
- assert.equal(stub.lastCall.firstArg.unresolved, false);
+ waitUntilCalled(stubSave, 'saveDraft()');
+ assert.equal(stubSave.lastCall.firstArg.message, 'Done');
+ assert.equal(stubSave.lastCall.firstArg.unresolved, false);
});
test('handle Reply', async () => {
- assert.isUndefined(element.unsavedComment);
+ assert.equal(element.thread?.comments.length, 2);
queryAndAssert<GrButton>(element, '#replyBtn').click();
- assert.equal(element.unsavedComment?.message, '');
+ assert.isTrue(stubAdd.called);
+ assert.equal(stubAdd.lastCall.firstArg.message, '');
});
test('handle Quote', async () => {
- assert.isUndefined(element.unsavedComment);
+ assert.equal(element.thread?.comments.length, 2);
queryAndAssert<GrButton>(element, '#quoteBtn').click();
- assert.equal(element.unsavedComment?.message?.trim(), `> ${c2.message}`);
- });
- });
-
- suite('self removal when empty thread changed to editing:false', () => {
- let threadEl: GrCommentThread;
-
- setup(async () => {
- threadEl = await fixture(html`<gr-comment-thread></gr-comment-thread>`);
- threadEl.thread = createThread();
- });
-
- test('new thread el normally has a parent and an unsaved comment', async () => {
- await waitUntil(() => threadEl.editing);
- assert.isOk(threadEl.unsavedComment);
- assert.isOk(threadEl.parentElement);
- });
-
- test('thread el removed after clicking CANCEL', async () => {
- await waitUntil(() => threadEl.editing);
-
- const commentEl = queryAndAssert(threadEl, 'gr-comment');
- const buttonEl = queryAndAssert<GrButton>(commentEl, 'gr-button.cancel');
- buttonEl.click();
-
- await waitUntil(() => !threadEl.editing);
- assert.isNotOk(threadEl.parentElement);
+ assert.isTrue(stubAdd.called);
+ assert.equal(stubAdd.lastCall.firstArg.message.trim(), `> ${c2.message}`);
});
});
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 9e8264c4ec..27a5590307 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -3,7 +3,6 @@
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
@@ -11,7 +10,6 @@ import '../gr-button/gr-button';
import '../gr-dialog/gr-dialog';
import '../gr-formatted-text/gr-formatted-text';
import '../gr-icon/gr-icon';
-import '../gr-overlay/gr-overlay';
import '../gr-textarea/gr-textarea';
import '../gr-tooltip-content/gr-tooltip-content';
import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
@@ -21,24 +19,26 @@ import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {resolve} from '../../../models/dependency';
import {GrTextarea} from '../gr-textarea/gr-textarea';
-import {GrOverlay} from '../gr-overlay/gr-overlay';
import {
AccountDetailInfo,
+ DraftInfo,
NumericChangeId,
RepoName,
RobotCommentInfo,
+ Comment,
+ isRobot,
+ isSaving,
+ isError,
+ isDraft,
+ isNew,
} from '../../../types/common';
import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
import {
- Comment,
createUserFixSuggestion,
- DraftInfo,
getContentInCommentRange,
getUserSuggestion,
hasUserSuggestion,
- isDraftOrUnsaved,
- isRobot,
- isUnsaved,
+ id,
NEWLINE_PATTERN,
USER_SUGGESTION_START_PATTERN,
} from '../../../utils/comment-util';
@@ -47,9 +47,9 @@ import {
ReplyToCommentEventDetail,
ValueChangedEvent,
} from '../../../types/events';
-import {fire, fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {assertIsDefined, assert} from '../../../utils/common-util';
-import {Key, Modifier} from '../../../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
import {commentsModelToken} from '../../../models/comments/comments-model';
import {sharedStyles} from '../../../styles/shared-styles';
import {subscribe} from '../../lit/subscription-controller';
@@ -60,20 +60,17 @@ import {CommentSide, SpecialFilePath} from '../../../constants/constants';
import {Subject} from 'rxjs';
import {debounceTime} from 'rxjs/operators';
import {changeModelToken} from '../../../models/change/change-model';
-import {Interaction} from '../../../constants/reporting';
import {KnownExperimentId} from '../../../services/flags/flags';
import {isBase64FileContent} from '../../../api/rest-api';
-import {createDiffUrl} from '../../../models/views/diff';
-
-const UNSAVED_MESSAGE = 'Unable to save draft';
+import {createDiffUrl} from '../../../models/views/change';
+import {userModelToken} from '../../../models/user/user-model';
+import {modalStyles} from '../../../styles/gr-modal-styles';
const FILE = 'FILE';
// visible for testing
export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
-export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
-
declare global {
interface HTMLElementEventMap {
'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
@@ -128,8 +125,11 @@ export class GrComment extends LitElement {
@query('#resolvedCheckbox')
resolvedCheckbox?: HTMLInputElement;
- @query('#confirmDeleteOverlay')
- confirmDeleteOverlay?: GrOverlay;
+ @query('#confirmDeleteModal')
+ confirmDeleteModal?: HTMLDialogElement;
+
+ @query('#confirmDeleteCommentDialog')
+ confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
@property({type: Object})
comment?: Comment;
@@ -165,21 +165,11 @@ export class GrComment extends LitElement {
@property({type: String})
messagePlaceholder?: string;
- /* private, but used in css rules */
- @property({type: Boolean, reflect: true})
- saving = false;
-
// GrReplyDialog requires the patchset level comment to always remain
// editable.
@property({type: Boolean, attribute: 'permanent-editing-mode'})
permanentEditingMode = false;
- /**
- * `saving` and `autoSaving` are separate and cannot be set at the same time.
- * `saving` affects the UI state (disabled buttons, etc.) and eventually
- * leaves editing mode, but `autoSaving` just happens in the background
- * without the user noticing.
- */
@state()
autoSaving?: Promise<DraftInfo>;
@@ -200,12 +190,6 @@ export class GrComment extends LitElement {
@state()
unresolved = true;
- @property({type: Boolean})
- showConfirmDeleteOverlay = false;
-
- @property({type: Boolean})
- unableToSave = false;
-
@property({type: Boolean, attribute: 'show-patchset'})
showPatchset = false;
@@ -229,10 +213,9 @@ export class GrComment extends LitElement {
private readonly getChangeModel = resolve(this, changeModelToken);
- // Private but used in tests.
- readonly getCommentsModel = resolve(this, commentsModelToken);
+ private readonly getCommentsModel = resolve(this, commentsModelToken);
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private readonly shortcuts = new ShortcutController(this);
@@ -277,17 +260,18 @@ export class GrComment extends LitElement {
this.save();
});
}
- if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- this.messagePlaceholder = 'Mention others with @';
- }
+ this.addEventListener('open-user-suggest-preview', e => {
+ this.handleShowFix(e.detail.code);
+ });
+ this.messagePlaceholder = 'Mention others with @';
subscribe(
this,
- () => this.userModel.account$,
+ () => this.getUserModel().account$,
x => (this.account = x)
);
subscribe(
this,
- () => this.userModel.isAdmin$,
+ () => this.getUserModel().isAdmin$,
x => (this.isAdmin = x)
);
@@ -319,17 +303,13 @@ export class GrComment extends LitElement {
override disconnectedCallback() {
// Clean up emoji dropdown.
if (this.textarea) this.textarea.closeDropdown();
- if (this.editing) {
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
- );
- }
super.disconnectedCallback();
}
static override get styles() {
return [
sharedStyles,
+ modalStyles,
css`
:host {
display: block;
@@ -339,13 +319,9 @@ export class GrComment extends LitElement {
:host([collapsed]) {
padding: var(--spacing-s) var(--spacing-m);
}
- :host([saving]) {
- pointer-events: none;
- }
- :host([saving]) .actions,
- :host([saving]) .robotActions,
- :host([saving]) .date {
- opacity: 0.5;
+ :host([error]) {
+ background-color: var(--error-background);
+ border-radius: var(--border-radius);
}
.header {
align-items: center;
@@ -500,19 +476,37 @@ export class GrComment extends LitElement {
margin-left: var(--spacing-m);
cursor: pointer;
}
+ .suggestEdit {
+ /** same height as header */
+ --margin: calc(0px - var(--spacing-s));
+ margin-right: var(--spacing-s);
+ }
+ .suggestEdit gr-icon {
+ color: inherit;
+ margin-right: var(--spacing-s);
+ }
`,
];
}
override render() {
- if (isUnsaved(this.comment) && !this.editing) return;
- const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
+ if (!this.comment) return;
+ this.toggleAttribute('saving', isSaving(this.comment));
+ this.toggleAttribute('error', isError(this.comment));
+ const classes = {
+ container: true,
+ draft: isDraft(this.comment),
+ };
return html`
<gr-endpoint-decorator name="comment">
<gr-endpoint-param name="comment" .value=${this.comment}>
</gr-endpoint-param>
<gr-endpoint-param name="editing" .value=${this.editing}>
</gr-endpoint-param>
+ <gr-endpoint-param name="message" .value=${this.messageText}>
+ </gr-endpoint-param>
+ <gr-endpoint-param name="isDraft" .value=${isDraft(this.comment)}>
+ </gr-endpoint-param>
<div id="container" class=${classMap(classes)}>
${this.renderHeader()}
<div class="body">
@@ -520,7 +514,6 @@ export class GrComment extends LitElement {
${this.renderCommentMessage()}
<gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
${this.renderHumanActions()} ${this.renderRobotActions()}
- ${this.renderSuggestEditActions()}
</div>
</div>
</gr-endpoint-decorator>
@@ -541,24 +534,21 @@ export class GrComment extends LitElement {
${this.renderDraftLabel()}
</div>
<div class="headerMiddle">${this.renderCollapsedContent()}</div>
- ${this.renderRunDetails()} ${this.renderDeleteButton()}
- ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
+ ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
+ ${this.renderDeleteButton()} ${this.renderPatchset()}
+ ${this.renderSeparator()} ${this.renderDate()} ${this.renderToggle()}
</div>
`;
}
private renderAuthor() {
- if (isDraftOrUnsaved(this.comment)) return;
+ if (isDraft(this.comment)) return;
if (isRobot(this.comment)) {
const id = this.comment.robot_id;
return html`<span class="robotName">${id}</span>`;
}
- const classes = {draft: isDraftOrUnsaved(this.comment)};
return html`
- <gr-account-label
- .account=${this.comment?.author ?? this.account}
- class=${classMap(classes)}
- >
+ <gr-account-label .account=${this.comment?.author ?? this.account}>
</gr-account-label>
`;
}
@@ -576,13 +566,13 @@ export class GrComment extends LitElement {
}
private renderDraftLabel() {
- if (!isDraftOrUnsaved(this.comment)) return;
+ if (!isDraft(this.comment)) return;
let label = 'Draft';
let tooltip =
'This draft is only visible to you. ' +
"To publish drafts, click the 'Reply' or 'Start review' button " +
"at the top of the change or press the 'a' key.";
- if (this.unableToSave) {
+ if (isError(this.comment)) {
label += ' (Failed to save)';
tooltip = 'Unable to save draft. Please try to save again.';
}
@@ -625,12 +615,7 @@ export class GrComment extends LitElement {
* a draft. It is an action applied to published comments.
*/
private renderDeleteButton() {
- if (
- !this.isAdmin ||
- isDraftOrUnsaved(this.comment) ||
- isRobot(this.comment)
- )
- return;
+ if (!this.isAdmin || isDraft(this.comment) || isRobot(this.comment)) return;
if (this.collapsed) return;
return html`
<gr-button
@@ -638,7 +623,10 @@ export class GrComment extends LitElement {
title="Delete Comment"
link
class="action delete"
- @click=${this.openDeleteCommentOverlay}
+ @click=${(e: MouseEvent) => {
+ e.stopPropagation();
+ this.openDeleteCommentModal();
+ }}
>
<gr-icon id="icon" icon="delete" filled></gr-icon>
</gr-button>
@@ -653,19 +641,36 @@ export class GrComment extends LitElement {
`;
}
+ private renderSeparator() {
+ // This should match the condition of `renderPatchset()`.
+ if (!this.showPatchset) return;
+ // This should match the condition of `renderDate()`.
+ if (this.collapsed) return;
+ // Render separator, if both are present: patchset AND date.
+ return html`<span class="separator"></span>`;
+ }
+
private renderDate() {
- if (!this.comment?.updated || this.collapsed) return;
+ if (this.collapsed) return;
return html`
- <span class="separator"></span>
<span class="date" tabindex="0" @click=${this.handleAnchorClick}>
- <gr-date-formatter
- withTooltip
- .dateStr=${this.comment.updated}
- ></gr-date-formatter>
+ ${this.renderDateInner()}
</span>
`;
}
+ private renderDateInner() {
+ if (isError(this.comment)) return 'Error';
+ if (isSaving(this.comment) && !this.autoSaving) return 'Saving';
+ if (isNew(this.comment)) return 'New';
+ return html`
+ <gr-date-formatter
+ withTooltip
+ .dateStr=${this.comment!.updated}
+ ></gr-date-formatter>
+ `;
+ }
+
private renderToggle() {
const icon = this.collapsed ? 'expand_more' : 'expand_less';
const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
@@ -697,7 +702,6 @@ export class GrComment extends LitElement {
class="editMessage"
autocomplete="on"
code=""
- ?disabled=${this.saving}
rows="4"
.placeholder=${this.messagePlaceholder}
text=${this.messageText}
@@ -729,7 +733,7 @@ export class GrComment extends LitElement {
private renderCopyLinkIcon() {
// Only show the icon when the thread contains a published comment.
- if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
+ if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
return html`
<gr-icon
icon="link"
@@ -744,7 +748,7 @@ export class GrComment extends LitElement {
private renderHumanActions() {
if (!this.account || isRobot(this.comment)) return;
- if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
+ if (this.collapsed || !isDraft(this.comment)) return;
return html`
<div class="actions">
<div class="action resolve">
@@ -764,42 +768,22 @@ export class GrComment extends LitElement {
}
private renderDraftActions() {
- if (!isDraftOrUnsaved(this.comment)) return;
+ if (!isDraft(this.comment)) return;
return html`
<div class="rightActions">
- ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
- ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
- ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
+ ${this.renderDiscardButton()} ${this.renderEditButton()}
${this.renderCancelButton()} ${this.renderSaveButton()}
${this.renderCopyLinkIcon()}
</div>
`;
}
- private renderPreviewSuggestEditButton() {
- if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
- return nothing;
- }
- assertIsDefined(this.comment, 'comment');
- if (!hasUserSuggestion(this.comment)) return nothing;
- return html`
- <gr-button
- link
- secondary
- class="action show-fix"
- ?disabled=${this.saving}
- @click=${this.handleShowFix}
- >
- Preview Fix
- </gr-button>
- `;
- }
-
private renderSuggestEditButton() {
if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
return nothing;
}
if (
+ !this.editing ||
this.permanentEditingMode ||
this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
) {
@@ -815,8 +799,9 @@ export class GrComment extends LitElement {
return html`<gr-button
link
class="action suggestEdit"
+ title="This button copies the text to make a suggestion"
@click=${this.createSuggestEdit}
- >Suggest Fix</gr-button
+ ><gr-icon icon="edit" id="icon" filled></gr-icon> Suggest edit</gr-button
>`;
}
@@ -824,7 +809,7 @@ export class GrComment extends LitElement {
if (this.editing || this.permanentEditingMode) return;
return html`<gr-button
link
- ?disabled=${this.saving}
+ ?disabled=${isSaving(this.comment) && !this.autoSaving}
class="action discard"
@click=${this.discard}
>Discard</gr-button
@@ -833,11 +818,7 @@ export class GrComment extends LitElement {
private renderEditButton() {
if (this.editing) return;
- return html`<gr-button
- link
- ?disabled=${this.saving}
- class="action edit"
- @click=${this.edit}
+ return html`<gr-button link class="action edit" @click=${this.edit}
>Edit</gr-button
>`;
}
@@ -847,7 +828,7 @@ export class GrComment extends LitElement {
return html`
<gr-button
link
- ?disabled=${this.saving}
+ ?disabled=${isSaving(this.comment) && !this.autoSaving}
class="action cancel"
@click=${this.cancel}
>Cancel</gr-button
@@ -856,7 +837,7 @@ export class GrComment extends LitElement {
}
private renderSaveButton() {
- if (!this.editing && !this.unableToSave) return;
+ if (!this.editing) return;
return html`
<gr-button
link
@@ -884,31 +865,15 @@ export class GrComment extends LitElement {
`;
}
- private renderSuggestEditActions() {
- if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
- return nothing;
- }
- if (
- !this.account ||
- isRobot(this.comment) ||
- isDraftOrUnsaved(this.comment)
- ) {
- return nothing;
- }
- return html`
- <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
- `;
- }
-
private renderShowFixButton() {
- if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
+ const fix_suggestions = (this.comment as RobotCommentInfo)?.fix_suggestions;
+ if (!fix_suggestions || fix_suggestions.length === 0) return;
return html`
<gr-button
link
secondary
class="action show-fix"
- ?disabled=${this.saving}
- @click=${this.handleShowFix}
+ @click=${() => this.handleShowFix()}
>
Show Fix
</gr-button>
@@ -930,27 +895,24 @@ export class GrComment extends LitElement {
}
private renderConfirmDialog() {
- if (!this.showConfirmDeleteOverlay) return;
return html`
- <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+ <dialog id="confirmDeleteModal" tabindex="-1">
<gr-confirm-delete-comment-dialog
- id="confirmDeleteComment"
+ id="confirmDeleteCommentDialog"
@confirm=${this.handleConfirmDeleteComment}
- @cancel=${this.closeDeleteCommentOverlay}
+ @cancel=${this.closeDeleteCommentModal}
>
</gr-confirm-delete-comment-dialog>
- </gr-overlay>
+ </dialog>
`;
}
private getUrlForComment() {
- const comment = this.comment;
- if (!comment || !this.changeNum || !this.repoName) return '';
- if (!comment.id) throw new Error('comment must have an id');
+ if (!this.changeNum || !this.repoName || !this.comment?.id) return '';
return createDiffUrl({
changeNum: this.changeNum,
- project: this.repoName,
- commentId: comment.id,
+ repo: this.repoName,
+ commentId: this.comment.id,
});
}
@@ -958,24 +920,41 @@ export class GrComment extends LitElement {
firstWillUpdate() {
if (this.firstWillUpdateDone) return;
- this.firstWillUpdateDone = true;
- if (this.permanentEditingMode) this.editing = true;
assertIsDefined(this.comment, 'comment');
+ this.firstWillUpdateDone = true;
this.unresolved = this.comment.unresolved ?? true;
- if (isUnsaved(this.comment)) this.editing = true;
- if (isDraftOrUnsaved(this.comment)) {
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
- {editing: this.editing, unsaved: isUnsaved(this.comment)}
- );
+ if (this.permanentEditingMode) {
+ this.edit();
+ }
+ if (
+ isDraft(this.comment) &&
+ isNew(this.comment) &&
+ !isSaving(this.comment)
+ ) {
+ this.edit();
+ }
+ if (isDraft(this.comment)) {
this.collapsed = false;
} else {
this.collapsed = !!this.initiallyCollapsed;
}
}
+ override updated(changed: PropertyValues) {
+ if (changed.has('editing')) {
+ if (this.editing && !this.permanentEditingMode) {
+ whenVisible(this, () => this.textarea?.putCursorAtEnd());
+ }
+ }
+ }
+
override willUpdate(changed: PropertyValues) {
this.firstWillUpdate();
+ if (changed.has('comment')) {
+ if (isDraft(this.comment) && isError(this.comment)) {
+ this.edit();
+ }
+ }
if (changed.has('editing')) {
this.onEditingChanged();
}
@@ -1000,14 +979,12 @@ export class GrComment extends LitElement {
}
private handleCopyLink() {
- fireEvent(this, 'copy-comment-link');
+ fire(this, 'copy-comment-link', {});
}
/** Enter editing mode. */
private edit() {
- if (!isDraftOrUnsaved(this.comment)) {
- throw new Error('Cannot edit published comment.');
- }
+ assert(isDraft(this.comment), 'only drafts are editable');
if (this.editing) return;
this.editing = true;
}
@@ -1022,12 +999,14 @@ export class GrComment extends LitElement {
}
// private, but visible for testing
- async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
+ async createFixPreview(
+ replacement?: string
+ ): Promise<OpenFixPreviewEventDetail> {
assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
assertIsDefined(this.comment?.path, 'comment.path');
- if (hasUserSuggestion(this.comment)) {
- const replacement = getUserSuggestion(this.comment);
+ if (hasUserSuggestion(this.comment) || replacement) {
+ replacement = replacement ?? getUserSuggestion(this.comment);
assert(!!replacement, 'malformed user suggestion');
const line = await this.getCommentedCode();
@@ -1038,6 +1017,11 @@ export class GrComment extends LitElement {
replacement
),
patchNum: this.comment.patch_set,
+ onCloseFixPreviewCallbacks: [
+ fixApplied => {
+ if (fixApplied) this.handleAppliedFix();
+ },
+ ],
};
}
if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
@@ -1050,6 +1034,7 @@ export class GrComment extends LitElement {
};
}),
patchNum: this.comment.patch_set,
+ onCloseFixPreviewCallbacks: [],
};
}
throw new Error('unable to create preview fix event');
@@ -1060,9 +1045,10 @@ export class GrComment extends LitElement {
this.collapsed = false;
this.messageText = this.comment?.message ?? '';
this.unresolved = this.comment?.unresolved ?? true;
- this.originalMessage = this.messageText;
- this.originalUnresolved = this.unresolved;
- setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
+ if (!isError(this.comment) && !isSaving(this.comment)) {
+ this.originalMessage = this.messageText;
+ this.originalUnresolved = this.unresolved;
+ }
}
// Parent components such as the reply dialog might be interested in whether
@@ -1076,10 +1062,14 @@ export class GrComment extends LitElement {
// private, but visible for testing
isSaveDisabled() {
assertIsDefined(this.comment, 'comment');
- if (this.saving) return true;
+ if (isSaving(this.comment) && !this.autoSaving) return true;
return !this.messageText?.trimEnd();
}
+ override focus() {
+ this.textarea?.focus();
+ }
+
private handleEsc() {
// vim users don't like ESC to cancel/discard, so only do this when the
// comment text is empty.
@@ -1114,12 +1104,25 @@ export class GrComment extends LitElement {
fire(this, 'reply-to-comment', eventDetail);
}
- private async handleShowFix() {
+ private handleAppliedFix() {
+ const message = this.comment?.message;
+ assert(!!message, 'empty message');
+ const eventDetail: ReplyToCommentEventDetail = {
+ content: 'Fix applied.',
+ userWantsToEdit: false,
+ unresolved: false,
+ };
+ // Handled by <gr-comment-thread>.
+ fire(this, 'reply-to-comment', eventDetail);
+ }
+
+ private async handleShowFix(replacement?: string) {
// Handled top-level in the diff and change view components.
- fire(this, 'open-fix-preview', await this.createFixPreview());
+ fire(this, 'open-fix-preview', await this.createFixPreview(replacement));
}
- async createSuggestEdit() {
+ async createSuggestEdit(e: MouseEvent) {
+ e.stopPropagation();
const line = await this.getCommentedCode();
this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
}
@@ -1146,24 +1149,22 @@ export class GrComment extends LitElement {
// private, but visible for testing
cancel() {
assertIsDefined(this.comment, 'comment');
- if (!isDraftOrUnsaved(this.comment)) {
- throw new Error('only unsaved and draft comments are editable');
- }
+ assert(isDraft(this.comment), 'only drafts are editable');
this.messageText = this.originalMessage;
this.unresolved = this.originalUnresolved;
this.save();
}
async autoSave() {
- if (this.saving || this.autoSaving) return;
+ if (isSaving(this.comment) || this.autoSaving) return;
if (!this.editing || !this.comment) return;
- if (!isDraftOrUnsaved(this.comment)) return;
+ assert(isDraft(this.comment), 'only drafts are editable');
const messageToSave = this.messageText.trimEnd();
if (messageToSave === '') return;
if (messageToSave === this.comment.message) return;
try {
- this.autoSaving = this.rawSave(messageToSave, {showToast: false});
+ this.autoSaving = this.rawSave({showToast: false});
await this.autoSaving;
} finally {
this.autoSaving = undefined;
@@ -1176,55 +1177,51 @@ export class GrComment extends LitElement {
}
async save() {
- if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
- // If it's an unsaved comment then it does not have a draftID yet which
- // means sending another save() request will create a new draft
- if (isUnsaved(this.comment) && this.saving) return;
-
- try {
- this.saving = true;
- this.unableToSave = false;
- if (this.autoSaving) {
- this.comment = await this.autoSaving;
- }
- // Depending on whether `messageToSave` is empty we treat this either as
- // a discard or a save action.
- const messageToSave = this.messageText.trimEnd();
- if (messageToSave === '') {
- // Don't try to discard UnsavedInfo. Nothing to do then.
- if (this.comment.id) {
- await this.getCommentsModel().discardDraft(this.comment.id);
- }
- } else {
- // No need to make a backend call when nothing has changed.
- if (
- messageToSave !== this.comment?.message ||
- this.unresolved !== this.comment.unresolved
- ) {
- await this.rawSave(messageToSave, {showToast: true});
- }
+ assert(isDraft(this.comment), 'only drafts are editable');
+ // There is a minimal chance of `isSaving()` being false between iterations
+ // of the below while loop. But this will be extremely rare and just lead
+ // to a harmless assertion error. So let's not bother.
+ if (isSaving(this.comment) && !this.autoSaving) return;
+
+ if (!this.permanentEditingMode) {
+ this.editing = false;
+ }
+ if (this.autoSaving) {
+ this.comment = await this.autoSaving;
+ }
+ // Depending on whether `messageToSave` is empty we treat this either as
+ // a discard or a save action.
+ const messageToSave = this.messageText.trimEnd();
+ if (messageToSave === '') {
+ if (!this.permanentEditingMode || this.somethingToSave()) {
+ await this.getCommentsModel().discardDraft(id(this.comment));
}
- this.reporting.reportInteraction(
- Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
- );
- if (!this.permanentEditingMode) {
- this.editing = false;
+ } else {
+ // No need to make a backend call when nothing has changed.
+ while (this.somethingToSave()) {
+ this.comment = await this.rawSave({showToast: true});
+ if (isError(this.comment)) return;
}
- } catch (e) {
- this.unableToSave = true;
- throw e;
- } finally {
- this.saving = false;
}
}
+ private somethingToSave() {
+ if (!this.comment) return false;
+ return (
+ isError(this.comment) ||
+ this.messageText.trimEnd() !== this.comment?.message ||
+ this.unresolved !== this.comment.unresolved
+ );
+ }
+
/** For sharing between save() and autoSave(). */
- private rawSave(message: string, options: {showToast: boolean}) {
- if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
+ private rawSave(options: {showToast: boolean}) {
+ assert(isDraft(this.comment), 'only drafts are editable');
+ assert(!isSaving(this.comment), 'saving already in progress');
return this.getCommentsModel().saveDraft(
{
...this.comment,
- message,
+ message: this.messageText.trimEnd(),
unresolved: this.unresolved,
},
options.showToast
@@ -1243,51 +1240,35 @@ export class GrComment extends LitElement {
}
}
- private async openDeleteCommentOverlay() {
- this.showConfirmDeleteOverlay = true;
- await this.updateComplete;
- await this.confirmDeleteOverlay?.open();
+ private openDeleteCommentModal() {
+ this.confirmDeleteModal?.showModal();
+ whenVisible(this.confirmDeleteDialog!, () => {
+ this.confirmDeleteDialog!.resetFocus();
+ });
}
- private closeDeleteCommentOverlay() {
- this.showConfirmDeleteOverlay = false;
- this.confirmDeleteOverlay?.remove();
- this.confirmDeleteOverlay?.close();
+ private closeDeleteCommentModal() {
+ this.confirmDeleteModal?.close();
}
/**
* Deleting a *published* comment is an admin feature. It means more than just
* discarding a draft.
- *
- * TODO: Also move this into the comments-service.
- * TODO: Figure out a good reloading strategy when deleting was successful.
- * `this.comment = newComment` does not seem sufficient.
*/
// private, but visible for testing
- handleConfirmDeleteComment() {
- const dialog = this.confirmDeleteOverlay?.querySelector(
- '#confirmDeleteComment'
- ) as GrConfirmDeleteCommentDialog | null;
- if (!dialog || !dialog.message) {
+ async handleConfirmDeleteComment() {
+ if (!this.confirmDeleteDialog || !this.confirmDeleteDialog.message) {
throw new Error('missing confirm delete dialog');
}
assertIsDefined(this.changeNum, 'changeNum');
assertIsDefined(this.comment, 'comment');
- assertIsDefined(this.comment.patch_set, 'comment.patch_set');
- if (isDraftOrUnsaved(this.comment)) {
- throw new Error('Admin deletion is only for published comments.');
- }
- this.restApiService
- .deleteComment(
- this.changeNum,
- this.comment.patch_set,
- this.comment.id,
- dialog.message
- )
- .then(newComment => {
- this.closeDeleteCommentOverlay();
- this.comment = newComment;
- });
+
+ await this.getCommentsModel().deleteComment(
+ this.changeNum,
+ this.comment,
+ this.confirmDeleteDialog.message
+ );
+ this.closeDeleteCommentModal();
}
}
@@ -1295,4 +1276,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-comment': GrComment;
}
+ interface HTMLElementEventMap {
+ 'copy-comment-link': CustomEvent<{}>;
+ }
}
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 2293619e6e..59485e29a7 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
@@ -17,9 +17,12 @@ import {
dispatch,
MockPromise,
stubFlags,
+ waitUntil,
} from '../../../test/test-utils';
import {
AccountId,
+ DraftInfo,
+ SavingState,
EmailAddress,
NumericChangeId,
PatchSetNum,
@@ -29,27 +32,25 @@ import {
import {
createComment,
createDraft,
- createFixSuggestionInfo,
createRobotComment,
- createUnsaved,
+ createNewDraft,
} from '../../../test/test-data-generators';
-import {
- ReplyToCommentEvent,
- OpenFixPreviewEventDetail,
-} from '../../../types/events';
+import {ReplyToCommentEvent} from '../../../types/events';
import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
-import {
- DraftInfo,
- USER_SUGGESTION_START_PATTERN,
-} from '../../../utils/comment-util';
import {assertIsDefined} from '../../../utils/common-util';
import {Modifier} from '../../../utils/dom-util';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
import {fixture, html, assert} from '@open-wc/testing';
import {GrButton} from '../gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {
+ CommentsModel,
+ commentsModelToken,
+} from '../../../models/comments/comments-model';
suite('gr-comment tests', () => {
let element: GrComment;
+ let commentsModel: CommentsModel;
const account = {
email: 'dhruvsri@google.com' as EmailAddress,
name: 'Dhruv Srivastava',
@@ -77,6 +78,7 @@ suite('gr-comment tests', () => {
.comment=${comment}
></gr-comment>`
);
+ commentsModel = testResolver(commentsModelToken);
});
suite('DOM rendering', () => {
@@ -95,6 +97,8 @@ suite('gr-comment tests', () => {
<gr-endpoint-decorator name="comment">
<gr-endpoint-param name="comment"></gr-endpoint-param>
<gr-endpoint-param name="editing"></gr-endpoint-param>
+ <gr-endpoint-param name="message"></gr-endpoint-param>
+ <gr-endpoint-param name="isDraft"></gr-endpoint-param>
<div class="container" id="container">
<div class="header" id="header">
<div class="headerLeft">
@@ -118,6 +122,10 @@ suite('gr-comment tests', () => {
</div>
</div>
</gr-endpoint-decorator>
+ <dialog id="confirmDeleteModal" tabindex="-1">
+ <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+ </gr-confirm-delete-comment-dialog>
+ </dialog>
`
);
});
@@ -131,6 +139,8 @@ suite('gr-comment tests', () => {
<gr-endpoint-decorator name="comment">
<gr-endpoint-param name="comment"></gr-endpoint-param>
<gr-endpoint-param name="editing"></gr-endpoint-param>
+ <gr-endpoint-param name="message"></gr-endpoint-param>
+ <gr-endpoint-param name="isDraft"></gr-endpoint-param>
<div class="container" id="container">
<div class="header" id="header">
<div class="headerLeft">
@@ -155,6 +165,10 @@ suite('gr-comment tests', () => {
</div>
</div>
</gr-endpoint-decorator>
+ <dialog id="confirmDeleteModal" tabindex="-1">
+ <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+ </gr-confirm-delete-comment-dialog>
+ </dialog>
`
);
});
@@ -169,6 +183,8 @@ suite('gr-comment tests', () => {
<gr-endpoint-decorator name="comment">
<gr-endpoint-param name="comment"></gr-endpoint-param>
<gr-endpoint-param name="editing"></gr-endpoint-param>
+ <gr-endpoint-param name="message"></gr-endpoint-param>
+ <gr-endpoint-param name="isDraft"></gr-endpoint-param>
<div class="container" id="container">
<div class="header" id="header">
<div class="headerLeft">
@@ -225,6 +241,10 @@ suite('gr-comment tests', () => {
</div>
</div>
</gr-endpoint-decorator>
+ <dialog id="confirmDeleteModal" tabindex="-1">
+ <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+ </gr-confirm-delete-comment-dialog>
+ </dialog>
`
);
});
@@ -253,7 +273,7 @@ suite('gr-comment tests', () => {
test('renders draft', async () => {
element.initiallyCollapsed = false;
- (element.comment as DraftInfo).__draft = true;
+ (element.comment as DraftInfo).savingState = SavingState.OK;
await element.updateComplete;
assert.shadowDom.equal(
element,
@@ -261,6 +281,8 @@ suite('gr-comment tests', () => {
<gr-endpoint-decorator name="comment">
<gr-endpoint-param name="comment"></gr-endpoint-param>
<gr-endpoint-param name="editing"></gr-endpoint-param>
+ <gr-endpoint-param name="message"></gr-endpoint-param>
+ <gr-endpoint-param name="isDraft"></gr-endpoint-param>
<div class="container draft" id="container">
<div class="header" id="header">
<div class="headerLeft">
@@ -321,13 +343,17 @@ suite('gr-comment tests', () => {
</div>
</div>
</gr-endpoint-decorator>
+ <dialog id="confirmDeleteModal" tabindex="-1">
+ <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+ </gr-confirm-delete-comment-dialog>
+ </dialog>
`
);
});
test('renders draft in editing mode', async () => {
element.initiallyCollapsed = false;
- (element.comment as DraftInfo).__draft = true;
+ (element.comment as DraftInfo).savingState = SavingState.OK;
element.editing = true;
await element.updateComplete;
assert.shadowDom.equal(
@@ -336,6 +362,8 @@ suite('gr-comment tests', () => {
<gr-endpoint-decorator name="comment">
<gr-endpoint-param name="comment"></gr-endpoint-param>
<gr-endpoint-param name="editing"></gr-endpoint-param>
+ <gr-endpoint-param name="message"></gr-endpoint-param>
+ <gr-endpoint-param name="isDraft"></gr-endpoint-param>
<div class="container draft" id="container">
<div class="header" id="header">
<div class="headerLeft">
@@ -404,6 +432,10 @@ suite('gr-comment tests', () => {
</div>
</div>
</gr-endpoint-decorator>
+ <dialog id="confirmDeleteModal" tabindex="-1">
+ <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog">
+ </gr-confirm-delete-comment-dialog>
+ </dialog>
`
);
});
@@ -435,7 +467,7 @@ suite('gr-comment tests', () => {
},
line: 5,
path: 'test',
- __draft: true,
+ savingState: SavingState.OK,
message: 'hello world',
};
element.editing = true;
@@ -460,7 +492,7 @@ suite('gr-comment tests', () => {
},
line: 5,
path: 'test',
- __draft: true,
+ savingState: SavingState.OK,
message: 'hello world',
};
element.editing = true;
@@ -483,10 +515,10 @@ suite('gr-comment tests', () => {
deleteButton.click();
await element.updateComplete;
- assertIsDefined(element.confirmDeleteOverlay, 'confirmDeleteOverlay');
+ assertIsDefined(element.confirmDeleteModal, 'confirmDeleteModal');
const dialog = queryAndAssert<GrConfirmDeleteCommentDialog>(
- element.confirmDeleteOverlay,
- '#confirmDeleteComment'
+ element.confirmDeleteModal,
+ '#confirmDeleteCommentDialog'
);
dialog.message = 'removal reason';
await element.updateComplete;
@@ -510,14 +542,13 @@ suite('gr-comment tests', () => {
element.changeNum = 42 as NumericChangeId;
element.comment = {
...createComment(),
- __draft: true,
+ savingState: SavingState.OK,
path: '/path/to/file',
line: 5,
};
});
test('isSaveDisabled', async () => {
- element.saving = false;
element.unresolved = true;
element.comment = {...createComment(), unresolved: true};
element.messageText = 'asdf';
@@ -534,7 +565,7 @@ suite('gr-comment tests', () => {
await element.updateComplete;
assert.isTrue(element.isSaveDisabled());
- element.saving = true;
+ element.comment = {...element.comment, savingState: SavingState.SAVING};
await element.updateComplete;
assert.isTrue(element.isSaveDisabled());
});
@@ -550,9 +581,7 @@ suite('gr-comment tests', () => {
test('save', async () => {
const savePromise = mockPromise<DraftInfo>();
- const stub = sinon
- .stub(element.getCommentsModel(), 'saveDraft')
- .returns(savePromise);
+ const stub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
element.comment = createDraft();
element.editing = true;
@@ -568,14 +597,12 @@ suite('gr-comment tests', () => {
waitUntilCalled(stub, 'saveDraft()');
assert.equal(stub.lastCall.firstArg.message, textToSave);
assert.equal(stub.lastCall.firstArg.unresolved, true);
- assert.isTrue(element.editing);
- assert.isTrue(element.saving);
+ assert.isFalse(element.editing);
savePromise.resolve();
await element.updateComplete;
assert.isFalse(element.editing);
- assert.isFalse(element.saving);
});
test('previewing formatting triggers save', async () => {
@@ -596,28 +623,36 @@ suite('gr-comment tests', () => {
});
test('save failed', async () => {
- sinon
- .stub(element.getCommentsModel(), 'saveDraft')
- .returns(Promise.reject(new Error('saving failed')));
+ sinon.stub(commentsModel, 'saveDraft').returns(
+ Promise.resolve({
+ ...createNewDraft(),
+ message: 'something, not important',
+ unresolved: true,
+ savingState: SavingState.ERROR,
+ })
+ );
- element.comment = createDraft();
+ element.comment = createNewDraft({
+ message: '',
+ unresolved: true,
+ });
+ element.unresolved = true;
element.editing = true;
await element.updateComplete;
element.messageText = 'something, not important';
await element.updateComplete;
element.save();
- await element.updateComplete;
+ assert.isFalse(element.editing);
- assert.isTrue(element.unableToSave);
+ await waitUntil(() => element.hasAttribute('error'));
assert.isTrue(element.editing);
- assert.isFalse(element.saving);
});
test('discard', async () => {
const discardPromise = mockPromise<void>();
const stub = sinon
- .stub(element.getCommentsModel(), 'discardDraft')
+ .stub(commentsModel, 'discardDraft')
.returns(discardPromise);
element.comment = createDraft();
@@ -629,21 +664,19 @@ suite('gr-comment tests', () => {
await element.updateComplete;
waitUntilCalled(stub, 'discardDraft()');
assert.equal(stub.lastCall.firstArg, element.comment.id);
- assert.isTrue(element.editing);
- assert.isTrue(element.saving);
+ assert.isFalse(element.editing);
discardPromise.resolve();
await element.updateComplete;
assert.isFalse(element.editing);
- assert.isFalse(element.saving);
});
test('resolved comment state indicated by checkbox', async () => {
- const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
+ const saveStub = sinon.stub(commentsModel, 'saveDraft');
element.comment = {
...createComment(),
- __draft: true,
+ savingState: SavingState.OK,
unresolved: false,
};
await element.updateComplete;
@@ -664,11 +697,8 @@ suite('gr-comment tests', () => {
});
test('saving empty text calls discard()', async () => {
- const saveStub = sinon.stub(element.getCommentsModel(), 'saveDraft');
- const discardStub = sinon.stub(
- element.getCommentsModel(),
- 'discardDraft'
- );
+ const saveStub = sinon.stub(commentsModel, 'saveDraft');
+ const discardStub = sinon.stub(commentsModel, 'discardDraft');
element.comment = createDraft();
element.editing = true;
await element.updateComplete;
@@ -715,38 +745,19 @@ suite('gr-comment tests', () => {
actions = query(element, '.robotActions gr-button.fix');
assert.isNotOk(actions);
});
-
- test('handleShowFix fires open-fix-preview event', async () => {
- const listener = listenOnce<CustomEvent<OpenFixPreviewEventDetail>>(
- element,
- 'open-fix-preview'
- );
- element.comment = {
- ...createRobotComment(),
- fix_suggestions: [{...createFixSuggestionInfo()}],
- };
- await element.updateComplete;
-
- queryAndAssert<GrButton>(element, '.show-fix').click();
-
- const e = await listener;
- assert.deepEqual(e.detail, await element.createFixPreview());
- });
});
suite('auto saving', () => {
let clock: sinon.SinonFakeTimers;
let savePromise: MockPromise<DraftInfo>;
- let saveStub: SinonStub;
+ let saveStub: SinonStubbedMember<CommentsModel['saveDraft']>;
setup(async () => {
clock = sinon.useFakeTimers();
savePromise = mockPromise<DraftInfo>();
- saveStub = sinon
- .stub(element.getCommentsModel(), 'saveDraft')
- .returns(savePromise);
+ saveStub = sinon.stub(commentsModel, 'saveDraft').returns(savePromise);
- element.comment = createUnsaved();
+ element.comment = createNewDraft();
element.editing = true;
await element.updateComplete;
});
@@ -772,32 +783,43 @@ suite('gr-comment tests', () => {
});
test('saving while auto saving', async () => {
+ saveStub.reset();
+ const autoSavePromise = mockPromise<DraftInfo>();
+ saveStub.onCall(0).returns(autoSavePromise);
+ saveStub.onCall(1).returns(savePromise);
+
const textarea = queryAndAssert<HTMLElement>(element, '#editTextarea');
dispatch(textarea, 'text-changed', {value: 'auto save text'});
clock.tick(2 * AUTO_SAVE_DEBOUNCE_DELAY_MS);
- assert.isTrue(saveStub.called);
+ assert.equal(saveStub.callCount, 1);
assert.equal(saveStub.firstCall.firstArg.message, 'auto save text');
- saveStub.reset();
element.messageText = 'actual save text';
const save = element.save();
await element.updateComplete;
// First wait for the auto saving to finish.
- assert.isFalse(saveStub.called);
+ assert.equal(saveStub.callCount, 1);
- // Resolve auto-saving promise.
- savePromise.resolve({
+ autoSavePromise.resolve({
...element.comment,
- __draft: true,
+ savingState: SavingState.OK,
+ message: 'auto save text',
id: 'exp123' as UrlEncodedCommentId,
updated: '2018-02-13 22:48:48.018000000' as Timestamp,
});
+ savePromise.resolve({
+ ...element.comment,
+ savingState: SavingState.OK,
+ message: 'actual save text',
+ id: 'exp123' as UrlEncodedCommentId,
+ updated: '2018-02-13 22:48:49.018000000' as Timestamp,
+ });
await save;
// Only then save.
- assert.isTrue(saveStub.called);
- assert.equal(saveStub.firstCall.firstArg.message, 'actual save text');
- assert.equal(saveStub.firstCall.firstArg.id, 'exp123');
+ assert.equal(saveStub.callCount, 2);
+ assert.equal(saveStub.lastCall.firstArg.message, 'actual save text');
+ assert.equal(saveStub.lastCall.firstArg.id, 'exp123');
});
});
@@ -813,7 +835,7 @@ suite('gr-comment tests', () => {
},
line: 5,
path: 'test',
- __draft: true,
+ savingState: SavingState.OK,
message: 'hello world',
};
element = await fixture(
@@ -824,46 +846,19 @@ suite('gr-comment tests', () => {
.initiallyCollapsed=${false}
></gr-comment>`
);
+ element.editing = true;
});
- test('renders suggest fix button', () => {
+ test('renders suggest edit button', () => {
assert.dom.equal(
queryAndAssert(element, 'gr-button.suggestEdit'),
/* HTML */ `<gr-button
- aria-disabled="false"
class="action suggestEdit"
link=""
role="button"
tabindex="0"
+ title="This button copies the text to make a suggestion"
>
- Suggest Fix
- </gr-button> `
- );
- });
-
- test('renders preview suggest fix', async () => {
- element.comment = {
- ...createComment(),
- author: {
- name: 'Mr. Peanutbutter',
- email: 'tenn1sballchaser@aol.com' as EmailAddress,
- },
- line: 5,
- path: 'test',
- message: `${USER_SUGGESTION_START_PATTERN}afterSuggestion${'\n```'}`,
- };
- await element.updateComplete;
-
- assert.dom.equal(
- queryAndAssert(element, 'gr-button.show-fix'),
- /* HTML */ `<gr-button
- aria-disabled="false"
- class="action show-fix"
- link=""
- role="button"
- secondary
- tabindex="0"
- >
- Preview Fix
+ <gr-icon icon="edit" id="icon" filled></gr-icon> Suggest edit
</gr-button> `
);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index b6512b3854..e8ab172d54 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -11,6 +11,7 @@ import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
import {sharedStyles} from '../../../styles/shared-styles';
import {assertIsDefined} from '../../../utils/common-util';
import {BindValueChangeEvent} from '../../../types/events';
+import {fireNoBubble} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -75,6 +76,7 @@ export class GrConfirmDeleteCommentDialog extends LitElement {
override render() {
return html` <gr-dialog
confirm-label="Delete"
+ ?disabled=${this.message === ''}
@confirm=${this.handleConfirmTap}
@cancel=${this.handleCancelTap}
>
@@ -107,23 +109,12 @@ export class GrConfirmDeleteCommentDialog extends LitElement {
private handleConfirmTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('confirm', {
- detail: {reason: this.message},
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
index bd84ac431f..b7551c1c47 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog_test.ts
@@ -7,6 +7,7 @@ import '../../../test/common-test-setup';
import {fixture, html, assert} from '@open-wc/testing';
import {GrConfirmDeleteCommentDialog} from './gr-confirm-delete-comment-dialog';
import './gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
suite('gr-confirm-delete-comment-dialog tests', () => {
let element: GrConfirmDeleteCommentDialog;
@@ -17,7 +18,10 @@ suite('gr-confirm-delete-comment-dialog tests', () => {
);
});
- test('render', () => {
+ test('render', async () => {
+ element.message = 'Just cause';
+ await element.updateComplete;
+
// prettier and shadowDom string disagree about wrapping in <p> tag.
assert.shadowDom.equal(
element,
@@ -43,4 +47,13 @@ suite('gr-confirm-delete-comment-dialog tests', () => {
`
);
});
+
+ test('dialog is disabled when message is empty', async () => {
+ element.message = '';
+ await element.updateComplete;
+
+ assert.isTrue(
+ (element.shadowRoot!.querySelector('gr-dialog') as GrDialog).disabled
+ );
+ });
});
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 0e9b87453c..5b26ede7f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -39,6 +39,10 @@ export class GrCopyClipboard extends LitElement {
@property({type: Boolean})
hideInput = false;
+ // Optional property for toast to announce correct name of target that was copied
+ @property({type: String, reflect: true})
+ copyTargetName?: string;
+
@query('#icon')
iconEl!: GrIcon;
@@ -66,7 +70,10 @@ export class GrCopyClipboard extends LitElement {
color: var(--primary-text-color);
}
gr-icon {
- color: var(--deemphasized-text-color);
+ color: var(
+ --gr-copy-clipboard-icon-color,
+ var(--deemphasized-text-color)
+ );
}
gr-button {
display: block;
@@ -105,7 +112,8 @@ export class GrCopyClipboard extends LitElement {
link=""
class="copyToClipboard"
@click=${this._copyToClipboard}
- aria-label="Click to copy to clipboard"
+ aria-label="copy"
+ aria-description="Click to copy to clipboard"
>
<div>
<gr-icon id="icon" icon="content_copy" small></gr-icon>
@@ -133,7 +141,7 @@ export class GrCopyClipboard extends LitElement {
this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
assertIsDefined(this.text, 'text');
this.iconEl.icon = 'check';
- copyToClipbard(this.text, 'Link');
+ copyToClipbard(this.text, this.copyTargetName ?? 'Link');
setTimeout(() => (this.iconEl.icon = 'content_copy'), COPY_TIMEOUT_MS);
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 6e93803a7e..4c36a56fb9 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -42,7 +42,8 @@ suite('gr-copy-clipboard tests', () => {
<gr-tooltip-content>
<gr-button
aria-disabled="false"
- aria-label="Click to copy to clipboard"
+ aria-label="copy"
+ aria-description="Click to copy to clipboard"
class="copyToClipboard"
id="copy-clipboard-button"
link=""
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
index 4b8157ecca..81d2b450f9 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.ts
@@ -4,18 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-// eslint-disable-next-line import/named
import {fixture, html, assert} from '@open-wc/testing';
import {AbortStop, CursorMoveResult} from '../../../api/core';
import {GrCursorManager} from './gr-cursor-manager';
suite('gr-cursor-manager tests', () => {
- let cursor;
- let list;
+ let cursor: GrCursorManager;
+ let list: Element;
setup(async () => {
- list = await fixture(html`
- <ul>
+ list = await fixture(html` <ul>
<li>A</li>
<li>B</li>
<li>C</li>
@@ -42,11 +40,11 @@ suite('gr-cursor-manager tests', () => {
assert.isNotOk(cursor.target);
// Select the third stop.
- cursor.setCursor(list.children[2]);
+ cursor.setCursor(list.children[2] as HTMLElement);
// It should update its internal state and update the element's class.
assert.equal(cursor.index, 2);
- assert.equal(cursor.target, list.children[2]);
+ assert.equal(cursor.target, list.children[2] as HTMLElement);
assert.isTrue(list.children[2].classList.contains('targeted'));
assert.isFalse(cursor.isAtStart());
assert.isFalse(cursor.isAtEnd());
@@ -58,7 +56,7 @@ suite('gr-cursor-manager tests', () => {
// unselected.
assert.equal(result, CursorMoveResult.MOVED);
assert.equal(cursor.index, 3);
- assert.equal(cursor.target, list.children[3]);
+ assert.equal(cursor.target, list.children[3] as HTMLElement);
assert.isTrue(cursor.isAtEnd());
assert.isFalse(list.children[2].classList.contains('targeted'));
assert.isTrue(list.children[3].classList.contains('targeted'));
@@ -69,7 +67,7 @@ suite('gr-cursor-manager tests', () => {
// We should still be at the end.
assert.equal(result, CursorMoveResult.CLIPPED);
assert.equal(cursor.index, 3);
- assert.equal(cursor.target, list.children[3]);
+ assert.equal(cursor.target, list.children[3] as HTMLElement);
assert.isTrue(cursor.isAtEnd());
// Wind the cursor all the way back to the first stop.
@@ -82,7 +80,7 @@ suite('gr-cursor-manager tests', () => {
// The element state should reflect the start of the list.
assert.equal(cursor.index, 0);
- assert.equal(cursor.target, list.children[0]);
+ assert.equal(cursor.target, list.children[0] as HTMLElement);
assert.isTrue(cursor.isAtStart());
assert.isTrue(list.children[0].classList.contains('targeted'));
@@ -118,7 +116,7 @@ suite('gr-cursor-manager tests', () => {
assert.equal(result, CursorMoveResult.MOVED);
assert.equal(cursor.index, 0);
- assert.equal(cursor.target, list.children[0]);
+ assert.equal(cursor.target, list.children[0] as HTMLElement);
assert.isTrue(list.children[0].classList.contains('targeted'));
assert.isTrue(cursor.isAtStart());
assert.isFalse(cursor.isAtEnd());
@@ -141,7 +139,7 @@ suite('gr-cursor-manager tests', () => {
assert.equal(result, CursorMoveResult.MOVED);
const lastIndex = list.children.length - 1;
assert.equal(cursor.index, lastIndex);
- assert.equal(cursor.target, list.children[lastIndex]);
+ assert.equal(cursor.target, list.children[lastIndex] as HTMLElement);
assert.isTrue(list.children[lastIndex].classList.contains('targeted'));
assert.isFalse(cursor.isAtStart());
assert.isTrue(cursor.isAtEnd());
@@ -161,7 +159,7 @@ suite('gr-cursor-manager tests', () => {
// Initialize the cursor with its stops.
cursor.stops = [...list.querySelectorAll('li')];
// Select the first stop.
- cursor.setCursor(list.children[0]);
+ cursor.setCursor(list.children[0] as HTMLElement);
const getTargetHeight = sinon.stub();
// Move the cursor without an optional get target height function.
@@ -176,7 +174,7 @@ suite('gr-cursor-manager tests', () => {
test('_moveCursor from for invalid index does not check height', () => {
cursor.stops = [];
const getTargetHeight = sinon.stub();
- cursor._moveCursor(1, () => false, {getTargetHeight});
+ cursor._moveCursor(1, {filter: () => false, getTargetHeight});
assert.isFalse(getTargetHeight.called);
});
@@ -194,12 +192,12 @@ suite('gr-cursor-manager tests', () => {
});
test('move with filter', () => {
- const isLetterB = function(row) {
+ const isLetterB = function (row: HTMLElement) {
return row.textContent === 'B';
};
cursor.stops = [...list.querySelectorAll('li')];
// Start cursor at the first stop.
- cursor.setCursor(list.children[0]);
+ cursor.setCursor(list.children[0] as HTMLElement);
// Move forward to meet the next condition.
cursor.next({filter: isLetterB});
@@ -225,19 +223,19 @@ suite('gr-cursor-manager tests', () => {
test('focusOnMove prop', () => {
const listEls = [...list.querySelectorAll('li')];
- for (let i = 0; i < listEls.length; i++) {
- sinon.spy(listEls[i], 'focus');
- }
+ const listFocusStubs = listEls.map(listEl => sinon.spy(listEl, 'focus'));
cursor.stops = listEls;
- cursor.setCursor(list.children[0]);
+ cursor.setCursor(list.children[0] as HTMLElement);
cursor.focusOnMove = false;
cursor.next();
- assert.isFalse(cursor.target.focus.called);
+ assert.equal(listEls[1], cursor.target);
+ assert.isFalse(listFocusStubs[1].called);
cursor.focusOnMove = true;
cursor.next();
- assert.isTrue(cursor.target.focus.called);
+ assert.equal(listEls[2], cursor.target);
+ assert.isTrue(listFocusStubs[2].called);
});
suite('circular options', () => {
@@ -247,26 +245,26 @@ suite('gr-cursor-manager tests', () => {
});
test('previous() on first element goes to last element', () => {
- cursor.setCursor(list.children[0]);
+ cursor.setCursor(list.children[0] as HTMLElement);
cursor.previous(options);
assert.equal(cursor.index, list.children.length - 1);
});
test('next() on last element goes to first element', () => {
- cursor.setCursor(list.children[list.children.length - 1]);
+ cursor.setCursor(list.children[list.children.length - 1] as HTMLElement);
cursor.next(options);
assert.equal(cursor.index, 0);
});
});
suite('_scrollToTarget', () => {
- let scrollStub;
+ let scrollStub: sinon.SinonStub;
setup(() => {
cursor.stops = [...list.querySelectorAll('li')];
cursor.scrollMode = 'keep-visible';
// There is a target which has a targetNext
- cursor.setCursor(list.children[0]);
+ cursor.setCursor(list.children[0] as HTMLElement);
cursor._moveCursor(1);
scrollStub = sinon.stub(window, 'scrollTo');
window.innerHeight = 60;
@@ -285,8 +283,9 @@ suite('gr-cursor-manager tests', () => {
});
test('Called when top is visible, bottom is not, scroll is lower', () => {
- const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
- () => visibleStub.callCount === 2);
+ const visibleStub = sinon
+ .stub(cursor, '_targetIsVisible')
+ .callsFake(() => visibleStub.callCount === 2);
window.scrollX = 123;
window.scrollY = 15;
window.innerHeight = 1000;
@@ -299,8 +298,9 @@ suite('gr-cursor-manager tests', () => {
});
test('Called when top is visible, bottom not, scroll is higher', () => {
- const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
- () => visibleStub.callCount === 2);
+ const visibleStub = sinon
+ .stub(cursor, '_targetIsVisible')
+ .callsFake(() => visibleStub.callCount === 2);
window.scrollX = 123;
window.scrollY = 25;
window.innerHeight = 1000;
@@ -316,8 +316,8 @@ suite('gr-cursor-manager tests', () => {
window.scrollY = 25;
window.innerHeight = 300;
window.pageYOffset = 0;
- assert.equal(cursor._calculateScrollToValue(1000, {offsetHeight: 10}),
- 905);
+ const fakeElement = {offsetHeight: 10} as HTMLElement;
+ assert.equal(cursor._calculateScrollToValue(1000, fakeElement), 905);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
index 25ee13098a..05b7fb5cfe 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -18,8 +18,10 @@ import {
} from '../../../utils/date-util';
import {TimeFormat, DateFormat} from '../../../constants/constants';
import {assertNever} from '../../../utils/common-util';
-import {Timestamp} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
+import {PreferencesInfo, Timestamp} from '../../../types/common';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
+import {subscribe} from '../../lit/subscription-controller';
const TimeFormats = {
TIME_12: 'h:mm A', // 2:14 PM
@@ -95,7 +97,7 @@ export class GrDateFormatter extends LitElement {
@state()
relative = false;
- private readonly restApiService = getAppContext().restApiService;
+ private readonly getUserModel = resolve(this, userModelToken);
static override get styles() {
return [
@@ -108,17 +110,30 @@ export class GrDateFormatter extends LitElement {
];
}
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().preferences$,
+ prefs => this.setPreferences(prefs)
+ );
+ }
+
+ // private but used by tests
+ setPreferences(prefs: PreferencesInfo) {
+ this.decideDateFormat(prefs.date_format);
+ this.decideTimeFormat(prefs.time_format);
+ this.relative =
+ this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
+ }
+
override render() {
- if (!this.withTooltip) {
- return this.renderDateString();
- }
+ if (!this.withTooltip) return this.renderDateString();
+ const tooltip = this.computeFullDateStr();
+ if (!tooltip) return this.renderDateString();
- const fullDateStr = this.computeFullDateStr();
- if (!fullDateStr) {
- return this.renderDateString();
- }
return html`
- <gr-tooltip-content has-tooltip title=${fullDateStr}>
+ <gr-tooltip-content has-tooltip title=${tooltip}>
${this.renderDateString()}
</gr-tooltip-content>
`;
@@ -128,38 +143,11 @@ export class GrDateFormatter extends LitElement {
return html` <span>${this.computeDateStr()}</span>`;
}
- override connectedCallback() {
- super.connectedCallback();
- this.loadPreferences();
- }
-
// private but used by tests
- _getUtcOffsetString() {
+ getUtcOffsetString() {
return utcOffsetString();
}
- // private but used by tests
- async loadPreferences() {
- const loggedIn = await this.restApiService.getLoggedIn();
- if (!loggedIn) {
- this.timeFormat = TimeFormats.TIME_24;
- this.dateFormat = DateFormats.STD;
- this.relative = this.forceRelative;
- return;
- }
- await Promise.all([this.loadTimeFormat(), this.loadRelative()]);
- }
-
- // private but used in gr/file-list_test.ts
- async loadTimeFormat() {
- const preferences = await this.restApiService.getPreferences();
- if (!preferences) {
- throw Error('Preferences is not set');
- }
- this.decideTimeFormat(preferences.time_format);
- this.decideDateFormat(preferences.date_format);
- }
-
private decideTimeFormat(timeFormat: TimeFormat) {
switch (timeFormat) {
case TimeFormat.HHMM_12:
@@ -195,12 +183,6 @@ export class GrDateFormatter extends LitElement {
}
}
- private async loadRelative() {
- const prefs = await this.restApiService.getPreferences();
- this.relative =
- this.forceRelative || Boolean(prefs?.relative_date_in_change_table);
- }
-
private computeDateStr() {
if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
return '';
@@ -222,33 +204,24 @@ export class GrDateFormatter extends LitElement {
if (isWithinHalfYear(now, date)) {
format = this.dateFormat.short;
}
- if (this.showDateAndTime || this.showDateAndTime) {
+ if (this.showDateAndTime) {
format = `${format} ${this.timeFormat}`;
}
}
return formatDate(date, format);
}
- private computeFullDateStr() {
- if (
- [this.dateStr, this.timeFormat].includes(undefined) ||
- !this.dateFormat
- ) {
- return undefined;
- }
-
- if (!this.dateStr) {
- return '';
- }
+ private computeFullDateStr(): string {
+ if (!this.dateStr) return '';
+ if (!this.timeFormat) return '';
+ if (!this.dateFormat) return '';
const date = parseDate(this.dateStr as Timestamp);
- if (!isValidDate(date)) {
- return '';
- }
- let format = this.dateFormat.full + ', ';
- format +=
+ if (!isValidDate(date)) return '';
+ const timeFormat =
this.timeFormat === TimeFormats.TIME_12
? TimeFormats.TIME_12_WITH_SEC
: TimeFormats.TIME_24_WITH_SEC;
- return formatDate(date, format) + this._getUtcOffsetString();
+ const format = `dddd, ${this.dateFormat.full}, ${timeFormat}`;
+ return formatDate(date, format) + this.getUtcOffsetString();
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
index d7c38dfd6e..98f2d82155 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.ts
@@ -8,16 +8,15 @@ import './gr-date-formatter';
import {GrDateFormatter} from './gr-date-formatter';
import {parseDate} from '../../../utils/date-util';
import {fixture, html, assert} from '@open-wc/testing';
-import {query, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
import {Timestamp} from '../../../api/rest-api';
-import {PreferencesInfo} from '../../../types/common';
-import {createPreferences} from '../../../test/test-data-generators';
import {
createDefaultPreferences,
DateFormat,
TimeFormat,
} from '../../../constants/constants';
+import {PreferencesInfo} from '../../../types/common';
const basicTemplate = html`
<gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
@@ -41,6 +40,10 @@ suite('gr-date-formatter tests', () => {
return d;
}
+ function setPrefs(prefs: Partial<PreferencesInfo>) {
+ element.setPreferences({...createDefaultPreferences(), ...prefs});
+ }
+
async function testDates(
nowStr: string,
dateStr: string,
@@ -68,23 +71,11 @@ suite('gr-date-formatter tests', () => {
assert.equal(span.textContent?.trim(), expectedWithDateAndTime);
}
- function stubRestAPI(preferences?: PreferencesInfo) {
- stubRestApi('getLoggedIn').resolves(preferences !== undefined);
- stubRestApi('getPreferences').resolves(preferences);
- }
-
suite('STD + 24 hours time format preference', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_24,
- date_format: DateFormat.STD,
- relative_date_in_change_table: false,
- });
-
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.STD, time_format: TimeFormat.HHMM_24});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -93,7 +84,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- 'Jul 29, 2015, 15:34:14'
+ 'Wednesday, Jul 29, 2015, 15:34:14'
);
});
@@ -103,7 +94,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-28 20:25:14.985000000',
'Jul 28',
'Jul 28 20:25',
- 'Jul 28, 2015, 20:25:14'
+ 'Tuesday, Jul 28, 2015, 20:25:14'
);
});
@@ -113,7 +104,7 @@ suite('gr-date-formatter tests', () => {
'2015-06-15 03:25:14.985000000',
'Jun 15',
'Jun 15 03:25',
- 'Jun 15, 2015, 03:25:14'
+ 'Monday, Jun 15, 2015, 03:25:14'
);
});
@@ -123,22 +114,16 @@ suite('gr-date-formatter tests', () => {
'2015-01-15 03:25:00.000000000',
'Jan 15, 2015',
'Jan 15, 2015 03:25',
- 'Jan 15, 2015, 03:25:00'
+ 'Thursday, Jan 15, 2015, 03:25:00'
);
});
});
suite('US + 24 hours time format preference', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_24,
- date_format: DateFormat.US,
- relative_date_in_change_table: false,
- });
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.US, time_format: TimeFormat.HHMM_24});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -147,7 +132,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '07/29/15, 15:34:14'
+ 'Wednesday, 07/29/15, 15:34:14'
);
});
@@ -157,7 +142,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-28 20:25:14.985000000',
'07/28',
'07/28 20:25',
- '07/28/15, 20:25:14'
+ 'Tuesday, 07/28/15, 20:25:14'
);
});
@@ -167,23 +152,16 @@ suite('gr-date-formatter tests', () => {
'2015-06-15 03:25:14.985000000',
'06/15',
'06/15 03:25',
- '06/15/15, 03:25:14'
+ 'Monday, 06/15/15, 03:25:14'
);
});
});
suite('ISO + 24 hours time format preference', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_24,
- date_format: DateFormat.ISO,
- relative_date_in_change_table: false,
- });
-
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.ISO, time_format: TimeFormat.HHMM_24});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -192,7 +170,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '2015-07-29, 15:34:14'
+ 'Wednesday, 2015-07-29, 15:34:14'
);
});
@@ -202,7 +180,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-28 20:25:14.985000000',
'07-28',
'07-28 20:25',
- '2015-07-28, 20:25:14'
+ 'Tuesday, 2015-07-28, 20:25:14'
);
});
@@ -212,23 +190,16 @@ suite('gr-date-formatter tests', () => {
'2015-06-15 03:25:14.985000000',
'06-15',
'06-15 03:25',
- '2015-06-15, 03:25:14'
+ 'Monday, 2015-06-15, 03:25:14'
);
});
});
suite('EURO + 24 hours time format preference', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_24,
- date_format: DateFormat.EURO,
- relative_date_in_change_table: false,
- });
-
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.EURO, time_format: TimeFormat.HHMM_24});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -237,7 +208,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '29.07.2015, 15:34:14'
+ 'Wednesday, 29.07.2015, 15:34:14'
);
});
@@ -247,7 +218,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-28 20:25:14.985000000',
'28. Jul',
'28. Jul 20:25',
- '28.07.2015, 20:25:14'
+ 'Tuesday, 28.07.2015, 20:25:14'
);
});
@@ -257,23 +228,16 @@ suite('gr-date-formatter tests', () => {
'2015-06-15 03:25:14.985000000',
'15. Jun',
'15. Jun 03:25',
- '15.06.2015, 03:25:14'
+ 'Monday, 15.06.2015, 03:25:14'
);
});
});
suite('UK + 24 hours time format preference', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_24,
- date_format: DateFormat.UK,
- relative_date_in_change_table: false,
- });
-
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.UK, time_format: TimeFormat.HHMM_24});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -282,7 +246,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '29/07/2015, 15:34:14'
+ 'Wednesday, 29/07/2015, 15:34:14'
);
});
@@ -292,7 +256,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-28 20:25:14.985000000',
'28/07',
'28/07 20:25',
- '28/07/2015, 20:25:14'
+ 'Tuesday, 28/07/2015, 20:25:14'
);
});
@@ -302,22 +266,16 @@ suite('gr-date-formatter tests', () => {
'2015-06-15 03:25:14.985000000',
'15/06',
'15/06 03:25',
- '15/06/2015, 03:25:14'
+ 'Monday, 15/06/2015, 03:25:14'
);
});
});
suite('STD + 12 hours time format preference', () => {
setup(async () => {
- // relative_date_in_change_table is not set when false.
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
- date_format: DateFormat.STD,
- });
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.STD, time_format: TimeFormat.HHMM_12});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -326,22 +284,16 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- 'Jul 29, 2015, 3:34:14 PM'
+ 'Wednesday, Jul 29, 2015, 3:34:14 PM'
);
});
});
suite('US + 12 hours time format preference', () => {
setup(async () => {
- // relative_date_in_change_table is not set when false.
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
- date_format: DateFormat.US,
- });
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.US, time_format: TimeFormat.HHMM_12});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -350,22 +302,16 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '07/29/15, 3:34:14 PM'
+ 'Wednesday, 07/29/15, 3:34:14 PM'
);
});
});
suite('ISO + 12 hours time format preference', () => {
setup(async () => {
- // relative_date_in_change_table is not set when false.
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
- date_format: DateFormat.ISO,
- });
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.ISO, time_format: TimeFormat.HHMM_12});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -374,22 +320,16 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '2015-07-29, 3:34:14 PM'
+ 'Wednesday, 2015-07-29, 3:34:14 PM'
);
});
});
suite('EURO + 12 hours time format preference', () => {
setup(async () => {
- // relative_date_in_change_table is not set when false.
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
- date_format: DateFormat.EURO,
- });
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.EURO, time_format: TimeFormat.HHMM_12});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -398,22 +338,16 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '29.07.2015, 3:34:14 PM'
+ 'Wednesday, 29.07.2015, 3:34:14 PM'
);
});
});
suite('UK + 12 hours time format preference', () => {
setup(async () => {
- // relative_date_in_change_table is not set when false.
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
- date_format: DateFormat.UK,
- });
element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ setPrefs({date_format: DateFormat.UK, time_format: TimeFormat.HHMM_12});
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -422,22 +356,20 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '29/07/2015, 3:34:14 PM'
+ 'Wednesday, 29/07/2015, 3:34:14 PM'
);
});
});
suite('relative date preference', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
+ element = await fixture(basicTemplate);
+ setPrefs({
date_format: DateFormat.STD,
+ time_format: TimeFormat.HHMM_12,
relative_date_in_change_table: true,
});
- element = await fixture(basicTemplate);
- sinon.stub(element, '_getUtcOffsetString').returns('');
- await element.loadPreferences();
+ sinon.stub(element, 'getUtcOffsetString').returns('');
});
test('Within 24 hours on same day', async () => {
@@ -446,7 +378,7 @@ suite('gr-date-formatter tests', () => {
'2015-07-29 15:34:14.985000000',
'5 hours ago',
'5 hours ago',
- 'Jul 29, 2015, 3:34:14 PM'
+ 'Wednesday, Jul 29, 2015, 3:34:14 PM'
);
});
@@ -456,21 +388,19 @@ suite('gr-date-formatter tests', () => {
'2015-01-15 03:25:00.000000000',
'8 months ago',
'8 months ago',
- 'Jan 15, 2015, 3:25:00 AM'
+ 'Thursday, Jan 15, 2015, 3:25:00 AM'
);
});
});
suite('logged in', () => {
setup(async () => {
- stubRestAPI({
- ...createPreferences(),
- time_format: TimeFormat.HHMM_12,
+ element = await fixture(basicTemplate);
+ setPrefs({
date_format: DateFormat.US,
+ time_format: TimeFormat.HHMM_12,
relative_date_in_change_table: true,
});
- element = await fixture(basicTemplate);
- await element.loadPreferences();
});
test('Preferences are respected', () => {
@@ -483,13 +413,12 @@ suite('gr-date-formatter tests', () => {
suite('logged out', () => {
setup(async () => {
- stubRestAPI(undefined);
element = await fixture(basicTemplate);
- await element.loadPreferences();
+ setPrefs({});
});
test('Default preferences are respected', () => {
- assert.equal(element.timeFormat, 'HH:mm');
+ assert.equal(element.timeFormat, 'h:mm A');
assert.equal(element.dateFormat?.short, 'MMM DD');
assert.equal(element.dateFormat?.full, 'MMM DD, YYYY');
assert.isFalse(element.relative);
@@ -498,9 +427,8 @@ suite('gr-date-formatter tests', () => {
suite('with tooltip', () => {
setup(async () => {
- stubRestAPI(createDefaultPreferences());
element = await fixture(basicTemplate);
- await element.loadPreferences();
+ setPrefs({});
await element.updateComplete;
});
@@ -515,9 +443,8 @@ suite('gr-date-formatter tests', () => {
suite('without tooltip', () => {
setup(async () => {
- stubRestAPI(createDefaultPreferences());
element = await fixture(lightTemplate);
- await element.loadPreferences();
+ setPrefs({});
await element.updateComplete;
});
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index dbf75f4731..93201c8e9a 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -10,6 +10,7 @@ import {css, html, LitElement, PropertyValues} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
import {fontStyles} from '../../../styles/gr-font-styles';
import {when} from 'lit/directives/when.js';
+import {fireNoBubble} from '../../../utils/event-util';
declare global {
interface HTMLElementTagNameMap {
@@ -31,6 +32,9 @@ export class GrDialog extends LitElement {
* @event cancel
*/
+ @query('#cancel')
+ cancelButton?: GrButton;
+
@query('#confirm')
confirmButton?: GrButton;
@@ -102,6 +106,7 @@ export class GrDialog extends LitElement {
display: flex;
flex-shrink: 0;
padding-top: var(--spacing-xl);
+ align-items: center;
}
.flex-space {
flex-grow: 1;
@@ -147,6 +152,7 @@ export class GrDialog extends LitElement {
<span class="loadingLabel"> ${this.loadingLabel} </span>
`
)}
+ <slot name="footer"></slot>
<div class="flex-space"></div>
<gr-button
id="cancel"
@@ -195,23 +201,13 @@ export class GrDialog extends LitElement {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('confirm', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'confirm', {});
}
private handleCancelTap(e: Event) {
e.preventDefault();
e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('cancel', {
- composed: true,
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'cancel', {});
}
_handleKeydown(e: KeyboardEvent) {
@@ -221,6 +217,10 @@ export class GrDialog extends LitElement {
}
resetFocus() {
- this.confirmButton!.focus();
+ if (this.disabled && this.cancelLabel) {
+ this.cancelButton!.focus();
+ } else {
+ this.confirmButton!.focus();
+ }
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index d386c3247c..41fcfed979 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -36,6 +36,7 @@ suite('gr-dialog tests', () => {
</div>
</main>
<footer>
+ <slot name="footer"></slot>
<div class="flex-space"></div>
<gr-button
aria-disabled="false"
@@ -81,6 +82,7 @@ suite('gr-dialog tests', () => {
<span class="loadingSpin" aria-label="Loading!!" role="progressbar">
</span>
<span class="loadingLabel"> Loading!! </span>
+ <slot name="footer"></slot>
<div class="flex-space"></div>
<gr-button
aria-disabled="false"
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
index 15d7072e62..019bec1c68 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -8,7 +8,6 @@ import '../../../styles/shared-styles';
import '../gr-button/gr-button';
import '../gr-select/gr-select';
import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
-import {getAppContext} from '../../../services/app-context';
import {subscribe} from '../../lit/subscription-controller';
import {formStyles} from '../../../styles/gr-form-styles';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -18,6 +17,8 @@ import {convertToString} from '../../../utils/string-util';
import {fire} from '../../../utils/event-util';
import {ValueChangedEvent} from '../../../types/events';
import {GrSelect} from '../gr-select/gr-select';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
@customElement('gr-diff-preferences')
export class GrDiffPreferences extends LitElement {
@@ -51,13 +52,13 @@ export class GrDiffPreferences extends LitElement {
@state() private originalDiffPrefs?: DiffPreferencesInfo;
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
constructor() {
super();
subscribe(
this,
- () => this.userModel.diffPreferences$,
+ () => this.getUserModel().diffPreferences$,
diffPreferences => {
if (!diffPreferences) return;
this.originalDiffPrefs = diffPreferences;
@@ -314,7 +315,7 @@ export class GrDiffPreferences extends LitElement {
async save() {
if (!this.diffPrefs) return;
- await this.userModel.updateDiffPreference(this.diffPrefs);
+ await this.getUserModel().updateDiffPreference(this.diffPrefs);
fire(this, 'has-unsaved-changes-changed', {
value: this.hasUnsavedChanges(),
});
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
index 2d93227744..886894e9a2 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -16,6 +16,8 @@ import {LitElement, html, css} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
import {fire} from '../../../utils/event-util';
import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {userModelToken} from '../../../models/user/user-model';
declare global {
interface HTMLElementEventMap {
@@ -53,7 +55,7 @@ export class GrDownloadCommands extends LitElement {
private readonly restApiService = getAppContext().restApiService;
// Private but used in tests.
- readonly userModel = getAppContext().userModel;
+ readonly getUserModel = resolve(this, userModelToken);
private subscriptions: Subscription[] = [];
@@ -63,7 +65,7 @@ export class GrDownloadCommands extends LitElement {
this.loggedIn = loggedIn;
});
this.subscriptions.push(
- this.userModel.preferences$.subscribe(prefs => {
+ this.getUserModel().preferences$.subscribe(prefs => {
if (prefs?.download_scheme) {
// Note (issue 5180): normalize the download scheme with lower-case.
this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -194,7 +196,7 @@ export class GrDownloadCommands extends LitElement {
this.selectedScheme = scheme;
fire(this, 'selected-scheme-changed', {value: scheme});
if (this.loggedIn) {
- this.userModel.updatePreferences({
+ this.getUserModel().updatePreferences({
download_scheme: this.selectedScheme,
});
}
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
index 695c6745d3..b1d4e3600e 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -18,6 +18,8 @@ import {createDefaultPreferences} from '../../../constants/constants';
import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
import {fixture, html, assert} from '@open-wc/testing';
import {PaperTabElement} from '@polymer/paper-tabs/paper-tab';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
suite('gr-download-commands', () => {
let element: GrDownloadCommands;
@@ -170,11 +172,16 @@ suite('gr-download-commands', () => {
});
});
suite('authenticated', () => {
- test('loads scheme from preferences', async () => {
- const element: GrDownloadCommands = await fixture(
+ let element: GrDownloadCommands;
+ let userModel: UserModel;
+ setup(async () => {
+ userModel = testResolver(userModelToken);
+ element = await fixture(
html`<gr-download-commands></gr-download-commands>`
);
- element.userModel.setPreferences({
+ });
+ test('loads scheme from preferences', async () => {
+ userModel.setPreferences({
...createPreferences(),
download_scheme: 'repo',
});
@@ -182,10 +189,7 @@ suite('gr-download-commands', () => {
});
test('normalize scheme from preferences', async () => {
- const element: GrDownloadCommands = await fixture(
- html`<gr-download-commands></gr-download-commands>`
- );
- element.userModel.setPreferences({
+ userModel.setPreferences({
...createPreferences(),
download_scheme: 'REPO',
});
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
index b6ca9f5287..9b74e24221 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -14,7 +14,7 @@ import '../gr-file-status/gr-file-status';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {Timestamp} from '../../../types/common';
+import {CommentThread, Timestamp} from '../../../types/common';
import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
import {GrButton} from '../gr-button/gr-button';
import {assertIsDefined} from '../../../utils/common-util';
@@ -23,6 +23,7 @@ import {ValueChangedEvent} from '../../../types/events';
import {incrementalRepeat} from '../../lit/incremental-repeat';
import {when} from 'lit/directives/when.js';
import {isMagicPath} from '../../../utils/path-list-util';
+import {fireNoBubble} from '../../../utils/event-util';
/**
* Required values are text and value. mobileText and triggerText will
@@ -42,6 +43,7 @@ export interface DropdownItem {
date?: Timestamp;
disabled?: boolean;
file?: NormalizedFileInfo;
+ commentThreads?: CommentThread[];
}
declare global {
@@ -164,6 +166,9 @@ export class GrDropdownList extends LitElement {
--selection-background-color
);
}
+ gr-comments-summary {
+ padding-left: var(--spacing-s);
+ }
@media only screen and (max-width: 50em) {
gr-select {
display: var(--gr-select-style-display, inline);
@@ -250,7 +255,17 @@ export class GrDropdownList extends LitElement {
return html`
<paper-item ?disabled=${item.disabled} data-value=${item.value}>
<div class="topContent">
- <div>${item.text}</div>
+ <div>
+ <span>${item.text}</span>
+ ${when(
+ item.commentThreads,
+ () => html`<gr-comments-summary
+ .commentThreads=${item.commentThreads}
+ emptyWhenNoComments
+ showAvatarForResolved
+ ></gr-comments-summary>`
+ )}
+ </div>
${when(
item.date,
() => html`
@@ -303,12 +318,7 @@ export class GrDropdownList extends LitElement {
this.text = selectedObj.triggerText
? selectedObj.triggerText
: selectedObj.text;
- this.dispatchEvent(
- new CustomEvent('value-change', {
- detail: {value: this.value},
- bubbles: false,
- })
- );
+ fireNoBubble(this, 'value-change', {value: this.value});
}
/**
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
index b9380cb033..c148a1b947 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.ts
@@ -91,7 +91,7 @@ suite('gr-dropdown-list tests', () => {
tabindex="-1"
>
<div class="topContent">
- <div>Top Text 1</div>
+ <div><span>Top Text 1</span></div>
</div>
</paper-item>
<paper-item
@@ -103,7 +103,7 @@ suite('gr-dropdown-list tests', () => {
tabindex="0"
>
<div class="topContent">
- <div>Top Text 2</div>
+ <div><span>Top Text 2</span></div>
</div>
<div class="bottomContent">
<div>Bottom Text 2</div>
@@ -119,7 +119,7 @@ suite('gr-dropdown-list tests', () => {
tabindex="-1"
>
<div class="topContent">
- <div>Top Text 3</div>
+ <div><span>Top Text 3</span></div>
<gr-date-formatter> </gr-date-formatter>
</div>
<div class="bottomContent">
@@ -231,7 +231,8 @@ suite('gr-dropdown-list tests', () => {
assert.equal(items[0].dataset.value, element.items[0].value as any);
assert.equal(mobileItems[0].value, element.items[0].value);
assert.equal(
- queryAndAssert<HTMLDivElement>(items[0], '.topContent div').innerText,
+ queryAndAssert<HTMLDivElement>(items[0], '.topContent div span')
+ .innerText,
element.items[0].text
);
@@ -250,7 +251,8 @@ suite('gr-dropdown-list tests', () => {
assert.equal(items[1].dataset.value, element.items[1].value as any);
assert.equal(mobileItems[1].value, element.items[1].value);
assert.equal(
- queryAndAssert<HTMLDivElement>(items[1], '.topContent div').innerText,
+ queryAndAssert<HTMLDivElement>(items[1], '.topContent div span')
+ .textContent,
element.items[1].text
);
@@ -273,7 +275,8 @@ suite('gr-dropdown-list tests', () => {
assert.equal(items[2].dataset.value, element.items[2].value as any);
assert.equal(mobileItems[2].value, element.items[2].value);
assert.equal(
- queryAndAssert<HTMLDivElement>(items[2], '.topContent div').innerText,
+ queryAndAssert<HTMLDivElement>(items[2], '.topContent div span')
+ .innerText,
element.items[2].text
);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
index 3a8946aa5a..fbcd893176 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -21,6 +21,7 @@ import {fire} from '../../../utils/event-util';
import {ValueChangedEvent} from '../../../types/events';
import {assertIsDefined} from '../../../utils/common-util';
import {ShortcutController} from '../../lit/shortcut-controller';
+import {DropdownLink} from '../../../types/common';
const REL_NOOPENER = 'noopener';
const REL_EXTERNAL = 'external';
@@ -34,16 +35,6 @@ declare global {
}
}
-export interface DropdownLink {
- url?: string;
- name?: string;
- external?: boolean;
- target?: string | null;
- download?: boolean;
- id?: string;
- tooltip?: string;
-}
-
export interface DropdownContent {
text: string;
bold?: boolean;
@@ -242,7 +233,8 @@ export class GrDropdown extends LitElement {
allowOutsideScroll
.horizontalAlign=${this.horizontalAlign}
@click=${() => this.close()}
- @opened-changed=${(e: CustomEvent) => (this.opened = e.detail.value)}
+ @opened-changed=${(e: ValueChangedEvent<boolean>) =>
+ (this.opened = e.detail.value)}
>
${this.renderDropdownContent()}
</iron-dropdown>`;
@@ -460,13 +452,7 @@ export class GrDropdown extends LitElement {
const item = this.items.find(item => item.id === id);
if (id && !this.disabledIds.includes(id)) {
if (item) {
- this.dispatchEvent(
- new CustomEvent('tap-item', {
- detail: item,
- bubbles: true,
- composed: true,
- })
- );
+ fire(this, 'tap-item', item);
}
this.dispatchEvent(new CustomEvent('tap-item-' + id));
}
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index fab742f364..e9ef52bf2c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -5,11 +5,12 @@
*/
import '../../../test/common-test-setup';
import './gr-dropdown';
-import {DropdownLink, GrDropdown} from './gr-dropdown';
+import {GrDropdown} from './gr-dropdown';
import {pressKey, queryAll, queryAndAssert} from '../../../test/test-utils';
import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
import {assertIsDefined} from '../../../utils/common-util';
import {fixture, html, assert} from '@open-wc/testing';
+import {DropdownLink} from '../../../types/common';
suite('gr-dropdown tests', () => {
let element: GrDropdown;
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 d6a4d94573..3110a96b50 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
@@ -10,7 +10,7 @@ import '../gr-icon/gr-icon';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
import {getAppContext} from '../../../services/app-context';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {queryAndAssert} from '../../../utils/common-util';
@@ -21,11 +21,17 @@ import {customElement, property, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {css} from 'lit';
import {PropertyValues} from 'lit';
-import {BindValueChangeEvent, ValueChangedEvent} from '../../../types/events';
+import {
+ BindValueChangeEvent,
+ EditableContentSaveEvent,
+ ValueChangedEvent,
+} from '../../../types/events';
import {nothing} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
import {when} from 'lit/directives/when.js';
import {fontStyles} from '../../../styles/gr-font-styles';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {resolve} from '../../../models/dependency';
const RESTORED_MESSAGE = 'Content restored from a previous edit.';
const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -37,24 +43,16 @@ declare global {
interface HTMLElementEventMap {
'content-changed': ValueChangedEvent<string>;
'editing-changed': ValueChangedEvent<boolean>;
+ /** Fired when the 'cancel' button is pressed. */
+ 'editable-content-cancel': CustomEvent<{}>;
+ /** Fired when the 'save' button is pressed. */
+ 'editable-content-save': EditableContentSaveEvent;
}
}
@customElement('gr-editable-content')
export class GrEditableContent extends LitElement {
/**
- * Fired when the save button is pressed.
- *
- * @event editable-content-save
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event editable-content-cancel
- */
-
- /**
* Fired when content is restored from storage.
*
* @event show-alert
@@ -90,7 +88,7 @@ export class GrEditableContent extends LitElement {
@state() newContent = '';
- private readonly storage = getAppContext().storageService;
+ private readonly getStorage = resolve(this, storageServiceToken);
private readonly reporting = getAppContext().reportingService;
@@ -321,14 +319,14 @@ export class GrEditableContent extends LitElement {
this.storeTask,
() => {
if (this.newContent.length) {
- this.storage.setEditableContentItem(storageKey, this.newContent);
+ this.getStorage().setEditableContentItem(storageKey, this.newContent);
} else {
// This does not really happen, because we don't clear newContent
// after saving (see below). So this only occurs when the user clears
// all the content in the editable textarea. But GrStorage cleans
// up itself after one day, so we are not so concerned about leaving
// some garbage behind.
- this.storage.eraseEditableContentItem(storageKey);
+ this.getStorage().eraseEditableContentItem(storageKey);
}
},
STORAGE_DEBOUNCE_INTERVAL_MS
@@ -358,7 +356,7 @@ export class GrEditableContent extends LitElement {
let content;
if (this.storageKey) {
- const storedContent = this.storage.getEditableContentItem(
+ const storedContent = this.getStorage().getEditableContentItem(
this.storageKey
);
if (storedContent?.message) {
@@ -384,13 +382,7 @@ export class GrEditableContent extends LitElement {
handleSave(e: Event) {
e.preventDefault();
- this.dispatchEvent(
- new CustomEvent('editable-content-save', {
- detail: {content: this.newContent},
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'editable-content-save', {content: this.newContent});
// It would be nice, if we would set this.newContent = undefined here,
// but we can only do that when we are sure that the save operation has
// succeeded.
@@ -399,7 +391,7 @@ export class GrEditableContent extends LitElement {
handleCancel(e: Event) {
e.preventDefault();
this.editing = false;
- fireEvent(this, 'editable-content-cancel');
+ fire(this, 'editable-content-cancel', {});
}
toggleCommitCollapsed() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index fec347c646..4a5611b855 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
@@ -6,17 +6,21 @@
import '../../../test/common-test-setup';
import './gr-editable-content';
import {GrEditableContent} from './gr-editable-content';
-import {query, queryAndAssert, stubStorage} from '../../../test/test-utils';
+import {query, queryAndAssert} from '../../../test/test-utils';
import {GrButton} from '../gr-button/gr-button';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {StorageService} from '../../../services/storage/gr-storage';
+import {storageServiceToken} from '../../../services/storage/gr-storage_impl';
+import {testResolver} from '../../../test/common-test-setup';
suite('gr-editable-content tests', () => {
let element: GrEditableContent;
+ let storageService: StorageService;
setup(async () => {
element = await fixture(html`<gr-editable-content></gr-editable-content>`);
await element.updateComplete;
+ storageService = testResolver(storageServiceToken);
});
test('renders', () => {
@@ -177,7 +181,7 @@ suite('gr-editable-content tests', () => {
});
test('editing toggled to true, has stored data', async () => {
- stubStorage('getEditableContentItem').returns({
+ sinon.stub(storageService, 'getEditableContentItem').returns({
message: 'stored content',
updated: 0,
});
@@ -185,11 +189,11 @@ suite('gr-editable-content tests', () => {
await element.updateComplete;
assert.equal(element.newContent, 'stored content');
assert.isTrue(dispatchSpy.called);
- assert.equal(dispatchSpy.lastCall.args[0].type, EventType.SHOW_ALERT);
+ assert.equal(dispatchSpy.lastCall.args[0].type, 'show-alert');
});
test('editing toggled to true, has no stored data', async () => {
- stubStorage('getEditableContentItem').returns(null);
+ sinon.stub(storageService, 'getEditableContentItem').returns(null);
element.editing = true;
await element.updateComplete;
@@ -199,8 +203,8 @@ suite('gr-editable-content tests', () => {
});
test('edits are cached', async () => {
- const storeStub = stubStorage('setEditableContentItem');
- const eraseStub = stubStorage('eraseEditableContentItem');
+ const storeStub = sinon.stub(storageService, 'setEditableContentItem');
+ const eraseStub = sinon.stub(storageService, 'eraseEditableContentItem');
element.editing = true;
// Needed because editingChanged resets newContent
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
index bf8209b2b5..b82c02301b 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -21,6 +21,8 @@ import {sharedStyles} from '../../../styles/shared-styles';
import {PaperInputElement} from '@polymer/paper-input/paper-input';
import {IronInputElement} from '@polymer/iron-input';
import {ShortcutController} from '../../lit/shortcut-controller';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
const AWAIT_MAX_ITERS = 10;
const AWAIT_STEP = 5;
@@ -118,6 +120,7 @@ export class GrEditableLabel extends LitElement {
.inputContainer {
background-color: var(--dialog-background-color);
padding: var(--spacing-m);
+ white-space: nowrap;
}
/* This makes inputContainer on one line. */
.inputContainer gr-autocomplete,
@@ -207,7 +210,7 @@ export class GrEditableLabel extends LitElement {
.text=${this.inputText}
.query=${this.query}
@cancel=${this.cancel}
- @text-changed=${(e: CustomEvent) => {
+ @text-changed=${(e: ValueChangedEvent) => {
this.inputText = e.detail.value;
}}
>
@@ -308,13 +311,8 @@ export class GrEditableLabel extends LitElement {
this.value = this.inputText || '';
}
this.editing = false;
- this.dispatchEvent(
- new CustomEvent('changed', {
- detail: this.value,
- composed: true,
- bubbles: true,
- })
- );
+ // TODO: This event seems to be unused (no listener). Remove?
+ fire(this, 'changed', this.value);
}
private cancel() {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
index d916118cf3..3bb058ef85 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.ts
@@ -295,7 +295,10 @@ suite('gr-editable-label tests', () => {
suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
await element.open();
+ // Waiting until dropdown not hidden, will ensure dialog is open and input
+ // is focused, but not that the suggestion has loaded.
await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+ await autocomplete.latestSuggestionUpdateComplete;
pressKey(autocomplete.input!, Key.ENTER);
@@ -312,7 +315,11 @@ suite('gr-editable-label tests', () => {
test('autocomplete suggestions closed enter saves suggestion', async () => {
suggestions = [{name: 'value text 1'}, {name: 'value text 2'}];
await element.open();
+ // Waiting until dropdown not hidden, will ensure dialog is open and input
+ // is focused, but not that the suggestion has loaded.
await waitUntil(() => !autocomplete.suggestionsDropdown!.isHidden);
+ await autocomplete.latestSuggestionUpdateComplete;
+
// Press enter to close suggestions.
pressKey(autocomplete.input!, Key.ENTER);
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
index 578eda4091..943f4de43a 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status.ts
@@ -10,7 +10,7 @@ import {assertNever} from '../../../utils/common-util';
import '../gr-tooltip-content/gr-tooltip-content';
import '../gr-icon/gr-icon';
-function statusString(status: FileInfoStatus) {
+function statusString(status?: FileInfoStatus) {
if (!status) return '';
switch (status) {
case FileInfoStatus.ADDED:
@@ -115,15 +115,17 @@ export class GrFileStatus extends LitElement {
private renderStatus() {
const classes = ['status', this.status];
- return html`<gr-tooltip-content title=${this.computeLabel()} has-tooltip>
- <div
- class=${classes.join(' ')}
- tabindex="0"
- aria-label=${this.computeLabel()}
+ return html`
+ <gr-tooltip-content
+ title=${this.computeLabel()}
+ has-tooltip
+ aria-label=${statusString(this.status)}
>
- ${this.renderIconOrLetter()}
- </div>
- </gr-tooltip-content>`;
+ <div class=${classes.join(' ')} aria-hidden="true">
+ ${this.renderIconOrLetter()}
+ </div>
+ </gr-tooltip-content>
+ `;
}
private renderIconOrLetter() {
@@ -135,13 +137,15 @@ export class GrFileStatus extends LitElement {
private renderNewlyChanged() {
if (!this.newlyChanged) return;
- return html`<gr-tooltip-content title=${this.computeLabel()} has-tooltip>
- <gr-icon
- icon="new_releases"
- class="size-16"
- aria-label=${this.computeLabel()}
- ></gr-icon>
- </gr-tooltip-content>`;
+ return html`
+ <gr-tooltip-content
+ title=${this.computeLabel()}
+ has-tooltip
+ aria-label="newly"
+ >
+ <gr-icon icon="new_releases" class="size-16"></gr-icon>
+ </gr-tooltip-content>
+ `;
}
private computeLabel() {
diff --git a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
index 3bf877e3f6..555b2371c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-file-status/gr-file-status_test.ts
@@ -28,8 +28,8 @@ suite('gr-file-status tests', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-tooltip-content has-tooltip="" title="">
- <div class="status" aria-label="" tabindex="0"><span></span></div>
+ <gr-tooltip-content has-tooltip="" title="" aria-label="">
+ <div class="status" aria-hidden="true"><span></span></div>
</gr-tooltip-content>
`
);
@@ -40,8 +40,8 @@ suite('gr-file-status tests', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-tooltip-content has-tooltip="" title="Added">
- <div class="A status" aria-label="Added" tabindex="0">
+ <gr-tooltip-content has-tooltip="" title="Added" aria-label="Added">
+ <div class="A status" aria-hidden="true">
<span>A</span>
</div>
</gr-tooltip-content>
@@ -54,15 +54,19 @@ suite('gr-file-status tests', () => {
assert.shadowDom.equal(
element,
/* HTML */ `
- <gr-tooltip-content has-tooltip="" title="Newly Added">
- <gr-icon
- icon="new_releases"
- class="size-16"
- aria-label="Newly Added"
- ></gr-icon>
+ <gr-tooltip-content
+ has-tooltip=""
+ title="Newly Added"
+ aria-label="newly"
+ >
+ <gr-icon icon="new_releases" class="size-16"></gr-icon>
</gr-tooltip-content>
- <gr-tooltip-content has-tooltip="" title="Newly Added">
- <div class="A status" aria-label="Newly Added" tabindex="0">
+ <gr-tooltip-content
+ has-tooltip=""
+ title="Newly Added"
+ aria-label="Added"
+ >
+ <div class="A status" aria-hidden="true">
<span>A</span>
</div>
</gr-tooltip-content>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 5a1db30116..a01f349627 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -18,8 +18,13 @@ import {configModelToken} from '../../../models/config/config-model';
import {CommentLinks, EmailAddress} from '../../../api/rest-api';
import {linkifyUrlsAndApplyRewrite} from '../../../utils/link-util';
import '../gr-account-chip/gr-account-chip';
+import '../gr-user-suggestion-fix/gr-user-suggestion-fix';
import {KnownExperimentId} from '../../../services/flags/flags';
import {getAppContext} from '../../../services/app-context';
+import {
+ getUserSuggestionFromString,
+ USER_SUGGESTION_INFO_STRING,
+} from '../../../utils/comment-util';
/**
* This element optionally renders markdown and also applies some regex
@@ -40,6 +45,12 @@ export class GrFormattedText extends LitElement {
private readonly getConfigModel = resolve(this, configModelToken);
+ // Private const but used in tests.
+ // Limit the length of markdown because otherwise the markdown lexer will
+ // run out of memory causing the tab to crash.
+ @state()
+ MARKDOWN_LIMIT = 100000;
+
/**
* Note: Do not use sharedStyles or other styles here that should not affect
* the generated HTML of the markdown.
@@ -84,11 +95,6 @@ export class GrFormattedText extends LitElement {
:not(pre) > code {
display: inline;
}
- p {
- /* prose will automatically wrap but inline <code> blocks won't and we
- should overflow in that case rather than wrapping or leaking out */
- overflow-x: auto;
- }
li {
margin-left: var(--spacing-xl);
}
@@ -100,6 +106,14 @@ export class GrFormattedText extends LitElement {
white-space: var(--linked-text-white-space, pre-wrap);
word-wrap: var(--linked-text-word-wrap, break-word);
}
+ .markdown-html {
+ /* code overrides white-space to pre, everything else should wrap as
+ normal. */
+ white-space: normal;
+ /* prose will automatically wrap but inline <code> blocks won't and we
+ should overflow in that case rather than wrapping or leaking out */
+ overflow-x: auto;
+ }
`,
];
@@ -108,12 +122,20 @@ export class GrFormattedText extends LitElement {
subscribe(
this,
() => this.getConfigModel().repoCommentLinks$,
- repoCommentLinks => (this.repoCommentLinks = repoCommentLinks)
+ repoCommentLinks => {
+ this.repoCommentLinks = repoCommentLinks;
+ // Always linkify URLs starting with https?://
+ this.repoCommentLinks['ALWAYS_LINK_HTTP'] = {
+ match: '(https?://\\S+[\\w/~-])',
+ link: '$1',
+ enabled: true,
+ };
+ }
);
}
override render() {
- if (this.markdown) {
+ if (this.markdown && this.content.length < this.MARKDOWN_LIMIT) {
return this.renderAsMarkdown();
} else {
return this.renderAsPlaintext();
@@ -132,11 +154,36 @@ export class GrFormattedText extends LitElement {
}
private renderAsMarkdown() {
- // <marked-element> internals will be in charge of calling our custom
- // renderer so we wrap 'this.rewriteText' so that 'this' is preserved via
- // closure.
- const boundRewriteText = (text: string) =>
- linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks);
+ // Need to find out here, since customRender is not arrow function
+ const suggestEditsEnable = this.flagsService.isEnabled(
+ KnownExperimentId.SUGGEST_EDIT
+ );
+ // Bind `this` via closure.
+ const boundRewriteText = (text: string) => {
+ const nonAsteriskRewrites = Object.fromEntries(
+ Object.entries(this.repoCommentLinks).filter(
+ ([_name, rewrite]) => !rewrite.match.includes('\\*')
+ )
+ );
+ return linkifyUrlsAndApplyRewrite(text, nonAsteriskRewrites);
+ };
+
+ // Due to a tokenizer bug in the old version of markedjs we use, text with a
+ // single asterisk is separated into 2 tokens before passing to renderer
+ // ['text'] which breaks our rewrites that would span across the 2 tokens.
+ // Since upgrading our markedjs version is infeasible, we are applying those
+ // asterisk rewrites again at the end (using renderer['paragraph'] hook)
+ // after all the nodes are combined.
+ // Bind `this` via closure.
+ const boundRewriteAsterisks = (text: string) => {
+ const asteriskRewrites = Object.fromEntries(
+ Object.entries(this.repoCommentLinks).filter(([_name, rewrite]) =>
+ rewrite.match.includes('\\*')
+ )
+ );
+ const linkedText = linkifyUrlsAndApplyRewrite(text, asteriskRewrites);
+ return `<p>${linkedText}</p>`;
+ };
// We are overriding some marked-element renderers for a few reasons:
// 1. Disable inline images as a design/policy choice.
@@ -165,7 +212,23 @@ export class GrFormattedText extends LitElement {
`![${text}](${href})`;
renderer['codespan'] = (text: string) =>
`<code>${unescapeHTML(text)}</code>`;
- renderer['code'] = (text: string) => `<pre><code>${text}</code></pre>`;
+ renderer['code'] = (text: string, infostring: string) => {
+ if (suggestEditsEnable && infostring === USER_SUGGESTION_INFO_STRING) {
+ // default santizer in markedjs is very restrictive, we need to use
+ // existing html element to mark element. We cannot use css class for
+ // it. Therefore we pick mark - as not frequently used html element to
+ // represent unconverted gr-user-suggestion-fix.
+ // TODO(milutin): Find a way to override sanitizer to directly use
+ // gr-user-suggestion-fix
+ return `<mark>${text}</mark>`;
+ } else {
+ return `<pre><code>${text}</code></pre>`;
+ }
+ };
+ // <marked-element> internals will be in charge of calling our custom
+ // renderer so we write these functions separately so that 'this' is
+ // preserved via closure.
+ renderer['paragraph'] = boundRewriteAsterisks;
renderer['text'] = boundRewriteText;
}
@@ -181,7 +244,7 @@ export class GrFormattedText extends LitElement {
.callback=${(_error: string | null, contents: string) =>
sanitizeHtml(contents)}
>
- <div slot="markdown-html"></div>
+ <div class="markdown-html" slot="markdown-html"></div>
</marked-element>
`;
}
@@ -192,15 +255,25 @@ export class GrFormattedText extends LitElement {
text = htmlEscape(text).toString();
// Unescape block quotes '>'. This is slightly dangerous as '>' can be used
// in HTML fragments, but it is insufficient on it's own.
- text = text.replace(/(^|\n)&gt;/g, '$1>');
+ for (;;) {
+ const newText = text.replace(
+ /(^|\n)((?:\s{0,3}&gt;)*\s{0,3})&gt;/g,
+ '$1$2>'
+ );
+ if (newText === text) {
+ break;
+ }
+ text = newText;
+ }
return text;
}
override updated() {
// Look for @mentions and replace them with an account-label chip.
- if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- this.convertEmailsToAccountChips();
+ this.convertEmailsToAccountChips();
+ if (this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+ this.convertCodeToSuggestions();
}
}
@@ -226,6 +299,24 @@ export class GrFormattedText extends LitElement {
}
}
}
+
+ private convertCodeToSuggestions() {
+ const marks = this.renderRoot.querySelectorAll('mark');
+ if (marks.length > 0) {
+ const userSuggestionMark = marks[0];
+ const userSuggestion = document.createElement('gr-user-suggestion-fix');
+ // Temporary workaround for bug - tabs replacement
+ if (this.content.includes('\t')) {
+ userSuggestion.textContent = getUserSuggestionFromString(this.content);
+ } else {
+ userSuggestion.textContent = userSuggestionMark.textContent ?? '';
+ }
+ userSuggestionMark.parentNode?.replaceChild(
+ userSuggestion,
+ userSuggestionMark
+ );
+ }
+ }
}
declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
index 67a94c6f4a..206082ba50 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -15,14 +15,9 @@ import {getAppContext} from '../../../services/app-context';
import './gr-formatted-text';
import {GrFormattedText} from './gr-formatted-text';
import {createConfig} from '../../../test/test-data-generators';
-import {
- queryAndAssert,
- stubFlags,
- waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
import {CommentLinks, EmailAddress} from '../../../api/rest-api';
import {testResolver} from '../../../test/common-test-setup';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
suite('gr-formatted-text tests', () => {
@@ -47,10 +42,6 @@ suite('gr-formatted-text tests', () => {
match: '(LinkRewriteMe)',
link: 'http://google.com/$1',
},
- customHtmlRewrite: {
- match: 'HTMLRewriteMe',
- html: '<div>HTMLRewritten</div>',
- },
complexLinkRewrite: {
match: '(^|\\s)A Link (\\d+)($|\\s)',
link: '/page?id=$2',
@@ -101,11 +92,13 @@ suite('gr-formatted-text tests', () => {
await setCommentLinks({
capitalizeFoo: {
match: 'foo',
- html: 'FOO',
+ prefix: 'FOO',
+ link: 'a.b.c',
},
lowercaseFoo: {
match: 'FOO',
- html: 'foo',
+ prefix: 'foo',
+ link: 'c.d.e',
},
});
element.content = 'foo';
@@ -115,9 +108,8 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<pre class="plaintext">
- FOO
- </pre
- >
+ FOO<a href="a.b.c" rel="noopener" target="_blank">foo</a>
+ </pre>
`
);
});
@@ -126,11 +118,15 @@ suite('gr-formatted-text tests', () => {
await setCommentLinks({
bracketNum: {
match: '(Start:) ([0-9]+)',
- html: '$1 [$2]',
+ prefix: '$1 ',
+ link: 'bug/$2',
+ text: 'bug/$2',
},
bracketNum2: {
match: '(Start: [0-9]+) ([0-9]+)',
- html: '$1 [$2]',
+ prefix: '$1 ',
+ link: 'bug/$2',
+ text: 'bug/$2',
},
});
element.content = 'Start: 123 456';
@@ -140,9 +136,14 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<pre class="plaintext">
- Start: [123] [456]
- </pre
- >
+ Start:
+ <a href="bug/123" rel="noopener" target="_blank">
+ bug/123
+ </a>
+ <a href="bug/456" rel="noopener" target="_blank">
+ bug/456
+ </a>
+ </pre>
`
);
});
@@ -151,8 +152,7 @@ suite('gr-formatted-text tests', () => {
element.content = `
text with plain link: http://google.com
text with config link: LinkRewriteMe
- text with complex link: A Link 12
- text with config html: HTMLRewriteMe`;
+ text with complex link: A Link 12`;
await element.updateComplete;
assert.shadowDom.equal(
@@ -183,8 +183,6 @@ suite('gr-formatted-text tests', () => {
>
Link 12
</a>
- text with config html:
- <div>HTMLRewritten</div>
</pre>
`
);
@@ -210,6 +208,22 @@ suite('gr-formatted-text tests', () => {
/* HTML */ '<pre class="plaintext"># A Markdown Heading</pre>'
);
});
+
+ test('does default linking', async () => {
+ const checkLinking = async (url: string) => {
+ element.content = url;
+ await element.updateComplete;
+ const a = queryAndAssert<HTMLElement>(element, 'a');
+ assert.equal(a.getAttribute('href'), url);
+ assert.equal(a.innerText, url);
+ };
+
+ await checkLinking('http://www.google.com');
+ await checkLinking('https://www.google.com');
+ await checkLinking('https://www.google.com/');
+ await checkLinking('https://www.google.com/asdf~');
+ await checkLinking('https://www.google.com/asdf-');
+ });
});
suite('as markdown', () => {
@@ -222,15 +236,14 @@ suite('gr-formatted-text tests', () => {
\ntext with plain link: http://google.com
\ntext with config link: LinkRewriteMe
\ntext without a link: NotA Link 15 cats
- \ntext with complex link: A Link 12
- \ntext with config html: HTMLRewriteMe`;
+ \ntext with complex link: A Link 12`;
await element.updateComplete;
assert.shadowDom.equal(
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>text</p>
<p>
text with plain link:
@@ -259,15 +272,56 @@ suite('gr-formatted-text tests', () => {
Link 12
</a>
</p>
- <p>text with config html:</p>
- <div>HTMLRewritten</div>
- <p></p>
</div>
</marked-element>
`
);
});
+ test('does not render if too long', async () => {
+ element.content = `text
+ text with plain link: http://google.com
+ text with config link: LinkRewriteMe
+ text without a link: NotA Link 15 cats
+ text with complex link: A Link 12`;
+ element.MARKDOWN_LIMIT = 10;
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <pre class="plaintext">
+ text
+ text with plain link:
+ <a
+ href="http://google.com"
+ rel="noopener"
+ target="_blank"
+ >
+ http://google.com
+ </a>
+ text with config link:
+ <a
+ href="http://google.com/LinkRewriteMe"
+ rel="noopener"
+ target="_blank"
+ >
+ LinkRewriteMe
+ </a>
+ text without a link: NotA Link 15 cats
+ text with complex link: A
+ <a
+ href="http://localhost/page?id=12"
+ rel="noopener"
+ target="_blank"
+ >
+ Link 12
+ </a>
+ </pre>
+ `
+ );
+ });
+
test('renders headings with links and rewrites', async () => {
element.content = `# h1-heading
\n## h2-heading
@@ -276,15 +330,14 @@ suite('gr-formatted-text tests', () => {
\n##### h5-heading
\n###### h6-heading
\n# heading with plain link: http://google.com
- \n# heading with config link: LinkRewriteMe
- \n# heading with config html: HTMLRewriteMe`;
+ \n# heading with config link: LinkRewriteMe`;
await element.updateComplete;
assert.shadowDom.equal(
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<h1>h1-heading</h1>
<h2>h2-heading</h2>
<h3>h3-heading</h3>
@@ -307,10 +360,6 @@ suite('gr-formatted-text tests', () => {
LinkRewriteMe
</a>
</h1>
- <h1>
- heading with config html:
- <div>HTMLRewritten</div>
- </h1>
</div>
</marked-element>
`
@@ -320,15 +369,14 @@ suite('gr-formatted-text tests', () => {
test('renders inline-code without linking or rewriting', async () => {
element.content = `\`inline code\`
\n\`inline code with plain link: google.com\`
- \n\`inline code with config link: LinkRewriteMe\`
- \n\`inline code with config html: HTMLRewriteMe\``;
+ \n\`inline code with config link: LinkRewriteMe\``;
await element.updateComplete;
assert.shadowDom.equal(
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>
<code>inline code</code>
</p>
@@ -338,9 +386,6 @@ suite('gr-formatted-text tests', () => {
<p>
<code>inline code with config link: LinkRewriteMe</code>
</p>
- <p>
- <code>inline code with config html: HTMLRewriteMe</code>
- </p>
</div>
</marked-element>
`
@@ -350,15 +395,14 @@ suite('gr-formatted-text tests', () => {
test('renders multiline-code without linking or rewriting', async () => {
element.content = `\`\`\`\nmultiline code\n\`\`\`
\n\`\`\`\nmultiline code with plain link: google.com\n\`\`\`
- \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\`
- \n\`\`\`\nmultiline code with config html: HTMLRewriteMe\n\`\`\``;
+ \n\`\`\`\nmultiline code with config link: LinkRewriteMe\n\`\`\``;
await element.updateComplete;
assert.shadowDom.equal(
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<pre>
<code>multiline code</code>
</pre>
@@ -368,9 +412,6 @@ suite('gr-formatted-text tests', () => {
<pre>
<code>multiline code with config link: LinkRewriteMe</code>
</pre>
- <pre>
- <code>multiline code with config html: HTMLRewriteMe</code>
- </pre>
</div>
</marked-element>
`
@@ -385,7 +426,7 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>![img](google.com/img.png)</p>
</div>
</marked-element>
@@ -393,38 +434,7 @@ suite('gr-formatted-text tests', () => {
);
});
- test('does not handle @mentions if not enabled', async () => {
- stubFlags('isEnabled')
- .withArgs(KnownExperimentId.MENTION_USERS)
- .returns(false);
- element.content = '@someone@google.com';
- await element.updateComplete;
-
- assert.shadowDom.equal(
- element,
- /* HTML */ `
- <marked-element>
- <div slot="markdown-html">
- <p>
- @
- <a
- href="mailto:someone@google.com"
- rel="noopener"
- target="_blank"
- >
- someone@google.com
- </a>
- </p>
- </div>
- </marked-element>
- `
- );
- });
-
- test('handles @mentions if enabled', async () => {
- stubFlags('isEnabled')
- .withArgs(KnownExperimentId.MENTION_USERS)
- .returns(true);
+ test('handles @mentions', async () => {
element.content = '@someone@google.com';
await element.updateComplete;
@@ -432,7 +442,7 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>
<gr-account-chip></gr-account-chip>
</p>
@@ -451,9 +461,6 @@ suite('gr-formatted-text tests', () => {
});
test('does not handle @mentions that is part of a code block', async () => {
- stubFlags('isEnabled')
- .withArgs(KnownExperimentId.MENTION_USERS)
- .returns(true);
element.content = '`@`someone@google.com';
await element.updateComplete;
@@ -461,7 +468,7 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>
<code>@</code>
<a
@@ -486,7 +493,7 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>
<a href="https://www.google.com" rel="noopener" target="_blank"
>myLink</a
@@ -501,15 +508,14 @@ suite('gr-formatted-text tests', () => {
test('renders block quotes with links and rewrites', async () => {
element.content = `> block quote
\n> block quote with plain link: http://google.com
- \n> block quote with config link: LinkRewriteMe
- \n> block quote with config html: HTMLRewriteMe`;
+ \n> block quote with config link: LinkRewriteMe`;
await element.updateComplete;
assert.shadowDom.equal(
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<blockquote>
<p>block quote</p>
</blockquote>
@@ -533,11 +539,6 @@ suite('gr-formatted-text tests', () => {
</a>
</p>
</blockquote>
- <blockquote>
- <p>block quote with config html:</p>
- <div>HTMLRewritten</div>
- <p></p>
- </blockquote>
</div>
</marked-element>
`
@@ -557,7 +558,7 @@ suite('gr-formatted-text tests', () => {
element,
/* HTML */ `
<marked-element>
- <div slot="markdown-html">
+ <div slot="markdown-html" class="markdown-html">
<p>plain text ${escapedDiv}</p>
<p>
<code>inline code ${escapedDiv}</code>
@@ -580,5 +581,90 @@ suite('gr-formatted-text tests', () => {
`
);
});
+
+ test('renders nested block quotes', async () => {
+ element.content = '> > > block quote';
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <marked-element>
+ <div slot="markdown-html" class="markdown-html">
+ <blockquote>
+ <blockquote>
+ <blockquote>
+ <p>block quote</p>
+ </blockquote>
+ </blockquote>
+ </blockquote>
+ </div>
+ </marked-element>
+ `
+ );
+ });
+
+ test('renders rewrites with an asterisk', async () => {
+ await setCommentLinks({
+ customLinkRewrite: {
+ match: 'asterisks (\\*) rule',
+ link: 'http://google.com',
+ },
+ });
+
+ element.content = 'I think asterisks * rule';
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <marked-element>
+ <div slot="markdown-html" class="markdown-html">
+ <p>
+ I think
+ <a href="http://google.com" rel="noopener" target="_blank"
+ >asterisks * rule</a
+ >
+ </p>
+ </div>
+ </marked-element>
+ `
+ );
+ });
+
+ test('does default linking', async () => {
+ const checkLinking = async (url: string) => {
+ element.content = url;
+ await element.updateComplete;
+ const a = queryAndAssert<HTMLElement>(element, 'a');
+ const p = queryAndAssert<HTMLElement>(element, 'p');
+ assert.equal(a.getAttribute('href'), url);
+ assert.equal(p.innerText, url);
+ };
+
+ await checkLinking('http://www.google.com');
+ await checkLinking('https://www.google.com');
+ await checkLinking('https://www.google.com/');
+ });
+
+ suite('user suggest fix', () => {
+ setup(async () => {
+ const flagsService = getAppContext().flagsService;
+ sinon.stub(flagsService, 'isEnabled').returns(true);
+ });
+
+ test('renders', async () => {
+ element.content = '```suggestion\nHello World```';
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `<marked-element>
+ <div class="markdown-html" slot="markdown-html">
+ <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+ </div>
+ </marked-element>`
+ );
+ });
+ });
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
new file mode 100644
index 0000000000..394015e9c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents.ts
@@ -0,0 +1,579 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-avatar/gr-avatar';
+import '../gr-button/gr-button';
+import '../gr-icon/gr-icon';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import {getAppContext} from '../../../services/app-context';
+import {
+ accountKey,
+ computeVoteableText,
+ isAccountEmailOnly,
+ isSelf,
+} from '../../../utils/account-util';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ServerInfo,
+ ReviewInput,
+} from '../../../types/common';
+import {
+ canHaveAttention,
+ getAddedByReason,
+ getLastUpdate,
+ getReason,
+ getRemovedByReason,
+ hasAttention,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {CURRENT} from '../../../utils/patch-set-util';
+import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement, nothing} from 'lit';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {subscribe} from '../../lit/subscription-controller';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {createSearchUrl} from '../../../models/views/search';
+import {createDashboardUrl} from '../../../models/views/dashboard';
+import {fire, fireReload} from '../../../utils/event-util';
+import {userModelToken} from '../../../models/user/user-model';
+
+@customElement('gr-hovercard-account-contents')
+export class GrHovercardAccountContents extends LitElement {
+ @property({type: Object})
+ account!: AccountInfo;
+
+ @state()
+ selfAccount?: AccountInfo;
+
+ /**
+ * Optional ChangeInfo object, typically comes from the change page or
+ * from a row in a list of search results. This is needed for some change
+ * related features like adding the user as a reviewer.
+ */
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ /**
+ * Should attention set related features be shown in the component? Note
+ * that the information whether the user is in the attention set or not is
+ * part of the ChangeInfo object in the change property.
+ */
+ @property({type: Boolean})
+ highlightAttention = false;
+
+ @state()
+ serverConfig?: ServerInfo;
+
+ private readonly restApiService = getAppContext().restApiService;
+
+ private readonly reporting = getAppContext().reportingService;
+
+ private readonly getUserModel = resolve(this, userModelToken);
+
+ private readonly getConfigModel = resolve(this, configModelToken);
+
+ constructor() {
+ super();
+ subscribe(
+ this,
+ () => this.getUserModel().account$,
+ x => (this.selfAccount = x)
+ );
+ subscribe(
+ this,
+ () => this.getConfigModel().serverConfig$,
+ config => {
+ this.serverConfig = config;
+ }
+ );
+ }
+
+ static override get styles() {
+ return [
+ sharedStyles,
+ fontStyles,
+ css`
+ .top,
+ .attention,
+ .status,
+ .voteable {
+ padding: var(--spacing-s) var(--spacing-l);
+ }
+ .links {
+ padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
+ }
+ .top {
+ display: flex;
+ padding-top: var(--spacing-xl);
+ min-width: 300px;
+ }
+ gr-avatar {
+ height: 48px;
+ width: 48px;
+ margin-right: var(--spacing-l);
+ }
+ .title,
+ .email {
+ color: var(--deemphasized-text-color);
+ }
+ .action {
+ border-top: 1px solid var(--border-color);
+ padding: var(--spacing-s) var(--spacing-l);
+ --gr-button-padding: var(--spacing-s) var(--spacing-m);
+ }
+ .attention {
+ background-color: var(--emphasis-color);
+ }
+ .attention a {
+ text-decoration: none;
+ }
+ .status gr-icon {
+ font-size: 14px;
+ position: relative;
+ top: 2px;
+ }
+ gr-icon.attentionIcon {
+ transform: scaleX(0.8);
+ }
+ gr-icon.linkIcon {
+ font-size: var(--line-height-normal, 20px);
+ color: var(--deemphasized-text-color);
+ padding-right: 12px;
+ }
+ .links a {
+ color: var(--link-color);
+ padding: 0px 4px;
+ }
+ .reason {
+ padding-top: var(--spacing-s);
+ }
+ .status .value {
+ white-space: pre-wrap;
+ }
+ /* Make sure that users cannot break the layout with super long
+ "About Me" texts. */
+ div.status {
+ max-height: 8em;
+ overflow-y: auto;
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ return html`
+ <div class="top">
+ <div class="avatar">
+ <gr-avatar .account=${this.account} .imageSize=${56}></gr-avatar>
+ </div>
+ <div class="account">
+ <h3 class="name heading-3">${this.account.name}</h3>
+ <div class="email">${this.account.email}</div>
+ </div>
+ </div>
+ ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
+ ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
+ `;
+ }
+
+ private renderChangeRelatedInfoAndActions() {
+ if (this.change === undefined) {
+ return nothing;
+ }
+ const voteableText = computeVoteableText(this.change, this.account);
+ return html`
+ ${voteableText
+ ? html`
+ <div class="voteable">
+ <span class="title">Voteable:</span>
+ <span class="value">${voteableText}</span>
+ </div>
+ `
+ : ''}
+ ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
+ ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
+ `;
+ }
+
+ private renderReviewerOrCcActions() {
+ // `selfAccount` is required so that logged out users can't perform actions.
+ if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
+ return nothing;
+ return html`
+ <div class="action">
+ <gr-button
+ class="removeReviewerOrCC"
+ link
+ no-uppercase
+ @click=${this.handleRemoveReviewerOrCC}
+ >
+ Remove ${this.computeReviewerOrCCText()}
+ </gr-button>
+ </div>
+ <div class="action">
+ <gr-button
+ class="changeReviewerOrCC"
+ link
+ no-uppercase
+ @click=${this.handleChangeReviewerOrCCStatus}
+ >
+ ${this.computeChangeReviewerOrCCText()}
+ </gr-button>
+ </div>
+ `;
+ }
+
+ private renderAccountStatusPlugins() {
+ return html`
+ <gr-endpoint-decorator name="hovercard-status">
+ <gr-endpoint-param
+ name="account"
+ .value=${this.account}
+ ></gr-endpoint-param>
+ </gr-endpoint-decorator>
+ `;
+ }
+
+ private renderLinks() {
+ if (!this.account || isAccountEmailOnly(this.account)) return nothing;
+ return html` <div class="links">
+ <gr-icon icon="link" class="linkIcon"></gr-icon>
+ <a
+ href=${ifDefined(this.computeOwnerChangesLink())}
+ @click=${() => {
+ fire(this, 'link-clicked', {});
+ }}
+ @enter=${() => {
+ fire(this, 'link-clicked', {});
+ }}
+ >
+ Changes
+ </a>
+ ·
+ <a
+ href=${ifDefined(this.computeOwnerDashboardLink())}
+ @click=${() => {
+ fire(this, 'link-clicked', {});
+ }}
+ @enter=${() => {
+ fire(this, 'link-clicked', {});
+ }}
+ >
+ Dashboard
+ </a>
+ </div>`;
+ }
+
+ private renderAccountStatus() {
+ if (!this.account.status) return nothing;
+ return html`
+ <div class="status">
+ <span class="title">About me:</span>
+ <span class="value">${this.account.status.trim()}</span>
+ </div>
+ `;
+ }
+
+ private renderNeedsAttention() {
+ if (!(this.isAttentionEnabled && this.hasUserAttention)) return nothing;
+ const lastUpdate = getLastUpdate(this.account, this.change);
+ return html`
+ <div class="attention">
+ <div>
+ <gr-icon
+ icon="label_important"
+ filled
+ small
+ class="attentionIcon"
+ ></gr-icon>
+ <span> ${this.computePronoun()} turn to take action. </span>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+ target="_blank"
+ >
+ <gr-icon icon="help" title="read documentation"></gr-icon>
+ </a>
+ </div>
+ <div class="reason">
+ <span class="title">Reason:</span>
+ <span class="value">
+ ${getReason(this.serverConfig, this.account, this.change)}
+ </span>
+ ${lastUpdate
+ ? html` (
+ <gr-date-formatter
+ withTooltip
+ .dateStr=${lastUpdate}
+ ></gr-date-formatter>
+ )`
+ : ''}
+ </div>
+ </div>
+ `;
+ }
+
+ private renderAddToAttention() {
+ if (!this.computeShowActionAddToAttentionSet()) return nothing;
+ return html`
+ <div class="action">
+ <gr-button
+ class="addToAttentionSet"
+ link
+ no-uppercase
+ @click=${this.handleClickAddToAttentionSet}
+ >
+ Add to attention set
+ </gr-button>
+ </div>
+ `;
+ }
+
+ private renderRemoveFromAttention() {
+ if (!this.computeShowActionRemoveFromAttentionSet()) return nothing;
+ return html`
+ <div class="action">
+ <gr-button
+ class="removeFromAttentionSet"
+ link
+ no-uppercase
+ @click=${this.handleClickRemoveFromAttentionSet}
+ >
+ Remove from attention set
+ </gr-button>
+ </div>
+ `;
+ }
+
+ // private but used by tests
+ computePronoun() {
+ if (!this.account || !this.selfAccount) return '';
+ return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
+ }
+
+ computeOwnerChangesLink() {
+ if (!this.account) return undefined;
+ return createSearchUrl({
+ owner:
+ this.account.email ||
+ this.account.username ||
+ this.account.name ||
+ `${this.account._account_id}`,
+ });
+ }
+
+ computeOwnerDashboardLink() {
+ if (!this.account) return undefined;
+ if (this.account._account_id)
+ return createDashboardUrl({user: `${this.account._account_id}`});
+ if (this.account.email)
+ return createDashboardUrl({user: this.account.email});
+ return undefined;
+ }
+
+ get isAttentionEnabled() {
+ return (
+ !!this.highlightAttention &&
+ !!this.change &&
+ canHaveAttention(this.account)
+ );
+ }
+
+ get hasUserAttention() {
+ return hasAttention(this.account, this.change);
+ }
+
+ private getReviewerState(change: ChangeInfo) {
+ if (
+ change.reviewers[ReviewerState.REVIEWER]?.some(
+ (reviewer: AccountInfo) =>
+ reviewer._account_id === this.account._account_id
+ )
+ ) {
+ return ReviewerState.REVIEWER;
+ }
+ return ReviewerState.CC;
+ }
+
+ private computeReviewerOrCCText() {
+ if (!this.change || !this.account) return '';
+ return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+ ? 'Reviewer'
+ : 'CC';
+ }
+
+ private computeChangeReviewerOrCCText() {
+ if (!this.change || !this.account) return '';
+ return this.getReviewerState(this.change) === ReviewerState.REVIEWER
+ ? 'Move Reviewer to CC'
+ : 'Move CC to Reviewer';
+ }
+
+ private handleChangeReviewerOrCCStatus() {
+ assertIsDefined(this.change, 'change');
+ // accountKey() throws an error if _account_id & email is not found, which
+ // we want to check before showing reloading toast
+ const _accountKey = accountKey(this.account);
+ fire(this, 'show-alert', {
+ message: 'Reloading page...',
+ });
+ const reviewInput: Partial<ReviewInput> = {};
+ reviewInput.reviewers = [
+ {
+ reviewer: _accountKey,
+ state:
+ this.getReviewerState(this.change) === ReviewerState.CC
+ ? ReviewerState.REVIEWER
+ : ReviewerState.CC,
+ },
+ ];
+
+ this.restApiService
+ .saveChangeReview(this.change._number, CURRENT, reviewInput)
+ .then(response => {
+ if (!response || !response.ok) {
+ throw new Error(
+ 'something went wrong when toggling' +
+ this.getReviewerState(this.change!)
+ );
+ }
+ fireReload(this);
+ });
+ }
+
+ private handleRemoveReviewerOrCC() {
+ if (!this.change || !(this.account?._account_id || this.account?.email))
+ throw new Error('Missing change or account.');
+ fire(this, 'show-alert', {
+ message: 'Reloading page...',
+ });
+ this.restApiService
+ .removeChangeReviewer(
+ this.change._number,
+ (this.account?._account_id || this.account?.email)!
+ )
+ .then((response: Response | undefined) => {
+ if (!response || !response.ok) {
+ throw new Error('something went wrong when removing user');
+ }
+ fireReload(this);
+ return response;
+ });
+ }
+
+ private computeShowActionAddToAttentionSet() {
+ const involvedOrSelf =
+ isInvolved(this.change, this.selfAccount) ||
+ isSelf(this.account, this.selfAccount);
+ return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
+ }
+
+ private computeShowActionRemoveFromAttentionSet() {
+ const involvedOrSelf =
+ isInvolved(this.change, this.selfAccount) ||
+ isSelf(this.account, this.selfAccount);
+ return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
+ }
+
+ private handleClickAddToAttentionSet() {
+ if (!this.change || !this.account._account_id) return;
+ fire(this, 'show-alert', {
+ message: 'Reloading page...',
+ dismissOnNavigation: true,
+ });
+
+ // We are deliberately updating the UI before making the API call. It is a
+ // risk that we are taking to achieve a better UX for 99.9% of the cases.
+ const reason = getAddedByReason(this.selfAccount, this.serverConfig);
+
+ if (!this.change.attention_set) this.change.attention_set = {};
+ this.change.attention_set[this.account._account_id] = {
+ account: this.account,
+ reason,
+ reason_account: this.selfAccount,
+ };
+ fire(this, 'attention-set-updated', {});
+
+ this.reporting.reportInteraction(
+ 'attention-hovercard-add',
+ this.reportingDetails()
+ );
+ this.restApiService
+ .addToAttentionSet(this.change._number, this.account._account_id, reason)
+ .then(() => {
+ fire(this, 'hide-alert', {});
+ });
+ fire(this, 'action-taken', {});
+ }
+
+ private handleClickRemoveFromAttentionSet() {
+ if (!this.change || !this.account._account_id) return;
+ fire(this, 'show-alert', {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ });
+
+ // We are deliberately updating the UI before making the API call. It is a
+ // risk that we are taking to achieve a better UX for 99.9% of the cases.
+
+ const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
+ if (this.change.attention_set)
+ delete this.change.attention_set[this.account._account_id];
+ fire(this, 'attention-set-updated', {});
+
+ this.reporting.reportInteraction(
+ 'attention-hovercard-remove',
+ this.reportingDetails()
+ );
+ this.restApiService
+ .removeFromAttentionSet(
+ this.change._number,
+ this.account._account_id,
+ reason
+ )
+ .then(() => {
+ fire(this, 'hide-alert', {});
+ });
+ fire(this, 'action-taken', {});
+ }
+
+ private reportingDetails() {
+ const targetId = this.account._account_id;
+ const ownerId =
+ (this.change && this.change.owner && this.change.owner._account_id) || -1;
+ const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
+ const reviewers =
+ this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+ ? [...this.change.reviewers.REVIEWER]
+ : [];
+ const reviewerIds = reviewers
+ .map(r => r._account_id)
+ .filter(rId => rId !== ownerId);
+ return {
+ actionByOwner: selfId === ownerId,
+ actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+ targetIsOwner: targetId === ownerId,
+ targetIsReviewer: reviewerIds.includes(targetId),
+ targetIsSelf: targetId === selfId,
+ };
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-hovercard-account-contents': GrHovercardAccountContents;
+ }
+ interface HTMLElementEventMap {
+ 'action-taken': CustomEvent<{}>;
+ 'attention-set-updated': CustomEvent<{}>;
+ 'link-clicked': CustomEvent<{}>;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
new file mode 100644
index 0000000000..7df06f4677
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account-contents_test.ts
@@ -0,0 +1,385 @@
+/**
+ * @license
+ * Copyright 2020 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-hovercard-account-contents';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {
+ mockPromise,
+ query,
+ queryAndAssert,
+ stubRestApi,
+} from '../../../test/test-utils';
+import {
+ AccountDetailInfo,
+ AccountId,
+ EmailAddress,
+ ReviewerState,
+} from '../../../api/rest-api';
+import {
+ createAccountDetailWithId,
+ createChange,
+ createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {GrButton} from '../gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
+import {userModelToken} from '../../../models/user/user-model';
+
+suite('gr-hovercard-account-contents tests', () => {
+ let element: GrHovercardAccountContents;
+
+ const ACCOUNT: AccountDetailInfo = {
+ ...createAccountDetailWithId(31),
+ email: 'kermit@gmail.com' as EmailAddress,
+ username: 'kermit',
+ name: 'Kermit The Frog',
+ status: ' I am a frog ',
+ _account_id: 31415926535 as AccountId,
+ };
+
+ setup(async () => {
+ const change = {
+ ...createChange(),
+ attention_set: {},
+ reviewers: {},
+ owner: {...ACCOUNT},
+ };
+ element = await fixture(
+ html`<gr-hovercard-account-contents .account=${ACCOUNT} .change=${change}>
+ </gr-hovercard-account-contents>`
+ );
+ testResolver(userModelToken).setAccount({...ACCOUNT});
+ await element.updateComplete;
+ });
+
+ test('renders', () => {
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="top">
+ <div class="avatar">
+ <gr-avatar hidden=""></gr-avatar>
+ </div>
+ <div class="account">
+ <h3 class="heading-3 name">Kermit The Frog</h3>
+ <div class="email">kermit@gmail.com</div>
+ </div>
+ </div>
+ <gr-endpoint-decorator name="hovercard-status">
+ <gr-endpoint-param name="account"></gr-endpoint-param>
+ </gr-endpoint-decorator>
+ <div class="status">
+ <span class="title">About me:</span>
+ <span class="value">I am a frog</span>
+ </div>
+ <div class="links">
+ <gr-icon icon="link" class="linkIcon"></gr-icon>
+ <a href="/q/owner:kermit@gmail.com">Changes</a>
+ ·
+ <a href="/dashboard/31415926535">Dashboard</a>
+ </div>
+ `
+ );
+ });
+
+ test('renders without change data', async () => {
+ const elementWithoutChange = await fixture(
+ html`<gr-hovercard-account-contents
+ .account=${ACCOUNT}
+ ></gr-hovercard-account-contents>`
+ );
+ assert.shadowDom.equal(
+ elementWithoutChange,
+ /* HTML */ `
+ <div class="top">
+ <div class="avatar">
+ <gr-avatar hidden=""></gr-avatar>
+ </div>
+ <div class="account">
+ <h3 class="heading-3 name">Kermit The Frog</h3>
+ <div class="email">kermit@gmail.com</div>
+ </div>
+ </div>
+ <gr-endpoint-decorator name="hovercard-status">
+ <gr-endpoint-param name="account"> </gr-endpoint-param>
+ </gr-endpoint-decorator>
+ <div class="status">
+ <span class="title"> About me: </span>
+ <span class="value"> I am a frog </span>
+ </div>
+ <div class="links">
+ <gr-icon class="linkIcon" icon="link"> </gr-icon>
+ <a href="/q/owner:kermit@gmail.com"> Changes </a>
+ ·
+ <a href="/dashboard/31415926535"> Dashboard </a>
+ </div>
+ `
+ );
+ });
+
+ test('account name is shown', () => {
+ const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
+ assert.equal(name.innerText, 'Kermit The Frog');
+ });
+
+ test('computePronoun', async () => {
+ element.account = createAccountDetailWithId(1);
+ element.selfAccount = createAccountDetailWithId(1);
+ await element.updateComplete;
+ assert.equal(element.computePronoun(), 'Your');
+ element.account = createAccountDetailWithId(2);
+ await element.updateComplete;
+ assert.equal(element.computePronoun(), 'Their');
+ });
+
+ test('account status is not shown if the property is not set', async () => {
+ element.account = {...ACCOUNT, status: undefined};
+ await element.updateComplete;
+ assert.isUndefined(query(element, '.status'));
+ });
+
+ test('account status is displayed', () => {
+ const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
+ assert.equal(status.innerText, 'I am a frog');
+ });
+
+ test('voteable div is not shown if the property is not set', () => {
+ assert.isUndefined(query(element, '.voteable'));
+ });
+
+ test('voteable div is displayed', async () => {
+ element.change = {
+ ...createChange(),
+ labels: {
+ Foo: {
+ ...createDetailedLabelInfo(),
+ all: [
+ {
+ _account_id: 7 as AccountId,
+ permitted_voting_range: {max: 2, min: 0},
+ },
+ ],
+ },
+ Bar: {
+ ...createDetailedLabelInfo(),
+ all: [
+ {
+ ...createAccountDetailWithId(1),
+ permitted_voting_range: {max: 1, min: 0},
+ },
+ {
+ _account_id: 7 as AccountId,
+ permitted_voting_range: {max: 1, min: 0},
+ },
+ ],
+ },
+ FooBar: {
+ ...createDetailedLabelInfo(),
+ all: [{_account_id: 7 as AccountId, value: 0}],
+ },
+ },
+ permitted_labels: {
+ Foo: ['-1', ' 0', '+1', '+2'],
+ FooBar: ['-1', ' 0'],
+ },
+ };
+ element.account = createAccountDetailWithId(1);
+
+ await element.updateComplete;
+ const voteableEl = queryAndAssert<HTMLSpanElement>(
+ element,
+ '.voteable .value'
+ );
+ assert.equal(voteableEl.innerText, 'Bar: +1');
+ });
+
+ test('remove reviewer', async () => {
+ element.change = {
+ ...createChange(),
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [ACCOUNT],
+ },
+ };
+ await element.updateComplete;
+ stubRestApi('removeChangeReviewer').returns(
+ Promise.resolve({...new Response(), ok: true})
+ );
+ const reloadListener = sinon.spy();
+ element.addEventListener('reload', reloadListener);
+ const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Remove Reviewer');
+ button.click();
+ await element.updateComplete;
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('move reviewer to cc', async () => {
+ element.change = {
+ ...createChange(),
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [ACCOUNT],
+ },
+ };
+ await element.updateComplete;
+ const saveReviewStub = stubRestApi('saveChangeReview').returns(
+ Promise.resolve({...new Response(), ok: true})
+ );
+ stubRestApi('removeChangeReviewer').returns(
+ Promise.resolve({...new Response(), ok: true})
+ );
+ const reloadListener = sinon.spy();
+ element.addEventListener('reload', reloadListener);
+
+ const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Move Reviewer to CC');
+ button.click();
+ await element.updateComplete;
+ assert.isTrue(saveReviewStub.called);
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('move reviewer to cc', async () => {
+ element.change = {
+ ...createChange(),
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [],
+ },
+ };
+ await element.updateComplete;
+ const saveReviewStub = stubRestApi('saveChangeReview').returns(
+ Promise.resolve({...new Response(), ok: true})
+ );
+ stubRestApi('removeChangeReviewer').returns(
+ Promise.resolve({...new Response(), ok: true})
+ );
+ const reloadListener = sinon.spy();
+ element.addEventListener('reload', reloadListener);
+
+ const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Move CC to Reviewer');
+
+ button.click();
+ await element.updateComplete;
+ assert.isTrue(saveReviewStub.called);
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('remove cc', async () => {
+ element.change = {
+ ...createChange(),
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [],
+ },
+ };
+ await element.updateComplete;
+ stubRestApi('removeChangeReviewer').returns(
+ Promise.resolve({...new Response(), ok: true})
+ );
+ const reloadListener = sinon.spy();
+ element.addEventListener('reload', reloadListener);
+
+ const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+
+ assert.equal(button.innerText, 'Remove CC');
+ assert.isOk(button);
+ button.click();
+ await element.updateComplete;
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('add to attention set', async () => {
+ const apiPromise = mockPromise<Response>();
+ const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
+ element.highlightAttention = true;
+ await element.updateComplete;
+ const showAlertListener = sinon.spy();
+ const hideAlertListener = sinon.spy();
+ const updatedListener = sinon.spy();
+ element.addEventListener('show-alert', showAlertListener);
+ element.addEventListener('hide-alert', hideAlertListener);
+ element.addEventListener('attention-set-updated', updatedListener);
+
+ const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
+ assert.isOk(button);
+ button.click();
+
+ assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
+ const attention_set_info = Object.values(
+ element.change?.attention_set ?? {}
+ )[0];
+ assert.equal(
+ attention_set_info.reason,
+ `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+ ' using the hovercard menu'
+ );
+ assert.equal(
+ attention_set_info.reason_account?._account_id,
+ ACCOUNT._account_id
+ );
+ assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+ assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+ apiPromise.resolve({...new Response(), ok: true});
+ await element.updateComplete;
+ assert.isTrue(apiSpy.calledOnce);
+ assert.equal(
+ apiSpy.lastCall.args[2],
+ `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+ ' using the hovercard menu'
+ );
+ assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+ });
+
+ test('remove from attention set', async () => {
+ const apiPromise = mockPromise<Response>();
+ const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
+ element.highlightAttention = true;
+ element.change = {
+ ...createChange(),
+ attention_set: {
+ '31415926535': {account: ACCOUNT, reason: 'a good reason'},
+ },
+ reviewers: {},
+ owner: {...ACCOUNT},
+ };
+ await element.updateComplete;
+ const showAlertListener = sinon.spy();
+ const hideAlertListener = sinon.spy();
+ const updatedListener = sinon.spy();
+ element.addEventListener('show-alert', showAlertListener);
+ element.addEventListener('hide-alert', hideAlertListener);
+ element.addEventListener('attention-set-updated', updatedListener);
+
+ const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
+ assert.isOk(button);
+ button.click();
+
+ assert.isDefined(element.change?.attention_set);
+ assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
+ assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+ assert.isTrue(updatedListener.called, 'updatedListener was called');
+
+ apiPromise.resolve({...new Response(), ok: true});
+ await element.updateComplete;
+
+ assert.isTrue(apiSpy.calledOnce);
+ assert.equal(
+ apiSpy.lastCall.args[2],
+ `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
+ ' using the hovercard menu'
+ );
+ assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+ });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
index 9647141aa2..543f5bc829 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -8,42 +8,12 @@ import '../gr-button/gr-button';
import '../gr-icon/gr-icon';
import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
import '../../plugins/gr-endpoint-param/gr-endpoint-param';
-import {getAppContext} from '../../../services/app-context';
-import {
- accountKey,
- computeVoteableText,
- isAccountEmailOnly,
- isSelf,
-} from '../../../utils/account-util';
-import {customElement, property, state} from 'lit/decorators.js';
-import {
- AccountInfo,
- ChangeInfo,
- ServerInfo,
- ReviewInput,
-} from '../../../types/common';
-import {
- canHaveAttention,
- getAddedByReason,
- getLastUpdate,
- getReason,
- getRemovedByReason,
- hasAttention,
-} from '../../../utils/attention-set-util';
-import {ReviewerState} from '../../../constants/constants';
-import {CURRENT} from '../../../utils/patch-set-util';
-import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
-import {assertIsDefined} from '../../../utils/common-util';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {css, html, LitElement, nothing} from 'lit';
-import {ifDefined} from 'lit/directives/if-defined.js';
+import {customElement, property} from 'lit/decorators.js';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {html, LitElement} from 'lit';
import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
-import {EventType} from '../../../types/events';
-import {subscribe} from '../../lit/subscription-controller';
-import {resolve} from '../../../models/dependency';
-import {configModelToken} from '../../../models/config/config-model';
-import {createSearchUrl} from '../../../models/views/search';
-import {createDashboardUrl} from '../../../models/views/dashboard';
+import {when} from 'lit/directives/when.js';
+import './gr-hovercard-account-contents';
// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
const base = HovercardMixin(LitElement);
@@ -53,9 +23,6 @@ export class GrHovercardAccount extends base {
@property({type: Object})
account!: AccountInfo;
- @state()
- selfAccount?: AccountInfo;
-
/**
* Optional ChangeInfo object, typically comes from the change page or
* from a row in a list of search results. This is needed for some change
@@ -72,498 +39,30 @@ export class GrHovercardAccount extends base {
@property({type: Boolean})
highlightAttention = false;
- @state()
- serverConfig?: ServerInfo;
-
- private readonly restApiService = getAppContext().restApiService;
-
- private readonly reporting = getAppContext().reportingService;
-
- // private but used in tests
- readonly userModel = getAppContext().userModel;
-
- private readonly getConfigModel = resolve(this, configModelToken);
-
- constructor() {
- super();
- subscribe(
- this,
- () => this.userModel.account$,
- x => (this.selfAccount = x)
- );
- subscribe(
- this,
- () => this.getConfigModel().serverConfig$,
- config => {
- this.serverConfig = config;
- }
- );
- }
-
- static override get styles() {
- return [
- fontStyles,
- base.styles || [],
- css`
- .top,
- .attention,
- .status,
- .voteable {
- padding: var(--spacing-s) var(--spacing-l);
- }
- .links {
- padding: var(--spacing-m) 0px var(--spacing-l) var(--spacing-xxl);
- }
- .top {
- display: flex;
- padding-top: var(--spacing-xl);
- min-width: 300px;
- }
- gr-avatar {
- height: 48px;
- width: 48px;
- margin-right: var(--spacing-l);
- }
- .title,
- .email {
- color: var(--deemphasized-text-color);
- }
- .action {
- border-top: 1px solid var(--border-color);
- padding: var(--spacing-s) var(--spacing-l);
- --gr-button-padding: var(--spacing-s) var(--spacing-m);
- }
- .attention {
- background-color: var(--emphasis-color);
- }
- .attention a {
- text-decoration: none;
- }
- .status gr-icon {
- font-size: 14px;
- position: relative;
- top: 2px;
- }
- gr-icon.attentionIcon {
- transform: scaleX(0.8);
- }
- gr-icon.linkIcon {
- font-size: var(--line-height-normal, 20px);
- color: var(--deemphasized-text-color);
- padding-right: 12px;
- }
- .links a {
- color: var(--link-color);
- padding: 0px 4px;
- }
- .reason {
- padding-top: var(--spacing-s);
- }
- `,
- ];
- }
-
override render() {
return html`
<div id="container" role="tooltip" tabindex="-1">
- ${this.renderContent()}
- </div>
- `;
- }
-
- private renderContent() {
- if (!this._isShowing) return;
- return html`
- <div class="top">
- <div class="avatar">
- <gr-avatar .account=${this.account} imageSize="56"></gr-avatar>
- </div>
- <div class="account">
- <h3 class="name heading-3">${this.account.name}</h3>
- <div class="email">${this.account.email}</div>
- </div>
- </div>
- ${this.renderAccountStatusPlugins()} ${this.renderAccountStatus()}
- ${this.renderLinks()} ${this.renderChangeRelatedInfoAndActions()}
- `;
- }
-
- private renderChangeRelatedInfoAndActions() {
- if (this.change === undefined) {
- return;
- }
- const voteableText = computeVoteableText(this.change, this.account);
- return html`
- ${voteableText
- ? html`
- <div class="voteable">
- <span class="title">Voteable:</span>
- <span class="value">${voteableText}</span>
- </div>
- `
- : ''}
- ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
- ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
- `;
- }
-
- private renderReviewerOrCcActions() {
- if (!this.selfAccount || !isRemovableReviewer(this.change, this.account))
- return;
- return html`
- <div class="action">
- <gr-button
- class="removeReviewerOrCC"
- link=""
- no-uppercase
- @click=${this.handleRemoveReviewerOrCC}
- >
- Remove ${this.computeReviewerOrCCText()}
- </gr-button>
- </div>
- <div class="action">
- <gr-button
- class="changeReviewerOrCC"
- link=""
- no-uppercase
- @click=${this.handleChangeReviewerOrCCStatus}
- >
- ${this.computeChangeReviewerOrCCText()}
- </gr-button>
- </div>
- `;
- }
-
- private renderAccountStatusPlugins() {
- return html`
- <gr-endpoint-decorator name="hovercard-status">
- <gr-endpoint-param
- name="account"
- .value=${this.account}
- ></gr-endpoint-param>
- </gr-endpoint-decorator>
- `;
- }
-
- private renderLinks() {
- if (!this.account || isAccountEmailOnly(this.account)) return nothing;
- return html` <div class="links">
- <gr-icon icon="link" class="linkIcon"></gr-icon
- ><a
- href=${ifDefined(this.computeOwnerChangesLink())}
- @click=${() => {
- this.forceHide();
- return true;
- }}
- @enter=${() => {
- this.forceHide();
- return true;
- }}
- >Changes</a
- >·<a
- href=${ifDefined(this.computeOwnerDashboardLink())}
- @click=${() => {
- this.forceHide();
- return true;
- }}
- @enter=${() => {
- this.forceHide();
- return true;
- }}
- >Dashboard</a
- >
- </div>`;
- }
-
- private renderAccountStatus() {
- if (!this.account.status) return;
- return html`
- <div class="status">
- <span class="title">About me:</span>
- <span class="value">${this.account.status}</span>
- </div>
- `;
- }
-
- private renderNeedsAttention() {
- if (!(this.isAttentionEnabled && this.hasUserAttention)) return;
- const lastUpdate = getLastUpdate(this.account, this.change);
- return html`
- <div class="attention">
- <div>
- <gr-icon
- icon="label_important"
- filled
- small
- class="attentionIcon"
- ></gr-icon>
- <span> ${this.computePronoun()} turn to take action. </span>
- <a
- href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
- target="_blank"
- >
- <gr-icon icon="help" title="read documentation"></gr-icon>
- </a>
- </div>
- <div class="reason">
- <span class="title">Reason:</span>
- <span class="value">
- ${getReason(this.serverConfig, this.account, this.change)}
- </span>
- ${lastUpdate
- ? html` (<gr-date-formatter
- withTooltip
- .dateStr=${lastUpdate}
- ></gr-date-formatter
- >)`
- : ''}
- </div>
- </div>
- `;
- }
-
- private renderAddToAttention() {
- if (!this.computeShowActionAddToAttentionSet()) return;
- return html`
- <div class="action">
- <gr-button
- class="addToAttentionSet"
- link=""
- no-uppercase
- @click=${this.handleClickAddToAttentionSet}
- >
- Add to attention set
- </gr-button>
+ ${when(
+ this._isShowing,
+ () =>
+ html`<gr-hovercard-account-contents
+ .account=${this.account}
+ .change=${this.change}
+ .highlightAttention=${this.highlightAttention}
+ @link-clicked=${this.forceHide}
+ @action-taken=${this.mouseHide}
+ @attention-set-updated=${this.redirectEventToTarget}
+ @hide-alert=${this.redirectEventToTarget}
+ @show-alert=${this.redirectEventToTarget}
+ @reload=${this.redirectEventToTarget}
+ ></gr-hovercard-account-contents>`
+ )}
</div>
`;
}
- private renderRemoveFromAttention() {
- if (!this.computeShowActionRemoveFromAttentionSet()) return;
- return html`
- <div class="action">
- <gr-button
- class="removeFromAttentionSet"
- link=""
- no-uppercase
- @click=${this.handleClickRemoveFromAttentionSet}
- >
- Remove from attention set
- </gr-button>
- </div>
- `;
- }
-
- // private but used by tests
- computePronoun() {
- if (!this.account || !this.selfAccount) return '';
- return isSelf(this.account, this.selfAccount) ? 'Your' : 'Their';
- }
-
- computeOwnerChangesLink() {
- if (!this.account) return undefined;
- return createSearchUrl({
- owner:
- this.account.email ||
- this.account.username ||
- this.account.name ||
- `${this.account._account_id}`,
- });
- }
-
- computeOwnerDashboardLink() {
- if (!this.account) return undefined;
- if (this.account._account_id)
- return createDashboardUrl({user: `${this.account._account_id}`});
- if (this.account.email)
- return createDashboardUrl({user: this.account.email});
- return undefined;
- }
-
- get isAttentionEnabled() {
- return (
- !!this.highlightAttention &&
- !!this.change &&
- canHaveAttention(this.account)
- );
- }
-
- get hasUserAttention() {
- return hasAttention(this.account, this.change);
- }
-
- private getReviewerState() {
- if (
- this.change!.reviewers[ReviewerState.REVIEWER]?.some(
- (reviewer: AccountInfo) =>
- reviewer._account_id === this.account._account_id
- )
- ) {
- return ReviewerState.REVIEWER;
- }
- return ReviewerState.CC;
- }
-
- private computeReviewerOrCCText() {
- if (!this.change || !this.account) return '';
- return this.getReviewerState() === ReviewerState.REVIEWER
- ? 'Reviewer'
- : 'CC';
- }
-
- private computeChangeReviewerOrCCText() {
- if (!this.change || !this.account) return '';
- return this.getReviewerState() === ReviewerState.REVIEWER
- ? 'Move Reviewer to CC'
- : 'Move CC to Reviewer';
- }
-
- private handleChangeReviewerOrCCStatus() {
- assertIsDefined(this.change, 'change');
- // accountKey() throws an error if _account_id & email is not found, which
- // we want to check before showing reloading toast
- const _accountKey = accountKey(this.account);
- this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
- message: 'Reloading page...',
- });
- const reviewInput: Partial<ReviewInput> = {};
- reviewInput.reviewers = [
- {
- reviewer: _accountKey,
- state:
- this.getReviewerState() === ReviewerState.CC
- ? ReviewerState.REVIEWER
- : ReviewerState.CC,
- },
- ];
-
- this.restApiService
- .saveChangeReview(this.change._number, CURRENT, reviewInput)
- .then(response => {
- if (!response || !response.ok) {
- throw new Error(
- 'something went wrong when toggling' + this.getReviewerState()
- );
- }
- this.dispatchEventThroughTarget('reload', {clearPatchset: true});
- });
- }
-
- private handleRemoveReviewerOrCC() {
- if (!this.change || !(this.account?._account_id || this.account?.email))
- throw new Error('Missing change or account.');
- this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
- message: 'Reloading page...',
- });
- this.restApiService
- .removeChangeReviewer(
- this.change._number,
- (this.account?._account_id || this.account?.email)!
- )
- .then((response: Response | undefined) => {
- if (!response || !response.ok) {
- throw new Error('something went wrong when removing user');
- }
- this.dispatchEventThroughTarget('reload', {clearPatchset: true});
- return response;
- });
- }
-
- private computeShowActionAddToAttentionSet() {
- const involvedOrSelf =
- isInvolved(this.change, this.selfAccount) ||
- isSelf(this.account, this.selfAccount);
- return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
- }
-
- private computeShowActionRemoveFromAttentionSet() {
- const involvedOrSelf =
- isInvolved(this.change, this.selfAccount) ||
- isSelf(this.account, this.selfAccount);
- return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
- }
-
- private handleClickAddToAttentionSet(e: MouseEvent) {
- if (!this.change || !this.account._account_id) return;
- this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
- message: 'Saving attention set update ...',
- dismissOnNavigation: true,
- });
-
- // We are deliberately updating the UI before making the API call. It is a
- // risk that we are taking to achieve a better UX for 99.9% of the cases.
- const reason = getAddedByReason(this.selfAccount, this.serverConfig);
-
- if (!this.change.attention_set) this.change.attention_set = {};
- this.change.attention_set[this.account._account_id] = {
- account: this.account,
- reason,
- reason_account: this.selfAccount,
- };
- this.dispatchEventThroughTarget('attention-set-updated');
-
- this.reporting.reportInteraction(
- 'attention-hovercard-add',
- this.reportingDetails()
- );
- this.restApiService
- .addToAttentionSet(this.change._number, this.account._account_id, reason)
- .then(() => {
- this.dispatchEventThroughTarget('hide-alert');
- });
- this.mouseHide(e);
- }
-
- private handleClickRemoveFromAttentionSet(e: MouseEvent) {
- if (!this.change || !this.account._account_id) return;
- this.dispatchEventThroughTarget(EventType.SHOW_ALERT, {
- message: 'Saving attention set update ...',
- dismissOnNavigation: true,
- });
-
- // We are deliberately updating the UI before making the API call. It is a
- // risk that we are taking to achieve a better UX for 99.9% of the cases.
-
- const reason = getRemovedByReason(this.selfAccount, this.serverConfig);
- if (this.change.attention_set)
- delete this.change.attention_set[this.account._account_id];
- this.dispatchEventThroughTarget('attention-set-updated');
-
- this.reporting.reportInteraction(
- 'attention-hovercard-remove',
- this.reportingDetails()
- );
- this.restApiService
- .removeFromAttentionSet(
- this.change._number,
- this.account._account_id,
- reason
- )
- .then(() => {
- this.dispatchEventThroughTarget('hide-alert');
- });
- this.mouseHide(e);
- }
-
- private reportingDetails() {
- const targetId = this.account._account_id;
- const ownerId =
- (this.change && this.change.owner && this.change.owner._account_id) || -1;
- const selfId = (this.selfAccount && this.selfAccount._account_id) || -1;
- const reviewers =
- this.change && this.change.reviewers && this.change.reviewers.REVIEWER
- ? [...this.change.reviewers.REVIEWER]
- : [];
- const reviewerIds = reviewers
- .map(r => r._account_id)
- .filter(rId => rId !== ownerId);
- return {
- actionByOwner: selfId === ownerId,
- actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
- targetIsOwner: targetId === ownerId,
- targetIsReviewer: reviewerIds.includes(targetId),
- targetIsSelf: targetId === selfId,
- };
+ private redirectEventToTarget(e: CustomEvent<unknown>) {
+ this.dispatchEventThroughTarget(e.type, e.detail);
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
index 89bc04336d..40e4c75e6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.ts
@@ -8,56 +8,35 @@ import {fixture, assert} from '@open-wc/testing';
import {html} from 'lit';
import './gr-hovercard-account';
import {GrHovercardAccount} from './gr-hovercard-account';
-import {
- mockPromise,
- query,
- queryAndAssert,
- stubRestApi,
-} from '../../../test/test-utils';
-import {
- AccountDetailInfo,
- AccountId,
- EmailAddress,
- ReviewerState,
-} from '../../../api/rest-api';
+import {queryAndAssert} from '../../../test/test-utils';
import {
createAccountDetailWithId,
createChange,
- createDetailedLabelInfo,
} from '../../../test/test-data-generators';
import {GrButton} from '../gr-button/gr-button';
-import {EventType} from '../../../types/events';
+import {GrHovercardAccountContents} from './gr-hovercard-account-contents';
+import {userModelToken} from '../../../models/user/user-model';
+import {testResolver} from '../../../test/common-test-setup';
suite('gr-hovercard-account tests', () => {
let element: GrHovercardAccount;
-
- const ACCOUNT: AccountDetailInfo = {
- ...createAccountDetailWithId(31),
- email: 'kermit@gmail.com' as EmailAddress,
- username: 'kermit',
- name: 'Kermit The Frog',
- status: 'I am a frog',
- _account_id: 31415926535 as AccountId,
- };
+ let contents: GrHovercardAccountContents;
setup(async () => {
- const change = {
- ...createChange(),
- attention_set: {},
- reviewers: {},
- owner: {...ACCOUNT},
- };
+ const account = createAccountDetailWithId(31);
element = await fixture<GrHovercardAccount>(
html`<gr-hovercard-account
class="hovered"
- .account=${ACCOUNT}
- .change=${change}
+ .account=${account}
+ .change=${createChange()}
+ .highlightAttention=${true}
>
</gr-hovercard-account>`
);
await element.show({});
- element.userModel.setAccount({...ACCOUNT});
+ testResolver(userModelToken).setAccount({...account});
await element.updateComplete;
+ contents = queryAndAssert(element, 'gr-hovercard-account-contents');
});
teardown(async () => {
@@ -70,337 +49,29 @@ suite('gr-hovercard-account tests', () => {
element,
/* HTML */ `
<div id="container" role="tooltip" tabindex="-1">
- <div class="top">
- <div class="avatar">
- <gr-avatar hidden="" imagesize="56"></gr-avatar>
- </div>
- <div class="account">
- <h3 class="heading-3 name">Kermit The Frog</h3>
- <div class="email">kermit@gmail.com</div>
- </div>
- </div>
- <gr-endpoint-decorator name="hovercard-status">
- <gr-endpoint-param name="account"></gr-endpoint-param>
- </gr-endpoint-decorator>
- <div class="status">
- <span class="title">About me:</span>
- <span class="value">I am a frog</span>
- </div>
- <div class="links">
- <gr-icon icon="link" class="linkIcon"></gr-icon>
- <a href="/q/owner:kermit%2540gmail.com">Changes</a>
- ·
- <a href="/dashboard/31415926535">Dashboard</a>
- </div>
- </div>
- `
- );
- });
-
- test('renders without change data', async () => {
- const elementWithoutChange = await fixture<GrHovercardAccount>(
- html`<gr-hovercard-account class="hovered" .account=${ACCOUNT}>
- </gr-hovercard-account>`
- );
- await elementWithoutChange.show({});
- assert.shadowDom.equal(
- elementWithoutChange,
- /* HTML */ `
- <div id="container" role="tooltip" tabindex="-1">
- <div class="top">
- <div class="avatar">
- <gr-avatar hidden="" imagesize="56"> </gr-avatar>
- </div>
- <div class="account">
- <h3 class="heading-3 name">Kermit The Frog</h3>
- <div class="email">kermit@gmail.com</div>
- </div>
- </div>
- <gr-endpoint-decorator name="hovercard-status">
- <gr-endpoint-param name="account"> </gr-endpoint-param>
- </gr-endpoint-decorator>
- <div class="status">
- <span class="title"> About me: </span>
- <span class="value"> I am a frog </span>
- </div>
- <div class="links">
- <gr-icon class="linkIcon" icon="link"> </gr-icon>
- <a href="/q/owner:kermit%2540gmail.com"> Changes </a>
- ·
- <a href="/dashboard/31415926535"> Dashboard </a>
- </div>
+ <gr-hovercard-account-contents></gr-hovercard-account-contents>
</div>
`
);
- elementWithoutChange.mouseHide(new MouseEvent('click'));
- await elementWithoutChange.updateComplete;
- });
-
- test('account name is shown', () => {
- const name = queryAndAssert<HTMLHeadingElement>(element, '.name');
- assert.equal(name.innerText, 'Kermit The Frog');
- });
-
- test('computePronoun', async () => {
- element.account = createAccountDetailWithId(1);
- element.selfAccount = createAccountDetailWithId(1);
- await element.updateComplete;
- assert.equal(element.computePronoun(), 'Your');
- element.account = createAccountDetailWithId(2);
- await element.updateComplete;
- assert.equal(element.computePronoun(), 'Their');
- });
-
- test('account status is not shown if the property is not set', async () => {
- element.account = {...ACCOUNT, status: undefined};
- await element.updateComplete;
- assert.isUndefined(query(element, '.status'));
- });
-
- test('account status is displayed', () => {
- const status = queryAndAssert<HTMLSpanElement>(element, '.status .value');
- assert.equal(status.innerText, 'I am a frog');
- });
-
- test('voteable div is not shown if the property is not set', () => {
- assert.isUndefined(query(element, '.voteable'));
- });
-
- test('voteable div is displayed', async () => {
- element.change = {
- ...createChange(),
- labels: {
- Foo: {
- ...createDetailedLabelInfo(),
- all: [
- {
- _account_id: 7 as AccountId,
- permitted_voting_range: {max: 2, min: 0},
- },
- ],
- },
- Bar: {
- ...createDetailedLabelInfo(),
- all: [
- {
- ...createAccountDetailWithId(1),
- permitted_voting_range: {max: 1, min: 0},
- },
- {
- _account_id: 7 as AccountId,
- permitted_voting_range: {max: 1, min: 0},
- },
- ],
- },
- FooBar: {
- ...createDetailedLabelInfo(),
- all: [{_account_id: 7 as AccountId, value: 0}],
- },
- },
- permitted_labels: {
- Foo: ['-1', ' 0', '+1', '+2'],
- FooBar: ['-1', ' 0'],
- },
- };
- element.account = createAccountDetailWithId(1);
-
- await element.updateComplete;
- const voteableEl = queryAndAssert<HTMLSpanElement>(
- element,
- '.voteable .value'
- );
- assert.equal(voteableEl.innerText, 'Bar: +1');
- });
-
- test('remove reviewer', async () => {
- element.change = {
- ...createChange(),
- removable_reviewers: [ACCOUNT],
- reviewers: {
- [ReviewerState.REVIEWER]: [ACCOUNT],
- },
- };
- await element.updateComplete;
- stubRestApi('removeChangeReviewer').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- const reloadListener = sinon.spy();
- element._target?.addEventListener('reload', reloadListener);
- const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
- assert.isOk(button);
- assert.equal(button.innerText, 'Remove Reviewer');
- button.click();
- await element.updateComplete;
- assert.isTrue(reloadListener.called);
});
- test('move reviewer to cc', async () => {
- element.change = {
- ...createChange(),
- removable_reviewers: [ACCOUNT],
- reviewers: {
- [ReviewerState.REVIEWER]: [ACCOUNT],
- },
- };
- await element.updateComplete;
- const saveReviewStub = stubRestApi('saveChangeReview').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- stubRestApi('removeChangeReviewer').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- const reloadListener = sinon.spy();
- element._target?.addEventListener('reload', reloadListener);
-
- const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
-
- assert.isOk(button);
- assert.equal(button.innerText, 'Move Reviewer to CC');
- button.click();
- await element.updateComplete;
- assert.isTrue(saveReviewStub.called);
- assert.isTrue(reloadListener.called);
- });
+ test('hides when links are clicked', () => {
+ const changesLink = queryAndAssert<HTMLAnchorElement>(contents, 'a');
+ // Actually redirecting will break the test, replace URL with no-op
+ changesLink.href = 'javascript:';
- test('move reviewer to cc', async () => {
- element.change = {
- ...createChange(),
- removable_reviewers: [ACCOUNT],
- reviewers: {
- [ReviewerState.REVIEWER]: [],
- },
- };
- await element.updateComplete;
- const saveReviewStub = stubRestApi('saveChangeReview').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- stubRestApi('removeChangeReviewer').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- const reloadListener = sinon.spy();
- element._target?.addEventListener('reload', reloadListener);
+ assert.isTrue(element._isShowing);
- const button = queryAndAssert<GrButton>(element, '.changeReviewerOrCC');
- assert.isOk(button);
- assert.equal(button.innerText, 'Move CC to Reviewer');
+ changesLink.click();
- button.click();
- await element.updateComplete;
- assert.isTrue(saveReviewStub.called);
- assert.isTrue(reloadListener.called);
+ assert.isFalse(element._isShowing);
});
- test('remove cc', async () => {
- element.change = {
- ...createChange(),
- removable_reviewers: [ACCOUNT],
- reviewers: {
- [ReviewerState.REVIEWER]: [],
- },
- };
- await element.updateComplete;
- stubRestApi('removeChangeReviewer').returns(
- Promise.resolve({...new Response(), ok: true})
- );
- const reloadListener = sinon.spy();
- element._target?.addEventListener('reload', reloadListener);
-
- const button = queryAndAssert<GrButton>(element, '.removeReviewerOrCC');
+ test('hides when actions are performed', () => {
+ assert.isTrue(element._isShowing);
- assert.equal(button.innerText, 'Remove CC');
- assert.isOk(button);
- button.click();
- await element.updateComplete;
- assert.isTrue(reloadListener.called);
- });
+ queryAndAssert<GrButton>(contents, 'gr-button.addToAttentionSet').click();
- test('add to attention set', async () => {
- const apiPromise = mockPromise<Response>();
- const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
- element.highlightAttention = true;
- element._target = document.createElement('div');
- await element.updateComplete;
- const showAlertListener = sinon.spy();
- const hideAlertListener = sinon.spy();
- const updatedListener = sinon.spy();
- element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
- element._target.addEventListener('hide-alert', hideAlertListener);
- element._target.addEventListener('attention-set-updated', updatedListener);
-
- const button = queryAndAssert<GrButton>(element, '.addToAttentionSet');
- assert.isOk(button);
- assert.isTrue(element._isShowing, 'hovercard is showing');
- button.click();
-
- assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 1);
- const attention_set_info = Object.values(
- element.change?.attention_set ?? {}
- )[0];
- assert.equal(
- attention_set_info.reason,
- `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
- ' using the hovercard menu'
- );
- assert.equal(
- attention_set_info.reason_account?._account_id,
- ACCOUNT._account_id
- );
- assert.isTrue(showAlertListener.called, 'showAlertListener was called');
- assert.isTrue(updatedListener.called, 'updatedListener was called');
- assert.isFalse(element._isShowing, 'hovercard is hidden');
-
- apiPromise.resolve({...new Response(), ok: true});
- await element.updateComplete;
- assert.isTrue(apiSpy.calledOnce);
- assert.equal(
- apiSpy.lastCall.args[2],
- `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
- ' using the hovercard menu'
- );
- assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
- });
-
- test('remove from attention set', async () => {
- const apiPromise = mockPromise<Response>();
- const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
- element.highlightAttention = true;
- element.change = {
- ...createChange(),
- attention_set: {
- '31415926535': {account: ACCOUNT, reason: 'a good reason'},
- },
- reviewers: {},
- owner: {...ACCOUNT},
- };
- element._target = document.createElement('div');
- await element.updateComplete;
- const showAlertListener = sinon.spy();
- const hideAlertListener = sinon.spy();
- const updatedListener = sinon.spy();
- element._target.addEventListener(EventType.SHOW_ALERT, showAlertListener);
- element._target.addEventListener('hide-alert', hideAlertListener);
- element._target.addEventListener('attention-set-updated', updatedListener);
-
- const button = queryAndAssert<GrButton>(element, '.removeFromAttentionSet');
- assert.isOk(button);
- assert.isTrue(element._isShowing, 'hovercard is showing');
- button.click();
-
- assert.isDefined(element.change?.attention_set);
- assert.equal(Object.keys(element.change?.attention_set ?? {}).length, 0);
- assert.isTrue(showAlertListener.called, 'showAlertListener was called');
- assert.isTrue(updatedListener.called, 'updatedListener was called');
- assert.isFalse(element._isShowing, 'hovercard is hidden');
-
- apiPromise.resolve({...new Response(), ok: true});
- await element.updateComplete;
-
- assert.isTrue(apiSpy.calledOnce);
- assert.equal(
- apiSpy.lastCall.args[2],
- `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>` +
- ' using the hovercard menu'
- );
- assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+ assert.isFalse(element._isShowing);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index 65b1778f4d..89c8770aab 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -10,165 +10,12 @@ const $_documentContainer = document.createElement('template');
$_documentContainer.innerHTML = `<iron-iconset-svg name="gr-icons" size="24">
<svg>
<defs>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=unfold_more -->
- <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="create"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="star"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="star-border"><path d="M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="close"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="chevron-left"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="more-horiz"><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
- <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
- <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
- <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=help_outline -->
- <g id="help-outline"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="info-outline"><path d="M11 17h2v-6h-2v6zm1-15C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zM11 9h2V7h-2v2z"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=ic_hourglass_full-->
- <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"></path><path d="M0 0h24v24H0V0z" fill="none"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mode_comment-->
- <g id="comment"><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=calendar_today-->
- <g id="calendar"><path d="M20 3h-1V1h-2v2H7V1H5v2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 18H4V8h16v13z"></path><path d="M0 0h24v24H0z" fill="none"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="error"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="lightbulb-outline"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.12, 1.2)"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
- <g id="build"><path d="M22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9-2-2-5-2.4-7.4-1.3L9 6 6 9 1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle-->
- <g id="check-circle"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=check_circle_outline-->
- <g id="check-circle-outline"><path d="M0 0h24v24H0V0zm0 0h24v24H0V0z" fill="none"/><path d="M16.59 7.58L10 14.17l-3.59-3.58L5 12l5 5 8-8zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
- <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=check+circle-->
- <g id="check-circle-filled"><path d="M12,2C6.48,2,2,6.48,2,12c0,5.52,4.48,10,10,10s10-4.48,10-10C22,6.48,17.52,2,12,2z M10,17l-4-4l1.4-1.4l2.6,2.6l6.6-6.6 L18,9L10,17z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
- <!-- This SVG is a copy from https://fonts.google.com/icons?selected=Material+Icons:event_busy&icon.query=block-->
- <g id="block"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="abandon"><path d="M17.65675,17.65725 C14.77275,20.54125 10.23775,20.75625 7.09875,18.31525 L18.31475,7.09925 C20.75575,10.23825 20.54075,14.77325 17.65675,17.65725 M6.34275,6.34325 C9.22675,3.45925 13.76275,3.24425 16.90075,5.68525 L5.68475,16.90125 C3.24375,13.76325 3.45875,9.22725 6.34275,6.34325 M19.07075,4.92925 C15.16575,1.02425 8.83375,1.02425 4.92875,4.92925 C1.02375,8.83425 1.02375,15.16625 4.92875,19.07125 C8.83375,22.97625 15.16575,22.97625 19.07075,19.07125 C22.97575,15.16625 22.97575,8.83425 19.07075,4.92925"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="edit"><path d="M3,17.2525 L3,21.0025 L6.75,21.0025 L17.81,9.9425 L14.06,6.1925 L3,17.2525 L3,17.2525 Z M20.71,7.0425 C21.1,6.6525 21.1,6.0225 20.71,5.6325 L18.37,3.2925 C17.98,2.9025 17.35,2.9025 16.96,3.2925 L15.13,5.1225 L18.88,8.8725 L20.71,7.0425 L20.71,7.0425 Z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="rebase"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="rebaseEdit"><path d="M15.5759,19.4241 L14.5861,20.4146 L11.7574,23.2426 L10.3434,21.8286 L12.171569,20 L7.82933006,20 C7.41754308,21.1652555 6.30635522,22 5,22 C3.343,22 2,20.657 2,19 C2,17.6936448 2.83474451,16.5824569 4,16.1706699 L4,7.82933006 C2.83474451,7.41754308 2,6.30635522 2,5 C2,3.343 3.343,2 5,2 C6.30635522,2 7.41754308,2.83474451 7.82933006,4 L12.1715,4 L10.3431,2.1716 L11.7571,0.7576 L15.36365,4.3633 L16.0000001,4.99920039 C16.0004321,3.34256796 17.3432665,2 19,2 C20.657,2 22,3.343 22,5 C22,6.30635522 21.1652555,7.41754308 20,7.82933006 L20,16.1706699 C21.1652555,16.5824569 22,17.6936448 22,19 C22,20.657 20.657,22 19,22 C17.343,22 16,20.657 16,19 L15.5759,19.4241 Z M12.1715,18 L10.3431,16.1716 L11.7571,14.7576 L15.36365,18.3633 L16.0000001,18.9992004 C16.0003407,17.6931914 16.8349823,16.5823729 18,16.1706699 L18,7.82933006 C16.8347445,7.41754308 16,6.30635522 16,5 L15.5759,5.4241 L14.5861,6.4146 L11.7574,9.2426 L10.3434,7.8286 L12.171569,6 L7.82933006,6 C7.52807271,6.85248394 6.85248394,7.52807271 6,7.82933006 L6,16.1706699 C6.85248394,16.4719273 7.52807271,17.1475161 7.82933006,18 L12.1715,18 Z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="restore"><path d="M12,8 L12,13 L16.28,15.54 L17,14.33 L13.5,12.25 L13.5,8 L12,8 Z M13,3 C8.03,3 4,7.03 4,12 L1,12 L4.89,15.89 L4.96,16.03 L9,12 L6,12 C6,8.13 9.13,5 13,5 C16.87,5 20,8.13 20,12 C20,15.87 16.87,19 13,19 C11.07,19 9.32,18.21 8.06,16.94 L6.64,18.36 C8.27,19.99 10.51,21 13,21 C17.97,21 22,16.97 22,12 C22,7.03 17.97,3 13,3 Z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="revert"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
- <g id="revert_submission"><path d="M12.3,8.5 C9.64999995,8.5 7.24999995,9.49 5.39999995,11.1 L1.79999995,7.5 L1.79999995,16.5 L10.8,16.5 L7.17999995,12.88 C8.56999995,11.72 10.34,11 12.3,11 C15.84,11 18.85,13.31 19.9,16.5 L22.27,15.72 C20.88,11.53 16.95,8.5 12.3,8.5"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="stopEdit"><path d="M4 4 20 4 20 20 4 20z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="submit"><path d="M22.23,5 L11.65,15.58 L7.47000001,11.41 L6.06000001,12.82 L11.65,18.41 L23.649,6.41 L22.23,5 Z M16.58,5 L10.239,11.34 L11.65,12.75 L17.989,6.41 L16.58,5 Z M0.400000006,12.82 L5.99000001,18.41 L7.40000001,17 L1.82000001,11.41 L0.400000006,12.82 Z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="review"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
- <!-- This is a custom PolyGerrit SVG -->
- <g id="zeroState"><path d="M22 9V7h-2V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2v-2h-2V9h2zm-4 10H4V5h14v14zM6 13h5v4H6zm6-6h4v3h-4zM6 7h5v5H6zm6 4h4v6h-4z"></path></g>
- <!-- This SVG is an adaptation of material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=label_important-->
- <g id="attention"><path d="M1 23 l13 0 c.67 0 1.27 -.33 1.63 -.84 l7.37 -10.16 l-7.37 -10.16 c-.36 -.51 -.96 -.84 -1.63 -.84 L1 1 L7 12 z"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=pets-->
- <g id="pets"><circle cx="4.5" cy="9.5" r="2.5"/><circle cx="9" cy="5.5" r="2.5"/><circle cx="15" cy="5.5" r="2.5"/><circle cx="19.5" cy="9.5" r="2.5"/><path d="M17.34 14.86c-.87-1.02-1.6-1.89-2.48-2.91-.46-.54-1.05-1.08-1.75-1.32-.11-.04-.22-.07-.33-.09-.25-.04-.52-.04-.78-.04s-.53 0-.79.05c-.11.02-.22.05-.33.09-.7.24-1.28.78-1.75 1.32-.87 1.02-1.6 1.89-2.48 2.91-1.31 1.31-2.92 2.76-2.62 4.79.29 1.02 1.02 2.03 2.33 2.32.73.15 3.06-.44 5.54-.44h.18c2.48 0 4.81.58 5.54.44 1.31-.29 2.04-1.31 2.33-2.32.31-2.04-1.3-3.49-2.61-4.8z"/><path d="M0 0h24v24H0z" fill="none"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=visibility-->
- <g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
- <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
- <g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=bug_report-->
- <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.gstatic.com/s/i/googlematerialicons/move_item/v1/24px.svg -->
- <g id="move-item"><path d="M15,19H5V5h10v4h2V5c0-1.1-0.89-2-2-2H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h10c1.11,0,2-0.9,2-2v-4h-2V19z"/><polygon points="20.01,8.01 18.59,9.41 20.17,11 8,11 8,13 20.17,13 18.59,14.59 20.01,15.99 24,12"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=warning-->
- <g id="warning"><path d="M0 0h24v24H0z" fill="none"/><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=timelapse-->
- <g id="timelapse"><path d="M0 0h24v24H0z" fill="none"/><path d="M16.24 7.76C15.07 6.59 13.54 6 12 6v6l-4.24 4.24c2.34 2.34 6.14 2.34 8.49 0 2.34-2.34 2.34-6.14-.01-8.48zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=mark_chat_read-->
- <g id="markChatRead"><path d="M12,18l-6,0l-4,4V4c0-1.1,0.9-2,2-2h16c1.1,0,2,0.9,2,2v7l-2,0V4H4v12l8,0V18z M23,14.34l-1.41-1.41l-4.24,4.24l-2.12-2.12 l-1.41,1.41L17.34,20L23,14.34z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=message-->
- <g id="message"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=launch-->
- <g id="launch"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 19H5V5h7V3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=filter-->
- <g id="filter"><path d="M0,0h24 M24,24H0" fill="none"/><path d="M4.25,5.61C6.27,8.2,10,13,10,13v6c0,0.55,0.45,1,1,1h2c0.55,0,1-0.45,1-1v-6c0,0,3.72-4.8,5.74-7.39 C20.25,4.95,19.78,4,18.95,4H5.04C4.21,4,3.74,4.95,4.25,5.61z"/><path d="M0,0h24v24H0V0z" fill="none"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_down-->
- <g id="arrowDropDown"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 10l5 5 5-5z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=arrow_drop_up-->
- <g id="arrowDropUp"><path d="M0 0h24v24H0z" fill="none"/><path d="M7 14l5-5 5 5z"/></g>
- <!-- This is just a placeholder, i.e. an empty icon that has the same size as a normal icon. -->
- <g id="placeholder"><path d="M0 0h24v24H0z" fill="none"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=insert_photo-->
- <g id="insert-photo"><path d="M0 0h24v24H0z" fill="none"/><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=download-->
- <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=system_update-->
- <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
<!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=swap_horiz-->
<g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=link-->
- <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
<!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
<g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
<!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
<g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Acode-->
- <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Afile_present-->
- <g id="file-present"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7l-5-5zM6 20V4h8v4h4v12H6zm10-10v5c0 2.21-1.79 4-4 4s-4-1.79-4-4V8.5c0-1.47 1.26-2.64 2.76-2.49 1.3.13 2.24 1.32 2.24 2.63V15h-2V8.5c0-.28-.22-.5-.5-.5s-.5.22-.5.5V15c0 1.1.9 2 2 2s2-.9 2-2v-5h2z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aarrow_forward-->
- <g id="arrow-forward"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:feedback -->
- <g id="feedback"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
- <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
- <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:event_busy -->
- <g id="unavailable"><path d="M0 0h24v24H0z" fill="none"/><path d="M9.31 17l2.44-2.44L14.19 17l1.06-1.06-2.44-2.44 2.44-2.44L14.19 10l-2.44 2.44L9.31 10l-1.06 1.06 2.44 2.44-2.44 2.44L9.31 17zM19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11z"/></g>
- <!-- This SVG is a custom PolyGerrit SVG -->
- <g id="not-working-hours"><path d="M20.8,13.9c-0.6,0.1-1.3,0.2-2,0.2c-4.9,0-8.9-4-8.9-8.9c0-0.7,0.1-1.4,0.2-2c-4,0.9-6.9,4.5-6.9,8.7c0,4.9,4,8.9,8.9,8.9C16.3,20.8,19.9,17.9,20.8,13.9z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:pending_actions -->
- <g id="scheduled"><path d="M0 0h24v24H0z" fill="none"/><path d="M17.0 22.0Q14.925 22.0 13.4625 20.5375Q12.0 19.075 12.0 17.0Q12.0 14.925 13.4625 13.4625Q14.925 12.0 17.0 12.0Q19.075 12.0 20.5375 13.4625Q22.0 14.925 22.0 17.0Q22.0 19.075 20.5375 20.5375Q19.075 22.0 17.0 22.0ZM18.675 19.375 19.375 18.675 17.5 16.8V14.0H16.5V17.2ZM5.0 21.0Q4.175 21.0 3.5875 20.4125Q3.0 19.825 3.0 19.0V5.0Q3.0 4.175 3.5875 3.5875Q4.175 3.0 5.0 3.0H9.175Q9.5 2.125 10.2625 1.5625Q11.025 1.0 12.0 1.0Q12.975 1.0 13.7375 1.5625Q14.5 2.125 14.825 3.0H19.0Q19.825 3.0 20.4125 3.5875Q21.0 4.175 21.0 5.0V11.25Q20.55 10.925 20.05 10.7Q19.55 10.475 19.0 10.3V5.0Q19.0 5.0 19.0 5.0Q19.0 5.0 19.0 5.0H17.0V8.0H7.0V5.0H5.0Q5.0 5.0 5.0 5.0Q5.0 5.0 5.0 5.0V19.0Q5.0 19.0 5.0 19.0Q5.0 19.0 5.0 19.0H10.3Q10.475 19.55 10.7 20.05Q10.925 20.55 11.25 21.0ZM12.0 5.0Q12.425 5.0 12.7125 4.7125Q13.0 4.425 13.0 4.0Q13.0 3.575 12.7125 3.2875Q12.425 3.0 12.0 3.0Q11.575 3.0 11.2875 3.2875Q11.0 3.575 11.0 4.0Q11.0 4.425 11.2875 4.7125Q11.575 5.0 12.0 5.0Z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:new_releases -->
- <g id="new"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M23 12l-2.44-2.78.34-3.68-3.61-.82-1.89-3.18L12 3 8.6 1.54 6.71 4.72l-3.61.81.34 3.68L1 12l2.44 2.78-.34 3.69 3.61.82 1.89 3.18L12 21l3.4 1.46 1.89-3.18 3.61-.82-.34-3.68L23 12zm-4.51 2.11l.26 2.79-2.74.62-1.43 2.41L12 18.82l-2.58 1.11-1.43-2.41-2.74-.62.26-2.8L3.66 12l1.85-2.12-.26-2.78 2.74-.61 1.43-2.41L12 5.18l2.58-1.11 1.43 2.41 2.74.62-.26 2.79L20.34 12l-1.85 2.11zM11 15h2v2h-2zm0-8h2v6h-2z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:arrow_right_alt -->
- <g id="arrow-right"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M14 6l-1.41 1.41L16.17 11H4v2h12.17l-3.58 3.59L14 18l6-6z"/></g>
- <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:cancel -->
- <g id="cancel"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z"/></g>
</defs>
</svg>
</iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
index 7ab4689a43..4b5913c04e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -3,132 +3,25 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {DiffLayer, DiffLayerListener} from '../../../types/types';
-import {Side} from '../../../constants/constants';
-import {EventType, PluginApi} from '../../../api/plugin';
-import {getAppContext} from '../../../services/app-context';
import {AnnotationPluginApi, CoverageProvider} from '../../../api/annotation';
+import {PluginApi} from '../../../api/plugin';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
export class GrAnnotationActionsInterface implements AnnotationPluginApi {
- /**
- * Collect all annotation layers instantiated by createLayer. This is only
- * used for being able to look up the appropriate layer when notify() is
- * being called by plugins.
- */
- private annotationLayers: AnnotationLayer[] = [];
-
- private coverageProvider?: CoverageProvider;
-
- private readonly reporting = getAppContext().reportingService;
-
- constructor(private readonly plugin: PluginApi) {
+ constructor(
+ private readonly reporting: ReportingService,
+ private readonly pluginsModel: PluginsModel,
+ private readonly plugin: PluginApi
+ ) {
this.reporting.trackApi(this.plugin, 'annotation', 'constructor');
- plugin.on(EventType.ANNOTATE_DIFF, this);
}
- setCoverageProvider(
- coverageProvider: CoverageProvider
- ): GrAnnotationActionsInterface {
+ setCoverageProvider(provider: CoverageProvider) {
this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
- if (this.coverageProvider) {
- this.reporting.error(
- 'Annotation Plugin',
- new Error(
- `Overwriting coverage provider: ${this.plugin.getPluginName()}`
- )
- );
- }
- this.coverageProvider = coverageProvider;
- return this;
- }
-
- /**
- * Used by Gerrit to look up the coverage provider. Not intended to be called
- * by plugins.
- */
- getCoverageProvider() {
- return this.coverageProvider;
- }
-
- notify(path: string, start: number, end: number, side: Side) {
- this.reporting.trackApi(this.plugin, 'annotation', 'notify');
- for (const annotationLayer of this.annotationLayers) {
- // Notify only the annotation layer that is associated with the specified
- // path.
- if (annotationLayer.path === path) {
- annotationLayer.notifyListeners(start, end, side);
- }
- }
- }
-
- /**
- * Factory method called by Gerrit for creating a DiffLayer for each diff that
- * is rendered.
- *
- * Don't forget to also call disposeLayer().
- */
- createLayer(path: string) {
- const annotationLayer = new AnnotationLayer(path);
- this.annotationLayers.push(annotationLayer);
- return annotationLayer;
- }
-
- /**
- * Called by Gerrit for each diff renderer that had called createLayer().
- */
- disposeLayer(path: string) {
- this.annotationLayers = this.annotationLayers.filter(
- annotationLayer => annotationLayer.path !== path
- );
- }
-}
-
-/**
- * An AnnotationLayer exists for each file that is being rendered. This class is
- * not exposed to plugins, but being used by Gerrit's diff rendering.
- */
-export class AnnotationLayer implements DiffLayer {
- private listeners: DiffLayerListener[] = [];
-
- /**
- * Used to create an instance of the Annotation Layer interface.
- *
- * @param path The file path (eg: /COMMIT_MSG').
- */
- constructor(readonly path: string) {
- this.listeners = [];
- }
-
- /**
- * Register a listener for layer updates.
- * Don't forget to removeListener when you stop using layer.
- *
- * @param fn The update handler function.
- * Should accept as arguments the line numbers for the start and end of
- * the update and the side as a string.
- */
- addListener(listener: DiffLayerListener) {
- this.listeners.push(listener);
- }
-
- removeListener(listener: DiffLayerListener) {
- this.listeners = this.listeners.filter(f => f !== listener);
- }
-
- annotate() {}
-
- /**
- * Notify layer listeners (which typically is just Gerrit's diff renderer) of
- * changes to annotations after the diff rendering had already completed. This
- * is indirectly called by plugins using the AnnotationPluginApi.notify().
- *
- * @param start The line where the update starts.
- * @param end The line where the update ends.
- * @param side The side of the update. ('left' or 'right')
- */
- notifyListeners(start: number, end: number, side: Side) {
- for (const listener of this.listeners) {
- listener(start, end, side);
- }
+ this.pluginsModel.coverageRegister({
+ pluginName: this.plugin.getPluginName(),
+ provider,
+ });
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
deleted file mode 100644
index f103b608c9..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import '../../change/gr-change-actions/gr-change-actions';
-import {assert} from '@open-wc/testing';
-
-suite('gr-annotation-actions-js-api tests', () => {
- let annotationActions;
-
- let plugin;
-
- setup(() => {
- window.Gerrit.install(p => { plugin = p; }, '0.1',
- 'http://test.com/plugins/testplugin/static/test.js');
- annotationActions = plugin.annotationApi();
- });
-
- teardown(() => {
- annotationActions = null;
- });
-
- test('add notifier', () => {
- const path1 = '/dummy/path1';
- const path2 = '/dummy/path2';
- const annotationLayer1 = annotationActions.createLayer(path1, 1);
- const annotationLayer2 = annotationActions.createLayer(path2, 1);
- const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
- const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
-
- // Assert that no layers are invoked with a different path.
- annotationActions.notify('/dummy/path3', 0, 10, 'right');
- assert.isFalse(layer1Spy.called);
- assert.isFalse(layer2Spy.called);
-
- // Assert that only the 1st layer is invoked with path1.
- annotationActions.notify(path1, 0, 10, 'right');
- assert.isTrue(layer1Spy.called);
- assert.isFalse(layer2Spy.called);
-
- // Reset spies.
- layer1Spy.resetHistory();
- layer2Spy.resetHistory();
-
- // Assert that only the 2nd layer is invoked with path2.
- annotationActions.notify(path2, 0, 20, 'left');
- assert.isFalse(layer1Spy.called);
- assert.isTrue(layer2Spy.called);
- });
-
- test('layer notify listeners', () => {
- const annotationLayer = annotationActions.createLayer('/dummy/path', 1);
- let listenerCalledTimes = 0;
- const startRange = 10;
- const endRange = 20;
- const side = 'right';
- const listener = (st, end, s) => {
- listenerCalledTimes++;
- assert.equal(st, startRange);
- assert.equal(end, endRange);
- assert.equal(s, side);
- };
-
- // Notify with 0 listeners added.
- annotationLayer.notifyListeners(startRange, endRange, side);
- assert.equal(listenerCalledTimes, 0);
-
- // Add 1 listener.
- annotationLayer.addListener(listener);
- annotationLayer.notifyListeners(startRange, endRange, side);
- assert.equal(listenerCalledTimes, 1);
-
- // Add 1 more listener. Total 2 listeners.
- annotationLayer.addListener(listener);
- annotationLayer.notifyListeners(startRange, endRange, side);
- assert.equal(listenerCalledTimes, 3);
- });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
index 0bd491cacf..7b996601d5 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -60,11 +60,11 @@ export function send(
restApiService: RestApiService,
method: HttpMethod,
url: string,
- opt_callback?: (response: unknown) => void,
- opt_payload?: RequestPayload
+ callback?: (response: unknown) => void,
+ payload?: RequestPayload
) {
return restApiService
- .send(method, url, opt_payload)
+ .send(method, url, payload)
.then(response => {
if (response.status < 200 || response.status >= 300) {
return response.text().then((text: string | undefined) => {
@@ -79,8 +79,8 @@ export function send(
}
})
.then(response => {
- if (opt_callback) {
- opt_callback(response);
+ if (callback) {
+ callback(response);
}
return response;
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
index b8bfd217b5..f0143a6c73 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -15,6 +15,7 @@ import {
RevisionActions,
} from '../../../api/change-actions';
import {PropertyDeclaration} from 'lit';
+import {JsApiService} from './gr-js-api-types';
export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
__key: string;
@@ -65,9 +66,11 @@ export class GrChangeActionsInterface implements ChangeActionsPluginApi {
private readonly reporting = getAppContext().reportingService;
- private readonly jsApiService = getAppContext().jsApiService;
-
- constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
+ constructor(
+ public plugin: PluginApi,
+ private readonly jsApiService: JsApiService,
+ el?: GrChangeActionsElement
+ ) {
this.reporting.trackApi(this.plugin, 'actions', 'constructor');
this.setEl(el);
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
index df9adc9af3..e3ef083e75 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.ts
@@ -5,13 +5,7 @@
*/
import '../../../test/common-test-setup';
import '../../change/gr-change-actions/gr-change-actions';
-import {
- query,
- queryAll,
- queryAndAssert,
- resetPlugins,
-} from '../../../test/test-utils';
-import {getPluginLoader} from './gr-plugin-loader';
+import {query, queryAll, queryAndAssert} from '../../../test/test-utils';
import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
import {fixture, html, assert} from '@open-wc/testing';
import {PluginApi} from '../../../api/plugin';
@@ -24,6 +18,8 @@ import {GrButton} from '../gr-button/gr-button';
import {ChangeViewChangeInfo} from '../../../types/common';
import {GrDropdown} from '../gr-dropdown/gr-dropdown';
import {GrIcon} from '../gr-icon/gr-icon';
+import {testResolver} from '../../../test/common-test-setup';
+import {pluginLoaderToken} from './gr-plugin-loader';
suite('gr-change-actions-js-api-interface tests', () => {
let element: GrChangeActions;
@@ -32,7 +28,6 @@ suite('gr-change-actions-js-api-interface tests', () => {
suite('early init', () => {
setup(async () => {
- resetPlugins();
window.Gerrit.install(
p => {
plugin = p;
@@ -41,17 +36,13 @@ suite('gr-change-actions-js-api-interface tests', () => {
'http://test.com/plugins/testplugin/static/test.js'
);
// Mimic all plugins loaded.
- getPluginLoader().loadPlugins([]);
+ testResolver(pluginLoaderToken).loadPlugins([]);
changeActions = plugin.changeActions();
element = await fixture<GrChangeActions>(html`
<gr-change-actions></gr-change-actions>
`);
});
- teardown(() => {
- resetPlugins();
- });
-
test('does not throw', () => {
assert.doesNotThrow(() => {
changeActions.add(ActionType.CHANGE, 'foo');
@@ -61,12 +52,10 @@ suite('gr-change-actions-js-api-interface tests', () => {
suite('normal init', () => {
setup(async () => {
- resetPlugins();
element = await fixture<GrChangeActions>(html`
<gr-change-actions></gr-change-actions>
`);
element.change = {} as ChangeViewChangeInfo;
- element._hasKnownChainState = false;
window.Gerrit.install(
p => {
plugin = p;
@@ -76,11 +65,7 @@ suite('gr-change-actions-js-api-interface tests', () => {
);
changeActions = plugin.changeActions();
// Mimic all plugins loaded.
- getPluginLoader().loadPlugins([]);
- });
-
- teardown(() => {
- resetPlugins();
+ testResolver(pluginLoaderToken).loadPlugins([]);
});
test('property existence', () => {
@@ -161,20 +146,17 @@ suite('gr-change-actions-js-api-interface tests', () => {
test('move action button to overflow', async () => {
const key = changeActions.add(ActionType.REVISION, 'Bork!');
await element.updateComplete;
- assert.isTrue(queryAndAssert<GrDropdown>(element, '#moreActions').hidden);
- assert.isOk(
- queryAndAssert<GrButton>(element, `[data-action-key="${key}"]`)
- );
+
+ let items = queryAndAssert<GrDropdown>(element, '#moreActions').items;
+ assert.isFalse(items?.some(item => item.name === 'Bork!'));
+ assert.isOk(query<GrButton>(element, `[data-action-key="${key}"]`));
+
changeActions.setActionOverflow(ActionType.REVISION, key, true);
await element.updateComplete;
+
+ items = queryAndAssert<GrDropdown>(element, '#moreActions').items;
+ assert.isTrue(items?.some(item => item.name === 'Bork!'));
assert.isNotOk(query<GrButton>(element, `[data-action-key="${key}"]`));
- assert.isFalse(
- queryAndAssert<GrDropdown>(element, '#moreActions').hidden
- );
- assert.strictEqual(
- queryAndAssert<GrDropdown>(element, '#moreActions').items![0].name,
- 'Bork!'
- );
});
test('change actions priority', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
index 1d47d376d4..65d2687078 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.ts
@@ -6,44 +6,17 @@
import '../../../test/common-test-setup';
import '../../change/gr-reply-dialog/gr-reply-dialog';
import {stubElement} from '../../../test/test-utils';
-import {assert} from '@open-wc/testing';
+import {assert, fixture} from '@open-wc/testing';
import {PluginApi} from '../../../api/plugin';
import {ChangeReplyPluginApi} from '../../../api/change-reply';
+import {GrReplyDialog} from '../../change/gr-reply-dialog/gr-reply-dialog';
+import {html} from 'lit';
suite('gr-change-reply-js-api tests', () => {
let changeReply: ChangeReplyPluginApi;
let plugin: PluginApi;
- suite('early init', () => {
- setup(async () => {
- window.Gerrit.install(
- p => {
- plugin = p;
- },
- '0.1',
- 'http://test.com/plugins/testplugin/static/test.js'
- );
- changeReply = plugin.changeReply();
- });
-
- test('works', () => {
- stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
- assert.equal(changeReply.getLabelValue('My-Label'), '+123');
-
- const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
- changeReply.setLabelValue('My-Label', '+1337');
- assert.isTrue(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
-
- const setPluginMessageStub = stubElement(
- 'gr-reply-dialog',
- 'setPluginMessage'
- );
- changeReply.showMessage('foobar');
- assert.isTrue(setPluginMessageStub.calledWithExactly('foobar'));
- });
- });
-
- suite('normal init', () => {
+ suite('init', () => {
setup(async () => {
window.Gerrit.install(
p => {
@@ -53,10 +26,13 @@ suite('gr-change-reply-js-api tests', () => {
'http://test.com/plugins/testplugin/static/test.js'
);
changeReply = plugin.changeReply();
+ await fixture<GrReplyDialog>(html`<gr-reply-dialog></gr-reply-dialog>>`);
+ assert.ok(changeReply);
});
test('works', () => {
stubElement('gr-reply-dialog', 'getLabelValue').returns('+123');
+ assert.ok(changeReply);
assert.equal(changeReply.getLabelValue('My-Label'), '+123');
const setLabelValueStub = stubElement('gr-reply-dialog', 'setLabelValue');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
deleted file mode 100644
index 4900ed5156..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
+++ /dev/null
@@ -1,306 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/**
- * This defines the Gerrit instance. All methods directly attached to Gerrit
- * should be defined or linked here.
- */
-import {getPluginLoader, PluginOptionMap} from './gr-plugin-loader';
-import {send} from './gr-api-utils';
-import {getAppContext, AppContext} from '../../../services/app-context';
-import {PluginApi} from '../../../api/plugin';
-import {AuthService} from '../../../services/gr-auth/gr-auth';
-import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {HttpMethod} from '../../../constants/constants';
-import {RequestPayload} from '../../../types/common';
-import {
- EventCallback,
- EventEmitterService,
-} from '../../../services/gr-event-interface/gr-event-interface';
-import {Gerrit} from '../../../api/gerrit';
-import {fontStyles} from '../../../styles/gr-font-styles';
-import {formStyles} from '../../../styles/gr-form-styles';
-import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
-import {spinnerStyles} from '../../../styles/gr-spinner-styles';
-import {subpageStyles} from '../../../styles/gr-subpage-styles';
-import {tableStyles} from '../../../styles/gr-table-styles';
-import {assertIsDefined} from '../../../utils/common-util';
-import {iconStyles} from '../../../styles/gr-icon-styles';
-
-/**
- * These are the methods and properties that are exposed explicitly in the
- * public global `Gerrit` interface. In reality JavaScript plugins do depend
- * on some of this "internal" stuff. But we want to convert plugins to
- * TypeScript one by one and while doing that remove those dependencies.
- */
-export interface GerritInternal extends EventEmitterService, Gerrit {
- css(rule: string): string;
- install(
- callback: (plugin: PluginApi) => void,
- opt_version?: string,
- src?: string
- ): void;
- getLoggedIn(): Promise<boolean>;
- get(url: string, callback?: (response: unknown) => void): void;
- post(
- url: string,
- payload?: RequestPayload,
- callback?: (response: unknown) => void
- ): void;
- put(
- url: string,
- payload?: RequestPayload,
- callback?: (response: unknown) => void
- ): void;
- delete(url: string, callback?: (response: unknown) => void): void;
- isPluginLoaded(pathOrUrl: string): boolean;
- awaitPluginsLoaded(): Promise<unknown>;
- _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
- _arePluginsLoaded(): boolean;
- _isPluginEnabled(pathOrUrl: string): boolean;
- _isPluginLoaded(pathOrUrl: string): boolean;
- _customStyleSheet?: CSSStyleSheet;
-
- // exposed methods
- Auth: AuthService;
-}
-
-export function initGerritPluginApi(appContext: AppContext) {
- window.Gerrit = window.Gerrit ?? new GerritImpl(appContext);
-}
-
-export function _testOnly_getGerritInternalPluginApi(): GerritInternal {
- if (!window.Gerrit) throw new Error('initGerritPluginApi was not called');
- return window.Gerrit as GerritInternal;
-}
-
-export function deprecatedDelete(
- url: string,
- callback?: (response: Response) => void
-) {
- console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
- return getAppContext()
- .restApiService.send(HttpMethod.DELETE, url)
- .then(response => {
- if (response.status !== 204) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(`${response.status}`));
- }
- });
- }
- if (callback) callback(response);
- return response;
- });
-}
-
-const fakeApi = {
- getPluginName: () => 'global',
-};
-
-/**
- * TODO(brohlfs): Reduce this step by step until it only contains install().
- */
-class GerritImpl implements GerritInternal {
- _customStyleSheet?: CSSStyleSheet;
-
- public readonly Auth: AuthService;
-
- private readonly reportingService: ReportingService;
-
- private readonly eventEmitter: EventEmitterService;
-
- private readonly restApiService: RestApiService;
-
- public readonly styles = {
- font: fontStyles,
- form: formStyles,
- icon: iconStyles,
- menuPage: menuPageStyles,
- spinner: spinnerStyles,
- subPage: subpageStyles,
- table: tableStyles,
- };
-
- constructor(appContext: AppContext) {
- this.Auth = appContext.authService;
- this.reportingService = appContext.reportingService;
- this.eventEmitter = appContext.eventEmitter;
- this.restApiService = appContext.restApiService;
- assertIsDefined(this.reportingService, 'reportingService');
- assertIsDefined(this.eventEmitter, 'eventEmitter');
- assertIsDefined(this.restApiService, 'restApiService');
- }
-
- finalize() {}
-
- /**
- * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
- * the documentation how to replace it accordingly.
- */
- css(rulesStr: string) {
- this.reportingService.trackApi(fakeApi, 'global', 'css');
- console.warn(
- 'Gerrit.css(rulesStr) is deprecated!',
- 'Use plugin.styles().css(rulesStr)'
- );
- if (!this._customStyleSheet) {
- const styleEl = document.createElement('style');
- document.head.appendChild(styleEl);
- this._customStyleSheet = styleEl.sheet!;
- }
-
- const name = `__pg_js_api_class_${this._customStyleSheet.cssRules.length}`;
- this._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
- return name;
- }
-
- install(
- callback: (plugin: PluginApi) => void,
- version?: string,
- src?: string
- ) {
- getPluginLoader().install(callback, version, src);
- }
-
- getLoggedIn() {
- this.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
- console.warn(
- 'Gerrit.getLoggedIn() is deprecated! ' +
- 'Use plugin.restApi().getLoggedIn()'
- );
- return this.restApiService.getLoggedIn();
- }
-
- get(url: string, callback?: (response: unknown) => void) {
- this.reportingService.trackApi(fakeApi, 'global', 'get');
- console.warn('.get() is deprecated! Use plugin.restApi().get()');
- send(this.restApiService, HttpMethod.GET, url, callback);
- }
-
- post(
- url: string,
- payload?: RequestPayload,
- callback?: (response: unknown) => void
- ) {
- this.reportingService.trackApi(fakeApi, 'global', 'post');
- console.warn('.post() is deprecated! Use plugin.restApi().post()');
- send(this.restApiService, HttpMethod.POST, url, callback, payload);
- }
-
- put(
- url: string,
- payload?: RequestPayload,
- callback?: (response: unknown) => void
- ) {
- this.reportingService.trackApi(fakeApi, 'global', 'put');
- console.warn('.put() is deprecated! Use plugin.restApi().put()');
- send(this.restApiService, HttpMethod.PUT, url, callback, payload);
- }
-
- delete(url: string, callback?: (response: Response) => void) {
- this.reportingService.trackApi(fakeApi, 'global', 'delete');
- deprecatedDelete(url, callback);
- }
-
- awaitPluginsLoaded() {
- this.reportingService.trackApi(fakeApi, 'global', 'awaitPluginsLoaded');
- return getPluginLoader().awaitPluginsLoaded();
- }
-
- // TODO(taoalpha): consider removing these proxy methods
- // and using getPluginLoader() directly
- _loadPlugins(plugins: string[] = []) {
- this.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
- getPluginLoader().loadPlugins(plugins);
- }
-
- _arePluginsLoaded() {
- this.reportingService.trackApi(fakeApi, 'global', '_arePluginsLoaded');
- return getPluginLoader().arePluginsLoaded();
- }
-
- _isPluginEnabled(pathOrUrl: string) {
- this.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
- return getPluginLoader().isPluginEnabled(pathOrUrl);
- }
-
- isPluginLoaded(pathOrUrl: string) {
- return this._isPluginLoaded(pathOrUrl);
- }
-
- _isPluginLoaded(pathOrUrl: string) {
- this.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
- return getPluginLoader().isPluginLoaded(pathOrUrl);
- }
-
- /**
- * Enabling EventEmitter interface on Gerrit.
- *
- * This will enable to signal across different parts of js code without relying on DOM,
- * including core to core, plugin to plugin and also core to plugin.
- *
- * @example
- *
- * // Emit this event from pluginA
- * Gerrit.install(pluginA => {
- * fetch("some-api").then(() => {
- * Gerrit.on("your-special-event", {plugin: pluginA});
- * });
- * });
- *
- * // Listen on your-special-event from pluginB
- * Gerrit.install(pluginB => {
- * Gerrit.on("your-special-event", ({plugin}) => {
- * // do something, plugin is pluginA
- * });
- * });
- */
- addListener(eventName: string, cb: EventCallback) {
- this.reportingService.trackApi(fakeApi, 'global', 'addListener');
- return this.eventEmitter.addListener(eventName, cb);
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- dispatch(eventName: string, detail: any) {
- this.reportingService.trackApi(fakeApi, 'global', 'dispatch');
- return this.eventEmitter.dispatch(eventName, detail);
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- emit(eventName: string, detail: any) {
- this.reportingService.trackApi(fakeApi, 'global', 'emit');
- return this.eventEmitter.emit(eventName, detail);
- }
-
- off(eventName: string, cb: EventCallback) {
- this.reportingService.trackApi(fakeApi, 'global', 'off');
- this.eventEmitter.off(eventName, cb);
- }
-
- on(eventName: string, cb: EventCallback) {
- this.reportingService.trackApi(fakeApi, 'global', 'on');
- return this.eventEmitter.on(eventName, cb);
- }
-
- once(eventName: string, cb: EventCallback) {
- this.reportingService.trackApi(fakeApi, 'global', 'once');
- return this.eventEmitter.once(eventName, cb);
- }
-
- removeAllListeners(eventName: string) {
- this.reportingService.trackApi(fakeApi, 'global', 'removeAllListeners');
- this.eventEmitter.removeAllListeners(eventName);
- }
-
- removeListener(eventName: string, cb: EventCallback) {
- this.reportingService.trackApi(fakeApi, 'global', 'removeListener');
- this.eventEmitter.removeListener(eventName, cb);
- }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts
deleted file mode 100644
index b906891945..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {getPluginLoader} from './gr-plugin-loader';
-import {resetPlugins} from '../../../test/test-utils';
-import {
- GerritInternal,
- _testOnly_getGerritInternalPluginApi,
-} from './gr-gerrit';
-import {stubRestApi} from '../../../test/test-utils';
-import {getAppContext} from '../../../services/app-context';
-import {GrJsApiInterface} from './gr-js-api-interface-element';
-import {SinonFakeTimers} from 'sinon';
-import {Timestamp} from '../../../api/rest-api';
-import {assert} from '@open-wc/testing';
-
-suite('gr-gerrit tests', () => {
- let element: GrJsApiInterface;
- let clock: SinonFakeTimers;
- let pluginApi: GerritInternal;
-
- setup(() => {
- clock = sinon.useFakeTimers();
-
- stubRestApi('getAccount').returns(
- Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
- );
- stubRestApi('send').returns(
- Promise.resolve({...new Response(), status: 200})
- );
- element = getAppContext().jsApiService as GrJsApiInterface;
- pluginApi = _testOnly_getGerritInternalPluginApi();
- });
-
- teardown(() => {
- clock.restore();
- element._removeEventCallbacks();
- resetPlugins();
- });
-
- suite('proxy methods', () => {
- test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
- const stubFn = sinon.stub();
- sinon
- .stub(getPluginLoader(), 'isPluginEnabled')
- .callsFake((...args) => stubFn(...args));
- pluginApi._isPluginEnabled('test_plugin');
- assert.isTrue(stubFn.calledWith('test_plugin'));
- });
-
- test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
- const stubFn = sinon.stub();
- sinon
- .stub(getPluginLoader(), 'isPluginLoaded')
- .callsFake((...args) => stubFn(...args));
- pluginApi._isPluginLoaded('test_plugin');
- assert.isTrue(stubFn.calledWith('test_plugin'));
- });
- });
-});
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 93b16845dd..46c759b11d 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
@@ -3,15 +3,14 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {getPluginLoader} from './gr-plugin-loader';
import {hasOwnProperty} from '../../../utils/common-util';
import {
ChangeInfo,
LabelNameToValueMap,
+ PARENT,
ReviewInput,
RevisionInfo,
} from '../../../types/common';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
import {
JsApiService,
@@ -20,53 +19,23 @@ import {
ShowRevisionActionsDetail,
} from './gr-js-api-types';
import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, HighlightJS, ParsedChangeInfo} from '../../../types/types';
+import {ParsedChangeInfo} from '../../../types/types';
import {MenuLink} from '../../../api/admin';
import {Finalizable} from '../../../services/registry';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {Provider} from '../../../models/dependency';
const elements: {[key: string]: HTMLElement} = {};
const eventCallbacks: {[key: string]: EventCallback[]} = {};
export class GrJsApiInterface implements JsApiService, Finalizable {
- constructor(readonly reporting: ReportingService) {}
+ constructor(
+ private waitForPluginsToLoad: Provider<Promise<void>>,
+ readonly reporting: ReportingService
+ ) {}
finalize() {}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- handleEvent(type: EventType, detail: any) {
- getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- switch (type) {
- case EventType.HISTORY:
- this._handleHistory(detail);
- break;
- case EventType.SHOW_CHANGE:
- this._handleShowChange(detail);
- break;
- case EventType.COMMENT:
- this._handleComment(detail);
- break;
- case EventType.LABEL_CHANGE:
- this._handleLabelChange(detail);
- break;
- case EventType.SHOW_REVISION_ACTIONS:
- this._handleShowRevisionActions(detail);
- break;
- case EventType.HIGHLIGHTJS_LOADED:
- this._handleHighlightjsLoaded(detail);
- break;
- default:
- console.warn(
- 'handleEvent called with unsupported event type:',
- type
- );
- break;
- }
- });
- }
-
addElement(key: TargetElement, el: HTMLElement) {
elements[key] = el;
}
@@ -107,23 +76,9 @@ export class GrJsApiInterface implements JsApiService, Finalizable {
}
}
- // TODO(TS): The HISTORY event and its handler seem unused.
- _handleHistory(detail: {path: string}) {
- for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
- try {
- cb(detail.path);
- } catch (err: unknown) {
- this.reporting.error(
- 'GrJsApiInterface',
- new Error('handleHistory callback error'),
- err
- );
- }
- }
- }
-
- _handleShowChange(detail: ShowChangeDetail) {
+ async handleShowChange(detail: ShowChangeDetail) {
if (!detail.change) return;
+ await this.waitForPluginsToLoad();
// Note (issue 8221) Shallow clone the change object and add a mergeable
// getter with deprecation warning. This makes the change detail appear as
// though SKIP_MERGEABLE was not set, so that plugins that expect it can
@@ -144,20 +99,22 @@ export class GrJsApiInterface implements JsApiService, Finalizable {
return detail.info && detail.info.mergeable;
},
};
- const patchNum = detail.patchNum;
- const info = detail.info;
+ const {patchNum, info, basePatchNum} = detail;
let revision;
+ let baseRevision;
for (const rev of Object.values(change.revisions || {})) {
if (rev._number === patchNum) {
revision = rev;
- break;
+ }
+ if (rev._number === basePatchNum) {
+ baseRevision = rev;
}
}
for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
try {
- cb(change, revision, info);
+ cb(change, revision, info, baseRevision ?? PARENT);
} catch (err: unknown) {
this.reporting.error(
'GrJsApiInterface',
@@ -168,7 +125,8 @@ export class GrJsApiInterface implements JsApiService, Finalizable {
}
}
- _handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+ async handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+ await this.waitForPluginsToLoad();
const registeredCallbacks = this._getEventCallbacks(
EventType.SHOW_REVISION_ACTIONS
);
@@ -199,22 +157,8 @@ export class GrJsApiInterface implements JsApiService, Finalizable {
}
}
- // TODO(TS): The COMMENT event and its handler seem unused.
- _handleComment(detail: {node: Node}) {
- for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
- try {
- cb(detail.node);
- } catch (err: unknown) {
- this.reporting.error(
- 'GrJsApiInterface',
- new Error('comment callback error'),
- err
- );
- }
- }
- }
-
- _handleLabelChange(detail: {change: ChangeInfo}) {
+ async handleLabelChange(detail: {change?: ParsedChangeInfo}) {
+ await this.waitForPluginsToLoad();
for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
try {
cb(detail.change);
@@ -228,20 +172,6 @@ export class GrJsApiInterface implements JsApiService, Finalizable {
}
}
- _handleHighlightjsLoaded(detail: {hljs: HighlightJS}) {
- for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
- try {
- cb(detail.hljs);
- } catch (err: unknown) {
- this.reporting.error(
- 'GrJsApiInterface',
- new Error('HighlightjsLoaded callback error'),
- err
- );
- }
- }
- }
-
modifyRevertMsg(change: ChangeInfo, revertMsg: string, origMsg: string) {
for (const cb of this._getEventCallbacks(EventType.REVERT)) {
try {
@@ -280,60 +210,6 @@ export class GrJsApiInterface implements JsApiService, Finalizable {
return revertSubmissionMsg;
}
- getDiffLayers(path: string) {
- const layers: DiffLayer[] = [];
- for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
- const annotationApi = cb as unknown as GrAnnotationActionsInterface;
- try {
- const layer = annotationApi.createLayer(path);
- if (layer) layers.push(layer);
- } catch (err: unknown) {
- this.reporting.error(
- 'GrJsApiInterface',
- new Error('getDiffLayers callback error'),
- err
- );
- }
- }
- return layers;
- }
-
- disposeDiffLayers(path: string) {
- for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
- try {
- const annotationApi = cb as unknown as GrAnnotationActionsInterface;
- annotationApi.disposeLayer(path);
- } catch (err: unknown) {
- this.reporting.error(
- 'GrJsApiInterface',
- new Error('disposeDiffLayers callback error'),
- err
- );
- }
- }
- }
-
- /**
- * Retrieves coverage data possibly provided by a plugin.
- *
- * Will wait for plugins to be loaded. If multiple plugins offer a coverage
- * provider, the first one is returned. If no plugin offers a coverage provider,
- * will resolve to null.
- */
- getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]> {
- return getPluginLoader()
- .awaitPluginsLoaded()
- .then(() => {
- const providers: GrAnnotationActionsInterface[] = [];
- this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
- const annotationApi = cb as unknown as GrAnnotationActionsInterface;
- const provider = annotationApi.getCoverageProvider();
- if (provider) providers.push(annotationApi);
- });
- return providers;
- });
- }
-
getAdminMenuLinks(): MenuLink[] {
const links: MenuLink[] = [];
for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
index 240bc0bdd0..2ec4f2762f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -5,4 +5,3 @@
*/
import './gr-js-api-interface-element';
import './gr-public-js-api';
-import './gr-gerrit';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
deleted file mode 100644
index 4fc403d848..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ /dev/null
@@ -1,349 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-js-api-interface';
-import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
-import {EventType} from '../../../api/plugin';
-import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
-import {getPluginLoader} from './gr-plugin-loader';
-import {
- stubRestApi,
- stubBaseUrl,
- waitEventLoop,
-} from '../../../test/test-utils';
-import {getAppContext} from '../../../services/app-context';
-import {assert} from '@open-wc/testing';
-
-suite('GrJsApiInterface tests', () => {
- let element;
- let plugin;
- let errorStub;
-
- let sendStub;
- let clock;
-
- const throwErrFn = function() {
- throw Error('Unfortunately, this handler has stopped');
- };
-
- setup(() => {
- clock = sinon.useFakeTimers();
-
- stubRestApi('getAccount').returns(Promise.resolve({name: 'Judy Hopps'}));
- sendStub = stubRestApi('send').returns(Promise.resolve({status: 200}));
- element = getAppContext().jsApiService;
- errorStub = sinon.stub(element.reporting, 'error');
- window.Gerrit.install(p => { plugin = p; }, '0.1',
- 'http://test.com/plugins/testplugin/static/test.js');
- getPluginLoader().loadPlugins([]);
- });
-
- teardown(() => {
- clock.restore();
- element._removeEventCallbacks();
- plugin = null;
- });
-
- test('url', () => {
- assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
- assert.equal(plugin.url('/static/test.js'),
- 'http://test.com/plugins/testplugin/static/test.js');
- });
-
- test('_send on failure rejects with response text', () => {
- sendStub.returns(Promise.resolve(
- {status: 400, text() { return Promise.resolve('text'); }}));
- return plugin._send().catch(r => {
- assert.equal(r.message, 'text');
- });
- });
-
- test('_send on failure without text rejects with code', () => {
- sendStub.returns(Promise.resolve(
- {status: 400, text() { return Promise.resolve(null); }}));
- return plugin._send().catch(r => {
- assert.equal(r.message, '400');
- });
- });
-
- test('history event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- plugin.on(EventType.HISTORY, throwErrFn);
- plugin.on(EventType.HISTORY, resolve);
- element.handleEvent(EventType.HISTORY, {path: '/path/to/awesomesauce'});
- const path = await promise;
- assert.equal(path, '/path/to/awesomesauce');
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('showchange event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const testChange = {
- _number: 42,
- revisions: {def: {_number: 2}, abc: {_number: 1}},
- };
- const expectedChange = {mergeable: false, ...testChange};
- plugin.on(EventType.SHOW_CHANGE, throwErrFn);
- plugin.on(EventType.SHOW_CHANGE, (change, revision, info) => {
- resolve({change, revision, info});
- });
- element.handleEvent(EventType.SHOW_CHANGE,
- {change: testChange, patchNum: 1, info: {mergeable: false}});
-
- const {change, revision, info} = await promise;
- assert.deepEqual(change, expectedChange);
- assert.deepEqual(revision, testChange.revisions.abc);
- assert.deepEqual(info, {mergeable: false});
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('show-revision-actions event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const testChange = {
- _number: 42,
- revisions: {def: {_number: 2}, abc: {_number: 1}},
- };
- plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
- plugin.on(EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
- resolve({change, actions});
- });
- element.handleEvent(EventType.SHOW_REVISION_ACTIONS,
- {change: testChange, revisionActions: {test: {}}});
-
- const {change, actions} = await promise;
- assert.deepEqual(change, testChange);
- assert.deepEqual(actions, {test: {}});
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('handleEvent awaits plugins load', async () => {
- const testChange = {
- _number: 42,
- revisions: {def: {_number: 2}, abc: {_number: 1}},
- };
- const spy = sinon.spy();
- getPluginLoader().loadPlugins(['plugins/test.js']);
- plugin.on(EventType.SHOW_CHANGE, spy);
- element.handleEvent(EventType.SHOW_CHANGE,
- {change: testChange, patchNum: 1});
- assert.isFalse(spy.called);
-
- // Timeout on loading plugins
- clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
-
- await waitEventLoop();
- assert.isTrue(spy.called);
- });
-
- test('comment event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const testCommentNode = {foo: 'bar'};
- plugin.on(EventType.COMMENT, throwErrFn);
- plugin.on(EventType.COMMENT, resolve);
- element.handleEvent(EventType.COMMENT, {node: testCommentNode});
-
- const commentNode = await promise;
- assert.deepEqual(commentNode, testCommentNode);
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('revert event', () => {
- function appendToRevertMsg(c, revertMsg, originalMsg) {
- return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
- }
-
- assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
- assert.equal(errorStub.callCount, 0);
-
- plugin.on(EventType.REVERT, throwErrFn);
- plugin.on(EventType.REVERT, appendToRevertMsg);
- assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
- 'test\n> origTest\ninfo');
- assert.isTrue(errorStub.calledOnce);
-
- plugin.on(EventType.REVERT, appendToRevertMsg);
- assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
- 'test\n> origTest\ninfo\n> origTest\ninfo');
- assert.isTrue(errorStub.calledTwice);
- });
-
- test('postrevert event labels', () => {
- function getLabels(c) {
- return {'Code-Review': 1};
- }
-
- assert.deepEqual(element.getReviewPostRevert(null), {});
- assert.equal(errorStub.callCount, 0);
-
- plugin.on(EventType.POST_REVERT, throwErrFn);
- plugin.on(EventType.POST_REVERT, getLabels);
- assert.deepEqual(
- element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('postrevert event review', () => {
- function getReview(c) {
- return {labels: {'Code-Review': 1}};
- }
-
- assert.deepEqual(element.getReviewPostRevert(null), {});
- assert.equal(errorStub.callCount, 0);
-
- plugin.on(EventType.POST_REVERT, throwErrFn);
- plugin.on(EventType.POST_REVERT, getReview);
- assert.deepEqual(
- element.getReviewPostRevert(null), {labels: {'Code-Review': 1}});
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('commitmsgedit event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const testMsg = 'Test CL commit message';
- plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
- plugin.on(EventType.COMMIT_MSG_EDIT, (change, msg) => {
- resolve(msg);
- });
- element.handleCommitMessage(null, testMsg);
-
- const msg = await promise;
- assert.deepEqual(msg, testMsg);
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('labelchange event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const testChange = {_number: 42};
- plugin.on(EventType.LABEL_CHANGE, throwErrFn);
- plugin.on(EventType.LABEL_CHANGE, resolve);
- element.handleEvent(EventType.LABEL_CHANGE, {change: testChange});
-
- const change = await promise;
- assert.deepEqual(change, testChange);
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('submitchange', () => {
- plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
- plugin.on(EventType.SUBMIT_CHANGE, () => true);
- assert.isTrue(element.canSubmitChange());
- assert.isTrue(errorStub.calledOnce);
- plugin.on(EventType.SUBMIT_CHANGE, () => false);
- plugin.on(EventType.SUBMIT_CHANGE, () => true);
- assert.isFalse(element.canSubmitChange());
- assert.isTrue(errorStub.calledTwice);
- });
-
- test('highlightjs-loaded event', async () => {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const testHljs = {_number: 42};
- plugin.on(EventType.HIGHLIGHTJS_LOADED, throwErrFn);
- plugin.on(EventType.HIGHLIGHTJS_LOADED, resolve);
- element.handleEvent(EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
-
- const hljs = await promise;
- assert.deepEqual(hljs, testHljs);
- assert.isTrue(errorStub.calledOnce);
- });
-
- test('getLoggedIn', () => {
- // fake fetch for authCheck
- sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
- return plugin.restApi().getLoggedIn()
- .then(loggedIn => {
- assert.isTrue(loggedIn);
- });
- });
-
- test('attributeHelper', () => {
- assert.isOk(plugin.attributeHelper());
- });
-
- test('getAdminMenuLinks', () => {
- const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
- const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
- .returns([
- {getMenuLinks: () => [links[0]]},
- {getMenuLinks: () => [links[1]]},
- ]);
- const result = element.getAdminMenuLinks();
- assert.deepEqual(result, links);
- assert.isTrue(getCallbacksStub.calledOnce);
- assert.equal(getCallbacksStub.lastCall.args[0],
- EventType.ADMIN_MENU_LINKS);
- });
-
- suite('test plugin with base url', () => {
- let baseUrlPlugin;
-
- setup(() => {
- stubBaseUrl('/r');
-
- window.Gerrit.install(p => { baseUrlPlugin = p; }, '0.1',
- 'http://test.com/r/plugins/baseurlplugin/static/test.js');
- });
-
- test('url', () => {
- assert.notEqual(baseUrlPlugin.url(),
- 'http://test.com/plugins/baseurlplugin/');
- assert.equal(baseUrlPlugin.url(),
- 'http://test.com/r/plugins/baseurlplugin/');
- assert.equal(baseUrlPlugin.url('/static/test.js'),
- 'http://test.com/r/plugins/baseurlplugin/static/test.js');
- });
- });
-
- suite('popup', () => {
- test('popup(element) is deprecated', () => {
- sinon.stub(console, 'error');
- plugin.popup(document.createElement('div'));
- assert.isTrue(console.error.calledOnce);
- });
-
- test('popup(moduleName) creates popup with component', () => {
- const openStub = sinon.stub(GrPopupInterface.prototype, 'open').callsFake(
- function() {
- // Arrow function can't be used here, because we want to
- // get properties from the instance of GrPopupInterface
- // eslint-disable-next-line no-invalid-this
- const grPopupInterface = this;
- assert.equal(grPopupInterface.plugin, plugin);
- assert.equal(grPopupInterface.moduleName, 'some-name');
- });
- plugin.popup('some-name');
- assert.isTrue(openStub.calledOnce);
- });
- });
-
- suite('screen', () => {
- test('screenUrl()', () => {
- stubBaseUrl('/base');
- assert.equal(
- plugin.screenUrl(),
- `${location.origin}/base/x/testplugin`
- );
- assert.equal(
- plugin.screenUrl('foo'),
- `${location.origin}/base/x/testplugin/foo`
- );
- });
-
- test('works', () => {
- sinon.stub(plugin, 'registerCustomComponent');
- plugin.screen('foo', 'some-module');
- assert.isTrue(plugin.registerCustomComponent.calledWith(
- 'testplugin-screen-foo', 'some-module'));
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
new file mode 100644
index 0000000000..a21ddc31d8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.ts
@@ -0,0 +1,401 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {EventType} from '../../../api/plugin';
+import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
+import {
+ stubRestApi,
+ stubBaseUrl,
+ waitEventLoop,
+ waitUntilCalled,
+ assertFails,
+} from '../../../test/test-utils';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {PluginLoader, pluginLoaderToken} from './gr-plugin-loader';
+import {useFakeTimers, stub, SinonFakeTimers, SinonStub} from 'sinon';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {Plugin} from './gr-public-js-api';
+import {
+ ChangeInfo,
+ HttpMethod,
+ NumericChangeId,
+ PatchSetNum,
+ RevisionPatchSetNum,
+ Timestamp,
+} from '../../../api/rest-api';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+ createChange,
+ createParsedChange,
+ createRevision,
+} from '../../../test/test-data-generators';
+import {EventCallback} from './gr-js-api-types';
+
+suite('GrJsApiInterface tests', () => {
+ let element: GrJsApiInterface;
+ let plugin: Plugin;
+ let errorStub: SinonStub;
+ let pluginLoader: PluginLoader;
+
+ let sendStub: SinonStub;
+ let clock: SinonFakeTimers;
+
+ const throwErrFn = function () {
+ throw Error('Unfortunately, this handler has stopped');
+ };
+
+ setup(() => {
+ clock = useFakeTimers();
+
+ stubRestApi('getAccount').resolves({
+ name: 'Judy Hopps',
+ registered_on: '' as Timestamp,
+ });
+ sendStub = stubRestApi('send').resolves(
+ new Response(undefined, {status: 200})
+ );
+ pluginLoader = testResolver(pluginLoaderToken);
+
+ // We are using the jsApiService as the implementation class rather than the
+ // interface to better set up tests.
+ element = pluginLoader.jsApiService as GrJsApiInterface;
+ errorStub = stub(element.reporting, 'error');
+ pluginLoader.install(
+ p => {
+ // We are using the plugin API as the implementation class rather than
+ // the interface to better set up tests.
+ plugin = p as Plugin;
+ },
+ '0.1',
+ 'http://test.com/plugins/testplugin/static/test.js'
+ );
+ testResolver(pluginLoaderToken).loadPlugins([]);
+ });
+
+ teardown(() => {
+ clock.restore();
+ element._removeEventCallbacks();
+ });
+
+ test('url', () => {
+ assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
+ assert.equal(
+ plugin.url('/static/test.js'),
+ 'http://test.com/plugins/testplugin/static/test.js'
+ );
+ });
+
+ test('_send on failure rejects with response text', async () => {
+ sendStub.resolves({
+ status: 400,
+ text() {
+ return Promise.resolve('text');
+ },
+ });
+ const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
+ assert.equal(error.message, 'text');
+ });
+
+ test('_send on failure without text rejects with code', async () => {
+ sendStub.resolves({
+ status: 400,
+ text() {
+ return Promise.resolve(null);
+ },
+ });
+ const error = await assertFails<Error>(plugin._send(HttpMethod.POST, ''));
+ assert.equal(error.message, '400');
+ });
+
+ test('showchange event', async () => {
+ const showChangeStub = stub();
+ const testChange: ParsedChangeInfo = {
+ ...createParsedChange(),
+ _number: 42 as NumericChangeId,
+ revisions: {
+ def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+ abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+ },
+ };
+ const expectedChange = {mergeable: false, ...testChange};
+
+ plugin.on(EventType.SHOW_CHANGE, throwErrFn);
+ plugin.on(EventType.SHOW_CHANGE, showChangeStub);
+ element.handleShowChange({
+ change: testChange,
+ patchNum: 1 as PatchSetNum,
+ info: {mergeable: false},
+ });
+ await waitUntilCalled(showChangeStub, 'showChangeStub');
+
+ const [change, revision, info] = showChangeStub.firstCall.args;
+ assert.deepEqual(change, expectedChange);
+ assert.deepEqual(revision, testChange.revisions.abc);
+ assert.deepEqual(info, {mergeable: false});
+ assert.isTrue(errorStub.calledOnce);
+ });
+
+ test('show-revision-actions event', async () => {
+ const showRevisionActionsStub = stub();
+ const testChange: ChangeInfo = {
+ ...createChange(),
+ _number: 42 as NumericChangeId,
+ revisions: {
+ def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+ abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+ },
+ };
+
+ plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+ plugin.on(EventType.SHOW_REVISION_ACTIONS, showRevisionActionsStub);
+ element.handleShowRevisionActions({
+ change: testChange,
+ revisionActions: {test: {}},
+ });
+ await waitUntilCalled(showRevisionActionsStub, 'showRevisionActionsStub');
+
+ const [actions, change] = showRevisionActionsStub.firstCall.args;
+ assert.deepEqual(change, testChange);
+ assert.deepEqual(actions, {test: {}});
+ assert.isTrue(errorStub.calledOnce);
+ });
+
+ test('handleShowChange awaits plugins load', async () => {
+ const testChange: ParsedChangeInfo = {
+ ...createParsedChange(),
+ _number: 42 as NumericChangeId,
+ revisions: {
+ def: {...createRevision(), _number: 2 as RevisionPatchSetNum},
+ abc: {...createRevision(), _number: 1 as RevisionPatchSetNum},
+ },
+ };
+ const showChangeStub = stub();
+ testResolver(pluginLoaderToken).loadPlugins(['plugins/test.js']);
+ plugin.on(EventType.SHOW_CHANGE, showChangeStub);
+ element.handleShowChange({
+ change: testChange,
+ patchNum: 1 as PatchSetNum,
+ info: {mergeable: null},
+ });
+ assert.isFalse(showChangeStub.called);
+
+ // Timeout on loading plugins
+ clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+
+ await waitEventLoop();
+ assert.isTrue(showChangeStub.called);
+ });
+
+ test('revert event', () => {
+ function appendToRevertMsg(
+ _c: unknown,
+ revertMsg: string,
+ originalMsg: string
+ ) {
+ return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
+ }
+ const change = createChange();
+
+ assert.equal(element.modifyRevertMsg(change, 'test', 'origTest'), 'test');
+ assert.equal(errorStub.callCount, 0);
+
+ plugin.on(EventType.REVERT, throwErrFn);
+ plugin.on(EventType.REVERT, appendToRevertMsg);
+ assert.equal(
+ element.modifyRevertMsg(change, 'test', 'origTest'),
+ 'test\n> origTest\ninfo'
+ );
+ assert.isTrue(errorStub.calledOnce);
+
+ plugin.on(EventType.REVERT, appendToRevertMsg);
+ assert.equal(
+ element.modifyRevertMsg(change, 'test', 'origTest'),
+ 'test\n> origTest\ninfo\n> origTest\ninfo'
+ );
+ assert.isTrue(errorStub.calledTwice);
+ });
+
+ test('postrevert event labels', () => {
+ function getLabels(_c: unknown) {
+ return {'Code-Review': 1};
+ }
+
+ assert.deepEqual(element.getReviewPostRevert(undefined), {});
+ assert.equal(errorStub.callCount, 0);
+
+ plugin.on(EventType.POST_REVERT, throwErrFn);
+ plugin.on(EventType.POST_REVERT, getLabels);
+ assert.deepEqual(element.getReviewPostRevert(undefined), {
+ labels: {'Code-Review': 1},
+ });
+ assert.isTrue(errorStub.calledOnce);
+ });
+
+ test('postrevert event review', () => {
+ function getReview(_c: unknown) {
+ return {labels: {'Code-Review': 1}};
+ }
+
+ assert.deepEqual(element.getReviewPostRevert(undefined), {});
+ assert.equal(errorStub.callCount, 0);
+
+ plugin.on(EventType.POST_REVERT, throwErrFn);
+ plugin.on(EventType.POST_REVERT, getReview);
+ assert.deepEqual(element.getReviewPostRevert(undefined), {
+ labels: {'Code-Review': 1},
+ });
+ assert.isTrue(errorStub.calledOnce);
+ });
+
+ test('commitmsgedit event', async () => {
+ const commitMsgEditStub = stub();
+ const testMsg = 'Test CL commit message';
+
+ plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
+ plugin.on(EventType.COMMIT_MSG_EDIT, commitMsgEditStub);
+ element.handleCommitMessage(createChange(), testMsg);
+ await waitUntilCalled(commitMsgEditStub, 'commitMsgEditStub');
+
+ const msg = commitMsgEditStub.firstCall.args[1];
+ assert.deepEqual(msg, testMsg);
+ assert.isTrue(errorStub.calledOnce);
+ });
+
+ test('labelchange event', async () => {
+ const labelChangeStub = stub();
+ const testChange: ParsedChangeInfo = {
+ ...createParsedChange(),
+ _number: 42 as NumericChangeId,
+ };
+
+ plugin.on(EventType.LABEL_CHANGE, throwErrFn);
+ plugin.on(EventType.LABEL_CHANGE, labelChangeStub);
+ element.handleLabelChange({change: testChange});
+ await waitUntilCalled(labelChangeStub, 'labelChangeStub');
+
+ const [change] = labelChangeStub.firstCall.args;
+ assert.deepEqual(change, testChange);
+ assert.isTrue(errorStub.calledOnce);
+ });
+
+ test('submitchange', () => {
+ plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
+ plugin.on(EventType.SUBMIT_CHANGE, () => true);
+ assert.isTrue(element.canSubmitChange(createChange()));
+ assert.isTrue(errorStub.calledOnce);
+ plugin.on(EventType.SUBMIT_CHANGE, () => false);
+ plugin.on(EventType.SUBMIT_CHANGE, () => true);
+ assert.isFalse(element.canSubmitChange(createChange()));
+ assert.isTrue(errorStub.calledTwice);
+ });
+
+ test('getLoggedIn', async () => {
+ // fake fetch for authCheck
+ stub(window, 'fetch').resolves(new Response(undefined, {status: 204}));
+ const loggedIn = await plugin.restApi().getLoggedIn();
+ assert.isTrue(loggedIn);
+ });
+
+ test('attributeHelper', () => {
+ assert.isOk(plugin.attributeHelper(document.createElement('div')));
+ });
+
+ test('getAdminMenuLinks', () => {
+ const links = [
+ {text: 'a', url: 'b'},
+ {text: 'c', url: 'd'},
+ ];
+ // getAdminMenuLinks expects _getEventCallbacks to really return
+ // GrAdminApi[] even though _getEventCallbacks has return type
+ // EventCallback[]. Therefore this test must also return GrAdminApi[]
+ // disguised as EventCallback[].
+ const getCallbacksStub = stub(element, '_getEventCallbacks').returns([
+ {getMenuLinks: () => [links[0]]},
+ {getMenuLinks: () => [links[1]]},
+ ] as unknown as EventCallback[]);
+ const result = element.getAdminMenuLinks();
+ assert.deepEqual(result, links);
+ assert.isTrue(getCallbacksStub.calledOnce);
+ assert.equal(getCallbacksStub.lastCall.args[0], EventType.ADMIN_MENU_LINKS);
+ });
+
+ suite('test plugin with base url', () => {
+ let baseUrlPlugin: Plugin;
+
+ setup(() => {
+ stubBaseUrl('/r');
+
+ pluginLoader.install(
+ p => {
+ // We are using the plugin API as the implementation class rather than
+ // the interface to better set up tests.
+ baseUrlPlugin = p as Plugin;
+ },
+ '0.1',
+ 'http://test.com/r/plugins/baseurlplugin/static/test.js'
+ );
+ });
+
+ test('url', () => {
+ assert.notEqual(
+ baseUrlPlugin.url(),
+ 'http://test.com/plugins/baseurlplugin/'
+ );
+ assert.equal(
+ baseUrlPlugin.url(),
+ 'http://test.com/r/plugins/baseurlplugin/'
+ );
+ assert.equal(
+ baseUrlPlugin.url('/static/test.js'),
+ 'http://test.com/r/plugins/baseurlplugin/static/test.js'
+ );
+ });
+ });
+
+ suite('popup', () => {
+ test('popup(moduleName) creates popup with component', () => {
+ const openStub = stub(GrPopupInterface.prototype, 'open').callsFake(
+ async function (this: GrPopupInterface) {
+ // Arrow function can't be used here, because we want to
+ // get properties from the instance of GrPopupInterface
+ assert.equal(this.plugin, plugin);
+ assert.equal(this.moduleName, 'some-name');
+ return this;
+ }
+ );
+ plugin.popup('some-name');
+ assert.isTrue(openStub.calledOnce);
+ });
+ });
+
+ suite('screen', () => {
+ test('screenUrl()', () => {
+ stubBaseUrl('/base');
+ assert.equal(plugin.screenUrl(), `${location.origin}/base/x/testplugin`);
+ assert.equal(
+ plugin.screenUrl('foo'),
+ `${location.origin}/base/x/testplugin/foo`
+ );
+ });
+
+ test('works', () => {
+ const registerCustomComponentStub = stub(
+ plugin,
+ 'registerCustomComponent'
+ );
+ plugin.screen('foo', 'some-module');
+ assert.isTrue(
+ registerCustomComponentStub.calledWith(
+ 'testplugin-screen-foo',
+ 'some-module'
+ )
+ );
+ });
+ });
+});
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 7aad2f0532..8e3a87d925 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
@@ -6,25 +6,26 @@
import {
ActionInfo,
ChangeInfo,
+ BasePatchSetNum,
PatchSetNum,
ReviewInput,
RevisionInfo,
} from '../../../types/common';
import {Finalizable} from '../../../services/registry';
import {EventType, TargetElement} from '../../../api/plugin';
-import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {ParsedChangeInfo} from '../../../types/types';
import {MenuLink} from '../../../api/admin';
export interface ShowChangeDetail {
- change: ChangeInfo;
- patchNum: PatchSetNum;
- info: {mergeable: boolean};
+ change?: ParsedChangeInfo;
+ basePatchNum?: BasePatchSetNum;
+ patchNum?: PatchSetNum;
+ info: {mergeable: boolean | null};
}
export interface ShowRevisionActionsDetail {
change: ChangeInfo;
- revisionActions: {[key: string]: ActionInfo};
+ revisionActions: {[key: string]: ActionInfo | undefined};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -38,17 +39,15 @@ export interface JsApiService extends Finalizable {
revertSubmissionMsg: string,
origMsg: string
): string;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- handleEvent(eventName: EventType, detail: any): void;
+ handleShowChange(detail: ShowChangeDetail): void;
+ handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
+ handleLabelChange(detail: {change?: ParsedChangeInfo}): void;
modifyRevertMsg(
change: ChangeInfo,
revertMsg: string,
origMsg: string
): string;
addElement(key: TargetElement, el: HTMLElement): void;
- getDiffLayers(path: string): DiffLayer[];
- disposeDiffLayers(path: string): void;
- getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
getAdminMenuLinks(): MenuLink[];
handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string): void;
canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null): boolean;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
index 0628d2f77f..c81f586193 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -4,13 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
-import {EventType, ShowAlertEventDetail} from '../../../types/events';
import {PluginApi} from '../../../api/plugin';
import {UIActionInfo} from './gr-change-actions-js-api';
import {windowLocationReload} from '../../../utils/dom-util';
import {PopupPluginApi} from '../../../api/popup';
import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
import {getAppContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
interface ButtonCallBacks {
onclick: (event: Event) => boolean;
@@ -110,13 +110,7 @@ export class GrPluginActionContext {
.send(this.action.method, this.action.__url, payload)
.then(onSuccess)
.catch((error: unknown) => {
- document.dispatchEvent(
- new CustomEvent<ShowAlertEventDetail>(EventType.SHOW_ALERT, {
- detail: {
- message: `Plugin network error: ${error}`,
- },
- })
- );
+ fireAlert(document, `Plugin network error: ${error}`);
});
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
index a40135104e..76a6573645 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
@@ -7,26 +7,49 @@ import '../../../test/common-test-setup';
import './gr-js-api-interface';
import {GrPluginActionContext} from './gr-plugin-action-context';
import {addListenerForTest, waitEventLoop} from '../../../test/test-utils';
-import {EventType} from '../../../types/events';
import {assert} from '@open-wc/testing';
+import {PluginApi} from '../../../api/plugin';
+import {SinonStub, stub, spy} from 'sinon';
+import {PopupPluginApi} from '../../../api/popup';
+import {GrButton} from '../gr-button/gr-button';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {ActionType} from '../../../api/change-actions';
+import {HttpMethod} from '../../../api/rest-api';
+import {RestPluginApi} from '../../../api/rest';
suite('gr-plugin-action-context tests', () => {
- let instance;
+ let instance: GrPluginActionContext;
- let plugin;
+ let plugin: PluginApi;
setup(() => {
- window.Gerrit.install(p => { plugin = p; }, '0.1',
- 'http://test.com/plugins/testplugin/static/test.js');
- instance = new GrPluginActionContext(plugin);
+ window.Gerrit.install(
+ p => {
+ plugin = p;
+ },
+ '0.1',
+ 'http://test.com/plugins/testplugin/static/test.js'
+ );
+ instance = new GrPluginActionContext(
+ plugin,
+ {
+ label: 'MyAction',
+ method: HttpMethod.POST,
+ __key: 'key',
+ __url: '/changes/1/revisions/2/foo~bar',
+ __type: ActionType.REVISION,
+ },
+ createChange(),
+ createRevision()
+ );
});
test('popup() and hide()', async () => {
const popupApiStub = {
- _getElement: sinon.stub().returns(document.createElement('div')),
- close: sinon.stub(),
- };
- sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
+ _getElement: stub().returns(document.createElement('div')),
+ close: stub(),
+ } as PopupPluginApi & {_getElement: SinonStub; close: SinonStub};
+ stub(plugin, 'popup').resolves(popupApiStub);
const el = document.createElement('span');
instance.popup(el);
await waitEventLoop();
@@ -60,10 +83,10 @@ suite('gr-plugin-action-context tests', () => {
});
suite('button', () => {
- let clickStub;
- let button;
+ let clickStub: SinonStub;
+ let button: GrButton;
setup(() => {
- clickStub = sinon.stub();
+ clickStub = stub();
button = instance.button('foo', {onclick: clickStub});
// If you don't attach a Polymer element to the DOM, then the ready()
// callback will not be called and then e.g. this.$ is undefined.
@@ -89,48 +112,49 @@ suite('gr-plugin-action-context tests', () => {
});
test('label', () => {
- const fakeMsg = {};
- const fakeCheckbox = {};
- sinon.stub(instance, 'div');
- sinon.stub(instance, 'msg').returns(fakeMsg);
+ const divSpy = spy(instance, 'div');
+ const fakeMsg = document.createElement('gr-label');
+ const fakeCheckbox = document.createElement('input');
+ stub(instance, 'msg').returns(fakeMsg);
+
instance.label(fakeCheckbox, 'foo');
- assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
+
+ assert.isTrue(divSpy.calledWithExactly(fakeCheckbox, fakeMsg));
});
test('call', () => {
- instance.action = {
- method: 'METHOD',
- __key: 'key',
- __url: '/changes/1/revisions/2/foo~bar',
- };
- const sendStub = sinon.stub().returns(Promise.resolve());
- sinon.stub(plugin, 'restApi').returns({
- send: sendStub,
- });
+ const fakeRestApi = {
+ send: stub().resolves(),
+ } as RestPluginApi & {send: SinonStub};
+ stub(plugin, 'restApi').returns(fakeRestApi);
+
const payload = {foo: 'foo'};
- const successStub = sinon.stub();
- instance.call(payload, successStub);
- assert.isTrue(sendStub.calledWith(
- 'METHOD', '/changes/1/revisions/2/foo~bar', payload));
+ instance.call(payload, () => {});
+
+ assert.isTrue(
+ fakeRestApi.send.calledWith(
+ HttpMethod.POST,
+ '/changes/1/revisions/2/foo~bar',
+ payload
+ )
+ );
});
test('call error', async () => {
- instance.action = {
- method: 'METHOD',
- __key: 'key',
- __url: '/changes/1/revisions/2/foo~bar',
- };
- const sendStub = sinon.stub().returns(Promise.reject(new Error('boom')));
- sinon.stub(plugin, 'restApi').returns({
- send: sendStub,
- });
- const errorStub = sinon.stub();
- addListenerForTest(document, EventType.SHOW_ALERT, errorStub);
- instance.call();
+ const fakeRestApi = {
+ send: () => Promise.reject(new Error('boom')),
+ } as unknown as RestPluginApi;
+ stub(plugin, 'restApi').returns(fakeRestApi);
+ const errorStub = stub();
+ addListenerForTest(document, 'show-alert', errorStub);
+
+ instance.call({}, () => {});
await waitEventLoop();
+
assert.isTrue(errorStub.calledOnce);
- assert.equal(errorStub.args[0][0].detail.message,
- 'Plugin network error: Error: boom');
+ assert.equal(
+ errorStub.args[0][0].detail.message,
+ 'Plugin network error: Error: boom'
+ );
});
});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
index 9ced9173cd..b1b66ad47c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {PluginApi} from '../../../api/plugin';
-import {notUndefined} from '../../../types/types';
import {HookApi, PluginElement} from '../../../api/hook';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,16 +13,29 @@ export interface ModuleInfo {
moduleName: string;
plugin: PluginApi;
pluginUrl?: URL;
- type?: string;
+ type?: EndpointType;
domHook?: HookApi<PluginElement>;
slot?: string;
}
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ * decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ * component.
+ */
+export enum EndpointType {
+ DECORATE = 'decorate',
+ REPLACE = 'replace',
+}
+
interface Options {
endpoint: string;
dynamicEndpoint?: string;
slot?: string;
- type?: string;
+ type?: EndpointType;
moduleName?: string;
domHook?: HookApi<PluginElement>;
}
@@ -125,53 +137,7 @@ export class GrPluginEndpoints {
* Get detailed information about modules registered with an extension
* endpoint.
*/
- getDetails(name: string, options?: Options): ModuleInfo[] {
- const type = options && options.type;
- const moduleName = options && options.moduleName;
- if (!this._endpoints.has(name)) {
- return [];
- } else {
- return this._endpoints
- .get(name)!
- .filter(
- (item: ModuleInfo) =>
- (!type || item.type === type) &&
- (!moduleName || moduleName === item.moduleName)
- );
- }
- }
-
- /**
- * Get detailed module names for instantiating at the endpoint.
- */
- getModules(name: string, options?: Options): string[] {
- const modulesData = this.getDetails(name, options);
- if (!modulesData.length) {
- return [];
- }
- return modulesData.map(m => m.moduleName);
- }
-
- /**
- * Get plugin URLs with element and module definitions.
- */
- getPlugins(name: string, options?: Options): URL[] {
- const modulesData = this.getDetails(name, options);
- if (!modulesData.length) {
- return [];
- }
- return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
- notUndefined
- );
+ getDetails(name: string): ModuleInfo[] {
+ return this._endpoints.get(name) ?? [];
}
}
-
-let pluginEndpoints = new GrPluginEndpoints();
-
-// To avoid mutable-exports, we don't want to export above variable directly
-export function getPluginEndpoints() {
- return pluginEndpoints;
-}
-export function _testOnly_resetEndpoints() {
- pluginEndpoints = new GrPluginEndpoints();
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
index 15e19e60f1..ddba546dfd 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -4,9 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import {resetPlugins} from '../../../test/test-utils';
import './gr-js-api-interface';
-import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
import {PluginApi} from '../../../api/plugin';
import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
import {assert} from '@open-wc/testing';
@@ -40,7 +39,7 @@ export class MockHook<T extends PluginElement> implements HookApi<T> {
suite('gr-plugin-endpoints tests', () => {
let instance: GrPluginEndpoints;
let decoratePlugin: PluginApi;
- let stylePlugin: PluginApi;
+ let replacePlugin: PluginApi;
let domHook: HookApi<PluginElement>;
setup(() => {
@@ -53,102 +52,51 @@ suite('gr-plugin-endpoints tests', () => {
);
instance.registerModule(decoratePlugin, {
endpoint: 'my-endpoint',
- type: 'decorate',
+ type: EndpointType.DECORATE,
moduleName: 'decorate-module',
domHook,
});
window.Gerrit.install(
- plugin => (stylePlugin = plugin),
+ plugin => (replacePlugin = plugin),
'0.1',
- 'http://test.com/plugins/testplugin/static/style.js'
+ 'http://test.com/plugins/testplugin/static/replace.js'
);
- instance.registerModule(stylePlugin, {
+ instance.registerModule(replacePlugin, {
endpoint: 'my-endpoint',
- type: 'style',
- moduleName: 'style-module',
+ type: EndpointType.REPLACE,
+ moduleName: 'replace-module',
domHook,
});
});
- teardown(() => {
- resetPlugins();
- });
-
test('getDetails all', () => {
assert.deepEqual(instance.getDetails('my-endpoint'), [
{
moduleName: 'decorate-module',
plugin: decoratePlugin,
pluginUrl: decoratePlugin._url,
- type: 'decorate',
+ type: EndpointType.DECORATE,
domHook,
slot: undefined,
},
{
- moduleName: 'style-module',
- plugin: stylePlugin,
- pluginUrl: stylePlugin._url,
- type: 'style',
+ moduleName: 'replace-module',
+ plugin: replacePlugin,
+ pluginUrl: replacePlugin._url,
+ type: EndpointType.REPLACE,
domHook,
slot: undefined,
},
]);
});
- test('getDetails by type', () => {
- assert.deepEqual(
- instance.getDetails('my-endpoint', {endpoint: 'a-place', type: 'style'}),
- [
- {
- moduleName: 'style-module',
- plugin: stylePlugin,
- pluginUrl: stylePlugin._url,
- type: 'style',
- domHook,
- slot: undefined,
- },
- ]
- );
- });
-
- test('getDetails by module', () => {
- assert.deepEqual(
- instance.getDetails('my-endpoint', {
- endpoint: 'my-endpoint',
- moduleName: 'decorate-module',
- }),
- [
- {
- moduleName: 'decorate-module',
- plugin: decoratePlugin,
- pluginUrl: decoratePlugin._url,
- type: 'decorate',
- domHook,
- slot: undefined,
- },
- ]
- );
- });
-
- test('getModules', () => {
- assert.deepEqual(instance.getModules('my-endpoint'), [
- 'decorate-module',
- 'style-module',
- ]);
- });
-
- test('getPlugins URLs are unique', () => {
- assert.equal(decoratePlugin._url, stylePlugin._url);
- assert.deepEqual(instance.getPlugins('my-endpoint'), [decoratePlugin._url]);
- });
-
test('onNewEndpoint', () => {
const newModuleStub = sinon.stub();
instance.setPluginsReady();
instance.onNewEndpoint('my-endpoint', newModuleStub);
instance.registerModule(decoratePlugin, {
endpoint: 'my-endpoint',
- type: 'replace',
+ type: EndpointType.REPLACE,
moduleName: 'replace-module',
domHook,
});
@@ -156,7 +104,7 @@ suite('gr-plugin-endpoints tests', () => {
moduleName: 'replace-module',
plugin: decoratePlugin,
pluginUrl: decoratePlugin._url,
- type: 'replace',
+ type: EndpointType.REPLACE,
domHook,
slot: undefined,
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
index 4a903149f3..a58c6ccc7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -3,7 +3,6 @@
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {getAppContext} from '../../../services/app-context';
import {
PLUGIN_LOADING_TIMEOUT_MS,
getPluginNameFromUrl,
@@ -12,10 +11,25 @@ import {
} from './gr-api-utils';
import {Plugin} from './gr-public-js-api';
import {getBaseUrl} from '../../../utils/url-util';
-import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
import {PluginApi} from '../../../api/plugin';
import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
import {fireAlert} from '../../../utils/event-util';
+import {JsApiService} from './gr-js-api-types';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {Finalizable} from '../../../services/registry';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {Gerrit} from '../../../api/gerrit';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {iconStyles} from '../../../styles/gr-icon-styles';
+import {GrJsApiInterface} from './gr-js-api-interface-element';
+import {define} from '../../../models/dependency';
+import {modalStyles} from '../../../styles/gr-modal-styles';
enum PluginState {
/** State that indicates the plugin is pending to be loaded. */
@@ -55,6 +69,8 @@ const UNKNOWN_PLUGIN_PREFIX = '__$$__';
// plugins with incompatible version will not be loaded.
const API_VERSION = '0.1';
+export const pluginLoaderToken = define<PluginLoader>('plugin-loader');
+
/**
* PluginLoader, responsible for:
*
@@ -64,32 +80,54 @@ const API_VERSION = '0.1';
* Retrieve plugin.
* Check plugin status and if all plugins loaded.
*/
-export class PluginLoader {
- _pluginListLoaded = false;
-
- _plugins = new Map<string, PluginObject>();
-
- _reporting: ReportingService | null = null;
+export class PluginLoader implements Gerrit, Finalizable {
+ public readonly styles = {
+ font: fontStyles,
+ form: formStyles,
+ icon: iconStyles,
+ menuPage: menuPageStyles,
+ spinner: spinnerStyles,
+ subPage: subpageStyles,
+ table: tableStyles,
+ modal: modalStyles,
+ };
+
+ private pluginListLoaded = false;
+
+ private plugins = new Map<string, PluginObject>();
// Promise that resolves when all plugins loaded
- _loadingPromise: Promise<void> | null = null;
+ private loadingPromise: Promise<void> | null = null;
- // Resolver to resolve _loadingPromise once all plugins loaded
- _loadingResolver: (() => void) | null = null;
+ // Resolver to resolve loadingPromise once all plugins loaded
+ private loadingResolver: (() => void) | null = null;
private instanceId?: string;
- _getReporting() {
- if (!this._reporting) {
- this._reporting = getAppContext().reportingService;
- }
- return this._reporting;
+ public readonly jsApiService: JsApiService;
+
+ public readonly pluginsModel: PluginsModel;
+
+ public pluginEndPoints: GrPluginEndpoints;
+
+ constructor(
+ private readonly reportingService: ReportingService,
+ private readonly restApiService: RestApiService
+ ) {
+ this.jsApiService = new GrJsApiInterface(
+ () => this.awaitPluginsLoaded(),
+ this.reportingService
+ );
+ this.pluginsModel = new PluginsModel();
+ this.pluginEndPoints = new GrPluginEndpoints();
}
+ finalize() {}
+
/**
* Use the plugin name or use the full url if not recognized.
*/
- _getPluginKeyFromUrl(url: string) {
+ private getPluginKeyFromUrl(url: string) {
return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`;
}
@@ -98,41 +136,41 @@ export class PluginLoader {
*/
loadPlugins(plugins: string[] = [], instanceId?: string) {
this.instanceId = instanceId;
- this._pluginListLoaded = true;
+ this.pluginListLoaded = true;
plugins.forEach(path => {
- const url = this._urlFor(path, window.ASSETS_PATH);
- const pluginKey = this._getPluginKeyFromUrl(url);
+ const url = this.urlFor(path, window.ASSETS_PATH);
+ const pluginKey = this.getPluginKeyFromUrl(url);
// Skip if already installed.
- if (this._plugins.has(pluginKey)) return;
- this._plugins.set(pluginKey, {
+ if (this.plugins.has(pluginKey)) return;
+ this.plugins.set(pluginKey, {
name: pluginKey,
url,
state: PluginState.PENDING,
plugin: null,
});
- if (this._isPathEndsWith(url, '.js')) {
- this._loadJsPlugin(path);
+ if (this.isPathEndsWith(url, '.js')) {
+ this.loadJsPlugin(path);
} else {
- this._failToLoad(`Unrecognized plugin path ${path}`, path);
+ this.failToLoad(`Unrecognized plugin path ${path}`, path);
}
});
this.awaitPluginsLoaded().then(() => {
const loaded = this.getPluginsByState(PluginState.LOADED);
const failed = this.getPluginsByState(PluginState.LOAD_FAILED);
- this._getReporting().pluginsLoaded(loaded.map(p => p.name));
- this._getReporting().pluginsFailed(failed.map(p => p.name));
+ this.reportingService.pluginsLoaded(loaded.map(p => p.name));
+ this.reportingService.pluginsFailed(failed.map(p => p.name));
});
}
- _isPathEndsWith(url: string | URL, suffix: string) {
+ private isPathEndsWith(url: string | URL, suffix: string) {
if (!(url instanceof URL)) {
try {
url = new URL(url);
} catch (e: unknown) {
- this._getReporting().error(
+ this.reportingService.error(
'GrPluginLoader',
new Error('url parse error'),
e
@@ -145,7 +183,7 @@ export class PluginLoader {
}
private getPluginsByState(state: PluginState) {
- return [...this._plugins.values()].filter(p => p.state === state);
+ return [...this.plugins.values()].filter(p => p.state === state);
}
install(
@@ -163,31 +201,38 @@ export class PluginLoader {
src = script && script.baseURI;
}
if (!src) {
- this._failToLoad('Failed to determine src.');
+ this.failToLoad('Failed to determine src.');
return;
}
if (version && version !== API_VERSION) {
- this._failToLoad(
+ this.failToLoad(
`Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${version} was given.`,
src
);
return;
}
- const url = this._urlFor(src);
+ const url = this.urlFor(src);
const pluginObject = this.getPlugin(url);
let plugin = pluginObject && pluginObject.plugin;
if (!plugin) {
- plugin = new Plugin(url);
+ plugin = new Plugin(
+ url,
+ this.jsApiService,
+ this.reportingService,
+ this.restApiService,
+ this.pluginsModel,
+ this.pluginEndPoints
+ );
}
try {
callback(plugin);
- this._pluginInstalled(url, plugin);
+ this.pluginInstalled(url, plugin);
} catch (e: unknown) {
if (e instanceof Error) {
- this._failToLoad(`${e.name}: ${e.message}`, src);
+ this.failToLoad(`${e.name}: ${e.message}`, src);
} else {
- this._getReporting().error(
+ this.reportingService.error(
'GrPluginLoader',
new Error('plugin callback error'),
e
@@ -197,27 +242,27 @@ export class PluginLoader {
}
arePluginsLoaded() {
- if (!this._pluginListLoaded) return false;
+ if (!this.pluginListLoaded) return false;
return this.getPluginsByState(PluginState.PENDING).length === 0;
}
- _checkIfCompleted() {
+ private checkIfCompleted() {
if (this.arePluginsLoaded()) {
- getPluginEndpoints().setPluginsReady();
- if (this._loadingResolver) {
- this._loadingResolver();
- this._loadingResolver = null;
- this._loadingPromise = null;
+ this.pluginEndPoints.setPluginsReady();
+ if (this.loadingResolver) {
+ this.loadingResolver();
+ this.loadingResolver = null;
+ this.loadingPromise = null;
}
}
}
- _timeout() {
+ private timeout() {
const pending = this.getPluginsByState(PluginState.PENDING);
for (const plugin of pending) {
- this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
+ this.updatePluginState(plugin.url, PluginState.LOAD_FAILED);
}
- this._checkIfCompleted();
+ this.checkIfCompleted();
const errorMessage = `Timeout when loading plugins: ${pending
.map(p => p.name)
.join(',')}`;
@@ -225,21 +270,25 @@ export class PluginLoader {
return errorMessage;
}
- _failToLoad(message: string, pluginUrl?: string) {
+ // Private but mocked in tests.
+ failToLoad(message: string, pluginUrl?: string) {
// Show an alert with the error
fireAlert(document, `Plugin install error: ${message} from ${pluginUrl}`);
- if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
- this._checkIfCompleted();
+ if (pluginUrl) this.updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+ this.checkIfCompleted();
}
- _updatePluginState(pluginUrl: string, state: PluginState): PluginObject {
- const key = this._getPluginKeyFromUrl(pluginUrl);
- if (this._plugins.has(key)) {
- this._plugins.get(key)!.state = state;
+ private updatePluginState(
+ pluginUrl: string,
+ state: PluginState
+ ): PluginObject {
+ const key = this.getPluginKeyFromUrl(pluginUrl);
+ if (this.plugins.has(key)) {
+ this.plugins.get(key)!.state = state;
} else {
// Plugin is not recorded for some reason.
console.info(`Plugin loaded separately: ${pluginUrl}`);
- this._plugins.set(key, {
+ this.plugins.set(key, {
name: key,
url: pluginUrl,
state,
@@ -247,59 +296,61 @@ export class PluginLoader {
});
}
console.debug(`Plugin ${key} ${state}`);
- return this._plugins.get(key)!;
+ return this.plugins.get(key)!;
}
- _pluginInstalled(url: string, plugin: PluginApi) {
- const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+ private pluginInstalled(url: string, plugin: PluginApi) {
+ const pluginObj = this.updatePluginState(url, PluginState.LOADED);
pluginObj.plugin = plugin;
- this._getReporting().pluginLoaded(plugin.getPluginName() || url);
- this._checkIfCompleted();
+ this.reportingService.pluginLoaded(plugin.getPluginName() || url);
+ this.checkIfCompleted();
}
/**
* Checks if given plugin path/url is enabled or not.
*/
isPluginEnabled(pathOrUrl: string) {
- const url = this._urlFor(pathOrUrl);
- const key = this._getPluginKeyFromUrl(url);
- return this._plugins.has(key);
+ const url = this.urlFor(pathOrUrl);
+ const key = this.getPluginKeyFromUrl(url);
+ return this.plugins.has(key);
}
/**
* Returns the plugin object with a given url.
*/
getPlugin(pathOrUrl: string) {
- const url = this._urlFor(pathOrUrl);
- const key = this._getPluginKeyFromUrl(url);
- return this._plugins.get(key);
+ const url = this.urlFor(pathOrUrl);
+ const key = this.getPluginKeyFromUrl(url);
+ return this.plugins.get(key);
}
/**
* Checks if given plugin path/url is loaded or not.
*/
isPluginLoaded(pathOrUrl: string): boolean {
- const url = this._urlFor(pathOrUrl);
- const key = this._getPluginKeyFromUrl(url);
- return this._plugins.has(key)
- ? this._plugins.get(key)!.state === PluginState.LOADED
+ const url = this.urlFor(pathOrUrl);
+ const key = this.getPluginKeyFromUrl(url);
+ return this.plugins.has(key)
+ ? this.plugins.get(key)!.state === PluginState.LOADED
: false;
}
- _loadJsPlugin(pluginUrl: string) {
- const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
- const urlWithoutAP = this._urlFor(pluginUrl);
+ // Private but mocked in tests.
+ loadJsPlugin(pluginUrl: string) {
+ const urlWithAP = this.urlFor(pluginUrl, window.ASSETS_PATH);
+ const urlWithoutAP = this.urlFor(pluginUrl);
let onerror = undefined;
if (urlWithAP !== urlWithoutAP) {
- onerror = () => this._createScriptTag(urlWithoutAP);
+ onerror = () => this.createScriptTag(urlWithoutAP);
}
- this._createScriptTag(urlWithAP, onerror);
+ this.createScriptTag(urlWithAP, onerror);
}
- _createScriptTag(url: string, onerror?: OnErrorEventHandler) {
+ // Private but mocked in tests.
+ createScriptTag(url: string, onerror?: OnErrorEventHandler) {
if (!onerror) {
- onerror = () => this._failToLoad(`${url} load error`, url);
+ onerror = () => this.failToLoad(`${url} load error`, url);
}
const el = document.createElement('script');
@@ -313,7 +364,7 @@ export class PluginLoader {
return document.body.appendChild(el);
}
- _urlFor(pathOrUrl: string, assetsPath?: string): string {
+ private urlFor(pathOrUrl: string, assetsPath?: string): string {
if (isThemeFile(pathOrUrl)) {
if (assetsPath && this.instanceId) {
return `${assetsPath}/hosts/${this.instanceId}${THEME_JS}`;
@@ -341,39 +392,28 @@ export class PluginLoader {
awaitPluginsLoaded() {
// Resolve if completed.
- this._checkIfCompleted();
+ this.checkIfCompleted();
if (this.arePluginsLoaded()) {
return Promise.resolve();
}
- if (!this._loadingPromise) {
+ if (!this.loadingPromise) {
// specify window here so that TS pulls the correct setTimeout method
// if window is not specified, then the function is pulled from node
// and the return type is NodeJS.Timeout object
let timerId: number;
- this._loadingPromise = Promise.race([
- new Promise<void>(resolve => (this._loadingResolver = resolve)),
+ this.loadingPromise = Promise.race([
+ new Promise<void>(resolve => (this.loadingResolver = resolve)),
new Promise(
(_, reject) =>
(timerId = window.setTimeout(() => {
- reject(new Error(this._timeout()));
+ reject(new Error(this.timeout()));
}, PLUGIN_LOADING_TIMEOUT_MS))
),
]).finally(() => {
if (timerId) clearTimeout(timerId);
}) as Promise<void>;
}
- return this._loadingPromise;
+ return this.loadingPromise;
}
}
-
-// TODO(dmfilippov): Convert to service and add to appContext
-let pluginLoader = new PluginLoader();
-export function _testOnly_resetPluginLoader() {
- pluginLoader = new PluginLoader();
- return pluginLoader;
-}
-
-export function getPluginLoader() {
- return pluginLoader;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
index 3005c37a54..b2ac2bf210 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.ts
@@ -5,18 +5,14 @@
*/
import '../../../test/common-test-setup';
import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils';
-import {PluginLoader, _testOnly_resetPluginLoader} from './gr-plugin-loader';
-import {
- resetPlugins,
- stubBaseUrl,
- waitEventLoop,
-} from '../../../test/test-utils';
+import {PluginLoader} from './gr-plugin-loader';
+import {stubBaseUrl, waitEventLoop} from '../../../test/test-utils';
import {addListenerForTest, stubRestApi} from '../../../test/test-utils';
import {PluginApi} from '../../../api/plugin';
import {SinonFakeTimers} from 'sinon';
import {Timestamp} from '../../../api/rest-api';
-import {EventType} from '../../../types/events';
import {assert} from '@open-wc/testing';
+import {getAppContext} from '../../../services/app-context';
suite('gr-plugin-loader tests', () => {
let plugin: PluginApi;
@@ -35,18 +31,20 @@ suite('gr-plugin-loader tests', () => {
stubRestApi('send').returns(
Promise.resolve({...new Response(), status: 200})
);
- pluginLoader = _testOnly_resetPluginLoader();
+ pluginLoader = new PluginLoader(
+ getAppContext().reportingService,
+ getAppContext().restApiService
+ );
bodyStub = sinon.stub(document.body, 'appendChild');
url = window.location.origin;
});
teardown(() => {
clock.restore();
- resetPlugins();
});
test('reuse plugin for install calls', () => {
- window.Gerrit.install(
+ pluginLoader.install(
p => {
plugin = p;
},
@@ -55,7 +53,7 @@ suite('gr-plugin-loader tests', () => {
);
let otherPlugin;
- window.Gerrit.install(
+ pluginLoader.install(
p => {
otherPlugin = p;
},
@@ -67,17 +65,17 @@ suite('gr-plugin-loader tests', () => {
test('versioning', () => {
const callback = sinon.spy();
- window.Gerrit.install(callback, '0.0pre-alpha');
+ pluginLoader.install(callback, '0.0pre-alpha');
assert(callback.notCalled);
});
test('report pluginsLoaded', async () => {
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
pluginsLoadedStub.reset();
- (window.Gerrit as any)._loadPlugins([]);
+ pluginLoader.loadPlugins([]);
await waitEventLoop();
assert.isTrue(pluginsLoadedStub.called);
});
@@ -99,11 +97,11 @@ suite('gr-plugin-loader tests', () => {
});
test('plugins installed successfully', async () => {
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(() => void 0, undefined, url);
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(() => void 0, undefined, url);
});
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
@@ -119,8 +117,8 @@ suite('gr-plugin-loader tests', () => {
});
test('isPluginEnabled and isPluginLoaded', async () => {
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(() => void 0, undefined, url);
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(() => void 0, undefined, url);
});
const plugins = [
@@ -145,10 +143,10 @@ suite('gr-plugin-loader tests', () => {
];
const alertStub = sinon.stub();
- addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+ addListenerForTest(document, 'show-alert', alertStub);
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(
() => {
if (url === plugins[0]) {
throw new Error('failed');
@@ -160,7 +158,7 @@ suite('gr-plugin-loader tests', () => {
});
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
@@ -179,10 +177,10 @@ suite('gr-plugin-loader tests', () => {
];
const alertStub = sinon.stub();
- addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+ addListenerForTest(document, 'show-alert', alertStub);
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(
() => {
if (url === plugins[0]) {
throw new Error('failed');
@@ -194,7 +192,7 @@ suite('gr-plugin-loader tests', () => {
});
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
@@ -218,10 +216,10 @@ suite('gr-plugin-loader tests', () => {
];
const alertStub = sinon.stub();
- addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+ addListenerForTest(document, 'show-alert', alertStub);
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(
() => {
throw new Error('failed');
},
@@ -231,7 +229,7 @@ suite('gr-plugin-loader tests', () => {
});
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
@@ -250,14 +248,14 @@ suite('gr-plugin-loader tests', () => {
];
const alertStub = sinon.stub();
- addListenerForTest(document, EventType.SHOW_ALERT, alertStub);
+ addListenerForTest(document, 'show-alert', alertStub);
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(() => {}, url === plugins[0] ? '' : 'alpha', url);
});
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
@@ -270,11 +268,11 @@ suite('gr-plugin-loader tests', () => {
});
test('multiple assets for same plugin installed successfully', async () => {
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(() => void 0, undefined, url);
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(() => void 0, undefined, url);
});
const pluginsLoadedStub = sinon.stub(
- pluginLoader._getReporting(),
+ getAppContext().reportingService,
'pluginsLoaded'
);
@@ -295,7 +293,7 @@ suite('gr-plugin-loader tests', () => {
setup(() => {
loadJsPluginStub = sinon.stub();
sinon
- .stub(pluginLoader, '_createScriptTag')
+ .stub(pluginLoader, 'createScriptTag')
.callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
loadJsPluginStub(url)
);
@@ -303,7 +301,7 @@ suite('gr-plugin-loader tests', () => {
test('invalid plugin path', () => {
const failToLoadStub = sinon.stub();
- sinon.stub(pluginLoader, '_failToLoad').callsFake((...args) => {
+ sinon.stub(pluginLoader, 'failToLoad').callsFake((...args) => {
failToLoadStub(...args);
});
@@ -353,7 +351,7 @@ suite('gr-plugin-loader tests', () => {
window.ASSETS_PATH = 'https://cdn.com';
loadJsPluginStub = sinon.stub();
sinon
- .stub(pluginLoader, '_createScriptTag')
+ .stub(pluginLoader, 'createScriptTag')
.callsFake((url: string, _onerror?: OnErrorEventHandler | undefined) =>
loadJsPluginStub(url)
);
@@ -409,8 +407,8 @@ suite('gr-plugin-loader tests', () => {
installed = true;
}
}
- sinon.stub(pluginLoader, '_loadJsPlugin').callsFake(url => {
- window.Gerrit.install(() => pluginCallback(url), undefined, url);
+ sinon.stub(pluginLoader, 'loadJsPlugin').callsFake(url => {
+ pluginLoader.install(() => pluginCallback(url), undefined, url);
});
pluginLoader.loadPlugins(plugins);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
index b4f73242af..65e4960eb4 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -5,9 +5,10 @@
*/
import {HttpMethod} from '../../../constants/constants';
import {RequestPayload} from '../../../types/common';
-import {getAppContext} from '../../../services/app-context';
import {ErrorCallback, RestPluginApi} from '../../../api/rest';
import {PluginApi} from '../../../api/plugin';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
async function getErrorMessage(response: Response): Promise<string> {
const text = await response.text();
@@ -24,11 +25,12 @@ class ResponseError extends Error {
}
export class GrPluginRestApi implements RestPluginApi {
- private readonly restApi = getAppContext().restApiService;
-
- private readonly reporting = getAppContext().reportingService;
-
- constructor(readonly plugin: PluginApi, private readonly prefix = '') {
+ constructor(
+ private readonly restApi: RestApiService,
+ private readonly reporting: ReportingService,
+ readonly plugin: PluginApi,
+ private readonly prefix = ''
+ ) {
this.reporting.trackApi(this.plugin, 'rest', 'constructor');
}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
index d6d7fc2dcb..c5bef85843 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.ts
@@ -14,6 +14,7 @@ import {
createServerInfo,
} from '../../../test/test-data-generators';
import {HttpMethod} from '../../../api/rest-api';
+import {getAppContext} from '../../../services/app-context';
suite('gr-plugin-rest-api tests', () => {
let instance: GrPluginRestApi;
@@ -32,7 +33,11 @@ suite('gr-plugin-rest-api tests', () => {
'0.1',
'http://test.com/plugins/testplugin/static/test.js'
);
- instance = new GrPluginRestApi(pluginApi!);
+ instance = new GrPluginRestApi(
+ getAppContext().restApiService,
+ getAppContext().reportingService,
+ pluginApi!
+ );
});
test('fetch', async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
new file mode 100644
index 0000000000..13eefc8dbc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {PluginApi} from '../../../api/plugin';
+import {StylePluginApi} from '../../../api/styles';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+
+function getOrCreatePluginStyleEl(): HTMLStyleElement {
+ const el =
+ document.head.querySelector<HTMLStyleElement>('style#plugin-style');
+ if (el) return el;
+
+ const styleEl = document.createElement('style');
+ styleEl.setAttribute('id', 'plugin-style');
+ // Append at the end so that they override the default light and dark theme
+ // styles.
+ document.head.appendChild(styleEl);
+ return styleEl;
+}
+
+export class GrPluginStyleApi implements StylePluginApi {
+ constructor(
+ private readonly reporting: ReportingService,
+ private readonly plugin: PluginApi
+ ) {
+ this.reporting.trackApi(this.plugin, 'style', 'constructor');
+ }
+
+ insertCSSRule(rule: string): void {
+ this.reporting.trackApi(this.plugin, 'style', 'insertCSSRule');
+
+ const styleEl = getOrCreatePluginStyleEl();
+ try {
+ styleEl.sheet?.insertRule(rule);
+ } catch (error) {
+ console.error(
+ `Failed to insert CSS rule for plugin ${this.plugin.getPluginName()}: ${error}`
+ );
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
new file mode 100644
index 0000000000..469d6672cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-style-api_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-js-api-interface';
+import {query, queryAndAssert} from '../../../utils/common-util';
+import {assert} from '@open-wc/testing';
+import {StylePluginApi} from '../../../api/styles';
+
+suite('gr-plugin-style-api tests', () => {
+ let styleApi: StylePluginApi;
+
+ setup(() => {
+ window.Gerrit.install(
+ p => (styleApi = p.styleApi()),
+ '0.1',
+ 'http://test.com/plugins/testplugin/static/test.js'
+ );
+ });
+
+ teardown(() => {
+ const styleEl = query<HTMLStyleElement>(
+ document.head,
+ 'style#plugin-style'
+ );
+ styleEl?.remove();
+ });
+
+ test('insertCSSRule adds a rule', async () => {
+ styleApi.insertCSSRule('html{color:green;}');
+ const styleEl = queryAndAssert<HTMLStyleElement>(
+ document.head,
+ 'style#plugin-style'
+ );
+ const styleSheet = styleEl.sheet;
+ assert.equal(styleSheet?.cssRules.length, 1);
+ });
+
+ test('insertCSSRule re-uses the <style> element', async () => {
+ styleApi.insertCSSRule('html{color:green;}');
+ styleApi.insertCSSRule('html{margin:0px;}');
+ const styleEl = queryAndAssert<HTMLStyleElement>(
+ document.head,
+ 'style#plugin-style'
+ );
+ const styleSheet = styleEl.sheet;
+ assert.equal(styleSheet?.cssRules.length, 2);
+ });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
index 21ab10a296..832b97efee 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -13,7 +13,7 @@ import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
import {GrPluginRestApi} from './gr-plugin-rest-api';
-import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints';
import {getPluginNameFromUrl, send} from './gr-api-utils';
import {GrReportingJsApi} from './gr-reporting-js-api';
import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
@@ -21,7 +21,6 @@ import {RequestPayload} from '../../../types/common';
import {HttpMethod} from '../../../constants/constants';
import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
-import {getAppContext} from '../../../services/app-context';
import {AdminPluginApi} from '../../../api/admin';
import {AnnotationPluginApi} from '../../../api/annotation';
import {EventHelperPluginApi} from '../../../api/event-helper';
@@ -32,22 +31,12 @@ import {ChangeReplyPluginApi} from '../../../api/change-reply';
import {RestPluginApi} from '../../../api/rest';
import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook';
import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
-
-/**
- * Plugin-provided custom components can affect content in extension
- * points using one of following methods:
- * - DECORATE: custom component is set with `content` attribute and may
- * decorate (e.g. style) DOM element.
- * - REPLACE: contents of extension point are replaced with the custom
- * component.
- * - STYLE: custom component is a shared styles module that is inserted
- * into the extension point.
- */
-enum EndpointType {
- DECORATE = 'decorate',
- REPLACE = 'replace',
- STYLE = 'style',
-}
+import {JsApiService} from './gr-js-api-types';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
+import {PluginsModel} from '../../../models/plugins/plugins-model';
+import {GrPluginStyleApi} from './gr-plugin-style-api';
+import {StylePluginApi} from '../../../api/styles';
const PLUGIN_NAME_NOT_SET = 'NULL';
@@ -60,13 +49,14 @@ export class Plugin implements PluginApi {
private readonly _name: string = PLUGIN_NAME_NOT_SET;
- private readonly jsApi = getAppContext().jsApiService;
-
- private readonly report = getAppContext().reportingService;
-
- private readonly restApiService = getAppContext().restApiService;
-
- constructor(url?: string) {
+ constructor(
+ url: string,
+ private readonly jsApi: JsApiService,
+ private readonly report: ReportingService,
+ private readonly restApiService: RestApiService,
+ private readonly pluginsModel: PluginsModel,
+ private readonly pluginEndpoints: GrPluginEndpoints
+ ) {
this.domHooks = new GrDomHooksManager(this);
if (!url) {
@@ -88,18 +78,6 @@ export class Plugin implements PluginApi {
return this._name;
}
- registerStyleModule(endpoint: string, moduleName: string) {
- console.warn(
- `The deprecated plugin API 'registerStyleModule()' was called with parameters '${endpoint}' and '${moduleName}'.`
- );
- this.report.trackApi(this, 'plugin', 'registerStyleModule');
- getPluginEndpoints().registerModule(this, {
- endpoint,
- type: EndpointType.STYLE,
- moduleName,
- });
- }
-
/**
* Registers an endpoint for the plugin.
*/
@@ -145,7 +123,7 @@ export class Plugin implements PluginApi {
const slot = options?.slot ?? '';
const domHook = this.domHooks.getDomHook<T>(endpoint, moduleName);
moduleName = moduleName || domHook.getModuleName();
- getPluginEndpoints().registerModule(this, {
+ this.pluginEndpoints.registerModule(this, {
slot,
endpoint,
type,
@@ -211,12 +189,17 @@ export class Plugin implements PluginApi {
}
annotationApi(): AnnotationPluginApi {
- return new GrAnnotationActionsInterface(this);
+ return new GrAnnotationActionsInterface(
+ this.report,
+ this.pluginsModel,
+ this
+ );
}
changeActions(): ChangeActionsPluginApi {
return new GrChangeActionsInterface(
this,
+ this.jsApi,
this.jsApi.getElement(
TargetElement.CHANGE_ACTIONS
) as unknown as GrChangeActions
@@ -228,27 +211,31 @@ export class Plugin implements PluginApi {
}
checks(): GrChecksApi {
- return new GrChecksApi(this);
+ return new GrChecksApi(this.report, this.pluginsModel, this);
}
reporting(): ReportingPluginApi {
- return new GrReportingJsApi(this);
+ return new GrReportingJsApi(this.report, this);
+ }
+
+ styleApi(): StylePluginApi {
+ return new GrPluginStyleApi(this.report, this);
}
admin(): AdminPluginApi {
- return new GrAdminApi(this);
+ return new GrAdminApi(this.report, this);
}
restApi(prefix?: string): RestPluginApi {
- return new GrPluginRestApi(this, prefix);
+ return new GrPluginRestApi(this.restApiService, this.report, this, prefix);
}
attributeHelper(element: HTMLElement): AttributeHelperPluginApi {
- return new GrAttributeHelper(this, element);
+ return new GrAttributeHelper(this.report, this, element);
}
eventHelper(element: HTMLElement): EventHelperPluginApi {
- return new GrEventHelper(this, element);
+ return new GrEventHelper(this.report, this, element);
}
popup(): Promise<PopupPluginApi>;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
index fab0e6cba7..d82b68d7b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -3,17 +3,18 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {getAppContext} from '../../../services/app-context';
import {PluginApi} from '../../../api/plugin';
import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
/**
* Defines all methods that will be exported to plugin from reporting service.
*/
export class GrReportingJsApi implements ReportingPluginApi {
- private readonly reporting = getAppContext().reportingService;
-
- constructor(private readonly plugin: PluginApi) {
+ constructor(
+ private readonly reporting: ReportingService,
+ private readonly plugin: PluginApi
+ ) {
this.reporting.trackApi(this.plugin, 'reporting', 'constructor');
}
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
index 47d722d1ef..02f830d3ef 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -22,7 +22,7 @@ import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {GrButton} from '../gr-button/gr-button';
import {
- canVote,
+ canReviewerVote,
getApprovalInfo,
hasNeutralStatus,
hasVoted,
@@ -143,7 +143,7 @@ export class GrLabelInfo extends LitElement {
.filter(reviewer => {
if (this.showAllReviewers) {
if (isDetailedLabelInfo(labelInfo)) {
- return canVote(labelInfo, reviewer);
+ return canReviewerVote(labelInfo, reviewer);
} else {
// isQuickLabelInfo
return hasVoted(labelInfo, reviewer);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
index e48dcb3265..7717683910 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -6,7 +6,7 @@
import '../gr-button/gr-button';
import '../gr-icon/gr-icon';
import '../gr-limited-text/gr-limited-text';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
@@ -15,6 +15,11 @@ declare global {
interface HTMLElementTagNameMap {
'gr-linked-chip': GrLinkedChip;
}
+ interface HTMLElementEventMap {
+ /** Fired when the 'remove' button was clicked. */
+ // prettier-ignore
+ 'remove': CustomEvent<{}>;
+ }
}
@customElement('gr-linked-chip')
@@ -101,6 +106,6 @@ export class GrLinkedChip extends LitElement {
private handleRemoveTap(e: Event) {
e.preventDefault();
- fireEvent(this, 'remove');
+ fire(this, 'remove', {});
}
}
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
index fd438690f1..14b5d14b89 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -7,13 +7,14 @@ import '@polymer/iron-input/iron-input';
import '../gr-button/gr-button';
import '../gr-icon/gr-icon';
import {encodeURL, getBaseUrl} from '../../../utils/url-util';
-import {page} from '../../../utils/page-wrapper-utils';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {sharedStyles} from '../../../styles/shared-styles';
import {LitElement, PropertyValues, css, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {BindValueChangeEvent} from '../../../types/events';
+import {resolve} from '../../../models/dependency';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
@@ -21,6 +22,9 @@ declare global {
interface HTMLElementTagNameMap {
'gr-list-view': GrListView;
}
+ interface HTMLElementEventMap {
+ 'create-clicked': CustomEvent<{}>;
+ }
}
@customElement('gr-list-view')
@@ -43,11 +47,14 @@ export class GrListView extends LitElement {
@property({type: Boolean})
loading?: boolean;
+ /** Must include the base path. */
@property({type: String})
path?: string;
private reloadTask?: DelayedTask;
+ private readonly getNavigation = resolve(this, navigationToken);
+
override disconnectedCallback() {
this.reloadTask?.cancel();
super.disconnectedCallback();
@@ -121,30 +128,18 @@ export class GrListView extends LitElement {
</div>
<slot></slot>
<nav>
- Page ${this.computePage(this.offset, this.itemsPerPage)}
+ Page ${this.computePage()}
<a
id="prevArrow"
- href=${this.computeNavLink(
- this.offset,
- -1,
- this.itemsPerPage,
- this.filter,
- this.path
- )}
+ href=${this.computeNavLink(-1)}
?hidden=${this.loading || this.offset === 0}
>
<gr-icon icon="chevron_left"></gr-icon>
</a>
<a
id="nextArrow"
- href=${this.computeNavLink(
- this.offset,
- 1,
- this.itemsPerPage,
- this.filter,
- this.path
- )}
- ?hidden=${this.hideNextArrow(this.loading, this.items)}
+ href=${this.computeNavLink(1)}
+ ?hidden=${this.hideNextArrow()}
>
<gr-icon icon="chevron_right"></gr-icon>
</a>
@@ -177,33 +172,30 @@ export class GrListView extends LitElement {
() => {
if (!this.isConnected || !this.path) return;
if (filter) {
- page.show(`${this.path}/q/filter:${encodeURL(filter, false)}`);
+ this.getNavigation().setUrl(
+ `${this.path}/q/filter:${encodeURL(filter)}`
+ );
return;
}
- page.show(this.path);
+ this.getNavigation().setUrl(this.path);
},
REQUEST_DEBOUNCE_INTERVAL_MS
);
}
private createNewItem() {
- fireEvent(this, 'create-clicked');
+ fire(this, 'create-clicked', {});
}
// private but used in test
- computeNavLink(
- offset: number,
- direction: number,
- itemsPerPage: number,
- filter: string | undefined,
- path = ''
- ) {
+ computeNavLink(direction: number) {
// Offset could be a string when passed from the router.
- offset = +(offset || 0);
- const newOffset = Math.max(0, offset + itemsPerPage * direction);
- let href = getBaseUrl() + path;
- if (filter) {
- href += '/q/filter:' + encodeURL(filter, false);
+ const offset = +(this.offset || 0);
+ const newOffset = Math.max(0, offset + this.itemsPerPage * direction);
+ // Note that `this.path` already includes the base URL, if set and non-empty;
+ let href = this.path || getBaseUrl();
+ if (this.filter) {
+ href += '/q/filter:' + encodeURL(this.filter);
}
if (newOffset > 0) {
href += `,${newOffset}`;
@@ -212,11 +204,9 @@ export class GrListView extends LitElement {
}
// private but used in test
- hideNextArrow(loading?: boolean, items?: unknown[]) {
- if (loading || !items || !items.length) {
- return true;
- }
- const lastPage = items.length < this.itemsPerPage + 1;
+ hideNextArrow() {
+ if (this.loading || !this.items?.length) return true;
+ const lastPage = this.items.length < this.itemsPerPage + 1;
return lastPage;
}
@@ -224,8 +214,8 @@ export class GrListView extends LitElement {
// to either support a decimal or make it go to the nearest
// whole number (e.g 3).
// private but used in test
- computePage(offset: number, itemsPerPage: number) {
- return offset / itemsPerPage + 1;
+ computePage() {
+ return this.offset / this.itemsPerPage + 1;
}
private handleFilterBindValueChanged(e: BindValueChangeEvent) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
index bbbef72430..5b1e162cce 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.ts
@@ -6,10 +6,11 @@
import '../../../test/common-test-setup';
import './gr-list-view';
import {GrListView} from './gr-list-view';
-import {page} from '../../../utils/page-wrapper-utils';
import {queryAndAssert, stubBaseUrl} from '../../../test/test-utils';
import {GrButton} from '../gr-button/gr-button';
import {fixture, html, assert} from '@open-wc/testing';
+import {testResolver} from '../../../test/common-test-setup';
+import {navigationToken} from '../../core/gr-navigation/gr-navigation';
suite('gr-list-view tests', () => {
let element: GrListView;
@@ -57,37 +58,32 @@ suite('gr-list-view tests', () => {
});
test('computeNavLink', () => {
- const offset = 25;
- const projectsPerPage = 25;
- let filter = 'test';
- const path = '/admin/projects';
+ element.offset = 25;
+ element.itemsPerPage = 25;
+ element.filter = 'test';
+ element.path = '/base/admin/projects';
- stubBaseUrl('');
+ stubBaseUrl('/base');
assert.equal(
- element.computeNavLink(offset, 1, projectsPerPage, filter, path),
- '/admin/projects/q/filter:test,50'
+ element.computeNavLink(1),
+ '/base/admin/projects/q/filter:test,50'
);
assert.equal(
- element.computeNavLink(offset, -1, projectsPerPage, filter, path),
- '/admin/projects/q/filter:test'
+ element.computeNavLink(-1),
+ '/base/admin/projects/q/filter:test'
);
- assert.equal(
- element.computeNavLink(offset, 1, projectsPerPage, undefined, path),
- '/admin/projects,50'
- );
+ element.filter = undefined;
+ assert.equal(element.computeNavLink(1), '/base/admin/projects,50');
- assert.equal(
- element.computeNavLink(offset, -1, projectsPerPage, undefined, path),
- '/admin/projects'
- );
+ assert.equal(element.computeNavLink(-1), '/base/admin/projects');
- filter = 'plugins/';
+ element.filter = 'plugins/';
assert.equal(
- element.computeNavLink(offset, 1, projectsPerPage, filter, path),
- '/admin/projects/q/filter:plugins%252F,50'
+ element.computeNavLink(1),
+ '/base/admin/projects/q/filter:plugins/,50'
);
});
@@ -95,7 +91,9 @@ suite('gr-list-view tests', () => {
let resolve: (url: string) => void;
const promise = new Promise(r => (resolve = r));
element.path = '/admin/projects';
- sinon.stub(page, 'show').callsFake(r => resolve(r));
+ sinon
+ .stub(testResolver(navigationToken), 'setUrl')
+ .callsFake(r => resolve(r));
element.filter = 'test';
await element.updateComplete;
@@ -113,19 +111,19 @@ suite('gr-list-view tests', () => {
test('next button', async () => {
element.itemsPerPage = 25;
- let projects = new Array(26);
+ element.items = Array.from({length: 26});
+ element.loading = false;
await element.updateComplete;
- let loading;
- assert.isFalse(element.hideNextArrow(loading, projects));
- loading = true;
- assert.isTrue(element.hideNextArrow(loading, projects));
- loading = false;
- assert.isFalse(element.hideNextArrow(loading, projects));
- projects = [];
- assert.isTrue(element.hideNextArrow(loading, projects));
- projects = new Array(4);
- assert.isTrue(element.hideNextArrow(loading, projects));
+ assert.isFalse(element.hideNextArrow());
+ element.loading = true;
+ assert.isTrue(element.hideNextArrow());
+ element.loading = false;
+ assert.isFalse(element.hideNextArrow());
+ element.items = [];
+ assert.isTrue(element.hideNextArrow());
+ element.items = Array.from({length: 4});
+ assert.isTrue(element.hideNextArrow());
});
test('prev button', async () => {
@@ -186,20 +184,40 @@ suite('gr-list-view tests', () => {
test('next/prev links change when path changes', async () => {
const BRANCHES_PATH = '/path/to/branches';
const TAGS_PATH = '/path/to/tags';
- const computeNavLinkStub = sinon.stub(element, 'computeNavLink');
element.offset = 0;
element.itemsPerPage = 25;
element.filter = '';
element.path = BRANCHES_PATH;
await element.updateComplete;
- assert.equal(computeNavLinkStub.lastCall.args[4], BRANCHES_PATH);
+
+ assert.dom.equal(
+ queryAndAssert(element, 'nav a'),
+ /* HTML */ `
+ <a hidden="" href="${BRANCHES_PATH}" id="prevArrow">
+ <gr-icon icon="chevron_left"> </gr-icon>
+ </a>
+ `
+ );
+
element.path = TAGS_PATH;
await element.updateComplete;
- assert.equal(computeNavLinkStub.lastCall.args[4], TAGS_PATH);
+
+ assert.dom.equal(
+ queryAndAssert(element, 'nav a'),
+ /* HTML */ `
+ <a hidden="" href="${TAGS_PATH}" id="prevArrow">
+ <gr-icon icon="chevron_left"> </gr-icon>
+ </a>
+ `
+ );
});
test('computePage', () => {
- assert.equal(element.computePage(0, 25), 1);
- assert.equal(element.computePage(50, 25), 3);
+ element.offset = 0;
+ element.itemsPerPage = 25;
+ assert.equal(element.computePage(), 1);
+ element.offset = 50;
+ element.itemsPerPage = 25;
+ assert.equal(element.computePage(), 3);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
deleted file mode 100644
index c18a31d12e..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-overlay_html';
-import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
-import {customElement} from '@polymer/decorators';
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {findActiveElement} from '../../../utils/dom-util';
-import {fireEvent} from '../../../utils/event-util';
-import {getHovercardContainer} from '../../../mixins/hovercard-mixin/hovercard-mixin';
-import {getFocusableElements} from '../../../utils/focusable';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-declare global {
- interface HTMLElementTagNameMap {
- 'gr-overlay': GrOverlay;
- }
-}
-
-// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
-const base = IronOverlayMixin(
- PolymerElement,
- IronOverlayBehavior as IronOverlayBehavior
-);
-
-/**
- * @attr {Boolean} with-backdrop - inherited from IronOverlay
- * @attr {Boolean} always-on-top - inherited from IronOverlay
- * @attr {Boolean} no-cancel-on-esc-key - inherited from IronOverlay
- * @attr {Boolean} no-cancel-on-outside-click - inherited from IronOverlay
- * @attr {String} scroll-action - inherited from IronOverlay
- */
-@customElement('gr-overlay')
-export class GrOverlay extends base {
- static get template() {
- return htmlTemplate;
- }
-
- /**
- * Fired when a fullscreen overlay is closed
- *
- * @event fullscreen-overlay-closed
- */
-
- /**
- * Fired when an overlay is opened in full screen mode
- *
- * @event fullscreen-overlay-opened
- */
-
- // private but used in test
- fullScreenOpen = false;
-
- // private but used in test
- _boundHandleClose: () => void = () => super.close();
-
- private focusableNodes?: Node[];
-
- private returnFocusTo?: HTMLElement;
-
- override get _focusableNodes() {
- if (this.focusableNodes) {
- return this.focusableNodes;
- }
- return Array.from(getFocusableElements(this));
- }
-
- constructor() {
- super();
- this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
- this.addEventListener('iron-overlay-cancelled', () =>
- this._overlayClosed()
- );
- }
-
- override open() {
- this.returnFocusTo = findActiveElement(document, true) ?? undefined;
- window.addEventListener('popstate', this._boundHandleClose);
- return new Promise<void>((resolve, reject) => {
- super.open.apply(this);
- if (this._isMobile()) {
- fireEvent(this, 'fullscreen-overlay-opened');
- this.fullScreenOpen = true;
- }
- this._awaitOpen(resolve, reject);
- });
- }
-
- _isMobile() {
- return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
- }
-
- // called after iron-overlay is closed. Does not actually close the overlay
- _overlayClosed() {
- window.removeEventListener('popstate', this._boundHandleClose);
- if (this.fullScreenOpen) {
- fireEvent(this, 'fullscreen-overlay-closed');
- this.fullScreenOpen = false;
- }
- if (this.returnFocusTo) {
- this.returnFocusTo.focus();
- this.returnFocusTo = undefined;
- }
- }
-
- override _onCaptureFocus(e: Event) {
- const hovercardContainer = getHovercardContainer();
- if (hovercardContainer) {
- // Hovercard container is not a child of an overlay.
- // When an overlay is opened and a user clicks inside hovercard,
- // the IronOverlayBehavior doesn't allow to set focus inside a hovercard.
- // As a result, user can't select a text (username) in the hovercard
- // in a dialog. We should skip default _onCaptureFocus for hovercards.
- const path = e.composedPath();
- if (path.indexOf(hovercardContainer) >= 0) return;
- }
- super._onCaptureFocus(e);
- }
-
- /**
- * Override the focus stops that iron-overlay-behavior tries to find.
- */
- setFocusStops(stops: GrOverlayStops) {
- this.focusableNodes = [stops.start, stops.end];
- }
-
- /**
- * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
- * opening. Eventually replace with a direct way to listen to the overlay.
- */
- _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
- let iters = 0;
- const step = () => {
- setTimeout(() => {
- if (this.style.display !== 'none') {
- fn.call(this);
- } else if (iters++ < AWAIT_MAX_ITERS) {
- step.call(this);
- } else {
- reject(new Error('gr-overlay _awaitOpen failed to resolve'));
- }
- }, AWAIT_STEP);
- };
- step.call(this);
- }
-
- _id() {
- return this.getAttribute('id') || 'global';
- }
-}
-
-export interface GrOverlayStops {
- start: Node;
- end: Node;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
deleted file mode 100644
index f6818a5ce3..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_html.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- :host {
- background: var(--dialog-background-color);
- border-radius: var(--border-radius);
- box-shadow: var(--elevation-level-5);
- }
-
- @media screen and (max-width: 50em) {
- :host {
- height: 100%;
- left: 0;
- position: fixed;
- right: 0;
- top: 0;
- border-radius: 0;
- box-shadow: none;
- }
- }
- </style>
- <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
deleted file mode 100644
index dc987450d9..0000000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import './gr-overlay';
-import {GrOverlay} from './gr-overlay';
-import {fixture, html, assert} from '@open-wc/testing';
-
-suite('gr-overlay tests', () => {
- let element: GrOverlay;
-
- setup(async () => {
- element = await fixture(html`<gr-overlay><div>content</div></gr-overlay>`);
- });
-
- test('render', async () => {
- await element.open();
- assert.shadowDom.equal(element, /* HTML */ ' <slot></slot> ');
- });
-
- test('popstate listener is attached on open and removed on close', () => {
- const addEventListenerStub = sinon.stub(window, 'addEventListener');
- const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
- element.open();
- assert.isTrue(addEventListenerStub.called);
- assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
- assert.equal(
- addEventListenerStub.lastCall.args[1],
- element._boundHandleClose
- );
- element._overlayClosed();
- assert.isTrue(removeEventListenerStub.called);
- assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
- assert.equal(
- removeEventListenerStub.lastCall.args[1],
- element._boundHandleClose
- );
- });
-
- test('events are fired on fullscreen view', async () => {
- const isMobileStub = sinon.stub(element, '_isMobile').returns(true as any);
- const openHandler = sinon.stub();
- const closeHandler = sinon.stub();
- element.addEventListener('fullscreen-overlay-opened', openHandler);
- element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
- await element.open();
-
- assert.isTrue(isMobileStub.called);
- assert.isTrue(element.fullScreenOpen);
- assert.isTrue(openHandler.called);
-
- element._overlayClosed();
- assert.isFalse(element.fullScreenOpen);
- assert.isTrue(closeHandler.called);
- });
-
- test('events are not fired on desktop view', async () => {
- const isMobileStub = sinon.stub(element, '_isMobile').returns(false as any);
- const openHandler = sinon.stub();
- const closeHandler = sinon.stub();
- element.addEventListener('fullscreen-overlay-opened', openHandler);
- element.addEventListener('fullscreen-overlay-closed', closeHandler);
-
- await element.open();
-
- assert.isTrue(isMobileStub.called);
- assert.isFalse(element.fullScreenOpen);
- assert.isFalse(openHandler.called);
-
- element._overlayClosed();
- assert.isFalse(element.fullScreenOpen);
- assert.isFalse(closeHandler.called);
- });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
index 8fa351b5b2..6d2fe20fe0 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -21,6 +21,7 @@ import {customElement, property, state, query} from 'lit/decorators.js';
import {assertIsDefined} from '../../../utils/common-util';
import {fire} from '../../../utils/event-util';
import {BindValueChangeEvent} from '../../../types/events';
+import {throwingErrorCallback} from '../gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
const SUGGESTIONS_LIMIT = 15;
const REF_PREFIX = 'refs/heads/';
@@ -121,7 +122,13 @@ export class GrRepoBranchPicker extends LitElement {
input = input.substring(REF_PREFIX.length);
}
return this.restApiService
- .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+ .getRepoBranches(
+ input,
+ this.repo,
+ SUGGESTIONS_LIMIT,
+ /* offset=*/ undefined,
+ throwingErrorCallback
+ )
.then(res => this.branchResponseToSuggestions(res));
}
@@ -143,7 +150,12 @@ export class GrRepoBranchPicker extends LitElement {
// private but used in test
getRepoSuggestions(input: string) {
return this.restApiService
- .getRepos(input, SUGGESTIONS_LIMIT)
+ .getRepos(
+ input,
+ SUGGESTIONS_LIMIT,
+ /* offset=*/ undefined,
+ throwingErrorCallback
+ )
.then(res => this.repoResponseToSuggestions(res));
}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
index 4f22f257b5..fd69a339c4 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
@@ -10,12 +10,12 @@ import {GrEtagDecorator} from './gr-etag-decorator';
suite('gr-etag-decorator', () => {
let etag: GrEtagDecorator;
- const fakeRequest = (opt_etag?: string, opt_status?: number) => {
+ const fakeRequest = (etag?: string, status?: number) => {
const headers = new Headers();
- if (opt_etag) {
- headers.set('etag', opt_etag);
+ if (etag) {
+ headers.set('etag', etag);
}
- const status = opt_status || 200;
+ status = status || 200;
return {...new Response(), ok: true, status, headers};
};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
index 9c54349ddf..108451217a 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -5,10 +5,7 @@
*/
import {getBaseUrl} from '../../../../utils/url-util';
import {CancelConditionCallback} from '../../../../services/gr-rest-api/gr-rest-api';
-import {
- AuthRequestInit,
- AuthService,
-} from '../../../../services/gr-auth/gr-auth';
+import {AuthService} from '../../../../services/gr-auth/gr-auth';
import {
AccountDetailInfo,
EmailInfo,
@@ -17,8 +14,12 @@ import {
} from '../../../../types/common';
import {HttpMethod} from '../../../../constants/constants';
import {RpcLogEventDetail} from '../../../../types/events';
-import {fireNetworkError, fireServerError} from '../../../../utils/event-util';
-import {FetchRequest} from '../../../../types/types';
+import {
+ fire,
+ fireNetworkError,
+ fireServerError,
+} from '../../../../utils/event-util';
+import {AuthRequestInit, FetchRequest} from '../../../../types/types';
import {ErrorCallback} from '../../../../api/rest';
import {Scheduler, Task} from '../../../../services/scheduler/scheduler';
import {RetryError} from '../../../../services/scheduler/retry-scheduler';
@@ -102,7 +103,7 @@ export class SiteBasedCache {
get(key: '/accounts/self/emails'): EmailInfo[] | null;
- get(key: '/accounts/self/detail'): AccountDetailInfo[] | null;
+ get(key: '/accounts/self/detail'): AccountDetailInfo | null;
get(key: string): ParsedJSON | null;
@@ -112,7 +113,7 @@ export class SiteBasedCache {
set(key: '/accounts/self/emails', value: EmailInfo[]): void;
- set(key: '/accounts/self/detail', value: AccountDetailInfo[]): void;
+ set(key: '/accounts/self/detail', value: AccountDetailInfo): void;
set(key: string, value: ParsedJSON | null): void;
@@ -183,6 +184,35 @@ export type FetchParams = {
[name: string]: string[] | string | number | boolean | undefined | null;
};
+/**
+ * Error callback that throws an error.
+ *
+ * Pass into REST API methods as errFn to make the returned Promises reject on
+ * error.
+ *
+ * If error is provided, it's thrown.
+ * Otherwise if response with error is provided the promise that will throw an
+ * error is returned.
+ */
+export function throwingErrorCallback(
+ response?: Response | null,
+ err?: Error
+): void | Promise<void> {
+ if (err) throw err;
+ if (!response) return;
+
+ return response.text().then(errorText => {
+ let message = `Error ${response.status}`;
+ if (response.statusText) {
+ message += ` (${response.statusText})`;
+ }
+ if (errorText) {
+ message += `: ${errorText}`;
+ }
+ throw new Error(message);
+ });
+}
+
interface SendRequestBase {
method: HttpMethod | undefined;
body?: RequestPayload;
@@ -275,16 +305,14 @@ s */
* by this method, it should be called immediately after the request
* finishes.
*
+ * Private, but used in tests.
+ *
* @param startTime the time that the request was started.
* @param status the HTTP status of the response. The status value
* is used here rather than the response object so there is no way this
* method can read the body stream.
*/
- private _logCall(
- req: FetchRequest,
- startTime: number,
- status: number | null
- ) {
+ _logCall(req: FetchRequest, startTime: number, status: number | null) {
const method =
req.fetchOptions && req.fetchOptions.method
? req.fetchOptions.method
@@ -310,13 +338,7 @@ s */
elapsed,
anonymizedUrl: req.anonymizedUrl,
};
- document.dispatchEvent(
- new CustomEvent('gr-rpc-log', {
- detail,
- composed: true,
- bubbles: true,
- })
- );
+ fire(document, 'gr-rpc-log', detail);
}
}
@@ -363,27 +385,26 @@ s */
*
* @param noAcceptHeader - don't add default accept json header
*/
- fetchJSON(
+ async fetchJSON(
req: FetchJSONRequest,
noAcceptHeader?: boolean
): Promise<ParsedJSON | undefined> {
if (!noAcceptHeader) {
req = this.addAcceptJsonHeader(req);
}
- return this.fetchRawJSON(req).then(response => {
- if (!response) {
- return;
- }
- if (!response.ok) {
- if (req.errFn) {
- req.errFn.call(null, response);
- return;
- }
- fireServerError(response, req);
+ const response = await this.fetchRawJSON(req);
+ if (!response) {
+ return;
+ }
+ if (!response.ok) {
+ if (req.errFn) {
+ await req.errFn.call(undefined, response);
return;
}
- return this.getResponseObject(response);
- });
+ fireServerError(response, req);
+ return;
+ }
+ return this.getResponseObject(response);
}
urlWithParams(url: string, fetchParams?: FetchParams): string {
@@ -393,9 +414,7 @@ s */
const params: Array<string | number | boolean> = [];
for (const [p, paramValue] of Object.entries(fetchParams)) {
- // TODO(TS): Replace == null with === and check for null and undefined
- // eslint-disable-next-line eqeqeq
- if (paramValue == null) {
+ if (paramValue === null || paramValue === undefined) {
params.push(this.encodeRFC5987(p));
continue;
}
@@ -476,7 +495,7 @@ s */
* (i.e. no exception and response.ok is true). If response fails then
* promise resolves either to void if errFn is set or rejects if errFn
* is not set */
- send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
+ async send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
const options: AuthRequestInit = {method: req.method};
if (req.body) {
options.headers = new Headers();
@@ -501,38 +520,30 @@ s */
fetchOptions: options,
anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
};
- const xhr = this.fetch(fetchReq)
- .catch(err => {
- fireNetworkError(err);
- if (req.errFn) {
- req.errFn.call(undefined, null, err);
- return;
- } else {
- throw err;
- }
- })
- .then(response => {
- if (response && !response.ok) {
- if (req.errFn) {
- req.errFn.call(undefined, response);
- return;
- }
- fireServerError(response, fetchReq);
- }
- return response;
- });
+ let xhr;
+ try {
+ xhr = await this.fetch(fetchReq);
+ } catch (err) {
+ fireNetworkError(err as Error);
+ if (req.errFn) {
+ await req.errFn.call(undefined, null, err as Error);
+ xhr = undefined;
+ } else {
+ throw err;
+ }
+ }
+ if (xhr && !xhr.ok) {
+ if (req.errFn) {
+ await req.errFn.call(undefined, xhr);
+ } else {
+ fireServerError(xhr, fetchReq);
+ }
+ }
if (req.parseResponse) {
- // TODO(TS): remove as Response and fix error.
- // Javascript code allows returning of a Response object from errFn.
- // This can be a mistake and we should add check here or it can be used
- // somewhere - in this case we should fix it carefully (define
- // different type of callback if parseResponse is true, etc...).
- return xhr.then(res => this.getResponseObject(res as Response));
+ xhr = xhr && this.getResponseObject(xhr);
}
- // The actual xhr type is Promise<Response|undefined|void> because of the
- // catch callback
- return xhr as Promise<Response | undefined>;
+ return xhr;
}
invalidateFetchPromisesPrefix(prefix: string) {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
index 712ece496b..9f0319eba3 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.ts
@@ -9,14 +9,15 @@ import {
FetchPromisesCache,
GrRestApiHelper,
} from './gr-rest-api-helper';
-import {getAppContext} from '../../../../services/app-context';
-import {stubAuth, waitEventLoop} from '../../../../test/test-utils';
+import {assertFails, waitEventLoop} from '../../../../test/test-utils';
import {FakeScheduler} from '../../../../services/scheduler/fake-scheduler';
import {RetryScheduler} from '../../../../services/scheduler/retry-scheduler';
import {ParsedJSON} from '../../../../types/common';
import {HttpMethod} from '../../../../api/rest-api';
import {SinonFakeTimers} from 'sinon';
import {assert} from '@open-wc/testing';
+import {AuthService} from '../../../../services/gr-auth/gr-auth';
+import {GrAuthMock} from '../../../../services/gr-auth/gr-auth_mock';
function makeParsedJSON<T>(val: T): ParsedJSON {
return val as unknown as ParsedJSON;
@@ -32,6 +33,7 @@ suite('gr-rest-api-helper tests', () => {
let authFetchStub: sinon.SinonStub;
let readScheduler: FakeScheduler<Response>;
let writeScheduler: FakeScheduler<Response>;
+ let authService: AuthService;
setup(() => {
clock = sinon.useFakeTimers();
@@ -42,7 +44,8 @@ suite('gr-rest-api-helper tests', () => {
window.CANONICAL_PATH = 'testhelper';
const testJSON = ')]}\'\n{"hello": "bonjour"}';
- authFetchStub = stubAuth('fetch').returns(
+ authService = new GrAuthMock();
+ authFetchStub = sinon.stub(authService, 'fetch').returns(
Promise.resolve({
...new Response(),
ok: true,
@@ -57,7 +60,7 @@ suite('gr-rest-api-helper tests', () => {
helper = new GrRestApiHelper(
cache,
- getAppContext().authService,
+ authService,
fetchPromisesCache,
readScheduler,
writeScheduler
@@ -226,6 +229,79 @@ suite('gr-rest-api-helper tests', () => {
assert.isTrue(cancelCalled);
});
+ suite('throwing in errFn', () => {
+ function throwInPromise(response?: Response | null, _?: Error) {
+ return response?.text().then(text => {
+ throw new Error(text);
+ });
+ }
+
+ function throwImmediately(_1?: Response | null, _2?: Error) {
+ throw new Error('Error Callback error');
+ }
+
+ setup(() => {
+ authFetchStub.returns(
+ Promise.resolve({
+ ...new Response(),
+ status: 400,
+ ok: false,
+ text() {
+ return Promise.resolve('Nope');
+ },
+ })
+ );
+ });
+
+ test('errFn with Promise throw cause send to reject on error', async () => {
+ const promise = helper.send({
+ method: HttpMethod.GET,
+ url: '/dummy/url',
+ parseResponse: false,
+ errFn: throwInPromise,
+ });
+ await assertReadRequest();
+
+ const err = await assertFails(promise);
+ assert.equal((err as Error).message, 'Nope');
+ });
+
+ test('errFn with Promise throw cause fetchJSON to reject on error', async () => {
+ const promise = helper.fetchJSON({
+ url: '/dummy/url',
+ errFn: throwInPromise,
+ });
+ await assertReadRequest();
+
+ const err = await assertFails(promise);
+ assert.equal((err as Error).message, 'Nope');
+ });
+
+ test('errFn with immediate throw cause send to reject on error', async () => {
+ const promise = helper.send({
+ method: HttpMethod.GET,
+ url: '/dummy/url',
+ parseResponse: false,
+ errFn: throwImmediately,
+ });
+ await assertReadRequest();
+
+ const err = await assertFails(promise);
+ assert.equal((err as Error).message, 'Error Callback error');
+ });
+
+ test('errFn with immediate Promise cause fetchJSON to reject on error', async () => {
+ const promise = helper.fetchJSON({
+ url: '/dummy/url',
+ errFn: throwImmediately,
+ });
+ await assertReadRequest();
+
+ const err = await assertFails(promise);
+ assert.equal((err as Error).message, 'Error Callback error');
+ });
+ });
+
suite('429 errors', () => {
setup(() => {
authFetchStub.returns(
@@ -270,7 +346,7 @@ suite('gr-rest-api-helper tests', () => {
test('are retried', async () => {
helper = new GrRestApiHelper(
cache,
- getAppContext().authService,
+ authService,
fetchPromisesCache,
new RetryScheduler<Response>(readScheduler, 1, 50),
writeScheduler
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
index 9c879105c8..4225173a90 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -97,11 +97,11 @@ suite('gr-reviewer-updates-parser tests', () => {
const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
const date3 = '2017-01-26 12:33:50.000000000';
const date4 = '2017-01-26 12:44:50.000000000';
- const makeItem = function(state, reviewer, opt_date, opt_author) {
+ const makeItem = function(state, reviewer, date, author) {
return {
reviewer,
- updated: opt_date || date1,
- updated_by: opt_author || reviewer1,
+ updated: date || date1,
+ updated_by: author || reviewer1,
state,
};
};
@@ -173,9 +173,9 @@ suite('gr-reviewer-updates-parser tests', () => {
test('format reviewer updates', () => {
const reviewer1 = {_account_id: 1};
const reviewer2 = {_account_id: 2};
- const makeItem = function(prev, state, opt_reviewer) {
+ const makeItem = function(prev, state, reviewer) {
return {
- reviewer: opt_reviewer || reviewer1,
+ reviewer: reviewer || reviewer1,
prev_state: prev,
state,
};
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
index 4d683ef38d..7779ffff16 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -5,7 +5,6 @@
*/
import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import '../gr-cursor-manager/gr-cursor-manager';
-import '../gr-overlay/gr-overlay';
import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
import '../../../styles/shared-styles';
import {getAppContext} from '../../../services/app-context';
@@ -13,17 +12,16 @@ import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-
import {
GrAutocompleteDropdown,
Item,
- ItemSelectedEvent,
+ ItemSelectedEventDetail,
} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {Key} from '../../../utils/dom-util';
import {ValueChangedEvent} from '../../../types/events';
import {fire} from '../../../utils/event-util';
-import {LitElement, css, html, nothing} from 'lit';
+import {LitElement, css, html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
import {PropertyValues} from 'lit';
import {classMap} from 'lit/directives/class-map.js';
-import {KnownExperimentId} from '../../../services/flags/flags';
import {NumericChangeId, ServerInfo} from '../../../api/rest-api';
import {subscribe} from '../../lit/subscription-controller';
import {resolve} from '../../../models/dependency';
@@ -69,7 +67,7 @@ function isEmojiSuggestion(x: EmojiSuggestion | Item): x is EmojiSuggestion {
declare global {
interface HTMLElementEventMap {
- 'item-selected': CustomEvent<ItemSelectedEvent>;
+ 'item-selected': CustomEvent<ItemSelectedEventDetail>;
}
}
@@ -116,8 +114,6 @@ export class GrTextarea extends LitElement {
private readonly getChangeModel = resolve(this, changeModelToken);
- private readonly flagsService = getAppContext().flagsService;
-
private readonly restApiService = getAppContext().restApiService;
private readonly getConfigModel = resolve(this, configModelToken);
@@ -234,9 +230,7 @@ export class GrTextarea extends LitElement {
hiddenText in order to correctly position the dropdown. After being moved,
it is set as the positionTarget for the emojiSuggestions dropdown. -->
<span id="caratSpan"></span>
- ${this.renderEmojiDropdown()}
- ${this.renderMentionsDropdown()}
- </gr-autocomplete-dropdown>
+ ${this.renderEmojiDropdown()} ${this.renderMentionsDropdown()}
<iron-autogrow-textarea
id="textarea"
class=${classMap({noBorder: this.hideBorder})}
@@ -268,8 +262,6 @@ export class GrTextarea extends LitElement {
}
private renderMentionsDropdown() {
- if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
- return nothing;
return html` <gr-autocomplete-dropdown
id="mentionsSuggestions"
.suggestions=${this.suggestions}
@@ -302,14 +294,16 @@ export class GrTextarea extends LitElement {
return this.textarea!.textarea;
}
+ override focus() {
+ this.textarea?.textarea.focus();
+ }
+
putCursorAtEnd() {
const textarea = this.getNativeTextarea();
// Put the cursor at the end always.
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.selectionStart;
- setTimeout(() => {
- textarea.focus();
- });
+ textarea.focus();
}
private getVisibleDropdown() {
@@ -343,7 +337,7 @@ export class GrTextarea extends LitElement {
e.preventDefault();
e.stopPropagation();
this.getVisibleDropdown().cursorUp();
- this.textarea!.textarea.focus();
+ this.focus();
}
private handleDownKey(e: KeyboardEvent) {
@@ -353,16 +347,12 @@ export class GrTextarea extends LitElement {
e.preventDefault();
e.stopPropagation();
this.getVisibleDropdown().cursorDown();
- this.textarea!.textarea.focus();
+ this.focus();
}
private handleTabKey(e: KeyboardEvent) {
- // Tab should have normal behavior if the picker is closed or if the user
- // has only typed ':'.
- if (
- !this.isDropdownVisible() ||
- (this.isEmojiDropdownActive() && this.currentSearchString === '')
- ) {
+ // Tab should have normal behavior if the picker is closed.
+ if (!this.isDropdownVisible()) {
return;
}
e.preventDefault();
@@ -372,12 +362,9 @@ export class GrTextarea extends LitElement {
// private but used in test
handleEnterByKey(e: KeyboardEvent) {
- // Enter should have newline behavior if the picker is closed or if the user
- // has only typed ':'. Also make sure that shortcuts aren't clobbered.
- if (
- !this.isDropdownVisible() ||
- (this.isEmojiDropdownActive() && this.currentSearchString === '')
- ) {
+ // Enter should have newline behavior if the picker is closed. Also make
+ // sure that shortcuts aren't clobbered.
+ if (!this.isDropdownVisible()) {
this.indent(e);
return;
}
@@ -388,7 +375,7 @@ export class GrTextarea extends LitElement {
}
// private but used in test
- handleDropdownItemSelect(e: CustomEvent<ItemSelectedEvent>) {
+ handleDropdownItemSelect(e: CustomEvent<ItemSelectedEventDetail>) {
if (e.detail.selected?.dataset['value']) {
this.setValue(e.detail.selected?.dataset['value']);
}
@@ -488,14 +475,19 @@ export class GrTextarea extends LitElement {
}
private async computeSuggestions() {
+ this.suggestions = [];
if (this.currentSearchString === undefined) {
- this.suggestions = [];
return;
}
+ const searchString = this.currentSearchString;
+ let suggestions: (Item | EmojiSuggestion)[] = [];
if (this.isEmojiDropdownActive()) {
- this.computeEmojiSuggestions(this.currentSearchString);
+ suggestions = this.computeEmojiSuggestions(this.currentSearchString);
} else if (this.isMentionsDropdownActive()) {
- await this.computeReviewerSuggestions();
+ suggestions = await this.computeReviewerSuggestions();
+ }
+ if (searchString === this.currentSearchString) {
+ this.suggestions = suggestions;
}
}
@@ -532,8 +524,6 @@ export class GrTextarea extends LitElement {
}
private isMentionsDropdownActive() {
- if (!this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS))
- return false;
return (
this.specialCharIndex !== -1 && this.text[this.specialCharIndex] === '@'
);
@@ -548,10 +538,8 @@ export class GrTextarea extends LitElement {
private computeSpecialCharIndex() {
const charAtCursor = this.text[this.textarea!.selectionStart - 1];
- if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
- if (charAtCursor === '@' && this.specialCharIndex === -1) {
- this.specialCharIndex = this.getSpecialCharIndex(this.text);
- }
+ if (charAtCursor === '@' && this.specialCharIndex === -1) {
+ this.specialCharIndex = this.getSpecialCharIndex(this.text);
}
if (charAtCursor === ':' && this.specialCharIndex === -1) {
this.specialCharIndex = this.getSpecialCharIndex(this.text);
@@ -573,7 +561,7 @@ export class GrTextarea extends LitElement {
async handleTextChanged() {
await this.computeSuggestions();
this.openOrResetDropdown();
- this.textarea!.textarea.focus();
+ this.focus();
}
private openEmojiDropdown() {
@@ -587,7 +575,7 @@ export class GrTextarea extends LitElement {
}
// private but used in test
- formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
+ formatSuggestions(matchedSuggestions: EmojiSuggestion[]): EmojiSuggestion[] {
const suggestions = [];
for (const suggestion of matchedSuggestions) {
assert(isEmojiSuggestion(suggestion), 'malformed suggestion');
@@ -595,28 +583,27 @@ export class GrTextarea extends LitElement {
suggestion.text = `${suggestion.value} ${suggestion.match}`;
suggestions.push(suggestion);
}
- this.suggestions = suggestions;
+ return suggestions;
}
// private but used in test
- computeEmojiSuggestions(suggestionsText?: string) {
+ computeEmojiSuggestions(suggestionsText?: string): EmojiSuggestion[] {
if (suggestionsText === undefined) {
- this.suggestions = [];
- return;
+ return [];
}
if (!suggestionsText.length) {
- this.formatSuggestions(ALL_SUGGESTIONS);
+ return this.formatSuggestions(ALL_SUGGESTIONS);
} else {
const matches = ALL_SUGGESTIONS.filter(suggestion =>
suggestion.match.includes(suggestionsText)
).slice(0, MAX_ITEMS_DROPDOWN);
- this.formatSuggestions(matches);
+ return this.formatSuggestions(matches);
}
}
// TODO(dhruvsri): merge with getAccountSuggestions in account-util
- async computeReviewerSuggestions() {
- this.suggestions = (
+ async computeReviewerSuggestions(): Promise<Item[]> {
+ return (
(await this.restApiService.getSuggestedAccounts(
this.currentSearchString ?? '',
/* number= */ 15,
@@ -644,13 +631,7 @@ export class GrTextarea extends LitElement {
}
private fireChangedEvents() {
- // This is a bit redundant, because the `text` property has `notify:true`,
- // so whenever the `text` changes the component fires two identical events
- // `text-changed` and `value-changed`.
- fire(this, 'value-changed', {value: this.text});
fire(this, 'text-changed', {value: this.text});
- // Relay the event.
- fire(this, 'bind-value-changed', {value: this.text});
}
private indent(e: KeyboardEvent): void {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
index 78c8aa3151..4dcaa80b2d 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -6,10 +6,13 @@
import '../../../test/common-test-setup';
import './gr-textarea';
import {GrTextarea} from './gr-textarea';
-import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
import {
+ Item,
+ ItemSelectedEventDetail,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+ mockPromise,
pressKey,
- stubFlags,
stubRestApi,
waitUntil,
} from '../../../test/test-utils';
@@ -31,14 +34,16 @@ suite('gr-textarea tests', () => {
element,
/* HTML */ `<div id="hiddenText"></div>
<span id="caratSpan"> </span>
+ <gr-autocomplete-dropdown id="emojiSuggestions" is-hidden="">
+ </gr-autocomplete-dropdown>
<gr-autocomplete-dropdown
- id="emojiSuggestions"
+ id="mentionsSuggestions"
is-hidden=""
- style="position: fixed; top: 150px; left: 392.5px; box-sizing: border-box; max-height: 300px; max-width: 785px;"
+ role="listbox"
>
</gr-autocomplete-dropdown>
<iron-autogrow-textarea aria-disabled="false" focused="" id="textarea">
- </iron-autogrow-textarea> `,
+ </iron-autogrow-textarea>`,
{
// gr-autocomplete-dropdown sizing seems to vary between local & CI
ignoreAttributes: [
@@ -49,52 +54,11 @@ suite('gr-textarea tests', () => {
});
suite('mention users', () => {
- setup(async () => {
- stubFlags('isEnabled').returns(true);
- element.requestUpdate();
- await element.updateComplete;
- });
-
- test('renders', () => {
- assert.shadowDom.equal(
- element,
- /* HTML */ `
- <div id="hiddenText"></div>
- <span id="caratSpan"> </span>
- <gr-autocomplete-dropdown
- id="emojiSuggestions"
- is-hidden=""
- style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
- >
- </gr-autocomplete-dropdown>
- <gr-autocomplete-dropdown
- id="mentionsSuggestions"
- is-hidden=""
- role="listbox"
- style="position: fixed; top: 478px; left: 321px; box-sizing: border-box; max-height: 956px; max-width: 642px;"
- >
- </gr-autocomplete-dropdown>
- <iron-autogrow-textarea
- focused=""
- aria-disabled="false"
- id="textarea"
- >
- </iron-autogrow-textarea>
- `,
- {
- // gr-autocomplete-dropdown sizing seems to vary between local & CI
- ignoreAttributes: [
- {tags: ['gr-autocomplete-dropdown'], attributes: ['style']},
- ],
- }
- );
- });
-
test('mentions selector is open when @ is typed & the textarea has focus', async () => {
// Needed for Safari tests. selectionStart is not updated when text is
// updated.
const listenerStub = sinon.stub();
- element.addEventListener('bind-value-changed', listenerStub);
+ element.addEventListener('text-changed', listenerStub);
stubRestApi('getSuggestedAccounts').returns(
Promise.resolve([
createAccountWithEmail('abc@google.com'),
@@ -164,6 +128,98 @@ suite('gr-textarea tests', () => {
assert.isFalse(element.mentionsSuggestions!.isHidden);
});
+ test('mention suggestions cleared before request returns', async () => {
+ const promise = mockPromise<Item[]>();
+ stubRestApi('getSuggestedAccounts').returns(promise);
+ element.textarea!.focus();
+ await waitUntil(() => element.textarea!.focused === true);
+
+ element.suggestions = [
+ {dataValue: 'prior@google.com', text: 'Prior suggestion'},
+ ];
+ element.textarea!.selectionStart = 1;
+ element.textarea!.selectionEnd = 1;
+ element.text = '@';
+
+ await element.updateComplete;
+ assert.equal(element.suggestions.length, 0);
+
+ promise.resolve([
+ createAccountWithEmail('abc@google.com'),
+ createAccountWithEmail('abcdef@google.com'),
+ ]);
+ await waitUntil(() => element.suggestions.length !== 0);
+ assert.deepEqual(element.suggestions, [
+ {
+ dataValue: 'abc@google.com',
+ text: 'abc@google.com <abc@google.com>',
+ },
+ {
+ dataValue: 'abcdef@google.com',
+ text: 'abcdef@google.com <abcdef@google.com>',
+ },
+ ]);
+ });
+
+ test('mention dropdown shows suggestion for latest text', async () => {
+ const promise1 = mockPromise<Item[]>();
+ const promise2 = mockPromise<Item[]>();
+ const suggestionStub = stubRestApi('getSuggestedAccounts');
+ suggestionStub.returns(promise1);
+ element.textarea!.focus();
+ await waitUntil(() => element.textarea!.focused === true);
+
+ element.textarea!.selectionStart = 1;
+ element.textarea!.selectionEnd = 1;
+ element.text = '@';
+ await element.updateComplete;
+ assert.equal(element.currentSearchString, '');
+
+ suggestionStub.returns(promise2);
+ element.text = '@abc@google.com';
+ // None of suggestions returned yet.
+ assert.equal(element.suggestions.length, 0);
+ await element.updateComplete;
+ assert.equal(element.currentSearchString, 'abc@google.com');
+
+ promise2.resolve([
+ createAccountWithEmail('abc@google.com'),
+ createAccountWithEmail('abcdef@google.com'),
+ ]);
+
+ await waitUntil(() => element.suggestions.length !== 0);
+ assert.deepEqual(element.suggestions, [
+ {
+ dataValue: 'abc@google.com',
+ text: 'abc@google.com <abc@google.com>',
+ },
+ {
+ dataValue: 'abcdef@google.com',
+ text: 'abcdef@google.com <abcdef@google.com>',
+ },
+ ]);
+
+ promise1.resolve([
+ createAccountWithEmail('dce@google.com'),
+ createAccountWithEmail('defcba@google.com'),
+ ]);
+ // Empty the event queue.
+ await new Promise<void>(resolve => {
+ setTimeout(() => resolve());
+ });
+ // Suggestions didn't change
+ assert.deepEqual(element.suggestions, [
+ {
+ dataValue: 'abc@google.com',
+ text: 'abc@google.com <abc@google.com>',
+ },
+ {
+ dataValue: 'abcdef@google.com',
+ text: 'abcdef@google.com <abcdef@google.com>',
+ },
+ ]);
+ });
+
test('emoji selector does not open when previous char is \n', async () => {
element.textarea!.focus();
await waitUntil(() => element.textarea!.focused === true);
@@ -210,7 +266,7 @@ suite('gr-textarea tests', () => {
test('emoji dropdown does not open if mention dropdown is open', async () => {
const listenerStub = sinon.stub();
- element.addEventListener('bind-value-changed', listenerStub);
+ element.addEventListener('text-changed', listenerStub);
const resetSpy = sinon.spy(element, 'resetDropdown');
stubRestApi('getSuggestedAccounts').returns(
Promise.resolve([
@@ -239,21 +295,25 @@ suite('gr-textarea tests', () => {
assert.isFalse(element.mentionsSuggestions!.isHidden);
element.text = '@h';
+ await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
element.text = '@h ';
+ await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
element.text = '@h :';
+ await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
element.text = '@h :D';
+ await waitUntil(() => element.suggestions.length > 0);
await element.updateComplete;
assert.isTrue(element.emojiSuggestions!.isHidden);
assert.isFalse(element.mentionsSuggestions!.isHidden);
@@ -261,7 +321,7 @@ suite('gr-textarea tests', () => {
test('mention dropdown does not open if emoji dropdown is open', async () => {
const listenerStub = sinon.stub();
- element.addEventListener('bind-value-changed', listenerStub);
+ element.addEventListener('text-changed', listenerStub);
element.textarea!.focus();
await waitUntil(() => element.textarea!.focused === true);
@@ -355,7 +415,7 @@ suite('gr-textarea tests', () => {
// Needed for Safari tests. selectionStart is not updated when text is
// updated.
const listenerStub = sinon.stub();
- element.addEventListener('bind-value-changed', listenerStub);
+ element.addEventListener('text-changed', listenerStub);
element.textarea!.focus();
await waitUntil(() => element.textarea!.focused === true);
element.textarea!.selectionStart = 1;
@@ -509,13 +569,13 @@ suite('gr-textarea tests', () => {
{value: '😢', match: 'tear'},
{value: '😂', match: 'tears'},
];
- element.formatSuggestions(matchedSuggestions);
+ const suggestions = element.formatSuggestions(matchedSuggestions);
assert.deepEqual(
[
{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
{value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
],
- element.suggestions
+ suggestions
);
});
@@ -526,7 +586,7 @@ suite('gr-textarea tests', () => {
element.specialCharIndex = 10;
await element.updateComplete;
const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
- const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
+ const event = new CustomEvent<ItemSelectedEventDetail>('item-selected', {
detail: {trigger: 'click', selected: selectedItem},
});
element.handleDropdownItemSelect(event);
@@ -553,7 +613,7 @@ suite('gr-textarea tests', () => {
assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n ']);
});
- test('emoji dropdown is closed when iron-overlay-closed is fired', async () => {
+ test('emoji dropdown is closed when dropdown-closed is fired', async () => {
const resetSpy = sinon.spy(element, 'closeDropdown');
element.emojiSuggestions!.dispatchEvent(
new CustomEvent('dropdown-closed', {
@@ -617,28 +677,9 @@ suite('gr-textarea tests', () => {
await element.updateComplete;
assert.equal(element.text, '💯');
});
-
- test('enter key - ignored on just colon without more information', async () => {
- const enterSpy = sinon.spy(element.emojiSuggestions!, 'getCursorTarget');
- pressKey(element.textarea! as HTMLElement, Key.ENTER);
- assert.isFalse(enterSpy.called);
- element.textarea!.focus();
- element.textarea!.selectionStart = 1;
- element.textarea!.selectionEnd = 1;
- element.text = ':';
- await element.updateComplete;
- pressKey(element.textarea! as HTMLElement, Key.ENTER);
- assert.isFalse(enterSpy.called);
- });
});
suite('gr-textarea monospace', () => {
- // gr-textarea set monospace class in the ready() method.
- // In Polymer2, ready() is called from the fixture(...) method,
- // If ready() is called again later, some nested elements doesn't
- // handle it correctly. A separate test-fixture is used to set
- // properties before ready() is called.
-
let element: GrTextarea;
setup(async () => {
@@ -654,12 +695,6 @@ suite('gr-textarea tests', () => {
});
suite('gr-textarea hideBorder', () => {
- // gr-textarea set noBorder class in the ready() method.
- // In Polymer2, ready() is called from the fixture(...) method,
- // If ready() is called again later, some nested elements doesn't
- // handle it correctly. A separate test-fixture is used to set
- // properties before ready() is called.
-
let element: GrTextarea;
setup(async () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index 0e6b19eca5..4ccc635f4d 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -5,7 +5,6 @@
*/
import '../gr-icon/gr-icon';
import '../gr-tooltip/gr-tooltip';
-import {getRootElement} from '../../../scripts/rootElement';
import {GrTooltip} from '../gr-tooltip/gr-tooltip';
import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, state} from 'lit/decorators.js';
@@ -142,7 +141,7 @@ export class GrTooltipContent extends LitElement {
// Set visibility to hidden before appending to the DOM so that
// calculations can be made based on the element’s size.
tooltip.style.visibility = 'hidden';
- getRootElement().appendChild(tooltip);
+ document.body.appendChild(tooltip);
await tooltip.updateComplete;
this._positionTooltip(tooltip);
tooltip.style.visibility = 'initial';
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
new file mode 100644
index 0000000000..ff068a76ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix.ts
@@ -0,0 +1,111 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css, html, LitElement, nothing} from 'lit';
+import {customElement} from 'lit/decorators.js';
+import {getAppContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fire} from '../../../utils/event-util';
+
+declare global {
+ interface HTMLElementEventMap {
+ 'open-user-suggest-preview': OpenUserSuggestionPreviewEvent;
+ }
+}
+
+export type OpenUserSuggestionPreviewEvent =
+ CustomEvent<OpenUserSuggestionPreviewEventDetail>;
+export interface OpenUserSuggestionPreviewEventDetail {
+ code: string;
+}
+
+@customElement('gr-user-suggestion-fix')
+export class GrUserSuggetionFix extends LitElement {
+ private readonly flagsService = getAppContext().flagsService;
+
+ static override styles = [
+ css`
+ .header {
+ background-color: var(--background-color-primary);
+ border: 1px solid var(--border-color);
+ padding: var(--spacing-xs) var(--spacing-xl);
+ display: flex;
+ align-items: center;
+ border-top-left-radius: var(--border-radius);
+ border-top-right-radius: var(--border-radius);
+ }
+ .header .title {
+ flex: 1;
+ }
+ .copyButton {
+ margin-right: var(--spacing-l);
+ }
+ code {
+ max-width: var(--gr-formatted-text-prose-max-width, none);
+ background-color: var(--background-color-secondary);
+ border: 1px solid var(--border-color);
+ border-top: 0;
+ display: block;
+ font-family: var(--monospace-font-family);
+ font-size: var(--font-size-code);
+ line-height: var(--line-height-mono);
+ margin-bottom: var(--spacing-m);
+ padding: var(--spacing-xxs) var(--spacing-s);
+ overflow-x: auto;
+ /* Pre will preserve whitespace and line breaks but not wrap */
+ white-space: pre;
+ border-bottom-left-radius: var(--border-radius);
+ border-bottom-right-radius: var(--border-radius);
+ }
+ `,
+ ];
+
+ override render() {
+ if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
+ return nothing;
+ }
+ if (!this.textContent) return nothing;
+ const code = this.textContent;
+ return html`<div class="header">
+ <div class="title">
+ <span>Suggested edit</span>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+ target="_blank"
+ ><gr-icon icon="help" title="read documentation"></gr-icon
+ ></a>
+ </div>
+ <div class="copyButton">
+ <gr-copy-clipboard
+ hideInput=""
+ text=${code}
+ copyTargetName="Suggested edit"
+ ></gr-copy-clipboard>
+ </div>
+ <div>
+ <gr-button
+ secondary
+ flatten
+ class="action show-fix"
+ @click=${this.handleShowFix}
+ >
+ Show edit
+ </gr-button>
+ </div>
+ </div>
+ <code>${code}</code>`;
+ }
+
+ handleShowFix() {
+ if (!this.textContent) return;
+ fire(this, 'open-user-suggest-preview', {code: this.textContent});
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-user-suggestion-fix': GrUserSuggetionFix;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
new file mode 100644
index 0000000000..aecd93b88c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_test.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-user-suggestion-fix';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrUserSuggetionFix} from './gr-user-suggestion-fix';
+import {getAppContext} from '../../../services/app-context';
+
+suite('gr-user-suggestion-fix tests', () => {
+ let element: GrUserSuggetionFix;
+
+ setup(async () => {
+ const flagsService = getAppContext().flagsService;
+ sinon.stub(flagsService, 'isEnabled').returns(true);
+ element = await fixture<GrUserSuggetionFix>(html`
+ <gr-user-suggestion-fix>Hello World</gr-user-suggestion-fix>
+ `);
+ await element.updateComplete;
+ });
+
+ test('render', async () => {
+ await element.updateComplete;
+
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `<div class="header">
+ <div class="title">
+ <span>Suggested edit</span>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-suggest-edits.html"
+ target="_blank"
+ ><gr-icon icon="help" title="read documentation"></gr-icon
+ ></a>
+ </div>
+ <div class="copyButton">
+ <gr-copy-clipboard
+ hideinput=""
+ text="Hello World"
+ copytargetname="Suggested edit"
+ ></gr-copy-clipboard>
+ </div>
+ <div>
+ <gr-button class="action show-fix" secondary="" flatten=""
+ >Show edit</gr-button
+ >
+ </div>
+ </div>
+ <code>Hello World</code>`
+ );
+ });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
new file mode 100644
index 0000000000..fb6372c83f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink.ts
@@ -0,0 +1,71 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../gr-tooltip-content/gr-tooltip-content';
+import {LitElement, css, html, nothing} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {WebLinkInfo} from '../../../api/rest-api';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-weblink': GrWeblink;
+ }
+}
+@customElement('gr-weblink')
+export class GrWeblink extends LitElement {
+ @property({type: Object})
+ info?: WebLinkInfo;
+
+ @property({type: Boolean})
+ imageAndText = false;
+
+ static override get styles() {
+ return [
+ css`
+ :host {
+ display: inline-block;
+ vertical-align: top;
+ line-height: var(--line-height-normal);
+ margin-right: var(--spacing-s);
+ }
+ a {
+ color: var(--link-color);
+ }
+ :host([imageAndText]) img {
+ margin-right: var(--spacing-s);
+ }
+ img {
+ vertical-align: top;
+ width: var(--line-height-normal);
+ height: var(--line-height-normal);
+ }
+ `,
+ ];
+ }
+
+ override render() {
+ if (!this.info?.url) return nothing;
+ if (!this.info?.name) return nothing;
+
+ return html`
+ <a href=${this.info.url} rel="noopener" target="_blank">
+ <gr-tooltip-content
+ title=${ifDefined(this.info.tooltip)}
+ ?has-tooltip=${this.info.tooltip !== undefined}
+ >
+ ${when(
+ this.info.image_url,
+ () => html`<img src=${this.info!.image_url!} />`
+ )}${when(
+ !this.info.image_url || this.imageAndText,
+ () => html`<span>${this.info!.name}</span>`
+ )}
+ </gr-tooltip-content>
+ </a>
+ `;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
new file mode 100644
index 0000000000..acb309f7ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-weblink/gr-weblink_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import {fixture, assert} from '@open-wc/testing';
+import {html} from 'lit';
+import './gr-weblink';
+import {GrWeblink} from './gr-weblink';
+import {WebLinkInfo} from '../../../api/rest-api';
+
+suite('gr-weblink tests', () => {
+ test('renders with image', async () => {
+ const info: WebLinkInfo = {
+ name: 'gitiles',
+ url: 'https://www.google.com',
+ image_url: 'https://www.google.com/favicon.ico',
+ tooltip: 'Open in Gitiles',
+ };
+ const element = await fixture<GrWeblink>(
+ html`<gr-weblink .info=${info}></gr-weblink>`
+ );
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <a href="https://www.google.com" rel="noopener" target="_blank">
+ <gr-tooltip-content title="Open in Gitiles" has-tooltip>
+ <img src="https://www.google.com/favicon.ico" />
+ </gr-tooltip-content>
+ </a>
+ `
+ );
+ });
+
+ test('renders with text', async () => {
+ const info: WebLinkInfo = {
+ name: 'gitiles',
+ url: 'https://www.google.com',
+ tooltip: 'Open in Gitiles',
+ };
+ const element = await fixture<GrWeblink>(
+ html`<gr-weblink .info=${info}></gr-weblink>`
+ );
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <a href="https://www.google.com" rel="noopener" target="_blank">
+ <gr-tooltip-content title="Open in Gitiles" has-tooltip>
+ <span>gitiles</span>
+ </gr-tooltip-content>
+ </a>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
new file mode 100644
index 0000000000..79c40de899
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -0,0 +1,132 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-button/gr-button';
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {getShowConfig} from './gr-context-controls';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {when} from 'lit/directives/when.js';
+
+@customElement('gr-context-controls-section')
+export class GrContextControlsSection extends LitElement {
+ /** Should context controls be rendered for expanding above the section? */
+ @property({type: Boolean}) showAbove = false;
+
+ /** Should context controls be rendered for expanding below the section? */
+ @property({type: Boolean}) showBelow = false;
+
+ /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
+ @property({type: Object})
+ group?: GrDiffGroup;
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @property({type: Object})
+ renderPrefs?: RenderPreferences;
+
+ /**
+ * Semantic DOM diff testing does not work with just table fragments, so when
+ * running such tests the render() method has to wrap the DOM in a proper
+ * <table> element.
+ */
+ @state()
+ addTableWrapperForTesting = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ private renderPaddingRow(whereClass: 'above' | 'below') {
+ if (!this.showAbove && whereClass === 'above') return;
+ if (!this.showBelow && whereClass === 'below') return;
+ const modeClass = this.isSideBySide() ? 'side-by-side' : 'unified';
+ const type = this.isSideBySide()
+ ? GrDiffGroupType.CONTEXT_CONTROL
+ : undefined;
+ return html`
+ <tr
+ class=${diffClasses('contextBackground', modeClass, whereClass)}
+ left-type=${ifDefined(type)}
+ right-type=${ifDefined(type)}
+ >
+ <td class=${diffClasses('blame')} data-line-number="0"></td>
+ <td class=${diffClasses('contextLineNum')}></td>
+ ${when(
+ this.isSideBySide(),
+ () => html`
+ <td class=${diffClasses('sign')}></td>
+ <td class=${diffClasses()}></td>
+ `
+ )}
+ <td class=${diffClasses('contextLineNum')}></td>
+ ${when(
+ this.isSideBySide(),
+ () => html`<td class=${diffClasses('sign')}></td>`
+ )}
+ <td class=${diffClasses()}></td>
+ </tr>
+ `;
+ }
+
+ private isSideBySide() {
+ return this.renderPrefs?.view_mode !== DiffViewMode.UNIFIED;
+ }
+
+ private createContextControlRow() {
+ // Note that <td> table cells that have `display: none` don't count!
+ const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
+ const showConfig = getShowConfig(this.showAbove, this.showBelow);
+ return html`
+ <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+ <td class=${diffClasses('blame')} data-line-number="0"></td>
+ ${when(
+ this.isSideBySide(),
+ () => html`<td class=${diffClasses()}></td>`
+ )}
+ <td class=${diffClasses('dividerCell')} colspan=${colspan}>
+ <gr-context-controls
+ class=${diffClasses()}
+ .diff=${this.diff}
+ .renderPreferences=${this.renderPrefs}
+ .group=${this.group}
+ .showConfig=${showConfig}
+ >
+ </gr-context-controls>
+ </td>
+ </tr>
+ `;
+ }
+
+ override render() {
+ const rows = html`
+ ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
+ ${this.renderPaddingRow('below')}
+ `;
+ if (this.addTableWrapperForTesting) {
+ return html`<table>
+ ${rows}
+ </table>`;
+ }
+ return rows;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-context-controls-section': GrContextControlsSection;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
new file mode 100644
index 0000000000..6a557fc1d3
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -0,0 +1,70 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-context-controls-section';
+import {GrContextControlsSection} from './gr-context-controls-section';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-context-controls-section test', () => {
+ let element: GrContextControlsSection;
+
+ setup(async () => {
+ element = await fixture<GrContextControlsSection>(
+ html`<gr-context-controls-section></gr-context-controls-section>`
+ );
+ element.addTableWrapperForTesting = true;
+ await element.updateComplete;
+ });
+
+ test('render: normal with showAbove and showBelow', async () => {
+ element.showAbove = true;
+ element.showBelow = true;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ class="above contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ <tr class="dividerRow gr-diff show-both">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff"></td>
+ <td class="dividerCell gr-diff" colspan="3">
+ <gr-context-controls class="gr-diff" showconfig="both">
+ </gr-context-controls>
+ </td>
+ </tr>
+ <tr
+ class="below contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index a0d06f636a..4a2fee5e12 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -69,6 +69,19 @@ function findBlockTreePathForLine(
export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
+export function getShowConfig(
+ showAbove: boolean,
+ showBelow: boolean
+): GrContextControlsShowConfig {
+ if (showAbove && !showBelow) return 'above';
+ if (!showAbove && showBelow) return 'below';
+
+ // Note that !showAbove && !showBelow also intentionally returns 'both'.
+ // This means the file is completely collapsed, which is unusual, but at least
+ // happens in one test.
+ return 'both';
+}
+
@customElement('gr-context-controls')
export class GrContextControls extends LitElement {
@property({type: Object}) renderPreferences?: RenderPreferences;
@@ -148,6 +161,10 @@ export class GrContextControls extends LitElement {
/* same as defined in gr-button */
background: rgba(0, 0, 0, 0.12);
}
+ paper-button:focus-visible {
+ /* paper-button sets this to 0, thus preventing focus-based styling. */
+ outline-width: 1px;
+ }
.aboveBelowButtons {
display: flex;
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
index dae5c03d94..859a49d8a1 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Side} from '../../../api/diff';
-import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
+import {
+ CoverageRange,
+ CoverageType,
+ DiffLayer,
+ DiffLayerListener,
+} from '../../../types/types';
const TOOLTIP_MAP = new Map([
[CoverageType.COVERED, 'Covered by tests.'],
@@ -13,6 +18,31 @@ const TOOLTIP_MAP = new Map([
[CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
]);
+// Ranges are considered half-open: [start, end)
+export type Range = {start: number; end: number};
+
+export function mergeRanges(ranges: Range[]): Range[] {
+ ranges.sort((a, b) => a.start - b.start);
+
+ if (ranges.length <= 1) {
+ return ranges;
+ }
+
+ const stack: Range[] = [];
+ stack.push(ranges[0]);
+
+ for (let j = 1; j < ranges.length; j++) {
+ const interval = ranges[j];
+ const top = stack[stack.length - 1];
+ if (top.end < interval.start) {
+ stack.push(interval);
+ } else if (top.end < interval.end) {
+ top.end = interval.end;
+ }
+ }
+ return stack;
+}
+
export class GrCoverageLayer implements DiffLayer {
/**
* Must be sorted by code_range.start_line.
@@ -35,14 +65,56 @@ export class GrCoverageLayer implements DiffLayer {
*/
private index = 0;
+ /**
+ * Has any line been annotated already in the lifetime of this layer?
+ * If not, then `setRanges()` does not have to call `notify()` and thus
+ * trigger re-rendering of the affected diff rows.
+ */
+ // visible for testing
+ annotated = false;
+
+ private listeners: DiffLayerListener[] = [];
+
constructor(private readonly side: Side) {}
+ addListener(listener: DiffLayerListener) {
+ this.listeners.push(listener);
+ }
+
+ removeListener(listener: DiffLayerListener) {
+ this.listeners = this.listeners.filter(f => f !== listener);
+ }
+
/**
* Must be sorted by code_range.start_line.
* Must only contain ranges that match the side.
*/
setRanges(ranges: CoverageRange[]) {
+ const oldRanges = this.coverageRanges;
+ if (oldRanges.length === 0 && ranges.length === 0) return;
this.coverageRanges = ranges;
+
+ // If ranges are set before any diff row was rendered, then great, no need
+ // to notify and re-render.
+ if (this.annotated) this.notify([...oldRanges, ...ranges]);
+ }
+
+ /**
+ * Notify listeners (should be just gr-diff triggering a re-render).
+ *
+ * We are optimizing the notification calls by converting the coverange ranges
+ * to an array of [start, end) ranges and then merging them to non-overlapping
+ * set of ranges.
+ */
+ private notify(ranges: CoverageRange[]) {
+ const notifyRanges = mergeRanges(
+ ranges.map(r => {
+ return {start: r.code_range.start_line, end: r.code_range.end_line + 1};
+ })
+ );
+ for (const r of notifyRanges) {
+ for (const l of this.listeners) l(r.start, r.end - 1, this.side);
+ }
}
/**
@@ -74,6 +146,7 @@ export class GrCoverageLayer implements DiffLayer {
this.index = 0;
}
this.lastLineNumber = elementLineNumber;
+ this.annotated = true;
// We simply loop through all the coverage ranges until we find one that
// matches the line number.
diff --git a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
index c1a123e390..a8cdff6080 100644
--- a/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-coverage-layer/gr-coverage-layer_test.ts
@@ -4,51 +4,108 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../test/common-test-setup';
-import {CoverageRange, CoverageType, Side} from '../../../api/diff';
-import {GrCoverageLayer} from './gr-coverage-layer';
+import {CoverageType, Side} from '../../../api/diff';
+import {GrCoverageLayer, mergeRanges} from './gr-coverage-layer';
import {assert} from '@open-wc/testing';
+import {SinonStub} from 'sinon';
+
+const RANGES = [
+ {
+ type: CoverageType.COVERED,
+ side: Side.RIGHT,
+ code_range: {
+ start_line: 1,
+ end_line: 2,
+ },
+ },
+ {
+ type: CoverageType.NOT_COVERED,
+ side: Side.RIGHT,
+ code_range: {
+ start_line: 3,
+ end_line: 4,
+ },
+ },
+ {
+ type: CoverageType.PARTIALLY_COVERED,
+ side: Side.RIGHT,
+ code_range: {
+ start_line: 5,
+ end_line: 6,
+ },
+ },
+ {
+ type: CoverageType.NOT_INSTRUMENTED,
+ side: Side.RIGHT,
+ code_range: {
+ start_line: 8,
+ end_line: 9,
+ },
+ },
+];
suite('gr-coverage-layer', () => {
let layer: GrCoverageLayer;
- setup(() => {
- const initialCoverageRanges: CoverageRange[] = [
- {
- type: CoverageType.COVERED,
- side: Side.RIGHT,
- code_range: {
- start_line: 1,
- end_line: 2,
- },
- },
- {
- type: CoverageType.NOT_COVERED,
- side: Side.RIGHT,
- code_range: {
- start_line: 3,
- end_line: 4,
- },
- },
- {
- type: CoverageType.PARTIALLY_COVERED,
- side: Side.RIGHT,
- code_range: {
- start_line: 5,
- end_line: 6,
- },
- },
- {
- type: CoverageType.NOT_INSTRUMENTED,
- side: Side.RIGHT,
- code_range: {
- start_line: 8,
- end_line: 9,
- },
- },
- ];
-
- layer = new GrCoverageLayer(Side.RIGHT);
- layer.setRanges(initialCoverageRanges);
+ test('mergeRanges', () => {
+ assert.deepEqual(mergeRanges([]), []);
+ assert.deepEqual(mergeRanges([{start: 1, end: 2}]), [{start: 1, end: 2}]);
+ assert.deepEqual(
+ mergeRanges([
+ {start: 1, end: 2},
+ {start: 2, end: 3},
+ ]),
+ [{start: 1, end: 3}]
+ );
+ assert.deepEqual(
+ mergeRanges([
+ {start: 2, end: 3},
+ {start: 1, end: 2},
+ ]),
+ [{start: 1, end: 3}]
+ );
+ assert.deepEqual(
+ mergeRanges([
+ {start: 1, end: 3},
+ {start: 4, end: 5},
+ ]),
+ [
+ {start: 1, end: 3},
+ {start: 4, end: 5},
+ ]
+ );
+ });
+
+ suite('setRanges and notify', () => {
+ let listener: SinonStub;
+
+ setup(() => {
+ layer = new GrCoverageLayer(Side.RIGHT);
+ listener = sinon.stub();
+ layer.addListener(listener);
+ });
+
+ test('empty ranges do not notify', () => {
+ layer.annotated = true;
+ layer.setRanges([]);
+ assert.isFalse(listener.called);
+ });
+
+ test('do not notify while annotated is false', () => {
+ layer.setRanges(RANGES);
+ assert.isFalse(listener.called);
+ });
+
+ test('RANGES', () => {
+ layer.annotated = true;
+ layer.setRanges(RANGES);
+ assert.isTrue(listener.called);
+ assert.equal(listener.callCount, 2);
+ assert.equal(listener.getCall(0).args[0], 1);
+ assert.equal(listener.getCall(0).args[1], 6);
+ assert.equal(listener.getCall(1).args[0], 8);
+ assert.equal(listener.getCall(1).args[1], 9);
+ });
});
suite('annotate', () => {
@@ -73,6 +130,11 @@ suite('gr-coverage-layer', () => {
assert.isTrue(contains);
}
+ setup(() => {
+ layer = new GrCoverageLayer(Side.RIGHT);
+ layer.setRanges(RANGES);
+ });
+
test('line 1-2 are covered', () => {
checkLine(1, 'COVERED');
checkLine(2, 'COVERED');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index c095ffb7dc..cc45e1ea7e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -3,13 +3,13 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {GrDiffBuilder} from './gr-diff-builder';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {queryAndAssert} from '../../../utils/common-util';
import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {html, render} from 'lit';
-export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
+export class GrDiffBuilderBinary extends GrDiffBuilder {
constructor(
diff: DiffInfo,
prefs: DiffPreferencesInfo,
@@ -18,13 +18,25 @@ export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
super(diff, prefs, outputEl);
}
- override buildSectionElement(): HTMLElement {
+ override buildSectionElement(group: GrDiffGroup): HTMLElement {
const section = createElementDiff('tbody', 'binary-diff');
- const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
- const fileRow = this.createRow(line);
- const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
- contentTd.textContent = ' Difference in binary files';
- section.appendChild(fileRow);
- return section;
+ // Do not create a diff row for 'LOST'.
+ if (group.lines[0].beforeNumber !== 'FILE') return section;
+ return super.buildSectionElement(group);
+ }
+
+ public renderBinaryDiff() {
+ render(
+ html`
+ <tbody class="gr-diff binary-diff">
+ <tr class="gr-diff">
+ <td colspan="5" class="gr-diff">
+ <span>Difference in binary files</span>
+ </td>
+ </tr>
+ </tbody>
+ `,
+ this.outputEl
+ );
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index cf76b8c685..328b5772de 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -5,14 +5,15 @@
*/
import '../gr-diff-processor/gr-diff-processor';
import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import './gr-diff-builder-side-by-side';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {DiffBuilder, DiffContextExpandedEventDetail} from './gr-diff-builder';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {
+ GrDiffBuilder,
+ DiffContextExpandedEventDetail,
+ isImageDiffBuilder,
+ isBinaryDiffBuilder,
+} from './gr-diff-builder';
import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, makeCancelable} from '../../../scripts/util';
import {BlameInfo, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -35,7 +36,7 @@ import {
hideInContextControl,
} from '../gr-diff/gr-diff-group';
import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {fireAlert, fire} from '../../../utils/event-util';
import {assertIsDefined} from '../../../utils/common-util';
const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -113,7 +114,7 @@ export class GrDiffBuilderElement implements GroupConsumer {
layers: DiffLayer[] = [];
// visible for testing
- builder?: DiffBuilder;
+ builder?: GrDiffBuilder;
/**
* All layers, both from the outside and the default ones. See `layers` for
@@ -128,13 +129,6 @@ export class GrDiffBuilderElement implements GroupConsumer {
// visible for testing
showTrailingWhitespace?: boolean;
- /**
- * The promise last returned from `render()` while the asynchronous
- * rendering is running - `null` otherwise. Provides a `cancel()`
- * method that rejects it with `{isCancelled: true}`.
- */
- private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
-
private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
@@ -142,7 +136,7 @@ export class GrDiffBuilderElement implements GroupConsumer {
private rangeLayer?: GrRangedCommentLayer;
// visible for testing
- processor = new GrDiffProcessor();
+ processor?: GrDiffProcessor;
/**
* Groups are mostly just passed on to the diff builder (this.builder). But
@@ -154,10 +148,6 @@ export class GrDiffBuilderElement implements GroupConsumer {
*/
private groups: GrDiffGroup[] = [];
- constructor() {
- this.processor.consumer = this;
- }
-
updateCommentRanges(ranges: CommentRangeLayer[]) {
this.rangeLayer?.updateRanges(ranges);
}
@@ -168,6 +158,9 @@ export class GrDiffBuilderElement implements GroupConsumer {
}
render(keyLocations: KeyLocations): Promise<void> {
+ assertIsDefined(this.diff, 'diff');
+ assertIsDefined(this.diffElement, 'diff table');
+
// Setting up annotation layers must happen after plugins are
// installed, and |render| satisfies the requirement, however,
// |attached| doesn't because in the diff view page, the element is
@@ -177,21 +170,20 @@ export class GrDiffBuilderElement implements GroupConsumer {
this.showTabs = this.prefs.show_tabs;
this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
- // Stop the processor if it's running.
- this.cancel();
-
- this.builder?.clear();
- assertIsDefined(this.diff, 'diff');
- assertIsDefined(this.diffElement, 'diff table');
+ this.cleanup();
this.builder = this.getDiffBuilder();
+ this.init();
+ // TODO: Just pass along the diff model here instead of setting many
+ // individual properties.
+ this.processor = new GrDiffProcessor();
+ this.processor.consumer = this;
this.processor.context = this.prefs.context;
this.processor.keyLocations = keyLocations;
-
- this.diffElement.addEventListener(
- 'diff-context-expanded',
- this.onDiffContextExpanded
- );
+ if (this.renderPrefs?.num_lines_rendered_at_once) {
+ this.processor.asyncThreshold =
+ this.renderPrefs.num_lines_rendered_at_once;
+ }
this.clearDiffContent();
this.builder.addColumns(
@@ -201,21 +193,18 @@ export class GrDiffBuilderElement implements GroupConsumer {
const isBinary = !!(this.isImageDiff || this.diff.binary);
- this.fireDiffEvent('render-start');
- // TODO: processor.process() returns a cancelable promise already.
- // Why wrap another one around it?
- this.cancelableRenderPromise = makeCancelable(
- this.processor.process(this.diff.content, isBinary)
- );
- // All then/catch/finally clauses must be outside of makeCancelable().
+ fire(this.diffElement, 'render-start', {});
return (
- this.cancelableRenderPromise
+ this.processor
+ .process(this.diff.content, isBinary)
.then(async () => {
- if (this.isImageDiff) {
- (this.builder as GrDiffBuilderImage).renderDiff();
+ if (isImageDiffBuilder(this.builder)) {
+ this.builder.renderImageDiff();
+ } else if (isBinaryDiffBuilder(this.builder)) {
+ this.builder.renderBinaryDiff();
}
await this.untilGroupsRendered();
- this.fireDiffEvent('render-content');
+ fire(this.diffElement, 'render-content', {});
})
// Mocha testing does not like uncaught rejections, so we catch
// the cancels which are expected and should not throw errors in
@@ -224,9 +213,6 @@ export class GrDiffBuilderElement implements GroupConsumer {
if (!e.isCanceled) return Promise.reject(e);
return;
})
- .finally(() => {
- this.cancelableRenderPromise = null;
- })
);
}
@@ -243,11 +229,6 @@ export class GrDiffBuilderElement implements GroupConsumer {
this.replaceGroup(e.detail.contextGroup, e.detail.groups);
};
- private fireDiffEvent<K extends keyof HTMLElementEventMap>(type: K) {
- assertIsDefined(this.diffElement, 'diff table');
- fireEvent(this.diffElement, type);
- }
-
// visible for testing
setupAnnotationLayers() {
this.rangeLayer = new GrRangedCommentLayer();
@@ -268,31 +249,21 @@ export class GrDiffBuilderElement implements GroupConsumer {
this.layersInternal = layers;
}
- getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
- if (!this.builder) return null;
- return this.builder.getContentTdByLine(lineNumber, side, root);
+ getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+ if (!this.builder) return undefined;
+ return this.builder.getContentTdByLine(lineNumber, side);
}
- private getDiffRowByChild(child: Element) {
- while (!child.classList.contains('diff-row') && child.parentElement) {
- child = child.parentElement;
- }
- return child;
- }
-
- getContentTdByLineEl(lineEl?: Element): Element | null {
- if (!lineEl) return null;
+ getContentTdByLineEl(lineEl?: Element): Element | undefined {
+ if (!lineEl) return undefined;
const line = getLineNumber(lineEl);
- if (!line) return null;
+ if (!line) return undefined;
const side = getSideByLineEl(lineEl);
- // Performance optimization because we already have an element in the
- // correct row
- const row = this.getDiffRowByChild(lineEl);
- return this.getContentTdByLine(line, side, row);
+ return this.getContentTdByLine(line, side);
}
getLineElByNumber(lineNumber: LineNumber, side?: Side) {
- if (!this.builder) return null;
+ if (!this.builder) return undefined;
return this.builder.getLineElByNumber(lineNumber, side);
}
@@ -360,20 +331,41 @@ export class GrDiffBuilderElement implements GroupConsumer {
newGroups: readonly GrDiffGroup[]
) {
if (!this.builder) return;
- this.fireDiffEvent('render-start');
+ fire(this.diffElement, 'render-start', {});
this.builder.replaceGroup(contextGroup, newGroups);
this.groups = this.groups.filter(g => g !== contextGroup);
this.groups.push(...newGroups);
this.untilGroupsRendered(newGroups).then(() => {
- this.fireDiffEvent('render-content');
+ fire(this.diffElement, 'render-content', {});
});
}
- cancel() {
- this.processor.cancel();
- this.builder?.clear();
- this.cancelableRenderPromise?.cancel();
- this.cancelableRenderPromise = null;
+ /**
+ * This is meant to be called when the gr-diff component re-connects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with cleanup(), which is called
+ * when gr-diff disconnects.
+ */
+ init() {
+ this.cleanup();
+ this.diffElement?.addEventListener(
+ 'diff-context-expanded',
+ this.onDiffContextExpanded
+ );
+ this.builder?.init();
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component disconnects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with init(), which is called when
+ * gr-diff re-connects.
+ */
+ cleanup() {
+ this.processor?.cancel();
+ this.builder?.cleanup();
this.diffElement?.removeEventListener(
'diff-context-expanded',
this.onDiffContextExpanded
@@ -391,7 +383,7 @@ export class GrDiffBuilderElement implements GroupConsumer {
}
// visible for testing
- getDiffBuilder(): DiffBuilder {
+ getDiffBuilder(): GrDiffBuilder {
assertIsDefined(this.diff, 'diff');
assertIsDefined(this.diffElement, 'diff table');
if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
@@ -421,10 +413,13 @@ export class GrDiffBuilderElement implements GroupConsumer {
this.useNewImageDiffUi
);
} else if (this.diff.binary) {
- // If the diff is binary, but not an image.
return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
} else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
- builder = new GrDiffBuilderSideBySide(
+ this.renderPrefs = {
+ ...this.renderPrefs,
+ view_mode: DiffViewMode.SIDE_BY_SIDE,
+ };
+ builder = new GrDiffBuilder(
this.diff,
localPrefs,
this.diffElement,
@@ -432,7 +427,11 @@ export class GrDiffBuilderElement implements GroupConsumer {
this.renderPrefs
);
} else if (this.viewMode === DiffViewMode.UNIFIED) {
- builder = new GrDiffBuilderUnified(
+ this.renderPrefs = {
+ ...this.renderPrefs,
+ view_mode: DiffViewMode.UNIFIED,
+ };
+ builder = new GrDiffBuilder(
this.diff,
localPrefs,
this.diffElement,
@@ -489,7 +488,7 @@ export class GrDiffBuilderElement implements GroupConsumer {
// If endIndex isn't present, continue to the end of the line.
const endIndex =
highlight.endIndex === undefined
- ? line.text.length
+ ? GrAnnotation.getStringLength(line.text)
: highlight.endIndex;
GrAnnotation.annotateElement(
@@ -571,6 +570,5 @@ export class GrDiffBuilderElement implements GroupConsumer {
updateRenderPrefs(renderPrefs: RenderPreferences) {
this.builder?.updateRenderPrefs(renderPrefs);
- this.processor.updateRenderPrefs(renderPrefs);
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index 2cfb89578a..da2e9f1f00 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -6,44 +6,38 @@
import '../../../test/common-test-setup';
import {
createConfig,
- createDiff,
createEmptyDiff,
} from '../../../test/test-data-generators';
import './gr-diff-builder-element';
-import {queryAndAssert, stubBaseUrl, waitUntil} from '../../../test/test-utils';
+import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
import {
DiffContent,
- DiffInfo,
DiffLayer,
DiffPreferencesInfo,
DiffViewMode,
Side,
} from '../../../api/diff';
import {stubRestApi} from '../../../test/test-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
import {waitForEventOnce} from '../../../utils/event-util';
import {GrDiffBuilderElement} from './gr-diff-builder-element';
import {createDefaultDiffPrefs} from '../../../constants/constants';
import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {BlameInfo} from '../../../types/common';
import {fixture, html, assert} from '@open-wc/testing';
-import {EventType} from '../../../types/events';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {querySelectorAll} from '../../../utils/dom-util';
const DEFAULT_PREFS = createDefaultDiffPrefs();
suite('gr-diff-builder tests', () => {
let element: GrDiffBuilderElement;
- let builder: GrDiffBuilderLegacy;
+ let builder: GrDiffBuilder;
let diffTable: HTMLTableElement;
- const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
- const WBR_HTML = '<wbr class="gr-diff">';
-
const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
- builder = new GrDiffBuilderSideBySide(
+ builder = new GrDiffBuilder(
createEmptyDiff(),
{...createDefaultDiffPrefs(), ...prefs},
diffTable
@@ -66,24 +60,6 @@ suite('gr-diff-builder tests', () => {
setBuilderPrefs({});
});
- test('line_length applied with <wbr> if line_wrapping is true', () => {
- setBuilderPrefs({line_wrapping: true, tab_size: 4, line_length: 50});
- const text = 'a'.repeat(51);
- const expected = 'a'.repeat(50) + WBR_HTML + 'a';
- const result = builder.createTextEl(null, line(text)).firstElementChild
- ?.innerHTML;
- assert.equal(result, expected);
- });
-
- test('line_length applied with line break if line_wrapping is false', () => {
- setBuilderPrefs({line_wrapping: false, tab_size: 4, line_length: 50});
- const text = 'a'.repeat(51);
- const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
- const result = builder.createTextEl(null, line(text)).firstElementChild
- ?.innerHTML;
- assert.equal(result, expected);
- });
-
[DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
test(`line_length used for regular files under ${mode}`, () => {
element.path = '/a.txt';
@@ -94,8 +70,8 @@ suite('gr-diff-builder tests', () => {
tab_size: 4,
line_length: 50,
};
- builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
- assert.equal(builder._prefs.line_length, 50);
+ builder = element.getDiffBuilder();
+ assert.equal(builder.prefs.line_length, 50);
});
test(`line_length ignored for commit msg under ${mode}`, () => {
@@ -107,26 +83,11 @@ suite('gr-diff-builder tests', () => {
tab_size: 4,
line_length: 50,
};
- builder = element.getDiffBuilder() as GrDiffBuilderLegacy;
- assert.equal(builder._prefs.line_length, 72);
+ builder = element.getDiffBuilder();
+ assert.equal(builder.prefs.line_length, 72);
});
});
- test('createTextEl linewrap with tabs', () => {
- setBuilderPrefs({tab_size: 4, line_length: 10});
- const text = '\t'.repeat(7) + '!';
- const el = builder.createTextEl(null, line(text));
- assert.equal(el.innerText, text);
- // With line length 10 and tab size 4, there should be a line break
- // after every two tabs.
- const newlineEl = el.querySelector('.contentText > .br');
- assert.isOk(newlineEl);
- assert.equal(
- el.querySelector('.contentText .tab:nth-child(2)')?.nextSibling,
- newlineEl
- );
- });
-
test('_handlePreferenceError throws with invalid preference', () => {
element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
assert.throws(() => element.getDiffBuilder());
@@ -134,7 +95,7 @@ suite('gr-diff-builder tests', () => {
test('_handlePreferenceError triggers alert and javascript error', () => {
const errorStub = sinon.stub();
- diffTable.addEventListener(EventType.SHOW_ALERT, errorStub);
+ diffTable.addEventListener('show-alert', errorStub);
assert.throws(() => element.handlePreferenceError('tab size'));
assert.equal(
errorStub.lastCall.args[0].detail.message,
@@ -271,10 +232,11 @@ suite('gr-diff-builder tests', () => {
const str0 = slice(str, 0, 6);
const str1 = slice(str, 6);
+ const numHighlightedChars = GrAnnotation.getStringLength(str1);
layer.annotate(el, lineNumberEl, l, Side.LEFT);
- assert.isTrue(annotateElementSpy.called);
+ assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
assert.equal(el.childNodes.length, 2);
assert.instanceOf(el.childNodes[0], Text);
@@ -509,15 +471,11 @@ suite('gr-diff-builder tests', () => {
});
suite('rendering text, images and binary files', () => {
- let processStub: sinon.SinonStub;
let keyLocations: KeyLocations;
let content: DiffContent[] = [];
setup(() => {
element.viewMode = 'SIDE_BY_SIDE';
- processStub = sinon
- .stub(element.processor, 'process')
- .returns(Promise.resolve());
keyLocations = {left: {}, right: {}};
element.prefs = {
...DEFAULT_PREFS,
@@ -542,8 +500,7 @@ suite('gr-diff-builder tests', () => {
element.diff = {...createEmptyDiff(), content};
element.render(keyLocations);
await waitForEventOnce(diffTable, 'render-content');
- assert.isTrue(processStub.calledOnce);
- assert.isFalse(processStub.lastCall.args[1]);
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
});
test('image', async () => {
@@ -551,105 +508,14 @@ suite('gr-diff-builder tests', () => {
element.isImageDiff = true;
element.render(keyLocations);
await waitForEventOnce(diffTable, 'render-content');
- assert.isTrue(processStub.calledOnce);
- assert.isTrue(processStub.lastCall.args[1]);
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
});
test('binary', async () => {
element.diff = {...createEmptyDiff(), content, binary: true};
element.render(keyLocations);
await waitForEventOnce(diffTable, 'render-content');
- assert.isTrue(processStub.calledOnce);
- assert.isTrue(processStub.lastCall.args[1]);
- });
- });
-
- suite('rendering', () => {
- let content: DiffContent[];
- let outputEl: HTMLTableElement;
- let keyLocations: KeyLocations;
- let addColumnsStub: sinon.SinonStub;
- let dispatchStub: sinon.SinonStub;
- let builder: GrDiffBuilderSideBySide;
-
- setup(() => {
- const prefs = {...DEFAULT_PREFS};
- content = [
- {
- a: ['all work and no play make andybons a dull boy'],
- b: ['elgoog elgoog elgoog'],
- },
- {
- ab: [
- 'Non eram nescius, Brute, cum, quae summis ingeniis ',
- 'exquisitaque doctrina philosophi Graeco sermone tractavissent',
- ],
- },
- ];
- dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
- outputEl = element.diffElement!;
- keyLocations = {left: {}, right: {}};
- sinon.stub(element, 'getDiffBuilder').callsFake(() => {
- builder = new GrDiffBuilderSideBySide(
- {...createEmptyDiff(), content},
- prefs,
- outputEl
- );
- addColumnsStub = sinon.stub(builder, 'addColumns');
- builder.buildSectionElement = function (group) {
- const section = document.createElement('stub');
- section.style.display = 'block';
- section.textContent = group.lines.reduce(
- (acc, line) => acc + line.text,
- ''
- );
- return section;
- };
- return builder;
- });
- element.diff = {...createEmptyDiff(), content};
- element.prefs = prefs;
- element.render(keyLocations);
- });
-
- test('addColumns is called', () => {
- assert.isTrue(addColumnsStub.called);
- });
-
- test('getGroupsByLineRange one line', () => {
- const section = outputEl.querySelector<HTMLElement>(
- 'stub:nth-of-type(3)'
- );
- const groups = builder.getGroupsByLineRange(1, 1, Side.LEFT);
- assert.equal(groups.length, 1);
- assert.strictEqual(groups[0].element, section);
- });
-
- test('getGroupsByLineRange over diff', () => {
- const section = [
- outputEl.querySelector<HTMLElement>('stub:nth-of-type(3)'),
- outputEl.querySelector<HTMLElement>('stub:nth-of-type(4)'),
- ];
- const groups = builder.getGroupsByLineRange(1, 2, Side.LEFT);
- assert.equal(groups.length, 2);
- assert.strictEqual(groups[0].element, section[0]);
- assert.strictEqual(groups[1].element, section[1]);
- });
-
- test('render-start and render-content are fired', async () => {
- await waitUntil(() => dispatchStub.callCount >= 1);
- let firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
- assert.include(firedEventTypes, 'render-start');
-
- await waitUntil(() => dispatchStub.callCount >= 2);
- firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
- assert.include(firedEventTypes, 'render-content');
- });
-
- test('cancel cancels the processor', () => {
- const processorCancelStub = sinon.stub(element.processor, 'cancel');
- element.cancel();
- assert.isTrue(processorCancelStub.called);
+ assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
});
});
@@ -691,7 +557,7 @@ suite('gr-diff-builder tests', () => {
assert.include(diffRows[4].textContent, 'unchanged 11');
});
- test('clicking +x common lines expands those lines', () => {
+ test('clicking +x common lines expands those lines', async () => {
const contextControls = diffTable.querySelectorAll('gr-context-controls');
const topExpandCommonButton =
contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
@@ -699,10 +565,19 @@ suite('gr-diff-builder tests', () => {
)[0];
assert.isOk(topExpandCommonButton);
assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+ let diffRows = diffTable.querySelectorAll('.diff-row');
+ // 5 lines:
+ // FILE, LOST, the changed line plus one line of context in each direction
+ assert.equal(diffRows.length, 5);
+
topExpandCommonButton!.click();
- const diffRows = diffTable.querySelectorAll('.diff-row');
- // The first two are LOST and FILE line
- assert.equal(diffRows.length, 2 + 10 + 1 + 1);
+
+ await waitUntil(() => {
+ diffRows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
+ return diffRows.length === 14;
+ });
+ // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+ assert.equal(diffRows.length, 14);
assert.include(diffRows[2].textContent, 'unchanged 1');
assert.include(diffRows[3].textContent, 'unchanged 2');
assert.include(diffRows[4].textContent, 'unchanged 3');
@@ -722,6 +597,11 @@ suite('gr-diff-builder tests', () => {
dispatchStub.reset();
element.unhideLine(4, Side.LEFT);
+ await waitUntil(() => {
+ const rows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
+ return rows.length === 2 + 5 + 1 + 1 + 1;
+ });
+
const diffRows = diffTable.querySelectorAll('.diff-row');
// The first two are LOST and FILE line
// Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
@@ -744,427 +624,4 @@ suite('gr-diff-builder tests', () => {
assert.include(firedEventTypes, 'render-content');
});
});
-
- [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
- suite(`mock-diff mode:${mode}`, () => {
- let builder: GrDiffBuilderSideBySide;
- let diff: DiffInfo;
- let keyLocations: KeyLocations;
-
- setup(() => {
- element.viewMode = mode;
- diff = createDiff();
- element.diff = diff;
-
- keyLocations = {left: {}, right: {}};
-
- element.prefs = {
- ...createDefaultDiffPrefs(),
- line_length: 80,
- show_tabs: true,
- tab_size: 4,
- };
- element.render(keyLocations);
- builder = element.builder as GrDiffBuilderSideBySide;
- });
-
- test('aria-labels on added line numbers', () => {
- const deltaLineNumberButton = diffTable.querySelectorAll(
- '.lineNumButton.right'
- )[5];
-
- assert.isOk(deltaLineNumberButton);
- assert.equal(
- deltaLineNumberButton.getAttribute('aria-label'),
- '5 added'
- );
- });
-
- test('aria-labels on removed line numbers', () => {
- const deltaLineNumberButton = diffTable.querySelectorAll(
- '.lineNumButton.left'
- )[10];
-
- assert.isOk(deltaLineNumberButton);
- assert.equal(
- deltaLineNumberButton.getAttribute('aria-label'),
- '10 removed'
- );
- });
-
- test('getContentByLine', () => {
- let actual: HTMLElement | null;
-
- actual = builder.getContentByLine(2, Side.LEFT);
- assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
-
- actual = builder.getContentByLine(2, Side.RIGHT);
- assert.equal(actual?.textContent, diff.content[0].ab?.[1]);
-
- actual = builder.getContentByLine(5, Side.LEFT);
- assert.equal(actual?.textContent, diff.content[2].ab?.[0]);
-
- actual = builder.getContentByLine(5, Side.RIGHT);
- assert.equal(actual?.textContent, diff.content[1].b?.[0]);
- });
-
- test('getContentTdByLineEl works both with button and td', () => {
- const diffRow = diffTable.querySelectorAll('tr.diff-row')[2];
-
- const lineNumTdLeft = queryAndAssert(diffRow, 'td.lineNum.left');
- const lineNumButtonLeft = queryAndAssert(lineNumTdLeft, 'button');
- const contentTdLeft = diffRow.querySelectorAll('.content')[0];
-
- const lineNumTdRight = queryAndAssert(diffRow, 'td.lineNum.right');
- const lineNumButtonRight = queryAndAssert(lineNumTdRight, 'button');
- const contentTdRight =
- mode === DiffViewMode.SIDE_BY_SIDE
- ? diffRow.querySelectorAll('.content')[1]
- : contentTdLeft;
-
- assert.equal(
- element.getContentTdByLineEl(lineNumTdLeft),
- contentTdLeft
- );
- assert.equal(
- element.getContentTdByLineEl(lineNumButtonLeft),
- contentTdLeft
- );
- assert.equal(
- element.getContentTdByLineEl(lineNumTdRight),
- contentTdRight
- );
- assert.equal(
- element.getContentTdByLineEl(lineNumButtonRight),
- contentTdRight
- );
- });
-
- test('findLinesByRange LEFT', () => {
- const lines: GrDiffLine[] = [];
- const elems: HTMLElement[] = [];
- const start = 1;
- const end = 44;
-
- // lines 26-29 are collapsed, so minus 4
- let count = end - start + 1 - 4;
- // Lines 14+15 are part of a 'common' chunk. And we have a bug in
- // unified diff that results in not rendering these lines for the LEFT
- // side. TODO: Fix that bug!
- if (mode === DiffViewMode.UNIFIED) count -= 2;
-
- builder.findLinesByRange(start, end, Side.LEFT, lines, elems);
-
- assert.equal(lines.length, count);
- assert.equal(elems.length, count);
-
- for (let i = 0; i < count; i++) {
- assert.instanceOf(lines[i], GrDiffLine);
- assert.instanceOf(elems[i], HTMLElement);
- assert.equal(lines[i].text, elems[i].textContent);
- }
- });
-
- test('findLinesByRange RIGHT', () => {
- const lines: GrDiffLine[] = [];
- const elems: HTMLElement[] = [];
- const start = 1;
- const end = 48;
-
- // lines 26-29 are collapsed, so minus 4
- const count = end - start + 1 - 4;
-
- builder.findLinesByRange(start, end, Side.RIGHT, lines, elems);
-
- assert.equal(lines.length, count);
- assert.equal(elems.length, count);
-
- for (let i = 0; i < count; i++) {
- assert.instanceOf(lines[i], GrDiffLine);
- assert.instanceOf(elems[i], HTMLElement);
- assert.equal(lines[i].text, elems[i].textContent);
- }
- });
-
- test('renderContentByRange', () => {
- const spy = sinon.spy(builder, 'createTextEl');
- const start = 9;
- const end = 14;
- let count = end - start + 1;
- // Lines 14+15 are part of a 'common' chunk. And we have a bug in
- // unified diff that results in not rendering these lines for the LEFT
- // side. TODO: Fix that bug!
- if (mode === DiffViewMode.UNIFIED) count -= 1;
-
- builder.renderContentByRange(start, end, Side.LEFT);
-
- assert.equal(spy.callCount, count);
- spy.getCalls().forEach((call, i: number) => {
- assert.equal(call.args[1].beforeNumber, start + i);
- });
- });
-
- test('renderContentByRange non-existent elements', () => {
- const spy = sinon.spy(builder, 'createTextEl');
-
- sinon
- .stub(builder, 'getLineNumberEl')
- .returns(document.createElement('div'));
- sinon
- .stub(builder, 'findLinesByRange')
- .callsFake((_1, _2, _3, lines, elements) => {
- // Add a line and a corresponding element.
- lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
- const tr = document.createElement('tr');
- const td = document.createElement('td');
- const el = document.createElement('div');
- tr.appendChild(td);
- td.appendChild(el);
- elements?.push(el);
-
- // Add 2 lines without corresponding elements.
- lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
- lines?.push(new GrDiffLine(GrDiffLineType.BOTH));
- });
-
- builder.renderContentByRange(1, 10, Side.LEFT);
- // Should be called only once because only one line had a corresponding
- // element.
- assert.equal(spy.callCount, 1);
- });
-
- test('getLineNumberEl side-by-side left', () => {
- const contentEl = builder.getContentByLine(
- 5,
- Side.LEFT,
- element.diffElement as HTMLTableElement
- );
- assert.isOk(contentEl);
- const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
- assert.isOk(lineNumberEl);
- assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
- assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
- });
-
- test('getLineNumberEl side-by-side right', () => {
- const contentEl = builder.getContentByLine(
- 5,
- Side.RIGHT,
- element.diffElement as HTMLTableElement
- );
- assert.isOk(contentEl);
- const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
- assert.isOk(lineNumberEl);
- assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
- assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
- });
-
- test('getLineNumberEl unified left', async () => {
- // Re-render as unified:
- element.viewMode = 'UNIFIED_DIFF';
- element.render(keyLocations);
- builder = element.builder as GrDiffBuilderSideBySide;
-
- const contentEl = builder.getContentByLine(
- 5,
- Side.LEFT,
- element.diffElement as HTMLTableElement
- );
- assert.isOk(contentEl);
- const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.LEFT);
- assert.isOk(lineNumberEl);
- assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
- assert.isTrue(lineNumberEl!.classList.contains(Side.LEFT));
- });
-
- test('getLineNumberEl unified right', async () => {
- // Re-render as unified:
- element.viewMode = 'UNIFIED_DIFF';
- element.render(keyLocations);
- builder = element.builder as GrDiffBuilderSideBySide;
-
- const contentEl = builder.getContentByLine(
- 5,
- Side.RIGHT,
- element.diffElement as HTMLTableElement
- );
- assert.isOk(contentEl);
- const lineNumberEl = builder.getLineNumberEl(contentEl!, Side.RIGHT);
- assert.isOk(lineNumberEl);
- assert.isTrue(lineNumberEl!.classList.contains('lineNum'));
- assert.isTrue(lineNumberEl!.classList.contains(Side.RIGHT));
- });
-
- test('getNextContentOnSide side-by-side left', () => {
- const startElem = builder.getContentByLine(
- 5,
- Side.LEFT,
- element.diffElement as HTMLTableElement
- );
- assert.isOk(startElem);
- const expectedStartString = diff.content[2].ab?.[0];
- const expectedNextString = diff.content[2].ab?.[1];
- assert.equal(startElem!.textContent, expectedStartString);
-
- const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
- assert.isOk(nextElem);
- assert.equal(nextElem!.textContent, expectedNextString);
- });
-
- test('getNextContentOnSide side-by-side right', () => {
- const startElem = builder.getContentByLine(
- 5,
- Side.RIGHT,
- element.diffElement as HTMLTableElement
- );
- const expectedStartString = diff.content[1].b?.[0];
- const expectedNextString = diff.content[1].b?.[1];
- assert.isOk(startElem);
- assert.equal(startElem!.textContent, expectedStartString);
-
- const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
- assert.isOk(nextElem);
- assert.equal(nextElem!.textContent, expectedNextString);
- });
-
- test('getNextContentOnSide unified left', async () => {
- // Re-render as unified:
- element.viewMode = 'UNIFIED_DIFF';
- element.render(keyLocations);
- builder = element.builder as GrDiffBuilderSideBySide;
-
- const startElem = builder.getContentByLine(
- 5,
- Side.LEFT,
- element.diffElement as HTMLTableElement
- );
- const expectedStartString = diff.content[2].ab?.[0];
- const expectedNextString = diff.content[2].ab?.[1];
- assert.isOk(startElem);
- assert.equal(startElem!.textContent, expectedStartString);
-
- const nextElem = builder.getNextContentOnSide(startElem!, Side.LEFT);
- assert.isOk(nextElem);
- assert.equal(nextElem!.textContent, expectedNextString);
- });
-
- test('getNextContentOnSide unified right', async () => {
- // Re-render as unified:
- element.viewMode = 'UNIFIED_DIFF';
- element.render(keyLocations);
- builder = element.builder as GrDiffBuilderSideBySide;
-
- const startElem = builder.getContentByLine(
- 5,
- Side.RIGHT,
- element.diffElement as HTMLTableElement
- );
- const expectedStartString = diff.content[1].b?.[0];
- const expectedNextString = diff.content[1].b?.[1];
- assert.isOk(startElem);
- assert.equal(startElem!.textContent, expectedStartString);
-
- const nextElem = builder.getNextContentOnSide(startElem!, Side.RIGHT);
- assert.isOk(nextElem);
- assert.equal(nextElem!.textContent, expectedNextString);
- });
- });
- });
-
- suite('blame', () => {
- let mockBlame: BlameInfo[];
-
- setup(() => {
- mockBlame = [
- {
- author: 'test-author',
- time: 314,
- commit_msg: 'test-commit-message',
- id: 'commit 1',
- ranges: [
- {start: 1, end: 2},
- {start: 10, end: 16},
- ],
- },
- {
- author: 'test-author',
- time: 314,
- commit_msg: 'test-commit-message',
- id: 'commit 2',
- ranges: [
- {start: 4, end: 10},
- {start: 17, end: 32},
- ],
- },
- ];
- });
-
- test('setBlame attempts to render each blamed line', () => {
- const getBlameStub = sinon
- .stub(builder, 'getBlameTdByLine')
- .returns(undefined);
- builder.setBlame(mockBlame);
- assert.equal(getBlameStub.callCount, 32);
- });
-
- test('getBlameCommitForBaseLine', () => {
- sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
- builder.setBlame(mockBlame);
- assert.isOk(builder.getBlameCommitForBaseLine(1));
- assert.equal(builder.getBlameCommitForBaseLine(1)?.id, 'commit 1');
-
- assert.isOk(builder.getBlameCommitForBaseLine(11));
- assert.equal(builder.getBlameCommitForBaseLine(11)?.id, 'commit 1');
-
- assert.isOk(builder.getBlameCommitForBaseLine(32));
- assert.equal(builder.getBlameCommitForBaseLine(32)?.id, 'commit 2');
-
- assert.isUndefined(builder.getBlameCommitForBaseLine(33));
- });
-
- test('getBlameCommitForBaseLine w/o blame returns null', () => {
- assert.isUndefined(builder.getBlameCommitForBaseLine(1));
- assert.isUndefined(builder.getBlameCommitForBaseLine(11));
- assert.isUndefined(builder.getBlameCommitForBaseLine(31));
- });
-
- test('createBlameCell', () => {
- const mockBlameInfo = {
- time: 1576155200,
- id: '1234567890',
- author: 'Clark Kent',
- commit_msg: 'Testing Commit',
- ranges: [{start: 4, end: 10}],
- };
- const getBlameStub = sinon
- .stub(builder, 'getBlameCommitForBaseLine')
- .returns(mockBlameInfo);
- const line = new GrDiffLine(GrDiffLineType.BOTH);
- line.beforeNumber = 3;
- line.afterNumber = 5;
-
- const result = builder.createBlameCell(line.beforeNumber);
-
- assert.isTrue(getBlameStub.calledWithExactly(3));
- assert.equal(result.getAttribute('data-line-number'), '3');
- assert.dom.equal(
- result,
- /* HTML */ `
- <span class="gr-diff">
- <a class="blameDate gr-diff" href="/r/q/1234567890"> 12/12/2019 </a>
- <span class="blameAuthor gr-diff">Clark</span>
- <gr-hovercard class="gr-diff">
- <span class="blameHoverCard gr-diff">
- Commit 1234567890<br />
- Author: Clark Kent<br />
- Date: 12/12/2019<br />
- <br />
- Testing Commit
- </span>
- </gr-hovercard>
- </span>
- `
- );
- });
- });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 3cdd1f9a31..1f7ffd38f8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -3,228 +3,270 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
import {ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrEndpointParam} from '../../../elements/plugins/gr-endpoint-param/gr-endpoint-param';
-import {RenderPreferences} from '../../../api/diff';
+import {RenderPreferences, Side} from '../../../api/diff';
import '../gr-diff-image-viewer/gr-image-viewer';
-import {GrImageViewer} from '../gr-diff-image-viewer/gr-image-viewer';
+import {html, LitElement, nothing} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators.js';
+import {GrDiffBuilder} from './gr-diff-builder';
import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
// MIME types for images we allow showing. Do not include SVG, it can contain
// arbitrary JavaScript.
const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
-export class GrDiffBuilderImage extends GrDiffBuilderSideBySide {
+export class GrDiffBuilderImage extends GrDiffBuilder {
constructor(
diff: DiffInfo,
prefs: DiffPreferencesInfo,
outputEl: HTMLElement,
- private readonly _baseImage: ImageInfo | null,
- private readonly _revisionImage: ImageInfo | null,
+ private readonly baseImage: ImageInfo | null,
+ private readonly revisionImage: ImageInfo | null,
renderPrefs?: RenderPreferences,
- private readonly _useNewImageDiffUi: boolean = false
+ private readonly useNewImageDiffUi: boolean = false
) {
super(diff, prefs, outputEl, [], renderPrefs);
}
- public renderDiff() {
- const section = createElementDiff('tbody', 'image-diff');
-
- if (this._useNewImageDiffUi) {
- this._emitImageViewer(section);
+ override buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const section = createElementDiff('tbody');
+ // Do not create a diff row for 'LOST'.
+ if (group.lines[0].beforeNumber !== 'FILE') return section;
+ return super.buildSectionElement(group);
+ }
- this.outputEl.appendChild(section);
- } else {
- this._emitImagePair(section);
- this._emitImageLabels(section);
+ public renderImageDiff() {
+ const imageDiff = this.useNewImageDiffUi
+ ? this.createImageDiffNew()
+ : this.createImageDiffOld();
+ this.outputEl.appendChild(imageDiff);
+ }
- this.outputEl.appendChild(section);
- this.outputEl.appendChild(this._createEndpoint());
- }
+ private createImageDiffNew() {
+ const imageDiff = document.createElement('gr-diff-image-new');
+ imageDiff.automaticBlink = this.autoBlink();
+ imageDiff.baseImage = this.baseImage ?? undefined;
+ imageDiff.revisionImage = this.revisionImage ?? undefined;
+ return imageDiff;
}
- private _createEndpoint() {
- const tbody = createElementDiff('tbody');
- const tr = createElementDiff('tr');
- const td = createElementDiff('td');
+ private createImageDiffOld() {
+ const imageDiff = document.createElement('gr-diff-image-old');
+ imageDiff.baseImage = this.baseImage ?? undefined;
+ imageDiff.revisionImage = this.revisionImage ?? undefined;
+ return imageDiff;
+ }
- // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
- // column limit.
- td.setAttribute('colspan', '4');
- const endpointDomApi = createElementDiff('gr-endpoint-decorator');
- endpointDomApi.setAttribute('name', 'image-diff');
- endpointDomApi.appendChild(
- this._createEndpointParam('baseImage', this._baseImage)
- );
- endpointDomApi.appendChild(
- this._createEndpointParam('revisionImage', this._revisionImage)
- );
- td.appendChild(endpointDomApi);
- tr.appendChild(td);
- tbody.appendChild(tr);
- return tbody;
+ private autoBlink(): boolean {
+ return !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
}
- private _createEndpointParam(name: string, value: ImageInfo | null) {
- const endpointParam = createElementDiff(
- 'gr-endpoint-param'
- ) as GrEndpointParam;
- endpointParam.name = name;
- endpointParam.value = value;
- return endpointParam;
+ override updateRenderPrefs(renderPrefs: RenderPreferences) {
+ this.renderPrefs = renderPrefs;
+
+ // We have to update `imageDiff.automaticBlink` manually, because `this` is
+ // not a LitElement.
+ const imageDiff = this.outputEl.querySelector(
+ 'gr-diff-image-new'
+ ) as GrDiffImageNew;
+ if (imageDiff) imageDiff.automaticBlink = this.autoBlink();
}
+}
+
+@customElement('gr-diff-image-new')
+class GrDiffImageNew extends LitElement {
+ @property() baseImage?: ImageInfo;
- private _emitImageViewer(section: HTMLElement) {
- const tr = createElementDiff('tr');
- const td = createElementDiff('td');
- // TODO(hermannloose): Support blame for image diffs, see above.
- td.setAttribute('colspan', '4');
- const imageViewer = createElementDiff('gr-image-viewer') as GrImageViewer;
+ @property() revisionImage?: ImageInfo;
- imageViewer.baseUrl = this._getImageSrc(this._baseImage);
- imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
- imageViewer.automaticBlink =
- !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
+ @property() automaticBlink = false;
- td.appendChild(imageViewer);
- tr.appendChild(td);
- section.appendChild(tr);
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
}
- private _getImageSrc(image: ImageInfo | null): string {
- return image && IMAGE_MIME_PATTERN.test(image.type)
- ? `data:${image.type};base64,${image.body}`
- : '';
+ override render() {
+ return html`
+ <tbody class="gr-diff image-diff">
+ <tr class="gr-diff">
+ <td class="gr-diff" colspan="4">
+ <gr-image-viewer
+ class="gr-diff"
+ .baseUrl=${imageSrc(this.baseImage)}
+ .revisionUrl=${imageSrc(this.revisionImage)}
+ .automaticBlink=${this.automaticBlink}
+ >
+ </gr-image-viewer>
+ </td>
+ </tr>
+ </tbody>
+ `;
}
+}
- private _emitImagePair(section: HTMLElement) {
- const tr = createElementDiff('tr');
+@customElement('gr-diff-image-old')
+class GrDiffImageOld extends LitElement {
+ @property() baseImage?: ImageInfo;
- tr.appendChild(createElementDiff('td', 'left lineNum blank'));
- tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
+ @property() revisionImage?: ImageInfo;
- tr.appendChild(createElementDiff('td', 'right lineNum blank'));
- tr.appendChild(
- this._createImageCell(this._revisionImage, 'right', section)
- );
+ @query('img.left') baseImageEl?: HTMLImageElement;
- section.appendChild(tr);
- }
+ @query('img.right') revisionImageEl?: HTMLImageElement;
- private _createImageCell(
- image: ImageInfo | null,
- className: string,
- section: HTMLElement
- ) {
- const td = createElementDiff('td', className);
- const src = this._getImageSrc(image);
- if (image && src) {
- const imageEl = createElementDiff('img') as HTMLImageElement;
- imageEl.onload = () => {
- image._height = imageEl.naturalHeight;
- image._width = imageEl.naturalWidth;
- this._updateImageLabel(section, className, image);
- };
- imageEl.addEventListener('error', (e: Event) => {
- imageEl.remove();
- td.textContent = '[Image failed to load] ' + e.type;
- });
- imageEl.setAttribute('src', src);
- td.appendChild(imageEl);
- }
- return td;
- }
+ @state() baseError?: string;
- private _updateImageLabel(
- section: HTMLElement,
- className: string,
- image: ImageInfo
- ) {
- const label = section.querySelector(
- '.' + className + ' span.label'
- ) as HTMLElement;
- this._setLabelText(label, image);
- }
+ @state() revisionError?: string;
- private _setLabelText(label: HTMLElement, image: ImageInfo | null) {
- label.textContent = _getImageLabel(image);
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
}
- private _emitImageLabels(section: HTMLElement) {
- const tr = createElementDiff('tr');
-
- let addNamesInLabel = false;
-
- if (
- this._baseImage &&
- this._revisionImage &&
- this._baseImage._name !== this._revisionImage._name
- ) {
- addNamesInLabel = true;
- }
+ override render() {
+ return html`
+ <tbody class="gr-diff image-diff">
+ ${this.renderImagePairRow()} ${this.renderImageLabelRow()}
+ </tbody>
+ ${this.renderEndpoint()}
+ `;
+ }
- tr.appendChild(createElementDiff('td', 'left lineNum blank'));
- let td = createElementDiff('td', 'left');
- let label = createElementDiff('label');
- let nameSpan;
- let labelSpan = createElementDiff('span', 'label');
-
- if (addNamesInLabel) {
- nameSpan = createElementDiff('span', 'name');
- nameSpan.textContent = this._baseImage?._name ?? '';
- label.appendChild(nameSpan);
- label.appendChild(createElementDiff('br'));
- }
+ private renderEndpoint() {
+ return html`
+ <tbody class="gr-diff endpoint">
+ <tr class="gr-diff">
+ <td class="gr-diff" colspan="4">
+ <gr-endpoint-decorator class="gr-diff" name="image-diff">
+ ${this.renderEndpointParam('baseImage', this.baseImage)}
+ ${this.renderEndpointParam('revisionImage', this.revisionImage)}
+ </gr-endpoint-decorator>
+ </td>
+ </tr>
+ </tbody>
+ `;
+ }
- this._setLabelText(labelSpan, this._baseImage);
+ private renderEndpointParam(name: string, value: unknown) {
+ if (!value) return nothing;
+ return html`
+ <gr-endpoint-param class="gr-diff" name=${name} .value=${value}>
+ </gr-endpoint-param>
+ `;
+ }
- label.appendChild(labelSpan);
- td.appendChild(label);
- tr.appendChild(td);
+ private renderImagePairRow() {
+ return html`
+ <tr class="gr-diff">
+ <td class="gr-diff left lineNum blank"></td>
+ <td class="gr-diff left">${this.renderImage(Side.LEFT)}</td>
+ <td class="gr-diff right lineNum blank"></td>
+ <td class="gr-diff right">${this.renderImage(Side.RIGHT)}</td>
+ </tr>
+ `;
+ }
- tr.appendChild(createElementDiff('td', 'right lineNum blank'));
- td = createElementDiff('td', 'right');
- label = createElementDiff('label');
- labelSpan = createElementDiff('span', 'label');
+ private renderImage(side: Side) {
+ const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+ if (!image) return nothing;
+ const error = side === Side.LEFT ? this.baseError : this.revisionError;
+ if (error) return error;
+ const src = imageSrc(image);
+ if (!src) return nothing;
+
+ return html`
+ <img
+ class="gr-diff ${side}"
+ src=${src}
+ @load=${this.handleLoad}
+ @error=${(e: Event) => this.handleError(e, side)}
+ >
+ </img>
+ `;
+ }
- if (addNamesInLabel) {
- nameSpan = createElementDiff('span', 'name');
- nameSpan.textContent = this._revisionImage?._name ?? '';
- label.appendChild(nameSpan);
- label.appendChild(createElementDiff('br'));
- }
+ private handleLoad() {
+ this.requestUpdate();
+ }
- this._setLabelText(labelSpan, this._revisionImage);
+ private handleError(e: Event, side: Side) {
+ const msg = `[Image failed to load] ${e.type}`;
+ if (side === Side.LEFT) this.baseError = msg;
+ if (side === Side.RIGHT) this.revisionError = msg;
+ }
- label.appendChild(labelSpan);
- td.appendChild(label);
- tr.appendChild(td);
+ private renderImageLabelRow() {
+ return html`
+ <tr class="gr-diff">
+ <td class="gr-diff left lineNum blank"></td>
+ <td class="gr-diff left">
+ <label class="gr-diff">
+ ${this.renderName(this.baseImage?._name ?? '')}
+ <span class="gr-diff label">${this.imageLabel(Side.LEFT)}</span>
+ </label>
+ </td>
+ <td class="gr-diff right lineNum blank"></td>
+ <td class="gr-diff right">
+ <label class="gr-diff">
+ ${this.renderName(this.revisionImage?._name ?? '')}
+ <span class="gr-diff label"> ${this.imageLabel(Side.RIGHT)} </span>
+ </label>
+ </td>
+ </tr>
+ `;
+ }
- section.appendChild(tr);
+ private renderName(name?: string) {
+ const addNamesInLabel =
+ this.baseImage &&
+ this.revisionImage &&
+ this.baseImage._name !== this.revisionImage._name;
+ if (!addNamesInLabel) return nothing;
+ return html`
+ <span class="gr-diff name">${name}</span><br class="gr-diff" />
+ `;
}
- override updateRenderPrefs(renderPrefs: RenderPreferences) {
- const imageViewer = this.outputEl.querySelector(
- 'gr-image-viewer'
- ) as GrImageViewer;
- if (this._useNewImageDiffUi && imageViewer) {
- imageViewer.automaticBlink =
- !!renderPrefs?.image_diff_prefs?.automatic_blink;
+ private imageLabel(side: Side) {
+ const image = side === Side.LEFT ? this.baseImage : this.revisionImage;
+ const imageEl =
+ side === Side.LEFT ? this.baseImageEl : this.revisionImageEl;
+ if (image) {
+ const type = image.type ?? image._expectedType;
+ if (imageEl?.naturalWidth && imageEl.naturalHeight) {
+ return `${imageEl?.naturalWidth}×${imageEl.naturalHeight} ${type}`;
+ } else {
+ return type;
+ }
}
+ return 'No image';
}
}
-function _getImageLabel(image: ImageInfo | null) {
- if (image) {
- const type = image.type ?? image._expectedType;
- if (image._width && image._height) {
- return `${image._width}×${image._height} ${type}`;
- } else {
- return type;
- }
+function imageSrc(image?: ImageInfo): string {
+ return image && IMAGE_MIME_PATTERN.test(image.type)
+ ? `data:${image.type};base64,${image.body}`
+ : '';
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-image-new': GrDiffImageNew;
+ 'gr-diff-image-old': GrDiffImageOld;
}
- return 'No image';
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
deleted file mode 100644
index 8176e147fb..0000000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
+++ /dev/null
@@ -1,503 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
- MovedLinkClickedEventDetail,
- RenderPreferences,
-} from '../../../api/diff';
-import {fire} from '../../../utils/event-util';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import '../gr-context-controls/gr-context-controls';
-import {
- GrContextControls,
- GrContextControlsShowConfig,
-} from '../gr-context-controls/gr-context-controls';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {
- createBlameElement,
- createElementDiff,
- createElementDiffWithText,
- formatText,
- getResponsiveMode,
-} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {BlameInfo} from '../../../types/common';
-
-function lineTdSelector(lineNumber: LineNumber, side?: Side): string {
- const sideSelector = side ? `.${side}` : '';
- return `td.lineNum[data-value="${lineNumber}"]${sideSelector}`;
-}
-/**
- * Base class for builders that are creating the DOM elements programmatically
- * by calling `document.createElement()` and such. We are calling such builders
- * "legacy", because we want to create (Lit) component based diff elements.
- *
- * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
- */
-export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
- constructor(
- diff: DiffInfo,
- prefs: DiffPreferencesInfo,
- outputEl: HTMLElement,
- layers: DiffLayer[] = [],
- renderPrefs?: RenderPreferences
- ) {
- super(diff, prefs, outputEl, layers, renderPrefs);
- }
-
- override getContentTdByLine(
- lineNumber: LineNumber,
- side?: Side,
- root: Element = this.outputEl
- ): HTMLTableCellElement | null {
- return root.querySelector<HTMLTableCellElement>(
- `${lineTdSelector(lineNumber, side)} ~ td.content`
- );
- }
-
- override getLineElByNumber(
- lineNumber: LineNumber,
- side?: Side
- ): HTMLTableCellElement | null {
- return this.outputEl.querySelector<HTMLTableCellElement>(
- lineTdSelector(lineNumber, side)
- );
- }
-
- override getLineNumberRows() {
- return Array.from(
- this.outputEl.querySelectorAll<HTMLTableRowElement>(
- ':not(.contextControl) > .diff-row'
- ) ?? []
- ).filter(tr => tr.querySelector('button'));
- }
-
- override getLineNumEls(side: Side): HTMLTableCellElement[] {
- return Array.from(
- this.outputEl.querySelectorAll<HTMLTableCellElement>(
- `td.lineNum.${side}`
- ) ?? []
- );
- }
-
- override getBlameTdByLine(lineNum: number): Element | undefined {
- return (
- this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
- undefined
- );
- }
-
- override getContentByLine(
- lineNumber: LineNumber,
- side?: Side,
- root?: HTMLElement
- ): HTMLElement | null {
- const td = this.getContentTdByLine(lineNumber, side, root);
- return td ? td.querySelector('.contentText') : null;
- }
-
- override renderContentByRange(
- start: LineNumber,
- end: LineNumber,
- side: Side
- ) {
- const lines: GrDiffLine[] = [];
- const elements: HTMLElement[] = [];
- let line;
- let el;
- this.findLinesByRange(start, end, side, lines, elements);
- for (let i = 0; i < lines.length; i++) {
- line = lines[i];
- el = elements[i];
- if (!el || !el.parentElement) {
- // Cannot re-render an element if it does not exist. This can happen
- // if lines are collapsed and not visible on the page yet.
- continue;
- }
- const lineNumberEl = this.getLineNumberEl(el, side);
- const newContent = this.createTextEl(lineNumberEl, line, side)
- .firstChild as HTMLElement;
- // Note that ${el.id} ${newContent.id} might actually mismatch: In unified
- // diff we are rendering the same content twice for all the diff chunk
- // that are unchanged from left to right. TODO: Be smarter about this.
- el.parentElement.replaceChild(newContent, el);
- }
- }
-
- override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
- for (let i = start; i <= end; i++) {
- // TODO(wyatta): this query is expensive, but, when traversing a
- // range, the lines are consecutive, and given the previous blame
- // cell, the next one can be reached cheaply.
- const blameCell = this.getBlameTdByLine(i);
- if (!blameCell) continue;
-
- // Remove the element's children (if any).
- while (blameCell.hasChildNodes()) {
- blameCell.removeChild(blameCell.lastChild!);
- }
- const blameEl = createBlameElement(i, blame);
- if (blameEl) blameCell.appendChild(blameEl);
- }
- }
-
- /**
- * Finds the line number element given the content element by walking up the
- * DOM tree to the diff row and then querying for a .lineNum element on the
- * requested side.
- *
- * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
- */
- // visible for testing
- getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
- let row: HTMLElement | null = content;
- while (row && !row.classList.contains('diff-row')) row = row.parentElement;
- return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
- }
-
- /**
- * Adds <tr> table rows to a <tbody> section for allowing the user to expand
- * collapsed of lines. Called by subclasses.
- */
- protected createContextControls(
- section: HTMLElement,
- group: GrDiffGroup,
- viewMode: DiffViewMode
- ) {
- const leftStart = group.lineRange.left.start_line;
- const leftEnd = group.lineRange.left.end_line;
- const firstGroupIsSkipped = !!group.contextGroups[0].skip;
- const lastGroupIsSkipped =
- !!group.contextGroups[group.contextGroups.length - 1].skip;
-
- const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
- const showAbove =
- (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
- const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;
-
- if (showAbove) {
- const paddingRow = this.createContextControlPaddingRow(viewMode);
- paddingRow.classList.add('above');
- section.appendChild(paddingRow);
- }
- section.appendChild(
- this.createContextControlRow(group, showAbove, showBelow, viewMode)
- );
- if (showBelow) {
- const paddingRow = this.createContextControlPaddingRow(viewMode);
- paddingRow.classList.add('below');
- section.appendChild(paddingRow);
- }
- }
-
- /**
- * Creates a context control <tr> table row for with buttons the allow the
- * user to expand collapsed lines. Buttons extend from the gap created by this
- * method up or down into the area of code that they affect.
- */
- private createContextControlRow(
- group: GrDiffGroup,
- showAbove: boolean,
- showBelow: boolean,
- viewMode: DiffViewMode
- ): HTMLElement {
- const row = createElementDiff('tr', 'dividerRow');
- let showConfig: GrContextControlsShowConfig;
- if (showAbove && !showBelow) {
- showConfig = 'above';
- } else if (!showAbove && showBelow) {
- showConfig = 'below';
- } else {
- // Note that !showAbove && !showBelow also intentionally creates
- // "show-both". This means the file is completely collapsed, which is
- // unusual, but at least happens in one test.
- showConfig = 'both';
- }
- row.classList.add(`show-${showConfig}`);
-
- row.appendChild(this.createBlameCell(0));
- if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
- row.appendChild(createElementDiff('td'));
- }
-
- const cell = createElementDiff('td', 'dividerCell');
- const colspan = this.renderPrefs?.show_sign_col ? '5' : '3';
- cell.setAttribute('colspan', colspan);
- row.appendChild(cell);
-
- const contextControls = createElementDiff(
- 'gr-context-controls'
- ) as GrContextControls;
- contextControls.diff = this._diff;
- contextControls.renderPreferences = this.renderPrefs;
- contextControls.group = group;
- contextControls.showConfig = showConfig;
- cell.appendChild(contextControls);
- return row;
- }
-
- /**
- * Creates a table row to serve as padding between code and context controls.
- * Blame column, line gutters, and content area will continue visually, but
- * context controls can render over this background to map more clearly to
- * the area of code they expand.
- */
- private createContextControlPaddingRow(viewMode: DiffViewMode) {
- const row = createElementDiff('tr', 'contextBackground');
-
- if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
- row.classList.add('side-by-side');
- row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
- row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
- } else {
- row.classList.add('unified');
- }
-
- row.appendChild(this.createBlameCell(0));
- row.appendChild(createElementDiff('td', 'contextLineNum'));
- if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
- row.appendChild(createElementDiff('td', 'sign'));
- row.appendChild(createElementDiff('td'));
- }
- row.appendChild(createElementDiff('td', 'contextLineNum'));
- if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
- row.appendChild(createElementDiff('td', 'sign'));
- }
- row.appendChild(createElementDiff('td'));
-
- return row;
- }
-
- protected createLineEl(
- line: GrDiffLine,
- number: LineNumber,
- type: GrDiffLineType,
- side: Side
- ) {
- const td = createElementDiff('td');
- td.classList.add(side);
- if (line.type === GrDiffLineType.BLANK) {
- td.classList.add('blankLineNum');
- return td;
- }
- if (line.type === GrDiffLineType.BOTH || line.type === type) {
- td.classList.add('lineNum');
- td.dataset['value'] = number.toString();
-
- if (
- ((this._prefs.show_file_comment_button === false ||
- this.renderPrefs?.show_file_comment_button === false) &&
- number === 'FILE') ||
- number === 'LOST'
- ) {
- return td;
- }
-
- const button = createElementDiff('button');
- td.appendChild(button);
- button.tabIndex = -1;
- button.classList.add('lineNumButton');
- button.classList.add(side);
- button.dataset['value'] = number.toString();
- button.id =
- side === Side.LEFT ? `left-button-${number}` : `right-button-${number}`;
- button.textContent = number === 'FILE' ? 'File' : number.toString();
- if (number === 'FILE') {
- button.setAttribute('aria-label', 'Add file comment');
- }
-
- // Add aria-labels for valid line numbers.
- // For unified diff, this method will be called with number set to 0 for
- // the empty line number column for added/removed lines. This should not
- // be announced to the screenreader.
- if (number > 0) {
- if (line.type === GrDiffLineType.REMOVE) {
- button.setAttribute('aria-label', `${number} removed`);
- } else if (line.type === GrDiffLineType.ADD) {
- button.setAttribute('aria-label', `${number} added`);
- } else {
- button.setAttribute('aria-label', `${number} unmodified`);
- }
- }
- this.addLineNumberMouseEvents(td, number, side);
- }
- return td;
- }
-
- private addLineNumberMouseEvents(
- el: HTMLElement,
- number: LineNumber,
- side: Side
- ) {
- el.addEventListener('mouseenter', () => {
- fire(el, 'line-mouse-enter', {lineNum: number, side});
- });
- el.addEventListener('mouseleave', () => {
- fire(el, 'line-mouse-leave', {lineNum: number, side});
- });
- }
-
- // visible for testing
- createTextEl(
- lineNumberEl: HTMLElement | null,
- line: GrDiffLine,
- side?: Side
- ) {
- const td = createElementDiff('td');
- if (line.type !== GrDiffLineType.BLANK) {
- td.classList.add('content');
- }
- if (side) {
- td.classList.add(side);
- }
-
- // If intraline info is not available, the entire line will be
- // considered as changed and marked as dark red / green color
- if (!line.hasIntralineInfo) {
- td.classList.add('no-intraline-info');
- }
- td.classList.add(line.type);
-
- const {beforeNumber, afterNumber} = line;
- if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
- const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
- const contentText = formatText(
- line.text,
- responsiveMode,
- this._prefs.tab_size,
- this._prefs.line_length,
- side === Side.LEFT
- ? `left-content-${beforeNumber}`
- : `right-content-${afterNumber}`
- );
-
- if (side) {
- contentText.setAttribute('data-side', side);
- const number = side === Side.LEFT ? beforeNumber : afterNumber;
- this.addLineNumberMouseEvents(td, number, side);
- }
-
- if (lineNumberEl && side) {
- for (const layer of this.layers) {
- if (typeof layer.annotate === 'function') {
- layer.annotate(contentText, lineNumberEl, line, side);
- }
- }
- } else {
- console.error('lineNumberEl or side not set, skipping layer.annotate');
- }
-
- td.appendChild(contentText);
- } else if (line.beforeNumber === 'FILE') td.classList.add('file');
- else if (line.beforeNumber === 'LOST') td.classList.add('lost');
-
- return td;
- }
-
- private createMovedLineAnchor(line: number, side: Side) {
- const anchor = createElementDiffWithText('a', `${line}`);
-
- // href is not actually used but important for Screen Readers
- anchor.setAttribute('href', `#${line}`);
- anchor.addEventListener('click', e => {
- e.preventDefault();
- anchor.dispatchEvent(
- new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
- detail: {
- lineNum: line,
- side,
- },
- composed: true,
- bubbles: true,
- })
- );
- });
- return anchor;
- }
-
- private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
- const div = createElementDiff('div');
- if (group.moveDetails?.range) {
- const {changed, range} = group.moveDetails;
- const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
- const andChangedLabel = changed ? 'and changed ' : '';
- const direction = movedIn ? 'from' : 'to';
- const textLabel = `Moved ${andChangedLabel}${direction} lines `;
- div.appendChild(createElementDiffWithText('span', textLabel));
- div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
- div.appendChild(createElementDiffWithText('span', ' - '));
- div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
- } else {
- div.appendChild(
- createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
- );
- }
- return div;
- }
-
- protected buildMoveControls(group: GrDiffGroup) {
- const movedIn = group.adds.length > 0;
- const {
- numberOfCells,
- movedOutIndex,
- movedInIndex,
- lineNumberCols,
- signCols,
- } = this.getMoveControlsConfig();
-
- let controlsClass;
- let descriptionIndex;
- const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group);
- if (movedIn) {
- controlsClass = 'movedIn';
- descriptionIndex = movedInIndex;
- } else {
- controlsClass = 'movedOut';
- descriptionIndex = movedOutIndex;
- }
-
- const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
- const cells = [...Array(numberOfCells).keys()].map(() =>
- createElementDiff('td')
- );
- lineNumberCols.forEach(index => {
- cells[index].classList.add('moveControlsLineNumCol');
- });
-
- if (signCols) {
- cells[signCols.left].classList.add('sign', 'left');
- cells[signCols.right].classList.add('sign', 'right');
- }
- const moveRangeHeader = createElementDiff('gr-range-header');
- moveRangeHeader.setAttribute('icon', 'move_item');
- moveRangeHeader.appendChild(descriptionTextDiv);
- cells[descriptionIndex].classList.add('moveHeader');
- cells[descriptionIndex].appendChild(moveRangeHeader);
- cells.forEach(c => {
- controls.appendChild(c);
- });
- return controls;
- }
-
- /**
- * Create a blame cell for the given base line. Blame information will be
- * included in the cell if available.
- */
- // visible for testing
- createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
- const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
- blameTd.setAttribute('data-line-number', lineNumber.toString());
- if (!lineNumber) return blameTd;
-
- const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
- if (!blameInfo) return blameTd;
-
- blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
- return blameTd;
- }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
deleted file mode 100644
index f7d1552fa6..0000000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
-
-export class GrDiffBuilderSideBySide extends GrDiffBuilderLegacy {
- constructor(
- diff: DiffInfo,
- prefs: DiffPreferencesInfo,
- outputEl: HTMLElement,
- layers: DiffLayer[] = [],
- renderPrefs?: RenderPreferences
- ) {
- super(diff, prefs, outputEl, layers, renderPrefs);
- }
-
- protected override getMoveControlsConfig() {
- return {
- numberOfCells: 6,
- movedOutIndex: 2,
- movedInIndex: 5,
- lineNumberCols: [0, 3],
- signCols: {left: 1, right: 4},
- };
- }
-
- // visible for testing
- override buildSectionElement(group: GrDiffGroup) {
- const sectionEl = createElementDiff('tbody', 'section');
- sectionEl.classList.add(group.type);
- if (group.isTotal()) {
- sectionEl.classList.add('total');
- }
- if (group.dueToRebase) {
- sectionEl.classList.add('dueToRebase');
- }
- if (group.moveDetails) {
- sectionEl.classList.add('dueToMove');
- sectionEl.appendChild(this.buildMoveControls(group));
- }
- if (group.ignoredWhitespaceOnly) {
- sectionEl.classList.add('ignoredWhitespaceOnly');
- }
- if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
- this.createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
- return sectionEl;
- }
-
- const pairs = group.getSideBySidePairs();
- for (let i = 0; i < pairs.length; i++) {
- sectionEl.appendChild(this.createRow(pairs[i].left, pairs[i].right));
- }
- return sectionEl;
- }
-
- override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
- const colgroup = document.createElement('colgroup');
-
- // Add the blame column.
- let col = createElementDiff('col', 'blame');
- colgroup.appendChild(col);
-
- // Add left-side line number.
- col = createElementDiff('col', 'left');
- col.setAttribute('width', lineNumberWidth.toString());
- colgroup.appendChild(col);
-
- colgroup.appendChild(createElementDiff('col', 'sign left'));
-
- // Add left-side content.
- colgroup.appendChild(createElementDiff('col', 'left'));
-
- // Add right-side line number.
- col = document.createElement('col');
- col.setAttribute('width', lineNumberWidth.toString());
- colgroup.appendChild(col);
-
- colgroup.appendChild(createElementDiff('col', 'sign right'));
-
- // Add right-side content.
- colgroup.appendChild(document.createElement('col'));
-
- outputEl.appendChild(colgroup);
- }
-
- private createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
- const row = createElementDiff('tr');
- row.classList.add('diff-row', 'side-by-side');
- row.setAttribute('left-type', leftLine.type);
- row.setAttribute('right-type', rightLine.type);
- // TabIndex makes screen reader read a row when navigating with j/k
- row.tabIndex = -1;
- // Before Chrome 102, Chrome was able to compute a11y label from children
- // content. Now Chrome 102 and Firefox are not computing a11y label because
- // tr is not expected to have aria label. Adding aria role button is
- // pushing browser to compute aria even for tr. This can be removed, once
- // browsers will again compute a11y label even for tr when it is focused.
- // TODO: Remove when Chrome 102 is out of date for 1 year.
- if (
- leftLine.beforeNumber !== 'FILE' &&
- leftLine.beforeNumber !== 'LOST' &&
- rightLine.beforeNumber !== 'FILE' &&
- rightLine.beforeNumber !== 'LOST'
- ) {
- row.setAttribute(
- 'aria-labelledby',
- [
- leftLine.beforeNumber ? `left-button-${leftLine.beforeNumber}` : '',
- leftLine.beforeNumber ? `left-content-${leftLine.beforeNumber}` : '',
- rightLine.afterNumber ? `right-button-${rightLine.afterNumber}` : '',
- rightLine.afterNumber ? `right-content-${rightLine.afterNumber}` : '',
- ]
- .join(' ')
- .trim()
- );
- }
-
- row.appendChild(this.createBlameCell(leftLine.beforeNumber));
-
- this.appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
- this.appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
- return row;
- }
-
- private appendPair(
- row: HTMLElement,
- line: GrDiffLine,
- lineNumber: LineNumber,
- side: Side
- ) {
- const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
- row.appendChild(lineNumberEl);
- row.appendChild(this.createSignEl(line, side));
- row.appendChild(this.createTextEl(lineNumberEl, line, side));
- }
-
- private createSignEl(line: GrDiffLine, side: Side): HTMLElement {
- const td = createElementDiff('td', 'sign');
- td.classList.add(side);
- if (line.type === GrDiffLineType.BLANK) {
- td.classList.add('blank');
- } else if (line.type === GrDiffLineType.ADD && side === Side.RIGHT) {
- td.classList.add('add');
- td.innerText = '+';
- } else if (line.type === GrDiffLineType.REMOVE && side === Side.LEFT) {
- td.classList.add('remove');
- td.innerText = '-';
- }
- if (!line.hasIntralineInfo) {
- td.classList.add('no-intraline-info');
- }
- return td;
- }
-
- // visible for testing
- override getNextContentOnSide(
- content: HTMLElement,
- side: Side
- ): HTMLElement | null {
- let tr: HTMLElement = content.parentElement!.parentElement!;
- while ((tr = tr.nextSibling as HTMLElement)) {
- const nextContent = tr.querySelector(
- 'td.content .contentText[data-side="' + side + '"]'
- );
- if (nextContent) return nextContent as HTMLElement;
- }
- return null;
- }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
deleted file mode 100644
index a06701b9eb..0000000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
-import {RenderPreferences} from '../../../api/diff';
-import {createElementDiff} from '../gr-diff/gr-diff-utils';
-import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
-
-export class GrDiffBuilderUnified extends GrDiffBuilderLegacy {
- constructor(
- diff: DiffInfo,
- prefs: DiffPreferencesInfo,
- outputEl: HTMLElement,
- layers: DiffLayer[] = [],
- renderPrefs?: RenderPreferences
- ) {
- super(diff, prefs, outputEl, layers, renderPrefs);
- }
-
- protected override getMoveControlsConfig() {
- return {
- numberOfCells: 3,
- movedOutIndex: 2,
- movedInIndex: 2,
- lineNumberCols: [0, 1],
- };
- }
-
- // visible for testing
- override buildSectionElement(group: GrDiffGroup): HTMLElement {
- const sectionEl = createElementDiff('tbody', 'section');
- sectionEl.classList.add(group.type);
- if (group.isTotal()) {
- sectionEl.classList.add('total');
- }
- if (group.dueToRebase) {
- sectionEl.classList.add('dueToRebase');
- }
- if (group.moveDetails) {
- sectionEl.classList.add('dueToMove');
- sectionEl.appendChild(this.buildMoveControls(group));
- }
- if (group.ignoredWhitespaceOnly) {
- sectionEl.classList.add('ignoredWhitespaceOnly');
- }
- if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
- this.createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
- return sectionEl;
- }
-
- for (let i = 0; i < group.lines.length; ++i) {
- const line = group.lines[i];
- // If only whitespace has changed and the settings ask for whitespace to
- // be ignored, only render the right-side line in unified diff mode.
- if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
- continue;
- }
- sectionEl.appendChild(this.createRow(line));
- }
- return sectionEl;
- }
-
- override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
- const colgroup = document.createElement('colgroup');
-
- // Add the blame column.
- let col = createElementDiff('col', 'blame');
- colgroup.appendChild(col);
-
- // Add left-side line number.
- col = document.createElement('col');
- col.setAttribute('width', lineNumberWidth.toString());
- colgroup.appendChild(col);
-
- // Add right-side line number.
- col = document.createElement('col');
- col.setAttribute('width', lineNumberWidth.toString());
- colgroup.appendChild(col);
-
- // Add the content.
- colgroup.appendChild(document.createElement('col'));
-
- outputEl.appendChild(colgroup);
- }
-
- protected createRow(line: GrDiffLine) {
- const row = createElementDiff('tr', line.type);
- row.classList.add('diff-row', 'unified');
- // TabIndex makes screen reader read a row when navigating with j/k
- row.tabIndex = -1;
- row.appendChild(this.createBlameCell(line.beforeNumber));
- let lineNumberEl = this.createLineEl(
- line,
- line.beforeNumber,
- GrDiffLineType.REMOVE,
- Side.LEFT
- );
- row.appendChild(lineNumberEl);
- lineNumberEl = this.createLineEl(
- line,
- line.afterNumber,
- GrDiffLineType.ADD,
- Side.RIGHT
- );
- row.appendChild(lineNumberEl);
- let side = undefined;
- if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
- side = Side.RIGHT;
- }
- if (line.type === GrDiffLineType.REMOVE) {
- side = Side.LEFT;
- }
-
- // Before Chrome 102, Chrome was able to compute a11y label from children
- // content. Now Chrome 102 and Firefox are not computing a11y label because
- // tr is not expected to have aria label. Adding aria role button is
- // pushing browser to compute aria even for tr. This can be removed, once
- // browsers will again compute a11y label even for tr when it is focused.
- // TODO: Remove when Chrome 102 is out of date for 1 year.
- if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
- row.setAttribute(
- 'aria-labelledby',
- [
- line.beforeNumber ? `left-button-${line.beforeNumber}` : '',
- line.afterNumber ? `right-button-${line.afterNumber}` : '',
- side === Side.LEFT && line.beforeNumber
- ? `left-content-${line.beforeNumber}`
- : '',
- side === Side.RIGHT && line.afterNumber
- ? `right-content-${line.afterNumber}`
- : '',
- ]
- .join(' ')
- .trim()
- );
- }
- row.appendChild(this.createTextEl(lineNumberEl, line, side));
- return row;
- }
-
- getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
- let tr: HTMLElement = content.parentElement!.parentElement!;
- while ((tr = tr.nextSibling as HTMLElement)) {
- // Note that this does not work when there is a "common" chunk in the
- // diff (different content only because of whitespace). Such chunks are
- // rendered with class "add", so these rows will be skipped for the
- // 'left' side.
- // TODO: Fix this when writing a Lit component for unified diff.
- if (
- tr.classList.contains('both') ||
- (side === 'left' && tr.classList.contains('remove')) ||
- (side === 'right' && tr.classList.contains('add'))
- ) {
- return tr.querySelector('.contentText');
- }
- }
- return null;
- }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
deleted file mode 100644
index 8c4472715b..0000000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified_test.ts
+++ /dev/null
@@ -1,283 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import '../gr-diff/gr-diff-group';
-import './gr-diff-builder';
-import './gr-diff-builder-unified';
-import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
-import {DiffPreferencesInfo} from '../../../api/diff';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {createDiff} from '../../../test/test-data-generators';
-import {queryAndAssert} from '../../../utils/common-util';
-import {assert} from '@open-wc/testing';
-
-suite('GrDiffBuilderUnified tests', () => {
- let prefs: DiffPreferencesInfo;
- let outputEl: HTMLElement;
- let diffBuilder: GrDiffBuilderUnified;
-
- setup(() => {
- prefs = {
- ...createDefaultDiffPrefs(),
- line_length: 10,
- show_tabs: true,
- tab_size: 4,
- };
- outputEl = document.createElement('div');
- diffBuilder = new GrDiffBuilderUnified(createDiff(), prefs, outputEl, []);
- });
-
- suite('buildSectionElement for BOTH group', () => {
- let lines: GrDiffLine[];
- let group: GrDiffGroup;
-
- setup(() => {
- lines = [
- new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
- new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
- new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
- ];
- lines[0].text = 'def hello_world():';
- lines[1].text = ' print "Hello World";';
- lines[2].text = ' return True';
-
- group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
- });
-
- test('creates the section', () => {
- const sectionEl = diffBuilder.buildSectionElement(group);
- assert.isTrue(sectionEl.classList.contains('section'));
- assert.isTrue(sectionEl.classList.contains('both'));
- });
-
- test('creates each unchanged row once', () => {
- const sectionEl = diffBuilder.buildSectionElement(group);
- const rowEls = sectionEl.querySelectorAll('.diff-row');
-
- assert.equal(rowEls.length, 3);
-
- assert.equal(
- queryAndAssert(rowEls[0], '.lineNum.left').textContent,
- lines[0].beforeNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[0], '.lineNum.right').textContent,
- lines[0].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[0], '.content').textContent,
- lines[0].text
- );
-
- assert.equal(
- queryAndAssert(rowEls[1], '.lineNum.left').textContent,
- lines[1].beforeNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[1], '.lineNum.right').textContent,
- lines[1].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[1], '.content').textContent,
- lines[1].text
- );
-
- assert.equal(
- queryAndAssert(rowEls[2], '.lineNum.left').textContent,
- lines[2].beforeNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[2], '.lineNum.right').textContent,
- lines[2].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[2], '.content').textContent,
- lines[2].text
- );
- });
- });
-
- suite('buildSectionElement for moved chunks', () => {
- test('creates a moved out group', () => {
- const lines = [
- new GrDiffLine(GrDiffLineType.REMOVE, 15),
- new GrDiffLine(GrDiffLineType.REMOVE, 16),
- ];
- lines[0].text = 'def hello_world():';
- lines[1].text = ' print "Hello World"';
- const group = new GrDiffGroup({
- type: GrDiffGroupType.DELTA,
- lines,
- moveDetails: {changed: false},
- });
-
- const sectionEl = diffBuilder.buildSectionElement(group);
-
- const rowEls = sectionEl.querySelectorAll('tr');
- const moveControlsRow = rowEls[0];
- const cells = moveControlsRow.querySelectorAll('td');
- assert.isTrue(sectionEl.classList.contains('dueToMove'));
- assert.equal(rowEls.length, 3);
- assert.isTrue(moveControlsRow.classList.contains('movedOut'));
- assert.equal(cells.length, 3);
- assert.isTrue(cells[2].classList.contains('moveHeader'));
- assert.equal(cells[2].textContent, 'Moved out');
- });
-
- test('creates a moved in group', () => {
- const lines = [
- new GrDiffLine(GrDiffLineType.ADD, 37),
- new GrDiffLine(GrDiffLineType.ADD, 38),
- ];
- lines[0].text = 'def hello_world():';
- lines[1].text = ' print "Hello World"';
- const group = new GrDiffGroup({
- type: GrDiffGroupType.DELTA,
- lines,
- moveDetails: {changed: false},
- });
-
- const sectionEl = diffBuilder.buildSectionElement(group);
-
- const rowEls = sectionEl.querySelectorAll('tr');
- const moveControlsRow = rowEls[0];
- const cells = moveControlsRow.querySelectorAll('td');
- assert.isTrue(sectionEl.classList.contains('dueToMove'));
- assert.equal(rowEls.length, 3);
- assert.isTrue(moveControlsRow.classList.contains('movedIn'));
- assert.equal(cells.length, 3);
- assert.isTrue(cells[2].classList.contains('moveHeader'));
- assert.equal(cells[2].textContent, 'Moved in');
- });
- });
-
- suite('buildSectionElement for DELTA group', () => {
- let lines: GrDiffLine[];
- let group: GrDiffGroup;
-
- setup(() => {
- lines = [
- new GrDiffLine(GrDiffLineType.REMOVE, 1),
- new GrDiffLine(GrDiffLineType.REMOVE, 2),
- new GrDiffLine(GrDiffLineType.ADD, 2),
- new GrDiffLine(GrDiffLineType.ADD, 3),
- ];
- lines[0].text = 'def hello_world():';
- lines[1].text = ' print "Hello World"';
- lines[2].text = 'def hello_universe()';
- lines[3].text = ' print "Hello Universe"';
- });
-
- test('creates the section', () => {
- group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
- const sectionEl = diffBuilder.buildSectionElement(group);
- assert.isTrue(sectionEl.classList.contains('section'));
- assert.isTrue(sectionEl.classList.contains('delta'));
- });
-
- test('creates the section with class if ignoredWhitespaceOnly', () => {
- group = new GrDiffGroup({
- type: GrDiffGroupType.DELTA,
- lines,
- ignoredWhitespaceOnly: true,
- });
- const sectionEl = diffBuilder.buildSectionElement(group);
- assert.isTrue(sectionEl.classList.contains('ignoredWhitespaceOnly'));
- });
-
- test('creates the section with class if dueToRebase', () => {
- group = new GrDiffGroup({
- type: GrDiffGroupType.DELTA,
- lines,
- dueToRebase: true,
- });
- const sectionEl = diffBuilder.buildSectionElement(group);
- assert.isTrue(sectionEl.classList.contains('dueToRebase'));
- });
-
- test('creates first the removed and then the added rows', () => {
- group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
- const sectionEl = diffBuilder.buildSectionElement(group);
- const rowEls = sectionEl.querySelectorAll('.diff-row');
-
- assert.equal(rowEls.length, 4);
-
- assert.equal(
- queryAndAssert(rowEls[0], '.lineNum.left').textContent,
- lines[0].beforeNumber.toString()
- );
- assert.isNotOk(rowEls[0].querySelector('.lineNum.right'));
- assert.equal(
- queryAndAssert(rowEls[0], '.content').textContent,
- lines[0].text
- );
-
- assert.equal(
- queryAndAssert(rowEls[1], '.lineNum.left').textContent,
- lines[1].beforeNumber.toString()
- );
- assert.isNotOk(rowEls[1].querySelector('.lineNum.right'));
- assert.equal(
- queryAndAssert(rowEls[1], '.content').textContent,
- lines[1].text
- );
-
- assert.isNotOk(rowEls[2].querySelector('.lineNum.left'));
- assert.equal(
- queryAndAssert(rowEls[2], '.lineNum.right').textContent,
- lines[2].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[2], '.content').textContent,
- lines[2].text
- );
-
- assert.isNotOk(rowEls[3].querySelector('.lineNum.left'));
- assert.equal(
- queryAndAssert(rowEls[3], '.lineNum.right').textContent,
- lines[3].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[3], '.content').textContent,
- lines[3].text
- );
- });
-
- test('creates only the added rows if only ignored whitespace', () => {
- group = new GrDiffGroup({
- type: GrDiffGroupType.DELTA,
- lines,
- ignoredWhitespaceOnly: true,
- });
- const sectionEl = diffBuilder.buildSectionElement(group);
- const rowEls = sectionEl.querySelectorAll('.diff-row');
-
- assert.equal(rowEls.length, 2);
-
- assert.isNotOk(rowEls[0].querySelector('.lineNum.left'));
- assert.equal(
- queryAndAssert(rowEls[0], '.lineNum.right').textContent,
- lines[2].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[0], '.content').textContent,
- lines[2].text
- );
-
- assert.isNotOk(rowEls[1].querySelector('.lineNum.left'));
- assert.equal(
- queryAndAssert(rowEls[1], '.lineNum.right').textContent,
- lines[3].afterNumber.toString()
- );
- assert.equal(
- queryAndAssert(rowEls[1], '.content').textContent,
- lines[3].text
- );
- });
- });
-});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index 0006f26810..f38ba5c3b1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -3,19 +3,27 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
import {
ContentLoadNeededEventDetail,
DiffContextExpandedExternalDetail,
+ DiffViewMode,
RenderPreferences,
} from '../../../api/diff';
-import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {LineNumber} from '../gr-diff/gr-diff-line';
import {GrDiffGroup} from '../gr-diff/gr-diff-group';
-import {assert} from '../../../utils/common-util';
-import '../gr-context-controls/gr-context-controls';
import {BlameInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
import {Side} from '../../../constants/constants';
-import {DiffLayer} from '../../../types/types';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {GrDiffRow} from './gr-diff-row';
+import {GrDiffSection} from './gr-diff-section';
+import {html, render} from 'lit';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {when} from 'lit/directives/when.js';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
export interface DiffContextExpandedEventDetail
extends DiffContextExpandedExternalDetail {
@@ -32,62 +40,34 @@ declare global {
}
}
-/**
- * Given that GrDiffBuilder has ~1,000 lines of code, this interface is just
- * making refactorings easier by emphasizing what the public facing "contract"
- * of this class is. There are no plans for adding separate implementations.
- */
-export interface DiffBuilder {
- clear(): void;
- addGroups(groups: readonly GrDiffGroup[]): void;
- clearGroups(): void;
- replaceGroup(
- contextControl: GrDiffGroup,
- groups: readonly GrDiffGroup[]
- ): void;
- findGroup(side: Side, line: LineNumber): GrDiffGroup | undefined;
- addColumns(outputEl: HTMLElement, fontSize: number): void;
- // TODO: Change `null` to `undefined`.
- getContentTdByLine(
- lineNumber: LineNumber,
- side?: Side,
- root?: Element
- ): HTMLTableCellElement | null;
- getLineElByNumber(
- lineNumber: LineNumber,
- side?: Side
- ): HTMLTableCellElement | null;
- getLineNumberRows(): HTMLTableRowElement[];
- getLineNumEls(side: Side): HTMLTableCellElement[];
- setBlame(blame: BlameInfo[]): void;
- updateRenderPrefs(renderPrefs: RenderPreferences): void;
+export function isImageDiffBuilder<T extends GrDiffBuilder>(
+ x: T | GrDiffBuilderImage | undefined
+): x is GrDiffBuilderImage {
+ return !!x && !!(x as GrDiffBuilderImage).renderImageDiff;
+}
+
+export function isBinaryDiffBuilder<T extends GrDiffBuilder>(
+ x: T | GrDiffBuilderBinary | undefined
+): x is GrDiffBuilderBinary {
+ return !!x && !!(x as GrDiffBuilderBinary).renderBinaryDiff;
}
/**
- * Base class for different diff builders, like side-by-side, unified etc.
- *
* The builder takes GrDiffGroups, and builds the corresponding DOM elements,
* called sections. Only the builder should add or remove sections from the
* DOM. Callers can use the ...group() methods to modify groups and thus cause
* rendering changes.
- *
- * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
*/
-export abstract class GrDiffBuilder implements DiffBuilder {
- protected readonly _diff: DiffInfo;
-
- protected readonly numLinesLeft: number;
-
- // visible for testing
- readonly _prefs: DiffPreferencesInfo;
+export class GrDiffBuilder {
+ private readonly diff: DiffInfo;
- protected readonly renderPrefs?: RenderPreferences;
+ readonly prefs: DiffPreferencesInfo;
- protected readonly outputEl: HTMLElement;
+ renderPrefs?: RenderPreferences;
- protected groups: GrDiffGroup[];
+ readonly outputEl: HTMLElement;
- private blameInfo: BlameInfo[] = [];
+ private groups: GrDiffGroup[];
private readonly layerUpdateListener: (
start: LineNumber,
@@ -102,14 +82,8 @@ export abstract class GrDiffBuilder implements DiffBuilder {
readonly layers: DiffLayer[] = [],
renderPrefs?: RenderPreferences
) {
- this._diff = diff;
- this.numLinesLeft = this._diff.content
- ? this._diff.content.reduce((sum, chunk) => {
- const left = chunk.a || chunk.ab;
- return sum + (left?.length || chunk.skip || 0);
- }, 0)
- : 0;
- this._prefs = prefs;
+ this.diff = diff;
+ this.prefs = prefs;
this.renderPrefs = renderPrefs;
this.outputEl = outputEl;
this.groups = [];
@@ -127,6 +101,150 @@ export abstract class GrDiffBuilder implements DiffBuilder {
end: LineNumber,
side: Side
) => this.renderContentByRange(start, end, side);
+ this.init();
+ }
+
+ getContentTdByLine(
+ lineNumber: LineNumber,
+ side?: Side
+ ): HTMLTableCellElement | undefined {
+ if (!side) return undefined;
+ const row = this.findRow(lineNumber, side);
+ return row?.getContentCell(side);
+ }
+
+ getLineElByNumber(
+ lineNumber: LineNumber,
+ side?: Side
+ ): HTMLTableCellElement | undefined {
+ if (!side) return undefined;
+ const row = this.findRow(lineNumber, side);
+ return row?.getLineNumberCell(side);
+ }
+
+ private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+ if (!side || !lineNumber) return undefined;
+ const group = this.findGroup(side, lineNumber);
+ if (!group) return undefined;
+ const section = this.findSection(group);
+ if (!section) return undefined;
+ return section.findRow(side, lineNumber);
+ }
+
+ private getDiffRows() {
+ const sections = [
+ ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+ ];
+ return sections.map(s => s.getDiffRows()).flat();
+ }
+
+ getLineNumberRows(): HTMLTableRowElement[] {
+ const rows = this.getDiffRows();
+ return rows.map(r => r.getTableRow()).filter(isDefined);
+ }
+
+ getLineNumEls(side: Side): HTMLTableCellElement[] {
+ const rows = this.getDiffRows();
+ return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+ }
+
+ /** This is used when layers initiate an update. */
+ renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+ const groups = this.getGroupsByLineRange(start, end, side);
+ for (const group of groups) {
+ const section = this.findSection(group);
+ for (const row of section?.getDiffRows() ?? []) {
+ row.requestUpdate();
+ }
+ }
+ }
+
+ private findSection(group: GrDiffGroup): GrDiffSection | undefined {
+ const leftClass = `left-${group.startLine(Side.LEFT)}`;
+ const rightClass = `right-${group.startLine(Side.RIGHT)}`;
+ return (
+ this.outputEl.querySelector<GrDiffSection>(
+ `gr-diff-section.${leftClass}.${rightClass}`
+ ) ?? undefined
+ );
+ }
+
+ buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const leftCl = `left-${group.startLine(Side.LEFT)}`;
+ const rightCl = `right-${group.startLine(Side.RIGHT)}`;
+ const section = html`
+ <gr-diff-section
+ class="${leftCl} ${rightCl}"
+ .group=${group}
+ .diff=${this.diff}
+ .layers=${this.layers}
+ .diffPrefs=${this.prefs}
+ .renderPrefs=${this.renderPrefs}
+ ></gr-diff-section>
+ `;
+ // When using Lit's `render()` method it wants to be in full control of the
+ // element that it renders into, so we let it render into a temp element.
+ // Rendering into the diff table directly would interfere with
+ // `clearDiffContent()`for example.
+ // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+ // method into Lit's `render()` cycle.
+ const tempEl = document.createElement('div');
+ render(section, tempEl);
+ const sectionEl = tempEl.firstElementChild as GrDiffSection;
+ return sectionEl;
+ }
+
+ addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+ const colgroup = html`
+ <colgroup>
+ <col class=${diffClasses('blame')}></col>
+ ${when(
+ this.renderPrefs?.view_mode === DiffViewMode.UNIFIED,
+ () => html` ${this.renderUnifiedColumns(lineNumberWidth)} `,
+ () => html`
+ ${this.renderSideBySideColumns(Side.LEFT, lineNumberWidth)}
+ ${this.renderSideBySideColumns(Side.RIGHT, lineNumberWidth)}
+ `
+ )}
+ </colgroup>
+ `;
+ // When using Lit's `render()` method it wants to be in full control of the
+ // element that it renders into, so we let it render into a temp element.
+ // Rendering into the diff table directly would interfere with
+ // `clearDiffContent()`for example.
+ // TODO: Convert <gr-diff> to be fully lit controlled and incorporate this
+ // method into Lit's `render()` cycle.
+ const tempEl = document.createElement('div');
+ render(colgroup, tempEl);
+ const colgroupEl = tempEl.firstElementChild as HTMLElement;
+ outputEl.appendChild(colgroupEl);
+ }
+
+ private renderUnifiedColumns(lineNumberWidth: number) {
+ return html`
+ <col class=${diffClasses()} width=${lineNumberWidth}></col>
+ <col class=${diffClasses()} width=${lineNumberWidth}></col>
+ <col class=${diffClasses()}></col>
+ `;
+ }
+
+ private renderSideBySideColumns(side: Side, lineNumberWidth: number) {
+ return html`
+ <col class=${diffClasses(side)} width=${lineNumberWidth}></col>
+ <col class=${diffClasses(side, 'sign')}></col>
+ <col class=${diffClasses(side)}></col>
+ `;
+ }
+
+ /**
+ * This is meant to be called when the gr-diff component re-connects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with cleanup(), which is called
+ * when gr-diff disconnects.
+ */
+ init() {
+ this.cleanup();
for (const layer of this.layers) {
if (layer.addListener) {
layer.addListener(this.layerUpdateListener);
@@ -134,7 +252,14 @@ export abstract class GrDiffBuilder implements DiffBuilder {
}
}
- clear() {
+ /**
+ * This is meant to be called when the gr-diff component disconnects, or when
+ * the diff is (re-)rendered.
+ *
+ * Make sure that this method is symmetric with init(), which is called when
+ * gr-diff re-connects.
+ */
+ cleanup() {
for (const layer of this.layers) {
if (layer.removeListener) {
layer.removeListener(this.layerUpdateListener);
@@ -142,10 +267,6 @@ export abstract class GrDiffBuilder implements DiffBuilder {
}
}
- abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
-
- protected abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
-
addGroups(groups: readonly GrDiffGroup[]) {
for (const group of groups) {
this.groups.push(group);
@@ -208,151 +329,19 @@ export abstract class GrDiffBuilder implements DiffBuilder {
.filter(group => group.lines.length > 0);
}
- // TODO: Change `null` to `undefined`.
- abstract getContentTdByLine(
- lineNumber: LineNumber,
- side?: Side,
- root?: Element
- ): HTMLTableCellElement | null;
-
- // TODO: Change `null` to `undefined`.
- abstract getLineElByNumber(
- lineNumber: LineNumber,
- side?: Side
- ): HTMLTableCellElement | null;
-
- abstract getLineNumberRows(): HTMLTableRowElement[];
-
- abstract getLineNumEls(side: Side): HTMLTableCellElement[];
-
- protected abstract getBlameTdByLine(lineNum: number): Element | undefined;
-
- // TODO: Change `null` to `undefined`.
- protected abstract getContentByLine(
- lineNumber: LineNumber,
- side?: Side,
- root?: HTMLElement
- ): HTMLElement | null;
-
- /**
- * Find line elements or line objects by a range of line numbers and a side.
- *
- * @param start The first line number
- * @param end The last line number
- * @param side The side of the range. Either 'left' or 'right'.
- * @param out_lines The output list of line objects.
- * TODO: Change to camelCase.
- * @param out_elements The output list of line elements.
- * TODO: Change to camelCase.
- */
- // visible for testing
- findLinesByRange(
- start: LineNumber,
- end: LineNumber,
- side: Side,
- out_lines: GrDiffLine[],
- out_elements: HTMLElement[]
- ) {
- const groups = this.getGroupsByLineRange(start, end, side);
- for (const group of groups) {
- let content: HTMLElement | null = null;
- for (const line of group.lines) {
- if (
- (side === 'left' && line.type === GrDiffLineType.ADD) ||
- (side === 'right' && line.type === GrDiffLineType.REMOVE)
- ) {
- continue;
- }
- const lineNumber =
- side === 'left' ? line.beforeNumber : line.afterNumber;
- if (lineNumber < start || lineNumber > end) {
- continue;
- }
-
- if (content) {
- content = this.getNextContentOnSide(content, side);
- } else {
- content = this.getContentByLine(lineNumber, side, group.element);
- }
- if (content) {
- // out_lines and out_elements must match. So if we don't have an
- // element to push, then also don't push a line.
- out_lines.push(line);
- out_elements.push(content);
- }
- }
- }
- assert(
- out_lines.length === out_elements.length,
- 'findLinesByRange: lines and elements arrays must have same length'
- );
- }
-
- protected abstract renderContentByRange(
- start: LineNumber,
- end: LineNumber,
- side: Side
- ): void;
-
- protected abstract renderBlameByRange(
- blame: BlameInfo,
- start: number,
- end: number
- ): void;
-
- /**
- * Finds the next DIV.contentText element following the given element, and on
- * the same side. Will only search within a group.
- *
- * TODO: Change `null` to `undefined`.
- */
- protected abstract getNextContentOnSide(
- content: HTMLElement,
- side: Side
- ): HTMLElement | null;
-
- /**
- * Gets configuration for creating move controls for chunks marked with
- * dueToMove
- */
- protected abstract getMoveControlsConfig(): {
- numberOfCells: number;
- movedOutIndex: number;
- movedInIndex: number;
- lineNumberCols: number[];
- signCols?: {left: number; right: number};
- };
-
/**
* Set the blame information for the diff. For any already-rendered line,
* re-render its blame cell content.
*/
setBlame(blame: BlameInfo[]) {
- this.blameInfo = blame;
- for (const commit of blame) {
- for (const range of commit.ranges) {
- this.renderBlameByRange(commit, range.start, range.end);
- }
- }
- }
-
- /**
- * Given a base line number, return the commit containing that line in the
- * current set of blame information. If no blame information has been
- * provided, null is returned.
- *
- * @return The commit information.
- */
- // visible for testing
- getBlameCommitForBaseLine(lineNum: LineNumber): BlameInfo | undefined {
- for (const blameCommit of this.blameInfo) {
- for (const range of blameCommit.ranges) {
- if (range.start <= lineNum && range.end >= lineNum) {
- return blameCommit;
+ for (const blameInfo of blame) {
+ for (const range of blameInfo.ranges) {
+ for (let line = range.start; line <= range.end; line++) {
+ const row = this.findRow(line, Side.LEFT);
+ if (row) row.blameInfo = blameInfo;
}
}
}
- return undefined;
}
/**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
new file mode 100644
index 0000000000..9acda812bc
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -0,0 +1,474 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement, nothing, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createRef, Ref, ref} from 'lit/directives/ref.js';
+import {
+ DiffResponsiveMode,
+ Side,
+ LineNumber,
+ DiffLayer,
+} from '../../../api/diff';
+import {BlameInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import './gr-diff-text';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+
+@customElement('gr-diff-row')
+export class GrDiffRow extends LitElement {
+ contentLeftRef: Ref<LitElement> = createRef();
+
+ contentRightRef: Ref<LitElement> = createRef();
+
+ contentCellLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+ contentCellRightRef: Ref<HTMLTableCellElement> = createRef();
+
+ lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+ lineNumberRightRef: Ref<HTMLTableCellElement> = createRef();
+
+ blameCellRef: Ref<HTMLTableCellElement> = createRef();
+
+ tableRowRef: Ref<HTMLTableRowElement> = createRef();
+
+ @property({type: Object})
+ left?: GrDiffLine;
+
+ @property({type: Object})
+ right?: GrDiffLine;
+
+ @property({type: Object})
+ blameInfo?: BlameInfo;
+
+ @property({type: Object})
+ responsiveMode?: DiffResponsiveMode;
+
+ /**
+ * true: side-by-side diff
+ * false: unified diff
+ */
+ @property({type: Boolean})
+ unifiedDiff = false;
+
+ @property({type: Number})
+ tabSize = 2;
+
+ @property({type: Number})
+ lineLength = 80;
+
+ @property({type: Boolean})
+ hideFileCommentButton = false;
+
+ @property({type: Object})
+ layers: DiffLayer[] = [];
+
+ /**
+ * Semantic DOM diff testing does not work with just table fragments, so when
+ * running such tests the render() method has to wrap the DOM in a proper
+ * <table> element.
+ */
+ @state()
+ addTableWrapperForTesting = false;
+
+ /**
+ * Keeps track of whether diff layers have already been applied to the diff
+ * row. That happens after the DOM has been created in the `updated()`
+ * lifecycle callback.
+ *
+ * Once layers are applied, the diff row requires two rendering passes for an
+ * update: 1. Remove all <gr-diff-text> elements and their layer manipulated
+ * DOMs. 2. Add fresh <gr-diff-text> elements and let layers re-apply in
+ * `updated()`.
+ */
+ private layersApplied = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ override updated() {
+ if (this.layersApplied) {
+ // <gr-diff-text> elements have been removed during rendering. Let's start
+ // another rendering cycle with freshly created <gr-diff-text> elements.
+ this.updateComplete.then(() => {
+ this.layersApplied = false;
+ this.requestUpdate();
+ });
+ } else {
+ this.updateLayers(Side.LEFT);
+ this.updateLayers(Side.RIGHT);
+ }
+ }
+
+ /**
+ * The diff layers API is designed to let layers manipulate the DOM. So we
+ * have to apply them after the rendering cycle is done (`updated()`). But
+ * when re-rendering a row that already has layers applied, then we have to
+ * first wipe away <gr-diff-text>. This is achieved by
+ * `this.layersApplied = true`.
+ */
+ private async updateLayers(side: Side) {
+ const line = this.line(side);
+ const contentEl = this.contentRef(side).value;
+ const lineNumberEl = this.lineNumberRef(side).value;
+ if (!line || !contentEl || !lineNumberEl) return;
+
+ // We have to wait for the <gr-diff-text> child component to finish
+ // rendering before we can apply layers, which will re-write the HTML.
+ await contentEl?.updateComplete;
+ for (const layer of this.layers) {
+ if (typeof layer.annotate === 'function') {
+ layer.annotate(contentEl, lineNumberEl, line, side);
+ }
+ }
+ // At this point we consider layers applied. So as soon as <gr-diff-row>
+ // enters a new rendering cycle <gr-diff-text> elements will be removed.
+ this.layersApplied = true;
+ }
+
+ override render() {
+ if (!this.left || !this.right) return;
+ const classes = this.unifiedDiff ? ['unified'] : ['side-by-side'];
+ const unifiedType = this.unifiedType();
+ if (this.unifiedDiff && unifiedType) classes.push(unifiedType);
+ const row = html`
+ <tr
+ ${ref(this.tableRowRef)}
+ class=${diffClasses('diff-row', ...classes)}
+ left-type=${ifDefined(this.getType(Side.LEFT))}
+ right-type=${ifDefined(this.getType(Side.RIGHT))}
+ tabindex="-1"
+ aria-labelledby=${this.ariaLabelIds()}
+ >
+ ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
+ ${this.renderSignCell(Side.LEFT)} ${this.renderContentCell(Side.LEFT)}
+ ${this.renderLineNumberCell(Side.RIGHT)}
+ ${this.renderSignCell(Side.RIGHT)} ${this.renderContentCell(Side.RIGHT)}
+ </tr>
+ ${this.renderPostLineSlot(Side.LEFT)}
+ ${this.renderPostLineSlot(Side.RIGHT)}
+ `;
+ if (this.addTableWrapperForTesting) {
+ return html`<table>
+ ${row}
+ </table>`;
+ }
+ return row;
+ }
+
+ private ariaLabelIds() {
+ const ids: string[] = [];
+ ids.push(this.lineNumberId(Side.LEFT));
+ if (!this.unifiedDiff) ids.push(this.contentId(Side.LEFT));
+ ids.push(this.lineNumberId(Side.RIGHT));
+ if (!this.unifiedDiff) ids.push(this.contentId(Side.RIGHT));
+ if (this.unifiedDiff) ids.push(this.contentId(this.unifiedSide()));
+ return ids.filter(id => !!id).join(' ');
+ }
+
+ private lineNumberId(side: Side): string {
+ const lineNumber = this.lineNumber(side);
+ if (!lineNumber) return '';
+ return `${side}-button-${lineNumber}`;
+ }
+
+ private unifiedSide() {
+ const isLeft = this.line(Side.RIGHT)?.type === GrDiffLineType.BLANK;
+ return isLeft ? Side.LEFT : Side.RIGHT;
+ }
+
+ private contentId(side: Side): string {
+ const lineNumber = this.lineNumber(side);
+ if (!lineNumber) return '';
+ return `${side}-content-${lineNumber}`;
+ }
+
+ getTableRow(): HTMLTableRowElement | undefined {
+ return this.tableRowRef.value;
+ }
+
+ getLineNumberCell(side: Side): HTMLTableCellElement | undefined {
+ return this.lineNumberRef(side).value;
+ }
+
+ getContentCell(side: Side) {
+ return this.contentCellRef(side)?.value;
+ }
+
+ getBlameCell() {
+ return this.blameCellRef.value;
+ }
+
+ private renderBlameCell() {
+ // td.blame has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`
+ <td
+ ${ref(this.blameCellRef)}
+ class=${diffClasses('blame')}
+ data-line-number=${this.left?.beforeNumber ?? 0}
+ >${this.renderBlameElement()}</td>
+ `;
+ }
+
+ private renderBlameElement() {
+ const lineNum = this.left?.beforeNumber;
+ const commit = this.blameInfo;
+ if (!lineNum || !commit) return;
+
+ const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+ const extras: string[] = [];
+ if (isStartOfRange) extras.push('startOfRange');
+ const date = new Date(commit.time * 1000).toLocaleDateString();
+ const shortName = commit.author.split(' ')[0];
+ const url = `${getBaseUrl()}/q/${commit.id}`;
+
+ // td.blame has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`<span class=${diffClasses(...extras)}
+ ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
+ ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
+ ><gr-hovercard class=${diffClasses()}>
+ <span class=${diffClasses('blameHoverCard')}>
+ Commit ${commit.id}<br />
+ Author: ${commit.author}<br />
+ Date: ${date}<br />
+ <br />
+ ${commit.commit_msg}
+ </span>
+ </gr-hovercard
+ ></span>`;
+ }
+
+ private renderLineNumberCell(side: Side): TemplateResult {
+ const line = this.line(side);
+ const lineNumber = this.lineNumber(side);
+ const isBlank = line?.type === GrDiffLineType.BLANK;
+ if (!line || !lineNumber || isBlank || this.layersApplied) {
+ const blankClass = isBlank && !this.unifiedDiff ? 'blankLineNum' : '';
+ return html`<td
+ ${ref(this.lineNumberRef(side))}
+ class=${diffClasses(side, blankClass)}
+ ></td>`;
+ }
+
+ return html`<td
+ ${ref(this.lineNumberRef(side))}
+ class=${diffClasses(side, 'lineNum')}
+ data-value=${lineNumber}
+ >
+ ${this.renderLineNumberButton(line, lineNumber, side)}
+ </td>`;
+ }
+
+ private renderLineNumberButton(
+ line: GrDiffLine,
+ lineNumber: LineNumber,
+ side: Side
+ ) {
+ if (this.hideFileCommentButton && lineNumber === 'FILE') return;
+ if (lineNumber === 'LOST') return;
+ // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`
+ <button
+ id=${this.lineNumberId(side)}
+ class=${diffClasses('lineNumButton', side)}
+ tabindex="-1"
+ data-value=${lineNumber}
+ aria-label=${ifDefined(
+ this.computeLineNumberAriaLabel(line, lineNumber)
+ )}
+ @mouseenter=${() =>
+ fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
+ @mouseleave=${() =>
+ fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
+ >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+ `;
+ }
+
+ private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
+ if (lineNumber === 'FILE') return 'Add file comment';
+
+ // Add aria-labels for valid line numbers.
+ // For unified diff, this method will be called with number set to 0 for
+ // the empty line number column for added/removed lines. This should not
+ // be announced to the screenreader.
+ if (lineNumber === 'LOST' || lineNumber <= 0) return undefined;
+
+ switch (line.type) {
+ case GrDiffLineType.REMOVE:
+ return `${lineNumber} removed`;
+ case GrDiffLineType.ADD:
+ return `${lineNumber} added`;
+ case GrDiffLineType.BOTH:
+ case GrDiffLineType.BLANK:
+ return `${lineNumber} unmodified`;
+ }
+ }
+
+ private renderContentCell(side: Side) {
+ let line = this.line(side);
+ if (this.unifiedDiff) {
+ if (side === Side.LEFT) return nothing;
+ if (line?.type === GrDiffLineType.BLANK) {
+ side = Side.LEFT;
+ line = this.line(Side.LEFT);
+ }
+ }
+ const lineNumber = this.lineNumber(side);
+ assertIsDefined(line, 'line');
+ const extras: string[] = [line.type, side];
+ if (line.type !== GrDiffLineType.BLANK) extras.push('content');
+ if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+ if (line.beforeNumber === 'FILE') extras.push('file');
+ if (line.beforeNumber === 'LOST') extras.push('lost');
+
+ // .content has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`
+ <td
+ ${ref(this.contentCellRef(side))}
+ class=${diffClasses(...extras)}
+ @mouseenter=${() => {
+ if (lineNumber)
+ fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
+ }}
+ @mouseleave=${() => {
+ if (lineNumber)
+ fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
+ }}
+ >${this.renderText(side)}${this.renderThreadGroup(side)}</td>
+ `;
+ }
+
+ private renderSignCell(side: Side) {
+ if (this.unifiedDiff) return nothing;
+ const line = this.line(side);
+ assertIsDefined(line, 'line');
+ const isBlank = line.type === GrDiffLineType.BLANK;
+ const isAdd = line.type === GrDiffLineType.ADD && side === Side.RIGHT;
+ const isRemove = line.type === GrDiffLineType.REMOVE && side === Side.LEFT;
+ const extras: string[] = ['sign', side];
+ if (isBlank) extras.push('blank');
+ if (isAdd) extras.push('add');
+ if (isRemove) extras.push('remove');
+ if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+
+ const sign = isAdd ? '+' : isRemove ? '-' : '';
+ return html`<td class=${diffClasses(...extras)}>${sign}</td>`;
+ }
+
+ private renderThreadGroup(side: Side) {
+ const lineNumber = this.lineNumber(side);
+ if (!lineNumber) return nothing;
+ return html`<div class="thread-group" data-side=${side}>
+ <slot name="${side}-${lineNumber}"></slot>
+ ${this.renderSecondSlot()}
+ </div>`;
+ }
+
+ private renderSecondSlot() {
+ if (!this.unifiedDiff) return nothing;
+ if (this.line(Side.LEFT)?.type !== GrDiffLineType.BOTH) return nothing;
+ return html`<slot
+ name="${Side.LEFT}-${this.lineNumber(Side.LEFT)}"
+ ></slot>`;
+ }
+
+ private contentRef(side: Side) {
+ return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef;
+ }
+
+ private contentCellRef(side: Side) {
+ return side === Side.LEFT
+ ? this.contentCellLeftRef
+ : this.contentCellRightRef;
+ }
+
+ private lineNumberRef(side: Side) {
+ return side === Side.LEFT
+ ? this.lineNumberLeftRef
+ : this.lineNumberRightRef;
+ }
+
+ private lineNumber(side: Side) {
+ return this.line(side)?.lineNumber(side);
+ }
+
+ private line(side: Side) {
+ return side === Side.LEFT ? this.left : this.right;
+ }
+
+ private getType(side?: Side): string | undefined {
+ if (this.unifiedDiff) return undefined;
+ if (side === Side.LEFT) return this.left?.type;
+ if (side === Side.RIGHT) return this.right?.type;
+ return undefined;
+ }
+
+ private unifiedType() {
+ return this.left?.type === GrDiffLineType.BLANK
+ ? this.right?.type
+ : this.left?.type;
+ }
+
+ /**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ */
+ private renderText(side: Side) {
+ const line = this.line(side);
+ const lineNumber = this.lineNumber(side);
+ if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+
+ // Note that `this.layersApplied` will wipe away the <gr-diff-text>, and
+ // another rendering cycle will be initiated in `updated()`.
+ // prettier-ignore
+ const textElement = line?.text && !this.layersApplied
+ ? html`<gr-diff-text
+ ${ref(this.contentRef(side))}
+ .text=${line?.text}
+ .tabSize=${this.tabSize}
+ .lineLimit=${this.lineLength}
+ .isResponsive=${isResponsive(this.responsiveMode)}
+ ></gr-diff-text>` : '';
+ // .content has `white-space: pre`, so prettier must not add spaces.
+ // prettier-ignore
+ return html`<div
+ class=${diffClasses('contentText')}
+ data-side=${ifDefined(side)}
+ id=${this.contentId(side)}
+ >${textElement}</div>`;
+ }
+
+ private renderPostLineSlot(side: Side) {
+ const lineNumber = this.lineNumber(side);
+ return lineNumber && Number.isInteger(lineNumber)
+ ? html`<slot name="post-${side}-line-${lineNumber}"></slot>`
+ : nothing;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-row': GrDiffRow;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
new file mode 100644
index 0000000000..42d30aa795
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -0,0 +1,271 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-row';
+import {GrDiffRow} from './gr-diff-row';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-row test', () => {
+ let element: GrDiffRow;
+
+ setup(async () => {
+ element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
+ element.addTableWrapperForTesting = true;
+ await element.updateComplete;
+ });
+
+ test('both', async () => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = line;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ </tbody>
+ </table>
+ `
+ );
+ });
+
+ test('both unified', async () => {
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = line;
+ element.unifiedDiff = true;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="left-button-1 right-button-1 right-content-1"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ </tbody>
+ </table>
+ `
+ );
+ });
+
+ test('add', async () => {
+ const line = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+ line.text = 'lorem ipsum';
+ element.left = new GrDiffLine(GrDiffLineType.BLANK);
+ element.right = line;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 added"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ <slot name="post-right-line-1"></slot>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+
+ test('remove', async () => {
+ const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+ line.text = 'lorem ipsum';
+ element.left = line;
+ element.right = new GrDiffLine(GrDiffLineType.BLANK);
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr
+ aria-labelledby="left-button-1 left-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 removed"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> lorem ipsum </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <slot name="post-left-line-1"></slot>
+ </tbody>
+ </table>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
new file mode 100644
index 0000000000..e5d3d2e66a
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -0,0 +1,250 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+ DiffInfo,
+ DiffLayer,
+ DiffViewMode,
+ RenderPreferences,
+ Side,
+ LineNumber,
+ DiffPreferencesInfo,
+} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {
+ countLines,
+ diffClasses,
+ getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiffRow} from './gr-diff-row';
+import '../gr-context-controls/gr-context-controls-section';
+import '../gr-context-controls/gr-context-controls';
+import '../gr-range-header/gr-range-header';
+import './gr-diff-row';
+import {when} from 'lit/directives/when.js';
+import {fire} from '../../../utils/event-util';
+
+@customElement('gr-diff-section')
+export class GrDiffSection extends LitElement {
+ @property({type: Object})
+ group?: GrDiffGroup;
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @property({type: Object})
+ renderPrefs?: RenderPreferences;
+
+ @property({type: Object})
+ diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ layers: DiffLayer[] = [];
+
+ /**
+ * Semantic DOM diff testing does not work with just table fragments, so when
+ * running such tests the render() method has to wrap the DOM in a proper
+ * <table> element.
+ */
+ @state()
+ addTableWrapperForTesting = false;
+
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ override render() {
+ if (!this.group) return;
+ const extras: string[] = [];
+ extras.push('section');
+ extras.push(this.group.type);
+ if (this.group.isTotal()) extras.push('total');
+ if (this.group.dueToRebase) extras.push('dueToRebase');
+ if (this.group.moveDetails) extras.push('dueToMove');
+ if (this.group.moveDetails?.changed) extras.push('changed');
+ if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
+
+ const pairs = this.getLinePairs();
+ const responsiveMode = getResponsiveMode(this.diffPrefs, this.renderPrefs);
+ const hideFileCommentButton =
+ this.diffPrefs?.show_file_comment_button === false ||
+ this.renderPrefs?.show_file_comment_button === false;
+ const body = html`
+ <tbody class=${diffClasses(...extras)}>
+ ${this.renderContextControls()} ${this.renderMoveControls()}
+ ${pairs.map(pair => {
+ const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
+ const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+ return html`
+ <gr-diff-row
+ class="${leftCl} ${rightCl}"
+ .left=${pair.left}
+ .right=${pair.right}
+ .layers=${this.layers}
+ .lineLength=${this.diffPrefs?.line_length ?? 80}
+ .tabSize=${this.diffPrefs?.tab_size ?? 2}
+ .unifiedDiff=${this.isUnifiedDiff()}
+ .responsiveMode=${responsiveMode}
+ .hideFileCommentButton=${hideFileCommentButton}
+ >
+ </gr-diff-row>
+ `;
+ })}
+ </tbody>
+ `;
+ if (this.addTableWrapperForTesting) {
+ return html`<table>
+ ${body}
+ </table>`;
+ }
+ return body;
+ }
+
+ private isUnifiedDiff() {
+ return this.renderPrefs?.view_mode === DiffViewMode.UNIFIED;
+ }
+
+ getLinePairs() {
+ if (!this.group) return [];
+ const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+ if (isControl) return [];
+ return this.isUnifiedDiff()
+ ? this.group.getUnifiedPairs()
+ : this.group.getSideBySidePairs();
+ }
+
+ getDiffRows(): GrDiffRow[] {
+ return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
+ }
+
+ private renderContextControls() {
+ if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+
+ const leftStart = this.group.lineRange.left.start_line;
+ const leftEnd = this.group.lineRange.left.end_line;
+ const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
+ const lastGroupIsSkipped =
+ !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
+ const lineCountLeft = countLines(this.diff, Side.LEFT);
+ const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+ const showAbove =
+ (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+ const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
+
+ return html`
+ <gr-context-controls-section
+ .showAbove=${showAbove}
+ .showBelow=${showBelow}
+ .group=${this.group}
+ .diff=${this.diff}
+ .renderPrefs=${this.renderPrefs}
+ >
+ </gr-context-controls-section>
+ `;
+ }
+
+ findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+ return (
+ this.querySelector<GrDiffRow>(`gr-diff-row.${side}-${lineNumber}`) ??
+ undefined
+ );
+ }
+
+ private renderMoveControls() {
+ if (!this.group?.moveDetails) return;
+ const movedIn = this.group.adds.length > 0;
+ const plainCell = html`<td class=${diffClasses()}></td>`;
+ const signCell = html`<td class=${diffClasses('sign')}></td>`;
+ const lineNumberCell = html`
+ <td class=${diffClasses('moveControlsLineNumCol')}></td>
+ `;
+ const moveCell = html`
+ <td class=${diffClasses('moveHeader')}>
+ <gr-range-header class=${diffClasses()} icon="move_item">
+ ${this.renderMoveDescription(movedIn)}
+ </gr-range-header>
+ </td>
+ `;
+ return html`
+ <tr
+ class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
+ >
+ ${when(
+ this.isUnifiedDiff(),
+ () => html`${lineNumberCell} ${lineNumberCell} ${moveCell}`,
+ () => html`${lineNumberCell} ${signCell}
+ ${movedIn ? plainCell : moveCell} ${lineNumberCell} ${signCell}
+ ${movedIn ? moveCell : plainCell}`
+ )}
+ </tr>
+ `;
+ }
+
+ private renderMoveDescription(movedIn: boolean) {
+ if (this.group?.moveDetails?.range) {
+ const {changed, range} = this.group.moveDetails;
+ const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+ const andChangedLabel = changed ? 'and changed ' : '';
+ const direction = movedIn ? 'from' : 'to';
+ const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+ return html`
+ <div class=${diffClasses()}>
+ <span class=${diffClasses()}>${textLabel}</span>
+ ${this.renderMovedLineAnchor(range.start, otherSide)}
+ <span class=${diffClasses()}> - </span>
+ ${this.renderMovedLineAnchor(range.end, otherSide)}
+ </div>
+ `;
+ }
+
+ return html`
+ <div class=${diffClasses()}>
+ <span class=${diffClasses()}
+ >${movedIn ? 'Moved in' : 'Moved out'}</span
+ >
+ </div>
+ `;
+ }
+
+ private renderMovedLineAnchor(line: number, side: Side) {
+ const listener = (e: MouseEvent) => {
+ e.preventDefault();
+ this.handleMovedLineAnchorClick(e.target, side, line);
+ };
+ // `href` is not actually used but important for Screen Readers
+ return html`
+ <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
+ >${line}</a
+ >
+ `;
+ }
+
+ private handleMovedLineAnchorClick(
+ anchor: EventTarget | null,
+ side: Side,
+ line: number
+ ) {
+ if (!anchor) return;
+ fire(anchor, 'moved-link-clicked', {
+ lineNum: line,
+ side,
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-section': GrDiffSection;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
new file mode 100644
index 0000000000..381f9b2d32
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -0,0 +1,315 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-section';
+import {GrDiffSection} from './gr-diff-section';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, GrDiffLineType} from '../../../api/diff';
+import {waitQueryAndAssert} from '../../../test/test-utils';
+
+suite('gr-diff-section test', () => {
+ let element: GrDiffSection;
+
+ setup(async () => {
+ element = await fixture<GrDiffSection>(
+ html`<gr-diff-section></gr-diff-section>`
+ );
+ element.addTableWrapperForTesting = true;
+ await element.updateComplete;
+ });
+
+ suite('move controls', async () => {
+ setup(async () => {
+ const lines = [new GrDiffLine(GrDiffLineType.BOTH, 1, 1)];
+ lines[0].text = 'asdf';
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ lines,
+ moveDetails: {changed: false, range: {start: 1, end: 2}},
+ });
+ element.group = group;
+ await element.updateComplete;
+ });
+
+ test('side-by-side', async () => {
+ const row = await waitQueryAndAssert(element, 'tr.moveControls');
+ // Semantic dom diff has a problem with just comparing table rows or
+ // cells directly. So as a workaround put the row into an empty test
+ // table.
+ const testTable = document.createElement('table');
+ testTable.appendChild(row);
+ assert.dom.equal(
+ testTable,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr class="gr-diff moveControls movedOut">
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff moveHeader">
+ <gr-range-header class="gr-diff" icon="move_item">
+ <div class="gr-diff">
+ <span class="gr-diff"> Moved to lines </span>
+ <a class="gr-diff" href="#1"> 1 </a>
+ <span class="gr-diff"> - </span>
+ <a class="gr-diff" href="#2"> 2 </a>
+ </div>
+ </gr-range-header>
+ </td>
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ </table>
+ `,
+ {}
+ );
+ });
+
+ test('unified', async () => {
+ element.renderPrefs = {
+ ...element.renderPrefs,
+ view_mode: DiffViewMode.UNIFIED,
+ };
+ const row = await waitQueryAndAssert(element, 'tr.moveControls');
+ // Semantic dom diff has a problem with just comparing table rows or
+ // cells directly. So as a workaround put the row into an empty test
+ // table.
+ const testTable = document.createElement('table');
+ testTable.appendChild(row);
+ assert.dom.equal(
+ testTable,
+ /* HTML */ `
+ <table>
+ <tbody>
+ <tr class="gr-diff moveControls movedOut">
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff moveControlsLineNumCol"></td>
+ <td class="gr-diff moveHeader">
+ <gr-range-header class="gr-diff" icon="move_item">
+ <div class="gr-diff">
+ <span class="gr-diff"> Moved to lines </span>
+ <a class="gr-diff" href="#1"> 1 </a>
+ <span class="gr-diff"> - </span>
+ <a class="gr-diff" href="#2"> 2 </a>
+ </div>
+ </gr-range-header>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ `,
+ {}
+ );
+ });
+ });
+
+ test('3 normal unchanged rows', async () => {
+ const lines = [
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+ ];
+ lines[0].text = 'asdf';
+ lines[1].text = 'qwer';
+ lines[2].text = 'zxcv';
+ const group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+ element.group = group;
+ await element.updateComplete;
+ assert.lightDom.equal(
+ element,
+ /* HTML */ `
+ <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+ <slot name="post-left-line-1"></slot>
+ <slot name="post-right-line-1"></slot>
+ <table>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="left">
+ <slot name="left-1"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ >
+ <gr-diff-text> </gr-diff-text>
+ </div>
+ <div class="thread-group" data-side="right">
+ <slot name="right-1"> </slot>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
new file mode 100644
index 0000000000..c1b13ac74e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -0,0 +1,152 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {styleMap} from 'lit/directives/style-map.js';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+
+const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const TAB = '\t';
+
+/**
+ * Renders one line of code on one side of the diff. It takes care of:
+ * - Tabs, see `tabSize` property.
+ * - Line Breaks, see `lineLimit` property.
+ * - Surrogate Character Pairs.
+ *
+ * Note that other modifications to the code in a gr-diff is done via diff
+ * layers, which manipulate the DOM directly. So `gr-diff-text` is thrown
+ * away and re-rendered every time something changes by its parent
+ * `gr-diff-row`. So don't bother to optimize this component for re-rendering
+ * performance. And be aware that building longer lived local state is not
+ * useful here.
+ */
+@customElement('gr-diff-text')
+export class GrDiffText extends LitElement {
+ /**
+ * The browser API for handling selection does not (yet) work for selection
+ * across multiple shadow DOM elements. So we are rendering gr-diff components
+ * into the light DOM instead of the shadow DOM by overriding this method,
+ * which was the recommended workaround by the lit team.
+ * See also https://github.com/WICG/webcomponents/issues/79.
+ */
+ override createRenderRoot() {
+ return this;
+ }
+
+ @property({type: String})
+ text = '';
+
+ @property({type: Boolean})
+ isResponsive = false;
+
+ @property({type: Number})
+ tabSize = 2;
+
+ @property({type: Number})
+ lineLimit = 80;
+
+ /** Temporary state while rendering. */
+ private textOffset = 0;
+
+ /** Temporary state while rendering. */
+ private columnPos = 0;
+
+ /** Temporary state while rendering. */
+ private pieces: (string | TemplateResult)[] = [];
+
+ /** Split up the string into tabs, surrogate pairs and regular segments. */
+ override render() {
+ this.textOffset = 0;
+ this.columnPos = 0;
+ this.pieces = [];
+ const splitByTab = this.text.split('\t');
+ for (let i = 0; i < splitByTab.length; i++) {
+ const splitBySurrogate = splitByTab[i].split(SURROGATE_PAIR);
+ for (let j = 0; j < splitBySurrogate.length; j++) {
+ this.renderSegment(splitBySurrogate[j]);
+ if (j < splitBySurrogate.length - 1) {
+ this.renderSurrogatePair();
+ }
+ }
+ if (i < splitByTab.length - 1) {
+ this.renderTab();
+ }
+ }
+ if (this.textOffset !== this.text.length) throw new Error('unfinished');
+ return this.pieces;
+ }
+
+ /** Render regular characters, but insert line breaks appropriately. */
+ private renderSegment(segment: string) {
+ let segmentOffset = 0;
+ while (segmentOffset < segment.length) {
+ const newOffset = Math.min(
+ segment.length,
+ segmentOffset + this.lineLimit - this.columnPos
+ );
+ this.renderString(segment.substring(segmentOffset, newOffset));
+ segmentOffset = newOffset;
+ if (segmentOffset < segment.length && this.columnPos === this.lineLimit) {
+ this.renderLineBreak();
+ }
+ }
+ }
+
+ /** Render regular characters. */
+ private renderString(s: string) {
+ if (s.length === 0) return;
+ this.pieces.push(s);
+ this.textOffset += s.length;
+ this.columnPos += s.length;
+ if (this.columnPos > this.lineLimit) throw new Error('over line limit');
+ }
+
+ /** Render a tab character. */
+ private renderTab() {
+ let tabSize = this.tabSize - (this.columnPos % this.tabSize);
+ if (this.columnPos + tabSize > this.lineLimit) {
+ this.renderLineBreak();
+ tabSize = this.tabSize;
+ }
+ const piece = html`<span
+ class=${diffClasses('tab')}
+ style=${styleMap({'tab-size': `${tabSize}`})}
+ >${TAB}</span
+ >`;
+ this.pieces.push(piece);
+ this.textOffset += 1;
+ this.columnPos += tabSize;
+ }
+
+ /** Render a surrogate pair: string length is 2, but is just 1 char. */
+ private renderSurrogatePair() {
+ if (this.columnPos === this.lineLimit) {
+ this.renderLineBreak();
+ }
+ this.pieces.push(this.text.substring(this.textOffset, this.textOffset + 2));
+ this.textOffset += 2;
+ this.columnPos += 1;
+ }
+
+ /** Render a line break, don't advance text offset, reset col position. */
+ private renderLineBreak() {
+ if (this.isResponsive) {
+ this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+ } else {
+ this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+ }
+ // this.textOffset += 0;
+ this.columnPos = 0;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-text': GrDiffText;
+ }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
new file mode 100644
index 0000000000..3858bedc5b
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
@@ -0,0 +1,166 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-text';
+import {GrDiffText} from './gr-diff-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const LINE_BREAK = '<span class="gr-diff br"></span>';
+
+const LINE_BREAK_WBR = '<wbr class="gr-diff"></wbr>';
+
+const TAB = '<span class="" style=""></span>';
+
+const TAB_IGNORE = ['class', 'style'];
+
+suite('gr-diff-text test', () => {
+ let element: GrDiffText;
+
+ setup(async () => {
+ element = await fixture<GrDiffText>(
+ html`<gr-diff-text tabsize="4" linelimit="10"></gr-diff-text>`
+ );
+ });
+
+ const check = async (
+ text: string,
+ html: string,
+ ignoreAttributes: string[] = []
+ ) => {
+ element.text = text;
+ await element.updateComplete;
+ assert.lightDom.equal(element, html, {ignoreAttributes});
+ };
+
+ suite('lit rendering', () => {
+ test('renderText newlines 1', async () => {
+ await check('abcdef', 'abcdef');
+ await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK}aaaaaaaaaa`);
+ });
+
+ test('renderText newlines 1 responsive', async () => {
+ element.isResponsive = true;
+ await check('abcdef', 'abcdef');
+ await check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK_WBR}aaaaaaaaaa`);
+ });
+
+ test('renderText newlines 2', async () => {
+ await check(
+ '<span class="thumbsup">👍</span>',
+ '&lt;span clas' +
+ LINE_BREAK +
+ 's="thumbsu' +
+ LINE_BREAK +
+ 'p"&gt;👍&lt;/span' +
+ LINE_BREAK +
+ '&gt;'
+ );
+ });
+
+ test('renderText newlines 3', async () => {
+ await check(
+ '01234\t56789',
+ '01234' + TAB + '56' + LINE_BREAK + '789',
+ TAB_IGNORE
+ );
+ });
+
+ test('renderText newlines 4', async () => {
+ element.lineLimit = 20;
+ await element.updateComplete;
+ await check(
+ '👍'.repeat(58),
+ '👍'.repeat(20) +
+ LINE_BREAK +
+ '👍'.repeat(20) +
+ LINE_BREAK +
+ '👍'.repeat(18)
+ );
+ });
+
+ test('tab wrapper style', async () => {
+ element.lineLimit = 100;
+ element.tabSize = 4;
+ await check(
+ '\t',
+ /* HTML */ '<span class="gr-diff tab" style="tab-size:4;"></span>'
+ );
+ await check(
+ 'abc\t',
+ /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:1;"></span>'
+ );
+
+ element.tabSize = 8;
+ await check(
+ '\t',
+ /* HTML */ '<span class="gr-diff tab" style="tab-size:8;"></span>'
+ );
+ await check(
+ 'abc\t',
+ /* HTML */ 'abc<span class="gr-diff tab" style="tab-size:5;"></span>'
+ );
+ });
+
+ test('tab wrapper insertion', async () => {
+ await check('abc\tdef', 'abc' + TAB + 'def', TAB_IGNORE);
+ });
+
+ test('escaping HTML', async () => {
+ element.lineLimit = 100;
+ await element.updateComplete;
+ await check(
+ '<script>alert("XSS");<' + '/script>',
+ '&lt;script&gt;alert("XSS");&lt;/script&gt;'
+ );
+ await check('& < > " \' / `', '&amp; &lt; &gt; " \' / `');
+ });
+
+ test('text length with tabs and unicode', async () => {
+ async function expectTextLength(
+ text: string,
+ tabSize: number,
+ expected: number
+ ) {
+ element.text = text;
+ element.tabSize = tabSize;
+ element.lineLimit = expected;
+ await element.updateComplete;
+ const result = element.innerHTML;
+
+ // Must not contain a line break.
+ assert.isNotOk(element.querySelector('span.br'));
+
+ // Increasing the line limit by 1 should not change anything.
+ element.lineLimit = expected + 1;
+ await element.updateComplete;
+ const resultPlusOne = element.innerHTML;
+ assert.equal(resultPlusOne, result);
+
+ // Increasing the line limit to infinity should not change anything.
+ element.lineLimit = Infinity;
+ await element.updateComplete;
+ const resultInf = element.innerHTML;
+ assert.equal(resultInf, result);
+
+ // Decreasing the line limit by 1 should introduce a line break.
+ element.lineLimit = expected + 1;
+ await element.updateComplete;
+ assert.isNotOk(element.querySelector('span.br'));
+ }
+ expectTextLength('12345', 4, 5);
+ expectTextLength('\t\t12', 4, 10);
+ expectTextLength('abc💢123', 4, 7);
+ expectTextLength('abc\t', 8, 8);
+ expectTextLength('abc\t\t', 10, 20);
+ expectTextLength('', 10, 0);
+ // 17 Thai combining chars.
+ expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+ expectTextLength('abc\tde', 10, 12);
+ expectTextLength('abc\tde\t', 10, 20);
+ expectTextLength('\t\t\t\t\t', 20, 100);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
index 19e0e22c2a..e9076aa38d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer.ts
@@ -15,6 +15,7 @@ import {
getLineNumberByChild,
lineNumberToNumber,
} from '../gr-diff/gr-diff-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
const tokenMatcher = new RegExp(/[\w]+/g);
@@ -89,14 +90,43 @@ export class TokenHighlightLayer implements DiffLayer {
private updateTokenTask?: DelayedTask;
+ /**
+ * Container that contains all annotated tokens and contains no shadow root
+ * elements that would prevent tokens to be queryable by querySelectorAll.
+ */
+ private getTokenQueryContainer?: () => HTMLElement;
+
+ /**
+ * @param container for registering "deselect" click
+ * @param tokenHighlightListener method that is called,
+ * when token is highlighted.
+ * @param getTokenQueryContainer if specified, list of tokens to be
+ * highlighted are recalculated every time using querySelectorAll inside
+ * this element. Otherwise, the pointers calculated once at annotate() time
+ * and are reused.
+ */
constructor(
container: HTMLElement,
- tokenHighlightListener?: TokenHighlightListener
+ tokenHighlightListener?: TokenHighlightListener,
+ getTokenQueryContainer?: () => HTMLElement
) {
this.tokenHighlightListener = tokenHighlightListener;
container.addEventListener('click', e => {
this.handleContainerClick(e);
});
+ this.getTokenQueryContainer = getTokenQueryContainer;
+ }
+
+ static createTokenHighlightContainer(
+ container: HTMLElement,
+ getGrDiff: () => GrDiff,
+ tokenHighlightListener?: TokenHighlightListener
+ ): TokenHighlightLayer {
+ return new TokenHighlightLayer(
+ container,
+ tokenHighlightListener,
+ () => getGrDiff().diffTable!
+ );
}
annotate(el: HTMLElement, _1: HTMLElement, _2: GrDiffLine, _3: Side): void {
@@ -109,11 +139,17 @@ export class TokenHighlightLayer implements DiffLayer {
let atLeastOneTokenMatched = false;
while ((match = tokenMatcher.exec(text))) {
const token = match[0];
- const index = match.index;
- const length = token.length;
+
// Binary files encoded as text for example can have super long lines
// with super long tokens. Let's guard against this scenario.
- if (length > TOKEN_LENGTH_LIMIT) continue;
+ if (token.length > TOKEN_LENGTH_LIMIT) continue;
+
+ // This is to correctly count surrogate pairs in text and token.
+ // If the index calculation becomes a hotspot, we could precompute a code
+ // unit to code point index map for text before iterating over the results
+ const index = GrAnnotation.getStringLength(text.slice(0, match.index));
+ const length = GrAnnotation.getStringLength(token);
+
atLeastOneTokenMatched = true;
const highlightTypeClass =
token === this.currentHighlight ? CSS_HIGHLIGHT : '';
@@ -265,8 +301,19 @@ export class TokenHighlightLayer implements DiffLayer {
if (!token) {
return;
}
- const tokenEls = this.tokenToElements.get(token);
- if (!tokenEls) {
+ let tokenEls;
+ let tokenElsLength;
+ if (this.getTokenQueryContainer) {
+ tokenEls = this.getTokenQueryContainer().querySelectorAll(
+ `.${TOKEN_TEXT_PREFIX}${token}`
+ );
+ tokenElsLength = tokenEls.length;
+ } else {
+ tokenEls = this.tokenToElements.get(token);
+ tokenElsLength = tokenEls?.size;
+ }
+ if (!tokenEls || tokenElsLength === 0) {
+ console.warn(`No tokens have been found for '${token}'`);
return;
}
for (const el of tokenEls) {
@@ -298,7 +345,7 @@ export class TokenHighlightLayer implements DiffLayer {
start_line: line,
start_column: index + 1, // 1-based inclusive
end_line: line,
- end_column: index + token.length, // 1-based inclusive
+ end_column: index + GrAnnotation.getStringLength(token), // 1-based inclusive
};
this.tokenHighlightListener({token, element, side, range});
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
index 0e2def0329..8fd03bbd7f 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -105,15 +105,17 @@ suite('token-highlight-layer', () => {
suite('annotate', () => {
function assertAnnotation(
args: any[],
- el: HTMLElement,
- start: number,
- length: number,
- cssClass: string
+ expected: {
+ parent: HTMLElement;
+ offset: number;
+ length: number;
+ cssClass: string;
+ }
) {
- assert.equal(args[0], el);
- assert.equal(args[1], start);
- assert.equal(args[2], length);
- assert.equal(args[3], cssClass);
+ assert.equal(args[0], expected.parent);
+ assert.equal(args[1], expected.offset);
+ assert.equal(args[2], expected.length);
+ assert.equal(args[3], expected.cssClass);
}
test('annotate adds css token', () => {
@@ -121,27 +123,51 @@ suite('token-highlight-layer', () => {
const el = createLine('these are words');
annotate(el);
assert.isTrue(annotateElementStub.calledThrice);
- assertAnnotation(
- annotateElementStub.args[0],
- el,
- 0,
- 5,
- 'tk-text-these tk-index-0 token '
- );
- assertAnnotation(
- annotateElementStub.args[1],
- el,
- 6,
- 3,
- 'tk-text-are tk-index-6 token '
- );
- assertAnnotation(
- annotateElementStub.args[2],
- el,
- 10,
- 5,
- 'tk-text-words tk-index-10 token '
- );
+ assertAnnotation(annotateElementStub.args[0], {
+ parent: el,
+ offset: 0,
+ length: 5,
+ cssClass: 'tk-text-these tk-index-0 token ',
+ });
+ assertAnnotation(annotateElementStub.args[1], {
+ parent: el,
+ offset: 6,
+ length: 3,
+ cssClass: 'tk-text-are tk-index-6 token ',
+ });
+ assertAnnotation(annotateElementStub.args[2], {
+ parent: el,
+ offset: 10,
+ length: 5,
+ cssClass: 'tk-text-words tk-index-10 token ',
+ });
+ });
+
+ test('annotate adds css tokens w/ emojis', () => {
+ const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+ const el = createLine('these 💩 are 👨‍👩‍👧‍👦 words');
+
+ annotate(el);
+
+ assert.isTrue(annotateElementStub.calledThrice);
+ assertAnnotation(annotateElementStub.args[0], {
+ parent: el,
+ offset: 0,
+ length: 5,
+ cssClass: 'tk-text-these tk-index-0 token ',
+ });
+ assertAnnotation(annotateElementStub.args[1], {
+ parent: el,
+ offset: 8,
+ length: 3,
+ cssClass: 'tk-text-are tk-index-8 token ',
+ });
+ assertAnnotation(annotateElementStub.args[2], {
+ parent: el,
+ offset: 20,
+ length: 5,
+ cssClass: 'tk-text-words tk-index-20 token ',
+ });
});
test('annotate adds mouse handlers', () => {
@@ -335,5 +361,44 @@ suite('token-highlight-layer', () => {
assert.equal(listener.pending, 0);
assert.isTrue(words1.classList.contains('token-highlight'));
});
+
+ test('query based highlighting', async () => {
+ highlighter = new TokenHighlightLayer(
+ container,
+ tokenHighlightListener,
+ /* getTokenQueryContainer=*/ () => container
+ );
+ const clock = sinon.useFakeTimers();
+ const line1 = createLine('two words');
+ annotate(line1);
+ const line2 = createLine('three words', 2);
+ annotate(line2, Side.RIGHT, 2);
+ // Invalidate pointers.
+ for (const child of line1.childNodes) {
+ line1.replaceChild(child.cloneNode(), child);
+ }
+ for (const child of line2.childNodes) {
+ line2.replaceChild(child.cloneNode(), child);
+ }
+
+ const words1 = queryAndAssert(line1, '.tk-text-words');
+ assert.isTrue(words1.classList.contains('token'));
+ dispatchMouseEvent('mouseover', words1);
+ assert.equal(tokenHighlightingCalls.length, 0);
+ clock.tick(HOVER_DELAY_MS);
+ assert.equal(tokenHighlightingCalls.length, 1);
+ assert.deepEqual(tokenHighlightingCalls[0].details, {
+ token: 'words',
+ side: Side.RIGHT,
+ element: words1,
+ range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+ });
+ assert.isTrue(words1.classList.contains('token-highlight'));
+
+ container.click();
+ assert.equal(tokenHighlightingCalls.length, 2);
+ assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+ assert.isFalse(words1.classList.contains('token-highlight'));
+ });
});
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
index 35439d6713..9e3640bfad 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -8,7 +8,8 @@ import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
import {
DiffViewMode,
GrDiffCursor as GrDiffCursorApi,
- LineNumberEventDetail,
+ LineNumber,
+ LineSelectedEventDetail,
} from '../../../api/diff';
import {ScrollMode, Side} from '../../../constants/constants';
import {toggleClass} from '../../../utils/dom-util';
@@ -19,6 +20,7 @@ import {
import {GrDiffLineType} from '../gr-diff/gr-diff-line';
import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
import {GrDiff} from '../gr-diff/gr-diff';
+import {fire} from '../../../utils/event-util';
type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
@@ -30,6 +32,29 @@ interface Address {
number: number;
}
+/**
+ * From <tr> diff row go up to <tbody> diff chunk.
+ *
+ * In Lit based diff there is a <gr-diff-row> element in between the two.
+ */
+export function fromRowToChunk(
+ rowEl: HTMLElement
+): HTMLTableSectionElement | undefined {
+ const parent = rowEl.parentElement;
+ if (!parent) return undefined;
+ if (parent.tagName === 'TBODY') {
+ return parent as HTMLTableSectionElement;
+ }
+
+ const grandParent = parent.parentElement;
+ if (!grandParent) return undefined;
+ if (grandParent.tagName === 'TBODY') {
+ return grandParent as HTMLTableSectionElement;
+ }
+
+ return undefined;
+}
+
/** A subset of the GrDiff API that the cursor is using. */
export interface GrDiffCursorable extends HTMLElement {
isRangeSelected(): boolean;
@@ -179,8 +204,7 @@ export class GrDiffCursor implements GrDiffCursorApi {
moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
const result = this.cursorManager.next({
filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
- getTargetHeight: target =>
- (target?.parentNode as HTMLElement)?.scrollHeight || 0,
+ getTargetHeight: target => fromRowToChunk(target)?.scrollHeight || 0,
clipToTop,
});
this._fixSide();
@@ -215,7 +239,7 @@ export class GrDiffCursor implements GrDiffCursorApi {
}
moveToLineNumber(
- number: number,
+ number: LineNumber,
side: Side,
path?: string,
intentionalMove?: boolean
@@ -330,13 +354,10 @@ export class GrDiffCursor implements GrDiffCursorApi {
this.preventAutoScrollOnManualScroll = false;
};
- private _boundHandleDiffLineSelected = (event: Event) => {
- const customEvent = event as CustomEvent;
- this.moveToLineNumber(
- customEvent.detail.number,
- customEvent.detail.side,
- customEvent.detail.path
- );
+ private _boundHandleDiffLineSelected = (
+ e: CustomEvent<LineSelectedEventDetail>
+ ) => {
+ this.moveToLineNumber(e.detail.number, e.detail.side, e.detail.path);
};
createCommentInPlace() {
@@ -413,17 +434,21 @@ export class GrDiffCursor implements GrDiffCursorApi {
}
_isFirstRowOfChunk(row: HTMLElement) {
- const parentClassList = (row.parentNode as HTMLElement).classList;
- const isInChunk =
- parentClassList.contains('section') && parentClassList.contains('delta');
- const previousRow = row.previousSibling as HTMLElement;
- const firstContentRow =
- !previousRow || previousRow.classList.contains('moveControls');
- return isInChunk && firstContentRow;
+ const chunk = fromRowToChunk(row);
+ if (!chunk) return false;
+
+ const isInDeltaChunk = chunk.classList.contains('delta');
+ if (!isInDeltaChunk) return false;
+
+ const firstRow = chunk.querySelector('tr:not(.moveControls)');
+ return firstRow === row;
}
_rowHasThread(row: HTMLElement): boolean {
- return !!row.querySelector('.thread-group');
+ const slots = [
+ ...row.querySelectorAll<HTMLSlotElement>('.thread-group > slot'),
+ ];
+ return slots.some(slot => slot.assignedElements().length > 0);
}
/**
@@ -459,16 +484,10 @@ export class GrDiffCursor implements GrDiffCursorApi {
const address = this.getAddressFor(row, side);
if (address) {
const {leftSide, number} = address;
- row.dispatchEvent(
- new CustomEvent<LineNumberEventDetail>(event, {
- detail: {
- lineNum: number,
- side: leftSide ? Side.LEFT : Side.RIGHT,
- },
- composed: true,
- bubbles: true,
- })
- );
+ fire(row, event, {
+ lineNum: number,
+ side: leftSide ? Side.LEFT : Side.RIGHT,
+ });
}
}
@@ -554,7 +573,7 @@ export class GrDiffCursor implements GrDiffCursorApi {
}
_findRowByNumberAndFile(
- targetNumber: number,
+ targetNumber: LineNumber,
side: Side,
path?: string
): HTMLElement | undefined {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
index 1e554b742b..61f8551255 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-cursor/gr-diff-cursor_test.ts
@@ -7,7 +7,12 @@ import '../../../test/common-test-setup';
import '../gr-diff/gr-diff';
import './gr-diff-cursor';
import {fixture, html, assert} from '@open-wc/testing';
-import {mockPromise, queryAll, queryAndAssert} from '../../../test/test-utils';
+import {
+ mockPromise,
+ queryAll,
+ queryAndAssert,
+ waitUntil,
+} from '../../../test/test-utils';
import {createDiff} from '../../../test/test-data-generators';
import {createDefaultDiffPrefs} from '../../../constants/constants';
import {GrDiffCursor} from './gr-diff-cursor';
@@ -46,32 +51,23 @@ suite('gr-diff-cursor tests', () => {
});
test('diff cursor functionality (side-by-side)', () => {
- // The cursor has been initialized to the first delta.
assert.isOk(cursor.diffRow);
- const firstDeltaRow = queryAndAssert<HTMLElement>(
+ const deltaRows = queryAll<HTMLTableRowElement>(
diffElement,
- '.section.delta .diff-row'
+ '.section.delta tr.diff-row'
);
- assert.equal(cursor.diffRow, firstDeltaRow);
+ assert.equal(cursor.diffRow, deltaRows[0]);
cursor.moveDown();
- assert.isOk(firstDeltaRow.nextElementSibling);
- assert.notEqual(cursor.diffRow, firstDeltaRow);
- assert.equal(
- cursor.diffRow,
- firstDeltaRow.nextElementSibling as HTMLElement
- );
+ assert.notEqual(cursor.diffRow, deltaRows[0]);
+ assert.equal(cursor.diffRow, deltaRows[1]);
cursor.moveUp();
- assert.isOk(firstDeltaRow.nextElementSibling);
- assert.notEqual(
- cursor.diffRow,
- firstDeltaRow.nextElementSibling as HTMLElement
- );
- assert.equal(cursor.diffRow, firstDeltaRow);
+ assert.notEqual(cursor.diffRow, deltaRows[1]);
+ assert.equal(cursor.diffRow, deltaRows[0]);
});
test('moveToFirstChunk', async () => {
@@ -115,20 +111,26 @@ suite('gr-diff-cursor tests', () => {
] as HTMLElement[];
assert.equal(chunks.length, 2);
+ const rows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(rows.length, 2);
+
// Verify it works on fresh diff.
cursor.moveToFirstChunk();
assert.ok(cursor.diffRow);
- assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+ assert.equal(cursor.diffRow, rows[0]);
assert.equal(cursor.side, Side.RIGHT);
// Verify it works from other cursor positions.
cursor.moveToNextChunk();
assert.ok(cursor.diffRow);
- assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+ assert.equal(cursor.diffRow, rows[1]);
assert.equal(cursor.side, Side.LEFT);
+
cursor.moveToFirstChunk();
assert.ok(cursor.diffRow);
- assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+ assert.equal(cursor.diffRow, rows[0]);
assert.equal(cursor.side, Side.RIGHT);
});
@@ -164,20 +166,31 @@ suite('gr-diff-cursor tests', () => {
await waitForEventOnce(diffElement, 'render');
cursor._updateStops();
- const chunks = [...queryAll(diffElement, '.section.delta')];
+ const chunks = [
+ ...queryAll(diffElement, '.section.delta'),
+ ] as HTMLElement[];
assert.equal(chunks.length, 2);
+ const rows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(rows.length, 2);
+
// Verify it works on fresh diff.
cursor.moveToLastChunk();
- assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[1]);
assert.equal(cursor.side, Side.RIGHT);
// Verify it works from other cursor positions.
cursor.moveToPreviousChunk();
- assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 0);
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[0]);
assert.equal(cursor.side, Side.LEFT);
+
cursor.moveToLastChunk();
- assert.equal(chunks.indexOf(cursor.diffRow!.parentElement!), 1);
+ assert.ok(cursor.diffRow);
+ assert.equal(cursor.diffRow, rows[1]);
assert.equal(cursor.side, Side.RIGHT);
});
@@ -221,30 +234,22 @@ suite('gr-diff-cursor tests', () => {
});
test('diff cursor functionality (unified)', () => {
- // The cursor has been initialized to the first delta.
assert.isOk(cursor.diffRow);
- const firstDeltaRow = queryAndAssert<HTMLElement>(
- diffElement,
- '.section.delta .diff-row'
- );
- assert.equal(cursor.diffRow, firstDeltaRow);
+ const rows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(cursor.diffRow, rows[0]);
cursor.moveDown();
- assert.notEqual(cursor.diffRow, firstDeltaRow);
- assert.equal(
- cursor.diffRow,
- firstDeltaRow.nextElementSibling as HTMLElement
- );
+ assert.notEqual(cursor.diffRow, rows[0]);
+ assert.equal(cursor.diffRow, rows[1]);
cursor.moveUp();
- assert.notEqual(
- cursor.diffRow,
- firstDeltaRow.nextElementSibling as HTMLElement
- );
- assert.equal(cursor.diffRow, firstDeltaRow);
+ assert.notEqual(cursor.diffRow, rows[1]);
+ assert.equal(cursor.diffRow, rows[0]);
});
});
@@ -253,19 +258,21 @@ suite('gr-diff-cursor tests', () => {
// mode.
assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
- const firstDeltaSection = queryAndAssert<HTMLElement>(
- diffElement,
- '.section.delta'
- );
- const firstDeltaRow = queryAndAssert<HTMLElement>(
- firstDeltaSection,
- '.diff-row'
- );
+ const rows = [
+ ...queryAll(diffElement, '.section tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(rows.length, 50);
+ const deltaRows = [
+ ...queryAll(diffElement, '.section.delta tr.diff-row'),
+ ] as HTMLTableRowElement[];
+ assert.equal(deltaRows.length, 14);
+ const indexFirstDelta = rows.indexOf(deltaRows[0]);
+ const rowBeforeFirstDelta = rows[indexFirstDelta - 1];
// Because the first delta in this diff is on the right, it should be set
// to the right side.
assert.equal(cursor.side, Side.RIGHT);
- assert.equal(cursor.diffRow, firstDeltaRow);
+ assert.equal(cursor.diffRow, deltaRows[0]);
const firstIndex = cursor.cursorManager.index;
// Move the side to the left. Because this delta only has a right side, we
@@ -274,33 +281,26 @@ suite('gr-diff-cursor tests', () => {
cursor.moveLeft();
assert.equal(cursor.side, Side.LEFT);
- assert.notEqual(cursor.diffRow, firstDeltaRow);
+ assert.notEqual(cursor.diffRow, rows[0]);
+ assert.equal(cursor.diffRow, rowBeforeFirstDelta);
assert.equal(cursor.cursorManager.index, firstIndex - 1);
- assert.equal(
- cursor.diffRow!.parentElement,
- firstDeltaSection.previousSibling
- );
// If we move down, we should skip everything in the first delta because
// we are on the left side and the first delta has no content on the left.
cursor.moveDown();
assert.equal(cursor.side, Side.LEFT);
- assert.notEqual(cursor.diffRow, firstDeltaRow);
+ assert.notEqual(cursor.diffRow, rowBeforeFirstDelta);
+ assert.notEqual(cursor.diffRow, rows[0]);
assert.isTrue(cursor.cursorManager.index > firstIndex);
- assert.equal(cursor.diffRow!.parentElement, firstDeltaSection.nextSibling);
});
test('chunk skip functionality', () => {
- const chunks = [...queryAll(diffElement, '.section.delta')];
- const indexOfChunk = function (chunk: HTMLElement) {
- return Array.prototype.indexOf.call(chunks, chunk);
- };
+ const deltaChunks = [...queryAll(diffElement, 'tbody.section.delta')];
// We should be initialized to the first chunk. Since this chunk only has
// content on the right side, our side should be right.
- let currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
- assert.equal(currentIndex, 0);
+ assert.equal(cursor.diffRow, deltaChunks[0].querySelector('tr'));
assert.equal(cursor.side, Side.RIGHT);
// Move to the next chunk.
@@ -308,9 +308,7 @@ suite('gr-diff-cursor tests', () => {
// Since this chunk only has content on the left side. we should have been
// automatically moved over.
- const previousIndex = currentIndex;
- currentIndex = indexOfChunk(cursor.diffRow!.parentElement!);
- assert.equal(currentIndex, previousIndex + 1);
+ assert.equal(cursor.diffRow, deltaChunks[1].querySelector('tr'));
assert.equal(cursor.side, Side.LEFT);
});
@@ -358,10 +356,10 @@ suite('gr-diff-cursor tests', () => {
test('renders moveControls with simple descriptions', () => {
const [movedIn, movedOut] = [
- ...queryAll(diffElement, '.dueToMove .moveControls'),
+ ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
];
- assert.equal(movedIn.textContent, 'Moved in');
- assert.equal(movedOut.textContent, 'Moved out');
+ assert.include(movedIn.innerText, 'Moved in');
+ assert.include(movedOut.innerText, 'Moved out');
});
});
@@ -409,10 +407,10 @@ suite('gr-diff-cursor tests', () => {
test('renders moveControls with simple descriptions', () => {
const [movedIn, movedOut] = [
- ...queryAll(diffElement, '.dueToMove .moveControls'),
+ ...queryAll<HTMLElement>(diffElement, '.dueToMove tr.moveControls'),
];
- assert.equal(movedIn.textContent, 'Moved from lines 4 - 6');
- assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
+ assert.include(movedIn.innerText, 'Moved from lines 4 - 6');
+ assert.include(movedOut.innerText, 'Moved to lines 2 - 4');
});
test('startLineAnchor of movedIn chunk fires events', async () => {
@@ -609,6 +607,7 @@ suite('gr-diff-cursor tests', () => {
const showContext = queryAndAssert<HTMLElement>(controls, '.showContext');
showContext.click();
await waitForEventOnce(diffElement, 'render');
+ await waitUntil(() => spy.called);
assert.isTrue(spy.called);
});
@@ -661,7 +660,7 @@ suite('gr-diff-cursor tests', () => {
// Goto second last line of the first diff
cursor.moveToLineNumber(lastLine - 1, Side.RIGHT);
assert.equal(
- cursor.getTargetLineElement()!.textContent,
+ cursor.getTargetLineElement()!.textContent?.trim(),
`${lastLine - 1}`
);
@@ -669,7 +668,7 @@ suite('gr-diff-cursor tests', () => {
cursor.moveDown();
assert.equal(getTargetDiffIndex(), 0);
assert.equal(
- cursor.getTargetLineElement()!.textContent,
+ cursor.getTargetLineElement()!.textContent?.trim(),
lastLine.toString()
);
@@ -677,7 +676,7 @@ suite('gr-diff-cursor tests', () => {
cursor.moveDown();
assert.equal(getTargetDiffIndex(), 0);
assert.equal(
- cursor.getTargetLineElement()!.textContent,
+ cursor.getTargetLineElement()!.textContent?.trim(),
lastLine.toString()
);
@@ -686,9 +685,10 @@ suite('gr-diff-cursor tests', () => {
await waitForEventOnce(diffElements[1], 'render');
// Now we can go down
- cursor.moveDown();
+ cursor.moveDown(); // LOST
+ cursor.moveDown(); // FILE
assert.equal(getTargetDiffIndex(), 1);
- assert.equal(cursor.getTargetLineElement()!.textContent, 'File');
+ assert.equal(cursor.getTargetLineElement()!.textContent?.trim(), 'File');
});
});
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
index cc7cd49e71..38bd707bff 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation.ts
@@ -22,8 +22,14 @@ export const GrAnnotation = {
return this.getStringLength(node.textContent || '');
},
+ /**
+ * Returns the number of Unicode code points in the given string
+ *
+ * This is not necessarily the same as the number of visible symbols.
+ * See https://mathiasbynens.be/notes/javascript-unicode for more details.
+ */
getStringLength(str: string) {
- return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+ return [...str].length;
},
/**
@@ -165,18 +171,20 @@ export const GrAnnotation = {
cssClass: string,
firstPart?: boolean
) {
- if (this.getLength(node) === offset || offset === 0) {
+ if (
+ (this.getLength(node) === offset && firstPart) ||
+ (offset === 0 && !firstPart)
+ ) {
return this.wrapInHighlight(node, cssClass);
+ }
+ if (firstPart) {
+ this.splitNode(node, offset);
+ // Node points to first part of the Text, second one is sibling.
} else {
- if (firstPart) {
- this.splitNode(node, offset);
- // Node points to first part of the Text, second one is sibling.
- } else {
- // if node is Text then splitNode will return a Text
- node = this.splitNode(node, offset) as Text;
- }
- return this.wrapInHighlight(node, cssClass);
+ // if node is Text then splitNode will return a Text
+ node = this.splitNode(node, offset) as Text;
}
+ return this.wrapInHighlight(node, cssClass);
},
/**
@@ -219,7 +227,6 @@ export const GrAnnotation = {
*/
splitTextNode(node: Text, offset: number) {
if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
- // TODO (viktard): Polyfill Array.from for IE10.
const head = Array.from(node.textContent);
const tail = head.splice(offset);
const parent = node.parentNode;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
index c5cab0cfa1..f319a3c837 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -7,96 +7,97 @@
import '../../../test/common-test-setup';
import {GrAnnotation} from './gr-annotation';
import {
- sanitizeDOMValue,
+ getSanitizeDOMValue,
setSanitizeDOMValue,
} from '@polymer/polymer/lib/utils/settings';
-// eslint-disable-next-line import/named
import {assert, fixture, html} from '@open-wc/testing';
suite('annotation', () => {
- let str;
- let parent;
- let textNode;
+ let str: string;
+ let parent: HTMLDivElement;
+ let textNode: Text;
setup(async () => {
parent = await fixture(
- html`
+ html`
<div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
`
);
- textNode = parent.childNodes[0];
- str = textNode.textContent;
+ textNode = parent.childNodes[0] as Text;
+ str = textNode.textContent!;
});
- test('_annotateText Case 1', () => {
- GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
+ test('_annotateText length:0 offset:0', () => {
+ GrAnnotation._annotateText(textNode, 0, 0, 'foobar');
- assert.equal(parent.childNodes.length, 1);
- assert.instanceOf(parent.childNodes[0], HTMLElement);
- assert.equal(parent.childNodes[0].className, 'foobar');
- assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
- assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ '<hl class="foobar"></hl>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula'
+ );
});
- test('_annotateText Case 2', () => {
- const length = 12;
- const substr = str.substr(0, length);
- const remainder = str.substr(length);
-
- GrAnnotation._annotateText(textNode, 0, length, 'foobar');
+ test('_annotateText length:0 offset:1', () => {
+ GrAnnotation._annotateText(textNode, 1, 0, 'foobar');
- assert.equal(parent.childNodes.length, 2);
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'L<hl class="foobar"></hl>orem ipsum dolor sit amet, suspendisse inceptos vehicula'
+ );
+ });
- assert.instanceOf(parent.childNodes[0], HTMLElement);
- assert.equal(parent.childNodes[0].className, 'foobar');
- assert.instanceOf(parent.childNodes[0].childNodes[0], Text);
- assert.equal(parent.childNodes[0].childNodes[0].textContent, substr);
+ test('_annotateText length:0 offset:str.length', () => {
+ GrAnnotation._annotateText(textNode, str.length, 0, 'foobar');
- assert.instanceOf(parent.childNodes[1], Text);
- assert.equal(parent.childNodes[1].textContent, remainder);
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula<hl class="foobar"></hl>'
+ );
});
- test('_annotateText Case 3', () => {
- const index = 12;
- const length = str.length - index;
- const remainder = str.substr(0, index);
- const substr = str.substr(index);
+ test('_annotateText Case 1', () => {
+ GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
- GrAnnotation._annotateText(textNode, index, length, 'foobar');
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ '<hl class="foobar">Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</hl>'
+ );
+ });
+
+ test('_annotateText Case 2', () => {
+ GrAnnotation._annotateText(textNode, 0, 12, 'foobar');
- assert.equal(parent.childNodes.length, 2);
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ '<hl class="foobar">Lorem ipsum </hl>dolor sit amet, suspendisse inceptos vehicula'
+ );
+ });
- assert.instanceOf(parent.childNodes[0], Text);
- assert.equal(parent.childNodes[0].textContent, remainder);
+ test('_annotateText Case 3', () => {
+ GrAnnotation._annotateText(textNode, 12, str.length - 12, 'foobar');
- assert.instanceOf(parent.childNodes[1], HTMLElement);
- assert.equal(parent.childNodes[1].className, 'foobar');
- assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
- assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum <hl class="foobar">dolor sit amet, suspendisse inceptos vehicula</hl>'
+ );
});
test('_annotateText Case 4', () => {
const index = str.indexOf('dolor');
const length = 'dolor '.length;
- const remainderPre = str.substr(0, index);
- const substr = str.substr(index, length);
- const remainderPost = str.substr(index + length);
-
GrAnnotation._annotateText(textNode, index, length, 'foobar');
- assert.equal(parent.childNodes.length, 3);
-
- assert.instanceOf(parent.childNodes[0], Text);
- assert.equal(parent.childNodes[0].textContent, remainderPre);
-
- assert.instanceOf(parent.childNodes[1], HTMLElement);
- assert.equal(parent.childNodes[1].className, 'foobar');
- assert.instanceOf(parent.childNodes[1].childNodes[0], Text);
- assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
-
- assert.instanceOf(parent.childNodes[2], Text);
- assert.equal(parent.childNodes[2].textContent, remainderPost);
+ assert.equal(parent.textContent, str);
+ assert.equal(
+ parent.innerHTML,
+ 'Lorem ipsum <hl class="foobar">dolor </hl>sit amet, suspendisse inceptos vehicula'
+ );
});
test('_annotateElement design doc example', () => {
@@ -105,49 +106,17 @@ suite('annotation', () => {
// Apply the layers successively.
layers.forEach((layer, i) => {
GrAnnotation.annotateElement(
- parent,
- str.indexOf(layer),
- layer.length,
- `layer-${i + 1}`
+ parent,
+ str.indexOf(layer),
+ layer.length,
+ `layer-${i + 1}`
);
});
assert.equal(parent.textContent, str);
-
- // Layer 1:
- const layer1 = parent.querySelectorAll('.layer-1');
- assert.equal(layer1.length, 1);
- assert.equal(layer1[0].textContent, layers[0]);
- assert.equal(layer1[0].parentElement, parent);
-
- // Layer 2:
- const layer2 = parent.querySelectorAll('.layer-2');
- assert.equal(layer2.length, 1);
- assert.equal(layer2[0].textContent, layers[1]);
- assert.equal(layer2[0].parentElement, parent);
-
- // Layer 3:
- const layer3 = parent.querySelectorAll('.layer-3');
- assert.equal(layer3.length, 1);
- assert.equal(layer3[0].textContent, layers[2]);
- assert.equal(layer3[0].parentElement, layer1[0]);
-
- // Layer 4:
- const layer4 = parent.querySelectorAll('.layer-4');
- assert.equal(layer4.length, 3);
-
- assert.equal(layer4[0].textContent, 'et, ');
- assert.equal(layer4[0].parentElement, layer3[0]);
-
- assert.equal(layer4[1].textContent, 'suspendisse ');
- assert.equal(layer4[1].parentElement, parent);
-
- assert.equal(layer4[2].textContent, 'ince');
- assert.equal(layer4[2].parentElement, layer2[0]);
-
assert.equal(
- layer4[0].textContent + layer4[1].textContent + layer4[2].textContent,
- layers[3]
+ parent.innerHTML,
+ 'Lorem ipsum dolor sit <hl class="layer-1"><hl class="layer-3">am<hl class="layer-4">et, </hl></hl></hl><hl class="layer-4">suspendisse </hl><hl class="layer-2"><hl class="layer-4">ince</hl>ptos </hl>vehicula'
);
});
@@ -174,12 +143,17 @@ suite('annotation', () => {
suite('annotateWithElement', () => {
const fullText = '01234567890123456789';
- let mockSanitize;
- let originalSanitizeDOMValue;
+ let mockSanitize: sinon.SinonSpy;
+ let originalSanitizeDOMValue: (
+ p0: any,
+ p1: string,
+ p2: string,
+ p3: Node | null
+ ) => any;
setup(() => {
- setSanitizeDOMValue((p0, p1, p2, node) => p0);
- originalSanitizeDOMValue = sanitizeDOMValue;
+ setSanitizeDOMValue(p0 => p0);
+ originalSanitizeDOMValue = getSanitizeDOMValue()!;
assert.isDefined(originalSanitizeDOMValue);
mockSanitize = sinon.spy(originalSanitizeDOMValue);
setSanitizeDOMValue(mockSanitize);
@@ -198,8 +172,8 @@ suite('annotation', () => {
});
assert.equal(
- container.innerHTML,
- '0<test-wrapper>1234567890</test-wrapper>123456789'
+ container.innerHTML,
+ '0<test-wrapper>1234567890</test-wrapper>123456789'
);
});
@@ -213,8 +187,8 @@ suite('annotation', () => {
});
assert.equal(
- container.innerHTML,
- '0' +
+ container.innerHTML,
+ '0' +
'<test-wrapper>' +
'1234' +
'<hl class="testclass">567890</hl>' +
@@ -233,8 +207,8 @@ suite('annotation', () => {
});
assert.equal(
- container.innerHTML,
- '0<test-wrapper>1234567890</test-wrapper>123456789'
+ container.innerHTML,
+ '0<test-wrapper>1234567890</test-wrapper>123456789'
);
});
@@ -248,8 +222,8 @@ suite('annotation', () => {
});
assert.equal(
- container.innerHTML,
- '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
+ container.innerHTML,
+ '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789'
);
});
@@ -265,8 +239,8 @@ suite('annotation', () => {
});
assert.equal(
- container.innerHTML,
- '<!--comment1-->' +
+ container.innerHTML,
+ '<!--comment1-->' +
'0<test-wrapper>123456789' +
'<!--comment2-->' +
'<span></span>0</test-wrapper>123456789'
@@ -277,42 +251,58 @@ suite('annotation', () => {
const container = document.createElement('div');
container.textContent = fullText;
const attributes = {
- 'href': 'foo',
+ href: 'foo',
'data-foo': 'bar',
- 'class': 'hello world',
+ class: 'hello world',
};
GrAnnotation.annotateWithElement(container, 1, length, {
tagName: 'test-wrapper',
attributes,
});
assert(
- mockSanitize.calledWith(
- 'foo',
- 'href',
- 'attribute',
- sinon.match.instanceOf(Element)
- )
+ mockSanitize.calledWith(
+ 'foo',
+ 'href',
+ 'attribute',
+ sinon.match.instanceOf(Element)
+ )
);
assert(
- mockSanitize.calledWith(
- 'bar',
- 'data-foo',
- 'attribute',
- sinon.match.instanceOf(Element)
- )
+ mockSanitize.calledWith(
+ 'bar',
+ 'data-foo',
+ 'attribute',
+ sinon.match.instanceOf(Element)
+ )
);
assert(
- mockSanitize.calledWith(
- 'hello world',
- 'class',
- 'attribute',
- sinon.match.instanceOf(Element)
- )
+ mockSanitize.calledWith(
+ 'hello world',
+ 'class',
+ 'attribute',
+ sinon.match.instanceOf(Element)
+ )
);
- const el = container.querySelector('test-wrapper');
+ const el = container.querySelector('test-wrapper')!;
assert.equal(el.getAttribute('href'), 'foo');
assert.equal(el.getAttribute('data-foo'), 'bar');
assert.equal(el.getAttribute('class'), 'hello world');
});
});
+
+ suite('getStringLength', () => {
+ test('ASCII characters are counted correctly', () => {
+ assert.equal(GrAnnotation.getStringLength('ASCII'), 5);
+ });
+
+ test('Unicode surrogate pairs count as one symbol', () => {
+ assert.equal(GrAnnotation.getStringLength('Unic💢de'), 7);
+ assert.equal(GrAnnotation.getStringLength('💢💢'), 2);
+ });
+
+ test('Grapheme clusters count as multiple symbols', () => {
+ assert.equal(GrAnnotation.getStringLength('man\u0303ana'), 7); // mañana
+ assert.equal(GrAnnotation.getStringLength('q\u0307\u0323'), 3); // q̣̇
+ });
+ });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
index 0714645bbf..69c0f5c43c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -20,6 +20,7 @@ import {
} from '../gr-diff/gr-diff-utils';
import {debounce, DelayedTask} from '../../../utils/async-util';
import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
interface SidedRange {
side: Side;
@@ -43,7 +44,7 @@ interface NormalizedRange {
* fully blown dependency on GrDiffBuilderElement.
*/
export interface DiffBuilderInterface {
- getContentTdByLineEl(lineEl?: Element): Element | null;
+ getContentTdByLineEl(lineEl?: Element): Element | undefined;
}
/**
@@ -458,13 +459,9 @@ export class GrDiffHighlight {
}
private fireCreateRangeComment(side: Side, range: CommentRange) {
- this.diffTable?.dispatchEvent(
- new CustomEvent('create-range-comment', {
- detail: {side, range},
- composed: true,
- bubbles: true,
- })
- );
+ if (this.diffTable) {
+ fire(this.diffTable, 'create-range-comment', {side, range});
+ }
this.removeActionBox();
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
index af921e4640..f04e6a25d4 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-diff-highlight_test.ts
@@ -5,7 +5,7 @@
*/
import '../../../test/common-test-setup';
import './gr-diff-highlight';
-import {_getTextOffset} from './gr-range-normalizer';
+import {getTextOffset} from './gr-range-normalizer';
import {fixture, fixtureCleanup, html, assert} from '@open-wc/testing';
import {
GrDiffHighlight,
@@ -62,7 +62,7 @@ const diffTable = html`
<tr class="diff-row side-by-side" left-type="remove" right-type="add">
<td class="left lineNum" data-value="140"></td>
<!-- Next tag is formatted to eliminate zero-length text nodes. -->
- <td class="content remove"><div class="contentText">na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
+ <td class="content remove"><div class="contentText"><!-- a comment node -->na💢ti <hl class="foo">te, inquit</hl>, sumus <hl class="bar">aliquando</hl> otiosum, <hl>certe</hl> a <hl><span class="tab-indicator" style="tab-size:8;">\u0009</span></hl>udiam, <hl>quid</hl> sit, <span class="tab-indicator" style="tab-size:8;">\u0009</span>quod <hl>Epicurum</hl></div><div class="comment-thread">
[Yet another random diff thread content here]
</div></td>
<td class="right lineNum" data-value="120"></td>
@@ -684,13 +684,13 @@ suite('gr-diff-highlight', () => {
if (!content.lastChild) assert.fail('last child of content not found');
let child = content.lastChild.lastChild;
if (!child) assert.fail('last child of last child of content not found');
- let result = _getTextOffset(content, child);
+ let result = getTextOffset(content, child);
assert.equal(result, 75);
content = stubContent(146, Side.RIGHT);
if (!content) assert.fail('content element not found');
child = content.lastChild;
if (!child) assert.fail('child element not found');
- result = _getTextOffset(content, child);
+ result = getTextOffset(content, child);
assert.equal(result, 0);
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
index 9f23162039..b177e14caf 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-range-normalizer.ts
@@ -25,12 +25,12 @@ export interface NormalizedRange {
* for syntax highlighting.
*/
export function normalize(range: Range): NormalizedRange {
- const startContainer = _getContentTextParent(range.startContainer);
+ const startContainer = getContentTextParent(range.startContainer);
const startOffset =
- range.startOffset + _getTextOffset(startContainer, range.startContainer);
- const endContainer = _getContentTextParent(range.endContainer);
+ range.startOffset + getTextOffset(startContainer, range.startContainer);
+ const endContainer = getContentTextParent(range.endContainer);
const endOffset =
- range.endOffset + _getTextOffset(endContainer, range.endContainer);
+ range.endOffset + getTextOffset(endContainer, range.endContainer);
return {
startContainer,
startOffset,
@@ -39,7 +39,7 @@ export function normalize(range: Range): NormalizedRange {
};
}
-function _getContentTextParent(target: Node): Node {
+function getContentTextParent(target: Node): Node {
if (!target.parentElement) return target;
let element: Element | null;
@@ -67,7 +67,7 @@ function _getContentTextParent(target: Node): Node {
* @param child The child element being searched for.
*/
// TODO(TS): Only export for test.
-export function _getTextOffset(node: Node | null, child: Node): number {
+export function getTextOffset(node: Node | null, child: Node): number {
let count = 0;
let stack = [node];
while (stack.length) {
@@ -83,7 +83,7 @@ export function _getTextOffset(node: Node | null, child: Node): number {
arr.reverse();
stack = stack.concat(arr);
} else {
- count += _getLength(n);
+ count += getLength(n);
}
}
return count;
@@ -96,8 +96,8 @@ export function _getTextOffset(node: Node | null, child: Node): number {
* @param node A text node.
* @return The length of the text.
*/
-function _getLength(node?: Node | null) {
- return node && node.textContent
+function getLength(node?: Node | null) {
+ return node && node.textContent && node.nodeType !== Node.COMMENT_NODE
? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
: 0;
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 8a92bcce0f..645de1b54c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -24,14 +24,10 @@ import {ifDefined} from 'lit/directives/if-defined.js';
import {classMap} from 'lit/directives/class-map.js';
import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
-import {
- createEvent,
- Dimensions,
- fitToFrame,
- FrameConstrainer,
- Point,
- Rect,
-} from './util';
+import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {ValueChangedEvent} from '../../../types/events';
+import {fire} from '../../../utils/event-util';
+import {ImageDiffAction} from '../../../api/diff';
const DRAG_DEAD_ZONE_PIXELS = 5;
@@ -686,27 +682,25 @@ export class GrImageViewer extends LitElement {
});
}
+ fireAction(detail: ImageDiffAction) {
+ fire(this, 'image-diff-action', detail);
+ }
+
selectBase() {
if (!this.baseUrl) return;
this.baseSelected = true;
- this.dispatchEvent(
- createEvent({type: 'version-switcher-clicked', button: 'base'})
- );
+ this.fireAction({type: 'version-switcher-clicked', button: 'base'});
}
selectRevision() {
if (!this.revisionUrl) return;
this.baseSelected = false;
- this.dispatchEvent(
- createEvent({type: 'version-switcher-clicked', button: 'revision'})
- );
+ this.fireAction({type: 'version-switcher-clicked', button: 'revision'});
}
manualBlink() {
this.toggleImage();
- this.dispatchEvent(
- createEvent({type: 'version-switcher-clicked', button: 'switch'})
- );
+ this.fireAction({type: 'version-switcher-clicked', button: 'switch'});
}
private toggleImage() {
@@ -717,9 +711,10 @@ export class GrImageViewer extends LitElement {
toggleAutomaticBlink() {
this.automaticBlink = !this.automaticBlink;
- this.dispatchEvent(
- createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
- );
+ this.fireAction({
+ type: 'automatic-blink-changed',
+ value: this.automaticBlink,
+ });
}
private updateAutomaticBlink() {
@@ -751,52 +746,43 @@ export class GrImageViewer extends LitElement {
private toggleHighlight(source: 'controls' | 'magnifier') {
this.showHighlight = !this.showHighlight;
- this.dispatchEvent(
- createEvent({
- type: 'highlight-changes-changed',
- value: this.showHighlight,
- source,
- })
- );
- }
-
- zoomControlChanged(event: CustomEvent) {
- const value = event.detail.value;
- if (!value) return;
- if (value === 'fit') {
+ this.fireAction({
+ type: 'highlight-changes-changed',
+ value: this.showHighlight,
+ source,
+ });
+ }
+
+ zoomControlChanged(event: ValueChangedEvent<string>) {
+ const scaleString = event.detail.value;
+ if (!scaleString) return;
+ if (scaleString === 'fit') {
this.scaledSelected = true;
- this.dispatchEvent(
- createEvent({type: 'zoom-level-changed', scale: 'fit'})
- );
+ this.fireAction({type: 'zoom-level-changed', scale: 'fit'});
}
- if (value > 0) {
+ const scale = Number(scaleString);
+ if (Number.isFinite(scale) && scale > 0) {
this.scaledSelected = false;
- this.scale = value;
- this.dispatchEvent(
- createEvent({type: 'zoom-level-changed', scale: value})
- );
+ this.scale = scale;
+ this.fireAction({type: 'zoom-level-changed', scale});
}
this.updateSizes();
}
followMouseChanged() {
this.followMouse = !this.followMouse;
- this.dispatchEvent(
- createEvent({type: 'follow-mouse-changed', value: this.followMouse})
- );
+ this.fireAction({type: 'follow-mouse-changed', value: this.followMouse});
}
pickColor(value: string) {
this.checkerboardSelected = false;
this.backgroundColor = value;
- this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+ this.fireAction({type: 'background-color-changed', value});
}
pickCheckerboard() {
this.checkerboardSelected = true;
- this.dispatchEvent(
- createEvent({type: 'background-color-changed', value: 'checkerboard'})
- );
+ this.fireAction({type: 'background-color-changed', value: 'checkerboard'});
}
mousemoveImageArea(event: MouseEvent) {
@@ -849,9 +835,9 @@ export class GrImageViewer extends LitElement {
// external mice.
if (distance < DRAG_DEAD_ZONE_PIXELS) {
this.toggleImage();
- this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+ this.fireAction({type: 'magnifier-clicked'});
} else {
- this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+ this.fireAction({type: 'magnifier-dragged'});
}
}
@@ -894,17 +880,17 @@ export class GrImageViewer extends LitElement {
if (!this.ownsMouseDown) return;
this.grabbing = false;
this.ownsMouseDown = false;
- this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
+ this.fireAction({type: 'magnifier-dragged'});
}
dragstartMagnifier(event: DragEvent) {
event.preventDefault();
}
- onOverviewCenterUpdated(event: CustomEvent) {
+ onOverviewCenterUpdated(event: CustomEvent<Point>) {
this.frameConstrainer.requestCenter({
- x: event.detail.x as number,
- y: event.detail.y as number,
+ x: event.detail.x,
+ y: event.detail.y,
});
this.updateFrames();
}
@@ -955,4 +941,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-image-viewer': GrImageViewer;
}
+ interface HTMLElementEventMap {
+ 'image-diff-action': CustomEvent<ImageDiffAction>;
+ }
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
index 1bc1447ab5..21a7cf8137 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-overview-image.ts
@@ -7,8 +7,9 @@ import {css, html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {StyleInfo, styleMap} from 'lit/directives/style-map.js';
import {ImageDiffAction} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
-import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
+import {Dimensions, fitToFrame, Point, Rect} from './util';
/**
* Displays a scaled-down version of an image with a draggable frame for
@@ -243,7 +244,7 @@ export class GrOverviewImage extends LitElement {
const detail: ImageDiffAction = {
type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
};
- this.dispatchEvent(createEvent(detail));
+ fire(this, 'image-diff-action', detail);
this.dragging = false;
this.closeOverlay();
@@ -297,13 +298,7 @@ export class GrOverviewImage extends LitElement {
}
private notifyNewCenter(center: Point) {
- this.dispatchEvent(
- new CustomEvent('center-updated', {
- detail: {...center},
- bubbles: true,
- composed: true,
- })
- );
+ fire(this, 'center-updated', {...center});
}
}
@@ -311,4 +306,7 @@ declare global {
interface HTMLElementTagNameMap {
'gr-overview-image': GrOverviewImage;
}
+ interface HTMLElementEventMap {
+ 'center-updated': CustomEvent<Point>;
+ }
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
index 38a07b7986..896dc11fe2 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/util.ts
@@ -3,7 +3,6 @@
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {ImageDiffAction} from '../../../api/diff';
export interface Point {
x: number;
@@ -224,13 +223,3 @@ export class FrameConstrainer {
};
}
}
-
-export function createEvent(
- detail: ImageDiffAction
-): CustomEvent<ImageDiffAction> {
- return new CustomEvent('image-diff-action', {
- detail,
- bubbles: true,
- composed: true,
- });
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
index 5caffe6086..a9bdab8911 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -9,14 +9,13 @@ import '../../../elements/shared/gr-button/gr-button';
import '../../../elements/shared/gr-icon/gr-icon';
import {DiffViewMode} from '../../../constants/constants';
import {customElement, property, state} from 'lit/decorators.js';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
-import {FixIronA11yAnnouncer} from '../../../types/types';
-import {getAppContext} from '../../../services/app-context';
import {fireIronAnnounce} from '../../../utils/event-util';
import {browserModelToken} from '../../../models/browser/browser-model';
import {resolve} from '../../../models/dependency';
import {css, html, LitElement} from 'lit';
import {sharedStyles} from '../../../styles/shared-styles';
+import {userModelToken} from '../../../models/user/user-model';
+import {ironAnnouncerRequestAvailability} from '../../../elements/polymer-util';
@customElement('gr-diff-mode-selector')
export class GrDiffModeSelector extends LitElement {
@@ -34,7 +33,7 @@ export class GrDiffModeSelector extends LitElement {
private readonly getBrowserModel = resolve(this, browserModelToken);
- private readonly userModel = getAppContext().userModel;
+ private readonly getUserModel = resolve(this, userModelToken);
private subscriptions: Subscription[] = [];
@@ -44,9 +43,7 @@ export class GrDiffModeSelector extends LitElement {
override connectedCallback() {
super.connectedCallback();
- (
- IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
- ).requestAvailability();
+ ironAnnouncerRequestAvailability();
this.subscriptions.push(
this.getBrowserModel().diffViewMode$.subscribe(
diffView => (this.mode = diffView)
@@ -118,7 +115,7 @@ export class GrDiffModeSelector extends LitElement {
*/
private setMode(newMode: DiffViewMode) {
if (this.saveOnChange && this.mode && this.mode !== newMode) {
- this.userModel.updatePreferences({diff_view: newMode});
+ this.getUserModel().updatePreferences({diff_view: newMode});
}
this.mode = newMode;
let announcement;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index 34af01e897..d6469884dd 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
@@ -7,21 +7,17 @@ import '../../../test/common-test-setup';
import './gr-diff-mode-selector';
import {GrDiffModeSelector} from './gr-diff-mode-selector';
import {DiffViewMode} from '../../../constants/constants';
-import {
- queryAndAssert,
- stubUsers,
- waitUntilObserved,
-} from '../../../test/test-utils';
+import {queryAndAssert, waitUntilObserved} from '../../../test/test-utils';
import {fixture, html, assert} from '@open-wc/testing';
import {wrapInProvider} from '../../../models/di-provider-element';
import {
BrowserModel,
browserModelToken,
} from '../../../models/browser/browser-model';
-import {getAppContext} from '../../../services/app-context';
-import {UserModel} from '../../../models/user/user-model';
+import {UserModel, userModelToken} from '../../../models/user/user-model';
import {createPreferences} from '../../../test/test-data-generators';
import {GrButton} from '../../../elements/shared/gr-button/gr-button';
+import {testResolver} from '../../../test/common-test-setup';
suite('gr-diff-mode-selector tests', () => {
let element: GrDiffModeSelector;
@@ -29,7 +25,7 @@ suite('gr-diff-mode-selector tests', () => {
let userModel: UserModel;
setup(async () => {
- userModel = getAppContext().userModel;
+ userModel = testResolver(userModelToken);
browserModel = new BrowserModel(userModel);
element = (
await fixture(
@@ -129,7 +125,7 @@ suite('gr-diff-mode-selector tests', () => {
test('set mode', async () => {
browserModel.setScreenWidth(0);
- const saveStub = stubUsers('updatePreferences');
+ const saveStub = sinon.stub(userModel, 'updatePreferences');
// Setting the mode initially does not save prefs.
element.saveOnChange = true;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
new file mode 100644
index 0000000000..8fbda14e9e
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-model/gr-diff-model.ts
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {Observable} from 'rxjs';
+import {filter} from 'rxjs/operators';
+import {
+ DiffInfo,
+ DiffPreferencesInfo,
+ RenderPreferences,
+} from '../../../api/diff';
+import {define} from '../../../models/dependency';
+import {Model} from '../../../models/model';
+import {isDefined} from '../../../types/types';
+import {select} from '../../../utils/observable-util';
+
+export interface DiffState {
+ diff: DiffInfo;
+ path?: string;
+ renderPrefs: RenderPreferences;
+ diffPrefs: DiffPreferencesInfo;
+}
+
+export const diffModelToken = define<DiffModel>('diff-model');
+
+export class DiffModel extends Model<DiffState | undefined> {
+ readonly diff$: Observable<DiffInfo> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.diff
+ );
+
+ readonly path$: Observable<string | undefined> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.path
+ );
+
+ readonly renderPrefs$: Observable<RenderPreferences> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.renderPrefs
+ );
+
+ readonly diffPrefs$: Observable<DiffPreferencesInfo> = select(
+ this.state$.pipe(filter(isDefined)),
+ diffState => diffState.diffPrefs
+ );
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index 062347f066..05e5d3b9eb 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -15,12 +15,11 @@ import {
GrDiffGroupType,
hideInContextControl,
} from '../gr-diff/gr-diff-group';
-import {CancelablePromise, makeCancelable} from '../../../scripts/util';
import {DiffContent} from '../../../types/diff';
import {Side} from '../../../constants/constants';
import {debounce, DelayedTask} from '../../../utils/async-util';
-import {RenderPreferences} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
const WHOLE_FILE = -1;
@@ -94,15 +93,17 @@ export class GrDiffProcessor {
keyLocations: KeyLocations = {left: {}, right: {}};
- private asyncThreshold = 64;
-
- private nextStepHandle: number | null = null;
-
- private processPromise: CancelablePromise<void> | null = null;
+ asyncThreshold = 64;
// visible for testing
isScrolling?: boolean;
+ /** Just for making sure that process() is only called once. */
+ private isStarted = false;
+
+ /** Indicates that processing should be stopped. */
+ private isCancelled = false;
+
private resetIsScrollingTask?: DelayedTask;
private readonly handleWindowScroll = () => {
@@ -122,9 +123,9 @@ export class GrDiffProcessor {
* array of GrDiffGroups when the diff is completely processed.
*/
process(chunks: DiffContent[], isBinary: boolean) {
- // Cancel any still running process() calls, because they append to the
- // same groups field.
- this.cancel();
+ assert(this.isStarted === false, 'diff processor cannot be started twice');
+ this.isStarted = true;
+
window.addEventListener('scroll', this.handleWindowScroll);
assertIsDefined(this.consumer, 'consumer');
@@ -132,84 +133,61 @@ export class GrDiffProcessor {
this.consumer.addGroup(this.makeGroup('LOST'));
this.consumer.addGroup(this.makeGroup(FILE));
- // If it's a binary diff, we won't be rendering hunks of text differences
- // so finish processing.
- if (isBinary) {
- return Promise.resolve();
- }
+ if (isBinary) return Promise.resolve();
- // TODO: Canceling this promise does not help much. `nextStep` will continue
- // to be scheduled anyway. So either just remove the cancelable promise, so
- // future programmers are not fooled about this promise can do. Or fix the
- // scheduling of `nextStep` such that cancellation is taken into account.
- // The easiest approach is likely to just not re-use the processor for
- // multiple processing passes. There is no benefit from doing so.
- this.processPromise = makeCancelable(
- new Promise(resolve => {
- const state = {
- lineNums: {left: 0, right: 0},
- chunkIndex: 0,
- };
-
- chunks = this.splitLargeChunks(chunks);
- chunks = this.splitCommonChunksWithKeyLocations(chunks);
-
- let currentBatch = 0;
- const nextStep = () => {
- if (this.isScrolling) {
- this.nextStepHandle = window.setTimeout(nextStep, 100);
- return;
- }
- // If we are done, resolve the promise.
- if (state.chunkIndex >= chunks.length) {
- resolve();
- this.nextStepHandle = null;
- return;
- }
-
- // Process the next chunk and incorporate the result.
- const stateUpdate = this.processNext(state, chunks);
- for (const group of stateUpdate.groups) {
- assertIsDefined(this.consumer, 'consumer');
- this.consumer.addGroup(group);
- currentBatch += group.lines.length;
- }
- state.lineNums.left += stateUpdate.lineDelta.left;
- state.lineNums.right += stateUpdate.lineDelta.right;
-
- // Increment the index and recurse.
- state.chunkIndex = stateUpdate.newChunkIndex;
- if (currentBatch >= this.asyncThreshold) {
- currentBatch = 0;
- this.nextStepHandle = window.setTimeout(nextStep, 1);
- } else {
- nextStep.call(this);
- }
- };
-
- nextStep.call(this);
- })
- );
- return this.processPromise.finally(() => {
- this.processPromise = null;
- window.removeEventListener('scroll', this.handleWindowScroll);
+ return new Promise<void>(resolve => {
+ const state = {
+ lineNums: {left: 0, right: 0},
+ chunkIndex: 0,
+ };
+
+ chunks = this.splitLargeChunks(chunks);
+ chunks = this.splitCommonChunksWithKeyLocations(chunks);
+
+ let currentBatch = 0;
+ const nextStep = () => {
+ if (this.isCancelled || state.chunkIndex >= chunks.length) {
+ resolve();
+ return;
+ }
+ if (this.isScrolling) {
+ window.setTimeout(nextStep, 100);
+ return;
+ }
+
+ const stateUpdate = this.processNext(state, chunks);
+ for (const group of stateUpdate.groups) {
+ this.consumer?.addGroup(group);
+ currentBatch += group.lines.length;
+ }
+ state.lineNums.left += stateUpdate.lineDelta.left;
+ state.lineNums.right += stateUpdate.lineDelta.right;
+
+ state.chunkIndex = stateUpdate.newChunkIndex;
+ if (currentBatch >= this.asyncThreshold) {
+ currentBatch = 0;
+ window.setTimeout(nextStep, 1);
+ } else {
+ nextStep.call(this);
+ }
+ };
+
+ nextStep.call(this);
+ }).finally(() => {
+ this.finish();
});
}
- /**
- * Cancel any jobs that are running.
- */
- cancel() {
- if (this.nextStepHandle !== null) {
- window.clearTimeout(this.nextStepHandle);
- this.nextStepHandle = null;
- }
- if (this.processPromise) {
- this.processPromise.cancel();
- }
+ finish() {
+ this.consumer = undefined;
window.removeEventListener('scroll', this.handleWindowScroll);
}
+ cancel() {
+ this.isCancelled = true;
+ this.finish();
+ }
+
/**
* Process the next uncollapsible chunk, or the next collapsible chunks.
*/
@@ -639,16 +617,19 @@ export class GrDiffProcessor {
rows: string[],
intralineInfos: number[][]
): Highlights[] {
+ // +1 to account for the \n that is not part of the rows passed here
+ const lineLengths = rows.map(r => GrAnnotation.getStringLength(r) + 1);
+
let rowIndex = 0;
let idx = 0;
const normalized = [];
for (const [skipLength, markLength] of intralineInfos) {
- let line = rows[rowIndex] + '\n';
+ let lineLength = lineLengths[rowIndex];
let j = 0;
while (j < skipLength) {
- if (idx === line.length) {
+ if (idx === lineLength) {
idx = 0;
- line = rows[++rowIndex] + '\n';
+ lineLength = lineLengths[++rowIndex];
continue;
}
idx++;
@@ -660,10 +641,10 @@ export class GrDiffProcessor {
};
j = 0;
- while (line && j < markLength) {
- if (idx === line.length) {
+ while (lineLength && j < markLength) {
+ if (idx === lineLength) {
idx = 0;
- line = rows[++rowIndex] + '\n';
+ lineLength = lineLengths[++rowIndex];
normalized.push(lineHighlight);
lineHighlight = {
contentIndex: rowIndex,
@@ -735,10 +716,4 @@ export class GrDiffProcessor {
return this.breakdown(head, size).concat([tail]);
}
-
- updateRenderPrefs(renderPrefs: RenderPreferences) {
- if (renderPrefs.num_lines_rendered_at_once) {
- this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
- }
- }
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 6caeb62f7a..adcfff856d 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -135,7 +135,7 @@ suite('gr-diff-processor tests', () => {
element.context = 10;
const content = [
{
- ab: new Array(100).fill(
+ ab: Array.from<string>({length: 100}).fill(
'all work and no play make jack a dull boy'
),
},
@@ -165,9 +165,13 @@ suite('gr-diff-processor tests', () => {
test('at the beginning with skip chunks', async () => {
element.context = 10;
const content = [
- {ab: new Array(20).fill('all work and no play make jack a dull boy')},
+ {
+ ab: Array.from<string>({length: 20}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
{skip: 43900},
- {ab: new Array(30).fill('some other content')},
+ {ab: Array.from<string>({length: 30}).fill('some other content')},
{a: ['some other content']},
];
@@ -213,7 +217,11 @@ suite('gr-diff-processor tests', () => {
test('at the beginning, smaller than context', () => {
element.context = 10;
const content = [
- {ab: new Array(5).fill('all work and no play make jack a dull boy')},
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jack a dull boy'
+ ),
+ },
{a: ['all work and no play make andybons a dull boy']},
];
@@ -235,7 +243,7 @@ suite('gr-diff-processor tests', () => {
const content = [
{a: ['all work and no play make andybons a dull boy']},
{
- ab: new Array(100).fill(
+ ab: Array.from<string>({length: 100}).fill(
'all work and no play make jill a dull girl'
),
},
@@ -266,7 +274,11 @@ suite('gr-diff-processor tests', () => {
element.context = 10;
const content = [
{a: ['all work and no play make andybons a dull boy']},
- {ab: new Array(5).fill('all work and no play make jill a dull girl')},
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
];
return element.process(content, false).then(() => {
@@ -287,23 +299,39 @@ suite('gr-diff-processor tests', () => {
element.context = 10;
const content = [
{a: ['all work and no play make andybons a dull boy']},
- {ab: new Array(3).fill('all work and no play make jill a dull girl')},
{
- a: new Array(3).fill('all work and no play make jill a dull girl'),
- b: new Array(3).fill(
+ ab: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {
+ a: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ b: Array.from<string>({length: 3}).fill(
' all work and no play make jill a dull girl'
),
common: true,
},
- {ab: new Array(3).fill('all work and no play make jill a dull girl')},
{
- a: new Array(3).fill('all work and no play make jill a dull girl'),
- b: new Array(3).fill(
+ ab: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
+ {
+ a: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ b: Array.from<string>({length: 3}).fill(
' all work and no play make jill a dull girl'
),
common: true,
},
- {ab: new Array(3).fill('all work and no play make jill a dull girl')},
+ {
+ ab: Array.from<string>({length: 3}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
];
return element.process(content, false).then(() => {
@@ -387,7 +415,7 @@ suite('gr-diff-processor tests', () => {
const content = [
{a: ['all work and no play make andybons a dull boy']},
{
- ab: new Array(100).fill(
+ ab: Array.from<string>({length: 100}).fill(
'all work and no play make jill a dull girl'
),
},
@@ -425,7 +453,11 @@ suite('gr-diff-processor tests', () => {
element.context = 10;
const content = [
{a: ['all work and no play make andybons a dull boy']},
- {ab: new Array(5).fill('all work and no play make jill a dull girl')},
+ {
+ ab: Array.from<string>({length: 5}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
{a: ['all work and no play make andybons a dull boy']},
];
@@ -448,9 +480,17 @@ suite('gr-diff-processor tests', () => {
element.context = 10;
const content = [
{a: ['all work and no play make andybons a dull boy']},
- {ab: new Array(20).fill('all work and no play make jill a dull girl')},
+ {
+ ab: Array.from<string>({length: 20}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
{skip: 60},
- {ab: new Array(20).fill('all work and no play make jill a dull girl')},
+ {
+ ab: Array.from<string>({length: 20}).fill(
+ 'all work and no play make jill a dull girl'
+ ),
+ },
{a: ['all work and no play make andybons a dull boy']},
];
@@ -723,15 +763,37 @@ suite('gr-diff-processor tests', () => {
endIndex: 41,
},
]);
+
+ content = ['🙈 a', '🙉 b', '🙊 c'];
+ highlights = [[2, 7]];
+ results = element.convertIntralineInfos(content, highlights);
+ assert.deepEqual(results, [
+ {
+ contentIndex: 0,
+ startIndex: 2,
+ },
+ {
+ contentIndex: 1,
+ startIndex: 0,
+ },
+ {
+ contentIndex: 2,
+ startIndex: 0,
+ endIndex: 1,
+ },
+ ]);
});
- test('scrolling pauses rendering', () => {
+ test('isScrolling paused', () => {
const content = Array(200).fill({ab: ['', '']});
element.isScrolling = true;
element.process(content, false);
- // Just the files group - no more processing during scrolling.
+ // Just the FILE and LOST groups.
assert.equal(groups.length, 2);
+ });
+ test('isScrolling unpaused', () => {
+ const content = Array(200).fill({ab: ['', '']});
element.isScrolling = false;
element.process(content, false);
// More groups have been processed. How many does not matter here.
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 4bb8cc3afe..a790736b71 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
@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../../../styles/shared-styles';
+import {normalize} from '../gr-diff-highlight/gr-range-normalizer';
import {
- normalize,
- NormalizedRange,
-} from '../gr-diff-highlight/gr-range-normalizer';
-import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
+ descendedFromClass,
+ parentWithClass,
+ querySelectorAll,
+} from '../../../utils/dom-util';
import {DiffInfo} from '../../../types/diff';
import {Side} from '../../../constants/constants';
import {
@@ -17,7 +18,6 @@ import {
getSideByLineEl,
isThreadEl,
} from '../gr-diff/gr-diff-utils';
-import {assertIsDefined} from '../../../utils/common-util';
/**
* Possible CSS classes indicating the state of selection. Dynamically added/
@@ -30,6 +30,10 @@ const SelectionClass = {
BLAME: 'selected-blame',
};
+function selectionClassForSide(side?: Side) {
+ return side === Side.LEFT ? SelectionClass.LEFT : SelectionClass.RIGHT;
+}
+
interface LinesCache {
left: string[] | null;
right: string[] | null;
@@ -65,52 +69,31 @@ export class GrDiffSelection {
this.diffTable.removeEventListener('mousedown', this.handleDown);
}
- handleDownOnRangeComment(node: Element) {
- if (isThreadEl(node)) {
+ handleDown = (e: Event) => {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+
+ const commentEl = parentWithClass(target, 'comment-thread', this.diffTable);
+ if (commentEl && isThreadEl(commentEl)) {
this.setClasses([
SelectionClass.COMMENT,
- getSide(node) === Side.LEFT
- ? SelectionClass.LEFT
- : SelectionClass.RIGHT,
+ selectionClassForSide(getSide(commentEl)),
]);
- return true;
+ return;
}
- return false;
- }
- handleDown = (e: Event) => {
- const target = e.target;
- if (!(target instanceof Element)) return;
- const handled = this.handleDownOnRangeComment(target);
- if (handled) return;
- const lineEl = getLineElByChild(target);
const blameSelected = descendedFromClass(target, 'blame', this.diffTable);
- if (!lineEl && !blameSelected) {
+ if (blameSelected) {
+ this.setClasses([SelectionClass.BLAME]);
return;
}
- const targetClasses = [];
-
- if (blameSelected) {
- targetClasses.push(SelectionClass.BLAME);
- } else if (lineEl) {
- const commentSelected = descendedFromClass(
- target,
- 'gr-comment',
- this.diffTable
- );
- const side = getSideByLineEl(lineEl);
-
- targetClasses.push(
- side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
- );
-
- if (commentSelected) {
- targetClasses.push(SelectionClass.COMMENT);
- }
+ // This works for both, the content and the line number cells.
+ const lineEl = getLineElByChild(target);
+ if (lineEl) {
+ this.setClasses([selectionClassForSide(getSideByLineEl(lineEl))]);
+ return;
}
-
- this.setClasses(targetClasses);
};
/**
@@ -134,19 +117,17 @@ export class GrDiffSelection {
}
handleCopy = (e: ClipboardEvent) => {
- let commentSelected = false;
const target = e.composedPath()[0];
if (!(target instanceof Element)) return;
if (target instanceof HTMLTextAreaElement) return;
if (!descendedFromClass(target, 'diff-row', this.diffTable)) return;
if (!this.diffTable) return;
- if (this.diffTable.classList.contains(SelectionClass.COMMENT)) {
- commentSelected = true;
- }
+ if (this.diffTable.classList.contains(SelectionClass.COMMENT)) return;
+
const lineEl = getLineElByChild(target);
if (!lineEl) return;
const side = getSideByLineEl(lineEl);
- const text = this.getSelectedText(side, commentSelected);
+ const text = this.getSelectedText(side);
if (text && e.clipboardData) {
e.clipboardData.setData('Text', text);
e.preventDefault();
@@ -179,14 +160,11 @@ export class GrDiffSelection {
* @param commentSelected Whether or not a comment is selected.
* @return The selected text.
*/
- getSelectedText(side: Side, commentSelected: boolean) {
+ getSelectedText(side: Side) {
const sel = this.getSelection();
if (!sel || sel.rangeCount !== 1) {
return ''; // No multi-select support yet.
}
- if (commentSelected) {
- return this.getCommentLines(sel, side);
- }
const range = normalize(sel.getRangeAt(0));
const startLineEl = getLineElByChild(range.startContainer);
if (!startLineEl) return;
@@ -266,82 +244,4 @@ export class GrDiffSelection {
this.linesCache[side] = lines;
return lines;
}
-
- /**
- * Query the diffElement for comments and check whether they lie inside the
- * selection range.
- *
- * @param sel The selection of the window.
- * @param side The side that is currently selected.
- * @return The selected comment text.
- */
- getCommentLines(sel: Selection, side: Side) {
- const range = normalize(sel.getRangeAt(0));
- const content = [];
- assertIsDefined(this.diffTable, 'diffTable');
- const messages = this.diffTable.querySelectorAll(
- `.side-by-side [data-side="${side}"] .message *, .unified .message *`
- );
-
- for (let i = 0; i < messages.length; i++) {
- const el = messages[i];
- // Check if the comment element exists inside the selection.
- if (sel.containsNode(el, true)) {
- // Padded elements require newlines for accurate spacing.
- if (
- el.parentElement!.id === 'container' ||
- el.parentElement!.nodeName === 'BLOCKQUOTE'
- ) {
- if (content.length && content[content.length - 1] !== '') {
- content.push('');
- }
- }
-
- if (
- el.id === 'output' &&
- !descendedFromClass(el, 'collapsed', this.diffTable)
- ) {
- content.push(this.getTextContentForRange(el, sel, range));
- }
- }
- }
-
- return content.join('\n');
- }
-
- /**
- * Given a DOM node, a selection, and a selection range, recursively get all
- * of the text content within that selection.
- * Using a domNode that isn't in the selection returns an empty string.
- *
- * @param domNode The root DOM node.
- * @param sel The selection.
- * @param range The normalized selection range.
- * @return The text within the selection.
- */
- getTextContentForRange(
- domNode: Node,
- sel: Selection,
- range: NormalizedRange
- ) {
- if (!sel.containsNode(domNode, true)) {
- return '';
- }
-
- let text = '';
- if (domNode instanceof Text) {
- text = domNode.textContent || '';
- if (domNode === range.endContainer) {
- text = text.substring(0, range.endOffset);
- }
- if (domNode === range.startContainer) {
- text = text.substring(range.startOffset);
- }
- } else {
- for (const childNode of domNode.childNodes) {
- text += this.getTextContentForRange(childNode, sel, range);
- }
- }
- return text;
- }
}
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 8acaf045ee..f216e047fc 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
@@ -5,96 +5,25 @@
*/
import '../../../test/common-test-setup';
import './gr-diff-selection';
+import '../gr-diff/gr-diff';
+import '../../../elements/shared/gr-comment-thread/gr-comment-thread';
import {GrDiffSelection} from './gr-diff-selection';
import {createDiff} from '../../../test/test-data-generators';
import {DiffInfo, Side} from '../../../api/diff';
-import {GrFormattedText} from '../../../elements/shared/gr-formatted-text/gr-formatted-text';
import {fixture, html, assert} from '@open-wc/testing';
import {mouseDown} from '../../../test/test-utils';
+import {GrDiff} from '../gr-diff/gr-diff';
+import {waitForEventOnce} from '../../../utils/event-util';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
-const diffTableTemplate = html`
- <table id="diffTable" class="side-by-side">
- <tr class="diff-row">
- <td class="blame" data-line-number="1"></td>
- <td class="lineNum left" data-value="1">1</td>
- <td class="content">
- <div class="contentText" data-side="left">ba ba</div>
- <div data-side="left">
- <div class="comment-thread">
- <div class="gr-formatted-text message">
- <span id="output" class="gr-formatted-text"
- >This is a comment</span
- >
- </div>
- </div>
- </div>
- </td>
- <td class="lineNum right" data-value="1">1</td>
- <td class="content">
- <div class="contentText" data-side="right">some other text</div>
- </td>
- </tr>
- <tr class="diff-row">
- <td class="blame" data-line-number="2"></td>
- <td class="lineNum left" data-value="2">2</td>
- <td class="content">
- <div class="contentText" data-side="left">zin</div>
- </td>
- <td class="lineNum right" data-value="2">2</td>
- <td class="content">
- <div class="contentText" data-side="right">more more more</div>
- <div data-side="right">
- <div class="comment-thread">
- <div class="gr-formatted-text message">
- <span id="output" class="gr-formatted-text"
- >This is a comment on the right</span
- >
- </div>
- </div>
- </div>
- </td>
- </tr>
- <tr class="diff-row">
- <td class="blame" data-line-number="3"></td>
- <td class="lineNum left" data-value="3">3</td>
- <td class="content">
- <div class="contentText" data-side="left">ga ga</div>
- <div data-side="left">
- <div class="comment-thread">
- <div class="gr-formatted-text message">
- <span id="output" class="gr-formatted-text"
- >This is <a>a</a> different comment 💩 unicode is fun</span
- >
- </div>
- </div>
- </div>
- </td>
- <td class="lineNum right" data-value="3">3</td>
- </tr>
- <tr class="diff-row">
- <td class="blame" data-line-number="4"></td>
- <td class="lineNum left" data-value="4">4</td>
- <td class="content">
- <div class="contentText" data-side="left">ga ga</div>
- <div data-side="left">
- <div class="comment-thread">
- <textarea data-side="right">test for textarea copying</textarea>
- </div>
- </div>
- </td>
- <td class="lineNum right" data-value="4">4</td>
- </tr>
- <tr class="not-diff-row">
- <td class="other">
- <div class="contentText" data-side="right">some other text</div>
- </td>
- </tr>
- </table>
-`;
+function firstTextNode(el: HTMLElement) {
+ return [...el.childNodes].filter(node => node.nodeType === Node.TEXT_NODE)[0];
+}
suite('gr-diff-selection', () => {
let element: GrDiffSelection;
- let diffTable: HTMLTableElement;
+ let diffTable: HTMLElement;
+ let grDiff: GrDiff;
const emulateCopyOn = function (target: HTMLElement | null) {
const fakeEvent = {
@@ -112,8 +41,8 @@ suite('gr-diff-selection', () => {
};
setup(async () => {
- element = new GrDiffSelection();
- diffTable = await fixture<HTMLTableElement>(diffTableTemplate);
+ grDiff = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+ element = grDiff.diffSelection;
const diff: DiffInfo = {
...createDiff(),
@@ -132,51 +61,55 @@ suite('gr-diff-selection', () => {
},
],
};
- element.init(diff, diffTable);
+ grDiff.prefs = createDefaultDiffPrefs();
+ grDiff.diff = diff;
+ await waitForEventOnce(grDiff, 'render');
+ assert.isOk(element.diffTable);
+ diffTable = element.diffTable!;
});
test('applies selected-left on left side click', () => {
- element.diffTable!.classList.add('selected-right');
+ diffTable.classList.add('selected-right');
const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.left');
if (!lineNumberEl) assert.fail('line number element missing');
mouseDown(lineNumberEl);
assert.isTrue(
- element.diffTable!.classList.contains('selected-left'),
+ diffTable.classList.contains('selected-left'),
'adds selected-left'
);
assert.isFalse(
- element.diffTable!.classList.contains('selected-right'),
+ diffTable.classList.contains('selected-right'),
'removes selected-right'
);
});
test('applies selected-right on right side click', () => {
- element.diffTable!.classList.add('selected-left');
+ diffTable.classList.add('selected-left');
const lineNumberEl = diffTable.querySelector<HTMLElement>('.lineNum.right');
if (!lineNumberEl) assert.fail('line number element missing');
mouseDown(lineNumberEl);
assert.isTrue(
- element.diffTable!.classList.contains('selected-right'),
+ diffTable.classList.contains('selected-right'),
'adds selected-right'
);
assert.isFalse(
- element.diffTable!.classList.contains('selected-left'),
+ diffTable.classList.contains('selected-left'),
'removes selected-left'
);
});
test('applies selected-blame on blame click', () => {
- element.diffTable!.classList.add('selected-left');
+ diffTable.classList.add('selected-left');
const blameDiv = document.createElement('div');
blameDiv.classList.add('blame');
- element.diffTable!.appendChild(blameDiv);
+ diffTable.appendChild(blameDiv);
mouseDown(blameDiv);
assert.isTrue(
- element.diffTable!.classList.contains('selected-blame'),
+ diffTable.classList.contains('selected-blame'),
'adds selected-right'
);
assert.isFalse(
- element.diffTable!.classList.contains('selected-left'),
+ diffTable.classList.contains('selected-left'),
'removes selected-left'
);
});
@@ -190,7 +123,7 @@ suite('gr-diff-selection', () => {
test('asks for text for left side Elements', () => {
const getSelectedTextStub = sinon.stub(element, 'getSelectedText');
emulateCopyOn(diffTable.querySelector('div.contentText'));
- assert.deepEqual([Side.LEFT, false], getSelectedTextStub.lastCall.args);
+ assert.deepEqual([Side.LEFT], getSelectedTextStub.lastCall.args);
});
test('reacts to copy for content Elements', () => {
@@ -216,25 +149,25 @@ suite('gr-diff-selection', () => {
});
test('setClasses adds given SelectionClass values, removes others', () => {
- element.diffTable!.classList.add('selected-right');
+ diffTable.classList.add('selected-right');
element.setClasses(['selected-comment', 'selected-left']);
- assert.isTrue(element.diffTable!.classList.contains('selected-comment'));
- assert.isTrue(element.diffTable!.classList.contains('selected-left'));
- assert.isFalse(element.diffTable!.classList.contains('selected-right'));
- assert.isFalse(element.diffTable!.classList.contains('selected-blame'));
+ assert.isTrue(diffTable.classList.contains('selected-comment'));
+ assert.isTrue(diffTable.classList.contains('selected-left'));
+ assert.isFalse(diffTable.classList.contains('selected-right'));
+ assert.isFalse(diffTable.classList.contains('selected-blame'));
element.setClasses(['selected-blame']);
- assert.isFalse(element.diffTable!.classList.contains('selected-comment'));
- assert.isFalse(element.diffTable!.classList.contains('selected-left'));
- assert.isFalse(element.diffTable!.classList.contains('selected-right'));
- assert.isTrue(element.diffTable!.classList.contains('selected-blame'));
+ assert.isFalse(diffTable.classList.contains('selected-comment'));
+ assert.isFalse(diffTable.classList.contains('selected-left'));
+ assert.isFalse(diffTable.classList.contains('selected-right'));
+ assert.isTrue(diffTable.classList.contains('selected-blame'));
});
test('setClasses removes before it ads', () => {
- element.diffTable!.classList.add('selected-right');
- const addStub = sinon.stub(element.diffTable!.classList, 'add');
+ diffTable.classList.add('selected-right');
+ const addStub = sinon.stub(diffTable.classList, 'add');
const removeStub = sinon
- .stub(element.diffTable!.classList, 'remove')
+ .stub(diffTable.classList, 'remove')
.callsFake(() => {
assert.isFalse(addStub.called);
});
@@ -244,149 +177,43 @@ suite('gr-diff-selection', () => {
});
test('copies content correctly', () => {
- element.diffTable!.classList.add('selected-left');
- element.diffTable!.classList.remove('selected-right');
+ diffTable.classList.add('selected-left');
+ diffTable.classList.remove('selected-right');
const selection = document.getSelection();
if (selection === null) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
- range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
- range.setEnd(
- diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
- 2
- );
+ const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+ range.setStart(firstTextNode(texts[0]), 3);
+ range.setEnd(firstTextNode(texts[4]), 2);
selection.addRange(range);
- assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
- });
- test('copies comments', () => {
- element.diffTable!.classList.add('selected-left');
- element.diffTable!.classList.add('selected-comment');
- element.diffTable!.classList.remove('selected-right');
- const selection = document.getSelection();
- if (selection === null) assert.fail('no selection');
- selection.removeAllRanges();
- const range = document.createRange();
- range.setStart(
- diffTable.querySelector('.gr-formatted-text *')!.firstChild!,
- 3
- );
- range.setEnd(
- diffTable.querySelectorAll('.gr-formatted-text *')[2].childNodes[2],
- 7
- );
- selection.addRange(range);
- assert.equal(
- 's is a comment\nThis is a differ',
- element.getSelectedText(Side.LEFT, true)
- );
- });
-
- test('respects astral chars in comments', () => {
- element.diffTable!.classList.add('selected-left');
- element.diffTable!.classList.add('selected-comment');
- element.diffTable!.classList.remove('selected-right');
- const selection = document.getSelection();
- if (selection === null) assert.fail('no selection');
- selection.removeAllRanges();
- const range = document.createRange();
- const nodes = diffTable.querySelectorAll('.gr-formatted-text *');
- range.setStart(nodes[2].childNodes[2], 13);
- range.setEnd(nodes[2].childNodes[2], 23);
- selection.addRange(range);
- assert.equal('mment 💩 u', element.getSelectedText(Side.LEFT, true));
+ assert.equal(element.getSelectedText(Side.LEFT), 'ba\nzin\nga');
});
test('defers to default behavior for textarea', () => {
- element.diffTable!.classList.add('selected-left');
- element.diffTable!.classList.remove('selected-right');
+ diffTable.classList.add('selected-left');
+ diffTable.classList.remove('selected-right');
const selectedTextSpy = sinon.spy(element, 'getSelectedText');
emulateCopyOn(diffTable.querySelector('textarea'));
+
assert.isFalse(selectedTextSpy.called);
});
test('regression test for 4794', () => {
- element.diffTable!.classList.add('selected-right');
- element.diffTable!.classList.remove('selected-left');
+ diffTable.classList.add('selected-right');
+ diffTable.classList.remove('selected-left');
const selection = document.getSelection();
if (!selection) assert.fail('no selection');
selection.removeAllRanges();
const range = document.createRange();
- range.setStart(
- diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
- 4
- );
- range.setEnd(
- diffTable.querySelectorAll('div.contentText')[1]!.firstChild!,
- 10
- );
- selection.addRange(range);
- assert.equal(element.getSelectedText(Side.RIGHT, false), ' other');
- });
-
- test('copies to end of side (issue 7895)', () => {
- element.diffTable!.classList.add('selected-left');
- element.diffTable!.classList.remove('selected-right');
- const selection = document.getSelection();
- if (selection === null) assert.fail('no selection');
- selection.removeAllRanges();
- const range = document.createRange();
- range.setStart(diffTable.querySelector('div.contentText')!.firstChild!, 3);
- range.setEnd(
- diffTable.querySelectorAll('div.contentText')[4]!.firstChild!,
- 2
- );
+ const texts = diffTable.querySelectorAll<HTMLElement>('gr-diff-text');
+ range.setStart(firstTextNode(texts[1]), 4);
+ range.setEnd(firstTextNode(texts[1]), 10);
selection.addRange(range);
- assert.equal(element.getSelectedText(Side.LEFT, false), 'ba\nzin\nga');
- });
-
- suite('getTextContentForRange', () => {
- let selection: Selection;
- let range: Range;
- let nodes: NodeListOf<GrFormattedText>;
-
- setup(() => {
- element.diffTable!.classList.add('selected-left');
- element.diffTable!.classList.add('selected-comment');
- element.diffTable!.classList.remove('selected-right');
- const s = document.getSelection();
- if (s === null) assert.fail('no selection');
- selection = s;
- selection.removeAllRanges();
- range = document.createRange();
- nodes = diffTable.querySelectorAll('.gr-formatted-text *');
- });
-
- test('multi level element contained in range', () => {
- range.setStart(nodes[2].childNodes[0], 1);
- range.setEnd(nodes[2].childNodes[2], 7);
- selection.addRange(range);
- assert.equal(
- element.getTextContentForRange(diffTable, selection, range),
- 'his is a differ'
- );
- });
-
- test('multi level element as startContainer of range', () => {
- range.setStart(nodes[2].childNodes[1], 0);
- range.setEnd(nodes[2].childNodes[2], 7);
- selection.addRange(range);
- assert.equal(
- element.getTextContentForRange(diffTable, selection, range),
- 'a differ'
- );
- });
- test('startContainer === endContainer', () => {
- range.setStart(nodes[0].firstChild!, 2);
- range.setEnd(nodes[0].firstChild!, 12);
- selection.addRange(range);
- assert.equal(
- element.getTextContentForRange(diffTable, selection, range),
- 'is is a co'
- );
- });
+ assert.equal(element.getSelectedText(Side.RIGHT), ' other');
});
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index 5a34ae3309..6d80d7819c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -8,6 +8,8 @@ import {LineRange, Side} from '../../../api/diff';
import {LineNumber} from './gr-diff-line';
import {assertIsDefined, assert} from '../../../utils/common-util';
import {untilRendered} from '../../../utils/dom-util';
+import {isDefined} from '../../../types/types';
+import {LitElement} from 'lit';
export enum GrDiffGroupType {
/** Unchanged context. */
@@ -65,7 +67,7 @@ export function hideInContextControl(
// because then that row would consume as much space as the collapsed code.
if (numHidden > 3) {
if (hiddenStart) {
- [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
+ [before, hidden] = splitCommonGroups(hidden, hiddenStart);
}
if (hiddenEnd) {
let beforeLength = 0;
@@ -74,7 +76,7 @@ export function hideInContextControl(
const beforeEnd = before[before.length - 1].lineRange.left.end_line;
beforeLength = beforeEnd - beforeStart + 1;
}
- [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
+ [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
}
} else {
[hidden, after] = [[], hidden];
@@ -95,7 +97,7 @@ export function hideInContextControl(
/**
* Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
- * used in function _splitCommonGroups
+ * used in function splitCommonGroups
* Groups with some lines before and some lines after the split will be split
* into two groups, which will be put into the first and second list.
*
@@ -104,7 +106,7 @@ export function hideInContextControl(
* @param rightSplit The line number relative to the split on the right side
* @return two new groups, one before the split and another after it
*/
-function _splitGroupInTwo(
+function splitGroupInTwo(
group: GrDiffGroup,
leftSplit: number,
rightSplit: number
@@ -130,8 +132,14 @@ function _splitGroupInTwo(
const after = [];
for (const line of group.lines) {
if (
- (line.beforeNumber && line.beforeNumber < leftSplit) ||
- (line.afterNumber && line.afterNumber < rightSplit)
+ (line.beforeNumber &&
+ line.beforeNumber !== 'FILE' &&
+ line.beforeNumber !== 'LOST' &&
+ line.beforeNumber < leftSplit) ||
+ (line.afterNumber &&
+ line.afterNumber !== 'FILE' &&
+ line.afterNumber !== 'LOST' &&
+ line.afterNumber < rightSplit)
) {
before.push(line);
} else {
@@ -167,7 +175,7 @@ function _splitGroupInTwo(
* @return The outer array has 2 elements, the
* list of groups before and the list of groups after the split.
*/
-function _splitCommonGroups(
+function splitCommonGroups(
groups: readonly GrDiffGroup[],
split: number
): GrDiffGroup[][] {
@@ -189,7 +197,7 @@ function _splitCommonGroups(
} else if (isCompletelyAfter) {
afterGroups.push(group);
} else {
- const {beforeSplit, afterSplit} = _splitGroupInTwo(
+ const {beforeSplit, afterSplit} = splitGroupInTwo(
group,
leftSplit,
rightSplit
@@ -385,10 +393,7 @@ export class GrDiffGroup {
this.type === GrDiffGroupType.CONTEXT_CONTROL
) {
return this.lines.map(line => {
- return {
- left: line,
- right: line,
- };
+ return {left: line, right: line};
});
}
@@ -406,9 +411,27 @@ export class GrDiffGroup {
return pairs;
}
+ getUnifiedPairs(): GrDiffLinePair[] {
+ return this.lines
+ .map(line => {
+ if (line.type === GrDiffLineType.ADD) {
+ return {left: BLANK_LINE, right: line};
+ }
+ if (line.type === GrDiffLineType.REMOVE) {
+ if (this.ignoredWhitespaceOnly) return undefined;
+ return {left: line, right: BLANK_LINE};
+ }
+ return {left: line, right: line};
+ })
+ .filter(isDefined);
+ }
+
/** Returns true if it is, or contains, a skip group. */
hasSkipGroup() {
- return !!this.skip || this.contextGroups?.some(g => !!g.skip);
+ return (
+ this.skip !== undefined ||
+ this.contextGroups?.some(g => g.skip !== undefined)
+ );
}
containsLine(side: Side, line: LineNumber) {
@@ -420,6 +443,24 @@ export class GrDiffGroup {
return lineRange.start_line <= line && line <= lineRange.end_line;
}
+ startLine(side: Side): LineNumber {
+ // For both CONTEXT_CONTROL groups and SKIP groups the `lines` array will
+ // be empty. So we have to use `lineRange` instead of looking at the first
+ // line.
+ if (
+ this.type === GrDiffGroupType.CONTEXT_CONTROL ||
+ this.skip !== undefined
+ ) {
+ return side === Side.LEFT
+ ? this.lineRange.left.start_line
+ : this.lineRange.right.start_line;
+ }
+ // For "normal" groups we could also use the `lineRange`, but for FILE or
+ // LOST lines we want to return FILE or LOST. The `lineRange` contains
+ // numbers only.
+ return this.lines[0].lineNumber(side);
+ }
+
private _updateRangeWithNewLine(line: GrDiffLine) {
if (
line.beforeNumber === 'FILE' ||
@@ -463,7 +504,7 @@ export class GrDiffGroup {
// The LOST or FILE lines may be hidden and thus never resolve an
// untilRendered() promise.
if (
- this.skip ||
+ this.skip !== undefined ||
lineNumber === 'LOST' ||
lineNumber === 'FILE' ||
this.type === GrDiffGroupType.CONTEXT_CONTROL
@@ -471,7 +512,8 @@ export class GrDiffGroup {
return Promise.resolve();
}
assertIsDefined(this.element);
- await untilRendered(this.element);
+ await (this.element as LitElement).updateComplete;
+ await untilRendered(this.element.firstElementChild as HTMLElement);
}
/**
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
index 71e2e71dcf..7ead68f571 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group_test.ts
@@ -11,6 +11,7 @@ import {
hideInContextControl,
} from './gr-diff-group';
import {assert} from '@open-wc/testing';
+import {Side} from '../../../api/diff';
suite('gr-diff-group tests', () => {
test('delta line pairs', () => {
@@ -252,4 +253,62 @@ suite('gr-diff-group tests', () => {
assert.isFalse(group.isTotal());
});
});
+
+ suite('startLine', () => {
+ test('DELTA', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.equal(group.startLine(Side.LEFT), 3);
+ assert.equal(group.startLine(Side.RIGHT), 4);
+ });
+
+ test('CONTEXT CONTROL', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, 3, 4));
+ const delta = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.CONTEXT_CONTROL,
+ contextGroups: [delta],
+ });
+ assert.equal(group.startLine(Side.LEFT), 3);
+ assert.equal(group.startLine(Side.RIGHT), 4);
+ });
+
+ test('SKIP', () => {
+ const group = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ skip: 10,
+ offsetLeft: 3,
+ offsetRight: 6,
+ });
+ assert.equal(group.startLine(Side.LEFT), 3);
+ assert.equal(group.startLine(Side.RIGHT), 6);
+
+ const group2 = new GrDiffGroup({
+ type: GrDiffGroupType.BOTH,
+ skip: 0,
+ offsetLeft: 3,
+ offsetRight: 6,
+ });
+ assert.equal(group2.startLine(Side.LEFT), 3);
+ assert.equal(group2.startLine(Side.RIGHT), 6);
+ });
+
+ test('FILE', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE'));
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.equal(group.startLine(Side.LEFT), 'FILE');
+ assert.equal(group.startLine(Side.RIGHT), 'FILE');
+ });
+
+ test('LOST', () => {
+ const lines: GrDiffLine[] = [];
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH, 'LOST', 'LOST'));
+ const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
+ assert.equal(group.startLine(Side.LEFT), 'LOST');
+ assert.equal(group.startLine(Side.RIGHT), 'LOST');
+ });
+ });
});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
index 7ca4a03abb..338a27535e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -7,6 +7,7 @@ import {
GrDiffLine as GrDiffLineApi,
GrDiffLineType,
LineNumber,
+ Side,
} from '../../../api/diff';
export {GrDiffLineType};
@@ -27,6 +28,10 @@ export class GrDiffLine implements GrDiffLineApi {
text = '';
+ lineNumber(side: Side) {
+ return side === Side.LEFT ? this.beforeNumber : this.afterNumber;
+ }
+
// TODO(TS): remove this properties
static readonly Type = GrDiffLineType;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
new file mode 100644
index 0000000000..e7f4b51700
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -0,0 +1,671 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const grDiffStyles = css`
+ /* This is used to hide all left side of the diff (e.g. diffs besides
+ comments in the change log). Since we want to remove the first 4
+ cells consistently in all rows except context buttons (.dividerRow). */
+ :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
+ :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
+ display: none;
+ }
+ :host(.disable-context-control-buttons) {
+ --context-control-display: none;
+ }
+ :host(.disable-context-control-buttons) .section {
+ border-right: none;
+ }
+ :host(.hide-line-length-indicator) .full-width td.content .contentText {
+ background-image: none;
+ }
+
+ :host {
+ font-family: var(--monospace-font-family, ''), 'Roboto Mono';
+ font-size: var(--font-size, var(--font-size-code, 12px));
+ /* usually 16px = 12px + 4px */
+ line-height: calc(
+ var(--font-size, var(--font-size-code, 12px)) + var(--spacing-s, 4px)
+ );
+ }
+
+ .thread-group {
+ display: block;
+ max-width: var(--content-width, 80ch);
+ white-space: normal;
+ background-color: var(--diff-blank-background-color);
+ }
+ .diffContainer {
+ max-width: var(--diff-max-width, none);
+ font-family: var(--monospace-font-family);
+ }
+ table {
+ border-collapse: collapse;
+ table-layout: fixed;
+ }
+ td.lineNum {
+ /* Enforces background whenever lines wrap */
+ background-color: var(--diff-blank-background-color);
+ }
+
+ /* Provides the option to add side borders (left and right) to the line
+ number column. */
+ td.lineNum,
+ td.blankLineNum,
+ td.moveControlsLineNumCol,
+ td.contextLineNum {
+ box-shadow: var(--line-number-box-shadow, unset);
+ }
+
+ /* Context controls break up the table visually, so we set the right
+ border on individual sections to leave a gap for the divider.
+
+ Also taken into account for max-width calculations in SHRINK_ONLY mode
+ (check GrDiff.updatePreferenceStyles). */
+ .section {
+ border-right: 1px solid var(--border-color);
+ }
+ .section.contextControl {
+ /* Divider inside this section must not have border; we set borders on
+ the padding rows below. */
+ border-right-width: 0;
+ }
+ /* Padding rows behind context controls. The diff is styled to be cut
+ into two halves by the negative space of the divider on which the
+ context control buttons are anchored. */
+ .contextBackground {
+ border-right: 1px solid var(--border-color);
+ }
+ .contextBackground.above {
+ border-bottom: 1px solid var(--border-color);
+ }
+ .contextBackground.below {
+ border-top: 1px solid var(--border-color);
+ }
+
+ .lineNumButton {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: var(--diff-blank-background-color);
+ box-shadow: var(--line-number-box-shadow, unset);
+ }
+ td.lineNum {
+ vertical-align: top;
+ }
+
+ /* The only way to focus this (clicking) will apply our own focus
+ styling, so this default styling is not needed and distracting. */
+ .lineNumButton:focus {
+ outline: none;
+ }
+ gr-image-viewer {
+ width: 100%;
+ height: 100%;
+ max-width: var(--image-viewer-max-width, 95vw);
+ max-height: var(--image-viewer-max-height, 90vh);
+ /* Defined by paper-styles default-theme and used in various
+ components. background-color-secondary is a compromise between
+ fairly light in light theme (where we ideally would want
+ background-color-primary) yet slightly offset against the app
+ background in dark mode, where drop shadows e.g. around paper-card
+ are almost invisible. */
+ --primary-background-color: var(--background-color-secondary);
+ }
+ .image-diff .gr-diff {
+ text-align: center;
+ }
+ .image-diff img {
+ box-shadow: var(--elevation-level-1);
+ max-width: 50em;
+ }
+ .image-diff .right.lineNumButton {
+ border-left: 1px solid var(--border-color);
+ }
+ .image-diff label {
+ font-family: var(--font-family);
+ font-style: italic;
+ }
+ tbody.binary-diff td {
+ font-family: var(--font-family);
+ font-style: italic;
+ text-align: center;
+ padding: var(--spacing-s) 0;
+ }
+ .diff-row {
+ outline: none;
+ user-select: none;
+ }
+ .diff-row.target-row.target-side-left .lineNumButton.left,
+ .diff-row.target-row.target-side-right .lineNumButton.right,
+ .diff-row.target-row.unified .lineNumButton {
+ color: var(--primary-text-color);
+ }
+
+ /* Preparing selected line cells with position relative so it allows a
+ positioned overlay with 'position: absolute'. */
+ .target-row td {
+ position: relative;
+ }
+
+ /* Defines an overlay to the selected line for drawing an outline without
+ blocking user interaction (e.g. text selection). */
+ .target-row td::before {
+ border-width: 0;
+ border-style: solid;
+ border-color: var(--focused-line-outline-color);
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+ user-select: none;
+ content: ' ';
+ }
+
+ /* The outline for the selected content cell should be the same in all
+ cases. */
+ .target-row.target-side-left td.left.content::before,
+ .target-row.target-side-right td.right.content::before,
+ .unified.target-row td.content::before {
+ border-width: 1px 1px 1px 0;
+ }
+
+ /* The outline for the sign cell should be always be contiguous
+ top/bottom. */
+ .target-row.target-side-left td.left.sign::before,
+ .target-row.target-side-right td.right.sign::before {
+ border-width: 1px 0;
+ }
+
+ /* For side-by-side we need to select the correct line number to
+ "visually close" the outline. */
+ .side-by-side.target-row.target-side-left td.left.lineNum::before,
+ .side-by-side.target-row.target-side-right td.right.lineNum::before {
+ border-width: 1px 0 1px 1px;
+ }
+
+ /* For unified diff we always start the overlay from the left cell. */
+ .unified.target-row td.left:not(.content)::before {
+ border-width: 1px 0 1px 1px;
+ }
+
+ /* For unified diff we should continue the top/bottom border in right
+ line number column. */
+ .unified.target-row td.right:not(.content)::before {
+ border-width: 1px 0;
+ }
+
+ .content {
+ background-color: var(--diff-blank-background-color);
+ }
+
+ /* Describes two states of semantic tokens: whenever a token has a
+ definition that can be navigated to (navigable) and whenever
+ the token is actually clickable to perform this navigation. */
+ .semantic-token.navigable {
+ text-decoration-style: dotted;
+ text-decoration-line: underline;
+ }
+ .semantic-token.navigable.clickable {
+ text-decoration-style: solid;
+ cursor: pointer;
+ }
+
+ /* The file line, which has no contentText, add some margin before the
+ first comment. We cannot add padding the container because we only
+ want it if there is at least one comment thread, and the slotting
+ makes :empty not work as expected. */
+ .content.file slot:first-child::slotted(.comment-thread) {
+ display: block;
+ margin-top: var(--spacing-xs);
+ }
+ .contentText {
+ background-color: var(--view-background-color);
+ }
+ .blank {
+ background-color: var(--diff-blank-background-color);
+ }
+ .image-diff .content {
+ background-color: var(--diff-blank-background-color);
+ }
+ .responsive {
+ width: 100%;
+ }
+ .responsive .contentText {
+ white-space: break-spaces;
+ word-break: break-all;
+ }
+ .lineNumButton,
+ .content {
+ vertical-align: top;
+ white-space: pre;
+ }
+ .contextLineNum,
+ .lineNumButton {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ color: var(--deemphasized-text-color);
+ padding: 0 var(--spacing-m);
+ text-align: right;
+ }
+ .canComment .lineNumButton {
+ cursor: pointer;
+ }
+ .sign {
+ min-width: 1ch;
+ width: 1ch;
+ background-color: var(--view-background-color);
+ }
+ .sign.blank {
+ background-color: var(--diff-blank-background-color);
+ }
+ .content {
+ /* Set min width since setting width on table cells still allows them
+ to shrink. Do not set max width because CJK
+ (Chinese-Japanese-Korean) glyphs have variable width. */
+ min-width: var(--content-width, 80ch);
+ width: var(--content-width, 80ch);
+ }
+ /* If there are no intraline info, consider everything changed */
+ .content.add .contentText .intraline,
+ .content.add.no-intraline-info .contentText,
+ .sign.add.no-intraline-info,
+ .delta.total .content.add .contentText {
+ background-color: var(--dark-add-highlight-color);
+ }
+ .content.add .contentText,
+ .sign.add {
+ background-color: var(--light-add-highlight-color);
+ }
+ /* If there are no intraline info, consider everything changed */
+ .content.remove .contentText .intraline,
+ .content.remove.no-intraline-info .contentText,
+ .delta.total .content.remove .contentText,
+ .sign.remove.no-intraline-info {
+ background-color: var(--dark-remove-highlight-color);
+ }
+ .content.remove .contentText,
+ .sign.remove {
+ background-color: var(--light-remove-highlight-color);
+ }
+
+ .ignoredWhitespaceOnly .sign.no-intraline-info {
+ background-color: var(--view-background-color);
+ }
+
+ /* dueToRebase */
+ .dueToRebase .content.add .contentText .intraline,
+ .delta.total.dueToRebase .content.add .contentText {
+ background-color: var(--dark-rebased-add-highlight-color);
+ }
+ .dueToRebase .content.add .contentText {
+ background-color: var(--light-rebased-add-highlight-color);
+ }
+ .dueToRebase .content.remove .contentText .intraline,
+ .delta.total.dueToRebase .content.remove .contentText {
+ background-color: var(--dark-rebased-remove-highlight-color);
+ }
+ .dueToRebase .content.remove .contentText {
+ background-color: var(--light-rebased-remove-highlight-color);
+ }
+
+ /* dueToMove */
+ .dueToMove .sign.add,
+ .dueToMove .content.add .contentText,
+ .dueToMove .moveControls.movedIn .sign.right,
+ .dueToMove .moveControls.movedIn .moveHeader,
+ .delta.total.dueToMove .content.add .contentText {
+ background-color: var(--diff-moved-in-background);
+ }
+
+ .dueToMove.changed .sign.add,
+ .dueToMove.changed .content.add .contentText,
+ .dueToMove.changed .moveControls.movedIn .sign.right,
+ .dueToMove.changed .moveControls.movedIn .moveHeader,
+ .delta.total.dueToMove.changed .content.add .contentText {
+ background-color: var(--diff-moved-in-changed-background);
+ }
+
+ .dueToMove .sign.remove,
+ .dueToMove .content.remove .contentText,
+ .dueToMove .moveControls.movedOut .moveHeader,
+ .dueToMove .moveControls.movedOut .sign.left,
+ .delta.total.dueToMove .content.remove .contentText {
+ background-color: var(--diff-moved-out-background);
+ }
+
+ .delta.dueToMove .movedIn .moveHeader {
+ --gr-range-header-color: var(--diff-moved-in-label-color);
+ }
+ .delta.dueToMove.changed .movedIn .moveHeader {
+ --gr-range-header-color: var(--diff-moved-in-changed-label-color);
+ }
+ .delta.dueToMove .movedOut .moveHeader {
+ --gr-range-header-color: var(--diff-moved-out-label-color);
+ }
+
+ .moveHeader a {
+ color: inherit;
+ }
+
+ /* ignoredWhitespaceOnly */
+ .ignoredWhitespaceOnly .content.add .contentText .intraline,
+ .delta.total.ignoredWhitespaceOnly .content.add .contentText,
+ .ignoredWhitespaceOnly .content.add .contentText,
+ .ignoredWhitespaceOnly .content.remove .contentText .intraline,
+ .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
+ .ignoredWhitespaceOnly .content.remove .contentText {
+ background-color: var(--view-background-color);
+ }
+
+ .content .contentText gr-diff-text:empty:after,
+ .content .contentText gr-legacy-text:empty:after,
+ .content .contentText:empty:after {
+ /* Newline, to ensure empty lines are one line-height tall. */
+ content: '\\A';
+ }
+
+ /* Context controls */
+ .contextControl {
+ display: var(--context-control-display, table-row-group);
+ background-color: transparent;
+ border: none;
+ --divider-height: var(--spacing-s);
+ --divider-border: 1px;
+ }
+ /* TODO: Is this still used? */
+ .contextControl gr-button gr-icon {
+ /* should match line-height of gr-button */
+ font-size: var(--line-height-mono, 18px);
+ }
+ .contextControl td:not(.lineNumButton) {
+ text-align: center;
+ }
+
+ /* Padding rows behind context controls. Styled as a continuation of the
+ line gutters and code area. */
+ .contextBackground > .contextLineNum {
+ background-color: var(--diff-blank-background-color);
+ }
+ .contextBackground > td:not(.contextLineNum) {
+ background-color: var(--view-background-color);
+ }
+ .contextBackground {
+ /* One line of background behind the context expanders which they can
+ render on top of, plus some padding. */
+ height: calc(var(--line-height-normal) + var(--spacing-s));
+ }
+
+ .dividerCell {
+ vertical-align: top;
+ }
+ .dividerRow.show-both .dividerCell {
+ height: var(--divider-height);
+ }
+ .dividerRow.show-above .dividerCell,
+ .dividerRow.show-above .dividerCell {
+ height: 0;
+ }
+
+ .br:after {
+ /* Line feed */
+ content: '\\A';
+ }
+ .tab {
+ display: inline-block;
+ }
+ .tab-indicator:before {
+ color: var(--diff-tab-indicator-color);
+ /* >> character */
+ content: '\\00BB';
+ position: absolute;
+ }
+ .special-char-indicator {
+ /* spacing so elements don't collide */
+ padding-right: var(--spacing-m);
+ }
+ .special-char-indicator:before {
+ color: var(--diff-tab-indicator-color);
+ content: '•';
+ position: absolute;
+ }
+ .special-char-warning {
+ /* spacing so elements don't collide */
+ padding-right: var(--spacing-m);
+ }
+ .special-char-warning:before {
+ color: var(--warning-foreground);
+ content: '!';
+ position: absolute;
+ }
+ /* Is defined after other background-colors, such that this
+ rule wins in case of same specificity. */
+ .trailing-whitespace,
+ .content .contentText .trailing-whitespace,
+ .trailing-whitespace .intraline,
+ .content .contentText .trailing-whitespace .intraline {
+ border-radius: var(--border-radius, 4px);
+ background-color: var(--diff-trailing-whitespace-indicator);
+ }
+ #diffHeader {
+ background-color: var(--table-header-background-color);
+ border-bottom: 1px solid var(--border-color);
+ color: var(--link-color);
+ padding: var(--spacing-m) 0 var(--spacing-m) 48px;
+ }
+ #diffTable {
+ /* for gr-selection-action-box positioning */
+ position: relative;
+ }
+ #diffTable:focus {
+ outline: none;
+ }
+ #loadingError,
+ #sizeWarning {
+ display: block;
+ margin: var(--spacing-l) auto;
+ max-width: 60em;
+ text-align: center;
+ }
+ #loadingError {
+ color: var(--error-text-color);
+ }
+ #sizeWarning gr-button {
+ margin: var(--spacing-l);
+ }
+ .target-row td.blame {
+ background: var(--diff-selection-background-color);
+ }
+ td.lost div {
+ background-color: var(--info-background);
+ }
+ td.lost div.lost-message {
+ font-family: var(--font-family, 'Roboto');
+ font-size: var(--font-size-normal, 14px);
+ line-height: var(--line-height-normal);
+ padding: var(--spacing-s) 0;
+ }
+ td.lost div.lost-message gr-icon {
+ padding: 0 var(--spacing-s) 0 var(--spacing-m);
+ color: var(--blue-700);
+ }
+
+ col.sign,
+ td.sign {
+ display: none;
+ }
+
+ /* Sign column should only be shown in high-contrast mode. */
+ :host(.with-sign-col) col.sign {
+ display: table-column;
+ }
+ :host(.with-sign-col) td.sign {
+ display: table-cell;
+ }
+ col.blame {
+ display: none;
+ }
+ td.blame {
+ display: none;
+ padding: 0 var(--spacing-m);
+ white-space: pre;
+ }
+ :host(.showBlame) col.blame {
+ display: table-column;
+ }
+ :host(.showBlame) td.blame {
+ display: table-cell;
+ }
+ td.blame > span {
+ opacity: 0.6;
+ }
+ td.blame > span.startOfRange {
+ opacity: 1;
+ }
+ td.blame .blameDate {
+ font-family: var(--monospace-font-family);
+ color: var(--link-color);
+ text-decoration: none;
+ }
+ .responsive td.blame {
+ overflow: hidden;
+ width: 200px;
+ }
+ /** Support the line length indicator **/
+ .responsive td.content .contentText {
+ /* Same strategy as in
+ https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+ */
+ background-image: linear-gradient(
+ var(--line-length-indicator-color),
+ var(--line-length-indicator-color)
+ );
+ background-size: 1px 100%;
+ background-position: var(--line-limit-marker) 0;
+ background-repeat: no-repeat;
+ }
+ .newlineWarning {
+ color: var(--deemphasized-text-color);
+ text-align: center;
+ }
+ .newlineWarning.hidden {
+ display: none;
+ }
+ .lineNum.COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background-color: var(--coverage-covered, #e0f2f1);
+ }
+ .lineNum.NOT_COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background-color: var(--coverage-not-covered, #ffd1a4);
+ }
+ .lineNum.PARTIALLY_COVERED .lineNumButton {
+ color: var(
+ --coverage-covered-line-num-color,
+ var(--deemphasized-text-color)
+ );
+ background: linear-gradient(
+ to right bottom,
+ var(--coverage-not-covered, #ffd1a4) 0%,
+ var(--coverage-not-covered, #ffd1a4) 50%,
+ var(--coverage-covered, #e0f2f1) 50%,
+ var(--coverage-covered, #e0f2f1) 100%
+ );
+ }
+
+ // TODO: Investigate whether this CSS is still necessary.
+ /* BEGIN: Select and copy for Polymer 2 */
+ /* Below was copied and modified from the original css in gr-diff-selection.html. */
+ .content,
+ .contextControl,
+ .blame {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ .selected-left:not(.selected-comment)
+ .side-by-side
+ .left
+ + .content
+ .contentText,
+ .selected-right:not(.selected-comment)
+ .side-by-side
+ .right
+ + .content
+ .contentText,
+ .selected-left:not(.selected-comment)
+ .unified
+ .left.lineNum
+ ~ .content:not(.both)
+ .contentText,
+ .selected-right:not(.selected-comment)
+ .unified
+ .right.lineNum
+ ~ .content
+ .contentText,
+ .selected-left.selected-comment .side-by-side .left + .content .message,
+ .selected-right.selected-comment
+ .side-by-side
+ .right
+ + .content
+ .message
+ :not(.collapsedContent),
+ .selected-comment .unified .message :not(.collapsedContent),
+ .selected-blame .blame {
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ }
+
+ /* Make comments and check results selectable when selected */
+ .selected-left.selected-comment ::slotted(.comment-thread[diff-side='left']),
+ .selected-right.selected-comment
+ ::slotted(.comment-thread[diff-side='right']) {
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ user-select: text;
+ }
+ /* END: Select and copy for Polymer 2 */
+
+ .whitespace-change-only-message {
+ background-color: var(--diff-context-control-background-color);
+ border: 1px solid var(--diff-context-control-border-color);
+ text-align: center;
+ }
+
+ .token-highlight {
+ background-color: var(--token-highlighting-color, #fffd54);
+ }
+
+ gr-selection-action-box {
+ /* Needs z-index to appear above wrapped content, since it's inserted
+ into DOM before it. */
+ z-index: 10;
+ }
+
+ gr-diff-image-new,
+ gr-diff-image-old,
+ gr-diff-section,
+ gr-context-controls-section,
+ gr-diff-row {
+ display: contents;
+ }
+`;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 2b61c8cfc0..669537e002 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -34,27 +34,41 @@ import {getBaseUrl} from '../../../utils/url-util';
* Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
* A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
*/
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
// If any line of the diff is more than the character limit, then disable
// syntax highlighting for the entire file.
export const SYNTAX_MAX_LINE_LENGTH = 500;
+export function countLines(diff?: DiffInfo, side?: Side) {
+ if (!diff?.content || !side) return 0;
+ return diff.content.reduce((sum, chunk) => {
+ const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
+ return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
+ }, 0);
+}
+
+export function isFileUnchanged(diff: DiffInfo) {
+ return !diff.content.some(
+ content => (content.a && !content.common) || (content.b && !content.common)
+ );
+}
+
export function getResponsiveMode(
- prefs: DiffPreferencesInfo,
+ prefs?: DiffPreferencesInfo,
renderPrefs?: RenderPreferences
): DiffResponsiveMode {
if (renderPrefs?.responsive_mode) {
return renderPrefs.responsive_mode;
}
// Backwards compatibility to the line_wrapping param.
- if (prefs.line_wrapping) {
+ if (prefs?.line_wrapping) {
return 'FULL_RESPONSIVE';
}
return 'NONE';
}
-export function isResponsive(responsiveMode: DiffResponsiveMode) {
+export function isResponsive(responsiveMode?: DiffResponsiveMode) {
return (
responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
);
@@ -105,7 +119,12 @@ export function getLineElByChild(node?: Node): HTMLElement | null {
return null;
}
}
- node = node.previousSibling ?? node.parentElement ?? undefined;
+ node =
+ (node as Element).assignedSlot ??
+ (node as ShadowRoot).host ??
+ node.previousSibling ??
+ node.parentNode ??
+ undefined;
}
return null;
}
@@ -149,7 +168,7 @@ export function getRange(threadEl: HTMLElement): CommentRange | undefined {
const rangeAtt = threadEl.getAttribute('range');
if (!rangeAtt) return undefined;
const range = JSON.parse(rangeAtt) as CommentRange;
- if (!range.start_line) throw new Error(`invalid range: ${rangeAtt}`);
+ if (!range.start_line) return undefined;
return range;
}
@@ -184,9 +203,16 @@ export function anyLineTooLong(diff?: DiffInfo) {
}
/**
+ * Simple helper method for creating element classes in the context of
+ * gr-diff. This is just a super simple convenience function.
+ */
+export function diffClasses(...additionalClasses: string[]) {
+ return ['gr-diff', ...additionalClasses].join(' ');
+}
+
+/**
* Simple helper method for creating elements in the context of gr-diff.
- *
- * Otherwise this is just a super simple convenience function.
+ * This is just a super simple convenience function.
*/
export function createElementDiff(
tagName: string,
@@ -254,6 +280,12 @@ export function formatText(
elementId: string
): HTMLElement {
const contentText = createElementDiff('div', 'contentText');
+ // <gr-legacy-text> is not defined anywhere, so this behave just as a <div>
+ // would. We use this during the migration to lit based diff elements to
+ // match <gr-diff-text>. We define a css rule with `display:contents` making
+ // sure that this extra element is basically a no-op.
+ const legacyText = document.createElement('gr-legacy-text');
+ contentText.appendChild(legacyText);
contentText.id = elementId;
let columnPos = 0;
let textOffset = 0;
@@ -265,16 +297,16 @@ export function formatText(
let rowStart = 0;
let rowEnd = lineLimit - columnPos;
while (rowEnd < segment.length) {
- contentText.appendChild(
+ legacyText.appendChild(
document.createTextNode(segment.substring(rowStart, rowEnd))
);
- contentText.appendChild(createLineBreak(responsiveMode));
+ legacyText.appendChild(createLineBreak(responsiveMode));
columnPos = 0;
rowStart = rowEnd;
rowEnd += lineLimit;
}
// Append the last part of |segment|, which fits on the current line.
- contentText.appendChild(
+ legacyText.appendChild(
document.createTextNode(segment.substring(rowStart))
);
columnPos += segment.length - rowStart;
@@ -286,20 +318,20 @@ export function formatText(
// Append a single '\t' character.
let effectiveTabSize = tabSize - (columnPos % tabSize);
if (columnPos + effectiveTabSize > lineLimit) {
- contentText.appendChild(createLineBreak(responsiveMode));
+ legacyText.appendChild(createLineBreak(responsiveMode));
columnPos = 0;
effectiveTabSize = tabSize;
}
- contentText.appendChild(createTabWrapper(effectiveTabSize));
+ legacyText.appendChild(createTabWrapper(effectiveTabSize));
columnPos += effectiveTabSize;
textOffset++;
} else {
// Append a single surrogate pair.
if (columnPos >= lineLimit) {
- contentText.appendChild(createLineBreak(responsiveMode));
+ legacyText.appendChild(createLineBreak(responsiveMode));
columnPos = 0;
}
- contentText.appendChild(
+ legacyText.appendChild(
document.createTextNode(text.substring(textOffset, textOffset + 2))
);
textOffset += 2;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 7e8eb4ce83..2438bcb00c 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -4,8 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '@open-wc/testing';
+import {DiffInfo} from '../../../api/diff';
import '../../../test/common-test-setup';
-import {createElementDiff, formatText, createTabWrapper} from './gr-diff-utils';
+import {createDiff} from '../../../test/test-data-generators';
+import {
+ createElementDiff,
+ formatText,
+ createTabWrapper,
+ isFileUnchanged,
+ getRange,
+} from './gr-diff-utils';
const LINE_BREAK_HTML = '<span class="gr-diff br"></span>';
@@ -20,10 +28,13 @@ suite('gr-diff-utils tests', () => {
test('formatText newlines 1', () => {
let text = 'abcdef';
- assert.equal(formatText(text, 'NONE', 4, 10, '').innerHTML, text);
+ assert.equal(
+ formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
+ text
+ );
text = 'a'.repeat(20);
assert.equal(
- formatText(text, 'NONE', 4, 10, '').innerHTML,
+ formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
);
});
@@ -31,7 +42,7 @@ suite('gr-diff-utils tests', () => {
test('formatText newlines 2', () => {
const text = '<span class="thumbsup">👍</span>';
assert.equal(
- formatText(text, 'NONE', 4, 10, '').innerHTML,
+ formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
'&lt;span clas' +
LINE_BREAK_HTML +
's="thumbsu' +
@@ -45,7 +56,7 @@ suite('gr-diff-utils tests', () => {
test('formatText newlines 3', () => {
const text = '01234\t56789';
assert.equal(
- formatText(text, 'NONE', 4, 10, '').innerHTML,
+ formatText(text, 'NONE', 4, 10, '').firstElementChild?.innerHTML,
'01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
);
});
@@ -53,7 +64,7 @@ suite('gr-diff-utils tests', () => {
test('formatText newlines 4', () => {
const text = '👍'.repeat(58);
assert.equal(
- formatText(text, 'NONE', 4, 20, '').innerHTML,
+ formatText(text, 'NONE', 4, 20, '').firstElementChild?.innerHTML,
'👍'.repeat(20) +
LINE_BREAK_HTML +
'👍'.repeat(20) +
@@ -82,7 +93,8 @@ suite('gr-diff-utils tests', () => {
assert.ok(wrapper);
assert.equal(wrapper.innerText, '\t');
assert.equal(
- formatText(html, 'NONE', tabSize, Infinity, '').innerHTML,
+ formatText(html, 'NONE', tabSize, Infinity, '').firstElementChild
+ ?.innerHTML,
'abc' + wrapper.outerHTML + 'def'
);
});
@@ -91,31 +103,22 @@ suite('gr-diff-utils tests', () => {
let input = '<script>alert("XSS");<' + '/script>';
let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
- let result = formatText(
- input,
- 'NONE',
- 1,
- Number.POSITIVE_INFINITY,
- ''
- ).innerHTML;
+ let result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
+ .firstElementChild?.innerHTML;
assert.equal(result, expected);
input = '& < > " \' / `';
expected = '&amp; &lt; &gt; " \' / `';
- result = formatText(
- input,
- 'NONE',
- 1,
- Number.POSITIVE_INFINITY,
- ''
- ).innerHTML;
+ result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY, '')
+ .firstElementChild?.innerHTML;
assert.equal(result, expected);
});
test('text length with tabs and unicode', () => {
function expectTextLength(text: string, tabSize: number, expected: number) {
// Formatting to |expected| columns should not introduce line breaks.
- const result = formatText(text, 'NONE', tabSize, expected, '');
+ const result = formatText(text, 'NONE', tabSize, expected, '')
+ .firstElementChild!;
assert.isNotOk(
result.querySelector('.contentText > .br'),
' Expected the result of: \n' +
@@ -126,19 +129,22 @@ suite('gr-diff-utils tests', () => {
// Increasing the line limit should produce the same markup.
assert.equal(
- formatText(text, 'NONE', tabSize, Infinity, '').innerHTML,
+ formatText(text, 'NONE', tabSize, Infinity, '').firstElementChild
+ ?.innerHTML,
result.innerHTML
);
assert.equal(
- formatText(text, 'NONE', tabSize, expected + 1, '').innerHTML,
+ formatText(text, 'NONE', tabSize, expected + 1, '').firstElementChild
+ ?.innerHTML,
result.innerHTML
);
// Decreasing the line limit should introduce line breaks.
if (expected > 0) {
- const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '');
+ const tooSmall = formatText(text, 'NONE', tabSize, expected - 1, '')
+ .firstElementChild!;
assert.isOk(
- tooSmall.querySelector('.contentText > .br'),
+ tooSmall.querySelector('.contentText .br'),
' Expected the result of: \n' +
` _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
' to contain a br. But the actual result HTML was:\n' +
@@ -158,4 +164,52 @@ suite('gr-diff-utils tests', () => {
expectTextLength('abc\tde\t', 10, 20);
expectTextLength('\t\t\t\t\t', 20, 100);
});
+
+ test('isFileUnchanged', () => {
+ let diff: DiffInfo = {
+ ...createDiff(),
+ content: [
+ {a: ['abcd'], ab: ['ef']},
+ {b: ['ancd'], a: ['xx']},
+ ],
+ };
+ assert.equal(isFileUnchanged(diff), false);
+ diff = {
+ ...createDiff(),
+ content: [{ab: ['abcd']}, {ab: ['ancd']}],
+ };
+ assert.equal(isFileUnchanged(diff), true);
+ diff = {
+ ...createDiff(),
+ content: [
+ {a: ['abcd'], ab: ['ef'], common: true},
+ {b: ['ancd'], ab: ['xx']},
+ ],
+ };
+ assert.equal(isFileUnchanged(diff), false);
+ diff = {
+ ...createDiff(),
+ content: [
+ {a: ['abcd'], ab: ['ef'], common: true},
+ {b: ['ancd'], ab: ['xx'], common: true},
+ ],
+ };
+ assert.equal(isFileUnchanged(diff), true);
+ });
+
+ test('getRange returns undefined with start_line = 0', () => {
+ const range = {
+ start_line: 0,
+ end_line: 12,
+ start_character: 0,
+ end_character: 0,
+ };
+ const threadEl = document.createElement('div');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('diff-side', 'right');
+ threadEl.setAttribute('line-num', '1');
+ threadEl.setAttribute('range', JSON.stringify(range));
+ threadEl.setAttribute('slot', 'right-1');
+ assert.isUndefined(getRange(threadEl));
+ });
});
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 53c27803f2..3929330cbe 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -27,10 +27,12 @@ import {
isResponsive,
getDiffLength,
} from './gr-diff-utils';
-import {getHiddenScroll} from '../../../scripts/hiddenscroll';
import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
+import {
+ CreateRangeCommentEventDetail,
+ GrDiffHighlight,
+} from '../gr-diff-highlight/gr-diff-highlight';
import {
GrDiffBuilderElement,
getLineNumberCellWidth,
@@ -43,7 +45,7 @@ import {
Side,
} from '../../../constants/constants';
import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
+import {fire, fireAlert} from '../../../utils/event-util';
import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
import {getContentEditableRange} from '../../../utils/safari-selection-util';
import {AbortStop} from '../../../api/core';
@@ -63,12 +65,16 @@ import {
import {GrDiffSelection} from '../gr-diff-selection/gr-diff-selection';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
-import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {html, LitElement, nothing, PropertyValues} from 'lit';
import {when} from 'lit/directives/when.js';
import {grSyntaxTheme} from '../gr-syntax-themes/gr-syntax-theme';
import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
import {classMap} from 'lit/directives/class-map.js';
import {iconStyles} from '../../../styles/gr-icon-styles';
+import {expandFileMode} from '../../../utils/file-util';
+import {DiffModel, diffModelToken} from '../gr-diff-model/gr-diff-model';
+import {provide} from '../../../models/dependency';
+import {grDiffStyles} from './gr-diff-styles';
const NO_NEWLINE_LEFT = 'No newline at end of left file.';
const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -138,10 +144,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
prefs?: DiffPreferencesInfo;
@property({type: Object})
- renderPrefs?: RenderPreferences;
-
- @property({type: Boolean})
- displayLine = false;
+ renderPrefs: RenderPreferences = {};
@property({type: Boolean})
isImageDiff?: boolean;
@@ -259,7 +262,8 @@ export class GrDiff extends LitElement implements GrDiffApi {
// Private but used in tests.
renderDiffTableTask?: DelayedPromise<void>;
- private diffSelection = new GrDiffSelection();
+ // Private but used in tests.
+ diffSelection = new GrDiffSelection();
// Private but used in tests.
highlights = new GrDiffHighlight();
@@ -267,713 +271,25 @@ export class GrDiff extends LitElement implements GrDiffApi {
// Private but used in tests.
diffBuilder = new GrDiffBuilderElement();
+ private diffModel = new DiffModel(undefined);
+
static override get styles() {
return [
iconStyles,
sharedStyles,
grSyntaxTheme,
grRangedCommentTheme,
- css`
- /**
- This is used to hide all left side of the diff (e.g. diffs besides
- comments in the change log). Since we want to remove the first 4
- cells consistently in all rows except context buttons (.dividerRow).
- */
- :host(.no-left) .sideBySide colgroup col:nth-child(-n + 4),
- :host(.no-left) .sideBySide tr:not(.dividerRow) td:nth-child(-n + 4) {
- display: none;
- }
- :host(.disable-context-control-buttons) {
- --context-control-display: none;
- }
- :host(.disable-context-control-buttons) .section {
- border-right: none;
- }
- :host(.hide-line-length-indicator) .full-width td.content .contentText {
- background-image: none;
- }
-
- :host {
- font-family: var(--monospace-font-family, ''), 'Roboto Mono';
- font-size: var(--font-size, var(--font-size-code, 12px));
- /* usually 16px = 12px + 4px */
- line-height: calc(
- var(--font-size, var(--font-size-code, 12px)) +
- var(--spacing-s, 4px)
- );
- }
-
- .thread-group {
- display: block;
- max-width: var(--content-width, 80ch);
- white-space: normal;
- background-color: var(--diff-blank-background-color);
- }
- .diffContainer {
- max-width: var(--diff-max-width, none);
- display: flex;
- font-family: var(--monospace-font-family);
- }
- .diffContainer.hiddenscroll {
- margin-bottom: var(--spacing-m);
- }
- table {
- border-collapse: collapse;
- table-layout: fixed;
- }
- td.lineNum {
- /* Enforces background whenever lines wrap */
- background-color: var(--diff-blank-background-color);
- }
-
- /**
- Provides the option to add side borders (left and right) to the line
- number column.
- */
- td.lineNum,
- td.blankLineNum,
- td.moveControlsLineNumCol,
- td.contextLineNum {
- box-shadow: var(--line-number-box-shadow, unset);
- }
-
- /**
- Context controls break up the table visually, so we set the right
- border on individual sections to leave a gap for the divider.
-
- Also taken into account for max-width calculations in SHRINK_ONLY mode
- (check GrDiff.updatePreferenceStyles).
- */
- .section {
- border-right: 1px solid var(--border-color);
- }
- .section.contextControl {
- /**
- Divider inside this section must not have border; we set borders on
- the padding rows below.
- */
- border-right-width: 0;
- }
- /**
- Padding rows behind context controls. The diff is styled to be cut
- into two halves by the negative space of the divider on which the
- context control buttons are anchored.
- */
- .contextBackground {
- border-right: 1px solid var(--border-color);
- }
- .contextBackground.above {
- border-bottom: 1px solid var(--border-color);
- }
- .contextBackground.below {
- border-top: 1px solid var(--border-color);
- }
-
- .lineNumButton {
- display: block;
- width: 100%;
- height: 100%;
- background-color: var(--diff-blank-background-color);
- box-shadow: var(--line-number-box-shadow, unset);
- }
- td.lineNum {
- vertical-align: top;
- }
-
- /**
- The only way to focus this (clicking) will apply our own focus
- styling, so this default styling is not needed and distracting.
- */
- .lineNumButton:focus {
- outline: none;
- }
- gr-image-viewer {
- width: 100%;
- height: 100%;
- max-width: var(--image-viewer-max-width, 95vw);
- max-height: var(--image-viewer-max-height, 90vh);
- /**
- Defined by paper-styles default-theme and used in various
- components. background-color-secondary is a compromise between
- fairly light in light theme (where we ideally would want
- background-color-primary) yet slightly offset against the app
- background in dark mode, where drop shadows e.g. around paper-card
- are almost invisible.
- */
- --primary-background-color: var(--background-color-secondary);
- }
- .image-diff .gr-diff {
- text-align: center;
- }
- .image-diff img {
- box-shadow: var(--elevation-level-1);
- max-width: 50em;
- }
- .image-diff .right.lineNumButton {
- border-left: 1px solid var(--border-color);
- }
- .image-diff label,
- .binary-diff label {
- font-family: var(--font-family);
- font-style: italic;
- }
- .diff-row {
- outline: none;
- user-select: none;
- }
- .diff-row.target-row.target-side-left .lineNumButton.left,
- .diff-row.target-row.target-side-right .lineNumButton.right,
- .diff-row.target-row.unified .lineNumButton {
- color: var(--primary-text-color);
- }
-
- /**
- Preparing selected line cells with position relative so it allows a
- positioned overlay with 'position: absolute'.
- */
- .target-row td {
- position: relative;
- }
-
- /**
- Defines an overlay to the selected line for drawing an outline without
- blocking user interaction (e.g. text selection).
- */
- .target-row td::before {
- border-width: 0;
- border-style: solid;
- border-color: var(--focused-line-outline-color);
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- pointer-events: none;
- user-select: none;
- content: ' ';
- }
-
- /**
- the outline for the selected content cell should be the same in all
- cases.
- */
- .target-row.target-side-left td.left.content::before,
- .target-row.target-side-right td.right.content::before,
- .unified.target-row td.content::before {
- border-width: 1px 1px 1px 0;
- }
-
- /**
- the outline for the sign cell should be always be contiguous
- top/bottom.
- */
- .target-row.target-side-left td.left.sign::before,
- .target-row.target-side-right td.right.sign::before {
- border-width: 1px 0;
- }
-
- /**
- For side-by-side we need to select the correct line number to
- "visually close" the outline.
- */
- .side-by-side.target-row.target-side-left td.left.lineNum::before,
- .side-by-side.target-row.target-side-right td.right.lineNum::before {
- border-width: 1px 0 1px 1px;
- }
-
- /**
- For unified diff we always start the overlay from the left cell
- */
- .unified.target-row td.left:not(.content)::before {
- border-width: 1px 0 1px 1px;
- }
-
- /**
- For unified diff we should continue the top/bottom border in right
- line number column.
- */
- .unified.target-row td.right:not(.content)::before {
- border-width: 1px 0;
- }
-
- .content {
- background-color: var(--diff-blank-background-color);
- }
-
- /**
- Describes two states of semantic tokens: whenever a token has a
- definition that can be navigated to (navigable) and whenever
- the token is actually clickable to perform this navigation.
- */
- .semantic-token.navigable {
- text-decoration-style: dotted;
- text-decoration-line: underline;
- }
- .semantic-token.navigable.clickable {
- text-decoration-style: solid;
- cursor: pointer;
- }
-
- /*
- The file line, which has no contentText, add some margin before the
- first comment. We cannot add padding the container because we only
- want it if there is at least one comment thread, and the slotting
- makes :empty not work as expected.
- */
- .content.file slot:first-child::slotted(.comment-thread) {
- display: block;
- margin-top: var(--spacing-xs);
- }
- .contentText {
- background-color: var(--view-background-color);
- }
- .blank {
- background-color: var(--diff-blank-background-color);
- }
- .image-diff .content {
- background-color: var(--diff-blank-background-color);
- }
- .responsive {
- width: 100%;
- }
- .responsive .contentText {
- white-space: break-spaces;
- word-break: break-all;
- }
- .lineNumButton,
- .content {
- vertical-align: top;
- white-space: pre;
- }
- .contextLineNum,
- .lineNumButton {
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
-
- color: var(--deemphasized-text-color);
- padding: 0 var(--spacing-m);
- text-align: right;
- }
- .canComment .lineNumButton {
- cursor: pointer;
- }
- .sign {
- min-width: 1ch;
- width: 1ch;
- background-color: var(--view-background-color);
- }
- .sign.blank {
- background-color: var(--diff-blank-background-color);
- }
- .content {
- /*
- Set min width since setting width on table cells still allows them
- to shrink. Do not set max width because CJK
- (Chinese-Japanese-Korean) glyphs have variable width
- */
- min-width: var(--content-width, 80ch);
- width: var(--content-width, 80ch);
- }
- .content.add .contentText .intraline,
- /* If there are no intraline info, consider everything changed */
- .content.add.no-intraline-info .contentText,
- .sign.add.no-intraline-info,
- .delta.total .content.add .contentText {
- background-color: var(--dark-add-highlight-color);
- }
- .content.add .contentText,
- .sign.add {
- background-color: var(--light-add-highlight-color);
- }
- .content.remove .contentText .intraline,
- /* If there are no intraline info, consider everything changed */
- .content.remove.no-intraline-info .contentText,
- .delta.total .content.remove .contentText,
- .sign.remove.no-intraline-info {
- background-color: var(--dark-remove-highlight-color);
- }
- .content.remove .contentText,
- .sign.remove {
- background-color: var(--light-remove-highlight-color);
- }
-
- .ignoredWhitespaceOnly .sign.no-intraline-info {
- background-color: var(--view-background-color);
- }
-
- /* dueToRebase */
- .dueToRebase .content.add .contentText .intraline,
- .delta.total.dueToRebase .content.add .contentText {
- background-color: var(--dark-rebased-add-highlight-color);
- }
- .dueToRebase .content.add .contentText {
- background-color: var(--light-rebased-add-highlight-color);
- }
- .dueToRebase .content.remove .contentText .intraline,
- .delta.total.dueToRebase .content.remove .contentText {
- background-color: var(--dark-rebased-remove-highlight-color);
- }
- .dueToRebase .content.remove .contentText {
- background-color: var(--light-rebased-remove-highlight-color);
- }
-
- /* dueToMove */
- .dueToMove .sign.add,
- .dueToMove .content.add .contentText,
- .dueToMove .moveControls.movedIn .sign.right,
- .dueToMove .moveControls.movedIn .moveHeader,
- .delta.total.dueToMove .content.add .contentText {
- background-color: var(--diff-moved-in-background);
- }
-
- .dueToMove .sign.remove,
- .dueToMove .content.remove .contentText,
- .dueToMove .moveControls.movedOut .moveHeader,
- .dueToMove .moveControls.movedOut .sign.left,
- .delta.total.dueToMove .content.remove .contentText {
- background-color: var(--diff-moved-out-background);
- }
-
- .delta.dueToMove .movedIn .moveHeader {
- --gr-range-header-color: var(--diff-moved-in-label-color);
- }
- .delta.dueToMove .movedOut .moveHeader {
- --gr-range-header-color: var(--diff-moved-out-label-color);
- }
-
- .moveHeader a {
- color: inherit;
- }
-
- /* ignoredWhitespaceOnly */
- .ignoredWhitespaceOnly .content.add .contentText .intraline,
- .delta.total.ignoredWhitespaceOnly .content.add .contentText,
- .ignoredWhitespaceOnly .content.add .contentText,
- .ignoredWhitespaceOnly .content.remove .contentText .intraline,
- .delta.total.ignoredWhitespaceOnly .content.remove .contentText,
- .ignoredWhitespaceOnly .content.remove .contentText {
- background-color: var(--view-background-color);
- }
-
- .content .contentText:empty:after {
- /* Newline, to ensure empty lines are one line-height tall. */
- content: '\\A';
- }
-
- /* Context controls */
- .contextControl {
- display: var(--context-control-display, table-row-group);
- background-color: transparent;
- border: none;
- --divider-height: var(--spacing-s);
- --divider-border: 1px;
- }
- /* TODO: Is this still used? */
- .contextControl gr-button gr-icon {
- /* should match line-height of gr-button */
- font-size: var(--line-height-mono, 18px);
- }
- .contextControl td:not(.lineNumButton) {
- text-align: center;
- }
-
- /**
- Padding rows behind context controls. Styled as a continuation of the
- line gutters and code area.
- */
- .contextBackground > .contextLineNum {
- background-color: var(--diff-blank-background-color);
- }
- .contextBackground > td:not(.contextLineNum) {
- background-color: var(--view-background-color);
- }
- .contextBackground {
- /**
- One line of background behind the context expanders which they can
- render on top of, plus some padding.
- */
- height: calc(var(--line-height-normal) + var(--spacing-s));
- }
-
- .dividerCell {
- vertical-align: top;
- }
- .dividerRow.show-both .dividerCell {
- height: var(--divider-height);
- }
- .dividerRow.show-above .dividerCell,
- .dividerRow.show-above .dividerCell {
- height: 0;
- }
-
- .br:after {
- /* Line feed */
- content: '\\A';
- }
- .tab {
- display: inline-block;
- }
- .tab-indicator:before {
- color: var(--diff-tab-indicator-color);
- /* >> character */
- content: '\\00BB';
- position: absolute;
- }
- .special-char-indicator {
- /* spacing so elements don't collide */
- padding-right: var(--spacing-m);
- }
- .special-char-indicator:before {
- color: var(--diff-tab-indicator-color);
- content: '•';
- position: absolute;
- }
- .special-char-warning {
- /* spacing so elements don't collide */
- padding-right: var(--spacing-m);
- }
- .special-char-warning:before {
- color: var(--warning-foreground);
- content: '!';
- position: absolute;
- }
- /**
- Is defined after other background-colors, such that this
- rule wins in case of same specificity.
- */
- .trailing-whitespace,
- .content .trailing-whitespace,
- .trailing-whitespace .intraline,
- .content .trailing-whitespace .intraline {
- border-radius: var(--border-radius, 4px);
- background-color: var(--diff-trailing-whitespace-indicator);
- }
- #diffHeader {
- background-color: var(--table-header-background-color);
- border-bottom: 1px solid var(--border-color);
- color: var(--link-color);
- padding: var(--spacing-m) 0 var(--spacing-m) 48px;
- }
- #diffTable {
- /* for gr-selection-action-box positioning */
- position: relative;
- }
- #diffTable:focus {
- outline: none;
- }
- #loadingError,
- #sizeWarning {
- display: none;
- margin: var(--spacing-l) auto;
- max-width: 60em;
- text-align: center;
- }
- #loadingError {
- color: var(--error-text-color);
- }
- #sizeWarning gr-button {
- margin: var(--spacing-l);
- }
- #loadingError.showError,
- #sizeWarning.warn {
- display: block;
- }
- .target-row td.blame {
- background: var(--diff-selection-background-color);
- }
- td.lost div {
- background-color: var(--info-background);
- padding: var(--spacing-s) 0 0 0;
- }
- td.lost div:first-of-type {
- font-family: var(--font-family, 'Roboto');
- font-size: var(--font-size-normal, 14px);
- line-height: var(--line-height-normal);
- }
- td.lost gr-icon {
- padding: 0 var(--spacing-s) 0 var(--spacing-m);
- color: var(--blue-700);
- }
-
- col.sign,
- td.sign {
- display: none;
- }
-
- /* Sign column should only be shown in high-contrast mode. */
- :host(.with-sign-col) col.sign {
- display: table-column;
- }
- :host(.with-sign-col) td.sign {
- display: table-cell;
- }
- col.blame {
- display: none;
- }
- td.blame {
- display: none;
- padding: 0 var(--spacing-m);
- white-space: pre;
- }
- :host(.showBlame) col.blame {
- display: table-column;
- }
- :host(.showBlame) td.blame {
- display: table-cell;
- }
- td.blame > span {
- opacity: 0.6;
- }
- td.blame > span.startOfRange {
- opacity: 1;
- }
- td.blame .blameDate {
- font-family: var(--monospace-font-family);
- color: var(--link-color);
- text-decoration: none;
- }
- .responsive td.blame {
- overflow: hidden;
- width: 200px;
- }
- /** Support the line length indicator **/
- .responsive td.content .contentText {
- /**
- Same strategy as in
- https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
- */
- background-image: linear-gradient(
- var(--line-length-indicator-color),
- var(--line-length-indicator-color)
- );
- background-size: 1px 100%;
- background-position: var(--line-limit-marker) 0;
- background-repeat: no-repeat;
- }
- .newlineWarning {
- color: var(--deemphasized-text-color);
- text-align: center;
- }
- .newlineWarning.hidden {
- display: none;
- }
- .lineNum.COVERED .lineNumButton {
- color: var(
- --coverage-covered-line-num-color,
- var(--deemphasized-text-color)
- );
- background-color: var(--coverage-covered, #e0f2f1);
- }
- .lineNum.NOT_COVERED .lineNumButton {
- color: var(
- --coverage-covered-line-num-color,
- var(--deemphasized-text-color)
- );
- background-color: var(--coverage-not-covered, #ffd1a4);
- }
- .lineNum.PARTIALLY_COVERED .lineNumButton {
- color: var(
- --coverage-covered-line-num-color,
- var(--deemphasized-text-color)
- );
- background: linear-gradient(
- to right bottom,
- var(--coverage-not-covered, #ffd1a4) 0%,
- var(--coverage-not-covered, #ffd1a4) 50%,
- var(--coverage-covered, #e0f2f1) 50%,
- var(--coverage-covered, #e0f2f1) 100%
- );
- }
-
- // TODO: Investigate whether this CSS is still necessary.
- /** BEGIN: Select and copy for Polymer 2 */
- /**
- Below was copied and modified from the original css in
- gr-diff-selection.html
- */
- .content,
- .contextControl,
- .blame {
- -webkit-user-select: none;
- -moz-user-select: none;
- -ms-user-select: none;
- user-select: none;
- }
-
- .selected-left:not(.selected-comment)
- .side-by-side
- .left
- + .content
- .contentText,
- .selected-right:not(.selected-comment)
- .side-by-side
- .right
- + .content
- .contentText,
- .selected-left:not(.selected-comment)
- .unified
- .left.lineNum
- ~ .content:not(.both)
- .contentText,
- .selected-right:not(.selected-comment)
- .unified
- .right.lineNum
- ~ .content
- .contentText,
- .selected-left.selected-comment .side-by-side .left + .content .message,
- .selected-right.selected-comment
- .side-by-side
- .right
- + .content
- .message
- :not(.collapsedContent),
- .selected-comment .unified .message :not(.collapsedContent),
- .selected-blame .blame {
- -webkit-user-select: text;
- -moz-user-select: text;
- -ms-user-select: text;
- user-select: text;
- }
-
- /** Make comments and check results selectable when selected */
- .selected-left.selected-comment
- ::slotted(.comment-thread[diff-side='left']),
- .selected-right.selected-comment
- ::slotted(.comment-thread[diff-side='right']) {
- -webkit-user-select: text;
- -moz-user-select: text;
- -ms-user-select: text;
- user-select: text;
- }
- /** END: Select and copy for Polymer 2 */
-
- .whitespace-change-only-message {
- background-color: var(--diff-context-control-background-color);
- border: 1px solid var(--diff-context-control-border-color);
- text-align: center;
- }
-
- .token-highlight {
- background-color: var(--token-highlighting-color, #fffd54);
- }
-
- gr-selection-action-box {
- /**
- * Needs z-index to appear above wrapped content, since it's inserted
- * into DOM before it.
- */
- z-index: 10;
- }
- `,
+ grDiffStyles,
];
}
constructor() {
super();
- this.addEventListener('create-range-comment', (e: Event) =>
- this.handleCreateRangeComment(e as CustomEvent)
+ provide(this, diffModelToken, () => this.diffModel);
+ this.addEventListener(
+ 'create-range-comment',
+ (e: CustomEvent<CreateRangeCommentEventDetail>) =>
+ this.handleCreateRangeComment(e)
);
this.addEventListener('render-content', () => this.handleRenderContent());
this.addEventListener('moved-link-clicked', (e: MovedLinkClickedEvent) => {
@@ -986,6 +302,13 @@ export class GrDiff extends LitElement implements GrDiffApi {
if (this.loggedIn) {
this.addSelectionListeners();
}
+ if (this.diff && this.diffTable) {
+ this.diffSelection.init(this.diff, this.diffTable);
+ }
+ if (this.diffTable && this.diffBuilder) {
+ this.highlights.init(this.diffTable, this.diffBuilder);
+ }
+ this.diffBuilder.init();
}
override disconnectedCallback() {
@@ -993,7 +316,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
this.renderDiffTableTask?.cancel();
this.diffSelection.cleanup();
this.highlights.cleanup();
- this.diffBuilder.cancel();
+ this.diffBuilder.cleanup();
super.disconnectedCallback();
}
@@ -1022,9 +345,6 @@ export class GrDiff extends LitElement implements GrDiffApi {
}
if (changedProperties.has('coverageRanges')) {
this.diffBuilder.updateCoverageRanges(this.coverageRanges);
- if (this.diff) {
- this.debounceRenderDiffTable();
- }
}
if (changedProperties.has('lineOfInterest')) {
this.lineOfInterestChanged();
@@ -1061,9 +381,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
diffContainer: true,
unified: this.viewMode === DiffViewMode.UNIFIED,
sideBySide: this.viewMode === DiffViewMode.SIDE_BY_SIDE,
- hiddenscroll: !!getHiddenScroll(),
canComment: this.loggedIn,
- displayLine: this.displayLine,
};
return html`
<div class=${classMap(cssClasses)} @click=${this.handleTap}>
@@ -1087,24 +405,20 @@ export class GrDiff extends LitElement implements GrDiffApi {
private renderNewlineWarning() {
const newlineWarning = this.computeNewlineWarning();
- const newlineWarningClass = this.computeNewlineWarningClass(
- !!newlineWarning
- );
- return html` <div class=${newlineWarningClass}>${newlineWarning}</div> `;
+ if (!newlineWarning) return nothing;
+ return html`<div class="newlineWarning">${newlineWarning}</div>`;
}
private renderLoadingError() {
- return html`
- <div id="loadingError" class=${this.errorMessage ? 'showError' : ''}>
- ${this.errorMessage}
- </div>
- `;
+ if (!this.errorMessage) return nothing;
+ return html`<div id="loadingError">${this.errorMessage}</div>`;
}
private renderSizeWarning() {
+ if (!this.showWarning) return nothing;
// TODO: Update comment about 'Whole file' as it's not in settings.
return html`
- <div id="sizeWarning" class=${this.showWarning ? 'warn' : ''}>
+ <div id="sizeWarning">
<p>
Prevented render because "Whole file" is enabled and this diff is very
large (about ${this.diffLength} lines).
@@ -1253,16 +567,16 @@ export class GrDiff extends LitElement implements GrDiffApi {
threadEl: GrDiffThreadElement
) {
hoverEl.addEventListener('mouseenter', () => {
- fireEvent(threadEl, 'comment-thread-mouseenter');
+ fire(threadEl, 'comment-thread-mouseenter', {});
});
hoverEl.addEventListener('mouseleave', () => {
- fireEvent(threadEl, 'comment-thread-mouseleave');
+ fire(threadEl, 'comment-thread-mouseleave', {});
});
}
/** Cancel any remaining diff builder rendering work. */
cancel() {
- this.diffBuilder.cancel();
+ this.diffBuilder.cleanup();
this.renderDiffTableTask?.cancel();
}
@@ -1328,17 +642,11 @@ export class GrDiff extends LitElement implements GrDiffApi {
}
private dispatchSelectedLine(number: LineNumber, side: Side) {
- this.dispatchEvent(
- new CustomEvent('line-selected', {
- detail: {
- number,
- side,
- path: this.path,
- },
- composed: true,
- bubbles: true,
- })
- );
+ fire(this, 'line-selected', {
+ number,
+ side,
+ path: this.path,
+ });
}
addDraftAtLine(el: Element) {
@@ -1371,7 +679,9 @@ export class GrDiff extends LitElement implements GrDiffApi {
}
}
- private handleCreateRangeComment(e: CustomEvent) {
+ private handleCreateRangeComment(
+ e: CustomEvent<CreateRangeCommentEventDetail>
+ ) {
const range = e.detail.range;
const side = e.detail.side;
this.createCommentForSelection(side, range);
@@ -1388,35 +698,12 @@ export class GrDiff extends LitElement implements GrDiffApi {
if (!contentEl) throw new Error('content el not found for line el');
side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
assertIsDefined(this.path, 'path');
- this.dispatchEvent(
- new CustomEvent<CreateCommentEventDetail>('create-comment', {
- bubbles: true,
- composed: true,
- detail: {
- path: this.path,
- side,
- lineNum,
- range,
- },
- })
- );
- }
-
- /**
- * Gets or creates a comment thread group for a specific line and side on a
- * diff.
- * Private but used in tests.
- */
- getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
- // Check if thread group exists.
- let threadGroupEl = contentEl.querySelector('.thread-group');
- if (!threadGroupEl) {
- threadGroupEl = document.createElement('div');
- threadGroupEl.className = 'thread-group';
- threadGroupEl.setAttribute('data-side', commentSide);
- contentEl.appendChild(threadGroupEl);
- }
- return threadGroupEl;
+ fire(this, 'create-comment', {
+ path: this.path,
+ side,
+ lineNum,
+ range,
+ });
}
private getCommentSideByLineAndContent(
@@ -1447,6 +734,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
private prefsChanged() {
if (!this.prefs) return;
+ this.diffModel.updateState({diffPrefs: this.prefs});
this.blame = null;
this.updatePreferenceStyles();
@@ -1517,7 +805,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
}
private renderPrefsChanged() {
- if (!this.renderPrefs) return;
+ this.diffModel.updateState({renderPrefs: this.renderPrefs});
if (this.renderPrefs.hide_left_side) {
this.classList.add('no-left');
}
@@ -1567,10 +855,10 @@ export class GrDiff extends LitElement implements GrDiffApi {
// (client), although it was not actually rendered. Clients need to know
// when it is safe to perform operations like cursor moves, for example,
// and if changing an input actually requires a reload of the diff table.
- // Since `fireEvent` is synchronous it allows clients to be aware when an
+ // Since `fire` is synchronous it allows clients to be aware when an
// async render is needed and that they can wait for a further `render`
// event to actually take further action.
- fireEvent(this, 'render-required');
+ fire(this, 'render-required', {});
this.renderDiffTableTask = debounceP(
this.renderDiffTableTask,
async () => await this.renderDiffTable()
@@ -1584,8 +872,8 @@ export class GrDiff extends LitElement implements GrDiffApi {
// Private but used in tests.
async renderDiffTable() {
this.unobserveNodes();
- if (!this.prefs) {
- fireEvent(this, 'render');
+ if (!this.diff || !this.prefs) {
+ fire(this, 'render', {});
return;
}
if (
@@ -1595,7 +883,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
this.safetyBypass === null
) {
this.showWarning = true;
- fireEvent(this, 'render');
+ fire(this, 'render', {});
return;
}
@@ -1603,8 +891,15 @@ export class GrDiff extends LitElement implements GrDiffApi {
const keyLocations = this.computeKeyLocations();
+ this.diffModel.setState({
+ diff: this.diff,
+ path: this.path,
+ renderPrefs: this.renderPrefs,
+ diffPrefs: this.prefs,
+ });
+
// TODO: Setting tons of public properties like this is obviously a code
- // smell. We are planning to introduce a diff model for managing all this
+ // smell. We are introducing a diff model for managing all this
// data. Then diff builder will only need access to that model.
this.diffBuilder.prefs = this.getBypassPrefs();
this.diffBuilder.renderPrefs = this.renderPrefs;
@@ -1632,7 +927,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
this.observeNodes();
// We are just converting 'render-content' into 'render' here. Maybe we
// should retire the 'render' event in favor of 'render-content'?
- fireEvent(this, 'render');
+ fire(this, 'render', {});
}
private observeNodes() {
@@ -1685,10 +980,9 @@ export class GrDiff extends LitElement implements GrDiffApi {
}
const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
if (!contentEl) continue;
- if (lineNum === 'LOST' && !contentEl.hasChildNodes()) {
- contentEl.appendChild(this.portedCommentsWithoutRangeMessage());
+ if (lineNum === 'LOST') {
+ this.insertPortedCommentsWithoutRangeMessage(contentEl);
}
- const threadGroupEl = this.getOrCreateThreadGroup(contentEl, commentSide);
const slotAtt = threadEl.getAttribute('slot');
if (range && isLongCommentRange(range) && slotAtt) {
@@ -1701,16 +995,6 @@ export class GrDiff extends LitElement implements GrDiffApi {
this.insertBefore(longRangeCommentHint, threadEl);
this.redispatchHoverEvents(longRangeCommentHint, threadEl);
}
-
- // Create a slot for the thread and attach it to the thread group.
- // The Polyfill has some bugs and this only works if the slot is
- // attached to the group after the group is attached to the DOM.
- // The thread group may already have a slot with the right name, but
- // that is okay because the first matching slot is used and the rest
- // are ignored.
- const slot = document.createElement('slot');
- if (slotAtt) slot.name = slotAtt;
- threadGroupEl.appendChild(slot);
}
for (const threadEl of removedThreadEls) {
@@ -1733,15 +1017,19 @@ export class GrDiff extends LitElement implements GrDiffApi {
this.commentRanges = [];
}
- private portedCommentsWithoutRangeMessage() {
+ private insertPortedCommentsWithoutRangeMessage(lostCell: Element) {
+ const existingMessage = lostCell.querySelector('div.lost-message');
+ if (existingMessage) return;
+
const div = document.createElement('div');
+ div.className = 'lost-message';
const icon = document.createElement('gr-icon');
icon.setAttribute('icon', 'info');
div.appendChild(icon);
const span = document.createElement('span');
span.innerText = 'Original comment position not found in this patchset';
div.appendChild(span);
- return div;
+ lostCell.insertBefore(div, lostCell.firstChild);
}
/**
@@ -1765,19 +1053,18 @@ export class GrDiff extends LitElement implements GrDiffApi {
// Private but used in tests.
computeDiffHeaderItems() {
- if (!this.diff || !this.diff.diff_header) {
- return [];
- }
- return this.diff.diff_header.filter(
- item =>
- !(
- item.startsWith('diff --git ') ||
- item.startsWith('index ') ||
- item.startsWith('+++ ') ||
- item.startsWith('--- ') ||
- item === 'Binary files differ'
- )
- );
+ return (this.diff?.diff_header ?? [])
+ .filter(
+ item =>
+ !(
+ item.startsWith('diff --git ') ||
+ item.startsWith('index ') ||
+ item.startsWith('+++ ') ||
+ item.startsWith('--- ') ||
+ item === 'Binary files differ'
+ )
+ )
+ .map(expandFileMode);
}
private handleFullBypass() {
@@ -1805,7 +1092,7 @@ export class GrDiff extends LitElement implements GrDiffApi {
}
}
- private computeNewlineWarning() {
+ private computeNewlineWarning(): string | undefined {
const messages = [];
if (this.showNewlineWarningLeft) {
messages.push(NO_NEWLINE_LEFT);
@@ -1814,18 +1101,10 @@ export class GrDiff extends LitElement implements GrDiffApi {
messages.push(NO_NEWLINE_RIGHT);
}
if (!messages.length) {
- return null;
+ return undefined;
}
return messages.join(' \u2014 '); // \u2014 - '—'
}
-
- // Private but used in tests.
- computeNewlineWarningClass(warning: boolean) {
- if (this.loading || !warning) {
- return 'newlineWarning hidden';
- }
- return 'newlineWarning';
- }
}
function extractAddedNodes(mutations: MutationRecord[]) {
@@ -1841,6 +1120,9 @@ declare global {
'gr-diff': GrDiff;
}
interface HTMLElementEventMap {
+ 'comment-thread-mouseenter': CustomEvent<{}>;
+ 'comment-thread-mouseleave': CustomEvent<{}>;
'loading-changed': ValueChangedEvent<boolean>;
+ 'render-required': CustomEvent<{}>;
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index 59bfc8dc82..f0826ad925 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -6,9 +6,7 @@
import '../../../test/common-test-setup';
import {createDiff} from '../../../test/test-data-generators';
import './gr-diff';
-import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
import {getComputedStyleValue} from '../../../utils/dom-util';
-import {_setHiddenScroll} from '../../../scripts/hiddenscroll';
import '@polymer/paper-button/paper-button';
import {
DiffContent,
@@ -57,6 +55,2943 @@ suite('gr-diff tests', () => {
element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
});
+ suite('rendering', () => {
+ test('empty diff', async () => {
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer sideBySide">
+ <table id="diffTable"></table>
+ </div>
+ `
+ );
+ });
+
+ test('a unified diff lit', async () => {
+ element.viewMode = DiffViewMode.UNIFIED;
+ element.prefs = {...MINIMAL_PREFS};
+ element.diff = createDiff();
+ await element.updateComplete;
+ await waitForEventOnce(element, 'render');
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer unified">
+ <table class="selected-right" id="diffTable">
+ <colgroup>
+ <col class="blame gr-diff" />
+ <col class="gr-diff" width="48" />
+ <col class="gr-diff" width="48" />
+ <col class="gr-diff" />
+ </colgroup>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-LOST right-button-LOST right-content-LOST"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="LOST"></td>
+ <td class="gr-diff left lineNum" data-value="LOST"></td>
+ <td class="gr-diff lineNum right" data-value="LOST"></td>
+ <td class="both content gr-diff lost no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-FILE right-button-FILE right-content-FILE"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="FILE"></td>
+ <td class="gr-diff left lineNum" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff left lineNumButton"
+ data-value="FILE"
+ id="left-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff lineNumButton right"
+ data-value="FILE"
+ id="right-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="both content file gr-diff no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-1 right-button-1 right-content-1"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-2 right-button-2 right-content-2"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="2"></td>
+ <td class="gr-diff left lineNum" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="2"
+ id="left-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="2"
+ id="right-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-2"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-3 right-button-3 right-content-3"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="3"></td>
+ <td class="gr-diff left lineNum" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="3"
+ id="left-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="3"
+ id="right-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-3"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-4 right-button-4 right-content-4"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="4"></td>
+ <td class="gr-diff left lineNum" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="4"
+ id="left-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="4"
+ id="right-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-4"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-5 right-content-5"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="5">
+ <button
+ aria-label="5 added"
+ class="gr-diff lineNumButton right"
+ data-value="5"
+ id="right-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-5"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-6 right-content-6"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="6">
+ <button
+ aria-label="6 added"
+ class="gr-diff lineNumButton right"
+ data-value="6"
+ id="right-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-6"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-7 right-content-7"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="7">
+ <button
+ aria-label="7 added"
+ class="gr-diff lineNumButton right"
+ data-value="7"
+ id="right-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-7"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-5 right-button-8 right-content-8"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="5"></td>
+ <td class="gr-diff left lineNum" data-value="5">
+ <button
+ aria-label="5 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="5"
+ id="left-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="8"
+ id="right-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-8"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-6 right-button-9 right-content-9"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="6"></td>
+ <td class="gr-diff left lineNum" data-value="6">
+ <button
+ aria-label="6 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="6"
+ id="left-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="9"
+ id="right-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-9"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-7 right-button-10 right-content-10"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="7"></td>
+ <td class="gr-diff left lineNum" data-value="7">
+ <button
+ aria-label="7 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="7"
+ id="left-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="10">
+ <button
+ aria-label="10 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="10"
+ id="right-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-10"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-8 right-button-11 right-content-11"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="8"></td>
+ <td class="gr-diff left lineNum" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="8"
+ id="left-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="11">
+ <button
+ aria-label="11 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="11"
+ id="right-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-11"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-9 right-button-12 right-content-12"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="9"></td>
+ <td class="gr-diff left lineNum" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="9"
+ id="left-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="12">
+ <button
+ aria-label="12 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="12"
+ id="right-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-12"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="left-button-10 left-content-10"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="10"></td>
+ <td class="gr-diff left lineNum" data-value="10">
+ <button
+ aria-label="10 removed"
+ class="gr-diff left lineNumButton"
+ data-value="10"
+ id="left-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-10"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-11 left-content-11"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="11"></td>
+ <td class="gr-diff left lineNum" data-value="11">
+ <button
+ aria-label="11 removed"
+ class="gr-diff left lineNumButton"
+ data-value="11"
+ id="left-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-11"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-12 left-content-12"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="12"></td>
+ <td class="gr-diff left lineNum" data-value="12">
+ <button
+ aria-label="12 removed"
+ class="gr-diff left lineNumButton"
+ data-value="12"
+ id="left-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-12"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-13 left-content-13"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="13"></td>
+ <td class="gr-diff left lineNum" data-value="13">
+ <button
+ aria-label="13 removed"
+ class="gr-diff left lineNumButton"
+ data-value="13"
+ id="left-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-13"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+ <tr
+ aria-labelledby="right-button-13 right-content-13"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="13">
+ <button
+ aria-label="13 added"
+ class="gr-diff lineNumButton right"
+ data-value="13"
+ id="right-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-13"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-14 right-content-14"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="14">
+ <button
+ aria-label="14 added"
+ class="gr-diff lineNumButton right"
+ data-value="14"
+ id="right-button-14"
+ tabindex="-1"
+ >
+ 14
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-14"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section">
+ <tr
+ aria-labelledby="left-button-16 left-content-16"
+ class="diff-row gr-diff remove unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="16"></td>
+ <td class="gr-diff left lineNum" data-value="16">
+ <button
+ aria-label="16 removed"
+ class="gr-diff left lineNumButton"
+ data-value="16"
+ id="left-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="gr-diff right"></td>
+ <td class="content gr-diff left remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-16"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-15 right-content-15"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="15">
+ <button
+ aria-label="15 added"
+ class="gr-diff lineNumButton right"
+ data-value="15"
+ id="right-button-15"
+ tabindex="-1"
+ >
+ 15
+ </button>
+ </td>
+ <td class="add content gr-diff right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-15"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-17 right-button-16 right-content-16"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="17"></td>
+ <td class="gr-diff left lineNum" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="17"
+ id="left-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="16">
+ <button
+ aria-label="16 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="16"
+ id="right-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-16"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-18 right-button-17 right-content-17"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="18"></td>
+ <td class="gr-diff left lineNum" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="18"
+ id="left-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="17"
+ id="right-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-17"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-19 right-button-18 right-content-18"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="19"></td>
+ <td class="gr-diff left lineNum" data-value="19">
+ <button
+ aria-label="19 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="19"
+ id="left-button-19"
+ tabindex="-1"
+ >
+ 19
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="18"
+ id="right-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-18"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="contextControl gr-diff section">
+ <tr class="above contextBackground gr-diff unified">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ <tr class="dividerRow gr-diff show-both">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="dividerCell gr-diff" colspan="3">
+ <gr-context-controls class="gr-diff" showconfig="both">
+ </gr-context-controls>
+ </td>
+ </tr>
+ <tr class="below contextBackground gr-diff unified">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-38 right-button-37 right-content-37"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="38"></td>
+ <td class="gr-diff left lineNum" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="38"
+ id="left-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="37">
+ <button
+ aria-label="37 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="37"
+ id="right-button-37"
+ tabindex="-1"
+ >
+ 37
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-37"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-39 right-button-38 right-content-38"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="39"></td>
+ <td class="gr-diff left lineNum" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="39"
+ id="left-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="38"
+ id="right-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-38"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-40 right-button-39 right-content-39"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="40"></td>
+ <td class="gr-diff left lineNum" data-value="40">
+ <button
+ aria-label="40 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="40"
+ id="left-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="39"
+ id="right-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-39"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-40 right-content-40"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="40">
+ <button
+ aria-label="40 added"
+ class="gr-diff lineNumButton right"
+ data-value="40"
+ id="right-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-40"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-41 right-content-41"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="41">
+ <button
+ aria-label="41 added"
+ class="gr-diff lineNumButton right"
+ data-value="41"
+ id="right-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-41"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-42 right-content-42"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="42">
+ <button
+ aria-label="42 added"
+ class="gr-diff lineNumButton right"
+ data-value="42"
+ id="right-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-42"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-43 right-content-43"
+ class="add diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff left"></td>
+ <td class="gr-diff lineNum right" data-value="43">
+ <button
+ aria-label="43 added"
+ class="gr-diff lineNumButton right"
+ data-value="43"
+ id="right-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-43"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-41 right-button-44 right-content-44"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="41"></td>
+ <td class="gr-diff left lineNum" data-value="41">
+ <button
+ aria-label="41 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="41"
+ id="left-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="44"
+ id="right-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-44"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-42 right-button-45 right-content-45"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="42"></td>
+ <td class="gr-diff left lineNum" data-value="42">
+ <button
+ aria-label="42 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="42"
+ id="left-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="45"
+ id="right-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-45"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-43 right-button-46 right-content-46"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="43"></td>
+ <td class="gr-diff left lineNum" data-value="43">
+ <button
+ aria-label="43 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="43"
+ id="left-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="46">
+ <button
+ aria-label="46 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="46"
+ id="right-button-46"
+ tabindex="-1"
+ >
+ 46
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-46"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-44 right-button-47 right-content-47"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="44"></td>
+ <td class="gr-diff left lineNum" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="44"
+ id="left-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="47">
+ <button
+ aria-label="47 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="47"
+ id="right-button-47"
+ tabindex="-1"
+ >
+ 47
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-47"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-45 right-button-48 right-content-48"
+ class="both diff-row gr-diff unified"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="45"></td>
+ <td class="gr-diff left lineNum" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="45"
+ id="left-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="gr-diff lineNum right" data-value="48">
+ <button
+ aria-label="48 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="48"
+ id="right-button-48"
+ tabindex="-1"
+ >
+ 48
+ </button>
+ </td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-48"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ `,
+ {
+ ignoreTags: [
+ 'gr-context-controls-section',
+ 'gr-diff-section',
+ 'gr-diff-row',
+ 'gr-diff-text',
+ 'gr-legacy-text',
+ 'slot',
+ ],
+ }
+ );
+ });
+
+ test('a normal diff lit', async () => {
+ element.prefs = {...MINIMAL_PREFS};
+ element.diff = createDiff();
+ await element.updateComplete;
+ await waitForEventOnce(element, 'render');
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer sideBySide">
+ <table class="selected-right" id="diffTable">
+ <colgroup>
+ <col class="blame gr-diff" />
+ <col class="gr-diff left" width="48" />
+ <col class="gr-diff left sign" />
+ <col class="gr-diff left" />
+ <col class="gr-diff right" width="48" />
+ <col class="gr-diff right sign" />
+ <col class="gr-diff right" />
+ </colgroup>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-LOST left-content-LOST right-button-LOST right-content-LOST"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="LOST"></td>
+ <td class="gr-diff left lineNum" data-value="LOST"></td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left lost no-intraline-info">
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="LOST"></td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff lost no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="FILE"></td>
+ <td class="gr-diff left lineNum" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff left lineNumButton"
+ data-value="FILE"
+ id="left-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content file gr-diff left no-intraline-info">
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff lineNumButton right"
+ data-value="FILE"
+ id="right-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content file gr-diff no-intraline-info right">
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-1 left-content-1 right-button-1 right-content-1"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="1"></td>
+ <td class="gr-diff left lineNum" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="1"
+ id="left-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-1"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="1">
+ <button
+ aria-label="1 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="1"
+ id="right-button-1"
+ tabindex="-1"
+ >
+ 1
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-1"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-2 left-content-2 right-button-2 right-content-2"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="2"></td>
+ <td class="gr-diff left lineNum" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="2"
+ id="left-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-2"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="2">
+ <button
+ aria-label="2 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="2"
+ id="right-button-2"
+ tabindex="-1"
+ >
+ 2
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-2"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-3 left-content-3 right-button-3 right-content-3"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="3"></td>
+ <td class="gr-diff left lineNum" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="3"
+ id="left-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-3"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="3">
+ <button
+ aria-label="3 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="3"
+ id="right-button-3"
+ tabindex="-1"
+ >
+ 3
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-3"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-4 left-content-4 right-button-4 right-content-4"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="4"></td>
+ <td class="gr-diff left lineNum" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="4"
+ id="left-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-4"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="4">
+ <button
+ aria-label="4 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="4"
+ id="right-button-4"
+ tabindex="-1"
+ >
+ 4
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-4"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-5 right-content-5"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="5">
+ <button
+ aria-label="5 added"
+ class="gr-diff lineNumButton right"
+ data-value="5"
+ id="right-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-5"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-6 right-content-6"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="6">
+ <button
+ aria-label="6 added"
+ class="gr-diff lineNumButton right"
+ data-value="6"
+ id="right-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-6"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-7 right-content-7"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="7">
+ <button
+ aria-label="7 added"
+ class="gr-diff lineNumButton right"
+ data-value="7"
+ id="right-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-7"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-5 left-content-5 right-button-8 right-content-8"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="5"></td>
+ <td class="gr-diff left lineNum" data-value="5">
+ <button
+ aria-label="5 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="5"
+ id="left-button-5"
+ tabindex="-1"
+ >
+ 5
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-5"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="8"
+ id="right-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-8"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-6 left-content-6 right-button-9 right-content-9"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="6"></td>
+ <td class="gr-diff left lineNum" data-value="6">
+ <button
+ aria-label="6 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="6"
+ id="left-button-6"
+ tabindex="-1"
+ >
+ 6
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-6"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="9"
+ id="right-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-9"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-7 left-content-7 right-button-10 right-content-10"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="7"></td>
+ <td class="gr-diff left lineNum" data-value="7">
+ <button
+ aria-label="7 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="7"
+ id="left-button-7"
+ tabindex="-1"
+ >
+ 7
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-7"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="10">
+ <button
+ aria-label="10 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="10"
+ id="right-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-10"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-8 left-content-8 right-button-11 right-content-11"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="8"></td>
+ <td class="gr-diff left lineNum" data-value="8">
+ <button
+ aria-label="8 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="8"
+ id="left-button-8"
+ tabindex="-1"
+ >
+ 8
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-8"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="11">
+ <button
+ aria-label="11 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="11"
+ id="right-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-11"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-9 left-content-9 right-button-12 right-content-12"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="9"></td>
+ <td class="gr-diff left lineNum" data-value="9">
+ <button
+ aria-label="9 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="9"
+ id="left-button-9"
+ tabindex="-1"
+ >
+ 9
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-9"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="12">
+ <button
+ aria-label="12 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="12"
+ id="right-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-12"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="left-button-10 left-content-10"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="10"></td>
+ <td class="gr-diff left lineNum" data-value="10">
+ <button
+ aria-label="10 removed"
+ class="gr-diff left lineNumButton"
+ data-value="10"
+ id="left-button-10"
+ tabindex="-1"
+ >
+ 10
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-10"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-11 left-content-11"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="11"></td>
+ <td class="gr-diff left lineNum" data-value="11">
+ <button
+ aria-label="11 removed"
+ class="gr-diff left lineNumButton"
+ data-value="11"
+ id="left-button-11"
+ tabindex="-1"
+ >
+ 11
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-11"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-12 left-content-12"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="12"></td>
+ <td class="gr-diff left lineNum" data-value="12">
+ <button
+ aria-label="12 removed"
+ class="gr-diff left lineNumButton"
+ data-value="12"
+ id="left-button-12"
+ tabindex="-1"
+ >
+ 12
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-12"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-13 left-content-13"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="blank"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="13"></td>
+ <td class="gr-diff left lineNum" data-value="13">
+ <button
+ aria-label="13 removed"
+ class="gr-diff left lineNumButton"
+ data-value="13"
+ id="left-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-13"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="blankLineNum gr-diff right"></td>
+ <td class="blank gr-diff no-intraline-info right sign"></td>
+ <td class="blank gr-diff no-intraline-info right">
+ <div class="contentText gr-diff" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff ignoredWhitespaceOnly section">
+ <tr
+ aria-labelledby="left-button-14 left-content-14 right-button-13 right-content-13"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="14"></td>
+ <td class="gr-diff left lineNum" data-value="14">
+ <button
+ aria-label="14 removed"
+ class="gr-diff left lineNumButton"
+ data-value="14"
+ id="left-button-14"
+ tabindex="-1"
+ >
+ 14
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-14"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="13">
+ <button
+ aria-label="13 added"
+ class="gr-diff lineNumButton right"
+ data-value="13"
+ id="right-button-13"
+ tabindex="-1"
+ >
+ 13
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-13"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-15 left-content-15 right-button-14 right-content-14"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="15"></td>
+ <td class="gr-diff left lineNum" data-value="15">
+ <button
+ aria-label="15 removed"
+ class="gr-diff left lineNumButton"
+ data-value="15"
+ id="left-button-15"
+ tabindex="-1"
+ >
+ 15
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info remove sign">-</td>
+ <td class="content gr-diff left no-intraline-info remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-15"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="14">
+ <button
+ aria-label="14 added"
+ class="gr-diff lineNumButton right"
+ data-value="14"
+ id="right-button-14"
+ tabindex="-1"
+ >
+ 14
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-14"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section">
+ <tr
+ aria-labelledby="left-button-16 left-content-16 right-button-15 right-content-15"
+ class="diff-row gr-diff side-by-side"
+ left-type="remove"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="16"></td>
+ <td class="gr-diff left lineNum" data-value="16">
+ <button
+ aria-label="16 removed"
+ class="gr-diff left lineNumButton"
+ data-value="16"
+ id="left-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="gr-diff left remove sign">-</td>
+ <td class="content gr-diff left remove">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-16"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="15">
+ <button
+ aria-label="15 added"
+ class="gr-diff lineNumButton right"
+ data-value="15"
+ id="right-button-15"
+ tabindex="-1"
+ >
+ 15
+ </button>
+ </td>
+ <td class="add gr-diff right sign">+</td>
+ <td class="add content gr-diff right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-15"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-17 left-content-17 right-button-16 right-content-16"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="17"></td>
+ <td class="gr-diff left lineNum" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="17"
+ id="left-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-17"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="16">
+ <button
+ aria-label="16 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="16"
+ id="right-button-16"
+ tabindex="-1"
+ >
+ 16
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-16"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-18 left-content-18 right-button-17 right-content-17"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="18"></td>
+ <td class="gr-diff left lineNum" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="18"
+ id="left-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-18"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="17">
+ <button
+ aria-label="17 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="17"
+ id="right-button-17"
+ tabindex="-1"
+ >
+ 17
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-17"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-19 left-content-19 right-button-18 right-content-18"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="19"></td>
+ <td class="gr-diff left lineNum" data-value="19">
+ <button
+ aria-label="19 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="19"
+ id="left-button-19"
+ tabindex="-1"
+ >
+ 19
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-19"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="18">
+ <button
+ aria-label="18 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="18"
+ id="right-button-18"
+ tabindex="-1"
+ >
+ 18
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-18"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="contextControl gr-diff section">
+ <tr
+ class="above contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ <tr class="dividerRow gr-diff show-both">
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="gr-diff"></td>
+ <td class="dividerCell gr-diff" colspan="3">
+ <gr-context-controls
+ class="gr-diff"
+ showconfig="both"
+ ></gr-context-controls>
+ </td>
+ </tr>
+ <tr
+ class="below contextBackground gr-diff side-by-side"
+ left-type="contextControl"
+ right-type="contextControl"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ <td class="contextLineNum gr-diff"></td>
+ <td class="gr-diff sign"></td>
+ <td class="gr-diff"></td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-38 left-content-38 right-button-37 right-content-37"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="38"></td>
+ <td class="gr-diff left lineNum" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="38"
+ id="left-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-38"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="37">
+ <button
+ aria-label="37 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="37"
+ id="right-button-37"
+ tabindex="-1"
+ >
+ 37
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-37"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-39 left-content-39 right-button-38 right-content-38"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="39"></td>
+ <td class="gr-diff left lineNum" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="39"
+ id="left-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-39"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="38">
+ <button
+ aria-label="38 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="38"
+ id="right-button-38"
+ tabindex="-1"
+ >
+ 38
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-38"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-40 left-content-40 right-button-39 right-content-39"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="40"></td>
+ <td class="gr-diff left lineNum" data-value="40">
+ <button
+ aria-label="40 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="40"
+ id="left-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-40"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="39">
+ <button
+ aria-label="39 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="39"
+ id="right-button-39"
+ tabindex="-1"
+ >
+ 39
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-39"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="delta gr-diff section total">
+ <tr
+ aria-labelledby="right-button-40 right-content-40"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="40">
+ <button
+ aria-label="40 added"
+ class="gr-diff lineNumButton right"
+ data-value="40"
+ id="right-button-40"
+ tabindex="-1"
+ >
+ 40
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-40"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-41 right-content-41"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="41">
+ <button
+ aria-label="41 added"
+ class="gr-diff lineNumButton right"
+ data-value="41"
+ id="right-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-41"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-42 right-content-42"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="42">
+ <button
+ aria-label="42 added"
+ class="gr-diff lineNumButton right"
+ data-value="42"
+ id="right-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-42"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="right-button-43 right-content-43"
+ class="diff-row gr-diff side-by-side"
+ left-type="blank"
+ right-type="add"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="0"></td>
+ <td class="blankLineNum gr-diff left"></td>
+ <td class="blank gr-diff left no-intraline-info sign"></td>
+ <td class="blank gr-diff left no-intraline-info">
+ <div class="contentText gr-diff" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="43">
+ <button
+ aria-label="43 added"
+ class="gr-diff lineNumButton right"
+ data-value="43"
+ id="right-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="add gr-diff no-intraline-info right sign">+</td>
+ <td class="add content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-43"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-41 left-content-41 right-button-44 right-content-44"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="41"></td>
+ <td class="gr-diff left lineNum" data-value="41">
+ <button
+ aria-label="41 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="41"
+ id="left-button-41"
+ tabindex="-1"
+ >
+ 41
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-41"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="44"
+ id="right-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-44"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-42 left-content-42 right-button-45 right-content-45"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="42"></td>
+ <td class="gr-diff left lineNum" data-value="42">
+ <button
+ aria-label="42 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="42"
+ id="left-button-42"
+ tabindex="-1"
+ >
+ 42
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-42"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="45"
+ id="right-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-45"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-43 left-content-43 right-button-46 right-content-46"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="43"></td>
+ <td class="gr-diff left lineNum" data-value="43">
+ <button
+ aria-label="43 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="43"
+ id="left-button-43"
+ tabindex="-1"
+ >
+ 43
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-43"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="46">
+ <button
+ aria-label="46 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="46"
+ id="right-button-46"
+ tabindex="-1"
+ >
+ 46
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-46"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-44 left-content-44 right-button-47 right-content-47"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="44"></td>
+ <td class="gr-diff left lineNum" data-value="44">
+ <button
+ aria-label="44 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="44"
+ id="left-button-44"
+ tabindex="-1"
+ >
+ 44
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-44"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="47">
+ <button
+ aria-label="47 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="47"
+ id="right-button-47"
+ tabindex="-1"
+ >
+ 47
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-47"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ <tr
+ aria-labelledby="left-button-45 left-content-45 right-button-48 right-content-48"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="45"></td>
+ <td class="gr-diff left lineNum" data-value="45">
+ <button
+ aria-label="45 unmodified"
+ class="gr-diff left lineNumButton"
+ data-value="45"
+ id="left-button-45"
+ tabindex="-1"
+ >
+ 45
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td class="both content gr-diff left no-intraline-info">
+ <div
+ class="contentText gr-diff"
+ data-side="left"
+ id="left-content-45"
+ ></div>
+ <div class="thread-group" data-side="left"></div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="48">
+ <button
+ aria-label="48 unmodified"
+ class="gr-diff lineNumButton right"
+ data-value="48"
+ id="right-button-48"
+ tabindex="-1"
+ >
+ 48
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td class="both content gr-diff no-intraline-info right">
+ <div
+ class="contentText gr-diff"
+ data-side="right"
+ id="right-content-48"
+ ></div>
+ <div class="thread-group" data-side="right"></div>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ `,
+ {
+ ignoreTags: [
+ 'gr-context-controls-section',
+ 'gr-diff-section',
+ 'gr-diff-row',
+ 'gr-diff-text',
+ 'gr-legacy-text',
+ 'slot',
+ ],
+ }
+ );
+ });
+ });
+
suite('selectionchange event handling', () => {
let handleSelectionChangeStub: sinon.SinonSpy;
@@ -87,9 +3022,9 @@ suite('gr-diff tests', () => {
});
test('cancel', () => {
- const cancelStub = sinon.stub(element.diffBuilder, 'cancel');
+ const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
element.cancel();
- assert.isTrue(cancelStub.calledOnce);
+ assert.isTrue(cleanupStub.calledOnce);
});
test('line limit with line_wrapping', async () => {
@@ -187,35 +3122,100 @@ suite('gr-diff tests', () => {
assert.isFalse(element.classList.contains('no-left'));
});
- test('view does not start with displayLine classList', () => {
- const container = queryAndAssert(element, '.diffContainer');
- assert.isFalse(container.classList.contains('displayLine'));
- });
-
- test('displayLine class added when displayLine is true', async () => {
- element.displayLine = true;
- await element.updateComplete;
- const container = queryAndAssert(element, '.diffContainer');
- assert.isTrue(container.classList.contains('displayLine'));
- });
-
- test('thread groups', () => {
- const contentEl = document.createElement('div');
-
- element.path = 'file.txt';
-
- // No thread groups.
- assert.equal(contentEl.querySelectorAll('.thread-group').length, 0);
-
- // A thread group gets created.
- const threadGroupEl = element.getOrCreateThreadGroup(
- contentEl,
- Side.LEFT
- );
- assert.isOk(threadGroupEl);
+ suite('binary diffs', () => {
+ test('render binary diff', async () => {
+ element.prefs = {
+ ...MINIMAL_PREFS,
+ };
+ element.diff = {
+ meta_a: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+ meta_b: {name: 'carrot.exe', content_type: 'binary', lines: 0},
+ change_type: 'MODIFIED',
+ intraline_status: 'OK',
+ diff_header: [],
+ content: [],
+ binary: true,
+ };
+ await waitForEventOnce(element, 'render');
- // The new thread group can be fetched.
- assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="diffContainer sideBySide">
+ <gr-diff-section class="left-FILE right-FILE"> </gr-diff-section>
+ <gr-diff-row class="left-FILE right-FILE"> </gr-diff-row>
+ <table class="selected-right" id="diffTable">
+ <colgroup>
+ <col class="blame gr-diff" />
+ <col class="gr-diff left" width="48" />
+ <col class="gr-diff left sign" />
+ <col class="gr-diff left" />
+ <col class="gr-diff right" width="48" />
+ <col class="gr-diff right sign" />
+ <col class="gr-diff right" />
+ </colgroup>
+ <tbody class="binary-diff gr-diff"></tbody>
+ <tbody class="both gr-diff section">
+ <tr
+ aria-labelledby="left-button-FILE left-content-FILE right-button-FILE right-content-FILE"
+ class="diff-row gr-diff side-by-side"
+ left-type="both"
+ right-type="both"
+ tabindex="-1"
+ >
+ <td class="blame gr-diff" data-line-number="FILE"></td>
+ <td class="gr-diff left lineNum" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff left lineNumButton"
+ data-value="FILE"
+ id="left-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff left no-intraline-info sign"></td>
+ <td
+ class="both content file gr-diff left no-intraline-info"
+ >
+ <div class="thread-group" data-side="left">
+ <slot name="left-FILE"> </slot>
+ </div>
+ </td>
+ <td class="gr-diff lineNum right" data-value="FILE">
+ <button
+ aria-label="Add file comment"
+ class="gr-diff lineNumButton right"
+ data-value="FILE"
+ id="right-button-FILE"
+ tabindex="-1"
+ >
+ File
+ </button>
+ </td>
+ <td class="gr-diff no-intraline-info right sign"></td>
+ <td
+ class="both content file gr-diff no-intraline-info right"
+ >
+ <div class="thread-group" data-side="right">
+ <slot name="right-FILE"> </slot>
+ </div>
+ </td>
+ </tr>
+ </tbody>
+ <tbody class="binary-diff gr-diff">
+ <tr class="gr-diff">
+ <td class="gr-diff" colspan="5">
+ <span> Difference in binary files </span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ `
+ );
+ });
});
suite('image diffs', () => {
@@ -251,7 +3251,7 @@ suite('gr-diff tests', () => {
};
});
- test('renders image diffs with same file name', async () => {
+ test('render image diff', async () => {
element.baseImage = mockFile1;
element.revisionImage = mockFile2;
element.diff = {
@@ -269,39 +3269,62 @@ suite('gr-diff tests', () => {
content: [{skip: 66}],
binary: true,
};
- await waitForEventOnce(element, 'render');
-
- // Recognizes that it should be an image diff.
- assert.isTrue(element.isImageDiff);
- assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
- // Left image rendered with the parent commit's version of the file.
- assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- const leftImage = queryAndAssert(diffTable, 'td.left img');
- const leftLabel = queryAndAssert(diffTable, 'td.left label');
- const leftLabelContent = queryAndAssert(leftLabel, '.label');
- const leftLabelName = query(leftLabel, '.name');
-
- const rightImage = queryAndAssert(diffTable, 'td.right img');
- const rightLabel = queryAndAssert(diffTable, 'td.right label');
- const rightLabelContent = queryAndAssert(rightLabel, '.label');
- const rightLabelName = query(rightLabel, '.name');
-
- assert.isNotOk(rightLabelName);
- assert.isNotOk(leftLabelName);
-
- assert.equal(
- leftImage.getAttribute('src'),
- 'data:image/bmp;base64,' + mockFile1.body
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ assert.lightDom.equal(
+ imageDiffSection,
+ /* HTML */ `
+ <tbody class="gr-diff image-diff">
+ <tr class="gr-diff">
+ <td class="blank gr-diff left lineNum"></td>
+ <td class="gr-diff left">
+ <img
+ class="gr-diff left"
+ src="data:image/bmp;base64,${mockFile1.body}"
+ />
+ </td>
+ <td class="blank gr-diff lineNum right"></td>
+ <td class="gr-diff right">
+ <img
+ class="gr-diff right"
+ src="data:image/bmp;base64,${mockFile2.body}"
+ />
+ </td>
+ </tr>
+ <tr class="gr-diff">
+ <td class="blank gr-diff left lineNum"></td>
+ <td class="gr-diff left">
+ <label class="gr-diff">
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ </td>
+ <td class="blank gr-diff lineNum right"></td>
+ <td class="gr-diff right">
+ <label class="gr-diff">
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ </td>
+ </tr>
+ </tbody>
+ `
);
- assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
-
- assert.equal(
- rightImage.getAttribute('src'),
- 'data:image/bmp;base64,' + mockFile2.body
+ const endpoint = queryAndAssert(element, 'tbody.endpoint');
+ assert.dom.equal(
+ endpoint,
+ /* HTML */ `
+ <tbody class="gr-diff endpoint">
+ <tr class="gr-diff">
+ <gr-endpoint-decorator class="gr-diff" name="image-diff">
+ <gr-endpoint-param class="gr-diff" name="baseImage">
+ </gr-endpoint-param>
+ <gr-endpoint-param class="gr-diff" name="revisionImage">
+ </gr-endpoint-param>
+ </gr-endpoint-decorator>
+ </tr>
+ </tbody>
+ `
);
- assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
});
test('renders image diffs with a different file name', async () => {
@@ -326,43 +3349,31 @@ suite('gr-diff tests', () => {
element.revisionImage = mockFile2;
element.revisionImage._name = mockDiff.meta_b!.name;
element.diff = mockDiff;
- await waitForEventOnce(element, 'render');
-
- // Recognizes that it should be an image diff.
- assert.isTrue(element.isImageDiff);
- assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
- // Left image rendered with the parent commit's version of the file.
- assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- const leftImage = queryAndAssert(diffTable, 'td.left img');
- const leftLabel = queryAndAssert(diffTable, 'td.left label');
- const leftLabelContent = queryAndAssert(leftLabel, '.label');
- const leftLabelName = queryAndAssert(leftLabel, '.name');
-
- const rightImage = queryAndAssert(diffTable, 'td.right img');
- const rightLabel = queryAndAssert(diffTable, 'td.right label');
- const rightLabelContent = queryAndAssert(rightLabel, '.label');
- const rightLabelName = queryAndAssert(rightLabel, '.name');
-
- assert.isOk(rightLabelName);
- assert.isOk(leftLabelName);
- assert.equal(leftLabelName.textContent, mockDiff.meta_a?.name);
- assert.equal(rightLabelName.textContent, mockDiff.meta_b?.name);
-
- assert.isOk(leftImage);
- assert.equal(
- leftImage.getAttribute('src'),
- 'data:image/bmp;base64,' + mockFile1.body
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftLabel = queryAndAssert(imageDiffSection, 'td.left label');
+ const rightLabel = queryAndAssert(imageDiffSection, 'td.right label');
+ assert.dom.equal(
+ leftLabel,
+ /* HTML */ `
+ <label class="gr-diff">
+ <span class="gr-diff name"> carrot.jpg </span>
+ <br class="gr-diff" />
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ `
);
- assert.isTrue(leftLabelContent.textContent?.includes('image/bmp'));
-
- assert.isOk(rightImage);
- assert.equal(
- rightImage.getAttribute('src'),
- 'data:image/bmp;base64,' + mockFile2.body
+ assert.dom.equal(
+ rightLabel,
+ /* HTML */ `
+ <label class="gr-diff">
+ <span class="gr-diff name"> carrot2.jpg </span>
+ <br class="gr-diff" />
+ <span class="gr-diff label"> image/bmp </span>
+ </label>
+ `
);
- assert.isTrue(rightLabelContent.textContent?.includes('image/bmp'));
});
test('renders added image', async () => {
@@ -380,26 +3391,23 @@ suite('gr-diff tests', () => {
content: [{skip: 66}],
binary: true,
};
-
- const promise = mockPromise();
- function rendered() {
- promise.resolve();
- }
- element.addEventListener('render', rendered);
-
element.revisionImage = mockFile2;
element.diff = mockDiff;
- await promise;
- element.removeEventListener('render', rendered);
- // Recognizes that it should be an image diff.
- assert.isTrue(element.isImageDiff);
- assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
- assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- const leftImage = query(diffTable, 'td.left img');
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftImage = query(imageDiffSection, 'td.left img');
+ const rightImage = queryAndAssert(imageDiffSection, 'td.right img');
assert.isNotOk(leftImage);
- queryAndAssert(diffTable, 'td.right img');
+ assert.dom.equal(
+ rightImage,
+ /* HTML */ `
+ <img
+ class="gr-diff right"
+ src="data:image/bmp;base64,${mockFile2.body}"
+ />
+ `
+ );
});
test('renders removed image', async () => {
@@ -417,25 +3425,23 @@ suite('gr-diff tests', () => {
content: [{skip: 66}],
binary: true,
};
- const promise = mockPromise();
- function rendered() {
- promise.resolve();
- }
- element.addEventListener('render', rendered);
-
element.baseImage = mockFile1;
element.diff = mockDiff;
- await promise;
- element.removeEventListener('render', rendered);
- // Recognizes that it should be an image diff.
- assert.isTrue(element.isImageDiff);
- assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
- assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- queryAndAssert(diffTable, 'td.left img');
- const rightImage = query(diffTable, 'td.right img');
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftImage = queryAndAssert(imageDiffSection, 'td.left img');
+ const rightImage = query(imageDiffSection, 'td.right img');
assert.isNotOk(rightImage);
+ assert.dom.equal(
+ leftImage,
+ /* HTML */ `
+ <img
+ class="gr-diff left"
+ src="data:image/bmp;base64,${mockFile1.body}"
+ />
+ `
+ );
});
test('does not render disallowed image type', async () => {
@@ -458,23 +3464,12 @@ suite('gr-diff tests', () => {
binary: true,
};
mockFile1.type = 'image/jpeg-evil';
-
- const promise = mockPromise();
- function rendered() {
- promise.resolve();
- }
- element.addEventListener('render', rendered);
-
element.baseImage = mockFile1;
element.diff = mockDiff;
- await promise;
- element.removeEventListener('render', rendered);
- // Recognizes that it should be an image diff.
- assert.isTrue(element.isImageDiff);
- assert.instanceOf(element.diffBuilder.builder, GrDiffBuilderImage);
- assertIsDefined(element.diffTable);
- const diffTable = element.diffTable;
- const leftImage = query(diffTable, 'td.left img');
+
+ await waitForEventOnce(element, 'render');
+ const imageDiffSection = queryAndAssert(element, 'tbody.image-diff');
+ const leftImage = query(imageDiffSection, 'td.left img');
assert.isNotOk(leftImage);
});
});
@@ -553,7 +3548,11 @@ suite('gr-diff tests', () => {
await element.updateComplete;
const ROWS = 48;
const FILE_ROW = 1;
- assert.equal(element.getCursorStops().length, ROWS + FILE_ROW);
+ const LOST_ROW = 1;
+ assert.equal(
+ element.getCursorStops().length,
+ ROWS + FILE_ROW + LOST_ROW
+ );
});
test('returns an additional AbortStop when still loading', async () => {
@@ -562,19 +3561,12 @@ suite('gr-diff tests', () => {
await element.updateComplete;
const ROWS = 48;
const FILE_ROW = 1;
+ const LOST_ROW = 1;
const actual = element.getCursorStops();
- assert.equal(actual.length, ROWS + FILE_ROW + 1);
+ assert.equal(actual.length, ROWS + FILE_ROW + LOST_ROW + 1);
assert.isTrue(actual[actual.length - 1] instanceof AbortStop);
});
});
-
- test('adds .hiddenscroll', async () => {
- _setHiddenScroll(true);
- element.displayLine = true;
- await element.updateComplete;
- const container = queryAndAssert(element, '.diffContainer');
- assert.include(container.className, 'hiddenscroll');
- });
});
suite('logged in', async () => {
@@ -820,45 +3812,30 @@ suite('gr-diff tests', () => {
test('large render w/ context = 10', async () => {
element.prefs = {...MINIMAL_PREFS, context: 10};
- const promise = mockPromise();
- function rendered() {
- assert.isTrue(renderStub.called);
- assert.isFalse(element.showWarning);
- promise.resolve();
- element.removeEventListener('render', rendered);
- }
- element.addEventListener('render', rendered);
element.renderDiffTable();
- await promise;
+ await waitForEventOnce(element, 'render');
+
+ assert.isTrue(renderStub.called);
+ assert.isFalse(element.showWarning);
});
test('large render w/ whole file and bypass', async () => {
element.prefs = {...MINIMAL_PREFS, context: -1};
element.safetyBypass = 10;
- const promise = mockPromise();
- function rendered() {
- assert.isTrue(renderStub.called);
- assert.isFalse(element.showWarning);
- promise.resolve();
- element.removeEventListener('render', rendered);
- }
- element.addEventListener('render', rendered);
element.renderDiffTable();
- await promise;
+ await waitForEventOnce(element, 'render');
+
+ assert.isTrue(renderStub.called);
+ assert.isFalse(element.showWarning);
});
test('large render w/ whole file and no bypass', async () => {
element.prefs = {...MINIMAL_PREFS, context: -1};
- const promise = mockPromise();
- function rendered() {
- assert.isFalse(renderStub.called);
- assert.isTrue(element.showWarning);
- promise.resolve();
- element.removeEventListener('render', rendered);
- }
- element.addEventListener('render', rendered);
element.renderDiffTable();
- await promise;
+ await waitForEventOnce(element, 'render');
+
+ assert.isFalse(renderStub.called);
+ assert.isTrue(element.showWarning);
});
test('toggles expand context using bypass', async () => {
@@ -930,8 +3907,8 @@ suite('gr-diff tests', () => {
const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
const getWarning = (element: GrDiff) => {
- const warningElement = queryAndAssert(element, '.newlineWarning');
- return warningElement.textContent;
+ const warningElement = query(element, '.newlineWarning');
+ return warningElement?.textContent ?? '';
};
setup(async () => {
@@ -977,17 +3954,6 @@ suite('gr-diff tests', () => {
assert.notInclude(getWarning(element), NO_NEWLINE_RIGHT);
});
});
-
- test('computeNewlineWarningClass', () => {
- const hidden = 'newlineWarning hidden';
- const shown = 'newlineWarning';
- element.loading = true;
- assert.equal(element.computeNewlineWarningClass(false), hidden);
- assert.equal(element.computeNewlineWarningClass(true), hidden);
- element.loading = false;
- assert.equal(element.computeNewlineWarningClass(false), hidden);
- assert.equal(element.computeNewlineWarningClass(true), shown);
- });
});
suite('key locations', () => {
@@ -995,6 +3961,7 @@ suite('gr-diff tests', () => {
setup(async () => {
element.prefs = {...MINIMAL_PREFS};
+ element.diff = createDiff();
renderStub = sinon.stub(element.diffBuilder, 'render');
await element.updateComplete;
});
@@ -1088,13 +4055,13 @@ suite('gr-diff tests', () => {
b: ['Non eram nescius, Brute, cum, quae summis ingeniis '],
},
];
- function assertDiffTableWithContent() {
+ function diffTableHasContent() {
assertIsDefined(element.diffTable);
const diffTable = element.diffTable;
- assert.isTrue(diffTable.innerText.includes(content[0].a?.[0] ?? ''));
+ return diffTable.innerText.includes(content[0].a?.[0] ?? '');
}
await setupSampleDiff({content});
- assertDiffTableWithContent();
+ await waitUntil(diffTableHasContent);
element.diff = {...element.diff!};
await element.updateComplete;
// immediately cleaned up
@@ -1104,7 +4071,7 @@ suite('gr-diff tests', () => {
element.renderDiffTable();
await element.updateComplete;
// rendered again
- assertDiffTableWithContent();
+ await waitUntil(diffTableHasContent);
});
suite('selection test', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts
new file mode 100644
index 0000000000..b31197d164
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-range-header/gr-range-header_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-range-header';
+import {GrRangeHeader} from './gr-range-header';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-range-header test', () => {
+ let element: GrRangeHeader;
+
+ setup(async () => {
+ element = await fixture<GrRangeHeader>(
+ html`<gr-range-header></gr-range-header>`
+ );
+ await element.updateComplete;
+ });
+
+ test('renders', async () => {
+ element.filled = true;
+ element.icon = 'test-icon';
+ await element.updateComplete;
+ assert.shadowDom.equal(
+ element,
+ /* HTML */ `
+ <div class="row">
+ <gr-icon
+ aria-hidden="true"
+ class="icon"
+ filled
+ icon="test-icon"
+ ></gr-icon>
+ <slot></slot>
+ </div>
+ `
+ );
+ });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
index 58f3f7526e..38eecfaf0f 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -144,12 +144,13 @@ export class GrRangedCommentLayer implements DiffLayer {
side,
range,
operation: (forLine, startChar, endChar) => {
- forLine.push({
- start: startChar,
- end: endChar,
- id: id(commentRange),
- longRange,
- });
+ if (startChar !== endChar)
+ forLine.push({
+ start: startChar,
+ end: endChar,
+ id: id(commentRange),
+ longRange,
+ });
},
});
}
@@ -202,7 +203,7 @@ export class GrRangedCommentLayer implements DiffLayer {
// Normalize invalid ranges where the start is after the end but the
// start still makes sense. Set the end to the end of the line.
// @see Issue 5744
- if (range.start >= range.end && range.start < line.text.length) {
+ if (range.start > range.end && range.start < line.text.length) {
range.end = line.text.length;
}
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
index 33515b25b2..7feda472c4 100644
--- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.ts
@@ -69,6 +69,16 @@ const rangeE: CommentRangeLayer = {
},
};
+const rangeF: CommentRangeLayer = {
+ side: Side.RIGHT,
+ range: {
+ end_character: 0,
+ end_line: 24,
+ start_character: 0,
+ start_line: 23,
+ },
+};
+
suite('gr-ranged-comment-layer', () => {
let element: GrRangedCommentLayer;
@@ -79,6 +89,7 @@ suite('gr-ranged-comment-layer', () => {
rangeC,
rangeD,
rangeE,
+ rangeF,
];
element = new GrRangedCommentLayer();
@@ -219,6 +230,16 @@ suite('gr-ranged-comment-layer', () => {
);
});
+ test('do not annotate lines with end_character 0', () => {
+ line = new GrDiffLine(GrDiffLineType.BOTH);
+ line.afterNumber = 24;
+ el.setAttribute('data-side', Side.RIGHT);
+
+ element.annotate(el, lineNumberEl, line);
+
+ assert.isFalse(annotateElementStub.called);
+ });
+
test('updateRanges remove all', () => {
assertHasRange(rangeA, true);
assertHasRange(rangeB, true);
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
index cb08e55ec7..68aa3b41c4 100644
--- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
+++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -5,7 +5,7 @@
*/
import '../../../elements/shared/gr-tooltip/gr-tooltip';
import {GrTooltip} from '../../../elements/shared/gr-tooltip/gr-tooltip';
-import {fireEvent} from '../../../utils/event-util';
+import {fire} from '../../../utils/event-util';
import {css, html, LitElement} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {sharedStyles} from '../../../styles/shared-styles';
@@ -14,16 +14,14 @@ declare global {
interface HTMLElementTagNameMap {
'gr-selection-action-box': GrSelectionActionBox;
}
+ interface HTMLElementEventMap {
+ /** Fired when the comment creation action was taken (click). */
+ 'create-comment-requested': CustomEvent<{}>;
+ }
}
@customElement('gr-selection-action-box')
export class GrSelectionActionBox extends LitElement {
- /**
- * Fired when the comment creation action was taken (click).
- *
- * @event create-comment-requested
- */
-
@query('#tooltip')
tooltip?: GrTooltip;
@@ -133,6 +131,6 @@ export class GrSelectionActionBox extends LitElement {
} // 0 = main button
e.preventDefault();
e.stopPropagation();
- fireEvent(this, 'create-comment-requested');
+ fire(this, 'create-comment-requested', {});
}
}
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
index a9f88bdd81..da08a1f27f 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker.ts
@@ -8,9 +8,11 @@ import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
import {DiffLayer, DiffLayerListener} from '../../../types/types';
import {Side} from '../../../constants/constants';
-import {getAppContext} from '../../../services/app-context';
import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
-import {CancelablePromise, makeCancelable} from '../../../scripts/util';
+import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
+import {HighlightService} from '../../../services/highlight/highlight-service';
+import {Provider} from '../../../models/dependency';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
const LANGUAGE_MAP = new Map<string, string>([
['application/dart', 'dart'],
@@ -162,9 +164,10 @@ export class GrSyntaxLayerWorker implements DiffLayer {
private listeners: DiffLayerListener[] = [];
- private readonly highlightService = getAppContext().highlightService;
-
- private readonly reportingService = getAppContext().reportingService;
+ constructor(
+ private readonly getHighlightService: Provider<HighlightService>,
+ private readonly getReportingService: Provider<ReportingService>
+ ) {}
setEnabled(enabled: boolean) {
this.enabled = enabled;
@@ -276,7 +279,7 @@ export class GrSyntaxLayerWorker implements DiffLayer {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (!err.isCanceled)
- this.reportingService.error('Diff Syntax Layer', err as Error);
+ this.getReportingService().error('Diff Syntax Layer', err as Error);
// One source of "error" can promise cancelation.
this.leftRanges = [];
this.rightRanges = [];
@@ -287,7 +290,7 @@ export class GrSyntaxLayerWorker implements DiffLayer {
language?: string,
code?: string
): CancelablePromise<SyntaxLayerLine[]> {
- const hlPromise = this.highlightService.highlight(language, code);
+ const hlPromise = this.getHighlightService().highlight(language, code);
return makeCancelable(hlPromise);
}
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
index 5c9a6cc456..c6c46f95d8 100644
--- a/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-syntax-layer/gr-syntax-layer-worker_test.ts
@@ -5,8 +5,14 @@
*/
import {assert} from '@open-wc/testing';
import {DiffInfo, GrDiffLineType, Side} from '../../../api/diff';
+import {getAppContext} from '../../../services/app-context';
+import {
+ HighlightService,
+ highlightServiceToken,
+} from '../../../services/highlight/highlight-service';
import '../../../test/common-test-setup';
-import {mockPromise, stubHighlightService} from '../../../test/test-utils';
+import {testResolver} from '../../../test/common-test-setup';
+import {mockPromise} from '../../../test/test-utils';
import {SyntaxLayerLine} from '../../../types/syntax-worker-api';
import {GrDiffLine} from '../gr-diff/gr-diff-line';
import {GrSyntaxLayerWorker} from './gr-syntax-layer-worker';
@@ -62,6 +68,7 @@ const rightRanges: SyntaxLayerLine[] = [
suite('gr-syntax-layer-worker tests', () => {
let layer: GrSyntaxLayerWorker;
let listener: sinon.SinonStub;
+ let highlightService: HighlightService;
const annotate = (side: Side, lineNumber: number, text: string) => {
const el = document.createElement('div');
@@ -76,7 +83,11 @@ suite('gr-syntax-layer-worker tests', () => {
};
setup(() => {
- layer = new GrSyntaxLayerWorker();
+ highlightService = testResolver(highlightServiceToken);
+ layer = new GrSyntaxLayerWorker(
+ () => highlightService,
+ () => getAppContext().reportingService
+ );
});
test('cancel processing', async () => {
@@ -84,7 +95,7 @@ suite('gr-syntax-layer-worker tests', () => {
const mockPromise2 = mockPromise<SyntaxLayerLine[]>();
const mockPromise3 = mockPromise<SyntaxLayerLine[]>();
const mockPromise4 = mockPromise<SyntaxLayerLine[]>();
- const stub = stubHighlightService('highlight');
+ const stub = sinon.stub(highlightService, 'highlight');
stub.onCall(0).returns(mockPromise1);
stub.onCall(1).returns(mockPromise2);
stub.onCall(2).returns(mockPromise3);
@@ -116,7 +127,7 @@ suite('gr-syntax-layer-worker tests', () => {
setup(() => {
listener = sinon.stub();
layer.addListener(listener);
- stubHighlightService('highlight').callsFake((lang?: string) => {
+ sinon.stub(highlightService, 'highlight').callsFake((lang?: string) => {
if (lang === 'lang-left') return Promise.resolve(leftRanges);
if (lang === 'lang-right') return Promise.resolve(rightRanges);
return Promise.resolve([]);
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
index f865d6d760..36ebb9f7cb 100644
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -63,30 +63,6 @@ export function createDiffAppContext(): AppContext & Finalizable {
restApiService: (_ctx: Partial<AppContext>) => {
throw new Error('restApiService is not implemented');
},
- jsApiService: (_ctx: Partial<AppContext>) => {
- throw new Error('jsApiService is not implemented');
- },
- storageService: (_ctx: Partial<AppContext>) => {
- throw new Error('storageService is not implemented');
- },
- userModel: (_ctx: Partial<AppContext>) => {
- throw new Error('userModel is not implemented');
- },
- accountsModel: (_ctx: Partial<AppContext>) => {
- throw new Error('accountsModel is not implemented');
- },
- routerModel: (_ctx: Partial<AppContext>) => {
- throw new Error('routerModel is not implemented');
- },
- shortcutsService: (_ctx: Partial<AppContext>) => {
- throw new Error('shortcutsService is not implemented');
- },
- pluginsModel: (_ctx: Partial<AppContext>) => {
- throw new Error('pluginsModel is not implemented');
- },
- highlightService: (_ctx: Partial<AppContext>) => {
- throw new Error('highlightService is not implemented');
- },
};
return create<AppContext>(appRegistry);
}
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
index 3d3790c369..4f79e4c22d 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -3,11 +3,10 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {getRootElement} from '../../scripts/rootElement';
import {Constructor} from '../../utils/common-util';
import {LitElement, PropertyValues} from 'lit';
import {property, query} from 'lit/decorators.js';
-import {EventType, ShowAlertEventDetail} from '../../types/events';
+import {ShowAlertEventDetail} from '../../types/events';
import {debounce, DelayedTask} from '../../utils/async-util';
import {hovercardStyles} from '../../styles/gr-hovercard-styles';
import {sharedStyles} from '../../styles/shared-styles';
@@ -48,21 +47,6 @@ export interface MouseKeyboardOrFocusEvent {
focusEvent?: FocusEvent;
}
-export function getHovercardContainer(
- options: {createIfNotExists: boolean} = {createIfNotExists: false}
-): HTMLElement | null {
- let container = getRootElement().querySelector<HTMLElement>(
- `#${containerId}`
- );
- if (!container && options.createIfNotExists) {
- // If it does not exist, create and initialize the hovercard container.
- container = document.createElement('div');
- container.setAttribute('id', containerId);
- getRootElement().appendChild(container);
- }
- return container;
-}
-
/**
* How long should we wait before showing the hovercard when the user hovers
* over the element?
@@ -177,7 +161,7 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
this.addTargetEventListeners();
}
- this.container = getHovercardContainer({createIfNotExists: true});
+ this.container = this.getContainer();
this.cleanups.push(
addShortcut(
this,
@@ -235,6 +219,7 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
);
}
this.addEventListener('request-dependency', this.resolveDep);
+ this.addEventListener('reload', this.reload);
}
private removeTargetEventListeners() {
@@ -247,6 +232,7 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
}
this.targetCleanups = [];
this.removeEventListener('request-dependency', this.resolveDep);
+ this.removeEventListener('reload', this.reload);
}
/**
@@ -262,6 +248,10 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
}
}
+ readonly reload = () => {
+ this.dispatchEventThroughTarget('reload');
+ };
+
readonly mouseDebounceHide = (e: MouseEvent) => {
this.debounceHide({mouseEvent: e});
};
@@ -313,7 +303,7 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
dispatchEventThroughTarget(eventName: string): void;
dispatchEventThroughTarget(
- eventName: EventType.SHOW_ALERT,
+ eventName: 'show-alert',
detail: ShowAlertEventDetail
): void;
@@ -334,6 +324,29 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
);
}
+ getHost(): HTMLElement {
+ let el = this._target as Node;
+ while (el) {
+ if ((el as HTMLElement).tagName === 'DIALOG') {
+ return el as HTMLElement;
+ }
+ el = el.parentNode || (el as ShadowRoot).host;
+ }
+ return document.body;
+ }
+
+ getContainer(): HTMLElement | null {
+ const host = this.getHost();
+ let container = host.querySelector<HTMLElement>(`#${containerId}`);
+ if (!container) {
+ // If it does not exist, create and initialize the hovercard container.
+ container = document.createElement('div');
+ container.setAttribute('id', containerId);
+ host.appendChild(container);
+ }
+ return container;
+ }
+
/**
* Returns the target element that the hovercard is anchored to (the `id` of
* the `for` property).
@@ -360,6 +373,10 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
this.forceHide();
};
+ private containerClickListener = (e: MouseEvent) => {
+ e.stopPropagation();
+ };
+
/**
* Hovercards aren't children of <gr-app>. Dependencies must be resolved via
* their targets, so re-route 'request-dependency' events.
@@ -424,6 +441,7 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
this.container.removeChild(this);
}
document.removeEventListener('click', this.documentClickListener);
+ this.container?.removeEventListener('click', this.containerClickListener);
this.reportingTimer?.end({
targetId: this._target?.id,
tagName: this.tagName,
@@ -521,6 +539,7 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
if (props?.keyboardEvent) {
this.focus();
}
+ this.container.addEventListener('click', this.containerClickListener);
document.addEventListener('click', this.documentClickListener);
this.reportingTimer = this.reporting.getTimer('Show Hovercard');
};
@@ -542,16 +561,15 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
if (this._isInsideViewport()) return;
}
this.updatePositionTo(this.position);
- console.warn('Could not find a visible position for the hovercard.');
}
_isInsideViewport() {
const thisRect = this.getBoundingClientRect();
- if (thisRect.top < 0) return false;
- if (thisRect.left < 0) return false;
- const docuRect = document.documentElement.getBoundingClientRect();
- if (thisRect.bottom > docuRect.height) return false;
- if (thisRect.right > docuRect.width) return false;
+ const hostRect = this.getHost().getBoundingClientRect();
+ if (thisRect.top < hostRect.top) return false;
+ if (thisRect.left < hostRect.left) return false;
+ if (thisRect.bottom > hostRect.bottom) return false;
+ if (thisRect.right > hostRect.right) return false;
return true;
}
@@ -576,12 +594,12 @@ export const HovercardMixin = <T extends Constructor<LitElement>>(
// in the width and height of the bounding client rect.
this.style.cssText = '';
- const docuRect = document.documentElement.getBoundingClientRect();
+ const hostRect = this.getHost().getBoundingClientRect();
const targetRect = this._target.getBoundingClientRect();
const thisRect = this.getBoundingClientRect();
- const targetLeft = targetRect.left - docuRect.left;
- const targetTop = targetRect.top - docuRect.top;
+ const targetLeft = targetRect.left - hostRect.left;
+ const targetTop = targetRect.top - hostRect.top;
let hovercardLeft;
let hovercardTop;
@@ -640,6 +658,7 @@ export interface HovercardMixinInterface {
// Used for tests
mouseHide(e: MouseEvent): void;
+ getHost(): HTMLElement;
hide(props: MouseKeyboardOrFocusEvent): void;
container: HTMLElement | null;
hideTask?: DelayedTask;
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
index 8d32c5b903..ffae9e5177 100644
--- a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -73,7 +73,7 @@ suite('gr-hovercard tests', () => {
assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
- const parentRect = document.documentElement.getBoundingClientRect();
+ const parentRect = element.getHost().getBoundingClientRect();
const targetRect = element._target!.getBoundingClientRect();
const thisRect = element.getBoundingClientRect();
@@ -93,6 +93,16 @@ suite('gr-hovercard tests', () => {
);
});
+ test('getHost', () => {
+ element._target = document.createElement('span');
+
+ const dialog = document.createElement('dialog');
+
+ assert.deepEqual(element.getHost(), document.body);
+ dialog.appendChild(element._target);
+ assert.deepEqual(element.getHost(), dialog);
+ });
+
test('hide', () => {
element.mouseHide(new MouseEvent('click'));
const style = getComputedStyle(element);
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
deleted file mode 100644
index 30adedfbd3..0000000000
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronFitMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronFitBehavior in the same file where IronFitMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
-// The code 'IronFitBehavior as IronFitBehavior' required, because IronFitBehavior
-// defined as an object, not as IronFitBehavior instance.
-
-export const IronFitMixin = <T extends Constructor<PolymerElement>>(
- superClass: T,
- _: IronFitBehavior
-): T & Constructor<IronFitBehavior> =>
- // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
- // which will fail the type check due to missing IronFitBehavior interface
- // eslint-disable-next-line
- mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
deleted file mode 100644
index 362522877e..0000000000
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {Constructor} from '../../utils/common-util';
-
-// The mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronOverlayMixin with correct type.
-// Due to the following issues:
-// https://github.com/microsoft/TypeScript/issues/15870
-// https://github.com/microsoft/TypeScript/issues/9944
-// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
-// is used. To ensure that this import can't be avoided, the second parameter
-// is added. Usage example:
-// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
-// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
-// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
-export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
- superClass: T,
- _: IronOverlayBehavior
-): T & Constructor<IronOverlayBehavior> =>
- // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
- // instead which will fail the type check due to missing
- // IronOverlayBehavior interface
- // eslint-disable-next-line
- mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/models/accounts-model/accounts-model.ts b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
index 3f35127b3b..1c67857348 100644
--- a/polygerrit-ui/app/models/accounts-model/accounts-model.ts
+++ b/polygerrit-ui/app/models/accounts-model/accounts-model.ts
@@ -6,52 +6,57 @@
import {AccountDetailInfo, AccountInfo} from '../../api/rest-api';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
import {UserId} from '../../types/common';
import {getUserId, isDetailedAccount} from '../../utils/account-util';
+import {hasOwnProperty} from '../../utils/common-util';
import {define} from '../dependency';
import {Model} from '../model';
export interface AccountsState {
- accounts: {[id: UserId]: AccountDetailInfo};
+ accounts: {
+ [id: UserId]: AccountDetailInfo | AccountInfo;
+ };
}
export const accountsModelToken = define<AccountsModel>('accounts-model');
-export class AccountsModel extends Model<AccountsState> implements Finalizable {
+export class AccountsModel extends Model<AccountsState> {
constructor(readonly restApiService: RestApiService) {
super({
accounts: {},
});
}
- private updateStateAccount(id: UserId, account?: AccountDetailInfo) {
+ private updateStateAccount(
+ id: UserId,
+ account: AccountDetailInfo | AccountInfo
+ ) {
if (!account) return;
const current = {...this.getState()};
current.accounts = {...current.accounts, [id]: account};
this.setState(current);
}
- async getAccount(partialAccount: AccountInfo) {
+ async getAccount(
+ partialAccount: AccountInfo
+ ): Promise<AccountDetailInfo | AccountInfo> {
const current = this.getState();
const id = getUserId(partialAccount);
- if (current.accounts[id]) return current.accounts[id];
+ if (hasOwnProperty(current.accounts, id)) return current.accounts[id];
// It is possible to add emails to CC when they don't have a Gerrit
- // account. In this case getAccountDetails will return a 404 error hence
- // pass an empty error function to handle that.
+ // account. In this case getAccountDetails will return a 404 error then
+ // we at least use what is in partialAccount.
const account = await this.restApiService.getAccountDetails(id, () => {
- this.updateStateAccount(id, partialAccount as AccountDetailInfo);
+ this.updateStateAccount(id, partialAccount);
return;
});
if (account) this.updateStateAccount(id, account);
- return account;
+ return account ?? partialAccount;
}
async fillDetails(account: AccountInfo) {
if (!isDetailedAccount(account)) {
- if (account.email) return await this.getAccount({email: account.email});
- else if (account._account_id)
- return await this.getAccount({_account_id: account._account_id});
+ return await this.getAccount(account);
}
return account;
}
diff --git a/polygerrit-ui/app/models/browser/browser-model.ts b/polygerrit-ui/app/models/browser/browser-model.ts
index 1592cd869d..50b6325571 100644
--- a/polygerrit-ui/app/models/browser/browser-model.ts
+++ b/polygerrit-ui/app/models/browser/browser-model.ts
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Observable, combineLatest} from 'rxjs';
-import {Finalizable} from '../../services/registry';
import {define} from '../dependency';
import {DiffViewMode} from '../../api/diff';
import {UserModel} from '../user/user-model';
@@ -26,7 +25,7 @@ const initialState: BrowserState = {};
export const browserModelToken = define<BrowserModel>('browser-model');
-export class BrowserModel extends Model<BrowserState> implements Finalizable {
+export class BrowserModel extends Model<BrowserState> {
private readonly isScreenTooSmall$ = select(
this.state$,
state =>
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 f706712690..b13a16feba 100644
--- a/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
+++ b/polygerrit-ui/app/models/bulk-actions/bulk-actions-model.ts
@@ -14,7 +14,6 @@ import {
Hashtag,
} from '../../api/rest-api';
import {Model} from '../model';
-import {Finalizable} from '../../services/registry';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {define} from '../dependency';
import {select} from '../../utils/observable-util';
@@ -50,10 +49,7 @@ const initialState: BulkActionsState = {
allChanges: new Map(),
};
-export class BulkActionsModel
- extends Model<BulkActionsState>
- implements Finalizable
-{
+export class BulkActionsModel extends Model<BulkActionsState> {
constructor(private readonly restApiService: RestApiService) {
super(initialState);
}
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index e00aefed31..4e9f015e8f 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -5,6 +5,7 @@
*/
import {
BasePatchSetNum,
+ ChangeInfo,
EditInfo,
EDIT,
PARENT,
@@ -12,34 +13,41 @@ import {
PatchSetNum,
PreferencesInfo,
RevisionPatchSetNum,
+ PatchSetNumber,
+ CommitId,
} from '../../types/common';
-import {DefaultBase} from '../../constants/constants';
-import {combineLatest, from, fromEvent, Observable, forkJoin, of} from 'rxjs';
-import {
- map,
- filter,
- withLatestFrom,
- startWith,
- switchMap,
-} from 'rxjs/operators';
-import {RouterModel} from '../../services/router/router-model';
+import {ChangeStatus, DefaultBase} from '../../constants/constants';
+import {combineLatest, from, Observable, forkJoin, of} from 'rxjs';
+import {map, filter, withLatestFrom, switchMap} from 'rxjs/operators';
import {
computeAllPatchSets,
computeLatestPatchNum,
computeLatestPatchNumWithEdit,
+ findEdit,
+ sortRevisions,
} from '../../utils/patch-set-util';
-import {ParsedChangeInfo} from '../../types/types';
-import {fireAlert} from '../../utils/event-util';
-
-import {ChangeInfo} from '../../types/common';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
+import {fireAlert, fireTitleChange} from '../../utils/event-util';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
import {select} from '../../utils/observable-util';
import {assertIsDefined} from '../../utils/common-util';
import {Model} from '../model';
import {UserModel} from '../user/user-model';
import {define} from '../dependency';
import {isOwner} from '../../utils/change-util';
+import {
+ ChangeChildView,
+ ChangeViewModel,
+ createChangeUrl,
+ createDiffUrl,
+ createEditUrl,
+} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
+import {getRevertCreatedChangeIds} from '../../utils/message-util';
+import {computeTruncatedPath} from '../../utils/path-list-util';
+import {PluginLoader} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+import {Timing} from '../../constants/reporting';
export enum LoadingStatus {
NOT_LOADED = 'NOT_LOADED',
@@ -58,17 +66,44 @@ export interface ChangeState {
loadingStatus: LoadingStatus;
change?: ParsedChangeInfo;
/**
- * The name of the file user is viewing in the diff view mode. File path is
- * specified in the url or derived from the commentId.
- * Does not apply to change-view or edit-view.
- */
- diffPath?: string;
- /**
* The list of reviewed files, kept in the model because we want changes made
* in one view to reflect on other views without re-rendering the other views.
* Undefined means it's still loading and empty set means no files reviewed.
*/
reviewedFiles?: string[];
+ /**
+ * Either filled from `change.mergeable`, or from a dedicated REST API call.
+ * Is initially `undefined`, such that you can identify whether this
+ * information has already been loaded once for this change or not. Will never
+ * go back to `undefined` after being set for a change.
+ */
+ mergeable?: boolean;
+}
+
+/**
+ * `change.revisions` is a dictionary mapping the revision sha to RevisionInfo,
+ * but the info object itself does not contain the sha, which is a problem when
+ * working with just the info objects.
+ *
+ * So we are iterating over the map here and are assigning the sha map key to
+ * the property `revision.commit.commit`.
+ *
+ * As usual we are treating data objects as immutable, so we are doind a lot of
+ * cloning here.
+ */
+export function updateRevisionsWithCommitShas(changeInput?: ParsedChangeInfo) {
+ if (!changeInput?.revisions) return changeInput;
+ const changeOutput = {...changeInput, revisions: {...changeInput.revisions}};
+ for (const sha of Object.keys(changeOutput.revisions)) {
+ const revision = changeOutput.revisions[sha];
+ if (revision?.commit && !revision.commit.commit) {
+ changeOutput.revisions[sha] = {
+ ...revision,
+ commit: {...revision.commit, commit: sha as CommitId},
+ };
+ }
+ }
+ return changeOutput;
}
/**
@@ -78,7 +113,7 @@ export interface ChangeState {
export function updateChangeWithEdit(
change?: ParsedChangeInfo,
edit?: EditInfo,
- routerPatchNum?: PatchSetNum
+ viewModelPatchNum?: PatchSetNum
): ParsedChangeInfo | undefined {
if (!change || !edit) return change;
assertIsDefined(edit.commit.commit, 'edit.commit.commit');
@@ -97,7 +132,7 @@ export function updateChangeWithEdit(
// which is still done in change-view. `_patchRange.patchNum` should
// eventually also be model managed, so we can reconcile these two code
// snippets into one location.
- if (routerPatchNum === undefined) {
+ if (viewModelPatchNum === undefined) {
change.current_revision = edit.commit.commit;
}
return change;
@@ -105,20 +140,20 @@ export function updateChangeWithEdit(
/**
* Derives the base patchset number from all the data that can potentially
- * influence it. Mostly just returns `routerBasePatchNum` or PARENT, but has
+ * influence it. Mostly just returns `viewModelBasePatchNum` or PARENT, but has
* some special logic when looking at merge commits.
*
- * NOTE: At the moment this returns just `routerBasePatchNum ?? PARENT`, see
+ * NOTE: At the moment this returns just `viewModelBasePatchNum ?? PARENT`, see
* TODO below.
*/
function computeBase(
- routerBasePatchNum: BasePatchSetNum | undefined,
+ viewModelBasePatchNum: BasePatchSetNum | undefined,
patchNum: RevisionPatchSetNum | undefined,
change: ParsedChangeInfo | undefined,
preferences: PreferencesInfo
): BasePatchSetNum {
- if (routerBasePatchNum && routerBasePatchNum !== PARENT) {
- return routerBasePatchNum;
+ if (viewModelBasePatchNum && viewModelBasePatchNum !== PARENT) {
+ return viewModelBasePatchNum;
}
if (!change || !patchNum) return PARENT;
@@ -131,7 +166,7 @@ function computeBase(
// but we are not sure whether this was ever 100% working correctly. A
// major challenge is being able to select PARENT explicitly even if your
// preference for the default choice is FIRST_PARENT. <gr-file-list-header>
- // just uses `navigation.setUrl()` and the router does not have any
+ // just uses `navigation.setUrl()` and the view model does not have any
// way of forcing the basePatchSetNum to stick to PARENT without being
// altered back to FIRST_PARENT here.
// See also corresponding TODO in gr-settings-view.
@@ -149,10 +184,14 @@ const initialState: ChangeState = {
export const changeModelToken = define<ChangeModel>('change-model');
-export class ChangeModel extends Model<ChangeState> implements Finalizable {
+export class ChangeModel extends Model<ChangeState> {
private change?: ParsedChangeInfo;
- private patchNum?: PatchSetNum;
+ private patchNum?: RevisionPatchSetNum;
+
+ private basePatchNum?: BasePatchSetNum;
+
+ private latestPatchNum?: PatchSetNumber;
public readonly change$ = select(
this.state$,
@@ -164,9 +203,10 @@ export class ChangeModel extends Model<ChangeState> implements Finalizable {
changeState => changeState.loadingStatus
);
- public readonly diffPath$ = select(
- this.state$,
- changeState => changeState?.diffPath
+ public readonly loading$ = select(
+ this.changeLoadingStatus$,
+ status =>
+ status === LoadingStatus.LOADING || status === LoadingStatus.RELOADING
);
public readonly reviewedFiles$ = select(
@@ -174,15 +214,27 @@ export class ChangeModel extends Model<ChangeState> implements Finalizable {
changeState => changeState?.reviewedFiles
);
+ public readonly mergeable$ = select(
+ this.state$,
+ changeState => changeState.mergeable
+ );
+
+ public readonly branch$ = select(this.change$, change => change?.branch);
+
public readonly changeNum$ = select(this.change$, change => change?._number);
+ public readonly changeId$ = select(this.change$, change => change?.change_id);
+
public readonly repo$ = select(this.change$, change => change?.project);
+ public readonly topic$ = select(this.change$, change => change?.topic);
+
+ public readonly status$ = select(this.change$, change => change?.status);
+
public readonly labels$ = select(this.change$, change => change?.labels);
- public readonly revisions$ = select(
- this.change$,
- change => change?.revisions
+ public readonly revisions$ = select(this.change$, change =>
+ sortRevisions(Object.values(change?.revisions || {}))
);
public readonly patchsets$ = select(this.change$, change =>
@@ -197,6 +249,11 @@ export class ChangeModel extends Model<ChangeState> implements Finalizable {
computeLatestPatchNumWithEdit(patchsets)
);
+ public readonly latestUploader$ = select(
+ this.change$,
+ change => change?.revisions[change.current_revision]?.uploader
+ );
+
/**
* Emits the current patchset number. If the route does not define the current
* patchset num, then this selector waits for the change to be defined and
@@ -207,120 +264,327 @@ export class ChangeModel extends Model<ChangeState> implements Finalizable {
public readonly patchNum$: Observable<RevisionPatchSetNum | undefined> =
select(
combineLatest([
- this.routerModel.state$,
+ this.viewModel.state$,
this.state$,
this.latestPatchNumWithEdit$,
]).pipe(
/**
- * If you depend on both, router and change state, then you want to
- * filter out inconsistent state, e.g. router changeNum already updated,
- * change not yet reset to undefined.
+ * 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.
*/
- filter(([routerState, changeState, _latestPatchN]) => {
+ filter(([viewModelState, changeState, _latestPatchN]) => {
const changeNum = changeState.change?._number;
- const routerChangeNum = routerState.changeNum;
- return changeNum === undefined || changeNum === routerChangeNum;
+ const viewModelChangeNum = viewModelState?.changeNum;
+ return changeNum === undefined || changeNum === viewModelChangeNum;
})
),
- ([routerState, _changeState, latestPatchN]) =>
- routerState?.patchNum || latestPatchN
+ ([viewModelState, _changeState, latestPatchN]) =>
+ viewModelState?.patchNum || latestPatchN
);
/**
* Emits the base patchset number. This is identical to the
- * `routerBasePatchNum$`, but has some special logic for merges.
+ * `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, router and change state, then you want to filter
- * out inconsistent state, e.g. router changeNum already updated, change not
- * yet reset to undefined.
+ * 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(
combineLatest([
- this.routerModel.state$,
+ this.viewModel.state$,
this.state$,
this.userModel.state$,
]).pipe(
- filter(([routerState, changeState, _]) => {
+ filter(([viewModelState, changeState, _]) => {
const changeNum = changeState.change?._number;
- const routerChangeNum = routerState.changeNum;
- return changeNum === undefined || changeNum === routerChangeNum;
+ const viewModelChangeNum = viewModelState?.changeNum;
+ return changeNum === undefined || changeNum === viewModelChangeNum;
}),
withLatestFrom(
- this.routerModel.routerBasePatchNum$,
+ this.viewModel.basePatchNum$,
this.patchNum$,
this.change$,
this.userModel.preferences$
)
),
- ([_, routerBasePatchNum, patchNum, change, preferences]) =>
- computeBase(routerBasePatchNum, patchNum, change, preferences)
+ ([_, viewModelBasePatchNum, patchNum, change, preferences]) =>
+ computeBase(viewModelBasePatchNum, patchNum, change, preferences)
);
+ private selectRevision(
+ revisionNum$: Observable<RevisionPatchSetNum | undefined>
+ ) {
+ return select(
+ combineLatest([this.revisions$, revisionNum$]),
+ ([revisions, patchNum]) => {
+ if (!revisions || !patchNum) return undefined;
+ return Object.values(revisions).find(
+ revision => revision._number === patchNum
+ );
+ }
+ );
+ }
+
+ public readonly revision$ = this.selectRevision(this.patchNum$);
+
+ public readonly latestRevision$ = this.selectRevision(this.latestPatchNum$);
+
public readonly isOwner$: Observable<boolean> = select(
combineLatest([this.change$, this.userModel.account$]),
([change, account]) => isOwner(change, account)
);
- // For usage in `combineLatest` we need `startWith` such that reload$ has an
- // initial value.
- readonly reload$: Observable<unknown> = fromEvent(document, 'reload').pipe(
- startWith(undefined)
+ public readonly messages$ = select(this.change$, change => change?.messages);
+
+ public readonly revertingChangeIds$ = select(this.messages$, messages =>
+ getRevertCreatedChangeIds(messages ?? [])
);
constructor(
- readonly routerModel: RouterModel,
- readonly restApiService: RestApiService,
- readonly userModel: UserModel
+ 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.subscriptions = [
- combineLatest([this.routerModel.routerChangeNum$, this.reload$])
- .pipe(
- map(([changeNum, _]) => changeNum),
- switchMap(changeNum => {
- if (changeNum !== undefined) this.updateStateLoading(changeNum);
- const change = from(this.restApiService.getChangeDetail(changeNum));
- const edit = from(this.restApiService.getChangeEdit(changeNum));
- return forkJoin([change, edit]);
- }),
- withLatestFrom(this.routerModel.routerPatchNum$),
- map(([[change, edit], patchNum]) =>
- updateChangeWithEdit(change, edit, patchNum)
- )
- )
- .subscribe(change => {
- // The change service is currently a singleton, so we have to be
- // careful to avoid situations where the application state is
- // partially set for the old change where the user is coming from,
- // and partially for the new change where the user is navigating to.
- // So setting the change explicitly to undefined when the user
- // moves away from diff and change pages (changeNum === undefined)
- // helps with that.
- this.updateStateChange(change ?? undefined);
- }),
+ this.loadChange(),
+ this.loadMergeable(),
+ this.loadReviewedFiles(),
+ this.setOverviewTitle(),
+ this.setDiffTitle(),
+ this.setEditTitle(),
+ this.reportChangeReload(),
+ this.reportSendReply(),
+ this.fireShowChange(),
+ this.refuseEditForOpenChange(),
+ this.refuseEditForClosedChange(),
this.change$.subscribe(change => (this.change = change)),
this.patchNum$.subscribe(patchNum => (this.patchNum = patchNum)),
- combineLatest([this.patchNum$, this.changeNum$, this.userModel.loggedIn$])
- .pipe(
- switchMap(([patchNum, changeNum, loggedIn]) => {
- if (!changeNum || !patchNum || !loggedIn) {
- this.updateStateReviewedFiles([]);
- return of(undefined);
- }
- return from(this.fetchReviewedFiles(patchNum, changeNum));
- })
- )
- .subscribe(),
+ this.basePatchNum$.subscribe(
+ basePatchNum => (this.basePatchNum = basePatchNum)
+ ),
+ this.latestPatchNum$.subscribe(
+ latestPatchNum => (this.latestPatchNum = latestPatchNum)
+ ),
];
}
- // Temporary workaround until path is derived in the model itself.
- updatePath(diffPath?: string) {
- this.updateState({diffPath});
+ private reportSendReply() {
+ return this.changeLoadingStatus$.subscribe(loadingStatus => {
+ // We are ending the timer on each change load, because ending a timer
+ // that was not started is a no-op. :-)
+ if (loadingStatus === LoadingStatus.LOADED) {
+ this.reporting.timeEnd(Timing.SEND_REPLY);
+ }
+ });
+ }
+
+ private reportChangeReload() {
+ return this.changeLoadingStatus$.subscribe(loadingStatus => {
+ if (
+ loadingStatus === LoadingStatus.LOADING ||
+ loadingStatus === LoadingStatus.RELOADING
+ ) {
+ this.reporting.time(Timing.CHANGE_RELOAD);
+ }
+ if (
+ loadingStatus === LoadingStatus.LOADED ||
+ loadingStatus === LoadingStatus.NOT_LOADED
+ ) {
+ this.reporting.timeEnd(Timing.CHANGE_RELOAD);
+ }
+ });
+ }
+
+ private fireShowChange() {
+ return combineLatest([
+ this.viewModel.childView$,
+ this.change$,
+ this.basePatchNum$,
+ this.patchNum$,
+ this.mergeable$,
+ ])
+ .pipe(
+ filter(
+ ([childView, change, basePatchNum, patchNum, mergeable]) =>
+ childView === ChangeChildView.OVERVIEW &&
+ !!change &&
+ !!basePatchNum &&
+ !!patchNum &&
+ mergeable !== undefined
+ )
+ )
+ .subscribe(([_, change, basePatchNum, patchNum, mergeable]) => {
+ this.pluginLoader.jsApiService.handleShowChange({
+ change,
+ basePatchNum,
+ patchNum,
+ // `?? null` is for the TypeScript compiler only. We have a
+ // `mergeable !== undefined` filter above, so this cannot happen.
+ // It would be nice to change `ShowChangeDetail` to accept `undefined`
+ // instaed of `null`, but that would be a Plugin API change ...
+ info: {mergeable: mergeable ?? null},
+ });
+ });
+ }
+
+ private refuseEditForOpenChange() {
+ return combineLatest([this.revisions$, this.patchNum$, this.status$])
+ .pipe(
+ filter(
+ ([revisions, patchNum, status]) =>
+ status === ChangeStatus.NEW &&
+ revisions.length > 0 &&
+ patchNum === EDIT
+ )
+ )
+ .subscribe(([revisions]) => {
+ const editRev = findEdit(revisions);
+ if (!editRev) {
+ const msg = 'Change edit not found. Please create a change edit.';
+ fireAlert(document, msg);
+ this.navigateToChangeResetReload();
+ }
+ });
+ }
+
+ private refuseEditForClosedChange() {
+ return combineLatest([
+ this.revisions$,
+ this.viewModel.edit$,
+ this.patchNum$,
+ this.status$,
+ ])
+ .pipe(
+ filter(
+ ([revisions, edit, patchNum, status]) =>
+ (status === ChangeStatus.ABANDONED ||
+ status === ChangeStatus.MERGED) &&
+ revisions.length > 0 &&
+ (patchNum === EDIT || edit)
+ )
+ )
+ .subscribe(([revisions]) => {
+ const editRev = findEdit(revisions);
+ if (!editRev) {
+ const msg =
+ 'Change edits cannot be created if change is merged ' +
+ 'or abandoned. Redirecting to non edit mode.';
+ fireAlert(document, msg);
+ this.navigateToChangeResetReload();
+ }
+ });
+ }
+
+ private setOverviewTitle() {
+ return combineLatest([this.viewModel.childView$, this.change$])
+ .pipe(
+ filter(([childView, _]) => childView === ChangeChildView.OVERVIEW),
+ map(([_, change]) => change),
+ filter(isDefined)
+ )
+ .subscribe(change => {
+ const title = `${change.subject} (${change._number})`;
+ fireTitleChange(title);
+ });
+ }
+
+ private setDiffTitle() {
+ return combineLatest([this.viewModel.childView$, this.viewModel.diffPath$])
+ .pipe(
+ filter(([childView, _]) => childView === ChangeChildView.DIFF),
+ map(([_, diffPath]) => diffPath),
+ filter(isDefined)
+ )
+ .subscribe(diffPath => {
+ const title = computeTruncatedPath(diffPath);
+ fireTitleChange(title);
+ });
+ }
+
+ private setEditTitle() {
+ return combineLatest([this.viewModel.childView$, this.viewModel.editPath$])
+ .pipe(
+ filter(([childView, _]) => childView === ChangeChildView.EDIT),
+ map(([_, editPath]) => editPath),
+ filter(isDefined)
+ )
+ .subscribe(editPath => {
+ const title = `Editing ${computeTruncatedPath(editPath)}`;
+ fireTitleChange(title);
+ });
+ }
+
+ private loadReviewedFiles() {
+ return combineLatest([
+ this.patchNum$,
+ this.changeNum$,
+ this.userModel.loggedIn$,
+ ])
+ .pipe(
+ switchMap(([patchNum, changeNum, loggedIn]) => {
+ if (!changeNum || !patchNum || !loggedIn) {
+ this.updateStateReviewedFiles([]);
+ return of(undefined);
+ }
+ return from(this.fetchReviewedFiles(patchNum, changeNum));
+ })
+ )
+ .subscribe();
+ }
+
+ private loadMergeable() {
+ return this.change$
+ .pipe(
+ switchMap(change => {
+ if (change?._number === undefined) return of(undefined);
+ if (change.mergeable !== undefined) return of(change.mergeable);
+ if (change.status === ChangeStatus.MERGED) return of(false);
+ if (change.status === ChangeStatus.ABANDONED) return of(false);
+ return from(
+ this.restApiService
+ .getMergeable(change._number)
+ .then(mergableInfo => mergableInfo?.mergeable ?? false)
+ );
+ })
+ )
+ .subscribe(mergeable => this.updateState({mergeable}));
+ }
+
+ private loadChange() {
+ return this.viewModel.changeNum$
+ .pipe(
+ switchMap(changeNum => {
+ if (changeNum !== undefined) this.updateStateLoading(changeNum);
+ const change = from(this.restApiService.getChangeDetail(changeNum));
+ const edit = from(this.restApiService.getChangeEdit(changeNum));
+ return forkJoin([change, edit]);
+ }),
+ withLatestFrom(this.viewModel.patchNum$),
+ map(([[change, edit], patchNum]) =>
+ updateChangeWithEdit(change, edit, patchNum)
+ ),
+ map(updateRevisionsWithCommitShas)
+ )
+ .subscribe(change => {
+ // The change service is currently a singleton, so we have to be
+ // careful to avoid situations where the application state is
+ // partially set for the old change where the user is coming from,
+ // and partially for the new change where the user is navigating to.
+ // So setting the change explicitly to undefined when the user
+ // moves away from diff and change pages (changeNum === undefined)
+ // helps with that.
+ this.updateStateChange(change ?? undefined);
+ });
}
updateStateReviewedFiles(reviewedFiles: string[]) {
@@ -387,6 +651,80 @@ export class ChangeModel extends Model<ChangeState> implements Finalizable {
return this.getState().change;
}
+ diffUrl(
+ diffView: {path: string; lineNum?: number},
+ patchNum = this.patchNum,
+ basePatchNum = this.basePatchNum
+ ) {
+ if (!this.change) return;
+ if (!this.patchNum) return;
+ return createDiffUrl({
+ change: this.change,
+ patchNum,
+ basePatchNum,
+ diffView,
+ });
+ }
+
+ navigateToDiff(
+ diffView: {path: string; lineNum?: number},
+ patchNum = this.patchNum,
+ basePatchNum = this.basePatchNum
+ ) {
+ const url = this.diffUrl(diffView, patchNum, basePatchNum);
+ if (!url) return;
+ this.navigation.setUrl(url);
+ }
+
+ changeUrl(openReplyDialog = false) {
+ if (!this.change) return;
+ const isLatest = this.latestPatchNum === this.patchNum;
+ return createChangeUrl({
+ change: this.change,
+ patchNum:
+ isLatest && this.basePatchNum === PARENT ? undefined : this.patchNum,
+ basePatchNum: this.basePatchNum,
+ openReplyDialog,
+ });
+ }
+
+ // Mainly used for navigating from DIFF to OVERVIEW.
+ navigateToChange(openReplyDialog = false) {
+ const url = this.changeUrl(openReplyDialog);
+ if (!url) return;
+ this.navigation.setUrl(url);
+ }
+
+ /**
+ * Wipes all URL parameters and other view state and goes to the change
+ * overview page, forcing a reload.
+ *
+ * This will also wipe the `patchNum`, so will always go to the latest
+ * patchset.
+ */
+ navigateToChangeResetReload() {
+ if (!this.change) return;
+ const url = createChangeUrl({change: this.change, forceReload: true});
+ if (!url) return;
+ this.navigation.setUrl(url);
+ }
+
+ editUrl(editView: {path: string; lineNum?: number}) {
+ if (!this.change) return;
+ return createEditUrl({
+ changeNum: this.change._number,
+ repo: this.change.project,
+ patchNum: this.patchNum,
+ editView,
+ });
+ }
+
+ navigateToEdit(editView: {path: string; lineNum?: number}) {
+ const url = this.editUrl(editView);
+ if (!url) return;
+ this.navigation.setUrl(url);
+ }
+
/**
* Check whether there is no newer patch than the latest patch that was
* available when this change was loaded.
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts
index 4b51d5bf44..dc7d9c3c41 100644
--- a/polygerrit-ui/app/models/change/change-model_test.ts
+++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -7,9 +7,12 @@ import {Subject} from 'rxjs';
import {ChangeStatus} from '../../constants/constants';
import '../../test/common-test-setup';
import {
+ TEST_NUMERIC_CHANGE_ID,
createChange,
createChangeMessageInfo,
+ createChangeViewState,
createEditInfo,
+ createMergeable,
createParsedChange,
createRevision,
} from '../../test/test-data-generators';
@@ -28,10 +31,34 @@ import {
} from '../../types/common';
import {ParsedChangeInfo} from '../../types/types';
import {getAppContext} from '../../services/app-context';
-import {GerritView} from '../../services/router/router-model';
-import {ChangeState, LoadingStatus, updateChangeWithEdit} from './change-model';
+import {
+ ChangeState,
+ LoadingStatus,
+ updateChangeWithEdit,
+ updateRevisionsWithCommitShas,
+} from './change-model';
import {ChangeModel} from './change-model';
import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../user/user-model';
+import {
+ ChangeChildView,
+ ChangeViewModel,
+ changeViewModelToken,
+} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {SinonStub} from 'sinon';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {ShowChangeDetail} from '../../elements/shared/gr-js-api-interface/gr-js-api-types';
+
+suite('updateRevisionsWithCommitShas() tests', () => {
+ test('undefined edit', async () => {
+ const change = createParsedChange();
+ const updated = updateRevisionsWithCommitShas(change);
+ assert.equal(change?.revisions?.['abc'].commit?.commit, undefined);
+ assert.equal(updated?.revisions?.['abc'].commit?.commit, 'abc' as CommitId);
+ });
+});
suite('updateChangeWithEdit() tests', () => {
test('undefined change', async () => {
@@ -65,6 +92,7 @@ suite('updateChangeWithEdit() tests', () => {
});
suite('change model tests', () => {
+ let changeViewModel: ChangeViewModel;
let changeModel: ChangeModel;
let knownChange: ParsedChangeInfo;
const testCompleted = new Subject<void>();
@@ -80,24 +108,20 @@ suite('change model tests', () => {
}
setup(() => {
+ changeViewModel = testResolver(changeViewModelToken);
changeModel = new ChangeModel(
- getAppContext().routerModel,
+ testResolver(navigationToken),
+ changeViewModel,
getAppContext().restApiService,
- getAppContext().userModel
+ testResolver(userModelToken),
+ testResolver(pluginLoaderToken),
+ getAppContext().reportingService
);
knownChange = {
...createChange(),
revisions: {
- sha1: {
- ...createRevision(1),
- description: 'patch 1',
- _number: 1 as PatchSetNumber,
- },
- sha2: {
- ...createRevision(2),
- description: 'patch 2',
- _number: 2 as PatchSetNumber,
- },
+ sha1: {...createRevision(1), description: 'patch 1'},
+ sha2: {...createRevision(2), description: 'patch 2'},
},
status: ChangeStatus.NEW,
current_revision: 'abc' as CommitId,
@@ -110,6 +134,116 @@ suite('change model tests', () => {
changeModel.finalize();
});
+ suite('mergeability', async () => {
+ let getMergeableStub: SinonStub;
+ let mergeableApiResponse = false;
+
+ setup(() => {
+ getMergeableStub = stubRestApi('getMergeable').callsFake(() =>
+ Promise.resolve(createMergeable(mergeableApiResponse))
+ );
+ });
+
+ test('mergeability initially undefined', async () => {
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === undefined
+ );
+ assert.isFalse(getMergeableStub.called);
+ });
+
+ test('mergeability true from change', async () => {
+ changeModel.updateStateChange({...knownChange, mergeable: true});
+
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === true
+ );
+ assert.isFalse(getMergeableStub.called);
+ });
+
+ test('mergeability false from change', async () => {
+ changeModel.updateStateChange({...knownChange, mergeable: false});
+
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === true
+ );
+ assert.isFalse(getMergeableStub.called);
+ });
+
+ test('mergeability false for MERGED change', async () => {
+ changeModel.updateStateChange({
+ ...knownChange,
+ status: ChangeStatus.MERGED,
+ });
+
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === false
+ );
+ assert.isFalse(getMergeableStub.called);
+ });
+
+ test('mergeability false for ABANDONED change', async () => {
+ changeModel.updateStateChange({
+ ...knownChange,
+ status: ChangeStatus.ABANDONED,
+ });
+
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === false
+ );
+ assert.isFalse(getMergeableStub.called);
+ });
+
+ test('mergeability true from API', async () => {
+ mergeableApiResponse = true;
+ changeModel.updateStateChange(knownChange);
+
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === true
+ );
+ assert.isTrue(getMergeableStub.calledOnce);
+ });
+
+ test('mergeability false from API', async () => {
+ mergeableApiResponse = false;
+ changeModel.updateStateChange(knownChange);
+
+ waitUntilObserved(
+ changeModel.mergeable$,
+ mergeable => mergeable === false
+ );
+ assert.isTrue(getMergeableStub.calledOnce);
+ });
+ });
+
+ test('fireShowChange', async () => {
+ await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
+ const pluginLoader = testResolver(pluginLoaderToken);
+ const jsApiService = pluginLoader.jsApiService;
+ const showChangeStub = sinon.stub(jsApiService, 'handleShowChange');
+
+ changeViewModel.updateState({
+ childView: ChangeChildView.OVERVIEW,
+ patchNum: 1 as PatchSetNumber,
+ });
+ changeModel.updateState({
+ change: createParsedChange(),
+ mergeable: true,
+ });
+
+ assert.isTrue(showChangeStub.calledOnce);
+ const detail: ShowChangeDetail = showChangeStub.lastCall.firstArg;
+ assert.equal(detail.change?._number, createParsedChange()._number);
+ assert.equal(detail.patchNum, 1 as PatchSetNumber);
+ assert.equal(detail.basePatchNum, PARENT);
+ assert.equal(detail.info.mergeable, true);
+ });
+
test('load a change', async () => {
const promise = mockPromise<ParsedChangeInfo | undefined>();
const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
@@ -119,10 +253,7 @@ suite('change model tests', () => {
assert.equal(stub.callCount, 0);
assert.isUndefined(state?.change);
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: knownChange._number,
- });
+ testResolver(changeViewModelToken).setState(createChangeViewState());
state = await waitForLoadingStatus(LoadingStatus.LOADING);
assert.equal(stub.callCount, 1);
assert.isUndefined(state?.change);
@@ -130,7 +261,7 @@ suite('change model tests', () => {
promise.resolve(knownChange);
state = await waitForLoadingStatus(LoadingStatus.LOADED);
assert.equal(stub.callCount, 1);
- assert.equal(state?.change, knownChange);
+ assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
});
test('reload a change', async () => {
@@ -138,23 +269,23 @@ suite('change model tests', () => {
const promise = mockPromise<ParsedChangeInfo | undefined>();
const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
let state: ChangeState;
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: knownChange._number,
- });
+ testResolver(changeViewModelToken).setState(createChangeViewState());
promise.resolve(knownChange);
state = await waitForLoadingStatus(LoadingStatus.LOADED);
+ assert.equal(stub.callCount, 1);
// Reloading same change
document.dispatchEvent(new CustomEvent('reload'));
state = await waitForLoadingStatus(LoadingStatus.RELOADING);
- assert.equal(stub.callCount, 2);
- assert.equal(state?.change, knownChange);
+ assert.equal(stub.callCount, 3);
+ assert.equal(stub.getCall(1).firstArg, undefined);
+ assert.equal(stub.getCall(2).firstArg, TEST_NUMERIC_CHANGE_ID);
+ assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
promise.resolve(knownChange);
state = await waitForLoadingStatus(LoadingStatus.LOADED);
- assert.equal(stub.callCount, 2);
- assert.equal(state?.change, knownChange);
+ assert.equal(stub.callCount, 3);
+ assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
});
test('navigating to another change', async () => {
@@ -162,10 +293,7 @@ suite('change model tests', () => {
let promise = mockPromise<ParsedChangeInfo | undefined>();
const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
let state: ChangeState;
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: knownChange._number,
- });
+ testResolver(changeViewModelToken).setState(createChangeViewState());
promise.resolve(knownChange);
state = await waitForLoadingStatus(LoadingStatus.LOADED);
@@ -176,8 +304,8 @@ suite('change model tests', () => {
_number: 123 as NumericChangeId,
};
promise = mockPromise<ParsedChangeInfo | undefined>();
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
+ testResolver(changeViewModelToken).setState({
+ ...createChangeViewState(),
changeNum: otherChange._number,
});
state = await waitForLoadingStatus(LoadingStatus.LOADING);
@@ -187,7 +315,7 @@ suite('change model tests', () => {
promise.resolve(otherChange);
state = await waitForLoadingStatus(LoadingStatus.LOADED);
assert.equal(stub.callCount, 2);
- assert.equal(state?.change, otherChange);
+ assert.deepEqual(state?.change, updateRevisionsWithCommitShas(otherChange));
});
test('navigating to dashboard', async () => {
@@ -195,10 +323,7 @@ suite('change model tests', () => {
let promise = mockPromise<ParsedChangeInfo | undefined>();
const stub = stubRestApi('getChangeDetail').callsFake(() => promise);
let state: ChangeState;
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: knownChange._number,
- });
+ testResolver(changeViewModelToken).setState(createChangeViewState());
promise.resolve(knownChange);
state = await waitForLoadingStatus(LoadingStatus.LOADED);
@@ -206,10 +331,7 @@ suite('change model tests', () => {
promise = mockPromise<ParsedChangeInfo | undefined>();
promise.resolve(undefined);
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: undefined,
- });
+ testResolver(changeViewModelToken).setState(undefined);
state = await waitForLoadingStatus(LoadingStatus.NOT_LOADED);
assert.equal(stub.callCount, 2);
assert.isUndefined(state?.change);
@@ -218,13 +340,10 @@ suite('change model tests', () => {
promise = mockPromise<ParsedChangeInfo | undefined>();
promise.resolve(knownChange);
- changeModel.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: knownChange._number,
- });
+ testResolver(changeViewModelToken).setState(createChangeViewState());
state = await waitForLoadingStatus(LoadingStatus.LOADED);
assert.equal(stub.callCount, 3);
- assert.equal(state?.change, knownChange);
+ assert.deepEqual(state?.change, updateRevisionsWithCommitShas(knownChange));
});
test('changeModel.fetchChangeUpdates on latest', async () => {
@@ -297,7 +416,9 @@ suite('change model tests', () => {
assert.equal(spy.lastCall.firstArg, PARENT);
// test update
- changeModel.routerModel.updateState({basePatchNum: 1 as PatchSetNumber});
+ testResolver(changeViewModelToken).updateState({
+ basePatchNum: 1 as PatchSetNumber,
+ });
assert.equal(spy.callCount, 2);
assert.equal(spy.lastCall.firstArg, 1 as PatchSetNumber);
@@ -305,4 +426,28 @@ suite('change model tests', () => {
changeModel.updateStateChange(createParsedChange());
assert.equal(spy.callCount, 2);
});
+
+ test('revision$ selector latest', async () => {
+ changeViewModel.updateState({patchNum: undefined});
+ changeModel.updateState({change: knownChange});
+ await waitUntilObserved(changeModel.revision$, x => x?._number === 2);
+ });
+
+ test('revision$ selector 1', async () => {
+ changeViewModel.updateState({patchNum: 1 as PatchSetNumber});
+ changeModel.updateState({change: knownChange});
+ await waitUntilObserved(changeModel.revision$, x => x?._number === 1);
+ });
+
+ test('latestRevision$ selector latest', async () => {
+ changeViewModel.updateState({patchNum: undefined});
+ changeModel.updateState({change: knownChange});
+ await waitUntilObserved(changeModel.latestRevision$, x => x?._number === 2);
+ });
+
+ test('latestRevision$ selector 1', async () => {
+ changeViewModel.updateState({patchNum: 1 as PatchSetNumber});
+ changeModel.updateState({change: knownChange});
+ await waitUntilObserved(changeModel.latestRevision$, x => x?._number === 2);
+ });
});
diff --git a/polygerrit-ui/app/models/change/files-model.ts b/polygerrit-ui/app/models/change/files-model.ts
index 6922f6dce5..c01b718980 100644
--- a/polygerrit-ui/app/models/change/files-model.ts
+++ b/polygerrit-ui/app/models/change/files-model.ts
@@ -15,7 +15,6 @@ import {
import {combineLatest, of, from} from 'rxjs';
import {switchMap, map} from 'rxjs/operators';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
-import {Finalizable} from '../../services/registry';
import {select} from '../../utils/observable-util';
import {FileInfoStatus, SpecialFilePath} from '../../constants/constants';
import {specialFilePathCompare} from '../../utils/path-list-util';
@@ -23,7 +22,12 @@ import {Model} from '../model';
import {define} from '../dependency';
import {ChangeModel} from './change-model';
import {CommentsModel} from '../comments/comments-model';
+import {Timing} from '../../constants/reporting';
+import {ReportingService} from '../../services/gr-reporting/gr-reporting';
+export type FileNameToNormalizedFileInfoMap = {
+ [name: string]: NormalizedFileInfo;
+};
export interface NormalizedFileInfo extends FileInfo {
__path: string;
// Compared to `FileInfo` these four props are required here.
@@ -113,10 +117,15 @@ const initialState: FilesState = {
export const filesModelToken = define<FilesModel>('files-model');
-export class FilesModel extends Model<FilesState> implements Finalizable {
+export class FilesModel extends Model<FilesState> {
public readonly files$ = select(this.state$, state => state.files);
- public readonly filesWithUnmodified$ = select(
+ /**
+ * `files$` only includes the files that were modified. Here we also include
+ * 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)
);
@@ -134,10 +143,13 @@ export class FilesModel extends Model<FilesState> implements Finalizable {
constructor(
readonly changeModel: ChangeModel,
readonly commentsModel: CommentsModel,
- readonly restApiService: RestApiService
+ readonly restApiService: RestApiService,
+ private readonly reporting: ReportingService
) {
super(initialState);
this.subscriptions = [
+ this.reportChangeDataStart(),
+ this.reportChangeDataEnd(),
this.subscribeToFiles(
(psLeft, psRight) => {
return {basePatchNum: psLeft, patchNum: psRight};
@@ -148,7 +160,8 @@ export class FilesModel extends Model<FilesState> implements Finalizable {
),
this.subscribeToFiles(
(psLeft, _) => {
- if (psLeft === PARENT || psLeft <= 0) return undefined;
+ if (psLeft === PARENT || (psLeft as PatchSetNumber) <= 0)
+ return undefined;
return {basePatchNum: PARENT, patchNum: psLeft as PatchSetNumber};
},
files => {
@@ -157,7 +170,8 @@ export class FilesModel extends Model<FilesState> implements Finalizable {
),
this.subscribeToFiles(
(psLeft, psRight) => {
- if (psLeft === PARENT || psLeft <= 0) return undefined;
+ if (psLeft === PARENT || (psLeft as PatchSetNumber) <= 0)
+ return undefined;
return {basePatchNum: PARENT, patchNum: psRight as PatchSetNumber};
},
files => {
@@ -167,6 +181,26 @@ export class FilesModel extends Model<FilesState> implements Finalizable {
];
}
+ private reportChangeDataStart() {
+ return combineLatest([this.changeModel.loading$]).subscribe(
+ ([changeLoading]) => {
+ if (changeLoading) {
+ this.reporting.time(Timing.CHANGE_DATA);
+ }
+ }
+ );
+ }
+
+ private reportChangeDataEnd() {
+ return combineLatest([this.changeModel.loading$, this.files$]).subscribe(
+ ([changeLoading, files]) => {
+ if (!changeLoading && files.length > 0) {
+ this.reporting.timeEnd(Timing.CHANGE_DATA);
+ }
+ }
+ );
+ }
+
private subscribeToFiles(
rangeChooser: (
basePatchNum: BasePatchSetNum,
@@ -175,13 +209,12 @@ export class FilesModel extends Model<FilesState> implements Finalizable {
filesToState: (files: NormalizedFileInfo[]) => Partial<FilesState>
) {
return combineLatest([
- this.changeModel.reload$,
this.changeModel.changeNum$,
this.changeModel.basePatchNum$,
this.changeModel.patchNum$,
])
.pipe(
- switchMap(([_, changeNum, basePatchNum, patchNum]) => {
+ switchMap(([changeNum, basePatchNum, patchNum]) => {
if (!changeNum || !patchNum) return of({});
const range = rangeChooser(basePatchNum, patchNum);
if (!range) return of({});
diff --git a/polygerrit-ui/app/models/change/related-changes-model.ts b/polygerrit-ui/app/models/change/related-changes-model.ts
new file mode 100644
index 0000000000..6972ec8a47
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model.ts
@@ -0,0 +1,242 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {
+ ChangeInfo,
+ RelatedChangeAndCommitInfo,
+ SubmittedTogetherInfo,
+} from '../../types/common';
+import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {select} from '../../utils/observable-util';
+import {Model} from '../model';
+import {define} from '../dependency';
+import {ChangeModel} from './change-model';
+import {combineLatest, forkJoin, from, of} from 'rxjs';
+import {map, switchMap} from 'rxjs/operators';
+import {ConfigModel} from '../config/config-model';
+import {ChangeStatus} from '../../api/rest-api';
+import {isDefined} from '../../types/types';
+
+export interface RelatedChangesState {
+ /** `undefined` means "not yet loaded". */
+ relatedChanges?: RelatedChangeAndCommitInfo[];
+ submittedTogether?: SubmittedTogetherInfo;
+ cherryPicks?: ChangeInfo[];
+ conflictingChanges?: ChangeInfo[];
+ sameTopicChanges?: ChangeInfo[];
+ revertingChanges: ChangeInfo[];
+}
+
+const initialState: RelatedChangesState = {
+ relatedChanges: undefined,
+ submittedTogether: undefined,
+ cherryPicks: undefined,
+ conflictingChanges: undefined,
+ sameTopicChanges: undefined,
+ revertingChanges: [],
+};
+
+export const relatedChangesModelToken = define<RelatedChangesModel>(
+ 'related-changes-model'
+);
+
+export class RelatedChangesModel extends Model<RelatedChangesState> {
+ public readonly relatedChanges$ = select(
+ this.state$,
+ state => state.relatedChanges
+ );
+
+ public readonly submittedTogether$ = select(
+ this.state$,
+ state => state.submittedTogether
+ );
+
+ public readonly cherryPicks$ = select(
+ this.state$,
+ state => state.cherryPicks
+ );
+
+ public readonly conflictingChanges$ = select(
+ this.state$,
+ state => state.conflictingChanges
+ );
+
+ public readonly sameTopicChanges$ = select(
+ this.state$,
+ state => state.sameTopicChanges
+ );
+
+ /**
+ * Emits all changes that have reverted the current change, based on
+ * information from parsed change messages. Abandoned changes are not
+ * included.
+ */
+ public readonly revertingChanges$ = select(
+ this.state$,
+ state => state.revertingChanges
+ );
+
+ /**
+ * Emits one reverting change (if there is any) from revertingChanges$.
+ * It prefers MERGED changes. Otherwise the choice is random.
+ */
+ public readonly revertingChange$ = select(
+ this.revertingChanges$,
+ revertingChanges => {
+ if (revertingChanges.length === 0) return undefined;
+ const submittedRevert = revertingChanges.find(
+ c => c.status === ChangeStatus.MERGED
+ );
+ if (submittedRevert) return submittedRevert;
+ return revertingChanges[0];
+ }
+ );
+
+ /**
+ * Determines whether the change has a parent change. If there
+ * 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;
+ }
+ );
+
+ constructor(
+ readonly changeModel: ChangeModel,
+ readonly configModel: ConfigModel,
+ readonly restApiService: RestApiService
+ ) {
+ super(initialState);
+ this.subscriptions = [
+ this.loadRelatedChanges(),
+ this.loadSubmittedTogether(),
+ this.loadCherryPicks(),
+ this.loadConflictingChanges(),
+ this.loadSameTopicChanges(),
+ this.loadRevertingChanges(),
+ ];
+ }
+
+ private loadRelatedChanges() {
+ return combineLatest([
+ this.changeModel.changeNum$,
+ this.changeModel.latestPatchNum$,
+ ])
+ .pipe(
+ switchMap(([changeNum, latestPatchNum]) => {
+ if (!changeNum || !latestPatchNum) return of(undefined);
+ return from(
+ this.restApiService
+ .getRelatedChanges(changeNum, latestPatchNum)
+ .then(info => info?.changes ?? [])
+ );
+ })
+ )
+ .subscribe(relatedChanges => {
+ this.updateState({relatedChanges});
+ });
+ }
+
+ private loadSubmittedTogether() {
+ return this.changeModel.changeNum$
+ .pipe(
+ switchMap(changeNum => {
+ if (!changeNum) return of(undefined);
+ return from(
+ this.restApiService.getChangesSubmittedTogether(changeNum)
+ );
+ })
+ )
+ .subscribe(submittedTogether => {
+ this.updateState({submittedTogether});
+ });
+ }
+
+ private loadCherryPicks() {
+ return combineLatest([
+ this.changeModel.branch$,
+ this.changeModel.changeId$,
+ this.changeModel.repo$,
+ ])
+ .pipe(
+ switchMap(([branch, changeId, repo]) => {
+ if (!branch || !changeId || !repo) return of(undefined);
+ return from(
+ this.restApiService.getChangeCherryPicks(repo, changeId, branch)
+ );
+ })
+ )
+ .subscribe(cherryPicks => {
+ this.updateState({cherryPicks});
+ });
+ }
+
+ private loadConflictingChanges() {
+ return combineLatest([
+ this.changeModel.changeNum$,
+ this.changeModel.status$,
+ this.changeModel.mergeable$,
+ ])
+ .pipe(
+ switchMap(([changeNum, status, mergeable]) => {
+ if (!changeNum || !status || !mergeable) return of(undefined);
+ if (status !== ChangeStatus.NEW) return of(undefined);
+ return from(this.restApiService.getChangeConflicts(changeNum));
+ })
+ )
+ .subscribe(conflictingChanges => {
+ this.updateState({conflictingChanges});
+ });
+ }
+
+ private loadSameTopicChanges() {
+ return combineLatest([
+ this.changeModel.changeNum$,
+ this.changeModel.topic$,
+ this.configModel.serverConfig$,
+ ])
+ .pipe(
+ switchMap(([changeNum, topic, config]) => {
+ if (!changeNum || !topic || !config) return of(undefined);
+ if (config.change.submit_whole_topic) return of(undefined);
+ return from(
+ this.restApiService.getChangesWithSameTopic(topic, {
+ openChangesOnly: true,
+ changeToExclude: changeNum,
+ })
+ );
+ })
+ )
+ .subscribe(sameTopicChanges => {
+ this.updateState({sameTopicChanges});
+ });
+ }
+
+ private loadRevertingChanges() {
+ return this.changeModel.revertingChangeIds$
+ .pipe(
+ switchMap(changeIds => {
+ if (!changeIds?.length) return of([]);
+ return forkJoin(
+ changeIds.map(changeId =>
+ from(this.restApiService.getChange(changeId))
+ )
+ );
+ }),
+ map(changes => changes.filter(isDefined)),
+ map(changes => changes.filter(c => c.status !== ChangeStatus.ABANDONED))
+ )
+ .subscribe(revertingChanges => {
+ this.updateState({revertingChanges});
+ });
+ }
+}
diff --git a/polygerrit-ui/app/models/change/related-changes-model_test.ts b/polygerrit-ui/app/models/change/related-changes-model_test.ts
new file mode 100644
index 0000000000..295f28488f
--- /dev/null
+++ b/polygerrit-ui/app/models/change/related-changes-model_test.ts
@@ -0,0 +1,286 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {getAppContext} from '../../services/app-context';
+import {ChangeModel, changeModelToken} from '../change/change-model';
+import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {RelatedChangesModel} from './related-changes-model';
+import {configModelToken} from '../config/config-model';
+import {SinonStub} from 'sinon';
+import {
+ ChangeInfo,
+ RelatedChangesInfo,
+ SubmittedTogetherInfo,
+} from '../../types/common';
+import {stubRestApi, waitUntilObserved} from '../../test/test-utils';
+import {
+ createParsedChange,
+ createRelatedChangesInfo,
+ createRelatedChangeAndCommitInfo,
+ createChange,
+ createChangeMessage,
+} from '../../test/test-data-generators';
+import {ChangeStatus, ReviewInputTag, TopicName} from '../../api/rest-api';
+import {MessageTag} from '../../constants/constants';
+
+suite('related-changes-model tests', () => {
+ let model: RelatedChangesModel;
+ let changeModel: ChangeModel;
+
+ setup(async () => {
+ changeModel = testResolver(changeModelToken);
+ model = new RelatedChangesModel(
+ changeModel,
+ testResolver(configModelToken),
+ getAppContext().restApiService
+ );
+ await waitUntilObserved(changeModel.change$, c => c === undefined);
+ });
+
+ teardown(() => {
+ model.finalize();
+ });
+
+ test('register and fetch', async () => {
+ assert.equal('', '');
+ });
+
+ suite('related changes and hasParent', async () => {
+ let getRelatedChangesStub: SinonStub;
+ let getRelatedChangesResponse: RelatedChangesInfo;
+ let hasParent: boolean | undefined;
+
+ setup(() => {
+ getRelatedChangesStub = stubRestApi('getRelatedChanges').callsFake(() =>
+ Promise.resolve(getRelatedChangesResponse)
+ );
+ model.hasParent$.subscribe(x => (hasParent = x));
+ });
+
+ test('relatedChanges initially undefined', async () => {
+ await waitUntilObserved(
+ model.relatedChanges$,
+ relatedChanges => relatedChanges === undefined
+ );
+ assert.isFalse(getRelatedChangesStub.called);
+ assert.isUndefined(hasParent);
+ });
+
+ test('relatedChanges loading empty', async () => {
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.relatedChanges$,
+ relatedChanges => relatedChanges?.length === 0
+ );
+ assert.isTrue(getRelatedChangesStub.calledOnce);
+ assert.isFalse(hasParent);
+ });
+
+ test('relatedChanges loading one change', async () => {
+ getRelatedChangesResponse = {
+ ...createRelatedChangesInfo(),
+ changes: [createRelatedChangeAndCommitInfo()],
+ };
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.relatedChanges$,
+ relatedChanges => relatedChanges?.length === 1
+ );
+ assert.isTrue(getRelatedChangesStub.calledOnce);
+ assert.isTrue(hasParent);
+ });
+ });
+
+ suite('loadSubmittedTogether', async () => {
+ let getChangesSubmittedTogetherStub: SinonStub;
+ let getChangesSubmittedTogetherResponse: SubmittedTogetherInfo;
+
+ setup(() => {
+ getChangesSubmittedTogetherStub = stubRestApi(
+ 'getChangesSubmittedTogether'
+ ).callsFake(() => Promise.resolve(getChangesSubmittedTogetherResponse));
+ });
+
+ test('submittedTogether initially undefined', async () => {
+ await waitUntilObserved(
+ model.submittedTogether$,
+ submittedTogether => submittedTogether === undefined
+ );
+ assert.isFalse(getChangesSubmittedTogetherStub.called);
+ });
+
+ test('submittedTogether emits', async () => {
+ getChangesSubmittedTogetherResponse = {
+ changes: [createChange()],
+ non_visible_changes: 0,
+ };
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.submittedTogether$,
+ submittedTogether => submittedTogether?.changes?.length === 1
+ );
+ assert.isTrue(getChangesSubmittedTogetherStub.calledOnce);
+ });
+ });
+
+ suite('loadCherryPicks', async () => {
+ let getChangeCherryPicksStub: SinonStub;
+ let getChangeCherryPicksResponse: ChangeInfo[];
+
+ setup(() => {
+ getChangeCherryPicksStub = stubRestApi('getChangeCherryPicks').callsFake(
+ () => Promise.resolve(getChangeCherryPicksResponse)
+ );
+ });
+
+ test('cherryPicks initially undefined', async () => {
+ await waitUntilObserved(
+ model.cherryPicks$,
+ cherryPicks => cherryPicks === undefined
+ );
+ assert.isFalse(getChangeCherryPicksStub.called);
+ });
+
+ test('cherryPicks emits', async () => {
+ getChangeCherryPicksResponse = [createChange()];
+ changeModel.updateStateChange({...createParsedChange()});
+
+ await waitUntilObserved(
+ model.cherryPicks$,
+ cherryPicks => cherryPicks?.length === 1
+ );
+ assert.isTrue(getChangeCherryPicksStub.calledOnce);
+ });
+ });
+
+ suite('loadConflictingChanges', async () => {
+ let getChangeConflictsStub: SinonStub;
+ let getChangeConflictsResponse: ChangeInfo[];
+
+ setup(() => {
+ getChangeConflictsStub = stubRestApi('getChangeConflicts').callsFake(() =>
+ Promise.resolve(getChangeConflictsResponse)
+ );
+ });
+
+ test('conflictingChanges initially undefined', async () => {
+ await waitUntilObserved(
+ model.conflictingChanges$,
+ conflictingChanges => conflictingChanges === undefined
+ );
+ assert.isFalse(getChangeConflictsStub.called);
+ });
+
+ test('conflictingChanges not loaded for merged changes', async () => {
+ getChangeConflictsResponse = [createChange()];
+ changeModel.updateStateChange({
+ ...createParsedChange(),
+ mergeable: true,
+ status: ChangeStatus.MERGED,
+ });
+
+ await waitUntilObserved(
+ model.conflictingChanges$,
+ conflictingChanges => conflictingChanges === undefined
+ );
+ assert.isFalse(getChangeConflictsStub.called);
+ });
+
+ test('conflictingChanges emits', async () => {
+ getChangeConflictsResponse = [createChange()];
+ changeModel.updateStateChange({...createParsedChange(), mergeable: true});
+
+ await waitUntilObserved(
+ model.conflictingChanges$,
+ conflictingChanges => conflictingChanges?.length === 1
+ );
+ assert.isTrue(getChangeConflictsStub.calledOnce);
+ });
+ });
+
+ suite('loadSameTopicChanges', async () => {
+ let getChangesWithSameTopicStub: SinonStub;
+ let getChangesWithSameTopicResponse: ChangeInfo[];
+
+ setup(() => {
+ getChangesWithSameTopicStub = stubRestApi(
+ 'getChangesWithSameTopic'
+ ).callsFake(() => Promise.resolve(getChangesWithSameTopicResponse));
+ });
+
+ test('sameTopicChanges initially undefined', async () => {
+ await waitUntilObserved(
+ model.sameTopicChanges$,
+ sameTopicChanges => sameTopicChanges === undefined
+ );
+ assert.isFalse(getChangesWithSameTopicStub.called);
+ });
+
+ test('sameTopicChanges emits', async () => {
+ getChangesWithSameTopicResponse = [createChange()];
+ changeModel.updateStateChange({
+ ...createParsedChange(),
+ topic: 'test-topic' as TopicName,
+ });
+
+ await waitUntilObserved(
+ model.sameTopicChanges$,
+ sameTopicChanges => sameTopicChanges?.length === 1
+ );
+ assert.isTrue(getChangesWithSameTopicStub.calledOnce);
+ });
+ });
+
+ suite('loadRevertingChanges', async () => {
+ let getChangeStub: SinonStub;
+
+ setup(() => {
+ getChangeStub = stubRestApi('getChange').callsFake(() =>
+ Promise.resolve(createChange())
+ );
+ });
+
+ test('revertingChanges initially empty', async () => {
+ await waitUntilObserved(
+ model.revertingChanges$,
+ revertingChanges => revertingChanges.length === 0
+ );
+ assert.isFalse(getChangeStub.called);
+ });
+
+ test('revertingChanges empty when change does not contain a revert message', async () => {
+ changeModel.updateStateChange(createParsedChange());
+ await waitUntilObserved(
+ model.revertingChanges$,
+ revertingChanges => revertingChanges.length === 0
+ );
+ assert.isFalse(getChangeStub.called);
+ });
+
+ test('revertingChanges emits', async () => {
+ changeModel.updateStateChange({
+ ...createParsedChange(),
+ messages: [
+ {
+ ...createChangeMessage(),
+ message: 'Created a revert of this change as 123',
+ tag: MessageTag.TAG_REVERT as ReviewInputTag,
+ },
+ ],
+ });
+
+ await waitUntilObserved(
+ model.revertingChanges$,
+ revertingChanges => revertingChanges?.length === 1
+ );
+ assert.isTrue(getChangeStub.calledOnce);
+ });
+ });
+});
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts
index 6b35056955..25785e446c 100644
--- a/polygerrit-ui/app/models/checks/checks-model.ts
+++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -12,7 +12,6 @@ import {
} from './checks-util';
import {assertIsDefined} from '../../utils/common-util';
import {select} from '../../utils/observable-util';
-import {Finalizable} from '../../services/registry';
import {
BehaviorSubject,
combineLatest,
@@ -52,8 +51,7 @@ import {getCurrentRevision} from '../../utils/change-util';
import {getShaByPatchNum} from '../../utils/patch-set-util';
import {ReportingService} from '../../services/gr-reporting/gr-reporting';
import {Execution, Interaction, Timing} from '../../constants/reporting';
-import {fireAlert, fireEvent} from '../../utils/event-util';
-import {RouterModel} from '../../services/router/router-model';
+import {fireAlert, fire} from '../../utils/event-util';
import {Model} from '../model';
import {define} from '../dependency';
import {
@@ -110,9 +108,30 @@ export interface CheckRun extends CheckRunApi {
}
// This is a convenience type for working with results, because when working
-// with a bunch of results you will typically also want to know about the run
-// properties. So you can just combine them with {...run, ...result}.
-export type RunResult = CheckRun & CheckResult;
+// with a bunch of results you will typically also want to know about some run
+// properties.
+// Note that you don't want to just spread the entire run object, because you
+// definitely don't want the `results` property in the RunResult object.
+// Use the `runResult()` function below for creating `RunResult` objects.
+export type RunResult = CheckResult &
+ Pick<CheckRun, 'pluginName'> &
+ Pick<CheckRun, 'attempt'> &
+ Pick<CheckRun, 'patchset'> &
+ Pick<CheckRun, 'isLatestAttempt'> &
+ Pick<CheckRun, 'checkName'> &
+ Pick<CheckRun, 'labelName'> & {results?: never};
+
+export function runResult(run: CheckRun, result: CheckResult): RunResult {
+ return {
+ pluginName: run.pluginName,
+ attempt: run.attempt,
+ patchset: run.patchset,
+ isLatestAttempt: run.isLatestAttempt,
+ checkName: run.checkName,
+ labelName: run.labelName,
+ ...result,
+ };
+}
export const checksModelToken = define<ChecksModel>('checks-model');
@@ -161,17 +180,15 @@ const FETCH_RESULT_TIMEOUT_MS = 16000;
* Can be used in `reduce()` to collect all results from all runs from all
* providers into one array.
*/
-function collectRunResults(
+export function collectRunResults(
allResults: RunResult[],
providerState: ChecksProviderState
-) {
+): RunResult[] {
return [
...allResults,
...providerState.runs.reduce((results: RunResult[], run: CheckRun) => {
const runResults: RunResult[] =
- run.results?.map(r => {
- return {...run, ...r};
- }) ?? [];
+ run.results?.map(r => runResult(run, r)) ?? [];
return results.concat(runResults ?? []);
}, []),
];
@@ -182,7 +199,7 @@ export interface ErrorMessages {
[name: string]: string;
}
-export class ChecksModel extends Model<ChecksState> implements Finalizable {
+export class ChecksModel extends Model<ChecksState> {
private readonly providers: {[name: string]: ChecksProvider} = {};
private readonly reloadSubjects: {[name: string]: Subject<void>} = {};
@@ -197,8 +214,6 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
- private readonly reloadListener: () => void;
-
private readonly visibilityChangeListener: () => void;
public checksSelectedPatchsetNumber$ = select(
@@ -374,11 +389,10 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
);
constructor(
- readonly routerModel: RouterModel,
- readonly changeViewModel: ChangeViewModel,
- readonly changeModel: ChangeModel,
- readonly reporting: ReportingService,
- readonly pluginsModel: PluginsModel
+ private readonly changeViewModel: ChangeViewModel,
+ private readonly changeModel: ChangeModel,
+ private readonly reporting: ReportingService,
+ private readonly pluginsModel: PluginsModel
) {
super({
pluginStateLatest: {},
@@ -417,8 +431,6 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
'visibilitychange',
this.visibilityChangeListener
);
- this.reloadListener = () => this.reloadAll();
- document.addEventListener('reload', this.reloadListener);
}
private reportStats(state: {[name: string]: ChecksProviderState}) {
@@ -462,7 +474,6 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
}
override finalize() {
- document.removeEventListener('reload', this.reloadListener);
document.removeEventListener(
'visibilitychange',
this.visibilityChangeListener
@@ -637,8 +648,15 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
}
updateStateSetPatchset(num?: PatchSetNumber) {
+ const newPatchset = num === this.latestPatchNum ? undefined : num;
+ const oldPatchset = this.changeViewModel.getState()?.checksPatchset;
+ // For `checksPatchset` itself we could just let updateState() do the
+ // standard old===new comparison. But we have to make sure here that
+ // the attempt reset only actually happens when a new patchset is chosen.
+ if (newPatchset === oldPatchset) return;
this.changeViewModel.updateState({
- checksPatchset: num === this.latestPatchNum ? undefined : num,
+ checksPatchset: newPatchset,
+ attempt: LATEST_ATTEMPT,
});
}
@@ -682,7 +700,11 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
);
}
- triggerAction(action: Action, run: CheckRun | undefined, context: string) {
+ triggerAction(
+ action: Action,
+ run: CheckRun | RunResult | undefined,
+ context: string
+ ) {
if (!action?.callback) return;
if (!this.changeNum) return;
const patchSet = run?.patchset ?? this.latestPatchNum;
@@ -712,7 +734,7 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
if (result.errorMessage || result.message) {
fireAlert(document, `${result.message ?? result.errorMessage}`);
} else {
- fireEvent(document, 'hide-alert');
+ fire(document, 'hide-alert', {});
}
if (result.shouldReload) {
this.reloadForCheck(run?.checkName);
@@ -753,15 +775,14 @@ export class ChecksModel extends Model<ChecksState> implements Finalizable {
patchset === ChecksPatchset.LATEST
? this.changeModel.latestPatchNum$
: this.checksSelectedPatchsetNumber$,
- this.reloadSubjects[pluginName].pipe(
- throttleTime(1000, undefined, {trailing: true, leading: true})
- ),
+ this.reloadSubjects[pluginName],
pollIntervalMs === 0 ? from([0]) : timer(0, pollIntervalMs),
this.documentVisibilityChange$,
])
.pipe(
takeWhile(_ => !!this.providers[pluginName]),
filter(_ => document.visibilityState !== 'hidden'),
+ throttleTime(500, undefined, {leading: true, trailing: true}),
switchMap(([change, patchNum]): Observable<FetchResponse> => {
if (!change || !patchNum) return of(this.empty());
if (typeof patchNum !== 'number') return of(this.empty());
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts
index 3489c5a12b..c8fd37a85b 100644
--- a/polygerrit-ui/app/models/checks/checks-model_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -5,7 +5,14 @@
*/
import '../../test/common-test-setup';
import './checks-model';
-import {ChecksModel, ChecksPatchset, ChecksProviderState} from './checks-model';
+import {
+ CheckResult,
+ ChecksModel,
+ ChecksPatchset,
+ ChecksProviderState,
+ RunResult,
+ collectRunResults,
+} from './checks-model';
import {
Action,
Category,
@@ -16,7 +23,11 @@ import {
RunStatus,
} from '../../api/checks';
import {getAppContext} from '../../services/app-context';
-import {createParsedChange} from '../../test/test-data-generators';
+import {
+ createCheckResult,
+ createParsedChange,
+ createRun,
+} from '../../test/test-data-generators';
import {waitUntil, waitUntilCalled} from '../../test/test-utils';
import {ParsedChangeInfo} from '../../types/types';
import {changeModelToken} from '../change/change-model';
@@ -24,6 +35,7 @@ import {assert} from '@open-wc/testing';
import {testResolver} from '../../test/common-test-setup';
import {changeViewModelToken} from '../views/change';
import {NumericChangeId, PatchSetNumber} from '../../api/rest-api';
+import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader';
const PLUGIN_NAME = 'test-plugin';
@@ -69,11 +81,10 @@ suite('checks-model tests', () => {
setup(() => {
model = new ChecksModel(
- getAppContext().routerModel,
testResolver(changeViewModelToken),
testResolver(changeModelToken),
getAppContext().reportingService,
- getAppContext().pluginsModel
+ testResolver(pluginLoaderToken).pluginsModel
);
model.checksLatest$.subscribe(c => (current = c[PLUGIN_NAME]));
});
@@ -84,7 +95,7 @@ suite('checks-model tests', () => {
test('register and fetch', async () => {
let change: ParsedChangeInfo | undefined = undefined;
- model.changeModel.change$.subscribe(c => (change = c));
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
const provider = createProvider();
const fetchSpy = sinon.spy(provider, 'fetch');
@@ -96,7 +107,7 @@ suite('checks-model tests', () => {
await waitUntil(() => change === undefined);
const testChange = createParsedChange();
- model.changeModel.updateStateChange(testChange);
+ testResolver(changeModelToken).updateStateChange(testChange);
await waitUntil(() => change === testChange);
await waitUntilCalled(fetchSpy, 'fetch');
@@ -108,10 +119,10 @@ suite('checks-model tests', () => {
assert.equal(model.changeNum, testChange._number);
});
- test('reload throttle', async () => {
+ test('fetch throttle', async () => {
const clock = sinon.useFakeTimers();
let change: ParsedChangeInfo | undefined = undefined;
- model.changeModel.change$.subscribe(c => (change = c));
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
const provider = createProvider();
const fetchSpy = sinon.spy(provider, 'fetch');
@@ -123,18 +134,33 @@ suite('checks-model tests', () => {
await waitUntil(() => change === undefined);
const testChange = createParsedChange();
- model.changeModel.updateStateChange(testChange);
+ testResolver(changeModelToken).updateStateChange(testChange);
await waitUntil(() => change === testChange);
- clock.tick(1);
- assert.equal(fetchSpy.callCount, 1);
- // The second reload call will be processed, but only after a 1s throttle.
model.reload('test-plugin');
- clock.tick(100);
+ model.reload('test-plugin');
+ model.reload('test-plugin');
+
+ // Does not emit at 'leading' of throttle interval,
+ // because fetch() is not called when change is undefined.
+ assert.equal(fetchSpy.callCount, 0);
+
+ // 600 ms is greater than the 500 ms throttle time.
+ clock.tick(600);
+ // emits at 'trailing' of throttle interval
assert.equal(fetchSpy.callCount, 1);
- // 2000 ms is greater than the 1000 ms throttle time.
- clock.tick(2000);
+
+ model.reload('test-plugin');
+ model.reload('test-plugin');
+ model.reload('test-plugin');
+ model.reload('test-plugin');
+ // emits at 'leading' of throttle interval
assert.equal(fetchSpy.callCount, 2);
+
+ // 600 ms is greater than the 500 ms throttle time.
+ clock.tick(600);
+ // emits at 'trailing' of throttle interval
+ assert.equal(fetchSpy.callCount, 3);
});
test('triggerAction', async () => {
@@ -268,7 +294,7 @@ suite('checks-model tests', () => {
test('polls for changes', async () => {
const clock = sinon.useFakeTimers();
let change: ParsedChangeInfo | undefined = undefined;
- model.changeModel.change$.subscribe(c => (change = c));
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
const provider = createProvider();
const fetchSpy = sinon.spy(provider, 'fetch');
@@ -280,10 +306,10 @@ suite('checks-model tests', () => {
await waitUntil(() => change === undefined);
clock.tick(1);
const testChange = createParsedChange();
- model.changeModel.updateStateChange(testChange);
+ testResolver(changeModelToken).updateStateChange(testChange);
await waitUntil(() => change === testChange);
+ clock.tick(600); // need to wait for 500ms throttle
await waitUntilCalled(fetchSpy, 'fetch');
- clock.tick(1);
const pollCount = fetchSpy.callCount;
// polling should continue while we wait
@@ -295,7 +321,7 @@ suite('checks-model tests', () => {
test('does not poll when config specifies 0 seconds', async () => {
const clock = sinon.useFakeTimers();
let change: ParsedChangeInfo | undefined = undefined;
- model.changeModel.change$.subscribe(c => (change = c));
+ testResolver(changeModelToken).change$.subscribe(c => (change = c));
const provider = createProvider();
const fetchSpy = sinon.spy(provider, 'fetch');
@@ -307,8 +333,9 @@ suite('checks-model tests', () => {
await waitUntil(() => change === undefined);
clock.tick(1);
const testChange = createParsedChange();
- model.changeModel.updateStateChange(testChange);
+ testResolver(changeModelToken).updateStateChange(testChange);
await waitUntil(() => change === testChange);
+ clock.tick(600); // need to wait for 500ms throttle
await waitUntilCalled(fetchSpy, 'fetch');
clock.tick(1);
const pollCount = fetchSpy.callCount;
@@ -318,4 +345,24 @@ suite('checks-model tests', () => {
assert.equal(fetchSpy.callCount, pollCount);
});
+
+ test('collectRunResults does not incur quadratic size increase', async () => {
+ const results: CheckResult[] = [];
+ for (let i = 0; i < 100; i++) {
+ results.push({
+ ...createCheckResult({
+ message: 'some message',
+ }),
+ });
+ }
+ const run = createRun({results});
+ let collected: RunResult[] = [];
+ collected = collectRunResults(collected, {
+ runs: [run],
+ } as ChecksProviderState);
+ const collectedString = JSON.stringify(collected);
+ // If the `results` property would not be removed from every check run, then
+ // this combined string would be >1MB in size.
+ assert.isAtMost(collectedString.length, 50000);
+ });
});
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts
index 7ccdf9183a..026e5e5833 100644
--- a/polygerrit-ui/app/models/checks/checks-util.ts
+++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -14,12 +14,17 @@ import {
Replacement,
RunStatus,
} from '../../api/checks';
-import {PatchSetNumber} from '../../api/rest-api';
-import {FixSuggestionInfo, FixReplacementInfo} from '../../types/common';
+import {PatchSetNumber, RevisionPatchSetNum} from '../../api/rest-api';
+import {CommentSide} from '../../constants/constants';
+import {
+ FixSuggestionInfo,
+ FixReplacementInfo,
+ DraftInfo,
+} from '../../types/common';
import {OpenFixPreviewEventDetail} from '../../types/events';
-import {notUndefined} from '../../types/types';
-import {PROVIDED_FIX_ID} from '../../utils/comment-util';
-import {assert, assertNever} from '../../utils/common-util';
+import {isDefined} from '../../types/types';
+import {PROVIDED_FIX_ID, createNew} from '../../utils/comment-util';
+import {assert, assertIsDefined, assertNever} from '../../utils/common-util';
import {fire} from '../../utils/event-util';
import {CheckResult, CheckRun, RunResult} from './checks-model';
@@ -86,6 +91,25 @@ export function tooltipForLink(linkIcon?: LinkIcon) {
}
}
+function pleaseFixMessage(result: RunResult) {
+ return `Please fix this ${result.category} reported by ${result.checkName}: ${result.summary}
+
+${result.message}`;
+}
+
+export function createPleaseFixComment(result: RunResult): DraftInfo {
+ const pointer = result.codePointers?.[0];
+ assertIsDefined(pointer, 'codePointer');
+ return {
+ ...createNew(pleaseFixMessage(result), true),
+ path: pointer.path,
+ patch_set: result.patchset as RevisionPatchSetNum,
+ side: CommentSide.REVISION,
+ line: pointer.range.end_line ?? pointer.range.start_line,
+ range: pointer.range,
+ };
+}
+
export function createFixAction(
target: EventTarget,
result?: RunResult
@@ -94,11 +118,12 @@ export function createFixAction(
if (!result?.fixes) return;
const fixSuggestions = result.fixes
.map(f => rectifyFix(f, result?.checkName))
- .filter(notUndefined);
+ .filter(isDefined);
if (fixSuggestions.length === 0) return;
const eventDetail: OpenFixPreviewEventDetail = {
patchNum: result.patchset as PatchSetNumber,
fixSuggestions,
+ onCloseFixPreviewCallbacks: [],
};
return {
name: 'Show Fix',
@@ -116,7 +141,7 @@ export function rectifyFix(
if (!fix?.replacements) return undefined;
const replacements = fix.replacements
.map(rectifyReplacement)
- .filter(notUndefined);
+ .filter(isDefined);
if (replacements.length === 0) return undefined;
return {
@@ -445,7 +470,11 @@ export function createAttemptMap(runs: CheckRunApi[]) {
);
}
value.isSingleAttempt = false;
- if (run.attempt > value.latestAttempt) {
+ if (
+ value.latestAttempt !== 'all' &&
+ value.latestAttempt !== 'latest' &&
+ run.attempt > value.latestAttempt
+ ) {
value.latestAttempt = run.attempt;
}
value.attempts.push(detail);
diff --git a/polygerrit-ui/app/models/checks/checks-util_test.ts b/polygerrit-ui/app/models/checks/checks-util_test.ts
index c237c59b53..822435d47c 100644
--- a/polygerrit-ui/app/models/checks/checks-util_test.ts
+++ b/polygerrit-ui/app/models/checks/checks-util_test.ts
@@ -15,8 +15,8 @@ import {
stringToAttemptChoice,
} from './checks-util';
import {Fix, Replacement} from '../../api/checks';
-import {CommentRange} from '../../api/core';
import {PROVIDED_FIX_ID} from '../../utils/comment-util';
+import {CommentRange} from '../../api/rest-api';
suite('checks-util tests', () => {
setup(() => {});
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index b0ad4179ca..eca8b7c529 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -5,45 +5,46 @@
*/
import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
import {
- CommentBasics,
CommentInfo,
NumericChangeId,
PatchSetNum,
RevisionId,
UrlEncodedCommentId,
- PathToCommentsInfoMap,
RobotCommentInfo,
PathToRobotCommentsInfoMap,
AccountInfo,
+ DraftInfo,
+ Comment,
+ SavingState,
+ isSaving,
+ isError,
+ isDraft,
+ isNew,
} from '../../types/common';
import {
addPath,
- DraftInfo,
- isDraft,
+ createNew,
+ createNewPatchsetLevel,
+ id,
isDraftThread,
- isUnsaved,
+ isNewThread,
reportingDetails,
- UnsavedInfo,
} from '../../utils/comment-util';
import {deepEqual} from '../../utils/deep-util';
import {select} from '../../utils/observable-util';
-import {RouterModel} from '../../services/router/router-model';
-import {Finalizable} from '../../services/registry';
import {define} from '../dependency';
import {combineLatest, forkJoin, from, Observable, of} from 'rxjs';
-import {fire, fireAlert, fireEvent} from '../../utils/event-util';
+import {fire, fireAlert} from '../../utils/event-util';
import {CURRENT} from '../../utils/patch-set-util';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {ChangeModel} from '../change/change-model';
import {Interaction, Timing} from '../../constants/reporting';
-import {assertIsDefined} from '../../utils/common-util';
+import {assert, assertIsDefined} from '../../utils/common-util';
import {debounce, DelayedTask} from '../../utils/async-util';
-import {pluralize} from '../../utils/string-util';
import {ReportingService} from '../../services/gr-reporting/gr-reporting';
import {Model} from '../model';
import {Deduping} from '../../api/reporting';
import {extractMentionedUsers, getUserId} from '../../utils/account-util';
-import {EventType} from '../../types/events';
import {SpecialFilePath} from '../../constants/constants';
import {AccountsModel} from '../accounts-model/accounts-model';
import {
@@ -52,24 +53,24 @@ import {
shareReplay,
switchMap,
} from 'rxjs/operators';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
+import {ChangeViewModel} from '../views/change';
+import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
export interface CommentState {
/** undefined means 'still loading' */
- comments?: PathToCommentsInfoMap;
+ comments?: {[path: string]: CommentInfo[]};
/** undefined means 'still loading' */
robotComments?: {[path: string]: RobotCommentInfo[]};
- // All drafts are DraftInfo objects and have __draft = true set.
- // Drafts have an id and are known to the backend. Unsaved drafts
- // (see UnsavedInfo) do NOT belong in the application model.
+ // All drafts are DraftInfo objects and have `state` state set.
/** undefined means 'still loading' */
drafts?: {[path: string]: DraftInfo[]};
// Ported comments only affect `CommentThread` properties, not individual
// comments.
/** undefined means 'still loading' */
- portedComments?: PathToCommentsInfoMap;
+ portedComments?: {[path: string]: CommentInfo[]};
/** undefined means 'still loading' */
- portedDrafts?: PathToCommentsInfoMap;
+ portedDrafts?: {[path: string]: DraftInfo[]};
/**
* If a draft is discarded by the user, then we temporarily keep it in this
* array in case the user decides to Undo the discard operation and bring the
@@ -96,7 +97,7 @@ function getSavingMessage(numPending: number, requestFailed?: boolean) {
if (numPending === 0) {
return 'All changes saved';
}
- return `Saving ${pluralize(numPending, 'draft')}...`;
+ return undefined;
}
// Private but used in tests.
@@ -112,6 +113,35 @@ export function setComments(
return nextState;
}
+/** Updates a single comment in a state. */
+export function updateComment(
+ state: CommentState,
+ comment: CommentInfo
+): CommentState {
+ if (!comment.path || !state.comments) {
+ return state;
+ }
+ const newCommentsAtPath = [...state.comments[comment.path]];
+ for (let i = 0; i < newCommentsAtPath.length; ++i) {
+ if (newCommentsAtPath[i].id === comment.id) {
+ // TODO: In "delete comment" the returned comment is missing some of the
+ // fields (for example patch_set), which would throw errors when
+ // rendering. Remove merging with the old comment, once that is fixed in
+ // server code.
+ newCommentsAtPath[i] = {...newCommentsAtPath[i], ...comment};
+
+ return {
+ ...state,
+ comments: {
+ ...state.comments,
+ [comment.path]: newCommentsAtPath,
+ },
+ };
+ }
+ }
+ throw new Error('Comment to be updated does not exist');
+}
+
// Private but used in tests.
export function setRobotComments(
state: CommentState,
@@ -139,7 +169,7 @@ export function setDrafts(
// Private but used in tests.
export function setPortedComments(
state: CommentState,
- portedComments?: PathToCommentsInfoMap
+ portedComments?: {[path: string]: CommentInfo[]}
): CommentState {
if (deepEqual(portedComments, state.portedComments)) return state;
const nextState = {...state};
@@ -150,7 +180,7 @@ export function setPortedComments(
// Private but used in tests.
export function setPortedDrafts(
state: CommentState,
- portedDrafts?: PathToCommentsInfoMap
+ portedDrafts?: {[path: string]: DraftInfo[]}
): CommentState {
if (deepEqual(portedDrafts, state.portedDrafts)) return state;
const nextState = {...state};
@@ -175,7 +205,7 @@ export function deleteDiscardedDraft(
): CommentState {
const nextState = {...state};
const drafts = [...nextState.discardedDrafts];
- const index = drafts.findIndex(d => d.id === draftID);
+ const index = drafts.findIndex(draft => id(draft) === draftID);
if (index === -1) {
throw new Error('discarded draft not found');
}
@@ -187,15 +217,14 @@ export function deleteDiscardedDraft(
/** Adds or updates a draft. */
export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
const nextState = {...state};
- if (!draft.path) throw new Error('draft path undefined');
- if (!isDraft(draft)) throw new Error('draft is not a draft');
- if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+ assert(!!draft.path, 'draft without path');
+ assert(isDraft(draft), 'draft is not a draft');
nextState.drafts = {...nextState.drafts};
const drafts = nextState.drafts;
if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
else drafts[draft.path] = [...drafts[draft.path]];
- const index = drafts[draft.path].findIndex(d => d.id && d.id === draft.id);
+ const index = drafts[draft.path].findIndex(d => id(d) === id(draft));
if (index !== -1) {
drafts[draft.path][index] = draft;
} else {
@@ -209,14 +238,12 @@ export function deleteDraft(
draft: DraftInfo
): CommentState {
const nextState = {...state};
- if (!draft.path) throw new Error('draft path undefined');
- if (!isDraft(draft)) throw new Error('draft is not a draft');
- if (isUnsaved(draft)) throw new Error('unsaved drafts dont belong to model');
+ assert(!!draft.path, 'draft without path');
+ assert(isDraft(draft), 'draft is not a draft');
+
nextState.drafts = {...nextState.drafts};
const drafts = nextState.drafts;
- const index = (drafts[draft.path] || []).findIndex(
- d => d.id && d.id === draft.id
- );
+ const index = (drafts[draft.path] || []).findIndex(d => id(d) === id(draft));
if (index === -1) return state;
const discardedDraft = drafts[draft.path][index];
drafts[draft.path] = [...drafts[draft.path]];
@@ -225,7 +252,7 @@ export function deleteDraft(
}
export const commentsModelToken = define<CommentsModel>('comments-model');
-export class CommentsModel extends Model<CommentState> implements Finalizable {
+export class CommentsModel extends Model<CommentState> {
public readonly commentsLoading$ = select(
this.state$,
commentState =>
@@ -254,9 +281,22 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
commentState => commentState.drafts
);
- public readonly draftsCount$ = select(
+ public readonly draftsLoading$ = select(
this.drafts$,
- drafts => Object.values(drafts ?? {}).flat().length
+ drafts => drafts === undefined
+ );
+
+ public readonly draftsArray$ = select(this.drafts$, drafts =>
+ Object.values(drafts ?? {}).flat()
+ );
+
+ public readonly draftsSaved$ = select(this.draftsArray$, drafts =>
+ drafts.filter(d => !isNew(d))
+ );
+
+ public readonly draftsCount$ = select(
+ this.draftsSaved$,
+ drafts => drafts.length
);
public readonly portedComments$ = select(
@@ -269,21 +309,26 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
commentState => commentState.discardedDrafts
);
- public readonly patchsetLevelDrafts$ = select(this.drafts$, drafts =>
- Object.values(drafts ?? {})
- .flat()
- .filter(
- draft =>
- draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
- !draft.in_reply_to
- )
+ public readonly savingInProgress$ = select(this.draftsArray$, drafts =>
+ drafts.some(isSaving)
+ );
+
+ public readonly savingError$ = select(this.draftsArray$, drafts =>
+ drafts.some(isError)
+ );
+
+ public readonly patchsetLevelDrafts$ = select(this.draftsArray$, drafts =>
+ drafts.filter(
+ draft =>
+ draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
+ !draft.in_reply_to
+ )
);
public readonly mentionedUsersInDrafts$: Observable<AccountInfo[]> =
- this.drafts$.pipe(
- switchMap(drafts => {
+ this.draftsArray$.pipe(
+ switchMap(comments => {
const users: AccountInfo[] = [];
- const comments = Object.values(drafts ?? {}).flat();
for (const comment of comments) {
users.push(...extractMentionedUsers(comment.message));
}
@@ -299,18 +344,16 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
return forkJoin(filledUsers$);
}),
- map(users => users.filter(notUndefined)),
+ map(users => users.filter(isDefined)),
distinctUntilChanged(deepEqual),
shareReplay(1)
);
public readonly mentionedUsersInUnresolvedDrafts$: Observable<AccountInfo[]> =
- this.drafts$.pipe(
+ this.draftsArray$.pipe(
switchMap(drafts => {
const users: AccountInfo[] = [];
- const comments = Object.values(drafts ?? {})
- .flat()
- .filter(c => c.unresolved);
+ const comments = drafts.filter(c => c.unresolved);
for (const comment of comments) {
users.push(...extractMentionedUsers(comment.message));
}
@@ -326,7 +369,7 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
return forkJoin(filledUsers$);
}),
- map(users => users.filter(notUndefined)),
+ map(users => users.filter(isDefined)),
distinctUntilChanged(deepEqual),
shareReplay(1)
);
@@ -349,8 +392,12 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
changeComments.getAllThreadsForChange()
);
- public readonly draftThreads$ = select(this.threads$, threads =>
- threads.filter(isDraftThread)
+ public readonly threadsSaved$ = select(this.threads$, threads =>
+ threads.filter(t => !isNewThread(t))
+ );
+
+ public readonly draftThreadsSaved$ = select(this.threads$, threads =>
+ threads.filter(t => !isNewThread(t) && isDraftThread(t))
);
public readonly commentedPaths$ = select(
@@ -376,8 +423,6 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
private patchNum?: PatchSetNum;
- private readonly reloadListener: () => void;
-
private drafts: {[path: string]: DraftInfo[]} = {};
private draftToastTask?: DelayedTask;
@@ -385,14 +430,33 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
private discardedDrafts: DraftInfo[] = [];
constructor(
- readonly routerModel: RouterModel,
- readonly changeModel: ChangeModel,
- readonly accountsModel: AccountsModel,
- readonly restApiService: RestApiService,
- readonly reporting: ReportingService
+ private readonly changeViewModel: ChangeViewModel,
+ private readonly changeModel: ChangeModel,
+ private readonly accountsModel: AccountsModel,
+ private readonly restApiService: RestApiService,
+ private readonly reporting: ReportingService,
+ private readonly navigation: NavigationService
) {
super(initialState);
this.subscriptions.push(
+ this.savingInProgress$.subscribe(savingInProgress => {
+ if (savingInProgress) {
+ this.navigation.blockNavigation('draft comment still saving');
+ } else {
+ this.navigation.releaseNavigation('draft comment still saving');
+ }
+ })
+ );
+ this.subscriptions.push(
+ this.savingError$.subscribe(savingError => {
+ if (savingError) {
+ this.navigation.blockNavigation('draft comment failed to save');
+ } else {
+ this.navigation.releaseNavigation('draft comment failed to save');
+ }
+ })
+ );
+ this.subscriptions.push(
this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
);
this.subscriptions.push(
@@ -402,7 +466,17 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
this.changeModel.patchNum$.subscribe(x => (this.patchNum = x))
);
this.subscriptions.push(
- this.routerModel.routerChangeNum$.subscribe(changeNum => {
+ combineLatest([
+ this.draftsLoading$,
+ this.patchsetLevelDrafts$,
+ this.changeModel.latestPatchNum$,
+ ]).subscribe(([loading, plDraft, latestPatchNum]) => {
+ if (loading || plDraft.length > 0 || !latestPatchNum) return;
+ this.addNewDraft(createNewPatchsetLevel(latestPatchNum, '', false));
+ })
+ );
+ this.subscriptions.push(
+ this.changeViewModel.changeNum$.subscribe(changeNum => {
this.changeNum = changeNum;
this.setState({...initialState});
this.reloadAllComments();
@@ -418,16 +492,6 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
this.reloadAllPortedComments();
})
);
- this.reloadListener = () => {
- this.reloadAllComments();
- this.reloadAllPortedComments();
- };
- document.addEventListener('reload', this.reloadListener);
- }
-
- override finalize() {
- document.removeEventListener('reload', this.reloadListener);
- super.finalize();
}
// Note that this does *not* reload ported comments.
@@ -522,107 +586,150 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
this.modifyState(s => setPortedDrafts(s, portedDrafts));
}
- async restoreDraft(id: UrlEncodedCommentId) {
- const found = this.discardedDrafts?.find(d => d.id === id);
+ async restoreDraft(draftId: UrlEncodedCommentId) {
+ const found = this.discardedDrafts?.find(d => id(d) === draftId);
if (!found) throw new Error('discarded draft not found');
- const newDraft = {
+ const newDraft: DraftInfo = {
...found,
- id: undefined,
- updated: undefined,
- __draft: undefined,
- __unsaved: true,
+ ...createNew(),
};
await this.saveDraft(newDraft);
- this.modifyState(s => deleteDiscardedDraft(s, id));
+ this.modifyState(s => deleteDiscardedDraft(s, draftId));
+ }
+
+ /**
+ * Adds a new draft without saving it.
+ *
+ * There is no equivalent `removeNewDraft()` method, because
+ * `discardDraft()` can be used.
+ */
+ addNewDraft(draft: DraftInfo) {
+ assert(isNew(draft), 'draft must be new');
+ this.modifyState(s => setDraft(s, draft));
}
/**
* Saves a new or updates an existing draft.
- * The model will only be updated when a successful response comes back.
+ *
+ * `draft.message` must not be empty: Use `discardDraft()` instead.
+ *
+ * Draft must not be in `SAVING` state already.
*/
- async saveDraft(
- draft: DraftInfo | UnsavedInfo,
- showToast = true
- ): Promise<DraftInfo> {
+ async saveDraft(draft: DraftInfo, showToast = true): Promise<DraftInfo> {
assertIsDefined(this.changeNum, 'change number');
assertIsDefined(draft.patch_set, 'patchset number of comment draft');
- if (!draft.message?.trim()) throw new Error('Cannot save empty draft.');
+ assert(!!draft.message?.trim(), 'cannot save empty draft');
+ assert(!isSaving(draft), 'saving already in progress');
+
+ // optimistic update
+ const draftSaving: DraftInfo = {...draft, savingState: SavingState.SAVING};
+ this.modifyState(s => setDraft(s, draftSaving));
// Saving the change number as to make sure that the response is still
// relevant when it comes back. The user maybe have navigated away.
const changeNum = this.changeNum;
this.report(Interaction.SAVE_COMMENT, draft);
if (showToast) this.showStartRequest();
- const timing = isUnsaved(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
+ const timing = isNew(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
const timer = this.reporting.getTimer(timing);
- const result = await this.restApiService.saveDiffDraft(
- changeNum,
- draft.patch_set,
- draft
- );
- if (changeNum !== this.changeNum) throw new Error('change changed');
- if (!result.ok) {
- if (showToast) this.handleFailedDraftRequest();
- throw new Error(
- `Failed to save draft comment: ${JSON.stringify(result)}`
+
+ let savedComment;
+ try {
+ const result = await this.restApiService.saveDiffDraft(
+ changeNum,
+ draft.patch_set,
+ draft
);
+ if (changeNum !== this.changeNum) return draft;
+ if (!result.ok) throw new Error('request failed');
+ savedComment = (await this.restApiService.getResponseObject(
+ result
+ )) as unknown as CommentInfo;
+ } catch (error) {
+ if (showToast) this.handleFailedDraftRequest();
+ const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
+ this.modifyState(s => setDraft(s, draftError));
+ return draftError;
}
- const obj = await this.restApiService.getResponseObject(result);
- const savedComment = obj as unknown as CommentInfo;
- const updatedDraft = {
+
+ const draftSaved: DraftInfo = {
...draft,
id: savedComment.id,
updated: savedComment.updated,
- __draft: true,
- __unsaved: undefined,
+ savingState: SavingState.OK,
};
- timer.end({id: updatedDraft.id});
+ timer.end({id: draftSaved.id});
if (showToast) this.showEndRequest();
- this.modifyState(s => setDraft(s, updatedDraft));
- this.report(Interaction.COMMENT_SAVED, updatedDraft);
- return updatedDraft;
+ this.modifyState(s => setDraft(s, draftSaved));
+ this.report(Interaction.COMMENT_SAVED, draftSaved);
+ return draftSaved;
}
async discardDraft(draftId: UrlEncodedCommentId) {
const draft = this.lookupDraft(draftId);
- assertIsDefined(this.changeNum, 'change number');
assertIsDefined(draft, `draft not found by id ${draftId}`);
assertIsDefined(draft.patch_set, 'patchset number of comment draft');
+ assert(!isSaving(draft), 'saving already in progress');
- if (!draft.message?.trim()) throw new Error('saved draft cant be empty');
- // Saving the change number as to make sure that the response is still
- // relevant when it comes back. The user maybe have navigated away.
- const changeNum = this.changeNum;
- this.report(Interaction.DISCARD_COMMENT, draft);
- this.showStartRequest();
- const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
- const result = await this.restApiService.deleteDiffDraft(
- changeNum,
- draft.patch_set,
- {id: draft.id}
- );
- timer.end({id: draft.id});
- if (changeNum !== this.changeNum) throw new Error('change changed');
- if (!result.ok) {
- this.handleFailedDraftRequest();
- throw new Error(
- `Failed to discard draft comment: ${JSON.stringify(result)}`
+ // optimistic update
+ this.modifyState(s => deleteDraft(s, draft));
+
+ // For "unsaved" drafts there is nothing to discard on the server side.
+ if (draft.id) {
+ if (!draft.message?.trim()) throw new Error('empty draft');
+ // Saving the change number as to make sure that the response is still
+ // relevant when it comes back. The user maybe have navigated away.
+ assertIsDefined(this.changeNum, 'change number');
+ const changeNum = this.changeNum;
+ this.report(Interaction.DISCARD_COMMENT, draft);
+ this.showStartRequest();
+ const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
+ const result = await this.restApiService.deleteDiffDraft(
+ changeNum,
+ draft.patch_set,
+ {id: draft.id}
);
+ timer.end({id: draft.id});
+ if (changeNum !== this.changeNum) throw new Error('change changed');
+ if (!result.ok) {
+ this.handleFailedDraftRequest();
+ await this.restoreDraft(draftId);
+ throw new Error(
+ `Failed to discard draft comment: ${JSON.stringify(result)}`
+ );
+ }
+ this.showEndRequest();
}
- this.showEndRequest();
- this.modifyState(s => deleteDraft(s, draft));
+
// We don't store empty discarded drafts and don't need an UNDO then.
if (draft.message?.trim()) {
- fire(document, EventType.SHOW_ALERT, {
+ fire(document, 'show-alert', {
message: 'Draft Discarded',
action: 'Undo',
- callback: () => this.restoreDraft(draft.id),
+ callback: () => this.restoreDraft(draftId),
});
}
this.report(Interaction.COMMENT_DISCARDED, draft);
}
- private report(interaction: Interaction, comment: CommentBasics) {
+ async deleteComment(
+ changeNum: NumericChangeId,
+ comment: Comment,
+ reason: string
+ ) {
+ assertIsDefined(comment.patch_set, 'comment.patch_set');
+ assert(!isDraft(comment), 'Admin deletion is only for published comments.');
+
+ const newComment = await this.restApiService.deleteComment(
+ changeNum,
+ comment.patch_set,
+ comment.id,
+ reason
+ );
+ this.modifyState(s => updateComment(s, newComment));
+ }
+
+ private report(interaction: Interaction, comment: Comment) {
const details = reportingDetails(comment);
this.reporting.reportInteraction(interaction, details);
}
@@ -644,28 +751,24 @@ export class CommentsModel extends Model<CommentState> implements Finalizable {
private updateRequestToast(requestFailed?: boolean) {
if (this.numPendingDraftRequests === 0 && !requestFailed) {
- fireEvent(document, 'hide-alert');
+ fire(document, 'hide-alert', {});
return;
}
const message = getSavingMessage(
this.numPendingDraftRequests,
requestFailed
);
+ if (!message) return;
this.draftToastTask = debounce(
this.draftToastTask,
- () => {
- // Note: the event is fired on the body rather than this element because
- // this element may not be attached by the time this executes, in which
- // case the event would not bubble.
- fireAlert(document.body, message);
- },
+ () => fireAlert(document.body, message),
TOAST_DEBOUNCE_INTERVAL
);
}
- private lookupDraft(id: UrlEncodedCommentId): DraftInfo | undefined {
+ private lookupDraft(commentId: UrlEncodedCommentId): DraftInfo | undefined {
return Object.values(this.drafts)
.flat()
- .find(d => d.id === id);
+ .find(draft => id(draft) === commentId);
}
}
diff --git a/polygerrit-ui/app/models/comments/comments-model_test.ts b/polygerrit-ui/app/models/comments/comments-model_test.ts
index 32ea1bcdfb..0d3df42f60 100644
--- a/polygerrit-ui/app/models/comments/comments-model_test.ts
+++ b/polygerrit-ui/app/models/comments/comments-model_test.ts
@@ -6,11 +6,14 @@
import '../../test/common-test-setup';
import {
createAccountWithEmail,
+ createChangeViewState,
createDraft,
} from '../../test/test-data-generators';
import {
AccountInfo,
+ CommentInfo,
EmailAddress,
+ NumericChangeId,
Timestamp,
UrlEncodedCommentId,
} from '../../types/common';
@@ -19,15 +22,16 @@ import {Subscription} from 'rxjs';
import {
createComment,
createParsedChange,
- TEST_NUMERIC_CHANGE_ID,
} from '../../test/test-data-generators';
import {stubRestApi, waitUntil, waitUntilCalled} from '../../test/test-utils';
import {getAppContext} from '../../services/app-context';
-import {GerritView} from '../../services/router/router-model';
-import {PathToCommentsInfoMap} from '../../types/common';
import {changeModelToken} from '../change/change-model';
import {assert} from '@open-wc/testing';
import {testResolver} from '../../test/common-test-setup';
+import {accountsModelToken} from '../accounts-model/accounts-model';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {changeViewModelToken} from '../views/change';
+import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
suite('comments model tests', () => {
test('updateStateDeleteDraft', () => {
@@ -69,11 +73,12 @@ suite('change service tests', () => {
test('loads comments', async () => {
const model = new CommentsModel(
- getAppContext().routerModel,
+ testResolver(changeViewModelToken),
testResolver(changeModelToken),
- getAppContext().accountsModel,
+ testResolver(accountsModelToken),
getAppContext().restApiService,
- getAppContext().reportingService
+ getAppContext().reportingService,
+ testResolver(navigationToken)
);
const diffCommentsSpy = stubRestApi('getDiffComments').returns(
Promise.resolve({'foo.c': [createComment()]})
@@ -90,18 +95,15 @@ suite('change service tests', () => {
const portedDraftsSpy = stubRestApi('getPortedDrafts').returns(
Promise.resolve({})
);
- let comments: PathToCommentsInfoMap = {};
+ let comments: {[path: string]: CommentInfo[]} = {};
subscriptions.push(model.comments$.subscribe(c => (comments = c ?? {})));
- let portedComments: PathToCommentsInfoMap = {};
+ let portedComments: {[path: string]: CommentInfo[]} = {};
subscriptions.push(
model.portedComments$.subscribe(c => (portedComments = c ?? {}))
);
- model.routerModel.setState({
- view: GerritView.CHANGE,
- changeNum: TEST_NUMERIC_CHANGE_ID,
- });
- model.changeModel.updateStateChange(createParsedChange());
+ testResolver(changeViewModelToken).setState(createChangeViewState());
+ testResolver(changeModelToken).updateStateChange(createParsedChange());
await waitUntilCalled(diffCommentsSpy, 'diffCommentsSpy');
await waitUntilCalled(diffRobotCommentsSpy, 'diffRobotCommentsSpy');
@@ -130,11 +132,12 @@ suite('change service tests', () => {
};
stubRestApi('getAccountDetails').returns(Promise.resolve(account));
const model = new CommentsModel(
- getAppContext().routerModel,
+ testResolver(changeViewModelToken),
testResolver(changeModelToken),
- getAppContext().accountsModel,
+ testResolver(accountsModelToken),
getAppContext().restApiService,
- getAppContext().reportingService
+ getAppContext().reportingService,
+ testResolver(navigationToken)
);
let mentionedUsers: AccountInfo[] = [];
const draft = {...createDraft(), message: 'hey @abc@def.com'};
@@ -158,11 +161,12 @@ suite('change service tests', () => {
};
stubRestApi('getAccountDetails').returns(Promise.resolve(account));
const model = new CommentsModel(
- getAppContext().routerModel,
+ testResolver(changeViewModelToken),
testResolver(changeModelToken),
- getAppContext().accountsModel,
+ testResolver(accountsModelToken),
getAppContext().restApiService,
- getAppContext().reportingService
+ getAppContext().reportingService,
+ testResolver(navigationToken)
);
let mentionedUsers: AccountInfo[] = [];
const draft = {...createDraft(), message: 'hey @abc@def.com'};
@@ -186,4 +190,37 @@ suite('change service tests', () => {
});
await waitUntil(() => mentionedUsers.length === 0);
});
+
+ test('delete comment change is emitted', async () => {
+ const comment = createComment();
+ stubRestApi('deleteComment').returns(
+ Promise.resolve({
+ ...comment,
+ message: 'Comment is deleted',
+ })
+ );
+ const model = new CommentsModel(
+ testResolver(changeViewModelToken),
+ testResolver(changeModelToken),
+ testResolver(accountsModelToken),
+ getAppContext().restApiService,
+ getAppContext().reportingService,
+ testResolver(navigationToken)
+ );
+
+ let changeComments: ChangeComments | undefined = undefined;
+ model.changeComments$.subscribe(x => (changeComments = x));
+ model.setState({
+ comments: {[comment.path!]: [comment]},
+ discardedDrafts: [],
+ });
+
+ model.deleteComment(123 as NumericChangeId, comment, 'Comment is deleted');
+
+ await waitUntil(
+ () =>
+ changeComments?.getAllCommentsForPath(comment.path!)[0].message ===
+ 'Comment is deleted'
+ );
+ });
});
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 6e374d1ca2..168e0f4b53 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -6,13 +6,11 @@
import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
import {from, of} from 'rxjs';
import {switchMap} from 'rxjs/operators';
-import {Finalizable} from '../../services/registry';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {ChangeModel} from '../change/change-model';
import {select} from '../../utils/observable-util';
import {Model} from '../model';
import {define} from '../dependency';
-import {getDocsBaseUrl} from '../../utils/url-util';
export interface ConfigState {
repoConfig?: ConfigInfo;
@@ -20,7 +18,7 @@ export interface ConfigState {
}
export const configModelToken = define<ConfigModel>('config-model');
-export class ConfigModel extends Model<ConfigState> implements Finalizable {
+export class ConfigModel extends Model<ConfigState> {
public repoConfig$ = select(
this.state$,
configState => configState.repoConfig
@@ -44,7 +42,7 @@ export class ConfigModel extends Model<ConfigState> implements Finalizable {
public docsBaseUrl$ = select(
this.serverConfig$.pipe(
switchMap(serverConfig =>
- from(getDocsBaseUrl(serverConfig, this.restApiService))
+ from(this.restApiService.getDocsBaseUrl(serverConfig))
)
),
url => url
diff --git a/polygerrit-ui/app/models/dependency.ts b/polygerrit-ui/app/models/dependency.ts
index 5499db2387..3b5081a9ed 100644
--- a/polygerrit-ui/app/models/dependency.ts
+++ b/polygerrit-ui/app/models/dependency.ts
@@ -47,14 +47,15 @@ import {ReactiveController, ReactiveControllerHost} from 'lit';
* ---
*
* Ancestor components will inject the dependencies that a child component
- * requires by providing factories for those values.
+ * requires by providing providers for those values.
*
*
* To provide a dependency, a component needs to specify the following prior
* to finishing its connectedCallback:
*
* ```
- * provide(this, fooToken, () => new FooImpl())
+ * const fooImpl = new FooImpl();
+ * provide(this, fooToken, () => fooImpl);
* ```
* Dependencies are injected as factories in case the construction of them
* depends on other dependencies further up the component chain. For instance,
@@ -63,7 +64,8 @@ import {ReactiveController, ReactiveControllerHost} from 'lit';
*
* ```
* const barRef = resolve(this, barToken);
- * provide(this, fooToken, () => new FooImpl(barRef()));
+ * const fooImpl = new FooImpl(barRef());
+ * provide(this, fooToken, () => fooImpl);
* ```
*
* Lifetime guarantees
@@ -188,7 +190,7 @@ type Callback<T> = (value: T) => void;
*/
export interface DependencyRequest<T> {
readonly dependency: DependencyToken<T>;
- readonly callback: Callback<T>;
+ readonly callback: Callback<Provider<T>>;
}
declare global {
@@ -218,7 +220,7 @@ export class DependencyRequestEvent<T>
{
public constructor(
public readonly dependency: DependencyToken<T>,
- public readonly callback: Callback<T>
+ public readonly callback: Callback<Provider<T>>
) {
super('request-dependency', {bubbles: true, composed: true});
}
@@ -238,12 +240,20 @@ export class DependencyError<T> extends Error {
}
}
+function makeDependencyError<T>(
+ host: HTMLElement,
+ dependency: DependencyToken<T>
+): DependencyError<T> {
+ const dep = dependency.description;
+ const tag = host.tagName;
+ const msg = `Could not resolve dependency '${dep}' in '${tag}'`;
+ return new DependencyError(dependency, msg);
+}
+
class DependencySubscriber<T>
implements ReactiveController, ResolvedDependency<T>
{
- private value?: T;
-
- private resolved = false;
+ private provider?: Provider<T>;
constructor(
private readonly host: ReactiveControllerHost & HTMLElement,
@@ -251,34 +261,26 @@ class DependencySubscriber<T>
) {}
get() {
- this.checkResolved();
- return this.value!;
+ if (!this.provider) {
+ throw makeDependencyError(this.host, this.dependency);
+ }
+ return this.provider();
}
hostConnected() {
- this.value = undefined;
- this.resolved = false;
+ this.provider = undefined;
this.host.dispatchEvent(
- new DependencyRequestEvent(this.dependency, (value: T) => {
- this.resolved = true;
- this.value = value;
+ new DependencyRequestEvent(this.dependency, (provider: Provider<T>) => {
+ this.provider = provider;
})
);
- this.checkResolved();
- }
-
- checkResolved() {
- if (this.resolved) return;
- const dep = this.dependency.description;
- const tag = this.host.tagName;
- const msg = `Could not resolve dependency '${dep}' in '${tag}'`;
- throw new DependencyError(this.dependency, msg);
+ if (!this.provider) {
+ throw makeDependencyError(this.host, this.dependency);
+ }
}
}
class DependencyProvider<T> implements ReactiveController {
- private value?: T;
-
constructor(
private readonly host: ReactiveControllerHost & HTMLElement,
private readonly dependency: DependencyToken<T>,
@@ -286,20 +288,17 @@ class DependencyProvider<T> implements ReactiveController {
) {}
hostConnected() {
- // Delay construction in case the provider has its own dependencies.
- this.value = this.provider();
this.host.addEventListener('request-dependency', this.fullfill);
}
hostDisconnected() {
this.host.removeEventListener('request-dependency', this.fullfill);
- this.value = undefined;
}
private readonly fullfill = (ev: DependencyRequestEvent<unknown>) => {
if (ev.dependency !== this.dependency) return;
ev.stopPropagation();
ev.preventDefault();
- ev.callback(this.value!);
+ ev.callback(this.provider);
};
}
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts
index 7826c45890..83235b17cb 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -3,7 +3,6 @@
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {Finalizable} from '../../services/registry';
import {Observable, Subject} from 'rxjs';
import {
CheckResult,
@@ -12,8 +11,13 @@ import {
ChecksProvider,
} from '../../api/checks';
import {Model} from '../model';
-import {define} from '../dependency';
import {select} from '../../utils/observable-util';
+import {CoverageProvider} from '../../api/annotation';
+
+export interface CoveragePlugin {
+ pluginName: string;
+ provider: CoverageProvider;
+}
export interface ChecksPlugin {
pluginName: string;
@@ -30,14 +34,16 @@ export interface ChecksUpdate {
/** Application wide state of plugins. */
interface PluginsState {
/**
+ * List of plugins that have called annotationApi().setCoverageProvider().
+ */
+ coveragePlugins: CoveragePlugin[];
+ /**
* List of plugins that have called checks().register().
*/
checksPlugins: ChecksPlugin[];
}
-export const pluginsModelToken = define<PluginsModel>('plugins-model');
-
-export class PluginsModel extends Model<PluginsState> implements Finalizable {
+export class PluginsModel extends Model<PluginsState> {
/** Private version of the event bus below. */
private checksAnnounceSubject$ = new Subject<ChecksPlugin>();
@@ -54,19 +60,38 @@ export class PluginsModel extends Model<PluginsState> implements Finalizable {
public checksPlugins$ = select(this.state$, state => state.checksPlugins);
+ public coveragePlugins$ = select(this.state$, state => state.coveragePlugins);
+
constructor() {
super({
+ coveragePlugins: [],
checksPlugins: [],
});
}
+ coverageRegister(plugin: CoveragePlugin) {
+ const nextState = {...this.getState()};
+ nextState.coveragePlugins = [...nextState.coveragePlugins];
+ const alreadyRegistered = nextState.coveragePlugins.some(
+ p => p.pluginName === plugin.pluginName
+ );
+ if (alreadyRegistered) {
+ console.warn(
+ `${plugin.pluginName} tried to register twice as a coverage provider. Ignored.`
+ );
+ return;
+ }
+ nextState.coveragePlugins.push(plugin);
+ this.setState(nextState);
+ }
+
checksRegister(plugin: ChecksPlugin) {
const nextState = {...this.getState()};
nextState.checksPlugins = [...nextState.checksPlugins];
- const alreadysRegistered = nextState.checksPlugins.some(
+ const alreadyRegistered = nextState.checksPlugins.some(
p => p.pluginName === plugin.pluginName
);
- if (alreadysRegistered) {
+ if (alreadyRegistered) {
console.warn(
`${plugin.pluginName} tried to register twice as a checks provider. Ignored.`
);
diff --git a/polygerrit-ui/app/models/plugins/plugins-model_test.ts b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
index 639afc6964..7aabae74b5 100644
--- a/polygerrit-ui/app/models/plugins/plugins-model_test.ts
+++ b/polygerrit-ui/app/models/plugins/plugins-model_test.ts
@@ -7,7 +7,7 @@ import '../../test/common-test-setup';
import './plugins-model';
import {ChecksApiConfig, ChecksProvider, ResponseCode} from '../../api/checks';
import {ChecksPlugin, ChecksUpdate, PluginsModel} from './plugins-model';
-import {createRunResult} from '../../test/test-data-generators';
+import {createRun, createRunResult} from '../../test/test-data-generators';
import {assert} from '@open-wc/testing';
const PLUGIN_NAME = 'test-plugin';
@@ -75,7 +75,7 @@ suite('plugins-model tests', () => {
register();
model.checksUpdate({
pluginName: PLUGIN_NAME,
- run: createRunResult(),
+ run: createRun(),
result: createRunResult(),
});
diff --git a/polygerrit-ui/app/models/user/user-model.ts b/polygerrit-ui/app/models/user/user-model.ts
index 2f2fe7de4e..97f90fa4a6 100644
--- a/polygerrit-ui/app/models/user/user-model.ts
+++ b/polygerrit-ui/app/models/user/user-model.ts
@@ -23,10 +23,10 @@ import {
} from '../../constants/constants';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {DiffPreferencesInfo} from '../../types/diff';
-import {Finalizable} from '../../services/registry';
import {select} from '../../utils/observable-util';
+import {define} from '../dependency';
import {Model} from '../model';
-import {notUndefined} from '../../types/types';
+import {isDefined} from '../../types/types';
export interface UserState {
/**
@@ -56,7 +56,9 @@ export interface UserState {
capabilities?: AccountCapabilityInfo;
}
-export class UserModel extends Model<UserState> implements Finalizable {
+export const userModelToken = define<UserModel>('user-model');
+
+export class UserModel extends Model<UserState> {
/**
* Note that the initially emitted `undefined` value can mean "not loaded
* the account into object yet" or "user is not logged in". Consider using
@@ -99,17 +101,17 @@ export class UserModel extends Model<UserState> implements Finalizable {
readonly preferences$: Observable<PreferencesInfo> = select(
this.state$,
userState => userState.preferences
- ).pipe(filter(notUndefined));
+ ).pipe(filter(isDefined));
readonly diffPreferences$: Observable<DiffPreferencesInfo> = select(
this.state$,
userState => userState.diffPreferences
- ).pipe(filter(notUndefined));
+ ).pipe(filter(isDefined));
readonly editPreferences$: Observable<EditPreferencesInfo> = select(
this.state$,
userState => userState.editPreferences
- ).pipe(filter(notUndefined));
+ ).pipe(filter(isDefined));
readonly preferenceDiffViewMode$: Observable<DiffViewMode> = select(
this.preferences$,
diff --git a/polygerrit-ui/app/models/views/admin.ts b/polygerrit-ui/app/models/views/admin.ts
index 2ad95a2d40..017470e93d 100644
--- a/polygerrit-ui/app/models/views/admin.ts
+++ b/polygerrit-ui/app/models/views/admin.ts
@@ -4,15 +4,263 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+import {
+ RepoName,
+ GroupId,
+ AccountDetailInfo,
+ AccountCapabilityInfo,
+} from '../../types/common';
+import {hasOwnProperty} from '../../utils/common-util';
+import {MenuLink} from '../../api/admin';
+import {createGroupUrl, GroupDetailView} from './group';
+import {createRepoUrl, RepoDetailView} from './repo';
+
+export interface SubsectionInterface {
+ name: string;
+ view: GerritView;
+ detailType?: RepoDetailView | GroupDetailView;
+ url?: string;
+ children?: SubsectionInterface[];
+}
+
+export interface AdminNavLinksOption {
+ repoName?: RepoName;
+ groupId?: GroupId;
+ groupName?: string;
+ groupIsInternal?: boolean;
+ isAdmin?: boolean;
+ groupOwner?: boolean;
+}
+
+export interface NavLink {
+ name: string;
+ url: string;
+ view?: GerritView | AdminChildView;
+ viewableToAll?: boolean;
+ section?: string;
+ capability?: string;
+ target?: string | null;
+ subsection?: SubsectionInterface;
+ children?: SubsectionInterface[];
+}
+
+export const PLUGIN_LIST_ROUTE: Route<AdminViewState> = {
+ urlPattern: /^\/admin\/plugins(\/)?$/,
+ createState: () => {
+ const state: AdminViewState = {
+ view: GerritView.ADMIN,
+ adminView: AdminChildView.PLUGINS,
+ };
+ return state;
+ },
+};
export enum AdminChildView {
REPOS = 'gr-repo-list',
GROUPS = 'gr-admin-group-list',
PLUGINS = 'gr-plugin-list',
}
+const ADMIN_LINKS: NavLink[] = [
+ {
+ name: 'Repositories',
+ url: createAdminUrl({adminView: AdminChildView.REPOS}),
+ view: 'gr-repo-list' as GerritView,
+ viewableToAll: true,
+ },
+ {
+ name: 'Groups',
+ section: 'Groups',
+ url: createAdminUrl({adminView: AdminChildView.GROUPS}),
+ view: 'gr-admin-group-list' as GerritView,
+ },
+ {
+ name: 'Plugins',
+ capability: 'viewPlugins',
+ section: 'Plugins',
+ url: createAdminUrl({adminView: AdminChildView.PLUGINS}),
+ view: 'gr-plugin-list' as GerritView,
+ },
+];
+
+export interface AdminLink {
+ url: string;
+ text: string;
+ capability: string | null;
+ view: null;
+ viewableToAll: boolean;
+ target: '_blank' | null;
+}
+
+export interface AdminLinks {
+ links: NavLink[];
+ expandedSection?: SubsectionInterface;
+}
+
+export function getAdminLinks(
+ account: AccountDetailInfo | undefined,
+ getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
+ getAdminMenuLinks: () => MenuLink[],
+ options?: AdminNavLinksOption
+): Promise<AdminLinks> {
+ if (!account) {
+ return Promise.resolve(
+ filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
+ );
+ }
+ return getAccountCapabilities().then(capabilities =>
+ filterLinks(
+ link => !link.capability || hasOwnProperty(capabilities, link.capability),
+ getAdminMenuLinks,
+ options
+ )
+ );
+}
+
+function filterLinks(
+ filterFn: (link: NavLink) => boolean,
+ getAdminMenuLinks: () => MenuLink[],
+ options?: AdminNavLinksOption
+): AdminLinks {
+ let links: NavLink[] = ADMIN_LINKS.slice(0);
+ let expandedSection: SubsectionInterface | undefined = undefined;
+
+ const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
+
+ // Append top-level links that are defined by plugins.
+ links.push(
+ ...getAdminMenuLinks().map((link: MenuLink) => {
+ return {
+ url: link.url,
+ name: link.text,
+ capability: link.capability || undefined,
+ view: undefined,
+ viewableToAll: !link.capability,
+ target: isExternalLink(link) ? '_blank' : null,
+ };
+ })
+ );
+
+ links = links.filter(filterFn);
+
+ const filteredLinks: NavLink[] = [];
+ const repoName = options && options.repoName;
+ const groupId = options && options.groupId;
+ const groupName = options && options.groupName;
+ const groupIsInternal = options && options.groupIsInternal;
+ const isAdmin = options && options.isAdmin;
+ const groupOwner = options && options.groupOwner;
+
+ // Don't bother to get sub-navigation items if only the top level links
+ // are needed. This is used by the main header dropdown.
+ if (!repoName && !groupId) {
+ return {links, expandedSection};
+ }
+
+ // Otherwise determine the full set of links and return both the full
+ // set in addition to the subsection that should be displayed if it
+ // exists.
+ for (const link of links) {
+ const linkCopy = {...link};
+ if (linkCopy.name === 'Repositories' && repoName) {
+ linkCopy.subsection = getRepoSubsections(repoName);
+ expandedSection = linkCopy.subsection;
+ } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+ linkCopy.subsection = getGroupSubsections(
+ groupId,
+ groupName,
+ groupIsInternal,
+ isAdmin,
+ groupOwner
+ );
+ expandedSection = linkCopy.subsection;
+ }
+ filteredLinks.push(linkCopy);
+ }
+ return {links: filteredLinks, expandedSection};
+}
+
+function getGroupSubsections(
+ groupId: GroupId,
+ groupName: string,
+ groupIsInternal?: boolean,
+ isAdmin?: boolean,
+ groupOwner?: boolean
+) {
+ const children: SubsectionInterface[] = [];
+ const subsection: SubsectionInterface = {
+ name: groupName,
+ view: GerritView.GROUP,
+ url: createGroupUrl({groupId}),
+ children,
+ };
+ if (groupIsInternal) {
+ children.push({
+ name: 'Members',
+ detailType: GroupDetailView.MEMBERS,
+ view: GerritView.GROUP,
+ url: createGroupUrl({groupId, detail: GroupDetailView.MEMBERS}),
+ });
+ }
+ if (groupIsInternal && (isAdmin || groupOwner)) {
+ children.push({
+ name: 'Audit Log',
+ detailType: GroupDetailView.LOG,
+ view: GerritView.GROUP,
+ url: createGroupUrl({groupId, detail: GroupDetailView.LOG}),
+ });
+ }
+ return subsection;
+}
+
+function getRepoSubsections(repo: RepoName) {
+ return {
+ name: repo,
+ view: GerritView.REPO,
+ children: [
+ {
+ name: 'General',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.GENERAL,
+ url: createRepoUrl({repo, detail: RepoDetailView.GENERAL}),
+ },
+ {
+ name: 'Access',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.ACCESS,
+ url: createRepoUrl({repo, detail: RepoDetailView.ACCESS}),
+ },
+ {
+ name: 'Commands',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.COMMANDS,
+ url: createRepoUrl({repo, detail: RepoDetailView.COMMANDS}),
+ },
+ {
+ name: 'Branches',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.BRANCHES,
+ url: createRepoUrl({repo, detail: RepoDetailView.BRANCHES}),
+ },
+ {
+ name: 'Tags',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.TAGS,
+ url: createRepoUrl({repo, detail: RepoDetailView.TAGS}),
+ },
+ {
+ name: 'Dashboards',
+ view: GerritView.REPO,
+ detailType: RepoDetailView.DASHBOARDS,
+ url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
+ },
+ ],
+ };
+}
+
export interface AdminViewState extends ViewState {
view: GerritView.ADMIN;
adminView: AdminChildView;
@@ -21,6 +269,17 @@ export interface AdminViewState extends ViewState {
offset?: number | string;
}
+export function createAdminUrl(state: Omit<AdminViewState, 'view'>) {
+ switch (state.adminView) {
+ case AdminChildView.REPOS:
+ return `${getBaseUrl()}/admin/repos`;
+ case AdminChildView.GROUPS:
+ return `${getBaseUrl()}/admin/groups`;
+ case AdminChildView.PLUGINS:
+ return `${getBaseUrl()}/admin/plugins`;
+ }
+}
+
export const adminViewModelToken = define<AdminViewModel>('admin-view-model');
export class AdminViewModel extends Model<AdminViewState | undefined> {
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.ts b/polygerrit-ui/app/models/views/admin_test.ts
index a8600c7d01..5d142bfc97 100644
--- a/polygerrit-ui/app/utils/admin-nav-util_test.ts
+++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -1,14 +1,28 @@
/**
* @license
- * Copyright 2018 Google LLC
+ * Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '@open-wc/testing';
-import {AccountDetailInfo, GroupId, RepoName, Timestamp} from '../api/rest-api';
-import '../test/common-test-setup';
-import {AdminNavLinksOption, getAdminLinks} from './admin-nav-util';
-
-suite('gr-admin-nav-behavior tests', () => {
+import '../../test/common-test-setup';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
+import {GerritView} from '../../services/router/router-model';
+import {
+ AdminChildView,
+ AdminViewState,
+ createAdminUrl,
+ PLUGIN_LIST_ROUTE,
+ AdminNavLinksOption,
+ getAdminLinks,
+} from './admin';
+import {
+ AccountDetailInfo,
+ GroupId,
+ RepoName,
+ Timestamp,
+} from '../../api/rest-api';
+
+suite('admin links', () => {
let capabilityStub: sinon.SinonStub;
let menuLinkStub: sinon.SinonStub;
@@ -63,7 +77,7 @@ suite('gr-admin-nav-behavior tests', () => {
const linkMatch = res.links.find(
l => l.url === link.url && l.name === link.text
);
- assert.isTrue(!!linkMatch);
+ assert.isOk(linkMatch);
// External links should open in new tab.
if (link.url[0] !== '/') {
@@ -332,3 +346,25 @@ suite('gr-admin-nav-behavior tests', () => {
});
});
});
+
+suite('admin view model', () => {
+ suite('routes', () => {
+ test('PLUGIN_LIST', () => {
+ assertRouteFalse(PLUGIN_LIST_ROUTE, 'admin/plugins');
+ assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins');
+ assertRouteFalse(PLUGIN_LIST_ROUTE, '//admin/plugins?');
+ assertRouteFalse(PLUGIN_LIST_ROUTE, '/admin/plugins//');
+
+ const state: AdminViewState = {
+ view: GerritView.ADMIN,
+ adminView: AdminChildView.PLUGINS,
+ };
+ assertRouteState<AdminViewState>(
+ PLUGIN_LIST_ROUTE,
+ '/admin/plugins',
+ state,
+ createAdminUrl
+ );
+ });
+ });
+});
diff --git a/polygerrit-ui/app/models/views/base.ts b/polygerrit-ui/app/models/views/base.ts
index 065495d968..5c388f44f4 100644
--- a/polygerrit-ui/app/models/views/base.ts
+++ b/polygerrit-ui/app/models/views/base.ts
@@ -3,8 +3,18 @@
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import {PageContext} from '../../elements/core/gr-router/gr-page';
import {GerritView} from '../../services/router/router-model';
export interface ViewState {
view: GerritView;
}
+
+/**
+ * Based on `urlPattern` knows whether a URL matches and if so, then
+ * `createState()` can produce a `ViewState` from the matched URL.
+ */
+export interface Route<T extends ViewState> {
+ urlPattern: RegExp;
+ createState: (ctx: PageContext) => T;
+}
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 100c46bac8..f003e3f90c 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -10,6 +10,7 @@ import {
BasePatchSetNum,
ChangeInfo,
PatchSetNumber,
+ EDIT,
} from '../../api/rest-api';
import {Tab} from '../../constants/constants';
import {GerritView} from '../../services/router/router-model';
@@ -26,18 +27,31 @@ import {define} from '../dependency';
import {Model} from '../model';
import {ViewState} from './base';
+export enum ChangeChildView {
+ OVERVIEW = 'OVERVIEW',
+ DIFF = 'DIFF',
+ EDIT = 'EDIT',
+}
+
export interface ChangeViewState extends ViewState {
view: GerritView.CHANGE;
+ childView: ChangeChildView;
changeNum: NumericChangeId;
- project: RepoName;
- edit?: boolean;
+ repo: RepoName;
patchNum?: RevisionPatchSetNum;
basePatchNum?: BasePatchSetNum;
+ /** Refers to comment on COMMENTS tab in OVERVIEW. */
commentId?: UrlEncodedCommentId;
+
+ // TODO: Move properties that only apply to OVERVIEW into a submessage.
+
+ edit?: boolean;
/** This can be a string only for plugin provided tabs. */
tab?: Tab | string;
+ // TODO: Move properties that only apply to CHECKS tab into a submessage.
+
/** Checks related view state */
/** selected patchset for check runs (undefined=latest) */
@@ -55,12 +69,33 @@ export interface ChangeViewState extends ViewState {
/** for scrolling a Change Log message into view in gr-change-view */
messageHash?: string;
- /** for logging where the user came from */
+ /**
+ * For logging where the user came from. This is handled by the router, so
+ * this is not inspected by the model.
+ */
usp?: string;
- /** triggers all change related data to be reloaded */
+ /**
+ * Triggers all change related data to be reloaded. This is implemented by
+ * intercepting change view state updates and `forceReload` causing the view
+ * state to be wiped clean as `undefined` in an intermediate update.
+ */
forceReload?: boolean;
/** triggers opening the reply dialog */
openReplyDialog?: boolean;
+
+ /** These properties apply to the DIFF child view only. */
+ diffView?: {
+ path?: string;
+ // TODO: Use LineNumber as a type, i.e. accept FILE and LOST.
+ lineNum?: number;
+ leftSide?: boolean;
+ };
+
+ /** These properties apply to the EDIT child view only. */
+ editView?: {
+ path?: string;
+ lineNum?: number;
+ };
}
/**
@@ -70,7 +105,7 @@ export interface ChangeViewState extends ViewState {
*/
export type CreateChangeUrlObject = Omit<
ChangeViewState,
- 'view' | 'changeNum' | 'project'
+ 'view' | 'childView' | 'changeNum' | 'repo'
> & {
change: Pick<ChangeInfo, '_number' | 'project'>;
};
@@ -82,28 +117,41 @@ export function isCreateChangeUrlObject(
}
export function objToState(
- obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+ obj:
+ | (CreateChangeUrlObject & {childView: ChangeChildView})
+ | Omit<ChangeViewState, 'view'>
): ChangeViewState {
if (isCreateChangeUrlObject(obj)) {
return {
...obj,
view: GerritView.CHANGE,
changeNum: obj.change._number,
- project: obj.change.project,
+ repo: obj.change.project,
};
}
return {...obj, view: GerritView.CHANGE};
}
+export function createChangeViewUrl(state: ChangeViewState): string {
+ switch (state.childView) {
+ case ChangeChildView.OVERVIEW:
+ return createChangeUrl(state);
+ case ChangeChildView.DIFF:
+ return createDiffUrl(state);
+ case ChangeChildView.EDIT:
+ return createEditUrl(state);
+ }
+}
+
export function createChangeUrl(
- obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view'>
+ obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
) {
- const state: ChangeViewState = objToState(obj);
- let range = getPatchRangeExpression(state);
- if (range.length) {
- range = '/' + range;
- }
- let suffix = `${range}`;
+ const state: ChangeViewState = objToState({
+ ...obj,
+ childView: ChangeChildView.OVERVIEW,
+ });
+
+ let suffix = '';
const queries = [];
if (state.checksPatchset && state.checksPatchset > 0) {
queries.push(`checksPatchset=${state.checksPatchset}`);
@@ -136,7 +184,7 @@ export function createChangeUrl(
suffix += ',edit';
}
if (state.commentId) {
- suffix = suffix + `/comments/${state.commentId}`;
+ suffix += `/comments/${state.commentId}`;
}
if (queries.length > 0) {
suffix += '?' + queries.join('&');
@@ -144,19 +192,118 @@ export function createChangeUrl(
if (state.messageHash) {
suffix += state.messageHash;
}
- if (state.project) {
- const encodedProject = encodeURL(state.project, true);
- return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
- } else {
- return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
+
+ return `${createChangeUrlCommon(state)}${suffix}`;
+}
+
+export function createDiffUrl(
+ obj: CreateChangeUrlObject | Omit<ChangeViewState, 'view' | 'childView'>
+) {
+ const state: ChangeViewState = objToState({
+ ...obj,
+ childView: ChangeChildView.DIFF,
+ });
+
+ const path = `/${encodeURL(state.diffView?.path ?? '')}`;
+
+ let suffix = '';
+ // TODO: Move creating of comment URLs to a separate function. We are
+ // "abusing" the `commentId` property, which should only be used for pointing
+ // to comment in the COMMENTS tab of the OVERVIEW page.
+ if (state.commentId) {
+ suffix += `comment/${state.commentId}/`;
+ }
+
+ if (state.diffView?.lineNum) {
+ suffix += '#';
+ if (state.diffView?.leftSide) {
+ suffix += 'b';
+ }
+ suffix += state.diffView.lineNum;
}
+
+ return `${createChangeUrlCommon(state)}${path}${suffix}`;
+}
+
+export function createEditUrl(
+ obj: Omit<ChangeViewState, 'view' | 'childView'>
+): string {
+ const state: ChangeViewState = objToState({
+ ...obj,
+ childView: ChangeChildView.DIFF,
+ patchNum: obj.patchNum ?? EDIT,
+ });
+
+ const path = `/${encodeURL(state.editView?.path ?? '')}`;
+ const line = state.editView?.lineNum;
+ const suffix = line ? `#${line}` : '';
+
+ return `${createChangeUrlCommon(state)}${path},edit${suffix}`;
+}
+
+/**
+ * The shared part of creating a change URL between OVERVIEW, DIFF and EDIT
+ * child views.
+ */
+function createChangeUrlCommon(state: ChangeViewState) {
+ let range = getPatchRangeExpression(state);
+ if (range.length) range = '/' + range;
+
+ let repo = '';
+ if (state.repo) repo = `${encodeURL(state.repo)}/+/`;
+
+ return `${getBaseUrl()}/c/${repo}${state.changeNum}${range}`;
}
export const changeViewModelToken =
define<ChangeViewModel>('change-view-model');
export class ChangeViewModel extends Model<ChangeViewState | undefined> {
- public readonly tab$ = select(this.state$, state => state?.tab);
+ public readonly changeNum$ = select(this.state$, state => state?.changeNum);
+
+ public readonly patchNum$ = select(this.state$, state => state?.patchNum);
+
+ public readonly basePatchNum$ = select(
+ this.state$,
+ state => state?.basePatchNum
+ );
+
+ public readonly openReplyDialog$ = select(
+ this.state$,
+ state => state?.openReplyDialog
+ );
+
+ public readonly commentId$ = select(this.state$, state => state?.commentId);
+
+ public readonly edit$ = select(this.state$, state => !!state?.edit);
+
+ public readonly editPath$ = select(
+ this.state$,
+ state => state?.editView?.path
+ );
+
+ public readonly diffPath$ = select(
+ this.state$,
+ state => state?.diffView?.path
+ );
+
+ public readonly diffLine$ = select(
+ this.state$,
+ state => state?.diffView?.lineNum
+ );
+
+ public readonly diffLeftSide$ = select(
+ this.state$,
+ state => state?.diffView?.leftSide ?? false
+ );
+
+ public readonly childView$ = select(this.state$, state => state?.childView);
+
+ public readonly tab$ = select(this.state$, state => {
+ if (state?.tab) return state.tab;
+ if (state?.commentId) return Tab.COMMENT_THREADS;
+ return Tab.FILES;
+ });
public readonly checksPatchset$ = select(
this.state$,
@@ -188,6 +335,39 @@ export class ChangeViewModel extends Model<ChangeViewState | undefined> {
});
}
});
+ document.addEventListener('reload', this.reload);
+ }
+
+ override finalize(): void {
+ document.removeEventListener('reload', this.reload);
+ }
+
+ /**
+ * Calling this is the same as firing the 'reload' event. This is also the
+ * same as adding `forceReload` parameter in the URL. See below.
+ */
+ reload = () => {
+ const state = this.getState();
+ if (state !== undefined) this.forceLoad(state);
+ };
+
+ /**
+ * This is the destination of where the `reload()` method, the `reload` event
+ * and the `forceReload` URL parameter all end up.
+ */
+ private forceLoad(state: ChangeViewState) {
+ this.setState(undefined);
+ // We have to do this in a timeout, because we need the `undefined` value to
+ // be processed by all observers first and thus have the "reset" completed.
+ setTimeout(() => this.setState({...state, forceReload: undefined}));
+ }
+
+ override setState(state: ChangeViewState | undefined): void {
+ if (state?.forceReload) {
+ this.forceLoad(state);
+ } else {
+ super.setState(state);
+ }
}
toggleSelectedCheckRun(checkName: string) {
diff --git a/polygerrit-ui/app/models/views/change_test.ts b/polygerrit-ui/app/models/views/change_test.ts
index 24ced82ac3..837e36263c 100644
--- a/polygerrit-ui/app/models/views/change_test.ts
+++ b/polygerrit-ui/app/models/views/change_test.ts
@@ -6,73 +6,145 @@
import {assert} from '@open-wc/testing';
import {
BasePatchSetNum,
- NumericChangeId,
RepoName,
RevisionPatchSetNum,
} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
import '../../test/common-test-setup';
-import {createChangeUrl, ChangeViewState} from './change';
-
-const STATE: ChangeViewState = {
- view: GerritView.CHANGE,
- changeNum: 1234 as NumericChangeId,
- project: 'test' as RepoName,
-};
+import {
+ createChangeViewState,
+ createDiffViewState,
+ createEditViewState,
+} from '../../test/test-data-generators';
+import {
+ createChangeUrl,
+ createDiffUrl,
+ createEditUrl,
+ ChangeViewState,
+} from './change';
suite('change view state tests', () => {
test('createChangeUrl()', () => {
- const state: ChangeViewState = {...STATE};
+ const state: ChangeViewState = createChangeViewState();
- assert.equal(createChangeUrl(state), '/c/test/+/1234');
+ assert.equal(createChangeUrl(state), '/c/test-project/+/42');
state.patchNum = 10 as RevisionPatchSetNum;
- assert.equal(createChangeUrl(state), '/c/test/+/1234/10');
+ assert.equal(createChangeUrl(state), '/c/test-project/+/42/10');
state.basePatchNum = 5 as BasePatchSetNum;
- assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10');
+ assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10');
state.messageHash = '#123';
- assert.equal(createChangeUrl(state), '/c/test/+/1234/5..10#123');
+ assert.equal(createChangeUrl(state), '/c/test-project/+/42/5..10#123');
});
test('createChangeUrl() baseUrl', () => {
window.CANONICAL_PATH = '/base';
- const state: ChangeViewState = {...STATE};
+ const state: ChangeViewState = createChangeViewState();
assert.equal(createChangeUrl(state).substring(0, 5), '/base');
window.CANONICAL_PATH = undefined;
});
test('createChangeUrl() checksRunsSelected', () => {
const state: ChangeViewState = {
- ...STATE,
+ ...createChangeViewState(),
checksRunsSelected: new Set(['asdf']),
};
assert.equal(
createChangeUrl(state),
- '/c/test/+/1234?checksRunsSelected=asdf'
+ '/c/test-project/+/42?checksRunsSelected=asdf'
);
});
test('createChangeUrl() checksResultsFilter', () => {
const state: ChangeViewState = {
- ...STATE,
+ ...createChangeViewState(),
checksResultsFilter: 'asdf.*qwer',
};
assert.equal(
createChangeUrl(state),
- '/c/test/+/1234?checksResultsFilter=asdf.*qwer'
+ '/c/test-project/+/42?checksResultsFilter=asdf.*qwer'
);
});
test('createChangeUrl() with repo name encoding', () => {
const state: ChangeViewState = {
- view: GerritView.CHANGE,
- changeNum: 1234 as NumericChangeId,
- project: 'x+/y+/z+/w' as RepoName,
+ ...createChangeViewState(),
+ repo: 'x+/y+/z+/w' as RepoName,
+ };
+ assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/42');
+ });
+
+ test('createDiffUrl', () => {
+ const params: ChangeViewState = {
+ ...createDiffViewState(),
+ patchNum: 12 as RevisionPatchSetNum,
+ diffView: {path: 'x+y/path.cpp'},
+ };
+ assert.equal(
+ createDiffUrl(params),
+ '/c/test-project/+/42/12/x%252By/path.cpp'
+ );
+
+ window.CANONICAL_PATH = '/base';
+ assert.equal(createDiffUrl(params).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
+
+ params.repo = 'test' as RepoName;
+ assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
+
+ params.basePatchNum = 6 as BasePatchSetNum;
+ assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
+
+ params.diffView = {
+ path: 'foo bar/my+file.txt%',
+ };
+ params.patchNum = 2 as RevisionPatchSetNum;
+ delete params.basePatchNum;
+ assert.equal(
+ createDiffUrl(params),
+ '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
+ );
+
+ params.diffView = {
+ path: 'file.cpp',
+ lineNum: 123,
+ };
+ assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
+
+ params.diffView = {
+ path: 'file.cpp',
+ lineNum: 123,
+ leftSide: true,
+ };
+ assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
+ });
+
+ test('diff with repo name encoding', () => {
+ const params: ChangeViewState = {
+ ...createDiffViewState(),
+ patchNum: 12 as RevisionPatchSetNum,
+ repo: 'x+/y' as RepoName,
+ diffView: {path: 'x+y/path.cpp'},
};
- assert.equal(createChangeUrl(state), '/c/x%252B/y%252B/z%252B/w/+/1234');
+ assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
+ });
+
+ test('createEditUrl', () => {
+ const params: ChangeViewState = {
+ ...createEditViewState(),
+ patchNum: 12 as RevisionPatchSetNum,
+ editView: {path: 'x+y/path.cpp' as RepoName, lineNum: 31},
+ };
+ assert.equal(
+ createEditUrl(params),
+ '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
+ );
+
+ window.CANONICAL_PATH = '/base';
+ assert.equal(createEditUrl(params).substring(0, 5), '/base');
+ window.CANONICAL_PATH = undefined;
});
});
diff --git a/polygerrit-ui/app/models/views/dashboard.ts b/polygerrit-ui/app/models/views/dashboard.ts
index d9ff2d28d8..d2e7995d2e 100644
--- a/polygerrit-ui/app/models/views/dashboard.ts
+++ b/polygerrit-ui/app/models/views/dashboard.ts
@@ -10,7 +10,21 @@ import {DashboardSection} from '../../utils/dashboard-util';
import {encodeURL, getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
-import {ViewState} from './base';
+import {Route, ViewState} from './base';
+
+export const PROJECT_DASHBOARD_ROUTE: Route<DashboardViewState> = {
+ urlPattern: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+ createState: ctx => {
+ const project = (ctx.params[0] ?? '') as RepoName;
+ const dashboard = (ctx.params[1] ?? '') as DashboardId;
+ const state: DashboardViewState = {
+ view: GerritView.DASHBOARD,
+ project,
+ dashboard,
+ };
+ return state;
+ },
+};
export interface DashboardViewState extends ViewState {
view: GerritView.DASHBOARD;
@@ -33,7 +47,7 @@ function sectionsToEncodedParams(
const query = repoName
? section.query.replace(REPO_TOKEN_PATTERN, repoName)
: section.query;
- return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+ return encodeURL(section.name) + '=' + encodeURL(query);
});
}
@@ -43,13 +57,13 @@ export function createDashboardUrl(state: Omit<DashboardViewState, 'view'>) {
// Custom dashboard.
const queryParams = sectionsToEncodedParams(state.sections, repoName);
if (state.title) {
- queryParams.push('title=' + encodeURIComponent(state.title));
+ queryParams.push('title=' + encodeURL(state.title));
}
const user = state.user ? state.user : '';
return `${getBaseUrl()}/dashboard/${user}?${queryParams.join('&')}`;
} else if (repoName) {
// Project dashboard.
- const encodedRepo = encodeURL(repoName, true);
+ const encodedRepo = encodeURL(repoName);
return `${getBaseUrl()}/p/${encodedRepo}/+/dashboard/${state.dashboard}`;
} else {
// User dashboard.
diff --git a/polygerrit-ui/app/models/views/dashboard_test.ts b/polygerrit-ui/app/models/views/dashboard_test.ts
index 86bb5c052c..9509977374 100644
--- a/polygerrit-ui/app/models/views/dashboard_test.ts
+++ b/polygerrit-ui/app/models/views/dashboard_test.ts
@@ -5,11 +5,36 @@
*/
import {assert} from '@open-wc/testing';
import {RepoName} from '../../api/rest-api';
+import {GerritView} from '../../services/router/router-model';
import '../../test/common-test-setup';
+import {assertRouteFalse, assertRouteState} from '../../test/test-utils';
import {DashboardId} from '../../types/common';
-import {createDashboardUrl} from './dashboard';
+import {
+ createDashboardUrl,
+ DashboardViewState,
+ PROJECT_DASHBOARD_ROUTE,
+} from './dashboard';
suite('dashboard view state tests', () => {
+ suite('routes', () => {
+ test('PROJECT_DASHBOARD_ROUTE', () => {
+ assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p//+/dashboard/qwer');
+ assertRouteFalse(PROJECT_DASHBOARD_ROUTE, '/p/asdf/+/dashboard/');
+
+ const state: DashboardViewState = {
+ view: GerritView.DASHBOARD,
+ project: 'asdf' as RepoName,
+ dashboard: 'qwer' as DashboardId,
+ };
+ assertRouteState(
+ PROJECT_DASHBOARD_ROUTE,
+ '/p/asdf/+/dashboard/qwer',
+ state,
+ createDashboardUrl
+ );
+ });
+ });
+
suite('createDashboardUrl()', () => {
test('self dashboard', () => {
assert.equal(createDashboardUrl({}), '/dashboard/self');
@@ -34,7 +59,7 @@ suite('dashboard view state tests', () => {
};
assert.equal(
createDashboardUrl(state),
- '/dashboard/?section%201=query%201&section%202=query%202'
+ '/dashboard/?section+1=query+1&section+2=query+2'
);
});
@@ -48,8 +73,8 @@ suite('dashboard view state tests', () => {
};
assert.equal(
createDashboardUrl(state),
- '/dashboard/?section%201=query%201%20repo-name&' +
- 'section%202=query%202%20repo-name'
+ '/dashboard/?section+1=query+1+repo-name&' +
+ 'section+2=query+2+repo-name'
);
});
@@ -61,7 +86,7 @@ suite('dashboard view state tests', () => {
};
assert.equal(
createDashboardUrl(state),
- '/dashboard/user?name=query&title=custom%20dashboard'
+ '/dashboard/user?name=query&title=custom+dashboard'
);
});
diff --git a/polygerrit-ui/app/models/views/diff.ts b/polygerrit-ui/app/models/views/diff.ts
deleted file mode 100644
index 3cc107abcf..0000000000
--- a/polygerrit-ui/app/models/views/diff.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
- NumericChangeId,
- RepoName,
- RevisionPatchSetNum,
- BasePatchSetNum,
- ChangeInfo,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import {UrlEncodedCommentId} from '../../types/common';
-import {
- encodeURL,
- getBaseUrl,
- getPatchRangeExpression,
-} from '../../utils/url-util';
-import {define} from '../dependency';
-import {Model} from '../model';
-import {ViewState} from './base';
-
-export interface DiffViewState extends ViewState {
- view: GerritView.DIFF;
- changeNum: NumericChangeId;
- project?: RepoName;
- commentId?: UrlEncodedCommentId;
- path?: string;
- patchNum?: RevisionPatchSetNum;
- basePatchNum?: BasePatchSetNum;
- lineNum?: number;
- leftSide?: boolean;
- commentLink?: boolean;
-}
-
-/**
- * This is a convenience type such that you can pass a `ChangeInfo` object
- * as the `change` property instead of having to set both the `changeNum` and
- * `project` properties explicitly.
- */
-export type CreateChangeUrlObject = Omit<
- DiffViewState,
- 'view' | 'changeNum' | 'project'
-> & {
- change: Pick<ChangeInfo, '_number' | 'project'>;
-};
-
-export function isCreateChangeUrlObject(
- state: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-): state is CreateChangeUrlObject {
- return !!(state as CreateChangeUrlObject).change;
-}
-
-export function objToState(
- obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-): DiffViewState {
- if (isCreateChangeUrlObject(obj)) {
- return {
- ...obj,
- view: GerritView.DIFF,
- changeNum: obj.change._number,
- project: obj.change.project,
- };
- }
- return {...obj, view: GerritView.DIFF};
-}
-
-export function createDiffUrl(
- obj: CreateChangeUrlObject | Omit<DiffViewState, 'view'>
-) {
- const state: DiffViewState = objToState(obj);
- let range = getPatchRangeExpression(state);
- if (range.length) range = '/' + range;
-
- let suffix = `${range}/${encodeURL(state.path || '', true)}`;
-
- if (state.lineNum) {
- suffix += '#';
- if (state.leftSide) {
- suffix += 'b';
- }
- suffix += state.lineNum;
- }
-
- if (state.commentId) {
- suffix = `/comment/${state.commentId}` + suffix;
- }
-
- if (state.project) {
- const encodedProject = encodeURL(state.project, true);
- return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
- } else {
- return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
- }
-}
-
-export const diffViewModelToken = define<DiffViewModel>('diff-view-model');
-
-export class DiffViewModel extends Model<DiffViewState | undefined> {
- constructor() {
- super(undefined);
- }
-}
diff --git a/polygerrit-ui/app/models/views/diff_test.ts b/polygerrit-ui/app/models/views/diff_test.ts
deleted file mode 100644
index b0f91bb316..0000000000
--- a/polygerrit-ui/app/models/views/diff_test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import {
- BasePatchSetNum,
- NumericChangeId,
- RepoName,
- RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import '../../test/common-test-setup';
-import {createDiffUrl, DiffViewState} from './diff';
-
-suite('diff view state tests', () => {
- test('createDiffUrl', () => {
- const params: DiffViewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- path: 'x+y/path.cpp' as RepoName,
- patchNum: 12 as RevisionPatchSetNum,
- project: '' as RepoName,
- };
- assert.equal(createDiffUrl(params), '/c/42/12/x%252By/path.cpp');
-
- window.CANONICAL_PATH = '/base';
- assert.equal(createDiffUrl(params).substring(0, 5), '/base');
- window.CANONICAL_PATH = undefined;
-
- params.project = 'test' as RepoName;
- assert.equal(createDiffUrl(params), '/c/test/+/42/12/x%252By/path.cpp');
-
- params.basePatchNum = 6 as BasePatchSetNum;
- assert.equal(createDiffUrl(params), '/c/test/+/42/6..12/x%252By/path.cpp');
-
- params.path = 'foo bar/my+file.txt%';
- params.patchNum = 2 as RevisionPatchSetNum;
- delete params.basePatchNum;
- assert.equal(
- createDiffUrl(params),
- '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525'
- );
-
- params.path = 'file.cpp';
- params.lineNum = 123;
- assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#123');
-
- params.leftSide = true;
- assert.equal(createDiffUrl(params), '/c/test/+/42/2/file.cpp#b123');
- });
-
- test('diff with repo name encoding', () => {
- const params: DiffViewState = {
- view: GerritView.DIFF,
- changeNum: 42 as NumericChangeId,
- path: 'x+y/path.cpp',
- patchNum: 12 as RevisionPatchSetNum,
- project: 'x+/y' as RepoName,
- };
- assert.equal(createDiffUrl(params), '/c/x%252B/y/+/42/12/x%252By/path.cpp');
- });
-});
diff --git a/polygerrit-ui/app/models/views/documentation.ts b/polygerrit-ui/app/models/views/documentation.ts
index b564d64848..abb0f03069 100644
--- a/polygerrit-ui/app/models/views/documentation.ts
+++ b/polygerrit-ui/app/models/views/documentation.ts
@@ -4,13 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {GerritView} from '../../services/router/router-model';
+import {getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
import {ViewState} from './base';
export interface DocumentationViewState extends ViewState {
view: GerritView.DOCUMENTATION_SEARCH;
- filter?: string | null;
+ filter: string;
+}
+
+export function createDocumentationUrl() {
+ return `${getBaseUrl()}/Documentation`;
}
export const documentationViewModelToken = define<DocumentationViewModel>(
diff --git a/polygerrit-ui/app/models/views/edit.ts b/polygerrit-ui/app/models/views/edit.ts
deleted file mode 100644
index c63c8ce508..0000000000
--- a/polygerrit-ui/app/models/views/edit.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
- EDIT,
- NumericChangeId,
- RepoName,
- RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import {
- encodeURL,
- getBaseUrl,
- getPatchRangeExpression,
-} from '../../utils/url-util';
-import {define} from '../dependency';
-import {Model} from '../model';
-import {ViewState} from './base';
-
-export interface EditViewState extends ViewState {
- view: GerritView.EDIT;
- changeNum: NumericChangeId;
- project: RepoName;
- path: string;
- patchNum: RevisionPatchSetNum;
- lineNum?: number;
-}
-
-export function createEditUrl(state: Omit<EditViewState, 'view'>): string {
- if (state.patchNum === undefined) {
- state = {...state, patchNum: EDIT};
- }
- let range = getPatchRangeExpression(state);
- if (range.length) range = '/' + range;
-
- let suffix = `${range}/${encodeURL(state.path || '', true)}`;
- suffix += ',edit';
-
- if (state.lineNum) {
- suffix += '#';
- suffix += state.lineNum;
- }
-
- if (state.project) {
- const encodedProject = encodeURL(state.project, true);
- return `${getBaseUrl()}/c/${encodedProject}/+/${state.changeNum}${suffix}`;
- } else {
- return `${getBaseUrl()}/c/${state.changeNum}${suffix}`;
- }
-}
-
-export const editViewModelToken = define<EditViewModel>('edit-view-model');
-
-export class EditViewModel extends Model<EditViewState | undefined> {
- constructor() {
- super(undefined);
- }
-}
diff --git a/polygerrit-ui/app/models/views/edit_test.ts b/polygerrit-ui/app/models/views/edit_test.ts
deleted file mode 100644
index 291206334d..0000000000
--- a/polygerrit-ui/app/models/views/edit_test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import {
- NumericChangeId,
- RepoName,
- RevisionPatchSetNum,
-} from '../../api/rest-api';
-import {GerritView} from '../../services/router/router-model';
-import '../../test/common-test-setup';
-import {createEditUrl, EditViewState} from './edit';
-
-suite('edit view state tests', () => {
- test('createEditUrl', () => {
- const params: EditViewState = {
- view: GerritView.EDIT,
- changeNum: 42 as NumericChangeId,
- project: 'test-project' as RepoName,
- path: 'x+y/path.cpp' as RepoName,
- patchNum: 12 as RevisionPatchSetNum,
- lineNum: 31,
- };
- assert.equal(
- createEditUrl(params),
- '/c/test-project/+/42/12/x%252By/path.cpp,edit#31'
- );
-
- window.CANONICAL_PATH = '/base';
- assert.equal(createEditUrl(params).substring(0, 5), '/base');
- window.CANONICAL_PATH = undefined;
- });
-});
diff --git a/polygerrit-ui/app/models/views/group.ts b/polygerrit-ui/app/models/views/group.ts
index 277bcffad6..f4a7c78b96 100644
--- a/polygerrit-ui/app/models/views/group.ts
+++ b/polygerrit-ui/app/models/views/group.ts
@@ -17,12 +17,16 @@ export enum GroupDetailView {
export interface GroupViewState extends ViewState {
view: GerritView.GROUP;
+ /**
+ * This refers to the (string) `id` of `GroupInfo`, not the `groupId`, which
+ * is a number.
+ */
groupId: GroupId;
detail?: GroupDetailView;
}
export function createGroupUrl(state: Omit<GroupViewState, 'view'>) {
- let url = `/admin/groups/${encodeURL(`${state.groupId}`, true)}`;
+ let url = `/admin/groups/${encodeURL(`${state.groupId}`)}`;
if (state.detail === GroupDetailView.MEMBERS) {
url += ',members';
} else if (state.detail === GroupDetailView.LOG) {
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 02fd17d181..66bf5bf38d 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {GerritView} from '../../services/router/router-model';
-import {RepoName} from '../../types/common';
+import {BranchName, RepoName} from '../../types/common';
import {encodeURL, getBaseUrl} from '../../utils/url-util';
import {define} from '../dependency';
import {Model} from '../model';
@@ -25,10 +25,18 @@ export interface RepoViewState extends ViewState {
repo?: RepoName;
filter?: string | null;
offset?: number | string;
+ /**
+ * This is for creating a change from the URL and then redirecting to a file
+ * editing page.
+ */
+ createEdit?: {
+ branch: BranchName;
+ path: string;
+ };
}
export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
- let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
+ let url = `/admin/repos/${encodeURL(`${state.repo}`)}`;
if (state.detail === RepoDetailView.GENERAL) {
url += ',general';
} else if (state.detail === RepoDetailView.ACCESS) {
diff --git a/polygerrit-ui/app/models/views/search.ts b/polygerrit-ui/app/models/views/search.ts
index 78f2d8feeb..2edc540306 100644
--- a/polygerrit-ui/app/models/views/search.ts
+++ b/polygerrit-ui/app/models/views/search.ts
@@ -16,8 +16,9 @@ import {RepoName, BranchName, TopicName, ChangeInfo} from '../../api/rest-api';
import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {GerritView} from '../../services/router/router-model';
+import {accountKey} from '../../utils/account-util';
import {select} from '../../utils/observable-util';
-import {addQuotesWhen} from '../../utils/string-util';
+import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
import {encodeURL, getBaseUrl} from '../../utils/url-util';
import {define, Provider} from '../dependency';
import {Model} from '../model';
@@ -64,14 +65,19 @@ export interface SearchViewState extends ViewState {
/**
* The search results for the current query.
+ * `undefined` must be allowed here, because updating state with a partial
+ * state without `changes` must be possible without overwriting existing
+ * changes.
+ * TODO: We should consider moving `changes` to a another model. This is not
+ * really "view" state. View state must directly correlate to the URL.
*/
- changes: ChangeInfo[];
+ changes?: ChangeInfo[];
}
export interface SearchUrlOptions {
query?: string;
offset?: number;
- project?: RepoName;
+ repo?: RepoName;
branch?: BranchName;
topic?: TopicName;
statuses?: string[];
@@ -86,46 +92,39 @@ export function createSearchUrl(params: SearchUrlOptions): string {
}
if (params.query) {
- return `${getBaseUrl()}/q/${encodeURL(params.query, true)}${offsetExpr}`;
+ return `${getBaseUrl()}/q/${encodeURL(params.query)}${offsetExpr}`;
}
const operators: string[] = [];
if (params.owner) {
- operators.push('owner:' + encodeURL(params.owner, false));
+ operators.push('owner:' + encodeURL(params.owner));
}
- if (params.project) {
- operators.push('project:' + encodeURL(params.project, false));
+ if (params.repo) {
+ operators.push('project:' + encodeURL(params.repo));
}
if (params.branch) {
- operators.push('branch:' + encodeURL(params.branch, false));
+ operators.push('branch:' + encodeURL(params.branch));
}
if (params.topic) {
operators.push(
- 'topic:' +
- addQuotesWhen(
- encodeURL(params.topic, false),
- /[\s:]/.test(params.topic)
- )
+ 'topic:' + escapeAndWrapSearchOperatorValue(encodeURL(params.topic))
);
}
if (params.hashtag) {
operators.push(
'hashtag:' +
- addQuotesWhen(
- encodeURL(params.hashtag.toLowerCase(), false),
- /[\s:]/.test(params.hashtag)
+ escapeAndWrapSearchOperatorValue(
+ encodeURL(params.hashtag.toLowerCase())
)
);
}
if (params.statuses) {
if (params.statuses.length === 1) {
- operators.push('status:' + encodeURL(params.statuses[0], false));
+ operators.push('status:' + encodeURL(params.statuses[0]));
} else if (params.statuses.length > 1) {
operators.push(
'(' +
- params.statuses
- .map(s => `status:${encodeURL(s, false)}`)
- .join(' OR ') +
+ params.statuses.map(s => `status:${encodeURL(s)}`).join(' OR ') +
')'
);
}
@@ -170,8 +169,11 @@ export class SearchViewModel extends Model<SearchViewState | undefined> {
([query, changes]) => {
if (changes.length === 0) return undefined;
if (!USER_QUERY_PATTERN.test(query)) return undefined;
- const owner = changes[0].owner;
- return owner?._account_id ?? owner?.email;
+ const ownerKey = accountKey(changes[0].owner);
+ if (changes.some(change => accountKey(change.owner) !== ownerKey)) {
+ return undefined;
+ }
+ return ownerKey;
}
);
diff --git a/polygerrit-ui/app/models/views/search_test.ts b/polygerrit-ui/app/models/views/search_test.ts
index 9017f2ef90..ed6de4195c 100644
--- a/polygerrit-ui/app/models/views/search_test.ts
+++ b/polygerrit-ui/app/models/views/search_test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '@open-wc/testing';
-import {SinonStub} from 'sinon';
+import {SinonStubbedMember} from 'sinon';
import {
AccountId,
BranchName,
@@ -13,7 +13,10 @@ import {
RepoName,
TopicName,
} from '../../api/rest-api';
-import {navigationToken} from '../../elements/core/gr-navigation/gr-navigation';
+import {
+ NavigationService,
+ navigationToken,
+} from '../../elements/core/gr-navigation/gr-navigation';
import '../../test/common-test-setup';
import {testResolver} from '../../test/common-test-setup';
import {createChange} from '../../test/test-data-generators';
@@ -31,7 +34,7 @@ suite('search view state tests', () => {
test('createSearchUrl', () => {
let options: SearchUrlOptions = {
owner: 'a%b',
- project: 'c%d' as RepoName,
+ repo: 'c%d' as RepoName,
branch: 'e%f' as BranchName,
topic: 'g%h' as TopicName,
statuses: ['op%en'],
@@ -39,7 +42,7 @@ suite('search view state tests', () => {
assert.equal(
createSearchUrl(options),
'/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
- 'topic:g%2525h+status:op%2525en'
+ 'topic:"g%2525h"+status:op%2525en'
);
window.CANONICAL_PATH = '/base';
@@ -50,16 +53,16 @@ suite('search view state tests', () => {
assert.equal(
createSearchUrl(options),
'/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
- 'topic:g%2525h+status:op%2525en,100'
+ 'topic:"g%2525h"+status:op%2525en,100'
);
delete options.offset;
// The presence of the query param overrides other options.
options.query = 'foo$bar';
- assert.equal(createSearchUrl(options), '/q/foo%2524bar');
+ assert.equal(createSearchUrl(options), '/q/foo%24bar');
options.offset = 100;
- assert.equal(createSearchUrl(options), '/q/foo%2524bar,100');
+ assert.equal(createSearchUrl(options), '/q/foo%24bar,100');
options = {statuses: ['a', 'b', 'c']};
assert.equal(
@@ -68,7 +71,7 @@ suite('search view state tests', () => {
);
options = {topic: 'test' as TopicName};
- assert.equal(createSearchUrl(options), '/q/topic:test');
+ assert.equal(createSearchUrl(options), '/q/topic:"test"');
options = {topic: 'test test' as TopicName};
assert.equal(createSearchUrl(options), '/q/topic:"test+test"');
@@ -78,7 +81,7 @@ suite('search view state tests', () => {
});
suite('query based navigation', () => {
- let replaceUrlStub: SinonStub;
+ let replaceUrlStub: SinonStubbedMember<NavigationService['replaceUrl']>;
let model: SearchViewModel;
setup(() => {
@@ -151,25 +154,32 @@ suite('search view state tests', () => {
test('userId', async () => {
assert.isUndefined(userId);
+ // userId set when all owners are the same
model.updateState({
- query: 'owner: foo@bar',
+ query: 'owner:foo',
changes: [
{...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+ {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
],
});
assert.equal(userId, 'foo@bar' as EmailAddress);
+ // userId not set when multiple owners exist
model.updateState({
- query: 'foo bar baz',
+ query: 'owner:foo',
changes: [
{...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+ {...createChange(), owner: {email: 'foo@foo' as EmailAddress}},
],
});
assert.isUndefined(userId);
+ // userId not set when query is not about owner
model.updateState({
- query: 'owner: foo@bar',
- changes: [{...createChange(), owner: {}}],
+ query: 'foo bar baz',
+ changes: [
+ {...createChange(), owner: {email: 'foo@bar' as EmailAddress}},
+ ],
});
assert.isUndefined(userId);
});
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index cbb582b81d..126fd1fd53 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -329,14 +329,6 @@ const packages: PackageInfo[] = [
license: SharedLicenses.Polymer2018,
},
{
- name: 'codemirror-minified',
- license: {
- name: 'codemirror-minified',
- type: LicenseTypes.Mit,
- packageLicenseFile: 'LICENSE',
- },
- },
- {
name: 'isarray',
license: SharedLicenses.IsArray,
},
@@ -369,6 +361,10 @@ const packages: PackageInfo[] = [
license: SharedLicenses.Polymer2018,
},
{
+ name: 'polygerrit-gr-page',
+ license: SharedLicenses.Page,
+ },
+ {
name: 'web-vitals',
license: {
name: 'web-vitals',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 398ca8ac43..edf0206150 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -12,7 +12,6 @@
"@polymer/iron-icon": "^3.0.1",
"@polymer/iron-iconset-svg": "^3.0.1",
"@polymer/iron-input": "^3.0.1",
- "@polymer/iron-overlay-behavior": "^3.0.3",
"@polymer/iron-selector": "^3.0.1",
"@polymer/marked-element": "^3.0.1",
"@polymer/paper-button": "^3.0.1",
@@ -35,19 +34,17 @@
"@types/resize-observer-browser": "^0.1.5",
"@webcomponents/shadycss": "^1.10.2",
"@webcomponents/webcomponentsjs": "^1.3.3",
- "codemirror-minified": "^5.65.0",
"highlight.js": "^11.5.0",
"highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates",
"highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text",
"immer": "^9.0.5",
"lit": "^2.2.3",
- "page": "^1.11.6",
"polymer-bridges": "file:../../polymer-bridges/",
"polymer-resin": "^2.0.1",
"resemblejs": "^4.0.0",
"rxjs": "^6.6.7",
"safevalues": "^0.3.1",
- "web-vitals": "^2.1.4"
+ "web-vitals": "^3.0.0"
},
"license": "Apache-2.0",
"private": true
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index be60a6345c..bc9d7a8d86 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -73,14 +73,15 @@ export default {
customResolveOptions: {
// By default, it tries to use page.mjs file instead of page.js
// when importing 'page/page'.
+ // TODO: page.was removed. Is something obsolete here?
extensions: ['.js'],
moduleDirectory: 'external/ui_npm/node_modules',
},
}),
define({
- replacements: {
- 'process.env.NODE_ENV': JSON.stringify('production'),
- },
+ replacements: {
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ },
}),
importLocalFontMetaUrlResolver()],
};
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
deleted file mode 100644
index e95a362bc8..0000000000
--- a/polygerrit-ui/app/scripts/hiddenscroll.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-let hiddenscroll: boolean | undefined = undefined;
-
-window.addEventListener('WebComponentsReady', () => {
- const elem = document.createElement('div');
- elem.setAttribute('style', 'width:100px;height:100px;overflow:scroll');
- document.body.appendChild(elem);
- hiddenscroll = elem.offsetWidth === elem.clientWidth;
- elem.remove();
-});
-
-export function _setHiddenScroll(value: boolean) {
- hiddenscroll = value;
-}
-
-export function getHiddenScroll() {
- return hiddenscroll;
-}
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
index 573b24adc5..494acd9170 100644
--- a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -58,7 +58,3 @@ import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js';
import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
-
-// This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
-import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
-
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
deleted file mode 100644
index 1dbb2a11d5..0000000000
--- a/polygerrit-ui/app/scripts/rootElement.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * @license
- * Copyright 2017 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/**
- * Returns the root element of the dom: body.
- */
-export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
deleted file mode 100644
index b785a717a2..0000000000
--- a/polygerrit-ui/app/scripts/util.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-/**
- * @license
- * Copyright 2015 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-export interface CancelablePromise<T> extends Promise<T> {
- cancel(): void;
-}
-
-/**
- * Make the promise cancelable.
- *
- * Returns a promise with a `cancel()` method wrapped around `promise`.
- * Calling `cancel()` will reject the returned promise with
- * {isCancelled: true} synchronously. If the inner promise for a cancelled
- * promise resolves or rejects this is ignored.
- */
-export function makeCancelable<T>(promise: Promise<T>) {
- // True if the promise is either resolved or reject (possibly cancelled)
- let isDone = false;
-
- let rejectPromise: (reason?: unknown) => void;
-
- const wrappedPromise: CancelablePromise<T> = new Promise(
- (resolve, reject) => {
- rejectPromise = reject;
- promise.then(
- val => {
- if (!isDone) resolve(val);
- isDone = true;
- },
- error => {
- if (!isDone) reject(error);
- isDone = true;
- }
- );
- }
- ) as CancelablePromise<T>;
-
- wrappedPromise.cancel = () => {
- if (isDone) return;
- rejectPromise({isCanceled: true});
- isDone = true;
- };
- return wrappedPromise;
-}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index e662d5fc92..8589ae389c 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -8,20 +8,18 @@ import {create, Finalizable, Registry} from './registry';
import {DependencyToken} from '../models/dependency';
import {FlagsServiceImplementation} from './flags/flags_impl';
import {GrReporting} from './gr-reporting/gr-reporting_impl';
-import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
import {Auth} from './gr-auth/gr-auth_impl';
import {GrRestApiServiceImpl} from './gr-rest-api/gr-rest-api-impl';
import {ChangeModel, changeModelToken} from '../models/change/change-model';
import {FilesModel, filesModelToken} from '../models/change/files-model';
import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
-import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {GrStorageService} from './storage/gr-storage_impl';
-import {UserModel} from '../models/user/user-model';
+import {GrStorageService, storageServiceToken} from './storage/gr-storage_impl';
+import {UserModel, userModelToken} from '../models/user/user-model';
import {
CommentsModel,
commentsModelToken,
} from '../models/comments/comments-model';
-import {RouterModel} from './router/router-model';
+import {RouterModel, routerModelToken} from './router/router-model';
import {
ShortcutsService,
shortcutsServiceToken,
@@ -29,9 +27,14 @@ import {
import {assertIsDefined} from '../utils/common-util';
import {ConfigModel, configModelToken} from '../models/config/config-model';
import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
-import {PluginsModel} from '../models/plugins/plugins-model';
-import {HighlightService} from './highlight/highlight-service';
-import {AccountsModel} from '../models/accounts-model/accounts-model';
+import {
+ HighlightService,
+ highlightServiceToken,
+} from './highlight/highlight-service';
+import {
+ AccountsModel,
+ accountsModelToken,
+} from '../models/accounts-model/accounts-model';
import {
DashboardViewModel,
dashboardViewModelToken,
@@ -47,165 +50,191 @@ import {
agreementViewModelToken,
} from '../models/views/agreement';
import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
-import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
import {
DocumentationViewModel,
documentationViewModelToken,
} from '../models/views/documentation';
-import {EditViewModel, editViewModelToken} from '../models/views/edit';
import {GroupViewModel, groupViewModelToken} from '../models/views/group';
import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
+import {
+ PluginLoader,
+ pluginLoaderToken,
+} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {authServiceToken} from './gr-auth/gr-auth';
import {
- NavigationService,
- navigationToken,
-} from '../elements/core/gr-navigation/gr-navigation';
+ ServiceWorkerInstaller,
+ serviceWorkerInstallerToken,
+} from './service-worker-installer';
+import {
+ RelatedChangesModel,
+ relatedChangesModelToken,
+} from '../models/change/related-changes-model';
/**
* The AppContext lazy initializator for all services
*/
export function createAppContext(): AppContext & Finalizable {
const appRegistry: Registry<AppContext> = {
- routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
flagsService: (_ctx: Partial<AppContext>) =>
new FlagsServiceImplementation(),
reportingService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.flagsService, 'flagsService)');
return new GrReporting(ctx.flagsService);
},
- eventEmitter: (_ctx: Partial<AppContext>) => new EventEmitter(),
- authService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.eventEmitter, 'eventEmitter');
- return new Auth(ctx.eventEmitter);
- },
+ authService: (_ctx: Partial<AppContext>) => new Auth(),
restApiService: (ctx: Partial<AppContext>) => {
assertIsDefined(ctx.authService, 'authService');
- assertIsDefined(ctx.flagsService, 'flagsService');
- return new GrRestApiServiceImpl(ctx.authService, ctx.flagsService);
- },
- jsApiService: (ctx: Partial<AppContext>) => {
- const reportingService = ctx.reportingService;
- assertIsDefined(reportingService, 'reportingService');
- return new GrJsApiInterface(reportingService);
- },
- storageService: (_ctx: Partial<AppContext>) => new GrStorageService(),
- userModel: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new UserModel(ctx.restApiService);
- },
- accountsModel: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new AccountsModel(ctx.restApiService);
- },
- pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
- highlightService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new HighlightService(ctx.reportingService);
+ return new GrRestApiServiceImpl(ctx.authService);
},
};
return create<AppContext>(appRegistry);
}
-export function createAppDependencies(
- appContext: AppContext
-): Map<DependencyToken<unknown>, Finalizable> {
- const dependencies = new Map<DependencyToken<unknown>, Finalizable>();
- const browserModel = new BrowserModel(appContext.userModel);
- dependencies.set(browserModelToken, browserModel);
-
- const adminViewModel = new AdminViewModel();
- dependencies.set(adminViewModelToken, adminViewModel);
- const agreementViewModel = new AgreementViewModel();
- dependencies.set(agreementViewModelToken, agreementViewModel);
- const changeViewModel = new ChangeViewModel();
- dependencies.set(changeViewModelToken, changeViewModel);
- const dashboardViewModel = new DashboardViewModel();
- dependencies.set(dashboardViewModelToken, dashboardViewModel);
- const diffViewModel = new DiffViewModel();
- dependencies.set(diffViewModelToken, diffViewModel);
- const documentationViewModel = new DocumentationViewModel();
- dependencies.set(documentationViewModelToken, documentationViewModel);
- const editViewModel = new EditViewModel();
- dependencies.set(editViewModelToken, editViewModel);
- const groupViewModel = new GroupViewModel();
- dependencies.set(groupViewModelToken, groupViewModel);
- const pluginViewModel = new PluginViewModel();
- dependencies.set(pluginViewModelToken, pluginViewModel);
- const repoViewModel = new RepoViewModel();
- dependencies.set(repoViewModelToken, repoViewModel);
- const searchViewModel = new SearchViewModel(
- appContext.restApiService,
- appContext.userModel,
- () => dependencies.get(navigationToken) as unknown as NavigationService
- );
- dependencies.set(searchViewModelToken, searchViewModel);
- const settingsViewModel = new SettingsViewModel();
- dependencies.set(settingsViewModelToken, settingsViewModel);
+export type Creator<T> = () => T & Finalizable;
- const router = new GrRouter(
- appContext.reportingService,
- appContext.routerModel,
- appContext.restApiService,
- adminViewModel,
- agreementViewModel,
- changeViewModel,
- dashboardViewModel,
- diffViewModel,
- documentationViewModel,
- editViewModel,
- groupViewModel,
- pluginViewModel,
- repoViewModel,
- searchViewModel,
- settingsViewModel
- );
- dependencies.set(routerToken, router);
- dependencies.set(navigationToken, router);
-
- const changeModel = new ChangeModel(
- appContext.routerModel,
- appContext.restApiService,
- appContext.userModel
- );
- dependencies.set(changeModelToken, changeModel);
-
- const accountsModel = new AccountsModel(appContext.restApiService);
-
- const commentsModel = new CommentsModel(
- appContext.routerModel,
- changeModel,
- accountsModel,
- appContext.restApiService,
- appContext.reportingService
- );
- dependencies.set(commentsModelToken, commentsModel);
-
- const filesModel = new FilesModel(
- changeModel,
- commentsModel,
- appContext.restApiService
- );
- dependencies.set(filesModelToken, filesModel);
-
- const configModel = new ConfigModel(changeModel, appContext.restApiService);
- dependencies.set(configModelToken, configModel);
-
- const checksModel = new ChecksModel(
- appContext.routerModel,
- changeViewModel,
- changeModel,
- appContext.reportingService,
- appContext.pluginsModel
- );
-
- dependencies.set(checksModelToken, checksModel);
-
- const shortcutsService = new ShortcutsService(
- appContext.userModel,
- appContext.reportingService
- );
- dependencies.set(shortcutsServiceToken, shortcutsService);
-
- return dependencies;
+// Dependencies are provided as creator functions to ensure that they are
+// not created until they are utilized.
+// This is mainly useful in tests: E.g. don't create a
+// change-model in change-model_test.ts because it creates one in the test
+// after setting up stubs.
+export function createAppDependencies(
+ appContext: AppContext,
+ resolver: <T>(token: DependencyToken<T>) => T
+): Map<DependencyToken<unknown>, Creator<unknown>> {
+ return new Map<DependencyToken<unknown>, Creator<unknown>>([
+ [authServiceToken, () => appContext.authService],
+ [routerModelToken, () => new RouterModel()],
+ [userModelToken, () => new UserModel(appContext.restApiService)],
+ [browserModelToken, () => new BrowserModel(resolver(userModelToken))],
+ [accountsModelToken, () => new AccountsModel(appContext.restApiService)],
+ [adminViewModelToken, () => new AdminViewModel()],
+ [agreementViewModelToken, () => new AgreementViewModel()],
+ [changeViewModelToken, () => new ChangeViewModel()],
+ [dashboardViewModelToken, () => new DashboardViewModel()],
+ [documentationViewModelToken, () => new DocumentationViewModel()],
+ [groupViewModelToken, () => new GroupViewModel()],
+ [pluginViewModelToken, () => new PluginViewModel()],
+ [repoViewModelToken, () => new RepoViewModel()],
+ [
+ searchViewModelToken,
+ () =>
+ new SearchViewModel(
+ appContext.restApiService,
+ resolver(userModelToken),
+ () => resolver(navigationToken)
+ ),
+ ],
+ [settingsViewModelToken, () => new SettingsViewModel()],
+ [
+ routerToken,
+ () =>
+ new GrRouter(
+ appContext.reportingService,
+ resolver(routerModelToken),
+ appContext.restApiService,
+ resolver(adminViewModelToken),
+ resolver(agreementViewModelToken),
+ resolver(changeViewModelToken),
+ resolver(dashboardViewModelToken),
+ resolver(documentationViewModelToken),
+ resolver(groupViewModelToken),
+ resolver(pluginViewModelToken),
+ resolver(repoViewModelToken),
+ resolver(searchViewModelToken),
+ resolver(settingsViewModelToken)
+ ),
+ ],
+ [navigationToken, () => resolver(routerToken)],
+ [
+ changeModelToken,
+ () =>
+ new ChangeModel(
+ resolver(navigationToken),
+ resolver(changeViewModelToken),
+ appContext.restApiService,
+ resolver(userModelToken),
+ resolver(pluginLoaderToken),
+ appContext.reportingService
+ ),
+ ],
+ [
+ commentsModelToken,
+ () =>
+ new CommentsModel(
+ resolver(changeViewModelToken),
+ resolver(changeModelToken),
+ resolver(accountsModelToken),
+ appContext.restApiService,
+ appContext.reportingService,
+ resolver(navigationToken)
+ ),
+ ],
+ [
+ filesModelToken,
+ () =>
+ new FilesModel(
+ resolver(changeModelToken),
+ resolver(commentsModelToken),
+ appContext.restApiService,
+ appContext.reportingService
+ ),
+ ],
+ [
+ configModelToken,
+ () =>
+ new ConfigModel(resolver(changeModelToken), appContext.restApiService),
+ ],
+ [
+ relatedChangesModelToken,
+ () =>
+ new RelatedChangesModel(
+ resolver(changeModelToken),
+ resolver(configModelToken),
+ appContext.restApiService
+ ),
+ ],
+ [
+ pluginLoaderToken,
+ () =>
+ new PluginLoader(
+ appContext.reportingService,
+ appContext.restApiService
+ ),
+ ],
+ [
+ checksModelToken,
+ () =>
+ new ChecksModel(
+ resolver(changeViewModelToken),
+ resolver(changeModelToken),
+ appContext.reportingService,
+ resolver(pluginLoaderToken).pluginsModel
+ ),
+ ],
+ [
+ shortcutsServiceToken,
+ () =>
+ new ShortcutsService(
+ resolver(userModelToken),
+ appContext.reportingService
+ ),
+ ],
+ [storageServiceToken, () => new GrStorageService()],
+ [
+ highlightServiceToken,
+ () => new HighlightService(appContext.reportingService),
+ ],
+ [
+ serviceWorkerInstallerToken,
+ () =>
+ new ServiceWorkerInstaller(
+ appContext.flagsService,
+ appContext.reportingService,
+ resolver(userModelToken)
+ ),
+ ],
+ ]);
}
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index 5f47c43ded..aa2c032b84 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -5,31 +5,15 @@
*/
import {Finalizable} from './registry';
import {FlagsService} from './flags/flags';
-import {EventEmitterService} from './gr-event-interface/gr-event-interface';
import {ReportingService} from './gr-reporting/gr-reporting';
import {AuthService} from './gr-auth/gr-auth';
import {RestApiService} from './gr-rest-api/gr-rest-api';
-import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
-import {StorageService} from './storage/gr-storage';
-import {UserModel} from '../models/user/user-model';
-import {RouterModel} from './router/router-model';
-import {PluginsModel} from '../models/plugins/plugins-model';
-import {HighlightService} from './highlight/highlight-service';
-import {AccountsModel} from '../models/accounts-model/accounts-model';
export interface AppContext {
- routerModel: RouterModel;
flagsService: FlagsService;
reportingService: ReportingService;
- eventEmitter: EventEmitterService;
authService: AuthService;
restApiService: RestApiService;
- jsApiService: JsApiService;
- storageService: StorageService;
- userModel: UserModel;
- accountsModel: AccountsModel;
- pluginsModel: PluginsModel;
- highlightService: HighlightService;
}
/**
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index c3148a0f6b..7488e796e7 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -17,10 +17,6 @@ export enum KnownExperimentId {
NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
CHECKS_DEVELOPER = 'UiFeature__checks_developer',
PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer',
- DIFF_RENDERING_LIT = 'UiFeature__diff_rendering_lit',
PUSH_NOTIFICATIONS = 'UiFeature__push_notifications',
SUGGEST_EDIT = 'UiFeature__suggest_edit',
- CHECKS_FIXES = 'UiFeature__checks_fixes',
- MENTION_USERS = 'UiFeature__mention_users',
- RENDER_MARKDOWN = 'UiFeature__render_markdown',
}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
index 1dc4a84059..945d6f9dd0 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -3,6 +3,8 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import {define} from '../../models/dependency';
+import {AuthRequestInit} from '../../types/types';
import {Finalizable} from '../registry';
export enum AuthType {
XSRF_TOKEN = 'xsrf_token',
@@ -27,12 +29,7 @@ export interface DefaultAuthOptions {
credentials: RequestCredentials;
}
-export interface AuthRequestInit extends RequestInit {
- // RequestInit define headers as HeadersInit, i.e.
- // Headers | string[][] | Record<string, string>
- // Auth class supports only Headers in options
- headers?: Headers;
-}
+export const authServiceToken = define<AuthService>('auth-service');
export interface AuthService extends Finalizable {
baseUrl: string;
@@ -53,5 +50,5 @@ export interface AuthService extends Finalizable {
/**
* Perform network fetch with authentication.
*/
- fetch(url: string, opt_options?: AuthRequestInit): Promise<Response>;
+ fetch(url: string, options?: AuthRequestInit): Promise<Response>;
}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
index 8a4e51fb10..2312fc960a 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -3,11 +3,11 @@
* Copyright 2017 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import {AuthRequestInit} from '../../types/types';
+import {fire} from '../../utils/event-util';
import {getBaseUrl} from '../../utils/url-util';
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
import {Finalizable} from '../registry';
import {
- AuthRequestInit,
AuthService,
AuthStatus,
AuthType,
@@ -67,11 +67,8 @@ export class Auth implements AuthService, Finalizable {
private getToken: GetTokenCallback;
- public eventEmitter: EventEmitterService;
-
- constructor(eventEmitter: EventEmitterService) {
+ constructor() {
this.getToken = () => Promise.resolve(this.cachedTokenPromise);
- this.eventEmitter = eventEmitter;
}
get baseUrl() {
@@ -130,7 +127,7 @@ export class Auth implements AuthService, Finalizable {
if (this._status === status) return;
if (this._status === AuthStatus.AUTHED) {
- this.eventEmitter.emit('auth-error', {
+ fire(document, 'auth-error', {
message: Auth.CREDS_EXPIRED_MSG,
action: 'Refresh credentials',
});
@@ -165,18 +162,18 @@ export class Auth implements AuthService, Finalizable {
/**
* Perform network fetch with authentication.
*/
- fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
- const options: AuthRequestInitWithHeaders = {
+ fetch(url: string, options?: AuthRequestInit): Promise<Response> {
+ const optionsWithHeaders: AuthRequestInitWithHeaders = {
headers: new Headers(),
...this.defaultOptions,
- ...opt_options,
+ ...options,
};
if (this.type === AuthType.ACCESS_TOKEN) {
return this._getAccessToken().then(accessToken =>
- this._fetchWithAccessToken(url, options, accessToken)
+ this._fetchWithAccessToken(url, optionsWithHeaders, accessToken)
);
} else {
- return this._fetchWithXsrfToken(url, options);
+ return this._fetchWithXsrfToken(url, optionsWithHeaders);
}
}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
index cc34681eb5..9cdd37e2f0 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -3,9 +3,9 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {AuthRequestInit} from '../../types/types';
+import {fire} from '../../utils/event-util';
import {
- AuthRequestInit,
AuthService,
AuthStatus,
DefaultAuthOptions,
@@ -18,11 +18,7 @@ export class GrAuthMock implements AuthService {
private _status = AuthStatus.UNDETERMINED;
- public eventEmitter: EventEmitterService;
-
- constructor(eventEmitter: EventEmitterService) {
- this.eventEmitter = eventEmitter;
- }
+ constructor() {}
get isAuthed() {
return this._status === Auth.STATUS.AUTHED;
@@ -33,7 +29,7 @@ export class GrAuthMock implements AuthService {
private _setStatus(status: AuthStatus) {
if (this._status === status) return;
if (this._status === AuthStatus.AUTHED) {
- this.eventEmitter.emit('auth-error', {
+ fire(document, 'auth-error', {
message: Auth.CREDS_EXPIRED_MSG,
action: 'Refresh credentials',
});
@@ -61,7 +57,7 @@ export class GrAuthMock implements AuthService {
setup(_getToken: GetTokenCallback, _defaultOptions: DefaultAuthOptions) {}
- fetch(_url: string, _opt_options?: AuthRequestInit): Promise<Response> {
+ fetch(_url: string, _options?: AuthRequestInit): Promise<Response> {
return Promise.resolve(new Response());
}
}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
index 4552dadfd0..b9cef86de0 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -5,22 +5,17 @@
*/
import '../../test/common-test-setup';
import {Auth} from './gr-auth_impl';
-import {getAppContext} from '../app-context';
import {stubBaseUrl} from '../../test/test-utils';
-import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
import {SinonFakeTimers} from 'sinon';
-import {AuthRequestInit, DefaultAuthOptions} from './gr-auth';
+import {DefaultAuthOptions} from './gr-auth';
import {assert} from '@open-wc/testing';
+import {AuthRequestInit} from '../../types/types';
suite('gr-auth', () => {
let auth: Auth;
- let eventEmitter: EventEmitterService;
setup(() => {
- // TODO(poucet): Mock the eventEmitter completely instead of getting it
- // from appContext.
- eventEmitter = getAppContext().eventEmitter;
- auth = new Auth(eventEmitter);
+ auth = new Auth();
});
suite('Auth class methods', () => {
@@ -118,11 +113,13 @@ suite('gr-auth', () => {
assert.equal(auth.status, Auth.STATUS.AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.resolve({status: 403}));
- const emitStub = sinon.stub(eventEmitter, 'emit');
+ const emitStub = sinon.stub();
+ document.addEventListener('auth-error', emitStub);
const authed2 = await auth.authCheck();
assert.isFalse(authed2);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
assert.isTrue(emitStub.called);
+ document.removeEventListener('auth-error', emitStub);
});
test('fire event when switch from authed to error', async () => {
@@ -132,11 +129,13 @@ suite('gr-auth', () => {
assert.equal(auth.status, Auth.STATUS.AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.reject(new Error('random error')));
- const emitStub = sinon.stub(eventEmitter, 'emit');
+ const emitStub = sinon.stub();
+ document.addEventListener('auth-error', emitStub);
const authed2 = await auth.authCheck();
assert.isFalse(authed2);
assert.isTrue(emitStub.called);
assert.equal(auth.status, Auth.STATUS.ERROR);
+ document.removeEventListener('auth-error', emitStub);
});
test('no event from non-authed to other status', async () => {
@@ -146,11 +145,13 @@ suite('gr-auth', () => {
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.resolve({status: 204}));
- const emitStub = sinon.stub(eventEmitter, 'emit');
+ const emitStub = sinon.stub();
+ document.addEventListener('auth-error', emitStub);
const authed2 = await auth.authCheck();
assert.isTrue(authed2);
assert.isFalse(emitStub.called);
assert.equal(auth.status, Auth.STATUS.AUTHED);
+ document.removeEventListener('auth-error', emitStub);
});
test('no event from non-authed to other status', async () => {
@@ -160,11 +161,13 @@ suite('gr-auth', () => {
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.reject(new Error('random error')));
- const emitStub = sinon.stub(eventEmitter, 'emit');
+ const emitStub = sinon.stub();
+ document.addEventListener('auth-error', emitStub);
const authed2 = await auth.authCheck();
assert.isFalse(authed2);
assert.isFalse(emitStub.called);
assert.equal(auth.status, Auth.STATUS.ERROR);
+ document.removeEventListener('auth-error', emitStub);
});
});
@@ -205,9 +208,9 @@ suite('gr-auth', () => {
let getToken: sinon.SinonStub;
- const makeToken = (opt_accessToken?: string) => {
+ const makeToken = (accessToken?: string) => {
return {
- access_token: opt_accessToken || 'zbaz',
+ access_token: accessToken || 'zbaz',
expires_at: new Date(Date.now() + 10e8).getTime(),
};
};
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
deleted file mode 100644
index 4153b3d83a..0000000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {Finalizable} from '../registry';
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type EventCallback = (...args: any) => void;
-export type UnsubscribeMethod = () => void;
-
-export interface EventEmitterService extends Finalizable {
- /**
- * Register an event listener to an event.
- */
- addListener(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
- /**
- * Alias for addListener.
- */
- on(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
- /**
- * Attach event handler only once. Automatically removed.
- */
- once(eventName: string, cb: EventCallback): UnsubscribeMethod;
-
- /**
- * De-register an event listener to an event.
- */
- removeListener(eventName: string, cb: EventCallback): void;
-
- /**
- * Alias to removeListener
- */
- off(eventName: string, cb: EventCallback): void;
-
- /**
- * Synchronously calls each of the listeners registered for
- * the event named eventName, in the order they were registered,
- * passing the supplied detail to each.
- *
- * @return true if the event had listeners, false otherwise.
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- emit(eventName: string, detail: any): boolean;
-
- /**
- * Alias to emit.
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- dispatch(eventName: string, detail: any): boolean;
-
- /**
- * Remove listeners for a specific event or all.
- *
- * @param eventName if not provided, will remove all
- */
- removeAllListeners(eventName: string): void;
-}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
deleted file mode 100644
index 7228282e4e..0000000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {Finalizable} from '../registry';
-import {
- EventCallback,
- EventEmitterService,
- UnsubscribeMethod,
-} from './gr-event-interface';
-/**
- * An lite implementation of
- * https://nodejs.org/api/events.html#events_class_eventemitter.
- *
- * This is unrelated to the native DOM events, you should use it when you want
- * to enable EventEmitter interface on any class.
- *
- * @example
- *
- * class YourClass extends EventEmitter {
- * // now all instance of YourClass will have this EventEmitter interface
- * }
- *
- */
-export class EventEmitter implements EventEmitterService, Finalizable {
- private _listenersMap = new Map<string, EventCallback[]>();
-
- finalize() {
- this.removeAllListeners();
- }
-
- /**
- * Register an event listener to an event.
- */
- addListener(eventName: string, cb: EventCallback): UnsubscribeMethod {
- if (!eventName || !cb) {
- console.warn('A valid eventname and callback is required!');
- return () => {};
- }
-
- const listeners = this._listenersMap.get(eventName) || [];
- listeners.push(cb);
- this._listenersMap.set(eventName, listeners);
-
- return () => {
- this.off(eventName, cb);
- };
- }
-
- /**
- * Alias for addListener.
- */
- on(eventName: string, cb: EventCallback): UnsubscribeMethod {
- return this.addListener(eventName, cb);
- }
-
- /**
- * Attach event handler only once. Automatically removed.
- */
- once(eventName: string, cb: EventCallback): UnsubscribeMethod {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const onceWrapper = (...args: any[]) => {
- cb(...args);
- this.off(eventName, onceWrapper);
- };
- return this.on(eventName, onceWrapper);
- }
-
- /**
- * De-register an event listener to an event.
- */
- removeListener(eventName: string, cb: EventCallback): void {
- let listeners = this._listenersMap.get(eventName) || [];
- listeners = listeners.filter(listener => listener !== cb);
- this._listenersMap.set(eventName, listeners);
- }
-
- /**
- * Alias to removeListener
- */
- off(eventName: string, cb: EventCallback): void {
- this.removeListener(eventName, cb);
- }
-
- /**
- * Synchronously calls each of the listeners registered for
- * the event named eventName, in the order they were registered,
- * passing the supplied detail to each.
- *
- * @return true if the event had listeners, false otherwise.
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- emit(eventName: string, detail: any): boolean {
- const listeners = this._listenersMap.get(eventName) || [];
- for (const listener of listeners) {
- try {
- listener(detail);
- } catch (e) {
- console.error(e);
- }
- }
- return listeners.length !== 0;
- }
-
- /**
- * Alias to emit.
- */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- dispatch(eventName: string, detail: any): boolean {
- return this.emit(eventName, detail);
- }
-
- /**
- * Remove listeners for a specific event or all.
- *
- * @param eventName if not provided, will remove all
- */
- removeAllListeners(eventName?: string): void {
- if (eventName) {
- this._listenersMap.set(eventName, []);
- } else {
- this._listenersMap = new Map<string, EventCallback[]>();
- }
- }
-}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
deleted file mode 100644
index a63eda375c..0000000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * @license
- * Copyright 2019 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../test/common-test-setup';
-import {mockPromise} from '../../test/test-utils';
-import {EventEmitter} from './gr-event-interface_impl';
-import {assert} from '@open-wc/testing';
-
-suite('gr-event-interface tests', () => {
- let gerrit;
- setup(() => {
- gerrit = window.Gerrit;
- });
-
- suite('test on Gerrit', () => {
- setup(() => {
- gerrit.removeAllListeners();
- });
-
- test('communicate between plugin and Gerrit', async () => {
- const eventName = 'test-plugin-event';
- let p;
- const promise = mockPromise();
- gerrit.on(eventName, e => {
- assert.equal(e.value, 'test');
- assert.equal(e.plugin, p);
- promise.resolve();
- });
- gerrit.install(plugin => {
- p = plugin;
- gerrit.emit(eventName, {value: 'test', plugin});
- }, '0.1',
- 'http://test.com/plugins/testplugin/static/test.js');
- await promise;
- });
-
- test('listen on events from core', async () => {
- const eventName = 'test-plugin-event';
- const promise = mockPromise();
- gerrit.on(eventName, e => {
- assert.equal(e.value, 'test');
- promise.resolve();
- });
-
- gerrit.emit(eventName, {value: 'test'});
- await promise;
- });
-
- test('communicate across plugins', async () => {
- const eventName = 'test-plugin-event';
- const promise = mockPromise();
- gerrit.install(plugin => {
- gerrit.on(eventName, e => {
- assert.equal(e.plugin.getPluginName(), 'testB');
- promise.resolve();
- });
- }, '0.1',
- 'http://test.com/plugins/testA/static/testA.js');
-
- gerrit.install(plugin => {
- gerrit.emit(eventName, {plugin});
- }, '0.1',
- 'http://test.com/plugins/testB/static/testB.js');
- await promise;
- });
- });
-
- suite('test on interfaces', () => {
- let testObj;
-
- class TestClass extends EventEmitter {
- }
-
- setup(() => {
- testObj = new TestClass();
- });
-
- test('on', () => {
- const cbStub = sinon.stub();
- testObj.on('test', cbStub);
- testObj.emit('test');
- testObj.emit('test');
- assert.isTrue(cbStub.calledTwice);
- });
-
- test('once', () => {
- const cbStub = sinon.stub();
- testObj.once('test', cbStub);
- testObj.emit('test');
- testObj.emit('test');
- assert.isTrue(cbStub.calledOnce);
- });
-
- test('unsubscribe', () => {
- const cbStub = sinon.stub();
- const unsubscribe = testObj.on('test', cbStub);
- testObj.emit('test');
- unsubscribe();
- testObj.emit('test');
- assert.isTrue(cbStub.calledOnce);
- });
-
- test('off', () => {
- const cbStub = sinon.stub();
- testObj.on('test', cbStub);
- testObj.emit('test');
- testObj.off('test', cbStub);
- testObj.emit('test');
- assert.isTrue(cbStub.calledOnce);
- });
-
- test('removeAllListeners', () => {
- const cbStub = sinon.stub();
- testObj.on('test', cbStub);
- testObj.removeAllListeners('test');
- testObj.emit('test');
- assert.isTrue(cbStub.notCalled);
- });
- });
-});
-
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index f5527625fd..49259b496e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -29,7 +29,7 @@ export interface ReportingService extends Finalizable {
eventName: string,
eventValue?: EventValue,
eventDetails?: EventDetails,
- opt_noLog?: boolean
+ noLog?: boolean
): void;
appStarted(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index dadf9e42ef..55bb64cbd7 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -16,7 +16,8 @@ import {
LifeCycle,
Timing,
} from '../../constants/reporting';
-import {getCLS, getFID, getLCP, Metric} from 'web-vitals';
+import {onCLS, onFID, onLCP, Metric, onINP} from 'web-vitals';
+import {getEventPath, isElementTarget} from '../../utils/dom-util';
// Latency reporting constants.
@@ -130,8 +131,8 @@ export function initErrorReporter(reportingService: ReportingService) {
};
// TODO(dmfilippov): TS-fix-any unclear what is context
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const catchErrors = function (opt_context?: any) {
- const context = opt_context || window;
+ const catchErrors = function (context?: any) {
+ context = context || window;
const oldOnError = context.onerror;
context.onerror = (
event: Event | string,
@@ -186,21 +187,47 @@ export function initVisibilityReporter(reportingService: ReportingService) {
});
}
+export function initClickReporter(reportingService: ReportingService) {
+ document.addEventListener('click', (e: MouseEvent) => {
+ const anchorEl = e
+ .composedPath()
+ .find(el => isElementTarget(el) && el.tagName.toUpperCase() === 'A') as
+ | HTMLAnchorElement
+ | undefined;
+ if (!anchorEl) return;
+ reportingService.reportInteraction(Interaction.LINK_CLICK, {
+ path: getEventPath(e),
+ link: anchorEl.href,
+ text: anchorEl.innerText,
+ });
+ });
+}
+
export function initWebVitals(reportingService: ReportingService) {
function reportWebVitalMetric(name: Timing, metric: Metric) {
+ let score = metric.value;
+ // CLS good score is 0.1 and poor score is 0.25. Logging system
+ // prefers integers, so we multiple by 100;
+ if (name === Timing.CLS) {
+ score *= 100;
+ }
reportingService.reporter(
TIMING.TYPE,
TIMING.CATEGORY.UI_LATENCY,
name,
- metric.value,
- JSON.stringify(metric),
- false
+ score,
+ {
+ navigationType: metric.navigationType,
+ rating: metric.rating,
+ entries: metric.entries,
+ }
);
}
- getCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
- getFID(metric => reportWebVitalMetric(Timing.FID, metric));
- getLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
+ onCLS(metric => reportWebVitalMetric(Timing.CLS, metric));
+ onFID(metric => reportWebVitalMetric(Timing.FID, metric));
+ onLCP(metric => reportWebVitalMetric(Timing.LCP, metric));
+ onINP(metric => reportWebVitalMetric(Timing.INP, metric));
}
// Calculates the time of Gerrit being in a background tab. When Gerrit reports
@@ -360,18 +387,18 @@ export class GrReporting implements ReportingService, Finalizable {
// We cache until metrics plugin is loaded
this.pending.push([eventInfo, noLog]);
if (this._isMetricsPluginLoaded()) {
- this.pending.forEach(([eventInfo, opt_noLog]) => {
- this._reportEvent(eventInfo, opt_noLog);
+ this.pending.forEach(([eventInfo, noLog]) => {
+ this._reportEvent(eventInfo, noLog);
});
this.pending = [];
}
}
}
- private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
+ private _reportEvent(eventInfo: EventInfo, noLog?: boolean) {
const {type, value, name, eventDetails} = eventInfo;
document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
- if (opt_noLog) {
+ if (noLog) {
return;
}
if (type !== ERROR.TYPE) {
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 37231ad076..ed835e614b 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
@@ -20,7 +20,6 @@ import {
import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {parseDate} from '../../utils/date-util';
import {getBaseUrl} from '../../utils/url-util';
-import {getAppContext} from '../app-context';
import {Finalizable} from '../registry';
import {getParentIndex, isMergeParent} from '../../utils/patch-set-util';
import {
@@ -28,7 +27,7 @@ import {
listChangesOptionsToHex,
} from '../../utils/change-util';
import {assertNever, hasOwnProperty} from '../../utils/common-util';
-import {AuthRequestInit, AuthService} from '../gr-auth/gr-auth';
+import {AuthService} from '../gr-auth/gr-auth';
import {
AccountCapabilityInfo,
AccountDetailInfo,
@@ -94,13 +93,12 @@ import {
Password,
PatchRange,
PatchSetNum,
- PathToCommentsInfoMap,
PathToRobotCommentsInfoMap,
PluginInfo,
PreferencesInfo,
PreferencesInput,
ProjectAccessInfo,
- ProjectAccessInfoMap,
+ RepoAccessInfoMap,
ProjectAccessInput,
ProjectInfo,
ProjectInfoWithName,
@@ -120,6 +118,7 @@ import {
TopMenuEntryInfo,
UrlEncodedCommentId,
FixReplacementInfo,
+ DraftInfo,
} from '../../types/common';
import {
DiffInfo,
@@ -141,14 +140,16 @@ import {
ReviewerState,
} from '../../constants/constants';
import {firePageError, fireServerError} from '../../utils/event-util';
-import {ParsedChangeInfo} from '../../types/types';
+import {AuthRequestInit, ParsedChangeInfo} from '../../types/types';
import {ErrorCallback} from '../../api/rest';
-import {addDraftProp, DraftInfo} from '../../utils/comment-util';
-import {BaseScheduler} from '../scheduler/scheduler';
+import {addDraftProp} from '../../utils/comment-util';
+import {BaseScheduler, Scheduler} from '../scheduler/scheduler';
import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler';
-import {FlagsService} from '../flags/flags';
+import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util';
const MAX_PROJECT_RESULTS = 25;
+export const PROBE_PATH = '/Documentation/index.html';
+export const DOCS_BASE_PATH = '/Documentation';
const Requests = {
SEND_DIFF_DRAFT: 'sendDiffDraft',
@@ -255,13 +256,13 @@ interface GetDiffParams {
type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
-export function _testOnlyResetGrRestApiSharedObjects() {
+export function testOnlyResetGrRestApiSharedObjects(authService: AuthService) {
siteBasedCache = new SiteBasedCache();
fetchPromisesCache = new FetchPromisesCache();
pendingRequest = {};
grEtagDecorator = new GrEtagDecorator();
projectLookup = {};
- getAppContext().authService.clearCache();
+ authService.clearCache();
}
function createReadScheduler() {
@@ -271,6 +272,11 @@ function createReadScheduler() {
function createWriteScheduler() {
return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 5);
}
+
+function createSerializingScheduler() {
+ return new MaxInFlightScheduler<Response>(new BaseScheduler<Response>(), 1);
+}
+
export class GrRestApiServiceImpl implements RestApiService, Finalizable {
readonly _cache = siteBasedCache; // Shared across instances.
@@ -280,16 +286,19 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
readonly _etags = grEtagDecorator; // Shared across instances.
- readonly _projectLookup = projectLookup; // Shared across instances.
+ getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
+
+ // readonly, but set in tests.
+ _projectLookup = projectLookup; // Shared across instances.
// The value is set in created, before any other actions
- private readonly _restApiHelper: GrRestApiHelper;
+ // Private, but used in tests.
+ readonly _restApiHelper: GrRestApiHelper;
- constructor(
- private readonly authService: AuthService,
- // @ts-ignore: it's ok.
- private readonly _flagsService: FlagsService
- ) {
+ // Used to serialize requests for certain RPCs
+ readonly _serialScheduler: Scheduler<Response>;
+
+ constructor(private readonly authService: AuthService) {
this._restApiHelper = new GrRestApiHelper(
this._cache,
this.authService,
@@ -297,6 +306,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
createReadScheduler(),
createWriteScheduler()
);
+ this._serialScheduler = createSerializingScheduler();
}
finalize() {}
@@ -350,13 +360,13 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}) as Promise<ConfigInfo | undefined>;
}
- getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> {
+ getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined> {
// TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
// supports it.
return this._fetchSharedCacheURL({
url: '/access/?project=' + encodeURIComponent(repo),
anonymizedUrl: '/access/?project=*',
- }) as Promise<ProjectAccessInfoMap | undefined>;
+ }) as Promise<RepoAccessInfoMap | undefined>;
}
getRepoDashboards(
@@ -763,7 +773,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
userId: AccountId | EmailAddress,
errFn?: ErrorCallback
): Promise<AccountDetailInfo | undefined> {
- return this._restApiHelper.fetchJSON({
+ return this._fetchSharedCacheURL({
url: `/accounts/${encodeURIComponent(userId)}/detail`,
anonymizedUrl: '/accounts/*/detail',
errFn,
@@ -1079,7 +1089,8 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
changesPerPage?: number,
query?: string,
offset?: 'n,z' | number,
- options?: string
+ options?: string,
+ errFn?: ErrorCallback
): Promise<ChangeInfo[] | undefined> {
const request = this.getRequestForGetChanges(
changesPerPage,
@@ -1089,9 +1100,13 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
);
return Promise.resolve(
- this._restApiHelper.fetchJSON(request, true) as Promise<
- ChangeInfo[] | undefined
- >
+ this._restApiHelper.fetchJSON(
+ {
+ ...request,
+ errFn,
+ },
+ true
+ ) as Promise<ChangeInfo[] | undefined>
).then(response => {
if (!response) {
return;
@@ -1114,7 +1129,6 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
undefined,
listChangesOptionsToHex(
ListChangesOption.CHANGE_ACTIONS,
- ListChangesOption.CURRENT_ACTIONS,
ListChangesOption.CURRENT_REVISION,
ListChangesOption.DETAILED_LABELS,
// TODO: remove this option and merge requirements from dashboard req
@@ -1316,13 +1330,15 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
queryChangeFiles(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
- query: string
+ query: string,
+ errFn?: ErrorCallback
) {
return this._getChangeURLAndFetch({
changeNum,
endpoint: `/files?q=${encodeURIComponent(query)}`,
revision: patchNum,
anonymizedEndpoint: '/files?q=*',
+ errFn,
}) as Promise<string[] | undefined>;
}
@@ -1353,22 +1369,37 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
>;
}
- getChangeSuggestedReviewers(changeNum: NumericChangeId, inputVal: string) {
+ getChangeSuggestedReviewers(
+ changeNum: NumericChangeId,
+ inputVal: string,
+ errFn?: ErrorCallback
+ ) {
return this._getChangeSuggestedGroup(
ReviewerState.REVIEWER,
changeNum,
- inputVal
+ inputVal,
+ errFn
);
}
- getChangeSuggestedCCs(changeNum: NumericChangeId, inputVal: string) {
- return this._getChangeSuggestedGroup(ReviewerState.CC, changeNum, inputVal);
+ getChangeSuggestedCCs(
+ changeNum: NumericChangeId,
+ inputVal: string,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeSuggestedGroup(
+ ReviewerState.CC,
+ changeNum,
+ inputVal,
+ errFn
+ );
}
_getChangeSuggestedGroup(
reviewerState: ReviewerState,
changeNum: NumericChangeId,
- inputVal: string
+ inputVal: string,
+ errFn?: ErrorCallback
): Promise<SuggestedReviewerInfo[] | undefined> {
// More suggestions may obscure content underneath in the reply dialog,
// see issue 10793.
@@ -1384,6 +1415,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
endpoint: '/suggest_reviewers',
params,
reportEndpointAsIs: true,
+ errFn,
}) as Promise<SuggestedReviewerInfo[] | undefined>;
}
@@ -1471,7 +1503,8 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
async getRepos(
filter: string | undefined,
reposPerPage: number,
- offset?: number
+ offset?: number,
+ errFn?: ErrorCallback
): Promise<ProjectInfoWithName[] | undefined> {
const [isQuery, url] = this._getReposUrl(filter, reposPerPage, offset);
@@ -1485,11 +1518,13 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
return this._fetchSharedCacheURL({
url,
anonymizedUrl: '/projects/?*',
+ errFn,
}) as Promise<ProjectInfoWithName[] | undefined>;
} else {
const result = await (this._fetchSharedCacheURL({
url,
anonymizedUrl: '/projects/?*',
+ errFn,
}) as Promise<NameToProjectInfoMap | undefined>);
if (result === undefined) return [];
return Object.entries(result).map(([name, project]) => {
@@ -1615,7 +1650,8 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
getSuggestedGroups(
inputVal: string,
project?: RepoName,
- n?: number
+ n?: number,
+ errFn?: ErrorCallback
): Promise<GroupNameToGroupInfoMap | undefined> {
const params: QueryGroupsParams = {s: inputVal};
if (n) {
@@ -1628,12 +1664,14 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
url: '/groups/',
params,
reportUrlAsIs: true,
+ errFn,
}) as Promise<GroupNameToGroupInfoMap | undefined>;
}
- getSuggestedProjects(
+ getSuggestedRepos(
inputVal: string,
- n?: number
+ n?: number,
+ errFn?: ErrorCallback
): Promise<NameToProjectInfoMap | undefined> {
const params = {
m: inputVal,
@@ -1647,6 +1685,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
url: '/projects/',
params,
reportUrlAsIs: true,
+ errFn,
});
}
@@ -1654,13 +1693,18 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
inputVal: string,
n?: number,
canSee?: NumericChangeId,
- filterActive?: boolean
+ filterActive?: boolean,
+ errFn?: ErrorCallback
): Promise<AccountInfo[] | undefined> {
const params: QueryAccountsParams = {o: 'DETAILS', q: ''};
const queryParams = [];
inputVal = inputVal?.trim() ?? '';
if (inputVal.length > 0) {
- queryParams.push(inputVal);
+ // Wrap in quotes so that reserved keywords do not throw an error such
+ // as typing "and"
+ // Espace quotes in user input since we are wrapping input in quotes
+ // explicitly
+ queryParams.push(`${escapeAndWrapSearchOperatorValue(inputVal)}`);
}
if (canSee) {
queryParams.push(`cansee:${canSee}`);
@@ -1677,6 +1721,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
url: '/accounts/',
params,
anonymizedUrl: '/accounts/?n=*',
+ errFn,
}) as Promise<AccountInfo[] | undefined>;
}
@@ -1759,7 +1804,8 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}
const options = listChangesOptionsToHex(
ListChangesOption.CURRENT_REVISION,
- ListChangesOption.CURRENT_COMMIT
+ ListChangesOption.CURRENT_COMMIT,
+ ListChangesOption.SUBMITTABLE
);
const params = {
O: options,
@@ -1773,7 +1819,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}
getChangeCherryPicks(
- project: RepoName,
+ repo: RepoName,
changeID: ChangeId,
branch: BranchName
): Promise<ChangeInfo[] | undefined> {
@@ -1782,7 +1828,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
ListChangesOption.CURRENT_COMMIT
);
const query = [
- `project:${project}`,
+ `project:${repo}`,
`change:${changeID}`,
`-branch:${branch}`,
'-is:abandoned',
@@ -1809,9 +1855,10 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
ListChangesOption.LABELS,
ListChangesOption.CURRENT_REVISION,
ListChangesOption.CURRENT_COMMIT,
- ListChangesOption.DETAILED_LABELS
+ ListChangesOption.DETAILED_LABELS,
+ ListChangesOption.SUBMITTABLE
);
- const queryTerms = [`topic:"${topic}"`];
+ const queryTerms = [`topic:${escapeAndWrapSearchOperatorValue(topic)}`];
if (options?.openChangesOnly) {
queryTerms.push('status:open');
}
@@ -1829,23 +1876,29 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}) as Promise<ChangeInfo[] | undefined>;
}
- getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined> {
- const query = `intopic:"${topic}"`;
+ getChangesWithSimilarTopic(
+ topic: string,
+ errFn?: ErrorCallback
+ ): Promise<ChangeInfo[] | undefined> {
+ const query = `intopic:${escapeAndWrapSearchOperatorValue(topic)}`;
return this._restApiHelper.fetchJSON({
url: '/changes/',
params: {q: query},
anonymizedUrl: '/changes/intopic:*',
+ errFn,
}) as Promise<ChangeInfo[] | undefined>;
}
getChangesWithSimilarHashtag(
- hashtag: string
+ hashtag: string,
+ errFn?: ErrorCallback
): Promise<ChangeInfo[] | undefined> {
- const query = `inhashtag:"${hashtag}"`;
+ const query = `inhashtag:${escapeAndWrapSearchOperatorValue(hashtag)}`;
return this._restApiHelper.fetchJSON({
url: '/changes/',
params: {q: query},
anonymizedUrl: '/changes/inhashtag:*',
+ errFn,
}) as Promise<ChangeInfo[] | undefined>;
}
@@ -1929,7 +1982,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}
createChange(
- project: RepoName,
+ repo: RepoName,
branch: BranchName,
subject: string,
topic?: string,
@@ -1942,7 +1995,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
method: HttpMethod.POST,
url: '/changes/',
body: {
- project,
+ project: repo,
branch,
subject,
topic,
@@ -2193,11 +2246,13 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
return this.getFromProjectLookup(changeNum).then(project => {
const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
- return this._restApiHelper.send({
- method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
- url,
- anonymizedUrl: '/accounts/self/starred.changes/*',
- });
+ return this._serialScheduler.schedule(() =>
+ this._restApiHelper.send({
+ method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+ url,
+ anonymizedUrl: '/accounts/self/starred.changes/*',
+ })
+ );
});
}
@@ -2283,7 +2338,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
getDiffComments(
changeNum: NumericChangeId
- ): Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: CommentInfo[]} | undefined>;
getDiffComments(
changeNum: NumericChangeId,
@@ -2384,7 +2439,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
changeNum: NumericChangeId,
endpoint: '/comments' | '/drafts',
params?: FetchParams
- ): Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: CommentInfo[]} | undefined>;
_getDiffComments(
changeNum: NumericChangeId,
@@ -2419,7 +2474,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
): Promise<
| GetDiffCommentsOutput
| GetDiffRobotCommentsOutput
- | PathToCommentsInfoMap
+ | {[path: string]: CommentInfo[]}
| PathToRobotCommentsInfoMap
| undefined
> {
@@ -2441,7 +2496,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
},
noAcceptHeader
) as Promise<
- PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
+ {[path: string]: CommentInfo[]} | PathToRobotCommentsInfoMap | undefined
>;
if (!basePatchNum && !patchNum && !path) {
@@ -2509,7 +2564,7 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
getPortedComments(
changeNum: NumericChangeId,
revision: RevisionId
- ): Promise<PathToCommentsInfoMap | undefined> {
+ ): Promise<{[path: string]: CommentInfo[]} | undefined> {
// maintaining a custom error function so that errors do not surface in UI
const errFn: ErrorCallback = (response?: Response | null) => {
if (response)
@@ -2523,24 +2578,24 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
});
}
- getPortedDrafts(
+ async getPortedDrafts(
changeNum: NumericChangeId,
revision: RevisionId
- ): Promise<PathToCommentsInfoMap | undefined> {
+ ): Promise<{[path: string]: DraftInfo[]} | undefined> {
// maintaining a custom error function so that errors do not surface in UI
const errFn: ErrorCallback = (response?: Response | null) => {
if (response)
console.info(`Fetching ported drafts failed, ${response.status}`);
};
- return this.getLoggedIn().then(loggedIn => {
- if (!loggedIn) return {};
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/ported_drafts/',
- revision,
- errFn,
- });
- });
+ const loggedIn = await this.getLoggedIn();
+ if (!loggedIn) return {};
+ const comments = (await this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/ported_drafts/',
+ revision,
+ errFn,
+ })) as {[path: string]: CommentInfo[]} | undefined;
+ return addDraftProp(comments);
}
saveDiffDraft(
@@ -2638,16 +2693,16 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}
getCommitInfo(
- project: RepoName,
+ repo: RepoName,
commit: CommitId
): Promise<CommitInfo | undefined> {
return this._restApiHelper.fetchJSON({
url:
'/projects/' +
- encodeURIComponent(project) +
+ encodeURIComponent(repo) +
'/commits/' +
encodeURIComponent(commit),
- anonymizedUrl: '/projects/*/comments/*',
+ anonymizedUrl: '/projects/*/commits/*',
}) as Promise<CommitInfo | undefined>;
}
@@ -2742,15 +2797,9 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
_changeBaseURL(
changeNum: NumericChangeId,
- revisionId?: RevisionId,
- project?: RepoName
+ revisionId?: RevisionId
): Promise<string> {
- // TODO(kaspern): For full slicer migration, app should warn with a call
- // stack every time _changeBaseURL is called without a project.
- const projectPromise = project
- ? Promise.resolve(project)
- : this.getFromProjectLookup(changeNum);
- return projectPromise.then(project => {
+ return this.getFromProjectLookup(changeNum).then(project => {
// TODO(TS): unclear why project can't be null here. Fix it
let url = `/changes/${encodeURIComponent(
project as RepoName
@@ -3034,37 +3083,48 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
});
}
- async setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
- const lookupProject = await this._projectLookup[changeNum];
- if (lookupProject && lookupProject !== project) {
- console.warn(
- 'Change set with multiple project nums.' +
- 'One of them must be invalid.'
- );
- }
+ /**
+ * This can be called by the router, if the project can be determined from
+ * the URL. Or when handling a dashabord or a search response.
+ *
+ * Then we don't need to make a dedicated REST API call or have a fallback,
+ * if that fails.
+ */
+ setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
this._projectLookup[changeNum] = Promise.resolve(project);
}
getFromProjectLookup(
changeNum: NumericChangeId
): Promise<RepoName | undefined> {
- const project = this._projectLookup[`${changeNum}`];
- if (project) {
- return project;
- }
-
- const onError = (response?: Response | null) => firePageError(response);
-
- const projectPromise = this.getChange(changeNum, onError).then(change => {
- if (!change || !change.project) {
- return;
+ // Hopefully setInProjectLookup() has already been called. Then we don't
+ // have to make a dedicated REST API call to look up the project.
+ let projectPromise = this._projectLookup[changeNum];
+ if (projectPromise) return projectPromise;
+
+ // Ignore errors, because we have some dedicated fallback logic, see below.
+ const onError = () => {};
+ projectPromise = this.getChange(changeNum, onError).then(change => {
+ if (change?.project) return change.project;
+
+ // In the very rare case that the change index cannot provide an answer
+ // (e.g. stale index) we should check, if the router has called
+ // setInProjectLookup() in the meantime. Then we can fall back to that.
+ const currentProjectPromise = this._projectLookup[changeNum];
+ if (currentProjectPromise !== projectPromise) {
+ return currentProjectPromise;
}
- this.setInProjectLookup(changeNum, change.project);
- return change.project;
- });
+ // No luck. Without knowing the project we cannot proceed at all.
+ firePageError(
+ new Response(
+ `Failed to lookup the repo for change number ${changeNum}`,
+ {status: 404}
+ )
+ );
+ return undefined;
+ });
this._projectLookup[changeNum] = projectPromise;
-
return projectPromise;
}
@@ -3079,9 +3139,6 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
_getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
- /**
- * Alias for _changeBaseURL.then(send).
- */
_getChangeURLAndSend(
req: SendChangeRequest
): Promise<ParsedJSON | Response | undefined> {
@@ -3109,9 +3166,6 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
});
}
- /**
- * Alias for _changeBaseURL.then(_fetchJSON).
- */
_getChangeURLAndFetch(
req: FetchChangeJSON,
noAcceptHeader?: boolean
@@ -3224,13 +3278,13 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}
getDashboard(
- project: RepoName,
+ repo: RepoName,
dashboard: DashboardId,
errFn?: ErrorCallback
): Promise<DashboardInfo | undefined> {
const url =
'/projects/' +
- encodeURIComponent(project) +
+ encodeURIComponent(repo) +
'/dashboards/' +
encodeURIComponent(dashboard);
return this._fetchSharedCacheURL({
@@ -3240,6 +3294,26 @@ export class GrRestApiServiceImpl implements RestApiService, Finalizable {
}) as Promise<DashboardInfo | undefined>;
}
+ /**
+ * Get the docs base URL from either the server config or by probing.
+ *
+ * @return A promise that resolves with the docs base URL.
+ */
+ getDocsBaseUrl(config: ServerInfo | undefined): Promise<string | null> {
+ if (!this.getDocsBaseUrlCachedPromise) {
+ this.getDocsBaseUrlCachedPromise = new Promise(resolve => {
+ if (config?.gerrit?.doc_url) {
+ resolve(config.gerrit.doc_url);
+ } else {
+ this.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
+ resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
+ });
+ }
+ });
+ }
+ return this.getDocsBaseUrlCachedPromise;
+ }
+
getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
filter = filter.trim();
const encodedFilter = encodeURIComponent(filter);
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
deleted file mode 100644
index 3f517f44ee..0000000000
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.js
+++ /dev/null
@@ -1,1578 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../test/common-test-setup';
-import {
- addListenerForTest,
- mockPromise,
- stubAuth,
- waitEventLoop,
-} from '../../test/test-utils';
-import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
-import {
- ListChangesOption,
- listChangesOptionsToHex,
-} from '../../utils/change-util';
-import {getAppContext} from '../app-context';
-import {createChange} from '../../test/test-data-generators';
-import {CURRENT} from '../../utils/patch-set-util';
-import {
- parsePrefixedJSON,
- readResponsePayload,
- JSON_PREFIX,
- // eslint-disable-next-line max-len
-} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
-import {GrRestApiServiceImpl} from './gr-rest-api-impl';
-import {CommentSide} from '../../constants/constants';
-import {EDIT, PARENT} from '../../types/common';
-import {assert} from '@open-wc/testing';
-import {getBaseUrl} from '../../utils/url-util';
-
-const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
- ListChangesOption.CHANGE_ACTIONS,
- ListChangesOption.CURRENT_ACTIONS,
- ListChangesOption.CURRENT_REVISION,
- ListChangesOption.DETAILED_LABELS,
- ListChangesOption.SUBMIT_REQUIREMENTS
-);
-
-suite('gr-rest-api-service-impl tests', () => {
- let element;
-
- let ctr = 0;
- let originalCanonicalPath;
-
- setup(() => {
- // Modify CANONICAL_PATH to effectively reset cache.
- ctr += 1;
- originalCanonicalPath = window.CANONICAL_PATH;
- window.CANONICAL_PATH = `test${ctr}`;
-
- const testJSON = ')]}\'\n{"hello": "bonjour"}';
- sinon.stub(window, 'fetch').returns(
- Promise.resolve({
- ok: true,
- text() {
- return Promise.resolve(testJSON);
- },
- })
- );
- // fake auth
- sinon
- .stub(getAppContext().authService, 'authCheck')
- .returns(Promise.resolve(true));
- element = new GrRestApiServiceImpl(
- getAppContext().authService,
- getAppContext().flagsService
- );
- element._projectLookup = {};
- });
-
- teardown(() => {
- window.CANONICAL_PATH = originalCanonicalPath;
- });
-
- test('parent diff comments are properly grouped', () => {
- sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(() =>
- Promise.resolve({
- '/COMMIT_MSG': [],
- 'sieve.go': [
- {
- updated: '2017-02-03 22:32:28.000000000',
- message: 'this isn’t quite right',
- },
- {
- side: CommentSide.PARENT,
- message: 'how did this work in the first place?',
- updated: '2017-02-03 22:33:28.000000000',
- },
- ],
- })
- );
- return element
- ._getDiffComments('42', '', undefined, PARENT, 1, 'sieve.go')
- .then(obj => {
- assert.equal(obj.baseComments.length, 1);
- assert.deepEqual(obj.baseComments[0], {
- side: CommentSide.PARENT,
- message: 'how did this work in the first place?',
- path: 'sieve.go',
- updated: '2017-02-03 22:33:28.000000000',
- });
- assert.equal(obj.comments.length, 1);
- assert.deepEqual(obj.comments[0], {
- message: 'this isn’t quite right',
- path: 'sieve.go',
- updated: '2017-02-03 22:32:28.000000000',
- });
- });
- });
-
- test('_setRange', () => {
- const comments = [
- {
- id: 1,
- side: CommentSide.PARENT,
- message: 'how did this work in the first place?',
- updated: '2017-02-03 22:32:28.000000000',
- range: {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 1,
- },
- },
- {
- id: 2,
- in_reply_to: 1,
- message: 'this isn’t quite right',
- updated: '2017-02-03 22:33:28.000000000',
- },
- ];
- const expectedResult = {
- id: 2,
- in_reply_to: 1,
- message: 'this isn’t quite right',
- updated: '2017-02-03 22:33:28.000000000',
- range: {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 1,
- },
- };
- const comment = comments[1];
- assert.deepEqual(element._setRange(comments, comment), expectedResult);
- });
-
- test('_setRanges', () => {
- const comments = [
- {
- id: 3,
- in_reply_to: 2,
- message: 'this isn’t quite right either',
- updated: '2017-02-03 22:34:28.000000000',
- },
- {
- id: 2,
- in_reply_to: 1,
- message: 'this isn’t quite right',
- updated: '2017-02-03 22:33:28.000000000',
- },
- {
- id: 1,
- side: CommentSide.PARENT,
- message: 'how did this work in the first place?',
- updated: '2017-02-03 22:32:28.000000000',
- range: {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 1,
- },
- },
- ];
- const expectedResult = [
- {
- id: 1,
- side: CommentSide.PARENT,
- message: 'how did this work in the first place?',
- updated: '2017-02-03 22:32:28.000000000',
- range: {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 1,
- },
- },
- {
- id: 2,
- in_reply_to: 1,
- message: 'this isn’t quite right',
- updated: '2017-02-03 22:33:28.000000000',
- range: {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 1,
- },
- },
- {
- id: 3,
- in_reply_to: 2,
- message: 'this isn’t quite right either',
- updated: '2017-02-03 22:34:28.000000000',
- range: {
- start_line: 1,
- start_character: 1,
- end_line: 2,
- end_character: 1,
- },
- },
- ];
- assert.deepEqual(element._setRanges(comments), expectedResult);
- });
-
- test('differing patch diff comments are properly grouped', () => {
- sinon
- .stub(element, 'getFromProjectLookup')
- .returns(Promise.resolve('test'));
- sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(request => {
- const url = request.url;
- if (url === '/changes/test~42/revisions/1') {
- return Promise.resolve({
- '/COMMIT_MSG': [],
- 'sieve.go': [
- {
- message: 'this isn’t quite right',
- updated: '2017-02-03 22:32:28.000000000',
- },
- {
- side: CommentSide.PARENT,
- message: 'how did this work in the first place?',
- updated: '2017-02-03 22:33:28.000000000',
- },
- ],
- });
- } else if (url === '/changes/test~42/revisions/2') {
- return Promise.resolve({
- '/COMMIT_MSG': [],
- 'sieve.go': [
- {
- message: 'What on earth are you thinking, here?',
- updated: '2017-02-03 22:32:28.000000000',
- },
- {
- side: CommentSide.PARENT,
- message: 'Yeah not sure how this worked either?',
- updated: '2017-02-03 22:33:28.000000000',
- },
- {
- message: '¯\\_(ツ)_/¯',
- updated: '2017-02-04 22:33:28.000000000',
- },
- ],
- });
- }
- });
- return element
- ._getDiffComments('42', '', undefined, 1, 2, 'sieve.go')
- .then(obj => {
- assert.equal(obj.baseComments.length, 1);
- assert.deepEqual(obj.baseComments[0], {
- message: 'this isn’t quite right',
- path: 'sieve.go',
- updated: '2017-02-03 22:32:28.000000000',
- });
- assert.equal(obj.comments.length, 2);
- assert.deepEqual(obj.comments[0], {
- message: 'What on earth are you thinking, here?',
- path: 'sieve.go',
- updated: '2017-02-03 22:32:28.000000000',
- });
- assert.deepEqual(obj.comments[1], {
- message: '¯\\_(ツ)_/¯',
- path: 'sieve.go',
- updated: '2017-02-04 22:33:28.000000000',
- });
- });
- });
-
- test('server error', () => {
- const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
- stubAuth('fetch').returns(Promise.resolve({ok: false}));
- const serverErrorEventPromise = new Promise(resolve => {
- addListenerForTest(document, 'server-error', resolve);
- });
-
- return Promise.all([
- element._restApiHelper.fetchJSON({}).then(response => {
- assert.isUndefined(response);
- assert.isTrue(getResponseObjectStub.notCalled);
- }),
- serverErrorEventPromise,
- ]);
- });
-
- test('legacy n,z key in change url is replaced', async () => {
- sinon.stub(element, 'getConfig').callsFake(async () => {
- return {};
- });
- const stub = sinon
- .stub(element._restApiHelper, 'fetchJSON')
- .returns(Promise.resolve([]));
- await element.getChanges(1, null, 'n,z');
- assert.equal(stub.lastCall.args[0].params.S, 0);
- });
-
- test('saveDiffPreferences invalidates cache line', () => {
- const cacheKey = '/accounts/self/preferences.diff';
- const sendStub = sinon.stub(element._restApiHelper, 'send');
- element._cache.set(cacheKey, {tab_size: 4});
- element.saveDiffPreferences({tab_size: 8});
- assert.isTrue(sendStub.called);
- assert.isFalse(element._restApiHelper._cache.has(cacheKey));
- });
-
- suite('getAccountSuggestions', () => {
- let fetchStub;
- setup(() => {
- fetchStub = sinon
- .stub(element._restApiHelper, 'fetch')
- .returns(Promise.resolve(new Response()));
- });
-
- test('url with just email', () => {
- element.getSuggestedAccounts('bro');
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(
- fetchStub.firstCall.args[0].url,
- getBaseUrl() + '/accounts/?o=DETAILS&q=bro'
- );
- });
-
- test('url with email and canSee changeId', () => {
- element.getSuggestedAccounts('bro', undefined, 341682);
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(
- fetchStub.firstCall.args[0].url,
- getBaseUrl() + '/accounts/?o=DETAILS&q=bro%20and%20cansee%3A341682'
- );
- });
-
- test('url with email and canSee changeId and isActive', () => {
- element.getSuggestedAccounts('bro', undefined, 341682, true);
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(
- fetchStub.firstCall.args[0].url,
- getBaseUrl() + '/accounts/?o=DETAILS&q=bro%20and%20' +
- 'cansee%3A341682%20and%20is%3Aactive'
- );
- });
- });
-
- test('getAccount when resp is null does not add to cache', async () => {
- const cacheKey = '/accounts/self/detail';
- const stub = sinon
- .stub(element._restApiHelper, 'fetchCacheURL')
- .callsFake(() => Promise.resolve());
-
- await element.getAccount();
- assert.isTrue(stub.called);
- assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
- element._restApiHelper._cache.set(cacheKey, 'fake cache');
- stub.lastCall.args[0].errFn();
- });
-
- test('getAccount does not add to cache when status is 403', async () => {
- const cacheKey = '/accounts/self/detail';
- const stub = sinon
- .stub(element._restApiHelper, 'fetchCacheURL')
- .callsFake(() => Promise.resolve());
-
- await element.getAccount();
- assert.isTrue(stub.called);
- assert.isFalse(element._restApiHelper._cache.has(cacheKey));
-
- element._cache.set(cacheKey, 'fake cache');
- stub.lastCall.args[0].errFn({status: 403});
- });
-
- test('getAccount when resp is successful', async () => {
- const cacheKey = '/accounts/self/detail';
- const stub = sinon
- .stub(element._restApiHelper, 'fetchCacheURL')
- .callsFake(() => Promise.resolve());
-
- await element.getAccount();
-
- element._restApiHelper._cache.set(cacheKey, 'fake cache');
- assert.isTrue(stub.called);
- assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
- stub.lastCall.args[0].errFn({});
- });
-
- const preferenceSetup = function(testJSON, loggedIn) {
- sinon
- .stub(element, 'getLoggedIn')
- .callsFake(() => Promise.resolve(loggedIn));
- sinon
- .stub(element._restApiHelper, 'fetchCacheURL')
- .callsFake(() => Promise.resolve(testJSON));
- };
-
- test('getPreferences returns correctly logged in', () => {
- const testJSON = {diff_view: 'SIDE_BY_SIDE'};
- const loggedIn = true;
-
- preferenceSetup(testJSON, loggedIn);
-
- return element.getPreferences().then(obj => {
- assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
- });
- });
-
- test('getPreferences returns correctly on larger screens logged in', () => {
- const testJSON = {diff_view: 'UNIFIED_DIFF'};
- const loggedIn = true;
-
- preferenceSetup(testJSON, loggedIn);
-
- return element.getPreferences().then(obj => {
- assert.equal(obj.diff_view, 'UNIFIED_DIFF');
- });
- });
-
- test('getPreferences returns correctly on larger screens no login', () => {
- const testJSON = {diff_view: 'UNIFIED_DIFF'};
- const loggedIn = false;
-
- preferenceSetup(testJSON, loggedIn);
-
- return element.getPreferences().then(obj => {
- assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
- });
- });
-
- test('savPreferences normalizes download scheme', () => {
- const sendStub = sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve(new Response()));
- element.savePreferences({download_scheme: 'HTTP'});
- assert.isTrue(sendStub.called);
- assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
- });
-
- test('getDiffPreferences returns correct defaults', () => {
- sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
- return element.getDiffPreferences().then(obj => {
- assert.equal(obj.context, 10);
- assert.equal(obj.cursor_blink_rate, 0);
- assert.equal(obj.font_size, 12);
- assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
- assert.equal(obj.line_length, 100);
- assert.equal(obj.line_wrapping, false);
- assert.equal(obj.show_line_endings, true);
- assert.equal(obj.show_tabs, true);
- assert.equal(obj.show_whitespace_errors, true);
- assert.equal(obj.syntax_highlighting, true);
- assert.equal(obj.tab_size, 8);
- });
- });
-
- test('saveDiffPreferences set show_tabs to false', () => {
- const sendStub = sinon.stub(element._restApiHelper, 'send');
- element.saveDiffPreferences({show_tabs: false});
- assert.isTrue(sendStub.called);
- assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
- });
-
- test('getEditPreferences returns correct defaults', () => {
- sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
-
- return element.getEditPreferences().then(obj => {
- assert.equal(obj.auto_close_brackets, false);
- assert.equal(obj.cursor_blink_rate, 0);
- assert.equal(obj.hide_line_numbers, false);
- assert.equal(obj.hide_top_menu, false);
- assert.equal(obj.indent_unit, 2);
- assert.equal(obj.indent_with_tabs, false);
- assert.equal(obj.key_map_type, 'DEFAULT');
- assert.equal(obj.line_length, 100);
- assert.equal(obj.line_wrapping, false);
- assert.equal(obj.match_brackets, true);
- assert.equal(obj.show_base, false);
- assert.equal(obj.show_tabs, true);
- assert.equal(obj.show_whitespace_errors, true);
- assert.equal(obj.syntax_highlighting, true);
- assert.equal(obj.tab_size, 8);
- assert.equal(obj.theme, 'DEFAULT');
- });
- });
-
- test('saveEditPreferences set show_tabs to false', () => {
- const sendStub = sinon.stub(element._restApiHelper, 'send');
- element.saveEditPreferences({show_tabs: false});
- assert.isTrue(sendStub.called);
- assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
- });
-
- test('confirmEmail', () => {
- const sendStub = sinon.spy(element._restApiHelper, 'send');
- element.confirmEmail('foo');
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].method, 'PUT');
- assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
- assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
- });
-
- test('setPreferredAccountEmail', () => {
- const email1 = 'email1@example.com';
- const email2 = 'email2@example.com';
- const encodedEmail = encodeURIComponent(email2);
- const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
- element._cache.set('/accounts/self/emails', [
- {email: email1, preferred: true},
- {email: email2, preferred: false},
- ]);
-
- return element.setPreferredAccountEmail(email2).then(() => {
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].method, 'PUT');
- assert.equal(sendStub.lastCall.args[0].url,
- `/accounts/self/emails/${encodedEmail}/preferred`
- );
- assert.deepEqual(
- element._restApiHelper._cache.get('/accounts/self/emails'), [
- {email: email1, preferred: false},
- {email: email2, preferred: true},
- ]
- );
- });
- });
-
- test('setAccountStatus', () => {
- const sendStub = sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve('OOO'));
- element._cache.set('/accounts/self/detail', {});
- return element.setAccountStatus('OOO').then(() => {
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].method, 'PUT');
- assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
- assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
- assert.deepEqual(
- element._restApiHelper._cache.get('/accounts/self/detail'),
- {status: 'OOO'}
- );
- });
- });
-
- suite('draft comments', () => {
- test('_sendDiffDraftRequest pending requests tracked', () => {
- const obj = element._pendingRequests;
- sinon
- .stub(element, '_getChangeURLAndSend')
- .callsFake(() => mockPromise());
- assert.notOk(element.hasPendingDiffDrafts());
-
- element._sendDiffDraftRequest(null, null, null, {});
- assert.equal(obj.sendDiffDraft.length, 1);
- assert.isTrue(!!element.hasPendingDiffDrafts());
-
- element._sendDiffDraftRequest(null, null, null, {});
- assert.equal(obj.sendDiffDraft.length, 2);
- assert.isTrue(!!element.hasPendingDiffDrafts());
-
- for (const promise of obj.sendDiffDraft) {
- promise.resolve();
- }
-
- return element.awaitPendingDiffDrafts().then(() => {
- assert.equal(obj.sendDiffDraft.length, 0);
- assert.isFalse(!!element.hasPendingDiffDrafts());
- });
- });
-
- suite('_failForCreate200', () => {
- test('_sendDiffDraftRequest checks for 200 on create', () => {
- const sendPromise = Promise.resolve();
- sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
- const failStub = sinon
- .stub(element, '_failForCreate200')
- .returns(Promise.resolve());
- return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
- assert.isTrue(failStub.calledOnce);
- assert.isTrue(failStub.calledWithExactly(sendPromise));
- });
- });
-
- test('_sendDiffDraftRequest no checks for 200 on non create', () => {
- sinon.stub(element, '_getChangeURLAndSend').returns(Promise.resolve());
- const failStub = sinon
- .stub(element, '_failForCreate200')
- .returns(Promise.resolve());
- return element
- ._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
- .then(() => {
- assert.isFalse(failStub.called);
- });
- });
-
- test('_failForCreate200 fails on 200', () => {
- const result = {
- ok: true,
- status: 200,
- headers: {
- entries: () => [
- ['Set-CoOkiE', 'secret'],
- ['Innocuous', 'hello'],
- ],
- },
- };
- return element
- ._failForCreate200(Promise.resolve(result))
- .then(() => {
- assert.fail('Error expected.');
- })
- .catch(e => {
- assert.isOk(e);
- assert.include(e.message, 'Saving draft resulted in HTTP 200');
- assert.include(e.message, 'hello');
- assert.notInclude(e.message, 'secret');
- });
- });
-
- test('_failForCreate200 does not fail on 201', () => {
- const result = {
- ok: true,
- status: 201,
- headers: {entries: () => []},
- };
- return element._failForCreate200(Promise.resolve(result));
- });
- });
- });
-
- test('saveChangeEdit', () => {
- element._projectLookup = {1: Promise.resolve('test')};
- const change_num = '1';
- const file_name = 'index.php';
- const file_contents = '<?php';
- sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve([change_num, file_name, file_contents]));
- sinon
- .stub(element, 'getResponseObject')
- .returns(Promise.resolve([change_num, file_name, file_contents]));
- element._cache.set('/changes/' + change_num + '/edit/' + file_name, {});
- return element
- .saveChangeEdit(change_num, file_name, file_contents)
- .then(() => {
- assert.isTrue(element._restApiHelper.send.calledOnce);
- assert.equal(
- element._restApiHelper.send.lastCall.args[0].method,
- 'PUT'
- );
- assert.equal(
- element._restApiHelper.send.lastCall.args[0].url,
- '/changes/test~1/edit/' + file_name
- );
- assert.equal(
- element._restApiHelper.send.lastCall.args[0].body,
- file_contents
- );
- });
- });
-
- test('putChangeCommitMessage', () => {
- element._projectLookup = {1: Promise.resolve('test')};
- const change_num = '1';
- const message = 'this is a commit message';
- sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve([change_num, message]));
- sinon
- .stub(element, 'getResponseObject')
- .returns(Promise.resolve([change_num, message]));
- element._cache.set('/changes/' + change_num + '/message', {});
- return element.putChangeCommitMessage(change_num, message).then(() => {
- assert.isTrue(element._restApiHelper.send.calledOnce);
- assert.equal(element._restApiHelper.send.lastCall.args[0].method, 'PUT');
- assert.equal(
- element._restApiHelper.send.lastCall.args[0].url,
- '/changes/test~1/message'
- );
- assert.deepEqual(element._restApiHelper.send.lastCall.args[0].body, {
- message,
- });
- });
- });
-
- test('deleteChangeCommitMessage', () => {
- element._projectLookup = {1: Promise.resolve('test')};
- const change_num = '1';
- const messageId = 'abc';
- sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve([change_num, messageId]));
- sinon
- .stub(element, 'getResponseObject')
- .returns(Promise.resolve([change_num, messageId]));
- return element.deleteChangeCommitMessage(change_num, messageId).then(() => {
- assert.isTrue(element._restApiHelper.send.calledOnce);
- assert.equal(
- element._restApiHelper.send.lastCall.args[0].method,
- 'DELETE'
- );
- assert.equal(
- element._restApiHelper.send.lastCall.args[0].url,
- '/changes/test~1/messages/abc'
- );
- });
- });
-
- test('startWorkInProgress', () => {
- const sendStub = sinon
- .stub(element, '_getChangeURLAndSend')
- .returns(Promise.resolve('ok'));
- element.startWorkInProgress('42');
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].changeNum, '42');
- assert.equal(sendStub.lastCall.args[0].method, 'POST');
- assert.isNotOk(sendStub.lastCall.args[0].patchNum);
- assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
- assert.deepEqual(sendStub.lastCall.args[0].body, {});
-
- element.startWorkInProgress('42', 'revising...');
- assert.isTrue(sendStub.calledTwice);
- assert.equal(sendStub.lastCall.args[0].changeNum, '42');
- assert.equal(sendStub.lastCall.args[0].method, 'POST');
- assert.isNotOk(sendStub.lastCall.args[0].patchNum);
- assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
- assert.deepEqual(sendStub.lastCall.args[0].body, {
- message: 'revising...',
- });
- });
-
- test('deleteComment', () => {
- const sendStub = sinon
- .stub(element, '_getChangeURLAndSend')
- .returns(Promise.resolve('some response'));
- return element
- .deleteComment('foo', 'bar', '01234', 'removal reason')
- .then(response => {
- assert.equal(response, 'some response');
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].changeNum, 'foo');
- assert.equal(sendStub.lastCall.args[0].method, 'POST');
- assert.equal(sendStub.lastCall.args[0].patchNum, 'bar');
- assert.equal(
- sendStub.lastCall.args[0].endpoint,
- '/comments/01234/delete'
- );
- assert.deepEqual(sendStub.lastCall.args[0].body, {
- reason: 'removal reason',
- });
- });
- });
-
- test('createRepo encodes name', () => {
- const sendStub = sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve());
- return element.createRepo({name: 'x/y'}).then(() => {
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
- });
- });
-
- test('queryChangeFiles', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- return element.queryChangeFiles('42', EDIT, 'test/path.js').then(() => {
- assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
- assert.equal(
- fetchStub.lastCall.args[0].endpoint,
- '/files?q=test%2Fpath.js'
- );
- assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
- });
- });
-
- test('normal use', () => {
- const defaultQuery = '';
-
- assert.equal(
- element._getReposUrl('test', 25).toString(),
- [false, '/projects/?n=26&S=0&d=&m=test'].toString()
- );
-
- assert.equal(
- element._getReposUrl(null, 25).toString(),
- [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
- );
-
- assert.equal(
- element._getReposUrl('test', 25, 25).toString(),
- [false, '/projects/?n=26&S=25&d=&m=test'].toString()
- );
-
- assert.equal(
- element._getReposUrl('inname:test', 25, 25).toString(),
- [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
- );
- });
-
- test('invalidateReposCache', () => {
- const url = '/projects/?n=26&S=0&query=test';
-
- element._cache.set(url, {});
-
- element.invalidateReposCache();
-
- assert.isUndefined(element._sharedFetchPromises[url]);
-
- assert.isFalse(element._cache.has(url));
- });
-
- test('invalidateAccountsCache', () => {
- const url = '/accounts/self/detail';
-
- element._cache.set(url, {});
-
- element.invalidateAccountsCache();
-
- assert.isUndefined(element._sharedFetchPromises[url]);
-
- assert.isFalse(element._cache.has(url));
- });
-
- suite('getRepos', () => {
- const defaultQuery = '';
- let fetchCacheURLStub;
- setup(() => {
- fetchCacheURLStub = sinon
- .stub(element._restApiHelper, 'fetchCacheURL')
- .returns(Promise.resolve([]));
- });
-
- test('normal use', () => {
- element.getRepos('test', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=test'
- );
-
- element.getRepos(null, 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- `/projects/?n=26&S=0&d=&m=${defaultQuery}`
- );
-
- element.getRepos('test', 25, 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=25&d=&m=test'
- );
- });
-
- test('with blank', () => {
- element.getRepos('test/test', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=test%2Ftest'
- );
- });
-
- test('with hyphen', () => {
- element.getRepos('foo-bar', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=foo-bar'
- );
- });
-
- test('with leading hyphen', () => {
- element.getRepos('-bar', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=-bar'
- );
- });
-
- test('with trailing hyphen', () => {
- element.getRepos('foo-bar-', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=foo-bar-'
- );
- });
-
- test('with underscore', () => {
- element.getRepos('foo_bar', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=foo_bar'
- );
- });
-
- test('with underscore', () => {
- element.getRepos('foo_bar', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/?n=26&S=0&d=&m=foo_bar'
- );
- });
-
- test('hyphen only', () => {
- element.getRepos('-', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- `/projects/?n=26&S=0&d=&m=-`
- );
- });
-
- test('using query', () => {
- element.getRepos('description:project', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- `/projects/?n=26&S=0&query=description%3Aproject`
- );
- });
- });
-
- test('_getGroupsUrl normal use', () => {
- assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
-
- assert.equal(element._getGroupsUrl(null, 25), '/groups/?n=26&S=0');
-
- assert.equal(
- element._getGroupsUrl('test', 25, 25),
- '/groups/?n=26&S=25&m=test'
- );
- });
-
- test('invalidateGroupsCache', () => {
- const url = '/groups/?n=26&S=0&m=test';
-
- element._cache.set(url, {});
-
- element.invalidateGroupsCache();
-
- assert.isUndefined(element._sharedFetchPromises[url]);
-
- assert.isFalse(element._cache.has(url));
- });
-
- suite('getGroups', () => {
- let fetchCacheURLStub;
- setup(() => {
- fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
- });
-
- test('normal use', () => {
- element.getGroups('test', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/groups/?n=26&S=0&m=test'
- );
-
- element.getGroups(null, 25);
- assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
-
- element.getGroups('test', 25, 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/groups/?n=26&S=25&m=test'
- );
- });
-
- test('regex', () => {
- element.getGroups('^test.*', 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/groups/?n=26&S=0&r=%5Etest.*'
- );
-
- element.getGroups('^test.*', 25, 25);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/groups/?n=26&S=25&r=%5Etest.*'
- );
- });
- });
-
- test('gerrit auth is used', () => {
- stubAuth('fetch').returns(Promise.resolve());
- element._restApiHelper.fetchJSON({url: 'foo'});
- assert(getAppContext().authService.fetch.called);
- });
-
- test('getSuggestedAccounts does not return _fetchJSON', () => {
- const _fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
- return element.getSuggestedAccounts().then(accts => {
- assert.isFalse(_fetchJSONSpy.called);
- assert.equal(accts.length, 0);
- });
- });
-
- test('_fetchJSON gets called by getSuggestedAccounts', () => {
- const _fetchJSONStub = sinon
- .stub(element._restApiHelper, 'fetchJSON')
- .callsFake(() => Promise.resolve());
- return element.getSuggestedAccounts('own').then(() => {
- assert.deepEqual(_fetchJSONStub.lastCall.args[0].params, {
- q: 'own',
- o: 'DETAILS',
- });
- });
- });
-
- suite('getChangeDetail', () => {
- suite('change detail options', () => {
- setup(() => {
- sinon
- .stub(element, '_getChangeDetail')
- .callsFake(async (changeNum, options) => {
- return {changeNum, options};
- });
- });
-
- test('signed pushes disabled', async () => {
- sinon.stub(element, 'getConfig').callsFake(async () => {
- return {};
- });
- const {changeNum, options} = await element.getChangeDetail(123);
- assert.strictEqual(123, changeNum);
- assert.isNotOk(
- parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
- );
- });
-
- test('signed pushes enabled', async () => {
- sinon.stub(element, 'getConfig').callsFake(async () => {
- return {receive: {enable_signed_push: true}};
- });
- const {changeNum, options} = await element.getChangeDetail(123);
- assert.strictEqual(123, changeNum);
- assert.ok(
- parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
- );
- });
- });
-
- test('GrReviewerUpdatesParser.parse is used', () => {
- sinon
- .stub(GrReviewerUpdatesParser, 'parse')
- .returns(Promise.resolve('foo'));
- return element.getChangeDetail(42).then(result => {
- assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
- assert.equal(result, 'foo');
- });
- });
-
- test('_getChangeDetail passes params to ETags decorator', () => {
- const changeNum = 4321;
- element._projectLookup[changeNum] = Promise.resolve('test');
- const expectedUrl =
- window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
- sinon.stub(element._etags, 'getOptions');
- sinon.stub(element._etags, 'collect');
- return element._getChangeDetail(changeNum, '516714').then(() => {
- assert.isTrue(element._etags.getOptions.calledWithExactly(expectedUrl));
- assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
- });
- });
-
- test('_getChangeDetail calls errFn on 500', () => {
- const errFn = sinon.stub();
- sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
- sinon
- .stub(element._restApiHelper, 'fetchRawJSON')
- .returns(Promise.resolve({ok: false, status: 500}));
- return element._getChangeDetail(123, '516714', errFn).then(() => {
- assert.isTrue(errFn.called);
- });
- });
-
- test('_getChangeDetail populates _projectLookup', async () => {
- sinon.stub(element, 'getChangeActionURL').returns(Promise.resolve(''));
- sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
- Promise.resolve({
- ok: true,
- status: 200,
- text: () => Promise.resolve(`)]}'{"_number":1,"project":"test"}`),
- })
- );
- await element._getChangeDetail(1, '516714');
- assert.equal(Object.keys(element._projectLookup).length, 1);
- const project = await element._projectLookup[1];
- assert.equal(project, 'test');
- });
-
- suite('_getChangeDetail ETag cache', () => {
- let requestUrl;
- let mockResponseSerial;
- let collectSpy;
-
- setup(() => {
- requestUrl = '/foo/bar';
- const mockResponse = {foo: 'bar', baz: 42};
- mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
- sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
- sinon
- .stub(element, 'getChangeActionURL')
- .returns(Promise.resolve(requestUrl));
- collectSpy = sinon.spy(element._etags, 'collect');
- });
-
- test('contributes to cache', () => {
- const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
- sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
- Promise.resolve({
- text: () => Promise.resolve(mockResponseSerial),
- status: 200,
- ok: true,
- })
- );
-
- return element._getChangeDetail(123, '516714').then(detail => {
- assert.isFalse(getPayloadSpy.called);
- assert.isTrue(collectSpy.calledOnce);
- const cachedResponse = element._etags.getCachedPayload(requestUrl);
- assert.equal(cachedResponse, mockResponseSerial);
- });
- });
-
- test('uses cache on HTTP 304', () => {
- const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
- getPayloadStub.returns(mockResponseSerial);
- sinon.stub(element._restApiHelper, 'fetchRawJSON').returns(
- Promise.resolve({
- text: () => Promise.resolve(''),
- status: 304,
- ok: true,
- })
- );
-
- return element._getChangeDetail(123, '').then(detail => {
- assert.isFalse(collectSpy.called);
- assert.isTrue(getPayloadStub.calledOnce);
- });
- });
- });
- });
-
- test('setInProjectLookup', async () => {
- await element.setInProjectLookup('test', 'project');
- const project = await element.getFromProjectLookup('test');
- assert.deepEqual(project, 'project');
- });
-
- suite('getFromProjectLookup', () => {
- test('getChange succeeds, no project', async () => {
- sinon.stub(element, 'getChange').returns(Promise.resolve(null));
- const val = await element.getFromProjectLookup();
- assert.strictEqual(val, undefined);
- });
-
- test('getChange succeeds with project', () => {
- sinon
- .stub(element, 'getChange')
- .returns(Promise.resolve({project: 'project'}));
- const projectLookup = element.getFromProjectLookup('test');
- return projectLookup.then(val => {
- assert.equal(val, 'project');
- assert.deepEqual(element._projectLookup, {test: projectLookup});
- });
- });
- });
-
- suite('getChanges populates _projectLookup', () => {
- test('multiple queries', async () => {
- sinon.stub(element._restApiHelper, 'fetchJSON').returns(
- Promise.resolve([
- [
- {_number: 1, project: 'test'},
- {_number: 2, project: 'test'},
- ],
- [{_number: 3, project: 'test/test'}],
- ])
- );
- // When opt_query instanceof Array, _fetchJSON returns
- // Array<Array<Object>>.
- await element.getChangesForMultipleQueries(null, []);
- assert.equal(Object.keys(element._projectLookup).length, 3);
- const project1 = await element.getFromProjectLookup(1);
- assert.equal(project1, 'test');
- const project2 = await element.getFromProjectLookup(2);
- assert.equal(project2, 'test');
- const project3 = await element.getFromProjectLookup(3);
- assert.equal(project3, 'test/test');
- });
-
- test('no query', async () => {
- sinon.stub(element._restApiHelper, 'fetchJSON').returns(
- Promise.resolve([
- {_number: 1, project: 'test'},
- {_number: 2, project: 'test'},
- {_number: 3, project: 'test/test'},
- ])
- );
-
- // When opt_query !instanceof Array, _fetchJSON returns
- // Array<Object>.
- await element.getChanges();
- assert.equal(Object.keys(element._projectLookup).length, 3);
- const project1 = await element.getFromProjectLookup(1);
- assert.equal(project1, 'test');
- const project2 = await element.getFromProjectLookup(2);
- assert.equal(project2, 'test');
- const project3 = await element.getFromProjectLookup(3);
- assert.equal(project3, 'test/test');
- });
- });
-
- test('getDetailedChangesWithActions', async () => {
- const c1 = createChange();
- c1._number = 1;
- const c2 = createChange();
- c2._number = 2;
- 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, EXPECTED_QUERY_OPTIONS);
- return Promise.resolve([]);
- });
- await element.getDetailedChangesWithActions([c1._number, c2._number]);
- assert.isTrue(getChangesStub.calledOnce);
- });
-
- test('_getChangeURLAndFetch', () => {
- element._projectLookup = {1: Promise.resolve('test')};
- const fetchStub = sinon
- .stub(element._restApiHelper, 'fetchJSON')
- .returns(Promise.resolve());
- const req = {changeNum: 1, endpoint: '/test', revision: 1};
- return element._getChangeURLAndFetch(req).then(() => {
- assert.equal(
- fetchStub.lastCall.args[0].url,
- '/changes/test~1/revisions/1/test'
- );
- });
- });
-
- test('_getChangeURLAndSend', () => {
- element._projectLookup = {1: Promise.resolve('test')};
- const sendStub = sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve());
-
- const req = {
- changeNum: 1,
- method: 'POST',
- patchNum: 1,
- endpoint: '/test',
- };
- return element._getChangeURLAndSend(req).then(() => {
- assert.isTrue(sendStub.calledOnce);
- assert.equal(sendStub.lastCall.args[0].method, 'POST');
- assert.equal(
- sendStub.lastCall.args[0].url,
- '/changes/test~1/revisions/1/test'
- );
- });
- });
-
- suite('reading responses', () => {
- test('_readResponsePayload', async () => {
- const mockObject = {foo: 'bar', baz: 'foo'};
- const serial = JSON_PREFIX + JSON.stringify(mockObject);
- const mockResponse = {text: () => Promise.resolve(serial)};
- const payload = await readResponsePayload(mockResponse);
- assert.deepEqual(payload.parsed, mockObject);
- assert.equal(payload.raw, serial);
- });
-
- test('_parsePrefixedJSON', () => {
- const obj = {x: 3, y: {z: 4}, w: 23};
- const serial = JSON_PREFIX + JSON.stringify(obj);
- const result = parsePrefixedJSON(serial);
- assert.deepEqual(result, obj);
- });
- });
-
- test('setChangeTopic', () => {
- const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
- return element.setChangeTopic(123, 'foo-bar').then(() => {
- assert.isTrue(sendSpy.calledOnce);
- assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
- });
- });
-
- test('setChangeHashtag', () => {
- const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
- return element.setChangeHashtag(123, 'foo-bar').then(() => {
- assert.isTrue(sendSpy.calledOnce);
- assert.equal(sendSpy.lastCall.args[0].body, 'foo-bar');
- });
- });
-
- test('generateAccountHttpPassword', () => {
- const sendSpy = sinon.spy(element._restApiHelper, 'send');
- return element.generateAccountHttpPassword().then(() => {
- assert.isTrue(sendSpy.calledOnce);
- assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
- });
- });
-
- suite('getChangeFiles', () => {
- test('patch only', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- const range = {basePatchNum: PARENT, patchNum: 2};
- return element.getChangeFiles(123, range).then(() => {
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].revision, 2);
- assert.isNotOk(fetchStub.lastCall.args[0].params);
- });
- });
-
- test('simple range', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- const range = {basePatchNum: 4, patchNum: 5};
- return element.getChangeFiles(123, range).then(() => {
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].revision, 5);
- assert.isOk(fetchStub.lastCall.args[0].params);
- assert.equal(fetchStub.lastCall.args[0].params.base, 4);
- assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
- });
- });
-
- test('parent index', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- const range = {basePatchNum: -3, patchNum: 5};
- return element.getChangeFiles(123, range).then(() => {
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].revision, 5);
- assert.isOk(fetchStub.lastCall.args[0].params);
- assert.isNotOk(fetchStub.lastCall.args[0].params.base);
- assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
- });
- });
- });
-
- suite('getDiff', () => {
- test('patchOnly', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- return element.getDiff(123, PARENT, 2, 'foo/bar.baz').then(() => {
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].revision, 2);
- assert.isOk(fetchStub.lastCall.args[0].params);
- assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
- assert.isNotOk(fetchStub.lastCall.args[0].params.base);
- });
- });
-
- test('simple range', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].revision, 5);
- assert.isOk(fetchStub.lastCall.args[0].params);
- assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
- assert.equal(fetchStub.lastCall.args[0].params.base, 4);
- });
- });
-
- test('parent index', () => {
- const fetchStub = sinon
- .stub(element, '_getChangeURLAndFetch')
- .returns(Promise.resolve());
- return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
- assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].revision, 5);
- assert.isOk(fetchStub.lastCall.args[0].params);
- assert.isNotOk(fetchStub.lastCall.args[0].params.base);
- assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
- });
- });
- });
-
- test('getDashboard', () => {
- const fetchCacheURLStub = sinon.stub(
- element._restApiHelper,
- 'fetchCacheURL'
- );
- element.getDashboard('gerrit/project', 'default:main');
- assert.isTrue(fetchCacheURLStub.calledOnce);
- assert.equal(
- fetchCacheURLStub.lastCall.args[0].url,
- '/projects/gerrit%2Fproject/dashboards/default%3Amain'
- );
- });
-
- test('getFileContent', () => {
- sinon.stub(element, '_getChangeURLAndSend').returns(
- Promise.resolve({
- ok: 'true',
- headers: {
- get(header) {
- if (header === 'X-FYI-Content-Type') {
- return 'text/java';
- }
- },
- },
- })
- );
-
- sinon
- .stub(element, 'getResponseObject')
- .returns(Promise.resolve('new content'));
-
- const edit = element.getFileContent('1', 'tst/path', 'EDIT').then(res => {
- assert.deepEqual(res, {
- content: 'new content',
- type: 'text/java',
- ok: true,
- });
- });
-
- const normal = element.getFileContent('1', 'tst/path', '3').then(res => {
- assert.deepEqual(res, {
- content: 'new content',
- type: 'text/java',
- ok: true,
- });
- });
-
- return Promise.all([edit, normal]);
- });
-
- test('getFileContent suppresses 404s', () => {
- const res = {status: 404};
- const spy = sinon.spy();
- addListenerForTest(document, 'server-error', spy);
- sinon
- .stub(getAppContext().authService, 'fetch')
- .returns(Promise.resolve(res));
- sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
- return element
- .getFileContent('1', 'tst/path', '1')
- .then(() => waitEventLoop())
- .then(() => {
- assert.isFalse(spy.called);
-
- res.status = 500;
- return element.getFileContent('1', 'tst/path', '1');
- })
- .then(() => {
- assert.isTrue(spy.called);
- assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
- });
- });
-
- test('getChangeFilesOrEditFiles is edit-sensitive', () => {
- const fn = element.getChangeOrEditFiles.bind(element);
- const getChangeFilesStub = sinon
- .stub(element, 'getChangeFiles')
- .returns(Promise.resolve({}));
- const getChangeEditFilesStub = sinon
- .stub(element, 'getChangeEditFiles')
- .returns(Promise.resolve({}));
-
- return fn('1', {patchNum: EDIT}).then(() => {
- assert.isTrue(getChangeEditFilesStub.calledOnce);
- assert.isFalse(getChangeFilesStub.called);
- return fn('1', {patchNum: '1'}).then(() => {
- assert.isTrue(getChangeEditFilesStub.calledOnce);
- assert.isTrue(getChangeFilesStub.calledOnce);
- });
- });
- });
-
- test('_fetch forwards request and logs', () => {
- const logStub = sinon.stub(element._restApiHelper, '_logCall');
- const response = {status: 404, text: sinon.stub()};
- const url = 'my url';
- const fetchOptions = {method: 'DELETE'};
- sinon.stub(element.authService, 'fetch').returns(Promise.resolve(response));
- const startTime = 123;
- sinon.stub(Date, 'now').returns(startTime);
- const req = {url, fetchOptions};
- return element._restApiHelper.fetch(req).then(() => {
- assert.isTrue(logStub.calledOnce);
- assert.isTrue(logStub.calledWith(req, startTime, response.status));
- assert.isFalse(response.text.called);
- });
- });
-
- test('_logCall only reports requests with anonymized URLss', async () => {
- sinon.stub(Date, 'now').returns(200);
- const handler = sinon.stub();
- addListenerForTest(document, 'gr-rpc-log', handler);
-
- element._restApiHelper._logCall({url: 'url'}, 100, 200);
- assert.isFalse(handler.called);
-
- element._restApiHelper._logCall(
- {url: 'url', anonymizedUrl: 'not url'},
- 100,
- 200
- );
- await waitEventLoop();
- assert.isTrue(handler.calledOnce);
- });
-
- test('ported comment errors do not trigger error dialog', () => {
- const change = createChange();
- const handler = sinon.stub();
- addListenerForTest(document, 'server-error', handler);
- sinon.stub(element._restApiHelper, 'fetchJSON').returns(
- Promise.resolve({
- ok: false,
- })
- );
-
- element.getPortedComments(change._number, CURRENT);
-
- assert.isFalse(handler.called);
- });
-
- test('ported drafts are not requested user is not logged in', () => {
- const change = createChange();
- sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
- const getChangeURLAndFetchStub = sinon.stub(
- element,
- '_getChangeURLAndFetch'
- );
-
- element.getPortedDrafts(change._number, CURRENT);
-
- assert.isFalse(getChangeURLAndFetchStub.called);
- });
-
- test('saveChangeStarred', async () => {
- sinon
- .stub(element, 'getFromProjectLookup')
- .returns(Promise.resolve('test'));
- const sendStub = sinon
- .stub(element._restApiHelper, 'send')
- .returns(Promise.resolve());
-
- await element.saveChangeStarred(123, true);
- assert.isTrue(sendStub.calledOnce);
- assert.deepEqual(sendStub.lastCall.args[0], {
- method: 'PUT',
- url: '/accounts/self/starred.changes/test~123',
- anonymizedUrl: '/accounts/self/starred.changes/*',
- });
-
- await element.saveChangeStarred(456, false);
- assert.isTrue(sendStub.calledTwice);
- assert.deepEqual(sendStub.lastCall.args[0], {
- method: 'DELETE',
- url: '/accounts/self/starred.changes/test~456',
- anonymizedUrl: '/accounts/self/starred.changes/*',
- });
- });
-});
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
new file mode 100644
index 0000000000..7abcb0d6d6
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -0,0 +1,1677 @@
+/**
+ * @license
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../test/common-test-setup';
+import {
+ addListenerForTest,
+ assertFails,
+ MockPromise,
+ mockPromise,
+ waitEventLoop,
+} from '../../test/test-utils';
+import {GrReviewerUpdatesParser} from '../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ ListChangesOption,
+ listChangesOptionsToHex,
+} from '../../utils/change-util';
+import {
+ createAccountDetailWithId,
+ createChange,
+ createComment,
+ createGerritInfo,
+ createParsedChange,
+ createServerInfo,
+} from '../../test/test-data-generators';
+import {CURRENT} from '../../utils/patch-set-util';
+import {
+ parsePrefixedJSON,
+ readResponsePayload,
+ JSON_PREFIX,
+} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrRestApiServiceImpl} from './gr-rest-api-impl';
+import {
+ CommentSide,
+ createDefaultEditPrefs,
+ HttpMethod,
+} from '../../constants/constants';
+import {
+ BasePatchSetNum,
+ ChangeInfo,
+ ChangeMessageId,
+ CommentInfo,
+ DashboardId,
+ DiffPreferenceInput,
+ EDIT,
+ EditPreferencesInfo,
+ Hashtag,
+ HashtagsInput,
+ NumericChangeId,
+ PARENT,
+ ParsedJSON,
+ PatchSetNum,
+ PreferencesInfo,
+ RepoName,
+ RevisionId,
+ RevisionPatchSetNum,
+ RobotCommentInfo,
+ ServerInfo,
+ Timestamp,
+ UrlEncodedCommentId,
+} from '../../types/common';
+import {assert} from '@open-wc/testing';
+import {AuthService} from '../gr-auth/gr-auth';
+import {GrAuthMock} from '../gr-auth/gr-auth_mock';
+import {getBaseUrl} from '../../utils/url-util';
+
+const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex(
+ ListChangesOption.CHANGE_ACTIONS,
+ // Current actions can be costly to calculate (e.g submit action)
+ // They are not used in bulk actions.
+ // ListChangesOption.CURRENT_ACTIONS,
+ ListChangesOption.CURRENT_REVISION,
+ ListChangesOption.DETAILED_LABELS,
+ ListChangesOption.SUBMIT_REQUIREMENTS
+);
+
+suite('gr-rest-api-service-impl tests', () => {
+ let element: GrRestApiServiceImpl;
+ let authService: AuthService;
+
+ let ctr = 0;
+ let originalCanonicalPath: string | undefined;
+
+ setup(() => {
+ // Modify CANONICAL_PATH to effectively reset cache.
+ ctr += 1;
+ originalCanonicalPath = window.CANONICAL_PATH;
+ window.CANONICAL_PATH = `test${ctr}`;
+
+ const testJSON = ')]}\'\n{"hello": "bonjour"}';
+ sinon.stub(window, 'fetch').resolves(new Response(testJSON));
+ // fake auth
+ authService = new GrAuthMock();
+ sinon.stub(authService, 'authCheck').resolves(true);
+ element = new GrRestApiServiceImpl(authService);
+
+ element._projectLookup = {};
+ });
+
+ teardown(() => {
+ window.CANONICAL_PATH = originalCanonicalPath;
+ });
+
+ test('parent diff comments are properly grouped', async () => {
+ sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+ '/COMMIT_MSG': [],
+ 'sieve.go': [
+ {
+ updated: '2017-02-03 22:32:28.000000000',
+ message: 'this isn’t quite right',
+ },
+ {
+ side: CommentSide.PARENT,
+ message: 'how did this work in the first place?',
+ updated: '2017-02-03 22:33:28.000000000',
+ },
+ ],
+ } as unknown as ParsedJSON);
+ const obj = await element._getDiffComments(
+ 42 as NumericChangeId,
+ '/comments',
+ undefined,
+ PARENT,
+ 1 as PatchSetNum,
+ 'sieve.go'
+ );
+ assert.equal(obj.baseComments.length, 1);
+ assert.deepEqual(obj.baseComments[0], {
+ side: CommentSide.PARENT,
+ message: 'how did this work in the first place?',
+ path: 'sieve.go',
+ updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+ } as RobotCommentInfo);
+ assert.equal(obj.comments.length, 1);
+ assert.deepEqual(obj.comments[0], {
+ message: 'this isn’t quite right',
+ path: 'sieve.go',
+ updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+ } as RobotCommentInfo);
+ });
+
+ test('_setRange', () => {
+ const comments: CommentInfo[] = [
+ {
+ id: '1' as UrlEncodedCommentId,
+ side: CommentSide.PARENT,
+ message: 'how did this work in the first place?',
+ updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 1,
+ },
+ },
+ {
+ id: '2' as UrlEncodedCommentId,
+ in_reply_to: '1' as UrlEncodedCommentId,
+ message: 'this isn’t quite right',
+ updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+ },
+ ];
+ const expectedResult: CommentInfo = {
+ id: '2' as UrlEncodedCommentId,
+ in_reply_to: '1' as UrlEncodedCommentId,
+ message: 'this isn’t quite right',
+ updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 1,
+ },
+ };
+ const comment = comments[1];
+ assert.deepEqual(element._setRange(comments, comment), expectedResult);
+ });
+
+ test('_setRanges', () => {
+ const comments: CommentInfo[] = [
+ {
+ id: '3' as UrlEncodedCommentId,
+ in_reply_to: '2' as UrlEncodedCommentId,
+ message: 'this isn’t quite right either',
+ updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+ },
+ {
+ id: '2' as UrlEncodedCommentId,
+ in_reply_to: '1' as UrlEncodedCommentId,
+ message: 'this isn’t quite right',
+ updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+ },
+ {
+ id: '1' as UrlEncodedCommentId,
+ side: CommentSide.PARENT,
+ message: 'how did this work in the first place?',
+ updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 1,
+ },
+ },
+ ];
+ const expectedResult: CommentInfo[] = [
+ {
+ id: '1' as UrlEncodedCommentId,
+ side: CommentSide.PARENT,
+ message: 'how did this work in the first place?',
+ updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 1,
+ },
+ },
+ {
+ id: '2' as UrlEncodedCommentId,
+ in_reply_to: '1' as UrlEncodedCommentId,
+ message: 'this isn’t quite right',
+ updated: '2017-02-03 22:33:28.000000000' as Timestamp,
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 1,
+ },
+ },
+ {
+ id: '3' as UrlEncodedCommentId,
+ in_reply_to: '2' as UrlEncodedCommentId,
+ message: 'this isn’t quite right either',
+ updated: '2017-02-03 22:34:28.000000000' as Timestamp,
+ range: {
+ start_line: 1,
+ start_character: 1,
+ end_line: 2,
+ end_character: 1,
+ },
+ },
+ ];
+ assert.deepEqual(element._setRanges(comments), expectedResult);
+ });
+
+ test('differing patch diff comments are properly grouped', async () => {
+ sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+ sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(async request => {
+ const url = request.url;
+ if (url === '/changes/test~42/revisions/1/comments') {
+ return {
+ '/COMMIT_MSG': [],
+ 'sieve.go': [
+ {
+ message: 'this isn’t quite right',
+ updated: '2017-02-03 22:32:28.000000000',
+ },
+ {
+ side: CommentSide.PARENT,
+ message: 'how did this work in the first place?',
+ updated: '2017-02-03 22:33:28.000000000',
+ },
+ ],
+ } as unknown as ParsedJSON;
+ } else if (url === '/changes/test~42/revisions/2/comments') {
+ return {
+ '/COMMIT_MSG': [],
+ 'sieve.go': [
+ {
+ message: 'What on earth are you thinking, here?',
+ updated: '2017-02-03 22:32:28.000000000',
+ },
+ {
+ side: CommentSide.PARENT,
+ message: 'Yeah not sure how this worked either?',
+ updated: '2017-02-03 22:33:28.000000000',
+ },
+ {
+ message: '¯\\_(ツ)_/¯',
+ updated: '2017-02-04 22:33:28.000000000',
+ },
+ ],
+ } as unknown as ParsedJSON;
+ }
+ return undefined;
+ });
+ const obj = await element._getDiffComments(
+ 42 as NumericChangeId,
+ '/comments',
+ undefined,
+ 1 as BasePatchSetNum,
+ 2 as PatchSetNum,
+ 'sieve.go'
+ );
+ assert.equal(obj.baseComments.length, 1);
+ assert.deepEqual(obj.baseComments[0], {
+ message: 'this isn’t quite right',
+ path: 'sieve.go',
+ updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+ } as RobotCommentInfo);
+ assert.equal(obj.comments.length, 2);
+ assert.deepEqual(obj.comments[0], {
+ message: 'What on earth are you thinking, here?',
+ path: 'sieve.go',
+ updated: '2017-02-03 22:32:28.000000000' as Timestamp,
+ } as RobotCommentInfo);
+ assert.deepEqual(obj.comments[1], {
+ message: '¯\\_(ツ)_/¯',
+ path: 'sieve.go',
+ updated: '2017-02-04 22:33:28.000000000' as Timestamp,
+ } as RobotCommentInfo);
+ });
+
+ test('server error', async () => {
+ const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
+ sinon
+ .stub(authService, 'fetch')
+ .resolves(new Response(undefined, {status: 502}));
+ const serverErrorEventPromise = new Promise(resolve => {
+ addListenerForTest(document, 'server-error', resolve);
+ });
+ const response = await element._restApiHelper.fetchJSON({url: ''});
+ assert.isUndefined(response);
+ assert.isTrue(getResponseObjectStub.notCalled);
+ await serverErrorEventPromise;
+ });
+
+ test('legacy n,z key in change url is replaced', async () => {
+ const stub = sinon
+ .stub(element._restApiHelper, 'fetchJSON')
+ .resolves([] as unknown as ParsedJSON);
+ await element.getChanges(1, undefined, 'n,z');
+ assert.equal(stub.lastCall.args[0].params!.S, 0);
+ });
+
+ test('saveDiffPreferences invalidates cache line', () => {
+ const cacheKey = '/accounts/self/preferences.diff';
+ const sendStub = sinon.stub(element._restApiHelper, 'send');
+ element._cache.set(cacheKey, {tab_size: 4} as unknown as ParsedJSON);
+ element.saveDiffPreferences({
+ tab_size: 8,
+ ignore_whitespace: 'IGNORE_NONE',
+ });
+ assert.isTrue(sendStub.called);
+ assert.isFalse(element._cache.has(cacheKey));
+ });
+
+ suite('getAccountSuggestions', () => {
+ let fetchStub: sinon.SinonStub;
+ setup(() => {
+ fetchStub = sinon
+ .stub(element._restApiHelper, 'fetch')
+ .resolves(new Response());
+ });
+
+ test('url with just email', () => {
+ element.getSuggestedAccounts('bro');
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(
+ fetchStub.firstCall.args[0].url,
+ `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22`
+ );
+ });
+
+ test('url with email and canSee changeId', () => {
+ element.getSuggestedAccounts('bro', undefined, 341682 as NumericChangeId);
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(
+ fetchStub.firstCall.args[0].url,
+ `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682`
+ );
+ });
+
+ test('url with email and canSee changeId and isActive', () => {
+ element.getSuggestedAccounts(
+ 'bro',
+ undefined,
+ 341682 as NumericChangeId,
+ true
+ );
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(
+ fetchStub.firstCall.args[0].url,
+ `${getBaseUrl()}/accounts/?o=DETAILS&q=%22bro%22%20and%20cansee%3A341682%20and%20is%3Aactive`
+ );
+ });
+ });
+
+ test('getAccount when resp is undefined clears cache', async () => {
+ const cacheKey = '/accounts/self/detail';
+ const account = createAccountDetailWithId();
+ element._cache.set(cacheKey, account);
+ const stub = sinon
+ .stub(element._restApiHelper, 'fetchCacheURL')
+ .callsFake(async req => {
+ req.errFn!(undefined);
+ return undefined;
+ });
+ assert.isTrue(element._cache.has(cacheKey));
+
+ await element.getAccount();
+ assert.isTrue(stub.called);
+ assert.isFalse(element._cache.has(cacheKey));
+ });
+
+ test('getAccount when status is 403 clears cache', async () => {
+ const cacheKey = '/accounts/self/detail';
+ const account = createAccountDetailWithId();
+ element._cache.set(cacheKey, account);
+ const stub = sinon
+ .stub(element._restApiHelper, 'fetchCacheURL')
+ .callsFake(async req => {
+ req.errFn!(new Response(undefined, {status: 403}));
+ return undefined;
+ });
+ assert.isTrue(element._cache.has(cacheKey));
+
+ await element.getAccount();
+ assert.isTrue(stub.called);
+ assert.isFalse(element._cache.has(cacheKey));
+ });
+
+ test('getAccount when resp is successful updates cache', async () => {
+ const cacheKey = '/accounts/self/detail';
+ const account = createAccountDetailWithId();
+ const stub = sinon
+ .stub(element._restApiHelper, 'fetchCacheURL')
+ .callsFake(async () => {
+ element._cache.set(cacheKey, account);
+ return undefined;
+ });
+ assert.isFalse(element._cache.has(cacheKey));
+
+ await element.getAccount();
+ assert.isTrue(stub.called);
+ assert.equal(element._cache.get(cacheKey), account);
+ });
+
+ const preferenceSetup = function (testJSON: unknown, loggedIn: boolean) {
+ sinon
+ .stub(element, 'getLoggedIn')
+ .callsFake(() => Promise.resolve(loggedIn));
+ sinon
+ .stub(element._restApiHelper, 'fetchCacheURL')
+ .callsFake(() => Promise.resolve(testJSON as ParsedJSON));
+ };
+
+ test('getPreferences returns correctly logged in', async () => {
+ const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+ const loggedIn = true;
+
+ preferenceSetup(testJSON, loggedIn);
+
+ const obj = await element.getPreferences();
+ assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+ });
+
+ test('getPreferences returns correctly on larger screens logged in', async () => {
+ const testJSON = {diff_view: 'UNIFIED_DIFF'};
+ const loggedIn = true;
+
+ preferenceSetup(testJSON, loggedIn);
+
+ const obj = await element.getPreferences();
+ assert.equal(obj!.diff_view, 'UNIFIED_DIFF');
+ });
+
+ test('getPreferences returns correctly on larger screens no login', async () => {
+ const testJSON = {diff_view: 'UNIFIED_DIFF'};
+ const loggedIn = false;
+
+ preferenceSetup(testJSON, loggedIn);
+
+ const obj = await element.getPreferences();
+ assert.equal(obj!.diff_view, 'SIDE_BY_SIDE');
+ });
+
+ test('savPreferences normalizes download scheme', () => {
+ const sendStub = sinon
+ .stub(element._restApiHelper, 'send')
+ .resolves(new Response());
+ element.savePreferences({download_scheme: 'HTTP'});
+ assert.isTrue(sendStub.called);
+ assert.equal(
+ (sendStub.lastCall.args[0].body as Partial<PreferencesInfo>)
+ .download_scheme,
+ 'http'
+ );
+ });
+
+ test('getDiffPreferences returns correct defaults', async () => {
+ sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+ const obj = (await element.getDiffPreferences())!;
+ assert.equal(obj.context, 10);
+ assert.equal(obj.cursor_blink_rate, 0);
+ assert.equal(obj.font_size, 12);
+ assert.equal(obj.ignore_whitespace, 'IGNORE_NONE');
+ assert.equal(obj.line_length, 100);
+ assert.equal(obj.line_wrapping, false);
+ assert.equal(obj.show_line_endings, true);
+ assert.equal(obj.show_tabs, true);
+ assert.equal(obj.show_whitespace_errors, true);
+ assert.equal(obj.syntax_highlighting, true);
+ assert.equal(obj.tab_size, 8);
+ });
+
+ test('saveDiffPreferences set show_tabs to false', () => {
+ const sendStub = sinon.stub(element._restApiHelper, 'send');
+ element.saveDiffPreferences({
+ show_tabs: false,
+ ignore_whitespace: 'IGNORE_NONE',
+ });
+ assert.isTrue(sendStub.called);
+ assert.equal(
+ (sendStub.lastCall.args[0].body as Partial<DiffPreferenceInput>)
+ .show_tabs,
+ false
+ );
+ });
+
+ test('getEditPreferences returns correct defaults', async () => {
+ sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
+
+ const obj = (await element.getEditPreferences())!;
+ assert.equal(obj.auto_close_brackets, false);
+ assert.equal(obj.cursor_blink_rate, 0);
+ assert.equal(obj.hide_line_numbers, false);
+ assert.equal(obj.hide_top_menu, false);
+ assert.equal(obj.indent_unit, 2);
+ assert.equal(obj.indent_with_tabs, false);
+ assert.equal(obj.key_map_type, 'DEFAULT');
+ assert.equal(obj.line_length, 100);
+ assert.equal(obj.line_wrapping, false);
+ assert.equal(obj.match_brackets, true);
+ assert.equal(obj.show_base, false);
+ assert.equal(obj.show_tabs, true);
+ assert.equal(obj.show_whitespace_errors, true);
+ assert.equal(obj.syntax_highlighting, true);
+ assert.equal(obj.tab_size, 8);
+ assert.equal(obj.theme, 'DEFAULT');
+ });
+
+ test('saveEditPreferences set show_tabs to false', () => {
+ const sendStub = sinon.stub(element._restApiHelper, 'send');
+ element.saveEditPreferences({
+ ...createDefaultEditPrefs(),
+ show_tabs: false,
+ });
+ assert.isTrue(sendStub.called);
+ assert.equal(
+ (sendStub.lastCall.args[0].body as EditPreferencesInfo).show_tabs,
+ false
+ );
+ });
+
+ test('confirmEmail', () => {
+ const sendStub = sinon.spy(element._restApiHelper, 'send');
+ element.confirmEmail('foo');
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+ assert.equal(sendStub.lastCall.args[0].url, '/config/server/email.confirm');
+ assert.deepEqual(sendStub.lastCall.args[0].body, {token: 'foo'});
+ });
+
+ test('setPreferredAccountEmail', async () => {
+ const email1 = 'email1@example.com';
+ const email2 = 'email2@example.com';
+ const encodedEmail = encodeURIComponent(email2);
+ const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+ element._cache.set('/accounts/self/emails', [
+ {email: email1, preferred: true},
+ {email: email2, preferred: false},
+ ]);
+
+ await element.setPreferredAccountEmail(email2);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+ assert.equal(
+ sendStub.lastCall.args[0].url,
+ `/accounts/self/emails/${encodedEmail}/preferred`
+ );
+ assert.deepEqual(element._cache.get('/accounts/self/emails'), [
+ {email: email1, preferred: false},
+ {email: email2, preferred: true},
+ ]);
+ });
+
+ test('setAccountStatus', async () => {
+ const sendStub = sinon
+ .stub(element._restApiHelper, 'send')
+ .resolves('OOO' as unknown as ParsedJSON);
+ element._cache.set('/accounts/self/detail', createAccountDetailWithId());
+ await element.setAccountStatus('OOO');
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+ assert.equal(sendStub.lastCall.args[0].url, '/accounts/self/status');
+ assert.deepEqual(sendStub.lastCall.args[0].body, {status: 'OOO'});
+ assert.deepEqual(
+ element._cache.get('/accounts/self/detail')!.status,
+ 'OOO'
+ );
+ });
+
+ suite('draft comments', () => {
+ test('_sendDiffDraftRequest pending requests tracked', async () => {
+ const obj = element._pendingRequests;
+ sinon
+ .stub(element, '_getChangeURLAndSend')
+ .callsFake(() => mockPromise());
+ assert.notOk(element.hasPendingDiffDrafts());
+
+ element._sendDiffDraftRequest(
+ HttpMethod.PUT,
+ 123 as NumericChangeId,
+ 1 as PatchSetNum,
+ {}
+ );
+ assert.equal(obj.sendDiffDraft.length, 1);
+ assert.isTrue(!!element.hasPendingDiffDrafts());
+
+ element._sendDiffDraftRequest(
+ HttpMethod.PUT,
+ 123 as NumericChangeId,
+ 1 as PatchSetNum,
+ {}
+ );
+ assert.equal(obj.sendDiffDraft.length, 2);
+ assert.isTrue(!!element.hasPendingDiffDrafts());
+
+ for (const promise of obj.sendDiffDraft) {
+ (promise as MockPromise<void>).resolve();
+ }
+
+ await element.awaitPendingDiffDrafts();
+ assert.equal(obj.sendDiffDraft.length, 0);
+ assert.isFalse(!!element.hasPendingDiffDrafts());
+ });
+
+ suite('_failForCreate200', () => {
+ test('_sendDiffDraftRequest checks for 200 on create', async () => {
+ const sendPromise = Promise.resolve({} as unknown as ParsedJSON);
+ sinon.stub(element, '_getChangeURLAndSend').returns(sendPromise);
+ const failStub = sinon.stub(element, '_failForCreate200').resolves();
+ await element._sendDiffDraftRequest(
+ HttpMethod.PUT,
+ 123 as NumericChangeId,
+ 4 as PatchSetNum,
+ {}
+ );
+ assert.isTrue(failStub.calledOnce);
+ assert.isTrue(failStub.calledWithExactly(sendPromise));
+ });
+
+ test('_sendDiffDraftRequest no checks for 200 on non create', async () => {
+ sinon.stub(element, '_getChangeURLAndSend').resolves();
+ const failStub = sinon.stub(element, '_failForCreate200').resolves();
+ await element._sendDiffDraftRequest(
+ HttpMethod.PUT,
+ 123 as NumericChangeId,
+ 4 as PatchSetNum,
+ {
+ id: '123' as UrlEncodedCommentId,
+ }
+ );
+ assert.isFalse(failStub.called);
+ });
+
+ test('_failForCreate200 fails on 200', async () => {
+ const result = new Response(undefined, {
+ status: 200,
+ headers: {
+ 'Set-CoOkiE': 'secret',
+ Innocuous: 'hello',
+ },
+ });
+ const error = await assertFails<Error>(
+ element._failForCreate200(Promise.resolve(result))
+ );
+ assert.isOk(error);
+ assert.include(error.message, 'Saving draft resulted in HTTP 200');
+ assert.include(error.message, 'hello');
+ assert.notInclude(error.message, 'secret');
+ });
+
+ test('_failForCreate200 does not fail on 201', () => {
+ const result = new Response(undefined, {status: 201});
+ return element._failForCreate200(Promise.resolve(result));
+ });
+ });
+ });
+
+ test('saveChangeEdit', async () => {
+ element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+ const change_num = 1 as NumericChangeId;
+ const file_name = 'index.php';
+ const file_contents = '<?php';
+ const sendStub = sinon
+ .stub(element._restApiHelper, 'send')
+ .resolves([
+ change_num,
+ file_name,
+ file_contents,
+ ] as unknown as ParsedJSON);
+ sinon
+ .stub(element, 'getResponseObject')
+ .resolves([
+ change_num,
+ file_name,
+ file_contents,
+ ] as unknown as ParsedJSON);
+ element._cache.set(
+ `/changes/${change_num}/edit/${file_name}`,
+ {} as unknown as ParsedJSON
+ );
+ await element.saveChangeEdit(change_num, file_name, file_contents);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+ assert.equal(
+ sendStub.lastCall.args[0].url,
+ '/changes/test~1/edit/' + file_name
+ );
+ assert.equal(sendStub.lastCall.args[0].body, file_contents);
+ });
+
+ test('putChangeCommitMessage', async () => {
+ element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+ const change_num = 1 as NumericChangeId;
+ const message = 'this is a commit message';
+ const sendStub = sinon
+ .stub(element._restApiHelper, 'send')
+ .resolves([change_num, message] as unknown as ParsedJSON);
+ sinon
+ .stub(element, 'getResponseObject')
+ .resolves([change_num, message] as unknown as ParsedJSON);
+ element._cache.set(
+ `/changes/${change_num}/message`,
+ {} as unknown as ParsedJSON
+ );
+ await element.putChangeCommitMessage(change_num, message);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.PUT);
+ assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/message');
+ assert.deepEqual(sendStub.lastCall.args[0].body, {
+ message,
+ });
+ });
+
+ test('deleteChangeCommitMessage', async () => {
+ element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+ const change_num = 1 as NumericChangeId;
+ const messageId = 'abc' as ChangeMessageId;
+ const sendStub = sinon
+ .stub(element._restApiHelper, 'send')
+ .resolves([change_num, messageId] as unknown as ParsedJSON);
+ sinon
+ .stub(element, 'getResponseObject')
+ .resolves([change_num, messageId] as unknown as ParsedJSON);
+ await element.deleteChangeCommitMessage(change_num, messageId);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.DELETE);
+ assert.equal(sendStub.lastCall.args[0].url, '/changes/test~1/messages/abc');
+ });
+
+ test('startWorkInProgress', () => {
+ const sendStub = sinon
+ .stub(element, '_getChangeURLAndSend')
+ .resolves('ok' as unknown as ParsedJSON);
+ element.startWorkInProgress(42 as NumericChangeId);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+ assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+ assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+ assert.deepEqual(sendStub.lastCall.args[0].body, {});
+
+ element.startWorkInProgress(42 as NumericChangeId, 'revising...');
+ assert.isTrue(sendStub.calledTwice);
+ assert.equal(sendStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+ assert.isNotOk(sendStub.lastCall.args[0].patchNum);
+ assert.equal(sendStub.lastCall.args[0].endpoint, '/wip');
+ assert.deepEqual(sendStub.lastCall.args[0].body, {
+ message: 'revising...',
+ });
+ });
+
+ test('deleteComment', async () => {
+ const comment = createComment();
+ const sendStub = sinon
+ .stub(element, '_getChangeURLAndSend')
+ .resolves(comment as unknown as ParsedJSON);
+ const response = await element.deleteComment(
+ 123 as NumericChangeId,
+ 1 as PatchSetNum,
+ '01234' as UrlEncodedCommentId,
+ 'removal reason'
+ );
+ assert.equal(response, comment);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].changeNum, 123 as NumericChangeId);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+ assert.equal(sendStub.lastCall.args[0].patchNum, 1 as PatchSetNum);
+ assert.equal(sendStub.lastCall.args[0].endpoint, '/comments/01234/delete');
+ assert.deepEqual(sendStub.lastCall.args[0].body, {
+ reason: 'removal reason',
+ });
+ });
+
+ test('createRepo encodes name', async () => {
+ const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+ await element.createRepo({name: 'x/y' as RepoName});
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].url, '/projects/x%2Fy');
+ });
+
+ test('queryChangeFiles', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ await element.queryChangeFiles(42 as NumericChangeId, EDIT, 'test/path.js');
+ assert.equal(fetchStub.lastCall.args[0].changeNum, 42 as NumericChangeId);
+ assert.equal(
+ fetchStub.lastCall.args[0].endpoint,
+ '/files?q=test%2Fpath.js'
+ );
+ assert.equal(fetchStub.lastCall.args[0].revision, EDIT);
+ });
+
+ test('normal use', () => {
+ const defaultQuery = '';
+
+ assert.equal(
+ element._getReposUrl('test', 25).toString(),
+ [false, '/projects/?n=26&S=0&d=&m=test'].toString()
+ );
+
+ assert.equal(
+ element._getReposUrl(undefined, 25).toString(),
+ [false, `/projects/?n=26&S=0&d=&m=${defaultQuery}`].toString()
+ );
+
+ assert.equal(
+ element._getReposUrl('test', 25, 25).toString(),
+ [false, '/projects/?n=26&S=25&d=&m=test'].toString()
+ );
+
+ assert.equal(
+ element._getReposUrl('inname:test', 25, 25).toString(),
+ [true, '/projects/?n=26&S=25&query=inname%3Atest'].toString()
+ );
+ });
+
+ test('invalidateReposCache', () => {
+ const url = '/projects/?n=26&S=0&query=test';
+
+ element._cache.set(url, {} as unknown as ParsedJSON);
+
+ element.invalidateReposCache();
+
+ assert.isUndefined(element._sharedFetchPromises.get(url));
+
+ assert.isFalse(element._cache.has(url));
+ });
+
+ test('invalidateAccountsCache', () => {
+ const url = '/accounts/self/detail';
+
+ element._cache.set(url, {} as unknown as ParsedJSON);
+
+ element.invalidateAccountsCache();
+
+ assert.isUndefined(element._sharedFetchPromises.get(url));
+
+ assert.isFalse(element._cache.has(url));
+ });
+
+ suite('getRepos', () => {
+ const defaultQuery = '';
+ let fetchCacheURLStub: sinon.SinonStub;
+ setup(() => {
+ fetchCacheURLStub = sinon
+ .stub(element._restApiHelper, 'fetchCacheURL')
+ .resolves([] as unknown as ParsedJSON);
+ });
+
+ test('normal use', () => {
+ element.getRepos('test', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=test'
+ );
+
+ element.getRepos(undefined, 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ `/projects/?n=26&S=0&d=&m=${defaultQuery}`
+ );
+
+ element.getRepos('test', 25, 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=25&d=&m=test'
+ );
+ });
+
+ test('with blank', () => {
+ element.getRepos('test/test', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=test%2Ftest'
+ );
+ });
+
+ test('with hyphen', () => {
+ element.getRepos('foo-bar', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=foo-bar'
+ );
+ });
+
+ test('with leading hyphen', () => {
+ element.getRepos('-bar', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=-bar'
+ );
+ });
+
+ test('with trailing hyphen', () => {
+ element.getRepos('foo-bar-', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=foo-bar-'
+ );
+ });
+
+ test('with underscore', () => {
+ element.getRepos('foo_bar', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=foo_bar'
+ );
+ });
+
+ test('with underscore', () => {
+ element.getRepos('foo_bar', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=foo_bar'
+ );
+ });
+
+ test('hyphen only', () => {
+ element.getRepos('-', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&d=&m=-'
+ );
+ });
+
+ test('using query', () => {
+ element.getRepos('description:project', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/?n=26&S=0&query=description%3Aproject'
+ );
+ });
+ });
+
+ test('_getGroupsUrl normal use', () => {
+ assert.equal(element._getGroupsUrl('test', 25), '/groups/?n=26&S=0&m=test');
+
+ assert.equal(element._getGroupsUrl('', 25), '/groups/?n=26&S=0');
+
+ assert.equal(
+ element._getGroupsUrl('test', 25, 25),
+ '/groups/?n=26&S=25&m=test'
+ );
+ });
+
+ test('invalidateGroupsCache', () => {
+ const url = '/groups/?n=26&S=0&m=test';
+
+ element._cache.set(url, {} as unknown as ParsedJSON);
+
+ element.invalidateGroupsCache();
+
+ assert.isUndefined(element._sharedFetchPromises.get(url));
+
+ assert.isFalse(element._cache.has(url));
+ });
+
+ suite('getGroups', () => {
+ let fetchCacheURLStub: sinon.SinonStub;
+ setup(() => {
+ fetchCacheURLStub = sinon.stub(element._restApiHelper, 'fetchCacheURL');
+ });
+
+ test('normal use', () => {
+ element.getGroups('test', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/groups/?n=26&S=0&m=test'
+ );
+
+ element.getGroups('', 25);
+ assert.equal(fetchCacheURLStub.lastCall.args[0].url, '/groups/?n=26&S=0');
+
+ element.getGroups('test', 25, 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/groups/?n=26&S=25&m=test'
+ );
+ });
+
+ test('regex', () => {
+ element.getGroups('^test.*', 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/groups/?n=26&S=0&r=%5Etest.*'
+ );
+
+ element.getGroups('^test.*', 25, 25);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/groups/?n=26&S=25&r=%5Etest.*'
+ );
+ });
+ });
+
+ test('gerrit auth is used', () => {
+ const fetchStub = sinon.stub(authService, 'fetch').resolves();
+ element._restApiHelper.fetchJSON({url: 'foo'});
+ assert(fetchStub.called);
+ });
+
+ test('getSuggestedAccounts does not return fetchJSON', async () => {
+ const fetchJSONSpy = sinon.spy(element._restApiHelper, 'fetchJSON');
+ const accts = await element.getSuggestedAccounts('');
+ assert.isFalse(fetchJSONSpy.called);
+ assert.equal(accts!.length, 0);
+ });
+
+ test('fetchJSON gets called by getSuggestedAccounts', async () => {
+ const fetchJSONStub = sinon
+ .stub(element._restApiHelper, 'fetchJSON')
+ .resolves();
+ await element.getSuggestedAccounts('own');
+ assert.deepEqual(fetchJSONStub.lastCall.args[0].params, {
+ q: '"own"',
+ o: 'DETAILS',
+ });
+ });
+
+ suite('getChangeDetail', () => {
+ suite('change detail options', () => {
+ let changeDetailStub: sinon.SinonStub;
+ setup(() => {
+ changeDetailStub = sinon
+ .stub(element, '_getChangeDetail')
+ .resolves({...createChange(), _number: 123 as NumericChangeId});
+ });
+
+ test('signed pushes disabled', async () => {
+ sinon.stub(element, 'getConfig').resolves({
+ ...createServerInfo(),
+ receive: {enable_signed_push: undefined},
+ });
+ const change = await element.getChangeDetail(123 as NumericChangeId);
+ assert.strictEqual(123, change!._number);
+ const options = changeDetailStub.firstCall.args[1];
+ assert.isNotOk(
+ parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+ );
+ });
+
+ test('signed pushes enabled', async () => {
+ sinon.stub(element, 'getConfig').resolves({
+ ...createServerInfo(),
+ receive: {enable_signed_push: 'true'},
+ });
+ const change = await element.getChangeDetail(123 as NumericChangeId);
+ assert.strictEqual(123, change!._number);
+ const options = changeDetailStub.firstCall.args[1];
+ assert.ok(
+ parseInt(options, 16) & (1 << ListChangesOption.PUSH_CERTIFICATES)
+ );
+ });
+ });
+
+ test('GrReviewerUpdatesParser.parse is used', async () => {
+ const changeInfo = createParsedChange();
+ const parseStub = sinon
+ .stub(GrReviewerUpdatesParser, 'parse')
+ .resolves(changeInfo);
+ const result = await element.getChangeDetail(42 as NumericChangeId);
+ assert.isTrue(parseStub.calledOnce);
+ assert.equal(result, changeInfo);
+ });
+
+ test('_getChangeDetail passes params to ETags decorator', async () => {
+ const changeNum = 4321 as NumericChangeId;
+ element._projectLookup[changeNum] = Promise.resolve('test' as RepoName);
+ const expectedUrl = `${window.CANONICAL_PATH}/changes/test~4321/detail?O=516714`;
+ const optionsStub = sinon.stub(element._etags, 'getOptions');
+ const collectStub = sinon.stub(element._etags, 'collect');
+ await element._getChangeDetail(changeNum, '516714');
+ assert.isTrue(optionsStub.calledWithExactly(expectedUrl));
+ assert.equal(collectStub.lastCall.args[0], expectedUrl);
+ });
+
+ test('_getChangeDetail calls errFn on 500', async () => {
+ const errFn = sinon.stub();
+ sinon.stub(element, 'getChangeActionURL').resolves('');
+ sinon
+ .stub(element._restApiHelper, 'fetchRawJSON')
+ .resolves(new Response(undefined, {status: 500}));
+ await element._getChangeDetail(123 as NumericChangeId, '516714', errFn);
+ assert.isTrue(errFn.called);
+ });
+
+ test('_getChangeDetail populates _projectLookup', async () => {
+ sinon.stub(element, 'getChangeActionURL').resolves('');
+ sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+ new Response(')]}\'{"_number":1,"project":"test"}', {
+ status: 200,
+ })
+ );
+ await element._getChangeDetail(1 as NumericChangeId, '516714');
+ assert.equal(Object.keys(element._projectLookup).length, 1);
+ const project = await element._projectLookup[1];
+ assert.equal(project, 'test' as RepoName);
+ });
+
+ suite('_getChangeDetail ETag cache', () => {
+ let requestUrl: string;
+ let mockResponseSerial: string;
+ let collectSpy: sinon.SinonSpy;
+
+ setup(() => {
+ requestUrl = '/foo/bar';
+ const mockResponse = {foo: 'bar', baz: 42};
+ mockResponseSerial = JSON_PREFIX + JSON.stringify(mockResponse);
+ sinon.stub(element._restApiHelper, 'urlWithParams').returns(requestUrl);
+ sinon.stub(element, 'getChangeActionURL').resolves(requestUrl);
+ collectSpy = sinon.spy(element._etags, 'collect');
+ });
+
+ test('contributes to cache', async () => {
+ const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
+ sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+ new Response(mockResponseSerial, {
+ status: 200,
+ })
+ );
+
+ await element._getChangeDetail(123 as NumericChangeId, '516714');
+ assert.isFalse(getPayloadSpy.called);
+ assert.isTrue(collectSpy.calledOnce);
+ const cachedResponse = element._etags.getCachedPayload(requestUrl);
+ assert.equal(cachedResponse, mockResponseSerial);
+ });
+
+ test('uses cache on HTTP 304', async () => {
+ const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
+ getPayloadStub.returns(mockResponseSerial);
+ sinon.stub(element._restApiHelper, 'fetchRawJSON').resolves(
+ new Response(undefined, {
+ status: 304,
+ })
+ );
+
+ await element._getChangeDetail(123 as NumericChangeId, '');
+ assert.isFalse(collectSpy.called);
+ assert.isTrue(getPayloadStub.calledOnce);
+ });
+ });
+ });
+
+ test('setInProjectLookup', async () => {
+ element.setInProjectLookup(555 as NumericChangeId, 'project' as RepoName);
+ const project = await element.getFromProjectLookup(555 as NumericChangeId);
+ assert.deepEqual(project, 'project' as RepoName);
+ });
+
+ suite('getFromProjectLookup', () => {
+ const changeNum = 555 as NumericChangeId;
+ const repo = 'test-repo' as RepoName;
+
+ test('getChange fails to yield a project', async () => {
+ const promise = mockPromise<null>();
+ sinon.stub(element, 'getChange').returns(promise);
+
+ const projectLookup = element.getFromProjectLookup(changeNum);
+ promise.resolve(null);
+
+ assert.isUndefined(await projectLookup);
+ });
+
+ test('getChange succeeds with project', async () => {
+ const promise = mockPromise<null | ChangeInfo>();
+ sinon.stub(element, 'getChange').returns(promise);
+
+ const projectLookup = element.getFromProjectLookup(changeNum);
+ promise.resolve({...createChange(), project: repo});
+
+ assert.equal(await projectLookup, repo);
+ assert.deepEqual(element._projectLookup, {'555': projectLookup});
+ });
+
+ test('getChange fails, but a setInProjectLookup() call is used as fallback', async () => {
+ const promise = mockPromise<null>();
+ sinon.stub(element, 'getChange').returns(promise);
+
+ const projectLookup = element.getFromProjectLookup(changeNum);
+ element.setInProjectLookup(changeNum, repo);
+ promise.resolve(null);
+
+ assert.equal(await projectLookup, repo);
+ });
+ });
+
+ suite('getChanges populates _projectLookup', () => {
+ test('multiple queries', async () => {
+ sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+ [
+ {_number: 1, project: 'test'},
+ {_number: 2, project: 'test'},
+ ],
+ [{_number: 3, project: 'test/test'}],
+ ] as unknown as ParsedJSON);
+ // When query instanceof Array, fetchJSON returns
+ // Array<Array<Object>>.
+ await element.getChangesForMultipleQueries(undefined, []);
+ assert.equal(Object.keys(element._projectLookup).length, 3);
+ const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+ assert.equal(project1, 'test' as RepoName);
+ const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+ assert.equal(project2, 'test' as RepoName);
+ const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+ assert.equal(project3, 'test/test' as RepoName);
+ });
+
+ test('no query', async () => {
+ sinon.stub(element._restApiHelper, 'fetchJSON').resolves([
+ {_number: 1, project: 'test'},
+ {_number: 2, project: 'test'},
+ {_number: 3, project: 'test/test'},
+ ] as unknown as ParsedJSON);
+
+ // When query !instanceof Array, fetchJSON returns Array<Object>.
+ await element.getChanges();
+ assert.equal(Object.keys(element._projectLookup).length, 3);
+ const project1 = await element.getFromProjectLookup(1 as NumericChangeId);
+ assert.equal(project1, 'test' as RepoName);
+ const project2 = await element.getFromProjectLookup(2 as NumericChangeId);
+ assert.equal(project2, 'test' as RepoName);
+ const project3 = await element.getFromProjectLookup(3 as NumericChangeId);
+ assert.equal(project3, 'test/test' as RepoName);
+ });
+ });
+
+ test('getDetailedChangesWithActions', async () => {
+ 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, EXPECTED_QUERY_OPTIONS);
+ return Promise.resolve([]);
+ });
+ await element.getDetailedChangesWithActions([c1._number, c2._number]);
+ assert.isTrue(getChangesStub.calledOnce);
+ });
+
+ test('_getChangeURLAndFetch', async () => {
+ element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+ const fetchStub = sinon
+ .stub(element._restApiHelper, 'fetchJSON')
+ .resolves();
+ const req = {
+ changeNum: 1 as NumericChangeId,
+ endpoint: '/test',
+ revision: 1 as RevisionId,
+ };
+ await element._getChangeURLAndFetch(req);
+ assert.equal(
+ fetchStub.lastCall.args[0].url,
+ '/changes/test~1/revisions/1/test'
+ );
+ });
+
+ test('_getChangeURLAndSend', async () => {
+ element._projectLookup = {1: Promise.resolve('test' as RepoName)};
+ const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+ const req = {
+ changeNum: 1 as NumericChangeId,
+ method: HttpMethod.POST,
+ patchNum: 1 as PatchSetNum,
+ endpoint: '/test',
+ };
+ await element._getChangeURLAndSend(req);
+ assert.isTrue(sendStub.calledOnce);
+ assert.equal(sendStub.lastCall.args[0].method, HttpMethod.POST);
+ assert.equal(
+ sendStub.lastCall.args[0].url,
+ '/changes/test~1/revisions/1/test'
+ );
+ });
+
+ suite('reading responses', () => {
+ test('_readResponsePayload', async () => {
+ const mockObject = {foo: 'bar', baz: 'foo'} as unknown as ParsedJSON;
+ const serial = JSON_PREFIX + JSON.stringify(mockObject);
+ const response = new Response(serial);
+ const payload = await readResponsePayload(response);
+ assert.deepEqual(payload.parsed, mockObject);
+ assert.equal(payload.raw, serial);
+ });
+
+ test('_parsePrefixedJSON', () => {
+ const obj = {x: 3, y: {z: 4}, w: 23} as unknown as ParsedJSON;
+ const serial = JSON_PREFIX + JSON.stringify(obj);
+ const result = parsePrefixedJSON(serial);
+ assert.deepEqual(result, obj);
+ });
+ });
+
+ test('setChangeTopic', async () => {
+ const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+ await element.setChangeTopic(123 as NumericChangeId, 'foo-bar');
+ assert.isTrue(sendSpy.calledOnce);
+ assert.deepEqual(sendSpy.lastCall.args[0].body, {topic: 'foo-bar'});
+ });
+
+ test('setChangeHashtag', async () => {
+ const sendSpy = sinon.spy(element, '_getChangeURLAndSend');
+ await element.setChangeHashtag(123 as NumericChangeId, {
+ add: ['foo-bar' as Hashtag],
+ });
+ assert.isTrue(sendSpy.calledOnce);
+ assert.sameDeepMembers(
+ (sendSpy.lastCall.args[0].body! as HashtagsInput).add!,
+ ['foo-bar']
+ );
+ });
+
+ test('generateAccountHttpPassword', async () => {
+ const sendSpy = sinon.spy(element._restApiHelper, 'send');
+ await element.generateAccountHttpPassword();
+ assert.isTrue(sendSpy.calledOnce);
+ assert.deepEqual(sendSpy.lastCall.args[0].body, {generate: true});
+ });
+
+ suite('getChangeFiles', () => {
+ test('patch only', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ const range = {basePatchNum: PARENT, patchNum: 2 as RevisionPatchSetNum};
+ await element.getChangeFiles(123 as NumericChangeId, range);
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(
+ fetchStub.lastCall.args[0].revision,
+ 2 as RevisionPatchSetNum
+ );
+ assert.isNotOk(fetchStub.lastCall.args[0].params);
+ });
+
+ test('simple range', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ const range = {
+ basePatchNum: 4 as BasePatchSetNum,
+ patchNum: 5 as RevisionPatchSetNum,
+ };
+ await element.getChangeFiles(123 as NumericChangeId, range);
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+ assert.isOk(fetchStub.lastCall.args[0].params);
+ assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+ assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+ });
+
+ test('parent index', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ const range = {
+ basePatchNum: -3 as BasePatchSetNum,
+ patchNum: 5 as RevisionPatchSetNum,
+ };
+ await element.getChangeFiles(123 as NumericChangeId, range);
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+ assert.isOk(fetchStub.lastCall.args[0].params);
+ assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+ assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+ });
+ });
+
+ suite('getDiff', () => {
+ test('patchOnly', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ await element.getDiff(
+ 123 as NumericChangeId,
+ PARENT,
+ 2 as PatchSetNum,
+ 'foo/bar.baz'
+ );
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(fetchStub.lastCall.args[0].revision, 2 as RevisionId);
+ assert.isOk(fetchStub.lastCall.args[0].params);
+ assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+ assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+ });
+
+ test('simple range', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ await element.getDiff(
+ 123 as NumericChangeId,
+ 4 as PatchSetNum,
+ 5 as PatchSetNum,
+ 'foo/bar.baz'
+ );
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+ assert.isOk(fetchStub.lastCall.args[0].params);
+ assert.isNotOk(fetchStub.lastCall.args[0].params!.parent);
+ assert.equal(fetchStub.lastCall.args[0].params!.base, 4);
+ });
+
+ test('parent index', async () => {
+ const fetchStub = sinon.stub(element, '_getChangeURLAndFetch').resolves();
+ await element.getDiff(
+ 123 as NumericChangeId,
+ -3 as PatchSetNum,
+ 5 as PatchSetNum,
+ 'foo/bar.baz'
+ );
+ assert.isTrue(fetchStub.calledOnce);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5 as RevisionId);
+ assert.isOk(fetchStub.lastCall.args[0].params);
+ assert.isNotOk(fetchStub.lastCall.args[0].params!.base);
+ assert.equal(fetchStub.lastCall.args[0].params!.parent, 3);
+ });
+ });
+
+ test('getDashboard', () => {
+ const fetchCacheURLStub = sinon.stub(
+ element._restApiHelper,
+ 'fetchCacheURL'
+ );
+ element.getDashboard(
+ 'gerrit/project' as RepoName,
+ 'default:main' as DashboardId
+ );
+ assert.isTrue(fetchCacheURLStub.calledOnce);
+ assert.equal(
+ fetchCacheURLStub.lastCall.args[0].url,
+ '/projects/gerrit%2Fproject/dashboards/default%3Amain'
+ );
+ });
+
+ test('getFileContent', async () => {
+ sinon.stub(element, '_getChangeURLAndSend').resolves(
+ new Response(undefined, {
+ status: 200,
+ headers: {
+ 'X-FYI-Content-Type': 'text/java',
+ },
+ }) as unknown as ParsedJSON
+ );
+
+ sinon
+ .stub(element, 'getResponseObject')
+ .resolves('new content' as unknown as ParsedJSON);
+
+ const edit = await element.getFileContent(
+ 1 as NumericChangeId,
+ 'tst/path',
+ 'EDIT' as PatchSetNum
+ );
+
+ assert.deepEqual(edit, {
+ content: 'new content',
+ type: 'text/java',
+ ok: true,
+ });
+
+ const normal = await element.getFileContent(
+ 1 as NumericChangeId,
+ 'tst/path',
+ '3' as PatchSetNum
+ );
+ assert.deepEqual(normal, {
+ content: 'new content',
+ type: 'text/java',
+ ok: true,
+ });
+ });
+
+ test('getFileContent suppresses 404s', async () => {
+ const res404 = new Response(undefined, {status: 404});
+ const res500 = new Response(undefined, {status: 500});
+ const spy = sinon.spy();
+ addListenerForTest(document, 'server-error', spy);
+ const authStub = sinon.stub(authService, 'fetch').resolves(res404);
+ sinon.stub(element, '_changeBaseURL').resolves('');
+ await element.getFileContent(
+ 1 as NumericChangeId,
+ 'tst/path',
+ 1 as PatchSetNum
+ );
+ await waitEventLoop();
+ assert.isFalse(spy.called);
+ authStub.reset();
+ authStub.resolves(res500);
+ await element.getFileContent(
+ 1 as NumericChangeId,
+ 'tst/path',
+ 1 as PatchSetNum
+ );
+ assert.isTrue(spy.called);
+ assert.notEqual(spy.lastCall.args[0].detail.response.status, 404);
+ });
+
+ test('getChangeFilesOrEditFiles is edit-sensitive', async () => {
+ const getChangeFilesStub = sinon
+ .stub(element, 'getChangeFiles')
+ .resolves({});
+ const getChangeEditFilesStub = sinon
+ .stub(element, 'getChangeEditFiles')
+ .resolves({files: {}});
+
+ await element.getChangeOrEditFiles(1 as NumericChangeId, {
+ basePatchNum: PARENT,
+ patchNum: EDIT,
+ });
+ assert.isTrue(getChangeEditFilesStub.calledOnce);
+ assert.isFalse(getChangeFilesStub.called);
+ await element.getChangeOrEditFiles(1 as NumericChangeId, {
+ basePatchNum: PARENT,
+ patchNum: 1 as RevisionPatchSetNum,
+ });
+ assert.isTrue(getChangeEditFilesStub.calledOnce);
+ assert.isTrue(getChangeFilesStub.calledOnce);
+ });
+
+ test('_fetch forwards request and logs', async () => {
+ const logStub = sinon.stub(element._restApiHelper, '_logCall');
+ const response = new Response(undefined, {status: 404});
+ const url = 'my url';
+ const fetchOptions = {method: 'DELETE'};
+ sinon.stub(authService, 'fetch').resolves(response);
+ const startTime = 123;
+ sinon.stub(Date, 'now').returns(startTime);
+ const req = {url, fetchOptions};
+ await element._restApiHelper.fetch(req);
+ assert.isTrue(logStub.calledOnce);
+ assert.isTrue(logStub.calledWith(req, startTime, response.status));
+ });
+
+ test('_logCall only reports requests with anonymized URLss', async () => {
+ sinon.stub(Date, 'now').returns(200);
+ const handler = sinon.stub();
+ addListenerForTest(document, 'gr-rpc-log', handler);
+
+ element._restApiHelper._logCall({url: 'url'}, 100, 200);
+ assert.isFalse(handler.called);
+
+ element._restApiHelper._logCall(
+ {url: 'url', anonymizedUrl: 'not url'},
+ 100,
+ 200
+ );
+ await waitEventLoop();
+ assert.isTrue(handler.calledOnce);
+ });
+
+ test('ported comment errors do not trigger error dialog', () => {
+ const change = createChange();
+ const handler = sinon.stub();
+ addListenerForTest(document, 'server-error', handler);
+ sinon.stub(element._restApiHelper, 'fetchJSON').resolves({
+ ok: false,
+ } as unknown as ParsedJSON);
+
+ element.getPortedComments(change._number, CURRENT);
+
+ assert.isFalse(handler.called);
+ });
+
+ test('ported drafts are not requested user is not logged in', () => {
+ const change = createChange();
+ sinon.stub(element, 'getLoggedIn').resolves(false);
+ const getChangeURLAndFetchStub = sinon.stub(
+ element,
+ '_getChangeURLAndFetch'
+ );
+
+ element.getPortedDrafts(change._number, CURRENT);
+
+ assert.isFalse(getChangeURLAndFetchStub.called);
+ });
+
+ test('saveChangeStarred', async () => {
+ sinon.stub(element, 'getFromProjectLookup').resolves('test' as RepoName);
+ const sendStub = sinon.stub(element._restApiHelper, 'send').resolves();
+
+ await element.saveChangeStarred(123 as NumericChangeId, true);
+ assert.isTrue(sendStub.calledOnce);
+ assert.deepEqual(sendStub.lastCall.args[0], {
+ method: HttpMethod.PUT,
+ url: '/accounts/self/starred.changes/test~123',
+ anonymizedUrl: '/accounts/self/starred.changes/*',
+ });
+
+ await element.saveChangeStarred(456 as NumericChangeId, false);
+ assert.isTrue(sendStub.calledTwice);
+ assert.deepEqual(sendStub.lastCall.args[0], {
+ method: HttpMethod.DELETE,
+ url: '/accounts/self/starred.changes/test~456',
+ anonymizedUrl: '/accounts/self/starred.changes/*',
+ });
+ });
+
+ suite('getDocsBaseUrl tests', () => {
+ test('null config', async () => {
+ const probePathMock = sinon.stub(element, 'probePath').resolves(true);
+ const docsBaseUrl = await element.getDocsBaseUrl(undefined);
+ assert.equal(
+ probePathMock.lastCall.args[0],
+ `${getBaseUrl()}/Documentation/index.html`
+ );
+ assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+ });
+
+ test('no doc config', async () => {
+ const probePathMock = sinon.stub(element, 'probePath').resolves(true);
+ const config: ServerInfo = {
+ ...createServerInfo(),
+ gerrit: createGerritInfo(),
+ };
+ const docsBaseUrl = await element.getDocsBaseUrl(config);
+ assert.equal(
+ probePathMock.lastCall.args[0],
+ `${getBaseUrl()}/Documentation/index.html`
+ );
+ assert.equal(docsBaseUrl, `${getBaseUrl()}/Documentation`);
+ });
+
+ test('has doc config', async () => {
+ const probePathMock = sinon.stub(element, 'probePath').resolves(true);
+ const config: ServerInfo = {
+ ...createServerInfo(),
+ gerrit: {...createGerritInfo(), doc_url: 'foobar'},
+ };
+ const docsBaseUrl = await element.getDocsBaseUrl(config);
+ assert.isFalse(probePathMock.called);
+ assert.equal(docsBaseUrl, 'foobar');
+ });
+
+ test('no probe', async () => {
+ const probePathMock = sinon.stub(element, 'probePath').resolves(false);
+ const docsBaseUrl = await element.getDocsBaseUrl(undefined);
+ assert.equal(
+ probePathMock.lastCall.args[0],
+ `${getBaseUrl()}/Documentation/index.html`
+ );
+ assert.isNotOk(docsBaseUrl);
+ });
+ });
+});
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 b794233fc3..d8bf276927 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
@@ -64,13 +64,12 @@ import {
Password,
PatchRange,
PatchSetNum,
- PathToCommentsInfoMap,
PathToRobotCommentsInfoMap,
PluginInfo,
PreferencesInfo,
PreferencesInput,
ProjectAccessInfo,
- ProjectAccessInfoMap,
+ RepoAccessInfoMap,
ProjectAccessInput,
ProjectInfo,
ProjectInfoWithName,
@@ -91,6 +90,7 @@ import {
TopMenuEntryInfo,
UrlEncodedCommentId,
UserId,
+ DraftInfo,
} from '../../types/common';
import {
DiffInfo,
@@ -99,7 +99,6 @@ import {
} from '../../types/diff';
import {ParsedChangeInfo} from '../../types/types';
import {ErrorCallback} from '../../api/rest';
-import {DraftInfo} from '../../utils/comment-util';
export type CancelConditionCallback = () => boolean;
@@ -127,7 +126,8 @@ export interface RestApiService extends Finalizable {
getRepos(
filter: string | undefined,
reposPerPage: number,
- offset?: number
+ offset?: number,
+ errFn?: ErrorCallback
): Promise<ProjectInfoWithName[] | undefined>;
send(
@@ -152,11 +152,13 @@ export interface RestApiService extends Finalizable {
getChangeSuggestedReviewers(
changeNum: NumericChangeId,
- input: string
+ input: string,
+ errFn?: ErrorCallback
): Promise<SuggestedReviewerInfo[] | undefined>;
getChangeSuggestedCCs(
changeNum: NumericChangeId,
- input: string
+ input: string,
+ errFn?: ErrorCallback
): Promise<SuggestedReviewerInfo[] | undefined>;
/**
* Request list of accounts via https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-account
@@ -166,12 +168,14 @@ export interface RestApiService extends Finalizable {
input: string,
n?: number,
canSee?: NumericChangeId,
- filterActive?: boolean
+ filterActive?: boolean,
+ errFn?: ErrorCallback
): Promise<AccountInfo[] | undefined>;
getSuggestedGroups(
input: string,
project?: RepoName,
- n?: number
+ n?: number,
+ errFn?: ErrorCallback
): Promise<GroupNameToGroupInfoMap | undefined>;
/**
* Execute a change action or revision action on a change.
@@ -194,8 +198,8 @@ export interface RestApiService extends Finalizable {
getChangeDetail(
changeNum?: number | string,
- opt_errFn?: ErrorCallback,
- opt_cancelCondition?: Function
+ errFn?: ErrorCallback,
+ cancelCondition?: Function
): Promise<ParsedChangeInfo | undefined>;
/**
@@ -267,7 +271,8 @@ export interface RestApiService extends Finalizable {
queryChangeFiles(
changeNum: NumericChangeId,
patchNum: PatchSetNum,
- query: string
+ query: string,
+ errFn?: ErrorCallback
): Promise<string[] | undefined>;
getRepoAccessRights(
@@ -287,7 +292,7 @@ export interface RestApiService extends Finalizable {
errFn?: ErrorCallback
): Promise<DashboardInfo[] | undefined>;
- getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined>;
+ getRepoAccess(repo: RepoName): Promise<RepoAccessInfoMap | undefined>;
getProjectConfig(
repo: RepoName,
@@ -369,8 +374,10 @@ export interface RestApiService extends Finalizable {
endpoint: string
): Promise<string>;
+ getDocsBaseUrl(config?: ServerInfo): Promise<string | null>;
+
createChange(
- project: RepoName,
+ repo: RepoName,
branch: BranchName,
subject: string,
topic?: string,
@@ -403,16 +410,16 @@ export interface RestApiService extends Finalizable {
getPortedComments(
changeNum: NumericChangeId,
revision: RevisionId
- ): Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: CommentInfo[]} | undefined>;
getPortedDrafts(
changeNum: NumericChangeId,
revision: RevisionId
- ): Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: DraftInfo[]} | undefined>;
getDiffComments(
changeNum: NumericChangeId
- ): Promise<PathToCommentsInfoMap | undefined>;
+ ): Promise<{[path: string]: CommentInfo[]} | undefined>;
getDiffComments(
changeNum: NumericChangeId,
basePatchNum: PatchSetNum,
@@ -425,7 +432,7 @@ export interface RestApiService extends Finalizable {
patchNum?: PatchSetNum,
path?: string
):
- | Promise<PathToCommentsInfoMap | undefined>
+ | Promise<{[path: string]: CommentInfo[]} | undefined>
| Promise<GetDiffCommentsOutput>;
getDiffRobotComments(
@@ -472,7 +479,8 @@ export interface RestApiService extends Finalizable {
changesPerPage?: number,
query?: string,
offset?: 'n,z' | number,
- options?: string
+ options?: string,
+ errFn?: ErrorCallback
): Promise<ChangeInfo[] | undefined>;
getChangesForMultipleQueries(
changesPerPage?: number,
@@ -513,9 +521,10 @@ export interface RestApiService extends Finalizable {
deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response>;
- getSuggestedProjects(
+ getSuggestedRepos(
inputVal: string,
- n?: number
+ n?: number,
+ errFn?: ErrorCallback
): Promise<NameToProjectInfoMap | undefined>;
invalidateGroupsCache(): void;
@@ -640,7 +649,7 @@ export interface RestApiService extends Finalizable {
): Promise<ChangeInfo[] | undefined>;
getChangeCherryPicks(
- project: RepoName,
+ repo: RepoName,
changeID: ChangeId,
branch: BranchName
): Promise<ChangeInfo[] | undefined>;
@@ -652,9 +661,13 @@ export interface RestApiService extends Finalizable {
changeToExclude?: NumericChangeId;
}
): Promise<ChangeInfo[] | undefined>;
- getChangesWithSimilarTopic(topic: string): Promise<ChangeInfo[] | undefined>;
+ getChangesWithSimilarTopic(
+ topic: string,
+ errFn?: ErrorCallback
+ ): Promise<ChangeInfo[] | undefined>;
getChangesWithSimilarHashtag(
- hashtag: string
+ hashtag: string,
+ errFn?: ErrorCallback
): Promise<ChangeInfo[] | undefined>;
/**
@@ -764,7 +777,7 @@ export interface RestApiService extends Finalizable {
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
*/
getDashboard(
- project: RepoName,
+ repo: RepoName,
dashboard: DashboardId,
errFn?: ErrorCallback
): Promise<DashboardInfo | undefined>;
@@ -802,7 +815,7 @@ export interface RestApiService extends Finalizable {
getTopMenus(): Promise<TopMenuEntryInfo[] | undefined>;
- setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+ setInProjectLookup(changeNum: NumericChangeId, repo: RepoName): void;
getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
putChangeCommitMessage(
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
index 0bb02d83ad..842dace4eb 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -7,7 +7,7 @@ import {
getAccountDisplayName,
getGroupDisplayName,
} from '../../utils/display-name-util';
-import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
import {
AccountInfo,
isReviewerAccountSuggestion,
@@ -20,7 +20,7 @@ import {
import {assertNever} from '../../utils/common-util';
import {AutocompleteSuggestion} from '../../elements/shared/gr-autocomplete/gr-autocomplete';
import {allSettled, isFulfilled} from '../../utils/async-util';
-import {notUndefined, ParsedChangeInfo} from '../../types/types';
+import {isDefined, ParsedChangeInfo} from '../../types/types';
import {accountKey} from '../../utils/account-util';
import {
AccountId,
@@ -29,6 +29,7 @@ import {
GroupId,
ReviewerState,
} from '../../api/rest-api';
+import {throwingErrorCallback} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
export interface ReviewerSuggestionsProvider {
getSuggestions(input: string): Promise<Suggestion[]>;
@@ -52,6 +53,11 @@ export class GrReviewerSuggestionsProvider
this.changes = changes;
}
+ /**
+ * Requests related suggestions.
+ *
+ * If the request fails the returned promise is rejected.
+ */
async getSuggestions(input: string): Promise<Suggestion[]> {
if (!this.loggedIn) return [];
@@ -63,7 +69,7 @@ export class GrReviewerSuggestionsProvider
const suggestionsByChangeIndex = resultsByChangeIndex
.filter(isFulfilled)
.map(result => result.value)
- .filter(notUndefined);
+ .filter(isDefined);
if (suggestionsByChangeIndex.length !== resultsByChangeIndex.length) {
// one of the requests failed, so don't allow any suggestions.
return [];
@@ -121,8 +127,16 @@ export class GrReviewerSuggestionsProvider
input: string
): Promise<SuggestedReviewerInfo[] | undefined> {
return this.type === ReviewerState.REVIEWER
- ? this.restApi.getChangeSuggestedReviewers(changeNumber, input)
- : this.restApi.getChangeSuggestedCCs(changeNumber, input);
+ ? this.restApi.getChangeSuggestedReviewers(
+ changeNumber,
+ input,
+ throwingErrorCallback
+ )
+ : this.restApi.getChangeSuggestedCCs(
+ changeNumber,
+ input,
+ throwingErrorCallback
+ );
}
}
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
index 15f3d242b8..e96a2ad38d 100644
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
+++ b/polygerrit-ui/app/services/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider_test.ts
@@ -5,7 +5,7 @@
*/
import '../../test/common-test-setup';
import {GrReviewerSuggestionsProvider} from './gr-reviewer-suggestions-provider';
-import {getAppContext} from '../../services/app-context';
+import {getAppContext} from '../app-context';
import {stubRestApi} from '../../test/test-utils';
import {
AccountDetailInfo,
diff --git a/polygerrit-ui/app/services/highlight/highlight-service.ts b/polygerrit-ui/app/services/highlight/highlight-service.ts
index bfaa263c03..d10d875655 100644
--- a/polygerrit-ui/app/services/highlight/highlight-service.ts
+++ b/polygerrit-ui/app/services/highlight/highlight-service.ts
@@ -3,6 +3,7 @@
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import {define} from '../../models/dependency';
import {
SyntaxWorkerRequest,
SyntaxWorkerInit,
@@ -39,6 +40,8 @@ export const CODE_MAX_LINES = 20 * 1000;
*/
const CODE_MAX_LENGTH = 25 * CODE_MAX_LINES;
+export const highlightServiceToken =
+ define<HighlightService>('highlight-service');
/**
* Service for syntax highlighting. Maintains some HighlightJS workers doing
* their job in the background.
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index f8bc77818e..5e2cc10b41 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -4,23 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {Observable} from 'rxjs';
-import {Finalizable} from '../registry';
-import {
- NumericChangeId,
- RevisionPatchSetNum,
- BasePatchSetNum,
-} from '../../types/common';
import {Model} from '../../models/model';
import {select} from '../../utils/observable-util';
+import {define} from '../../models/dependency';
export enum GerritView {
ADMIN = 'admin',
AGREEMENTS = 'agreements',
CHANGE = 'change',
DASHBOARD = 'dashboard',
- DIFF = 'diff',
DOCUMENTATION_SEARCH = 'documentation-search',
- EDIT = 'edit',
GROUP = 'group',
PLUGIN_SCREEN = 'plugin-screen',
REPO = 'repo',
@@ -28,31 +21,23 @@ export enum GerritView {
SETTINGS = 'settings',
}
+// TODO: Consider renaming this to AppElementState or something similar.
+// Or maybe RootViewState. This class does *not* model the state of the router.
export interface RouterState {
// Note that this router model view must be updated before view model state.
view?: GerritView;
- changeNum?: NumericChangeId;
- patchNum?: RevisionPatchSetNum;
- basePatchNum?: BasePatchSetNum;
}
-export class RouterModel extends Model<RouterState> implements Finalizable {
+export const routerModelToken = define<RouterModel>('router-model');
+
+// TODO: Consider renaming this to AppElementViewModel or something similar.
+// Or maybe RootViewModel. This class is *not* a view model of the router.
+export class RouterModel extends Model<RouterState> {
readonly routerView$: Observable<GerritView | undefined> = select(
this.state$,
state => state.view
);
- readonly routerChangeNum$: Observable<NumericChangeId | undefined> = select(
- this.state$,
- state => state.changeNum
- );
-
- readonly routerPatchNum$: Observable<RevisionPatchSetNum | undefined> =
- select(this.state$, state => state.patchNum);
-
- readonly routerBasePatchNum$: Observable<BasePatchSetNum | undefined> =
- select(this.state$, state => state.basePatchNum);
-
constructor() {
super({});
}
diff --git a/polygerrit-ui/app/services/service-worker-installer.ts b/polygerrit-ui/app/services/service-worker-installer.ts
index 53cd3256c0..b83713c940 100644
--- a/polygerrit-ui/app/services/service-worker-installer.ts
+++ b/polygerrit-ui/app/services/service-worker-installer.ts
@@ -12,17 +12,51 @@ import {
import {UserModel} from '../models/user/user-model';
import {AccountDetailInfo} from '../api/rest-api';
import {until} from '../utils/async-util';
+import {LifeCycle} from '../constants/reporting';
+import {ReportingService} from './gr-reporting/gr-reporting';
+import {define} from '../models/dependency';
+import {Model} from '../models/model';
+import {Observable} from 'rxjs';
+import {select} from '../utils/observable-util';
/** Type of incoming messages for ServiceWorker. */
export enum ServiceWorkerMessageType {
TRIGGER_NOTIFICATIONS = 'TRIGGER_NOTIFICATIONS',
USER_PREFERENCE_CHANGE = 'USER_PREFERENCE_CHANGE',
+ REPORTING = 'REPORTING',
}
export const TRIGGER_NOTIFICATION_UPDATES_MS = 5 * 60 * 1000;
-export class ServiceWorkerInstaller {
- initialized = false;
+export const serviceWorkerInstallerToken = define<ServiceWorkerInstaller>(
+ 'service-worker-installer'
+);
+
+/**
+ * Service worker state:
+ * initialized - True when service worker registered and event listeners added.
+ * - False otherwise
+ * shouldShowPrompt - True when user didn't make decision about notifications
+ * - False otherwise
+ */
+export interface ServiceWorkerInstallerState {
+ initialized: boolean;
+ shouldShowPrompt: boolean;
+}
+
+export class ServiceWorkerInstaller extends Model<ServiceWorkerInstallerState> {
+ readonly initialized$: Observable<Boolean | undefined> = select(
+ this.state$,
+ state => state.initialized
+ );
+
+ readonly shouldShowPrompt$: Observable<Boolean | undefined> = select(
+ this.initialized$,
+ _ => this.shouldShowPrompt()
+ );
+
+ // Internal state, it's exposed in initialized$
+ private initialized = false;
account?: AccountDetailInfo;
@@ -30,8 +64,10 @@ export class ServiceWorkerInstaller {
constructor(
private readonly flagsService: FlagsService,
+ private readonly reportingService: ReportingService,
private readonly userModel: UserModel
) {
+ super({initialized: false, shouldShowPrompt: false});
if (!this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS)) {
return;
}
@@ -43,10 +79,12 @@ export class ServiceWorkerInstaller {
) {
this.allowBrowserNotificationsPreference =
prefs.allow_browser_notifications;
+ // flag can disable notifications similar to user setting
navigator.serviceWorker.controller?.postMessage({
type: ServiceWorkerMessageType.USER_PREFERENCE_CHANGE,
allowBrowserNotificationsPreference:
- this.allowBrowserNotificationsPreference,
+ this.allowBrowserNotificationsPreference &&
+ this.flagsService.isEnabled(KnownExperimentId.PUSH_NOTIFICATIONS),
});
}
});
@@ -73,9 +111,40 @@ export class ServiceWorkerInstaller {
return;
}
await registerServiceWorker('/service-worker.js');
- const permission = await Notification.requestPermission();
+ const permission = Notification.permission;
+ this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+ permission,
+ });
if (this.isPermitted(permission)) this.startTriggerTimer();
this.initialized = true;
+ this.updateState({initialized: true});
+ // Assumption: service worker will send event only to 1 client.
+ navigator.serviceWorker.onmessage = event => {
+ if (event.data?.type === ServiceWorkerMessageType.REPORTING) {
+ this.reportingService.reportLifeCycle(LifeCycle.SERVICE_WORKER_UPDATE, {
+ eventName: event.data.eventName as string | undefined,
+ });
+ }
+ };
+ }
+
+ // 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';
+ }
+
+ public async requestPermission() {
+ const permission = await Notification.requestPermission();
+ this.reportingService.reportLifeCycle(LifeCycle.NOTIFICATION_PERMISSION, {
+ requested: true,
+ permission,
+ });
+ if (this.isPermitted(permission)) this.startTriggerTimer();
}
areNotificationsEnabled() {
diff --git a/polygerrit-ui/app/services/service-worker-installer_test.ts b/polygerrit-ui/app/services/service-worker-installer_test.ts
index e8fd233387..a036289da9 100644
--- a/polygerrit-ui/app/services/service-worker-installer_test.ts
+++ b/polygerrit-ui/app/services/service-worker-installer_test.ts
@@ -9,14 +9,17 @@ import {ServiceWorkerInstaller} from './service-worker-installer';
import {assert} from '@open-wc/testing';
import {createDefaultPreferences} from '../constants/constants';
import {waitUntilObserved} from '../test/test-utils';
+import {testResolver} from '../test/common-test-setup';
+import {userModelToken} from '../models/user/user-model';
suite('service worker installer tests', () => {
test('init', async () => {
const registerStub = sinon.stub(window.navigator.serviceWorker, 'register');
const flagsService = getAppContext().flagsService;
- const userModel = getAppContext().userModel;
+ const reportingService = getAppContext().reportingService;
+ const userModel = testResolver(userModelToken);
sinon.stub(flagsService, 'isEnabled').returns(true);
- new ServiceWorkerInstaller(flagsService, userModel);
+ new ServiceWorkerInstaller(flagsService, reportingService, userModel);
const prefs = {
...createDefaultPreferences(),
allow_browser_notifications: true,
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
index da61c4118c..9ca2213c84 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -35,6 +35,8 @@ export enum Shortcut {
GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+ GO_TO_REPOS = 'GO_TO_REPOS',
+ GO_TO_GROUPS = 'GO_TO_GROUPS',
CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
@@ -167,6 +169,16 @@ export function createShortcutConfig() {
{key: 'w', combo: ComboKey.G}
);
describe(
+ Shortcut.GO_TO_REPOS,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Repositories',
+ {key: 'r', combo: ComboKey.G}
+ );
+ describe(Shortcut.GO_TO_GROUPS, ShortcutSection.EVERYWHERE, 'Go to Groups', {
+ key: 'g',
+ combo: ComboKey.G,
+ });
+ describe(
Shortcut.TOGGLE_CHECKBOX,
ShortcutSection.ACTIONS,
'Toggle checkbox',
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
index dac9b9265f..756c209835 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -25,6 +25,7 @@ import {ReportingService} from '../gr-reporting/gr-reporting';
import {Finalizable} from '../registry';
import {UserModel} from '../../models/user/user-model';
import {define} from '../../models/dependency';
+import {isCharacterLetter, isUpperCase} from '../../utils/string-util';
export {Shortcut, ShortcutSection};
@@ -365,7 +366,10 @@ export function describeBinding(binding: Binding): string[] {
if (binding.combo === ComboKey.V) {
description.push('v');
}
- if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+ if (
+ binding.modifiers?.includes(Modifier.SHIFT_KEY) ||
+ (isCharacterLetter(binding.key) && isUpperCase(binding.key))
+ ) {
description.push('Shift');
}
if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
@@ -377,6 +381,12 @@ export function describeBinding(binding: Binding): string[] {
if (binding.modifiers?.includes(Modifier.META_KEY)) {
description.push('Meta/Cmd');
}
- description.push(describeKey(binding.key));
+
+ let key = describeKey(binding.key);
+ if (isCharacterLetter(key)) {
+ key = key.toLowerCase();
+ }
+ description.push(key);
+
return description;
}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
index 5b38a8ab94..164000adde 100644
--- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -15,6 +15,8 @@ import {Binding, Key, Modifier} from '../../utils/dom-util';
import {getAppContext} from '../app-context';
import {pressKey} from '../../test/test-utils';
import {assert} from '@open-wc/testing';
+import {testResolver} from '../../test/common-test-setup';
+import {userModelToken} from '../../models/user/user-model';
const KEY_A: Binding = {key: 'a'};
@@ -23,14 +25,14 @@ suite('shortcuts-service tests', () => {
setup(() => {
service = new ShortcutsService(
- getAppContext().userModel,
+ testResolver(userModelToken),
getAppContext().reportingService
);
});
test('getShortcut', () => {
assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
- assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
+ assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'Shift+a');
});
suite('addShortcut()', () => {
diff --git a/polygerrit-ui/app/services/storage/gr-storage.ts b/polygerrit-ui/app/services/storage/gr-storage.ts
index b3b76d4cde..d7eb09a780 100644
--- a/polygerrit-ui/app/services/storage/gr-storage.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage.ts
@@ -3,29 +3,15 @@
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {CommentRange, NumericChangeId, PatchSetNum} from '../../types/common';
+import {NumericChangeId} from '../../types/common';
import {Finalizable} from '../registry';
-export interface StorageLocation {
- changeNum: number;
- patchNum: PatchSetNum | '@change';
- path?: string;
- line?: number;
- range?: CommentRange;
-}
-
export interface StorageObject {
message?: string;
updated: number;
}
export interface StorageService extends Finalizable {
- getDraftComment(location: StorageLocation): StorageObject | null;
-
- setDraftComment(location: StorageLocation, message: string): void;
-
- eraseDraftComment(location: StorageLocation): void;
-
getEditableContentItem(key: string): StorageObject | null;
setEditableContentItem(key: string, message: string): void;
diff --git a/polygerrit-ui/app/services/storage/gr-storage_impl.ts b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
index 0caffbc3a0..7a47e0e546 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_impl.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_impl.ts
@@ -3,9 +3,10 @@
* Copyright 2016 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {StorageObject, StorageService} from './gr-storage';
import {Finalizable} from '../registry';
import {NumericChangeId} from '../../types/common';
+import {define} from '../../models/dependency';
export const DURATION_DAY = 24 * 60 * 60 * 1000;
@@ -16,30 +17,19 @@ const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
+export const storageServiceToken = define<StorageService>('storage-service');
+
export class GrStorageService implements StorageService, Finalizable {
private lastCleanup = 0;
- private readonly storage = window.localStorage;
+ // visible for testing
+ storage = window.localStorage;
- private exceededQuota = false;
+ // visible for testing
+ exceededQuota = false;
finalize() {}
- getDraftComment(location: StorageLocation): StorageObject | null {
- this.cleanupItems();
- return this.getObject(this.getDraftKey(location));
- }
-
- setDraftComment(location: StorageLocation, message: string) {
- const key = this.getDraftKey(location);
- this.setObject(key, {message, updated: Date.now()});
- }
-
- eraseDraftComment(location: StorageLocation) {
- const key = this.getDraftKey(location);
- this.storage.removeItem(key);
- }
-
getEditableContentItem(key: string): StorageObject | null {
this.cleanupItems();
return this.getObject(this.getEditableContentKey(key));
@@ -76,29 +66,13 @@ export class GrStorageService implements StorageService, Finalizable {
}
}
- private getDraftKey(location: StorageLocation): string {
- const range = location.range
- ? `${location.range.start_line}-${location.range.start_character}` +
- `-${location.range.end_character}-${location.range.end_line}`
- : null;
- let key = [
- 'draft',
- location.changeNum,
- location.patchNum,
- location.path,
- location.line || '',
- ].join(':');
- if (range) {
- key = key + ':' + range;
- }
- return key;
- }
-
- private getEditableContentKey(key: string): string {
+ // visible for testing
+ getEditableContentKey(key: string): string {
return `editablecontent:${key}`;
}
- private cleanupItems() {
+ // visible for testing
+ cleanupItems() {
// Throttle cleanup to the throttle interval.
if (
this.lastCleanup &&
diff --git a/polygerrit-ui/app/services/storage/gr-storage_mock.ts b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
index 65e6a89b0e..822fef2aeb 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_mock.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_mock.ts
@@ -4,28 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {NumericChangeId} from '../../types/common';
-import {StorageLocation, StorageObject, StorageService} from './gr-storage';
+import {StorageObject, StorageService} from './gr-storage';
const storage = new Map<string, StorageObject>();
-const getDraftKey = (location: StorageLocation): string => {
- const range = location.range
- ? `${location.range.start_line}-${location.range.start_character}` +
- `-${location.range.end_character}-${location.range.end_line}`
- : null;
- let key = [
- 'draft',
- location.changeNum,
- location.patchNum,
- location.path,
- location.line || '',
- ].join(':');
- if (range) {
- key = key + ':' + range;
- }
- return key;
-};
-
const getEditableContentKey = (key: string): string => `editablecontent:${key}`;
export function cleanUpStorage() {
@@ -34,19 +16,6 @@ export function cleanUpStorage() {
export const grStorageMock: StorageService = {
finalize(): void {},
- getDraftComment(location: StorageLocation): StorageObject | null {
- return storage.get(getDraftKey(location)) ?? null;
- },
-
- setDraftComment(location: StorageLocation, message: string) {
- const key = getDraftKey(location);
- storage.set(key, {message, updated: Date.now()});
- },
-
- eraseDraftComment(location: StorageLocation) {
- const key = getDraftKey(location);
- storage.delete(key);
- },
getEditableContentItem(key: string): StorageObject | null {
return storage.get(getEditableContentKey(key)) ?? null;
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.ts b/polygerrit-ui/app/services/storage/gr-storage_test.ts
index 92d611e632..72878f8e96 100644
--- a/polygerrit-ui/app/services/storage/gr-storage_test.ts
+++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -4,17 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {assert} from '@open-wc/testing';
+import {NumericChangeId} from '../../api/rest-api';
import '../../test/common-test-setup';
-import {PatchSetNum} from '../../types/common';
-import {StorageLocation} from './gr-storage';
import {GrStorageService} from './gr-storage_impl';
suite('gr-storage tests', () => {
- // We have to type as any because we access private methods
- // for testing
- let grStorage: any;
+ let grStorage: GrStorageService;
- function mockStorage(opt_quotaExceeded: boolean) {
+ function mockStorage(quotaExceeded: boolean): Storage {
return {
getItem(key: string) {
return (this as any)[key];
@@ -23,12 +20,12 @@ suite('gr-storage tests', () => {
delete (this as any)[key];
},
setItem(key: string, value: string) {
- if (opt_quotaExceeded) {
+ if (quotaExceeded) {
throw new DOMException('error', 'QuotaExceededError');
}
(this as any)[key] = value;
},
- };
+ } as Storage;
}
setup(() => {
@@ -36,115 +33,12 @@ suite('gr-storage tests', () => {
grStorage.storage = mockStorage(false);
});
- test('storing, retrieving and erasing drafts', () => {
- const changeNum = 1234;
- const patchNum = 5 as PatchSetNum;
- const path = 'my_source_file.js';
- const line = 123;
- const location = {
- changeNum,
- patchNum,
- path,
- line,
- };
-
- // The key is in the expected format.
- const key = grStorage.getDraftKey(location);
- assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
-
- // There should be no draft initially.
- const draft = grStorage.getDraftComment(location);
- assert.isNotOk(draft);
-
- // Setting the draft stores it under the expected key.
- grStorage.setDraftComment(location, 'my comment');
- assert.isOk(grStorage.storage.getItem(key));
- assert.equal(
- JSON.parse(grStorage.storage.getItem(key)).message,
- 'my comment'
- );
- assert.isOk(JSON.parse(grStorage.storage.getItem(key)).updated);
-
- // Erasing the draft removes the key.
- grStorage.eraseDraftComment(location);
- assert.isNotOk(grStorage.storage.getItem(key));
- });
-
- test('automatically removes old drafts', () => {
- const changeNum = 1234;
- const patchNum = 5;
- const path = 'my_source_file.js';
- const line = 123;
- const location = {
- changeNum,
- patchNum,
- path,
- line,
- };
-
- const key = grStorage.getDraftKey(location);
-
- // Make sure that the call to cleanup doesn't get throttled.
- grStorage.lastCleanup = 0;
-
- const cleanupSpy = sinon.spy(grStorage, 'cleanupItems');
-
- // Create a message with a timestamp that is a second behind the max age.
- grStorage.storage.setItem(
- key,
- JSON.stringify({
- message: 'old message',
- updated: Date.now() - 24 * 60 * 60 * 1000 - 1000,
- })
- );
-
- // Getting the draft should cause it to be removed.
- const draft = grStorage.getDraftComment(location);
-
- assert.isTrue(cleanupSpy.called);
- assert.isNotOk(draft);
- assert.isNotOk(grStorage.storage.getItem(key));
- });
-
- test('getDraftKey', () => {
- const changeNum = 1234;
- const patchNum = 5 as PatchSetNum;
- const path = 'my_source_file.js';
- const line = 123;
- const location: StorageLocation = {
- changeNum,
- patchNum,
- path,
- line,
- };
- let expectedResult = 'draft:1234:5:my_source_file.js:123';
- assert.equal(grStorage.getDraftKey(location), expectedResult);
- location.range = {
- start_character: 1,
- start_line: 1,
- end_character: 1,
- end_line: 2,
- };
- expectedResult = 'draft:1234:5:my_source_file.js:123:1-1-1-2';
- assert.equal(grStorage.getDraftKey(location), expectedResult);
- });
-
test('exceeded quota disables storage', () => {
grStorage.storage = mockStorage(true);
assert.isFalse(grStorage.exceededQuota);
- const changeNum = 1234;
- const patchNum = 5;
- const path = 'my_source_file.js';
- const line = 123;
- const location = {
- changeNum,
- patchNum,
- path,
- line,
- };
- const key = grStorage.getDraftKey(location);
- grStorage.setDraftComment(location, 'my comment');
+ const key = grStorage.getEditableContentKey('test-key');
+ grStorage.setEditableContentItem(key, 'test message');
assert.isTrue(grStorage.exceededQuota);
assert.isNotOk(grStorage.storage.getItem(key));
});
@@ -159,16 +53,16 @@ suite('gr-storage tests', () => {
grStorage.setEditableContentItem(key, 'my content');
// Setting the draft stores it under the expected key.
- let item = grStorage.storage.getItem(computedKey);
+ const item = grStorage.storage.getItem(computedKey);
assert.isOk(item);
- assert.equal(JSON.parse(item).message, 'my content');
- assert.isOk(JSON.parse(item).updated);
+ assert.equal(JSON.parse(item!).message, 'my content');
+ assert.isOk(JSON.parse(item!).updated);
// getEditableContentItem performs as expected.
- item = grStorage.getEditableContentItem(key);
- assert.isOk(item);
- assert.equal(item.message, 'my content');
- assert.isOk(item.updated);
+ const obj = grStorage.getEditableContentItem(key);
+ assert.isOk(obj);
+ assert.equal(obj!.message, 'my content');
+ assert.isOk(obj!.updated);
assert.isTrue(cleanupStub.called);
// eraseEditableContentItem performs as expected.
@@ -188,8 +82,8 @@ suite('gr-storage tests', () => {
'editablecontent:c50_psedit_index.php'
);
assert.isOk(item);
- assert.equal(JSON.parse(item).message, 'my content test 1');
- assert.isOk(JSON.parse(item).updated);
+ assert.equal(JSON.parse(item!).message, 'my content test 1');
+ assert.isOk(JSON.parse(item!).updated);
// We have to add getItem, removeItem and setItem to the array.
// Typically these functions don't get outputed in .storage,
@@ -204,7 +98,7 @@ suite('gr-storage tests', () => {
'editablecontent:c50_ps3_index.php',
]);
- grStorage.eraseEditableContentItemsForChangeEdit(50);
+ grStorage.eraseEditableContentItemsForChangeEdit(50 as NumericChangeId);
// We have to add getItem, removeItem and setItem to the array.
// Typically these functions don't get outputed in .storage,
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index cd45f8b5bd..d9edb994fc 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -30,8 +30,7 @@ export const dashboardHeaderStyles = css`
.info > div > span {
display: inline-block;
font-weight: var(--font-weight-bold);
- text-align: right;
- width: 4em;
+ width: 3.5em;
}
`;
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 4af276e54e..0c7c151d34 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -14,11 +14,11 @@ export const changeListStyles = css`
background-color: var(--selection-background-color);
}
gr-change-list-item[highlight] {
- background-color: var(--assignee-highlight-color);
+ background-color: var(--line-item-highlight-color);
}
gr-change-list-item[highlight][selected],
gr-change-list-item[highlight]:focus {
- background-color: var(--assignee-highlight-selection-color);
+ background-color: var(--line-item-highlight-selection-color);
}
.groupTitle td,
.cell {
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index cc89c3c18e..120b0bd850 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -9,6 +9,7 @@ export const formStyles = css`
.gr-form-styles input {
background-color: var(--view-background-color);
color: var(--primary-text-color);
+ font: inherit;
}
.gr-form-styles select {
background-color: var(--select-background-color);
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index 7a44e7976a..17b7461692 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -32,7 +32,7 @@ export const menuPageStyles = css`
color: var(--deemphasized-text-color);
padding: var(--spacing-l);
}
- @media only screen and (max-width: 67em) {
+ @media only screen and (max-width: 70em) {
.main {
margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
}
diff --git a/polygerrit-ui/app/styles/gr-modal-styles.ts b/polygerrit-ui/app/styles/gr-modal-styles.ts
new file mode 100644
index 0000000000..b1bcf5124d
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-modal-styles.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {css} from 'lit';
+
+export const modalStyles = css`
+ dialog {
+ padding: 0;
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ background: var(--dialog-background-color);
+ box-shadow: var(--elevation-level-5);
+ /*
+ * These styles are taken from main.css
+ * Dialog exists in the top-layer outside the body hence the styles
+ * in main.css were not being applied.
+ */
+ font-family: var(--font-family, ''), 'Roboto', Arial, sans-serif;
+ font-size: var(--font-size-normal, 1rem);
+ line-height: var(--line-height-normal, 1.4);
+ color: var(--primary-text-color, black);
+ }
+
+ dialog::backdrop {
+ background-color: black;
+ opacity: var(--modal-opacity, 0.6);
+ }
+`;
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index b6e8f60a85..963b2a258b 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -16,12 +16,15 @@ export const pageNavStyles = css`
border-top: 1px solid transparent;
display: block;
padding: 0 var(--spacing-xl);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.navStyles li a {
display: block;
+ /* overflow and text-overflow are not inherited, must repeat them */
overflow: hidden;
text-overflow: ellipsis;
- white-space: nowrap;
}
.navStyles .subsectionItem {
padding-left: var(--spacing-xxl);
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 839f612480..5a7ca48ce5 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -124,13 +124,8 @@ export const sharedStyles = css`
/* iron-autogrow-textarea has a "-webkit-appearance: textarea" :host
css rule, which prevents overriding the border color. Clear that. */
-webkit-appearance: none;
- --iron-autogrow-textarea: {
- box-sizing: border-box;
- padding: var(--spacing-s);
- };
--iron-autogrow-textarea_-_box-sizing: border-box;
--iron-autogrow-textarea_-_padding: var(--spacing-s);
- --iron-autogrow-textarea_-_white-space: pre-wrap;
}
a {
color: var(--link-color);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index e148da9b4d..0503e4cecc 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -113,6 +113,8 @@ const appThemeCss = safeStyleSheet`
--white-10: #ffffff1a;
--white-12: #ffffff1f;
+ --modal-opacity: 0.32;
+
--error-foreground: var(--red-700);
--error-background: var(--red-50);
--error-background-hover: linear-gradient(
@@ -224,7 +226,7 @@ const appThemeCss = safeStyleSheet`
--tooltip-button-text-color: var(--gerrit-blue-dark);
--negative-red-text-color: var(--red-600);
--positive-green-text-color: var(--green-700);
- --indirect-ancestor-text-color: var(--green-700);
+ --indirect-relation-text-color: var(--green-700);
/* background colors */
/* primary background colors */
@@ -246,10 +248,13 @@ const appThemeCss = safeStyleSheet`
--table-subheader-background-color: var(--background-color-tertiary);
--view-background-color: var(--background-color-primary);
/* unique background colors */
+ /* TODO: Remove assignee colors once references are migrated */
--assignee-highlight-color: #fcfad6;
- /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
- --selection-background-color than to just invent another unique color. */
--assignee-highlight-selection-color: #f6f4d0;
+ --line-item-highlight-color: #fcfad6;
+ /* TODO: Find a nicer way to combine the --line-item-highlight-color and the
+ --selection-background-color than to just invent another unique color. */
+ --line-item-highlight-selection-color: #f6f4d0;
--chip-selected-background-color: var(--blue-50);
--edit-mode-background-color: #ebf5fb;
--emphasis-color: #fff9c4;
@@ -273,6 +278,11 @@ const appThemeCss = safeStyleSheet`
--robot-comment-background-color: var(--blue-50);
--unresolved-comment-background-color: #fef7e0;
+
+ /* Suggest edits */
+ --user-suggestion-header-background: var(--gray-700);
+ --user-suggestion-header-color: white;
+
/* vote background colors */
--vote-color-approved: var(--green-300);
--vote-color-disliked: var(--red-50);
@@ -397,6 +407,8 @@ const appThemeCss = safeStyleSheet`
--diff-moved-in-background: var(--cyan-50);
--diff-moved-in-label-color: var(--cyan-900);
+ --diff-moved-in-changed-background: var(--cyan-50);
+ --diff-moved-in-changed-label-color: var(--cyan-900);
--diff-moved-out-background: var(--purple-50);
--diff-moved-out-label-color: var(--purple-900);
@@ -411,8 +423,8 @@ const appThemeCss = safeStyleSheet`
--diff-trailing-whitespace-indicator: #ff9ad2;
--focused-line-outline-color: var(--blue-700);
--coverage-covered-line-num-color: var(--deemphasized-text-color);
- --coverage-covered: #e0f2f1;
- --coverage-not-covered: #ffd1a4;
+ --coverage-covered: var(--cyan-100);
+ --coverage-not-covered: var(--orange-100);
--ranged-comment-hint-text-color: var(--orange-900);
--token-highlighting-color: #fffd54;
@@ -474,13 +486,9 @@ const appThemeCss = safeStyleSheet`
/* misc */
--border-radius: 4px;
- --reply-overlay-z-index: 1000;
--line-length-indicator-color: #681da8;
- /* paper and iron component overrides */
- --iron-overlay-backdrop-background-color: black;
- --iron-overlay-backdrop-opacity: 0.32;
-
+ /* paper component overrides */
--paper-tooltip-delay-in: 200ms;
--paper-tooltip-delay-out: 0;
--paper-tooltip-duration-in: 0;
@@ -517,9 +525,6 @@ const appThemeCssPolymerLegacy = safeStyleSheet`
--paper-tooltip: {
font-size: var(--font-size-small);
};
- --iron-overlay-backdrop: {
- transition: none;
- };
}
`;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 2330041f29..dc3d4e951a 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -113,7 +113,7 @@ const darkThemeCss = safeStyleSheet`
--tooltip-button-text-color: var(--gerrit-blue-light);
--negative-red-text-color: var(--red-200);
--positive-green-text-color: var(--green-200);
- --indirect-ancestor-text-color: var(--green-200);
+ --indirect-relation-text-color: var(--green-200);
/* background colors */
/* primary background colors */
@@ -123,8 +123,8 @@ const darkThemeCss = safeStyleSheet`
/* directly derived from primary background colors */
/* empty, because inheriting from app-theme is just fine
/* unique background colors */
- --assignee-highlight-color: #3a361c;
- --assignee-highlight-selection-color: #423e24;
+ --line-item-highlight-color: #3a361c;
+ --line-item-highlight-selection-color: #423e24;
--chip-selected-background-color: #3c4455;
--edit-mode-background-color: #5c0a36;
--emphasis-color: #383f4a;
@@ -138,6 +138,10 @@ const darkThemeCss = safeStyleSheet`
--robot-comment-background-color: #1e3a5f;
--unresolved-comment-background-color: #614a19;
+ /* Suggest edits */
+ --user-suggestion-header-background: var(--gray-700);
+ --user-suggestion-header-color: white;
+
/* vote background colors */
--vote-color-approved: var(--green-300);
--vote-color-disliked: var(--red-tonal);
@@ -226,6 +230,8 @@ const darkThemeCss = safeStyleSheet`
--diff-moved-in-background: #1d4042;
--diff-moved-in-label-color: var(--cyan-50);
+ --diff-moved-in-changed-background: #1d4042;
+ --diff-moved-in-changed-label-color: var(--cyan-50);
--diff-moved-out-background: #230e34;
--diff-moved-out-label-color: var(--purple-50);
@@ -239,9 +245,9 @@ const darkThemeCss = safeStyleSheet`
--diff-tab-indicator-color: var(--deemphasized-text-color);
--diff-trailing-whitespace-indicator: #ff9ad2;
--focused-line-outline-color: var(--blue-200);
- --coverage-covered: #37674a;
+ --coverage-covered: var(--cyan-tonal);
--coverage-covered-line-num-color: var(--gray-200);
- --coverage-not-covered: #6b3600;
+ --coverage-not-covered: var(--orange-tonal);
--ranged-comment-hint-text-color: var(--blue-50);
--token-highlighting-color: var(--yellow-tonal);
@@ -279,9 +285,6 @@ const darkThemeCss = safeStyleSheet`
/* misc */
--line-length-indicator-color: #d7aefb;
- /* paper and iron component overrides */
- --iron-overlay-backdrop-background-color: white;
-
/* rules applied to html */
background-color: var(--view-background-color);
}
@@ -292,7 +295,13 @@ export function applyTheme() {
const styleEl = document.createElement('style');
styleEl.setAttribute('id', 'dark-theme');
safeStyleEl.setTextContent(styleEl, darkThemeCss);
- document.head.appendChild(styleEl);
+
+ // 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
+ // OTOH we want to insert before any plugin provided styles, because we do NOT
+ // want to override those.
+ const pluginStyleEl = document.head.querySelector('style#plugin-style');
+ document.head.insertBefore(styleEl, pluginStyleEl);
}
export function removeTheme() {
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 306747b5d8..365bb16bf9 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -6,24 +6,23 @@
// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
import '../scripts/bundled-polymer';
-import {AppContext, injectAppContext} from '../services/app-context';
+import {getAppContext} from '../services/app-context';
import {Finalizable} from '../services/registry';
import {
createTestAppContext,
createTestDependencies,
- Creator,
} from './test-app-context-init';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
+import {testOnlyResetGrRestApiSharedObjects} from '../services/gr-rest-api/gr-rest-api-impl';
import {
cleanupTestUtils,
getCleanupsCount,
- addIronOverlayBackdropStyleEl,
- removeIronOverlayBackdropStyleEl,
removeThemeStyles,
} from './test-utils';
import {safeTypesBridge} from '../utils/safe-types-util';
-import {initGlobalVariables} from '../elements/gr-app-global-var-init';
+import {
+ initGerrit,
+ initGlobalVariables,
+} from '../elements/gr-app-global-var-init';
import {assert, fixtureCleanup} from '@open-wc/testing';
import {
_testOnly_defaultResinReportHandler,
@@ -39,6 +38,8 @@ import {
} from '../models/dependency';
import * as sinon from 'sinon';
import '../styles/themes/app-theme.ts';
+import {Creator} from '../services/app-context-init';
+import {pluginLoaderToken} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
declare global {
interface Window {
@@ -59,7 +60,6 @@ installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
});
let testSetupTimestampMs = 0;
-let appContext: AppContext & Finalizable;
const injectedDependencies: Map<
DependencyToken<unknown>,
@@ -91,44 +91,44 @@ export function testResolver<T>(token: DependencyToken<T>): T {
}
function resolveDependency(evt: DependencyRequestEvent<unknown>) {
- evt.callback(testResolver(evt.dependency));
+ evt.callback(() => testResolver(evt.dependency));
}
setup(() => {
testSetupTimestampMs = new Date().getTime();
- addIronOverlayBackdropStyleEl();
// If the following asserts fails - then window.stub is
// overwritten by some other code.
assert.equal(getCleanupsCount(), 0);
- appContext = createTestAppContext();
- injectAppContext(appContext);
- finalizers.push(appContext);
- const dependencies = createTestDependencies(appContext, testResolver);
+ initGlobalVariables(createTestAppContext(), false);
+
+ finalizers.push(getAppContext());
+ const dependencies = createTestDependencies(getAppContext(), testResolver);
for (const [token, provider] of dependencies) {
injectDependency(token, provider);
}
document.addEventListener('request-dependency', resolveDependency);
+ initGerrit(testResolver(pluginLoaderToken));
+
// The following calls is necessary to avoid influence of previously executed
// tests.
- initGlobalVariables(appContext);
-
const selection = document.getSelection();
if (selection) {
selection.removeAllRanges();
}
- const pl = _testOnly_resetPluginLoader();
// For testing, always init with empty plugin list
// Since when serve in gr-app, we always retrieve the list
// from project config and init loading after that, all
// `awaitPluginsLoaded` will rely on that to kick off,
// in testing, we want to kick start this earlier.
- // You still can manually call _testOnly_resetPluginLoader
- // to reset this behavior if you need to test something specific.
- pl.loadPlugins([]);
- _testOnlyResetGrRestApiSharedObjects();
+ testResolver(pluginLoaderToken).loadPlugins([]);
+ testOnlyResetGrRestApiSharedObjects(getAppContext().authService);
});
+export function removeRequestDependencyListener() {
+ document.removeEventListener('request-dependency', resolveDependency);
+}
+
// Very simple function to catch unexpected elements in documents body.
// It can't catch everything, but in most cases it is enough.
function checkChildAllowed(element: Element) {
@@ -136,23 +136,6 @@ function checkChildAllowed(element: Element) {
if (allowedTags.includes(element.tagName)) {
return;
}
- if (element.tagName === 'TEST-FIXTURE') {
- if (
- element.children.length === 0 ||
- (element.children.length === 1 &&
- element.children[0].tagName === 'TEMPLATE')
- ) {
- return;
- }
- assert.fail(
- `Test fixture
- ${element.outerHTML}` +
- "isn't resotred after the test is finished. Please ensure that " +
- 'restore() method is called for this test-fixture. Usually the call' +
- 'happens automatically.'
- );
- return;
- }
if (
element.tagName === 'DIV' &&
element.id === 'gr-hovercard-container' &&
@@ -185,11 +168,10 @@ teardown(() => {
fixtureCleanup();
cleanupTestUtils();
checkGlobalSpace();
- removeIronOverlayBackdropStyleEl();
removeThemeStyles();
cancelAllTasks();
cleanUpStorage();
- document.removeEventListener('request-dependency', resolveDependency);
+ removeRequestDependencyListener();
injectedDependencies.clear();
// Reset state
for (const f of finalizers) {
diff --git a/polygerrit-ui/app/test/functional/README.md b/polygerrit-ui/app/test/functional/README.md
deleted file mode 100644
index 82c613360d..0000000000
--- a/polygerrit-ui/app/test/functional/README.md
+++ /dev/null
@@ -1,54 +0,0 @@
-# Functional test suite
-
-## Installing Docker (OSX)
-
-Simplest way to install all of those is to use Homebrew:
-
-```
-brew cask install docker
-```
-
-This will install a Docker in Applications. To run if from the command-line:
-
-```
-open /Applications/Docker.app
-```
-
-It'll require privileged access and will require user password to be entered.
-
-To validate Docker is installed correctly, run hello-world image:
-
-```
-docker run hello-world
-```
-
-## Building a Docker image
-
-Should be done once only for development purposes, run from the Gerrit checkout
-path:
-
-```
-docker build -t gerrit/polygerrit-functional:v1 \
- polygerrit-ui/app/test/functional/infra
-```
-
-## Running a smoke test
-
-Running a smoke test from Gerrit checkout path:
-
-```
-./polygerrit-ui/app/test/functional/run_functional.sh
-```
-
-The successful output should be something similar to this:
-
-```
-Starting local server..
-Starting Webdriver..
-Started
-.
-
-
-1 spec, 0 failures
-Finished in 2.565 seconds
-```
diff --git a/polygerrit-ui/app/test/functional/infra/Dockerfile b/polygerrit-ui/app/test/functional/infra/Dockerfile
deleted file mode 100644
index e6421766a7..0000000000
--- a/polygerrit-ui/app/test/functional/infra/Dockerfile
+++ /dev/null
@@ -1,38 +0,0 @@
-FROM selenium/standalone-chrome-debug
-
-USER root
-
-# nvm environment variables
-ENV NVM_DIR /usr/local/nvm
-ENV NODE_VERSION 9.4.0
-
-# install nvm
-# https://github.com/creationix/nvm#install-script
-RUN wget -qO- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash
-
-# install node and npm
-RUN [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" \
- && nvm install $NODE_VERSION \
- && nvm alias default $NODE_VERSION \
- && nvm use default
-
-ENV NODE_PATH $NVM_DIR/v$NODE_VERSION/lib/node_modules
-ENV PATH $NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH
-
-RUN npm install -g jasmine
-RUN npm install -g http-server
-
-USER seluser
-
-RUN mkdir -p /tmp/app
-WORKDIR /tmp/app
-
-RUN npm init -y
-RUN npm install --save selenium-webdriver
-
-EXPOSE 8080
-
-COPY test-infra.js /tmp/app/node_modules
-COPY run.sh /tmp/app/
-
-ENTRYPOINT [ "/tmp/app/run.sh" ]
diff --git a/polygerrit-ui/app/test/functional/infra/run.sh b/polygerrit-ui/app/test/functional/infra/run.sh
deleted file mode 100755
index 4beb3dd1df..0000000000
--- a/polygerrit-ui/app/test/functional/infra/run.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/sh
-echo Starting local server..
-cp /app/polygerrit_ui.zip .
-unzip -q polygerrit_ui.zip
-nohup http-server polygerrit_ui > /tmp/http-server.log 2>&1 &
-
-echo Starting Webdriver..
-nohup /opt/bin/entry_point.sh > /tmp/webdriver.log 2>&1 &
-
-# Wait for servers to start
-sleep 5
-
-cp $@ .
-jasmine $(basename $@)
diff --git a/polygerrit-ui/app/test/functional/infra/test-infra.js b/polygerrit-ui/app/test/functional/infra/test-infra.js
deleted file mode 100644
index 26196941ab..0000000000
--- a/polygerrit-ui/app/test/functional/infra/test-infra.js
+++ /dev/null
@@ -1,24 +0,0 @@
-'use strict';
-
-const {Builder} = require('selenium-webdriver');
-
-let driver;
-
-function setup() {
- return new Builder()
- .forBrowser('chrome')
- .usingServer('http://localhost:4444/wd/hub')
- .build()
- .then(d => {
- driver = d;
- return driver.get('http://localhost:8080');
- })
- .then(() => driver);
-}
-
-function cleanup() {
- return driver.quit();
-}
-
-exports.setup = setup;
-exports.cleanup = cleanup;
diff --git a/polygerrit-ui/app/test/functional/run_functional.sh b/polygerrit-ui/app/test/functional/run_functional.sh
deleted file mode 100755
index 7ce57b8d27..0000000000
--- a/polygerrit-ui/app/test/functional/run_functional.sh
+++ /dev/null
@@ -1,10 +0,0 @@
-#!/usr/bin/env bash
-
-bazel build //polygerrit-ui/app:polygerrit_ui
-
-docker run --rm \
- -p 5900:5900 \
- -v `pwd`/polygerrit-ui/app/test/functional:/tests \
- -v `pwd`/bazel-genfiles/polygerrit-ui/app:/app \
- -it gerrit/polygerrit-functional:v1 \
- /tests/test.js
diff --git a/polygerrit-ui/app/test/functional/test.js b/polygerrit-ui/app/test/functional/test.js
deleted file mode 100644
index ae572af5ef..0000000000
--- a/polygerrit-ui/app/test/functional/test.js
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * @fileoverview Minimal viable frontend functional test.
- */
-'use strict';
-
-const {until} = require('selenium-webdriver');
-const {setup, cleanup} = require('test-infra');
-
-jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000;
-
-describe('example ', () => {
- let driver;
-
- beforeAll(() => setup().then(d => driver = d));
-
- afterAll(() => cleanup());
-
- it('should update title', () => driver.wait(
- until.titleIs('status:open · Gerrit Code Review'), 5000
- ));
-});
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 492bd8d3fd..bfa881ecdd 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -30,10 +30,9 @@ import {
ConfigInfo,
EditInfo,
DashboardInfo,
- ProjectAccessInfoMap,
+ RepoAccessInfoMap,
IncludedInInfo,
CommentInfo,
- PathToCommentsInfoMap,
PluginInfo,
DocResult,
ContributorAgreementInfo,
@@ -59,6 +58,7 @@ import {
UrlEncodedRepoName,
NumericChangeId,
PreferencesInput,
+ DraftInfo,
} from '../../types/common';
import {DiffInfo, DiffPreferencesInfo} from '../../types/diff';
import {readResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
@@ -67,6 +67,7 @@ import {
createChange,
createCommit,
createConfig,
+ createMergeable,
createPreferences,
createServerInfo,
createSubmittedTogetherInfo,
@@ -76,6 +77,11 @@ import {
createDefaultEditPrefs,
} from '../../constants/constants';
import {ParsedChangeInfo} from '../../types/types';
+import {getBaseUrl} from '../../utils/url-util';
+import {
+ DOCS_BASE_PATH,
+ PROBE_PATH,
+} from '../../services/gr-rest-api/gr-rest-api-impl';
export const grRestApiMock: RestApiService = {
addAccountEmail(): Promise<Response> {
@@ -305,6 +311,16 @@ export const grRestApiMock: RestApiService = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return Promise.resolve({}) as any;
},
+ getDocsBaseUrl(config?: ServerInfo): Promise<string | null> {
+ if (config?.gerrit?.doc_url) {
+ return Promise.resolve(config.gerrit.doc_url);
+ } else {
+ return this.probePath(getBaseUrl() + PROBE_PATH).then(ok =>
+ Promise.resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null)
+ );
+ }
+ return Promise.resolve('');
+ },
getDocumentationSearches(): Promise<DocResult[] | undefined> {
return Promise.resolve([]);
},
@@ -350,18 +366,19 @@ export const grRestApiMock: RestApiService = {
return Promise.resolve(true);
},
getMergeable(): Promise<MergeableInfo | undefined> {
- throw new Error('getMergeable() not implemented by RestApiMock.');
+ return Promise.resolve(createMergeable());
},
getPlugins(): Promise<{[p: string]: PluginInfo} | undefined> {
return Promise.resolve({});
},
- getPortedComments(): Promise<PathToCommentsInfoMap | undefined> {
+ getPortedComments(): Promise<{[path: string]: CommentInfo[]} | undefined> {
return Promise.resolve({});
},
- getPortedDrafts(): Promise<PathToCommentsInfoMap | undefined> {
+ getPortedDrafts(): Promise<{[path: string]: DraftInfo[]} | undefined> {
return Promise.resolve({});
},
getPreferences(): Promise<PreferencesInfo | undefined> {
+ // TODO: Use createDefaultPreferences() instead.
return Promise.resolve(createPreferences());
},
getProjectConfig(): Promise<ConfigInfo | undefined> {
@@ -376,7 +393,7 @@ export const grRestApiMock: RestApiService = {
name: repo,
});
},
- getRepoAccess(): Promise<ProjectAccessInfoMap | undefined> {
+ getRepoAccess(): Promise<RepoAccessInfoMap | undefined> {
return Promise.resolve({});
},
getRepoAccessRights(): Promise<ProjectAccessInfo | undefined> {
@@ -412,7 +429,7 @@ export const grRestApiMock: RestApiService = {
getSuggestedGroups(): Promise<GroupNameToGroupInfoMap | undefined> {
return Promise.resolve({});
},
- getSuggestedProjects(): Promise<NameToProjectInfoMap | undefined> {
+ getSuggestedRepos(): Promise<NameToProjectInfoMap | undefined> {
return Promise.resolve({});
},
getTopMenus(): Promise<TopMenuEntryInfo[] | undefined> {
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
index 06935704a4..026e3b5cf8 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -6,221 +6,48 @@
// Init app context before any other imports
import {create, Registry, Finalizable} from '../services/registry';
-import {DependencyToken} from '../models/dependency';
-import {assertIsDefined} from '../utils/common-util';
import {AppContext} from '../services/app-context';
import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
import {grRestApiMock} from './mocks/gr-rest-api_mock';
import {grStorageMock} from '../services/storage/gr-storage_mock';
import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
import {FlagsServiceImplementation} from '../services/flags/flags_impl';
-import {EventEmitter} from '../services/gr-event-interface/gr-event-interface_impl';
-import {ChangeModel, changeModelToken} from '../models/change/change-model';
-import {FilesModel, filesModelToken} from '../models/change/files-model';
-import {ChecksModel, checksModelToken} from '../models/checks/checks-model';
-import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
-import {UserModel} from '../models/user/user-model';
-import {
- CommentsModel,
- commentsModelToken,
-} from '../models/comments/comments-model';
-import {RouterModel} from '../services/router/router-model';
-import {
- ShortcutsService,
- shortcutsServiceToken,
-} from '../services/shortcuts/shortcuts-service';
-import {ConfigModel, configModelToken} from '../models/config/config-model';
-import {BrowserModel, browserModelToken} from '../models/browser/browser-model';
-import {PluginsModel} from '../models/plugins/plugins-model';
import {MockHighlightService} from '../services/highlight/highlight-service-mock';
-import {
- AccountsModel,
- accountsModelToken,
-} from '../models/accounts-model/accounts-model';
-import {
- DashboardViewModel,
- dashboardViewModelToken,
-} from '../models/views/dashboard';
-import {
- SettingsViewModel,
- settingsViewModelToken,
-} from '../models/views/settings';
-import {GrRouter, routerToken} from '../elements/core/gr-router/gr-router';
-import {AdminViewModel, adminViewModelToken} from '../models/views/admin';
-import {
- AgreementViewModel,
- agreementViewModelToken,
-} from '../models/views/agreement';
-import {ChangeViewModel, changeViewModelToken} from '../models/views/change';
-import {DiffViewModel, diffViewModelToken} from '../models/views/diff';
-import {
- DocumentationViewModel,
- documentationViewModelToken,
-} from '../models/views/documentation';
-import {EditViewModel, editViewModelToken} from '../models/views/edit';
-import {GroupViewModel, groupViewModelToken} from '../models/views/group';
-import {PluginViewModel, pluginViewModelToken} from '../models/views/plugin';
-import {RepoViewModel, repoViewModelToken} from '../models/views/repo';
-import {SearchViewModel, searchViewModelToken} from '../models/views/search';
+import {createAppDependencies, Creator} from '../services/app-context-init';
import {navigationToken} from '../elements/core/gr-navigation/gr-navigation';
+import {DependencyToken} from '../models/dependency';
+import {storageServiceToken} from '../services/storage/gr-storage_impl';
+import {highlightServiceToken} from '../services/highlight/highlight-service';
export function createTestAppContext(): AppContext & Finalizable {
const appRegistry: Registry<AppContext> = {
- routerModel: (_ctx: Partial<AppContext>) => new RouterModel(),
flagsService: (_ctx: Partial<AppContext>) =>
new FlagsServiceImplementation(),
reportingService: (_ctx: Partial<AppContext>) => grReportingMock,
- eventEmitter: (_ctx: Partial<AppContext>) => new EventEmitter(),
- authService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.eventEmitter, 'eventEmitter');
- return new GrAuthMock(ctx.eventEmitter);
- },
+ authService: (_ctx: Partial<AppContext>) => new GrAuthMock(),
restApiService: (_ctx: Partial<AppContext>) => grRestApiMock,
- jsApiService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new GrJsApiInterface(ctx.reportingService);
- },
- storageService: (_ctx: Partial<AppContext>) => grStorageMock,
- userModel: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new UserModel(ctx.restApiService);
- },
- accountsModel: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.restApiService, 'restApiService');
- return new AccountsModel(ctx.restApiService);
- },
- shortcutsService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.userModel, 'userModel');
- assertIsDefined(ctx.flagsService, 'flagsService');
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new ShortcutsService(ctx.userModel, ctx.reportingService);
- },
- pluginsModel: (_ctx: Partial<AppContext>) => new PluginsModel(),
- highlightService: (ctx: Partial<AppContext>) => {
- assertIsDefined(ctx.reportingService, 'reportingService');
- return new MockHighlightService(ctx.reportingService);
- },
};
return create<AppContext>(appRegistry);
}
-export type Creator<T> = () => T & Finalizable;
-
-// Test dependencies are provides as creator functions to ensure that they are
-// not created if a test doesn't depend on them. E.g. don't create a
-// change-model in change-model_test.ts because it creates one in the test
-// after setting up stubs.
export function createTestDependencies(
appContext: AppContext,
resolver: <T>(token: DependencyToken<T>) => T
): Map<DependencyToken<unknown>, Creator<unknown>> {
- const dependencies = new Map<DependencyToken<unknown>, Creator<unknown>>();
- const browserModel = () => new BrowserModel(appContext.userModel);
- dependencies.set(browserModelToken, browserModel);
-
- const adminViewModelCreator = () => new AdminViewModel();
- dependencies.set(adminViewModelToken, adminViewModelCreator);
- const agreementViewModelCreator = () => new AgreementViewModel();
- dependencies.set(agreementViewModelToken, agreementViewModelCreator);
- const changeViewModelCreator = () => new ChangeViewModel();
- dependencies.set(changeViewModelToken, changeViewModelCreator);
- const dashboardViewModelCreator = () => new DashboardViewModel();
- dependencies.set(dashboardViewModelToken, dashboardViewModelCreator);
- const diffViewModelCreator = () => new DiffViewModel();
- dependencies.set(diffViewModelToken, diffViewModelCreator);
- const documentationViewModelCreator = () => new DocumentationViewModel();
- dependencies.set(documentationViewModelToken, documentationViewModelCreator);
- const editViewModelCreator = () => new EditViewModel();
- dependencies.set(editViewModelToken, editViewModelCreator);
- const groupViewModelCreator = () => new GroupViewModel();
- dependencies.set(groupViewModelToken, groupViewModelCreator);
- const pluginViewModelCreator = () => new PluginViewModel();
- dependencies.set(pluginViewModelToken, pluginViewModelCreator);
- const repoViewModelCreator = () => new RepoViewModel();
- dependencies.set(repoViewModelToken, repoViewModelCreator);
- const searchViewModelCreator = () =>
- new SearchViewModel(appContext.restApiService, appContext.userModel, () =>
- resolver(navigationToken)
- );
- dependencies.set(searchViewModelToken, searchViewModelCreator);
- const settingsViewModelCreator = () => new SettingsViewModel();
- dependencies.set(settingsViewModelToken, settingsViewModelCreator);
-
- const routerCreator = () =>
- new GrRouter(
- appContext.reportingService,
- appContext.routerModel,
- appContext.restApiService,
- resolver(adminViewModelToken),
- resolver(agreementViewModelToken),
- resolver(changeViewModelToken),
- resolver(dashboardViewModelToken),
- resolver(diffViewModelToken),
- resolver(documentationViewModelToken),
- resolver(editViewModelToken),
- resolver(groupViewModelToken),
- resolver(pluginViewModelToken),
- resolver(repoViewModelToken),
- resolver(searchViewModelToken),
- resolver(settingsViewModelToken)
- );
- dependencies.set(routerToken, routerCreator);
+ const dependencies = createAppDependencies(appContext, resolver);
+ dependencies.set(storageServiceToken, () => grStorageMock);
dependencies.set(navigationToken, () => {
return {
setUrl: () => {},
replaceUrl: () => {},
finalize: () => {},
+ blockNavigation: () => {},
+ releaseNavigation: () => {},
};
});
-
- const changeModelCreator = () =>
- new ChangeModel(
- appContext.routerModel,
- appContext.restApiService,
- appContext.userModel
- );
- dependencies.set(changeModelToken, changeModelCreator);
-
- const accountsModelCreator = () =>
- new AccountsModel(appContext.restApiService);
- dependencies.set(accountsModelToken, accountsModelCreator);
-
- const commentsModelCreator = () =>
- new CommentsModel(
- appContext.routerModel,
- resolver(changeModelToken),
- resolver(accountsModelToken),
- appContext.restApiService,
- appContext.reportingService
- );
- dependencies.set(commentsModelToken, commentsModelCreator);
-
- const filesModelCreator = () =>
- new FilesModel(
- resolver(changeModelToken),
- resolver(commentsModelToken),
- appContext.restApiService
- );
- dependencies.set(filesModelToken, filesModelCreator);
-
- const configModelCreator = () =>
- new ConfigModel(resolver(changeModelToken), appContext.restApiService);
- dependencies.set(configModelToken, configModelCreator);
-
- const checksModelCreator = () =>
- new ChecksModel(
- appContext.routerModel,
- resolver(changeViewModelToken),
- resolver(changeModelToken),
- appContext.reportingService,
- appContext.pluginsModel
- );
-
- dependencies.set(checksModelToken, checksModelCreator);
-
- const shortcutServiceCreator = () =>
- new ShortcutsService(appContext.userModel, appContext.reportingService);
- dependencies.set(shortcutsServiceToken, shortcutServiceCreator);
-
+ dependencies.set(
+ highlightServiceToken,
+ () => new MockHighlightService(appContext.reportingService)
+ );
return dependencies;
}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index ab3064f7f0..d2cba9a404 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -66,57 +66,53 @@ import {
SubmitTypeInfo,
SuggestInfo,
Timestamp,
- TimezoneOffset,
UrlEncodedCommentId,
UserConfigInfo,
+ CommentThread,
+ DraftInfo,
+ ChangeMessage,
+ SavingState,
} from '../types/common';
import {
AccountsVisibility,
AccountTag,
- AppTheme,
AuthType,
ChangeStatus,
CommentSide,
- DateFormat,
- DefaultBase,
+ createDefaultPreferences,
DefaultDisplayNameConfig,
- DiffViewMode,
EmailStrategy,
InheritedBooleanInfoConfiguredValue,
MergeabilityComputationBehavior,
RequirementStatus,
RevisionKind,
SubmitType,
- TimeFormat,
} from '../constants/constants';
import {formatDate} from '../utils/date-util';
import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
import {CommitInfoWithRequiredCommit} from '../elements/change/gr-change-metadata/gr-change-metadata';
import {WebLinkInfo} from '../types/diff';
-import {
- ChangeMessage,
- CommentThread,
- createCommentThreads,
- DraftInfo,
- UnsavedInfo,
-} from '../utils/comment-util';
+import {createCommentThreads, createNew} from '../utils/comment-util';
import {GerritView} from '../services/router/router-model';
import {ChangeComments} from '../elements/diff/gr-comment-api/gr-comment-api';
import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
import {
DetailedLabelInfo,
- FileInfo,
QuickLabelInfo,
SubmitRequirementExpressionInfo,
SubmitRequirementResultInfo,
SubmitRequirementStatus,
} from '../api/rest-api';
-import {CheckResult, RunResult} from '../models/checks/checks-model';
+import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model';
import {Category, RunStatus} from '../api/checks';
import {DiffInfo} from '../api/diff';
import {SearchViewState} from '../models/views/search';
-import {ChangeViewState} from '../models/views/change';
-import {EditViewState} from '../models/views/edit';
+import {ChangeChildView, ChangeViewState} from '../models/views/change';
+import {NormalizedFileInfo} from '../models/change/files-model';
+import {GroupViewState} from '../models/views/group';
+import {RepoDetailView, RepoViewState} from '../models/views/repo';
+import {AdminChildView, AdminViewState} from '../models/views/admin';
+import {DashboardViewState} from '../models/views/dashboard';
const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN';
export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
@@ -136,9 +132,13 @@ export function dateToTimestamp(date: Date): Timestamp {
nanosecondSuffix) as Timestamp;
}
-export function createCommentLink(match = 'test'): CommentLinkInfo {
+export function createCommentLink(
+ match = 'test',
+ link = 'http://test.com'
+): CommentLinkInfo {
return {
match,
+ link,
};
}
@@ -243,7 +243,6 @@ export function createGitPerson(name = 'Test name'): GitPersonInfo {
name,
email: `${name}@` as EmailAddress,
date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
- tz: 0 as TimezoneOffset,
};
}
@@ -388,6 +387,7 @@ export function createChangeMessages(count: number): ChangeMessageInfo[] {
messages.push({
...createChangeMessageInfo((i + messageIdStart).toString(16)),
date: dateToTimestamp(messageDate),
+ author: createAccountDetailWithId(i),
});
messageDate = new Date(messageDate);
messageDate.setDate(messageDate.getDate() + 1);
@@ -395,14 +395,19 @@ export function createChangeMessages(count: number): ChangeMessageInfo[] {
return messages;
}
-export function createFileInfo(): FileInfo {
+export function createFileInfo(
+ path = 'test-path/test-file.txt'
+): NormalizedFileInfo {
return {
size: 314,
size_delta: 7,
+ lines_deleted: 0,
+ lines_inserted: 0,
+ __path: path,
};
}
-export function createChange(): ChangeInfo {
+export function createChange(partial: Partial<ChangeInfo> = {}): ChangeInfo {
return {
id: TEST_CHANGE_INFO_ID,
project: TEST_PROJECT_NAME,
@@ -418,6 +423,7 @@ export function createChange(): ChangeInfo {
owner: createAccountWithId(),
// This is documented as optional, but actually always set.
reviewers: createReviewers(),
+ ...partial,
};
}
@@ -559,8 +565,7 @@ export function createDiff(): DiffInfo {
content: [
{
ab: [
- 'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
- 'nulla phasellus.',
+ 'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula.',
'Mattis lectus.',
'Sodales duis.',
'Orci a faucibus.',
@@ -631,7 +636,7 @@ export function createDiff(): DiffInfo {
'Etiam dui, blandit wisi.',
'Mi nec.',
'Vitae eget vestibulum.',
- 'Ullamcorper nunc ante, nec imperdiet felis, consectetur in.',
+ 'Ullamcorper nunc ante, nec imperdiet felis, consectetur.',
'Ac eget.',
'Vel fringilla, interdum pellentesque placerat, proin ante.',
],
@@ -667,25 +672,19 @@ export function createBlame(): BlameInfo {
};
}
-export function createMergeable(): MergeableInfo {
+export function createMergeable(mergeable = false): MergeableInfo {
return {
submit_type: SubmitType.MERGE_IF_NECESSARY,
- mergeable: false,
+ mergeable,
};
}
-// TODO: Maybe reconcile with createDefaultPreferences() in constants.ts.
+// TODO: Do not change the values of createDefaultPreferences() here.
export function createPreferences(): PreferencesInfo {
return {
+ ...createDefaultPreferences(),
changes_per_page: 10,
- theme: AppTheme.AUTO,
- date_format: DateFormat.ISO,
- time_format: TimeFormat.HHMM_24,
- diff_view: DiffViewMode.SIDE_BY_SIDE,
- my: [],
- change_table: [],
email_strategy: EmailStrategy.ENABLED,
- default_base_for_merges: DefaultBase.AUTO_MERGE,
allow_browser_notifications: true,
};
}
@@ -697,8 +696,9 @@ export function createApproval(account?: AccountInfo): ApprovalInfo {
export function createChangeViewState(): ChangeViewState {
return {
view: GerritView.CHANGE,
+ childView: ChangeChildView.OVERVIEW,
changeNum: TEST_NUMERIC_CHANGE_ID,
- project: TEST_PROJECT_NAME,
+ repo: TEST_PROJECT_NAME,
};
}
@@ -712,13 +712,89 @@ export function createAppElementSearchViewParams(): SearchViewState {
};
}
-export function createEditViewState(): EditViewState {
+export function createEditViewState(): ChangeViewState {
return {
- view: GerritView.EDIT,
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.EDIT,
changeNum: TEST_NUMERIC_CHANGE_ID,
patchNum: EDIT,
- path: 'foo/bar.baz',
- project: TEST_PROJECT_NAME,
+ repo: TEST_PROJECT_NAME,
+ editView: {path: 'foo/bar.baz'},
+ };
+}
+
+export function createDiffViewState(): ChangeViewState {
+ return {
+ view: GerritView.CHANGE,
+ childView: ChangeChildView.DIFF,
+ changeNum: TEST_NUMERIC_CHANGE_ID,
+ repo: TEST_PROJECT_NAME,
+ };
+}
+
+export function createSearchViewState(): SearchViewState {
+ return {
+ view: GerritView.SEARCH,
+ query: '',
+ offset: '0',
+ loading: false,
+ };
+}
+
+export function createDashboardViewState(): DashboardViewState {
+ return {
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ };
+}
+
+export function createAdminReposViewState(): AdminViewState {
+ return {
+ view: GerritView.ADMIN,
+ adminView: AdminChildView.REPOS,
+ offset: '0',
+ filter: '',
+ openCreateModal: false,
+ };
+}
+
+export function createAdminPluginsViewState(): AdminViewState {
+ return {
+ view: GerritView.ADMIN,
+ adminView: AdminChildView.PLUGINS,
+ offset: '0',
+ filter: '',
+ };
+}
+
+export function createGroupViewState(): GroupViewState {
+ return {
+ view: GerritView.GROUP,
+ groupId: 'test-group-id' as GroupId,
+ };
+}
+
+export function createRepoViewState(): RepoViewState {
+ return {
+ view: GerritView.REPO,
+ };
+}
+
+export function createRepoBranchesViewState(): RepoViewState {
+ return {
+ view: GerritView.REPO,
+ detail: RepoDetailView.BRANCHES,
+ offset: '0',
+ filter: '',
+ };
+}
+
+export function createRepoTagsViewState(): RepoViewState {
+ return {
+ view: GerritView.REPO,
+ detail: RepoDetailView.TAGS,
+ offset: '0',
+ filter: '',
};
}
@@ -766,18 +842,16 @@ export function createComment(
export function createDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
return {
...createComment(),
- __draft: true,
+ savingState: SavingState.OK,
...extra,
};
}
-export function createUnsaved(extra: Partial<CommentInfo> = {}): UnsavedInfo {
+export function createNewDraft(extra: Partial<CommentInfo> = {}): DraftInfo {
return {
...createComment(),
- __unsaved: true,
- id: undefined,
- updated: undefined,
...extra,
+ ...createNew(),
};
}
@@ -789,7 +863,24 @@ export function createRobotComment(
robot_id: 'robot-id-123' as RobotId,
robot_run_id: 'robot-run-id-456' as RobotRunId,
properties: {},
- fix_suggestions: [],
+ fix_suggestions: [
+ {
+ fix_id: 'robot-run-id-456-fix' as FixId,
+ description: 'Robot suggestion',
+ replacements: [
+ {
+ path: 'abc.txt'!,
+ range: {
+ start_line: 0,
+ start_character: 0,
+ end_line: 1,
+ end_character: 10,
+ },
+ replacement: 'replacement',
+ },
+ ],
+ },
+ ],
...extra,
};
}
@@ -900,6 +991,9 @@ export function createChangeComments(): ChangeComments {
export function createThread(
...comments: Partial<CommentInfo | DraftInfo>[]
): CommentThread {
+ if (comments.length === 0) {
+ comments = [createComment()];
+ }
return {
comments: comments.map(c => createComment(c)),
rootId: 'test-root-id-comment-thread' as UrlEncodedCommentId,
@@ -1013,27 +1107,39 @@ export function createNonApplicableSubmitRequirementResultInfo(): SubmitRequirem
};
}
-export function createRunResult(): RunResult {
+export function createRun(partial: Partial<CheckRun> = {}): CheckRun {
return {
attemptDetails: [],
- category: Category.INFO,
checkName: 'test-name',
- internalResultId: 'test-internal-result-id',
internalRunId: 'test-internal-run-id',
isLatestAttempt: true,
isSingleAttempt: true,
pluginName: 'test-plugin-name',
status: RunStatus.COMPLETED,
+ ...partial,
+ };
+}
+
+export function createRunResult(): RunResult {
+ return {
+ category: Category.INFO,
+ checkName: 'test-name',
+ internalResultId: 'test-internal-result-id',
+ isLatestAttempt: true,
+ pluginName: 'test-plugin-name',
summary: 'This is the test summary.',
message: 'This is the test message.',
};
}
-export function createCheckResult(): CheckResult {
+export function createCheckResult(
+ partial: Partial<CheckResult> = {}
+): CheckResult {
return {
category: Category.ERROR,
summary: 'error',
internalResultId: 'test-internal-result-id',
+ ...partial,
};
}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index d6ad43451b..21150eb2c0 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -4,42 +4,23 @@
* SPDX-License-Identifier: Apache-2.0
*/
import '../types/globals';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
-import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
import {getAppContext} from '../services/app-context';
import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
import {SinonSpy, SinonStub} from 'sinon';
-import {StorageService} from '../services/storage/gr-storage';
-import {AuthService} from '../services/gr-auth/gr-auth';
import {ReportingService} from '../services/gr-reporting/gr-reporting';
-import {UserModel} from '../models/user/user-model';
import {queryAndAssert, query} from '../utils/common-util';
import {FlagsService} from '../services/flags/flags';
-import {Key, Modifier} from '../utils/dom-util';
+import {Key, Modifier, whenVisible} from '../utils/dom-util';
import {Observable} from 'rxjs';
import {filter, take, timeout} from 'rxjs/operators';
-import {HighlightService} from '../services/highlight/highlight-service';
import {assert} from '@open-wc/testing';
+import {Route, ViewState} from '../models/views/base';
+import {PageContext} from '../elements/core/gr-router/gr-page';
+import {waitUntil} from '../utils/async-util';
export {query, queryAll, queryAndAssert} from '../utils/common-util';
-
-export interface MockPromise<T> extends Promise<T> {
- resolve: (value?: T) => void;
- reject: (reason?: any) => void;
-}
-
-export function mockPromise<T = unknown>(): MockPromise<T> {
- let res: (value?: T) => void;
- let rej: (reason?: any) => void;
- const promise: MockPromise<T> = new Promise<T | undefined>(
- (resolve, reject) => {
- res = resolve;
- rej = reject;
- }
- ) as MockPromise<T>;
- promise.resolve = res!;
- promise.reject = rej!;
- return promise;
-}
+export {waitUntil} from '../utils/async-util';
+export {mockPromise} from '../utils/async-util';
+export type {MockPromise} from '../utils/async-util';
export function isHidden(el: Element | undefined | null) {
if (!el) return true;
@@ -51,14 +32,6 @@ export function isVisible(el: Element) {
return getComputedStyle(el).getPropertyValue('display') !== 'none';
}
-// Provide reset plugins function to clear installed plugins between tests.
-// No gr-app found (running tests)
-export const resetPlugins = () => {
- _testOnly_resetEndpoints();
- const pl = _testOnly_resetPluginLoader();
- pl.loadPlugins([]);
-};
-
export type CleanupCallback = () => void;
const cleanups: CleanupCallback[] = [];
@@ -109,28 +82,6 @@ export function spyRestApi<K extends keyof RestApiService>(method: K) {
return sinon.spy(getAppContext().restApiService, method);
}
-export function stubUsers<K extends keyof UserModel>(method: K) {
- return sinon.stub(getAppContext().userModel, method);
-}
-
-export function stubHighlightService<K extends keyof HighlightService>(
- method: K
-) {
- return sinon.stub(getAppContext().highlightService, method);
-}
-
-export function stubStorage<K extends keyof StorageService>(method: K) {
- return sinon.stub(getAppContext().storageService, method);
-}
-
-export function spyStorage<K extends keyof StorageService>(method: K) {
- return sinon.spy(getAppContext().storageService, method);
-}
-
-export function stubAuth<K extends keyof AuthService>(method: K) {
- return sinon.stub(getAppContext().authService, method);
-}
-
export function stubReporting<K extends keyof ReportingService>(method: K) {
return sinon.stub(getAppContext().reportingService, method);
}
@@ -158,24 +109,6 @@ export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
ReturnType<F>
>;
-/**
- * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
- * otherwise the backdrop stays around in the DOM for too long waiting for
- * an animation to finish.
- */
-export function addIronOverlayBackdropStyleEl() {
- const el = document.createElement('style');
- el.setAttribute('id', 'backdrop-style');
- document.head.appendChild(el);
- el.sheet!.insertRule('body { --iron-overlay-backdrop-opacity: 0; }');
-}
-
-export function removeIronOverlayBackdropStyleEl() {
- const el = document.getElementById('backdrop-style');
- if (!el?.parentNode) throw new Error('Backdrop style element not found.');
- el.parentNode?.removeChild(el);
-}
-
export function removeThemeStyles() {
// Do not remove the light theme, because it is only added once statically,
// not once per gr-app instantiation.
@@ -183,6 +116,30 @@ export function removeThemeStyles() {
document.head.querySelector('#dark-theme')?.remove();
}
+function getActiveElement() {
+ return document.activeElement;
+}
+
+export function isFocusInsideElement(element: Element) {
+ // In Polymer 2 focused element either <paper-input> or nested
+ // native input <input> element depending on the current focus
+ // in browser window.
+ // For example, the focus is changed if the developer console
+ // get a focus.
+ let activeElement = getActiveElement();
+ while (activeElement) {
+ if (activeElement === element) {
+ return true;
+ }
+ if (activeElement.parentElement) {
+ activeElement = activeElement.parentElement;
+ } else {
+ activeElement = (activeElement.getRootNode() as ShadowRoot).host;
+ }
+ }
+ return false;
+}
+
export async function waitQueryAndAssert<E extends Element = Element>(
el: Element | null | undefined,
selector: string
@@ -194,29 +151,9 @@ export async function waitQueryAndAssert<E extends Element = Element>(
return queryAndAssert<E>(el, selector);
}
-export async function waitUntil(
- predicate: (() => boolean) | (() => Promise<boolean>),
- message = 'The waitUntil() predicate is still false after 1000 ms.',
- timeout_ms = 1000
-): Promise<void> {
- const start = Date.now();
- let sleep = 0;
- if (await predicate()) return Promise.resolve();
- const error = new Error(message);
- return new Promise((resolve, reject) => {
- const waiter = async () => {
- if (await predicate()) {
- resolve();
- return;
- }
- if (Date.now() - start >= timeout_ms) {
- reject(error);
- return;
- }
- setTimeout(waiter, sleep);
- sleep = sleep === 0 ? 1 : sleep * 4;
- };
- waiter();
+export async function waitUntilVisible(element: Element): Promise<void> {
+ return new Promise(resolve => {
+ whenVisible(element, () => resolve());
});
}
@@ -324,12 +261,12 @@ export function mouseDown(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('mousedown', eventOptions));
}
-export function assertFails(promise: Promise<unknown>, error?: unknown) {
+export function assertFails<T = unknown>(promise: Promise<unknown>, error?: T) {
return promise
.then((_v: unknown) => {
assert.fail('Promise resolved but should have failed');
})
- .catch((e: unknown) => {
+ .catch((e: T) => {
if (error) {
assert.equal(e, error);
}
@@ -352,3 +289,26 @@ export function logProxy<T extends object>(obj: T, name?: string): T {
};
return new Proxy(obj, handler) as unknown as T;
}
+
+export function assertRouteState<T extends ViewState>(
+ route: Route<T>,
+ path: string,
+ state: T,
+ createUrl: (state: T) => string
+) {
+ const {urlPattern, createState} = route;
+ const ctx = new PageContext(path);
+ const matches = ctx.match(urlPattern);
+ assert.isTrue(matches);
+ assert.deepEqual(createState(ctx), state);
+ assert.equal(path, createUrl(state));
+}
+
+export function assertRouteFalse<T extends ViewState>(
+ route: Route<T>,
+ path: string
+) {
+ const ctx = new PageContext(path);
+ const matches = ctx.match(route.urlPattern);
+ assert.isFalse(matches);
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 0ddf130a00..98aaf0f875 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -47,7 +47,7 @@
"lib": [
"dom",
"dom.iterable",
- "es2020",
+ "es2021",
"webworker"
],
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 008a8de1c8..b148780eb3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -3,10 +3,9 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {CommentRange} from '../api/core';
import {
ChangeStatus,
- ProjectState,
+ RepoState,
SubmitType,
InheritedBooleanInfoConfiguredValue,
PermissionAction,
@@ -23,7 +22,6 @@ import {
EmailFormat,
MergeStrategy,
} from '../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {
AccountId,
AccountDetailInfo,
@@ -107,7 +105,6 @@ import {
SubmitTypeInfo,
SuggestInfo,
Timestamp,
- TimezoneOffset,
TopicName,
UrlEncodedRepoName,
UserConfigInfo,
@@ -116,8 +113,10 @@ import {
isDetailedLabelInfo,
isQuickLabelInfo,
Base64FileContent,
+ CommentRange,
} from '../api/rest-api';
import {DiffInfo, IgnoreWhitespaceType} from './diff';
+import {LineNumber} from '../api/diff';
export type {
AccountId,
@@ -200,7 +199,6 @@ export type {
SubmitTypeInfo,
SuggestInfo,
Timestamp,
- TimezoneOffset,
TopicName,
UrlEncodedRepoName,
UserConfigInfo,
@@ -217,11 +215,6 @@ export type RequireProperties<T, K extends keyof T> = Omit<T, K> &
export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
-export type ElementPropertyDeepChange<
- T,
- K extends keyof T
-> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
-
/**
* Type alias for parsed json object to make code cleaner
*/
@@ -364,6 +357,16 @@ export interface GroupInput {
members?: string[];
}
+export interface DropdownLink {
+ url?: string;
+ name?: string;
+ external?: boolean;
+ target?: string | null;
+ download?: boolean;
+ id?: string;
+ tooltip?: string;
+}
+
/**
* New options for a group.
* https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
@@ -679,6 +682,160 @@ export interface TopMenuItemInfo {
id?: string;
}
+export enum ChangeStates {
+ ABANDONED = 'Abandoned',
+ ACTIVE = 'Active',
+ MERGE_CONFLICT = 'Merge Conflict',
+ GIT_CONFLICT = 'Git Conflict',
+ MERGED = 'Merged',
+ PRIVATE = 'Private',
+ READY_TO_SUBMIT = 'Ready to submit',
+ REVERT_CREATED = 'Revert Created',
+ REVERT_SUBMITTED = 'Revert Submitted',
+ WIP = 'WIP',
+}
+
+export enum SavingState {
+ /**
+ * Currently not saving. Not yet saved or last saving attempt successful.
+ */
+ // Possible prior states: SAVING
+ // Possible subsequent states: SAVING
+ OK = 'OK',
+ /**
+ * Currently saving to the backend.
+ */
+ // Possible prior states: OK, ERROR
+ // Possible subsequent states: OK, ERROR
+ SAVING = 'SAVING',
+ /**
+ * Latest saving attempt failed with an error.
+ */
+ // Possible prior states: SAVING
+ // Possible subsequent states: SAVING
+ ERROR = 'ERROR',
+}
+
+export type DraftInfo = Omit<CommentInfo, 'id' | 'updated'> & {
+ // Must be set for all drafts.
+ // Drafts received from the backend will be modified immediately with
+ // `state: OK` before allowing them to get into the model.
+ savingState: SavingState;
+ // Must be set for new drafts created in this session.
+ // Use the id() utility function for uniquely identifying drafts.
+ client_id?: UrlEncodedCommentId;
+ // Must be set for drafts known to the backend.
+ // Use the id() utility function for uniquely identifying drafts.
+ id?: UrlEncodedCommentId;
+ // Set, iff `id` is set. Reflects the time when the draft was last saved to
+ // the backend.
+ updated?: Timestamp;
+};
+
+export interface NewDraftInfo extends DraftInfo {
+ client_id: UrlEncodedCommentId;
+ id: undefined;
+ updated: undefined;
+}
+
+/**
+ * This is what human, robot and draft comments can agree upon.
+ *
+ * Note that `id` and `updated` must be considered optional, because we might
+ * be dealing with unsaved draft comments.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+// TODO: Replace the CommentMap type with just an array of paths.
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends Comment>(
+ x: T | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+ return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends Comment>(
+ x: T | DraftInfo | undefined
+): x is DraftInfo {
+ return !!x && (x as DraftInfo).savingState !== undefined;
+}
+
+export function isSaving<T extends Comment>(
+ x: T | DraftInfo | undefined
+): boolean {
+ return !!x && (x as DraftInfo).savingState === SavingState.SAVING;
+}
+
+export function isError<T extends Comment>(
+ x: T | DraftInfo | undefined
+): boolean {
+ return !!x && (x as DraftInfo).savingState === SavingState.ERROR;
+}
+
+/**
+ * A new draft comment is a comment that was created by the user in this session
+ * and has not yet been saved to the backend. Such a comment must have a
+ * `client_id`, but it must not have an `id`.
+ */
+export function isNew<T extends Comment>(
+ x: T | DraftInfo | undefined
+): boolean {
+ return !!x && !!(x as DraftInfo).client_id && !(x as DraftInfo).id;
+}
+
+export interface CommentThread {
+ /**
+ * This can only contain at most one draft. And if so, then it is the last
+ * comment in this list. This must not contain unsaved drafts.
+ */
+ comments: Array<Comment>;
+ /**
+ * Identical to the id of the first comment. If this is undefined, then the
+ * thread only contains an unsaved draft.
+ */
+ rootId?: UrlEncodedCommentId;
+ /**
+ * Note that all location information is typically identical to that of the
+ * first comment, but not for ported comments!
+ */
+ path: string;
+ commentSide: CommentSide;
+ /* mergeParentNum is the merge parent number only valid for merge commits
+ when commentSide is PARENT.
+ mergeParentNum is undefined for auto merge commits
+ Same as `parent` in CommentInfo.
+ */
+ mergeParentNum?: number;
+ patchNum?: RevisionPatchSetNum;
+ /* Different from CommentInfo, which just keeps the line undefined for
+ FILE comments. */
+ line?: LineNumber;
+ range?: CommentRange;
+ /**
+ * Was the thread ported over from its original location to a newer patchset?
+ * If yes, then the location information above contains the ported location,
+ * but the comments still have the original location set.
+ */
+ ported?: boolean;
+ /**
+ * Only relevant when ported:true. Means that no ported range could be
+ * computed. `line` and `range` can be undefined then.
+ */
+ rangeInfoLost?: boolean;
+}
+
+export type CommentIdToCommentThreadMap = {
+ [urlEncodedCommentId: string]: CommentThread;
+};
+
+export interface ChangeMessage extends ChangeMessageInfo {
+ // TODO(TS): maybe should be an enum instead
+ type: string;
+ expanded: boolean;
+ commentThreads: CommentThread[];
+}
+
/**
* The CommentInfo entity contains information about an inline comment.
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
@@ -704,8 +861,6 @@ export interface CommentInfo {
source_content_type?: string;
}
-export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
-
/**
* The ContextLine entity contains the line number and line text of a single line of the source file content..
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#context-line
@@ -750,8 +905,6 @@ export interface ImageInfo {
type: string;
_name?: string;
_expectedType?: string;
- _width?: number;
- _height?: number;
}
/**
@@ -769,13 +922,13 @@ export interface ProjectAccessInfo {
can_add?: boolean;
can_add_tags?: boolean;
config_visible?: boolean;
- groups: ProjectAccessGroups;
+ groups: RepoAccessGroups;
config_web_links: WebLinkInfo[];
}
-export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
+export type RepoAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
export type LocalAccessSectionInfo = {[ref: string]: AccessSectionInfo};
-export type ProjectAccessGroups = {[uuid: string]: GroupInfo};
+export type RepoAccessGroups = {[uuid: string]: GroupInfo};
/**
* The AccessSectionInfo describes the access rights that are assigned on a ref.
@@ -858,7 +1011,7 @@ export interface ConfigInput {
reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
max_object_size_limit?: MaxObjectSizeLimitInfo;
submit_type?: SubmitType;
- state?: ProjectState;
+ state?: RepoState;
plugin_config_values?: PluginNameToPluginParametersMap;
commentlinks?: ConfigInfoCommentLinks;
}
@@ -907,13 +1060,13 @@ export interface BranchInfo {
* https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-access-input
*/
export interface ProjectAccessInput {
- remove?: RefToProjectAccessInfoMap;
- add?: RefToProjectAccessInfoMap;
+ remove?: RefToRepoAccessInfoMap;
+ add?: RefToRepoAccessInfoMap;
message?: string;
parent?: string;
}
-export type RefToProjectAccessInfoMap = {[refName: string]: ProjectAccessInfo};
+export type RefToRepoAccessInfoMap = {[refName: string]: ProjectAccessInfo};
/**
* Represent a file in a base64 encoding
@@ -1205,18 +1358,6 @@ export type RecipientTypeToNotifyInfoMap = {
export type RobotCommentInput = RobotCommentInfo;
/**
- * This is what human, robot and draft comments can agree upon.
- *
- * Human, robot and saved draft comments all have a required id, but unsaved
- * drafts do not. That is why the id is omitted from CommentInfo, such that it
- * can be optional in Draft, but required in CommentInfo and RobotCommentInfo.
- */
-export interface CommentBasics extends Omit<CommentInfo, 'id' | 'updated'> {
- id?: UrlEncodedCommentId;
- updated?: Timestamp;
-}
-
-/**
* The RobotCommentInfo entity contains information about a robot inline comment
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-info
*/
@@ -1513,3 +1654,8 @@ export interface MergeableInfo {
conflicts?: string[];
mergeable_into?: string[];
}
+
+export interface ChangeActionDialog extends HTMLElement {
+ resetFocus?(): void;
+ init?(): void;
+}
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index c03a167b80..2a8c7e546d 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -44,6 +44,8 @@ export interface DiffInfo extends DiffInfoApi {
/**
* Links to the file diff in external sites as a list of DiffWebLinkInfo
* entries.
+ *
+ * NOTE: Unused as of Feb 2023.
*/
web_links?: DiffWebLinkInfo[];
@@ -58,18 +60,15 @@ export interface DiffInfo extends DiffInfoApi {
* The DiffWebLinkInfo entity describes a link on a diff screen to an external
* site.
* https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ *
+ * NOTE: Unused as of Feb 2023.
*/
export declare interface DiffWebLinkInfo {
- /** The link name. */
name: string;
- /** The link URL. */
url: string;
- /** URL to the icon of the link. */
image_url: string;
- // TODO: Are these really of type string? Not able to trigger them, but the
- // docs sound more like boolean.
- show_on_side_by_side_diff_view: string;
- show_on_unified_diff_view: string;
+ show_on_side_by_side_diff_view: boolean;
+ show_on_unified_diff_view: boolean;
}
export interface DiffFileMetaInfo extends DiffFileMetaInfoApi {
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
index 08c5ef4146..922d779962 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -3,80 +3,62 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {FixSuggestionInfo, PatchSetNum} from './common';
-import {ChangeMessage} from '../utils/comment-util';
+import {
+ AccountInfo,
+ ChangeMessage,
+ DropdownLink,
+ FixSuggestionInfo,
+ PatchSetNum,
+} from './common';
import {FetchRequest} from './types';
import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
import {Category, RunStatus} from '../api/checks';
-export enum EventType {
- BIND_VALUE_CHANGED = 'bind-value-changed',
- CHANGE = 'change',
- CHANGED = 'changed',
- CHANGE_MESSAGE_DELETED = 'change-message-deleted',
- COMMIT = 'commit',
- DIALOG_CHANGE = 'dialog-change',
- DROP = 'drop',
- EDITABLE_CONTENT_SAVE = 'editable-content-save',
- GR_RPC_LOG = 'gr-rpc-log',
- IRON_ANNOUNCE = 'iron-announce',
- KEYDOWN = 'keydown',
- KEYPRESS = 'keypress',
- LOCATION_CHANGE = 'location-change',
- MOVED_LINK_CLICKED = 'moved-link-clicked',
- NETWORK_ERROR = 'network-error',
- OPEN_FIX_PREVIEW = 'open-fix-preview',
- CLOSE_FIX_PREVIEW = 'close-fix-preview',
- PAGE_ERROR = 'page-error',
- RECREATE_CHANGE_VIEW = 'recreate-change-view',
- RECREATE_DIFF_VIEW = 'recreate-diff-view',
- RELOAD = 'reload',
- REPLY = 'reply',
- SERVER_ERROR = 'server-error',
- SHORTCUT_TRIGGERERD = 'shortcut-triggered',
- SHOW_ALERT = 'show-alert',
- SHOW_ERROR = 'show-error',
- SHOW_TAB = 'show-tab',
- SHOW_SECONDARY_TAB = 'show-secondary-tab',
- TAP_ITEM = 'tap-item',
- TITLE_CHANGE = 'title-change',
-}
-
+// TODO: Local events that are only fired by one component should also be
+// declared and documented in that component. Don't collect ALL the events here.
+// 'show-alert' for example is fine to keep, because it is fired all over the
+// place. But 'line-cursor-moved-in' is only fired by <gr-diff-cursor>, so let's
+// move it there.
declare global {
interface HTMLElementEventMap {
- /* prettier-ignore */
+ 'add-reviewer': AddReviewerEvent;
'bind-value-changed': BindValueChangeEvent;
- /* prettier-ignore */
+ /** Fired when a 'cancel' button in a dialog was pressed. */
+ // prettier-ignore
+ 'cancel': CustomEvent<{}>;
+ // prettier-ignore
'change': ChangeEvent;
- /* prettier-ignore */
+ // prettier-ignore
'changed': ChangedEvent;
- 'change-message-deleted': ChangeMessageDeletedEvent;
- /* prettier-ignore */
- 'commit': CommitEvent;
+ // prettier-ignore
+ 'close': CustomEvent<{}>;
+ // prettier-ignore
+ 'commit': AutocompleteCommitEvent;
+ /** Fired when a 'confirm' button in a dialog was pressed. */
+ // prettier-ignore
+ 'confirm': CustomEvent<{}>;
'dialog-change': DialogChangeEvent;
- /* prettier-ignore */
+ // prettier-ignore
'drop': DropEvent;
- 'editable-content-save': EditableContentSaveEvent;
+ 'hide-alert': CustomEvent<{}>;
'location-change': LocationChangeEvent;
'iron-announce': IronAnnounceEvent;
+ 'iron-resize': CustomEvent<{}>;
'line-mouse-enter': LineNumberEvent;
'line-mouse-leave': LineNumberEvent;
'line-cursor-moved-in': LineNumberEvent;
'line-cursor-moved-out': LineNumberEvent;
'moved-link-clicked': MovedLinkClickedEvent;
'open-fix-preview': OpenFixPreviewEvent;
- 'close-fix-preview': CloseFixPreviewEvent;
'reply-to-comment': ReplyToCommentEvent;
- /* prettier-ignore */
- 'reload': ReloadEvent;
- /* prettier-ignore */
- 'reply': ReplyEvent;
+ // prettier-ignore
+ 'reload': CustomEvent<{}>;
+ 'remove-reviewer': RemoveReviewerEvent;
'show-alert': ShowAlertEvent;
'show-error': ShowErrorEvent;
'show-tab': SwitchTabEvent;
'show-secondary-tab': SwitchTabEvent;
'tap-item': TapItemEvent;
- 'title-change': TitleChangeEvent;
}
}
@@ -85,15 +67,38 @@ declare global {
'gr-rpc-log': RpcLogEvent;
'network-error': NetworkErrorEvent;
'page-error': PageErrorEvent;
- /* prettier-ignore */
- 'reload': ReloadEvent;
+ // prettier-ignore
+ 'reload': CustomEvent<{}>;
'server-error': ServerErrorEvent;
'show-alert': ShowAlertEvent;
'show-error': ShowErrorEvent;
- 'location-change': LocationChangeEvent;
+ 'auth-error': AuthErrorEvent;
+ 'title-change': TitleChangeEvent;
}
}
+export interface AutocompleteCommitEventDetail {
+ value: string;
+}
+
+export type AutocompleteCommitEvent =
+ CustomEvent<AutocompleteCommitEventDetail>;
+
+export interface AddAccountEventDetail {
+ value: string;
+}
+export type AddAccountEvent = CustomEvent<AddAccountEventDetail>;
+
+export interface AddReviewerEventDetail {
+ reviewer: AccountInfo;
+}
+export type AddReviewerEvent = CustomEvent<AddReviewerEventDetail>;
+
+export interface RemoveReviewerEventDetail {
+ reviewer: AccountInfo;
+}
+export type RemoveReviewerEvent = CustomEvent<RemoveReviewerEventDetail>;
+
export interface BindValueChangeEventDetail {
value: string | undefined;
}
@@ -101,7 +106,8 @@ export type BindValueChangeEvent = CustomEvent<BindValueChangeEventDetail>;
export type ChangeEvent = InputEvent;
-export type ChangedEvent = CustomEvent<string>;
+// TODO: This event seems to be unused (no listener). Remove?
+export type ChangedEvent = CustomEvent<string | undefined>;
export interface ChangeMessageDeletedEventDetail {
message: ChangeMessage;
@@ -109,8 +115,6 @@ export interface ChangeMessageDeletedEventDetail {
export type ChangeMessageDeletedEvent =
CustomEvent<ChangeMessageDeletedEventDetail>;
-export type CommitEvent = CustomEvent;
-
// TODO(milutin) - remove once new gr-dialog will do it out of the box
// This informs gr-app-element to remove footer, header from a11y tree
export interface DialogChangeEventDetail {
@@ -127,6 +131,13 @@ export interface EditableContentSaveEventDetail {
export type EditableContentSaveEvent =
CustomEvent<EditableContentSaveEventDetail>;
+export interface FileActionTapEventDetail {
+ path: string;
+ action: string;
+}
+
+export type FileActionTapEvent = CustomEvent<FileActionTapEventDetail>;
+
export interface RpcLogEventDetail {
status: number | null;
method: string;
@@ -158,18 +169,16 @@ export type NetworkErrorEvent = CustomEvent<NetworkErrorEventDetail>;
export interface OpenFixPreviewEventDetail {
patchNum: PatchSetNum;
fixSuggestions: FixSuggestionInfo[];
+ onCloseFixPreviewCallbacks: ((fixapplied: boolean) => void)[];
}
export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
-export interface CloseFixPreviewEventDetail {
- fixApplied: boolean;
-}
-export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
export interface ReplyToCommentEventDetail {
content: string;
userWantsToEdit: boolean;
unresolved: boolean;
}
+
export type ReplyToCommentEvent = CustomEvent<ReplyToCommentEventDetail>;
export interface PageErrorEventDetail {
@@ -177,15 +186,10 @@ export interface PageErrorEventDetail {
}
export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
-export interface ReloadEventDetail {
- clearPatchset: boolean;
-}
-export type ReloadEvent = CustomEvent<ReloadEventDetail>;
-
-export interface ReplyEventDetail {
- message: ChangeMessage;
+export interface RemoveAccountEventDetail {
+ account: AccountInfo;
}
-export type ReplyEvent = CustomEvent<ReplyEventDetail>;
+export type RemoveAccountEvent = CustomEvent<RemoveAccountEventDetail>;
export interface ServerErrorEventDetail {
request?: FetchRequest;
@@ -207,6 +211,20 @@ export interface ShowErrorEventDetail {
}
export type ShowErrorEvent = CustomEvent<ShowErrorEventDetail>;
+export interface ShowReplyDialogEventDetail {
+ value: {
+ reviewersOnly: boolean;
+ ccsOnly: boolean;
+ };
+}
+export type ShowReplyDialogEvent = CustomEvent<ShowReplyDialogEventDetail>;
+
+export interface AuthErrorEventDetail {
+ message: string;
+ action: string;
+}
+export type AuthErrorEvent = CustomEvent<AuthErrorEventDetail>;
+
// Type for the custom event to switch tab.
export interface SwitchTabEventDetail {
// name of the tab to set as active, from custom event
@@ -232,7 +250,7 @@ export interface ChecksTabState {
}
export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
-export type TapItemEvent = CustomEvent;
+export type TapItemEvent = CustomEvent<DropdownLink>;
export interface TitleChangeEventDetail {
title: string;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 04052e276e..65178363d8 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -5,12 +5,10 @@
*/
import {DiffLayer as DiffLayerApi} from '../api/diff';
import {MessageTag, Side} from '../constants/constants';
-import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
import {
AccountInfo,
BasePatchSetNum,
ChangeViewChangeInfo,
- CommitId,
CommitInfo,
EditPatchSet,
PatchSetNum,
@@ -18,21 +16,11 @@ import {
RevisionInfo,
Timestamp,
} from './common';
-import {AuthRequestInit} from '../services/gr-auth/gr-auth';
-export function notUndefined<T>(x: T): x is NonNullable<T> {
+export function isDefined<T>(x: T): x is NonNullable<T> {
return x !== undefined && x !== null;
}
-export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
- requestAvailability(): void;
-}
-
-export interface CommitRange {
- baseCommit: CommitId;
- commit: CommitId;
-}
-
export type {CoverageRange} from '../api/diff';
export {CoverageType} from '../api/diff';
@@ -42,6 +30,13 @@ export enum ErrorType {
GENERIC = 'GENERIC',
}
+export interface AuthRequestInit extends RequestInit {
+ // RequestInit define headers as HeadersInit, i.e.
+ // Headers | string[][] | Record<string, string>
+ // Auth class supports only Headers in options
+ headers?: Headers;
+}
+
/*
export interface OwnerRoot {
host?: HTMLElement;
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index 7681b1042d..6160cdc63c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -13,7 +13,6 @@ import {
isAccount,
isDetailedLabelInfo,
isGroup,
- NumericChangeId,
ReviewerInput,
ServerInfo,
UserId,
@@ -22,13 +21,11 @@ import {
} from '../types/common';
import {AccountTag, ReviewerState} from '../constants/constants';
import {assertNever, hasOwnProperty} from './common-util';
-import {getAccountDisplayName, getDisplayName} from './display-name-util';
+import {getDisplayName} from './display-name-util';
import {getApprovalInfo} from './label-util';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
import {ParsedChangeInfo} from '../types/types';
export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
-const SUGGESTIONS_LIMIT = 15;
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
export const MENTIONS_REGEX =
/(?:^|\s)@([a-zA-Z0-9.!#$%&'*+=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)(?=\s+|$)/g;
@@ -123,6 +120,17 @@ export function uniqueDefinedAvatar(
);
}
+export function uniqueAccountId(
+ account: AccountInfo,
+ index: number,
+ accountArray: AccountInfo[]
+) {
+ return (
+ index ===
+ accountArray.findIndex(other => account._account_id === other._account_id)
+ );
+}
+
export function isDetailedAccount(account?: AccountInfo) {
// In case ChangeInfo is requested without DetailedAccount option, the
// reviewer entry is returned as just {_account_id: 123}
@@ -216,28 +224,6 @@ export function computeVoteableText(change: ChangeInfo, reviewer: AccountInfo) {
return maxScores.join(', ');
}
-export function getAccountSuggestions(
- input: string,
- restApiService: RestApiService,
- config?: ServerInfo,
- canSee?: NumericChangeId,
- filterActive = false
-) {
- return restApiService
- .getSuggestedAccounts(input, SUGGESTIONS_LIMIT, canSee, filterActive)
- .then(accounts => {
- if (!accounts) return [];
- const accountSuggestions = [];
- for (const account of accounts) {
- accountSuggestions.push({
- name: getAccountDisplayName(config, account),
- value: account._account_id?.toString(),
- });
- }
- return accountSuggestions;
- });
-}
-
/**
* Extracts mentioned users from a given text.
* A user can be mentioned by triggering the mentions dropdown in a comment
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
deleted file mode 100644
index c8fc9fbc88..0000000000
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * @license
- * Copyright 2018 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {
- RepoName,
- GroupId,
- AccountDetailInfo,
- AccountCapabilityInfo,
-} from '../types/common';
-import {hasOwnProperty} from './common-util';
-import {GerritView} from '../services/router/router-model';
-import {MenuLink} from '../api/admin';
-import {AdminChildView} from '../models/views/admin';
-import {createGroupUrl, GroupDetailView} from '../models/views/group';
-import {createRepoUrl, RepoDetailView} from '../models/views/repo';
-
-const ADMIN_LINKS: NavLink[] = [
- {
- name: 'Repositories',
- noBaseUrl: true,
- url: '/admin/repos',
- view: 'gr-repo-list' as GerritView,
- viewableToAll: true,
- },
- {
- name: 'Groups',
- section: 'Groups',
- noBaseUrl: true,
- url: '/admin/groups',
- view: 'gr-admin-group-list' as GerritView,
- },
- {
- name: 'Plugins',
- capability: 'viewPlugins',
- section: 'Plugins',
- noBaseUrl: true,
- url: '/admin/plugins',
- view: 'gr-plugin-list' as GerritView,
- },
-];
-
-export interface AdminLink {
- url: string;
- text: string;
- capability: string | null;
- noBaseUrl: boolean;
- view: null;
- viewableToAll: boolean;
- target: '_blank' | null;
-}
-
-export interface AdminLinks {
- links: NavLink[];
- expandedSection?: SubsectionInterface;
-}
-
-export function getAdminLinks(
- account: AccountDetailInfo | undefined,
- getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
- getAdminMenuLinks: () => MenuLink[],
- options?: AdminNavLinksOption
-): Promise<AdminLinks> {
- if (!account) {
- return Promise.resolve(
- _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
- );
- }
- return getAccountCapabilities().then(capabilities =>
- _filterLinks(
- link => !link.capability || hasOwnProperty(capabilities, link.capability),
- getAdminMenuLinks,
- options
- )
- );
-}
-
-function _filterLinks(
- filterFn: (link: NavLink) => boolean,
- getAdminMenuLinks: () => MenuLink[],
- options?: AdminNavLinksOption
-): AdminLinks {
- let links: NavLink[] = ADMIN_LINKS.slice(0);
- let expandedSection: SubsectionInterface | undefined = undefined;
-
- const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
-
- // Append top-level links that are defined by plugins.
- links.push(
- ...getAdminMenuLinks().map((link: MenuLink) => {
- return {
- url: link.url,
- name: link.text,
- capability: link.capability || undefined,
- noBaseUrl: !isExternalLink(link),
- view: undefined,
- viewableToAll: !link.capability,
- target: isExternalLink(link) ? '_blank' : null,
- };
- })
- );
-
- links = links.filter(filterFn);
-
- const filteredLinks: NavLink[] = [];
- const repoName = options && options.repoName;
- const groupId = options && options.groupId;
- const groupName = options && options.groupName;
- const groupIsInternal = options && options.groupIsInternal;
- const isAdmin = options && options.isAdmin;
- const groupOwner = options && options.groupOwner;
-
- // Don't bother to get sub-navigation items if only the top level links
- // are needed. This is used by the main header dropdown.
- if (!repoName && !groupId) {
- return {links, expandedSection};
- }
-
- // Otherwise determine the full set of links and return both the full
- // set in addition to the subsection that should be displayed if it
- // exists.
- for (const link of links) {
- const linkCopy = {...link};
- if (linkCopy.name === 'Repositories' && repoName) {
- linkCopy.subsection = getRepoSubsections(repoName);
- expandedSection = linkCopy.subsection;
- } else if (linkCopy.name === 'Groups' && groupId && groupName) {
- linkCopy.subsection = getGroupSubsections(
- groupId,
- groupName,
- groupIsInternal,
- isAdmin,
- groupOwner
- );
- expandedSection = linkCopy.subsection;
- }
- filteredLinks.push(linkCopy);
- }
- return {links: filteredLinks, expandedSection};
-}
-
-export function getGroupSubsections(
- groupId: GroupId,
- groupName: string,
- groupIsInternal?: boolean,
- isAdmin?: boolean,
- groupOwner?: boolean
-) {
- const children: SubsectionInterface[] = [];
- const subsection: SubsectionInterface = {
- name: groupName,
- view: GerritView.GROUP,
- url: createGroupUrl({groupId}),
- children,
- };
- if (groupIsInternal) {
- children.push({
- name: 'Members',
- detailType: GroupDetailView.MEMBERS,
- view: GerritView.GROUP,
- url: createGroupUrl({groupId, detail: GroupDetailView.MEMBERS}),
- });
- }
- if (groupIsInternal && (isAdmin || groupOwner)) {
- children.push({
- name: 'Audit Log',
- detailType: GroupDetailView.LOG,
- view: GerritView.GROUP,
- url: createGroupUrl({groupId, detail: GroupDetailView.LOG}),
- });
- }
- return subsection;
-}
-
-export function getRepoSubsections(repo: RepoName) {
- return {
- name: repo,
- view: GerritView.REPO,
- children: [
- {
- name: 'General',
- view: GerritView.REPO,
- detailType: RepoDetailView.GENERAL,
- url: createRepoUrl({repo, detail: RepoDetailView.GENERAL}),
- },
- {
- name: 'Access',
- view: GerritView.REPO,
- detailType: RepoDetailView.ACCESS,
- url: createRepoUrl({repo, detail: RepoDetailView.ACCESS}),
- },
- {
- name: 'Commands',
- view: GerritView.REPO,
- detailType: RepoDetailView.COMMANDS,
- url: createRepoUrl({repo, detail: RepoDetailView.COMMANDS}),
- },
- {
- name: 'Branches',
- view: GerritView.REPO,
- detailType: RepoDetailView.BRANCHES,
- url: createRepoUrl({repo, detail: RepoDetailView.BRANCHES}),
- },
- {
- name: 'Tags',
- view: GerritView.REPO,
- detailType: RepoDetailView.TAGS,
- url: createRepoUrl({repo, detail: RepoDetailView.TAGS}),
- },
- {
- name: 'Dashboards',
- view: GerritView.REPO,
- detailType: RepoDetailView.DASHBOARDS,
- url: createRepoUrl({repo, detail: RepoDetailView.DASHBOARDS}),
- },
- ],
- };
-}
-
-export interface SubsectionInterface {
- name: string;
- view: GerritView;
- detailType?: RepoDetailView | GroupDetailView;
- url?: string;
- children?: SubsectionInterface[];
-}
-
-export interface AdminNavLinksOption {
- repoName?: RepoName;
- groupId?: GroupId;
- groupName?: string;
- groupIsInternal?: boolean;
- isAdmin?: boolean;
- groupOwner?: boolean;
-}
-
-export interface NavLink {
- name: string;
- noBaseUrl: boolean;
- url: string;
- view?: GerritView | AdminChildView;
- viewableToAll?: boolean;
- section?: string;
- capability?: string;
- target?: string | null;
- subsection?: SubsectionInterface;
- children?: SubsectionInterface[];
-}
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 4281f4313b..1af66fa32b 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -37,6 +37,11 @@ export function asyncForeach<T>(
export const _testOnly_allTasks = new Map<number, DelayedTask>();
+export enum ResolvedDelayedTaskStatus {
+ CALLBACK_EXECUTED = 'CALLBACK_EXECUTED',
+ TASK_CANCELLED = 'TASK_CANCELLED',
+}
+
/**
* This is just a very simple and small wrapper around setTimeout(). Instead of
* the usual:
@@ -52,34 +57,69 @@ export const _testOnly_allTasks = new Map<number, DelayedTask>();
* It is just nicer to have an object for this instead of a number as a handle.
*/
export class DelayedTask {
- private timer?: number;
+ private timerId?: number;
- constructor(private callback: () => void, waitMs = 0) {
- this.timer = window.setTimeout(() => {
- if (this.timer) _testOnly_allTasks.delete(this.timer);
- this.timer = undefined;
- if (this.callback) this.callback();
- }, waitMs);
- _testOnly_allTasks.set(this.timer, this);
+ /**
+ * Promise that is resolved after the callback is run or the task is
+ * cancelled.
+ *
+ * If callback returns a Promise this resolves after the promise is settled.
+ */
+ public readonly promise: Promise<ResolvedDelayedTaskStatus>;
+
+ private resolvePromise?: (
+ value: ResolvedDelayedTaskStatus | PromiseLike<ResolvedDelayedTaskStatus>
+ ) => void;
+
+ private callCallbackAndResolveOnCompletion() {
+ let callbackResult;
+ if (this.callback) callbackResult = this.callback();
+ if (callbackResult instanceof Promise) {
+ callbackResult.finally(() => {
+ this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+ });
+ } else {
+ this.resolvePromise!(ResolvedDelayedTaskStatus.CALLBACK_EXECUTED);
+ }
+ }
+
+ constructor(
+ private readonly callback: () => void | Promise<void>,
+ waitMs = 0
+ ) {
+ this.promise = new Promise(resolve => {
+ this.resolvePromise = resolve;
+ this.timerId = window.setTimeout(() => {
+ if (this.timerId) _testOnly_allTasks.delete(this.timerId);
+ this.timerId = undefined;
+ this.callCallbackAndResolveOnCompletion();
+ }, waitMs);
+ _testOnly_allTasks.set(this.timerId, this);
+ });
+ }
+
+ private cancelTimer() {
+ window.clearTimeout(this.timerId);
+ if (this.timerId) _testOnly_allTasks.delete(this.timerId);
+ this.timerId = undefined;
}
cancel() {
if (this.isActive()) {
- window.clearTimeout(this.timer);
- if (this.timer) _testOnly_allTasks.delete(this.timer);
- this.timer = undefined;
+ this.cancelTimer();
+ this.resolvePromise?.(ResolvedDelayedTaskStatus.TASK_CANCELLED);
}
}
flush() {
if (this.isActive()) {
- this.cancel();
- if (this.callback) this.callback();
+ this.cancelTimer();
+ this.callCallbackAndResolveOnCompletion();
}
}
isActive() {
- return this.timer !== undefined;
+ return this.timerId !== undefined;
}
}
@@ -245,3 +285,115 @@ export function allSettled<T>(
)
);
}
+
+/**
+ * Noop function that can be used to suppress the tsetse must-use-promises rule.
+ *
+ * Example Usage:
+ * async function x() {
+ * await doA();
+ * noAwait(doB());
+ * }
+ */
+export function noAwait(_: {then: Function} | null | undefined) {}
+
+export interface CancelablePromise<T> extends Promise<T> {
+ cancel(): void;
+}
+
+/**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+export function makeCancelable<T>(promise: Promise<T>) {
+ // True if the promise is either resolved or reject (possibly cancelled)
+ let isDone = false;
+
+ let rejectPromise: (reason?: unknown) => void;
+
+ const wrappedPromise: CancelablePromise<T> = new Promise(
+ (resolve, reject) => {
+ rejectPromise = reject;
+ promise.then(
+ val => {
+ if (!isDone) resolve(val);
+ isDone = true;
+ },
+ error => {
+ if (!isDone) reject(error);
+ isDone = true;
+ }
+ );
+ }
+ ) as CancelablePromise<T>;
+
+ wrappedPromise.cancel = () => {
+ if (isDone) return;
+ rejectPromise({isCanceled: true});
+ isDone = true;
+ };
+ return wrappedPromise;
+}
+
+export async function waitUntil(
+ predicate: (() => boolean) | (() => Promise<boolean>),
+ message = 'The waitUntil() predicate is still false after 1000 ms.',
+ timeout_ms = 1000
+): Promise<void> {
+ if (await predicate()) return Promise.resolve();
+ const start = Date.now();
+ let sleep = 10;
+ const error = new Error(message);
+ return new Promise((resolve, reject) => {
+ const waiter = async () => {
+ if (await predicate()) {
+ resolve();
+ return;
+ }
+ if (Date.now() - start >= timeout_ms) {
+ reject(error);
+ return;
+ }
+ setTimeout(waiter, sleep);
+ sleep *= 2;
+ };
+ waiter();
+ });
+}
+
+export interface MockPromise<T> extends Promise<T> {
+ resolve: (value?: T) => void;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ reject: (reason?: any) => void;
+}
+
+export function mockPromise<T = unknown>(): MockPromise<T> {
+ let res: (value?: T) => void;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let rej: (reason?: any) => void;
+ const promise: MockPromise<T> = new Promise<T | undefined>(
+ (resolve, reject) => {
+ res = resolve;
+ rej = reject;
+ }
+ ) as MockPromise<T>;
+ promise.resolve = res!;
+ promise.reject = rej!;
+ return promise;
+}
+
+// MockPromise is the established name in tests, and we don't want to rename
+// that in 50 files. But "Mock" is a bit misleading and definitely not a great
+// fit for non-test code. So let's also export under a different name.
+export type InteractivePromise<T> = MockPromise<T>;
+export const interactivePromise = mockPromise;
+
+export function timeoutPromise(timeoutMs: number): Promise<void> {
+ return new Promise<void>(resolve => {
+ setTimeout(resolve, timeoutMs);
+ });
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
index 9f029b8a69..afc16d3f8b 100644
--- a/polygerrit-ui/app/utils/async-util_test.ts
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -6,10 +6,46 @@
import {assert} from '@open-wc/testing';
import {SinonFakeTimers} from 'sinon';
import '../test/common-test-setup';
-import {waitEventLoop} from '../test/test-utils';
-import {asyncForeach, debounceP} from './async-util';
+import {mockPromise, waitEventLoop, waitUntil} from '../test/test-utils';
+import {
+ asyncForeach,
+ debounceP,
+ DelayedTask,
+ interactivePromise,
+ timeoutPromise,
+} from './async-util';
suite('async-util tests', () => {
+ suite('interactivePromise', () => {
+ test('simple test', async () => {
+ let resolved = false;
+ const promise = interactivePromise();
+ promise.then(() => (resolved = true));
+ assert.isFalse(resolved);
+ promise.resolve();
+ await promise;
+ assert.isTrue(resolved);
+ });
+ });
+
+ suite('timeoutPromise', () => {
+ let clock: SinonFakeTimers;
+ setup(() => {
+ clock = sinon.useFakeTimers();
+ });
+ test('simple test', async () => {
+ let resolved = false;
+ const promise = timeoutPromise(1000);
+ promise.then(() => (resolved = true));
+ assert.isFalse(resolved);
+ clock.tick(999);
+ assert.isFalse(resolved);
+ clock.tick(1);
+ await promise;
+ assert.isTrue(resolved);
+ });
+ });
+
suite('asyncForeach', () => {
test('loops over each item', async () => {
const fn = sinon.stub().resolves();
@@ -205,4 +241,16 @@ suite('async-util tests', () => {
await waitEventLoop();
});
});
+
+ test('DelayedTask promise resolved when callback is done', async () => {
+ const callbackPromise = mockPromise<void>();
+ const task = new DelayedTask(() => callbackPromise);
+ let completed = false;
+ task.promise.then(() => (completed = true));
+ await waitUntil(() => !task.isActive());
+
+ assert.isFalse(completed);
+ callbackPromise.resolve();
+ await waitUntil(() => completed);
+ });
});
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 77834bd582..9dcde62d93 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -3,7 +3,13 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {
+ AccountInfo,
+ ChangeInfo,
+ CommentThread,
+ DetailedLabelInfo,
+ ServerInfo,
+} from '../types/common';
import {ParsedChangeInfo} from '../types/types';
import {
getAccountTemplate,
@@ -11,8 +17,9 @@ import {
isServiceUser,
replaceTemplates,
} from './account-util';
-import {CommentThread, isMentionedThread, isUnresolved} from './comment-util';
+import {isMentionedThread, isUnresolved} from './comment-util';
import {hasOwnProperty} from './common-util';
+import {getCodeReviewLabel} from './label-util';
export function canHaveAttention(account?: AccountInfo): boolean {
return !!account?._account_id && !isServiceUser(account);
@@ -101,9 +108,10 @@ export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
/**
* Sort order:
* 1. The user themselves
- * 2. Human users in the attention set.
- * 3. Other human users.
- * 4. Service users.
+ * 2. Users in the attention set first.
+ * 3. Human users first.
+ * 4. Users that have voted first in this order of vote values:
+ * -2, -1, +2, +1, 0 or no vote.
*/
export function sortReviewers(
r1: AccountInfo,
@@ -117,7 +125,22 @@ export function sortReviewers(
}
const a1 = hasAttention(r1, change) ? 1 : 0;
const a2 = hasAttention(r2, change) ? 1 : 0;
- const s1 = isServiceUser(r1) ? -2 : 0;
- const s2 = isServiceUser(r2) ? -2 : 0;
- return a2 - a1 + s2 - s1;
+ if (a2 - a1 !== 0) return a2 - a1;
+
+ const s1 = isServiceUser(r1) ? -1 : 0;
+ const s2 = isServiceUser(r2) ? -1 : 0;
+ if (s2 - s1 !== 0) return s2 - s1;
+
+ const crLabel = getCodeReviewLabel(change?.labels ?? {}) as DetailedLabelInfo;
+ let v1 =
+ crLabel?.all?.find(vote => vote._account_id === r1._account_id)?.value ?? 0;
+ let v2 =
+ crLabel?.all?.find(vote => vote._account_id === r2._account_id)?.value ?? 0;
+ // We want negative votes getting a higher score than positive votes, so
+ // we choose 10 as a random number that is higher than all positive votes that
+ // are in use, and then add the absolute value of the vote to that.
+ // So -2 becomes 12.
+ if (v1 < 0) v1 = 10 - v1;
+ if (v2 < 0) v2 = 10 - v2;
+ return v2 - v1;
}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
index 8092a6e745..5bd1924952 100644
--- a/polygerrit-ui/app/utils/attention-set-util_test.ts
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -6,9 +6,11 @@
import '../test/common-test-setup';
import {
createAccountDetailWithIdNameAndEmail,
+ createAccountWithId,
createChange,
createComment,
createCommentThread,
+ createParsedChange,
createServerInfo,
} from '../test/test-data-generators';
import {
@@ -22,9 +24,10 @@ import {
getMentionedReason,
getReason,
hasAttention,
+ sortReviewers,
} from './attention-set-util';
import {DefaultDisplayNameConfig} from '../api/rest-api';
-import {AccountsVisibility} from '../constants/constants';
+import {AccountsVisibility, AccountTag} from '../constants/constants';
import {assert} from '@open-wc/testing';
const KERMIT: AccountInfo = {
@@ -101,6 +104,45 @@ suite('attention-set-util', () => {
assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
});
+ test('sortReviewers', () => {
+ const a1 = createAccountWithId(1);
+ a1.tags = [AccountTag.SERVICE_USER];
+ const a2 = createAccountWithId(2);
+ a2.tags = [AccountTag.SERVICE_USER];
+ const a3 = createAccountWithId(3);
+ const a4 = createAccountWithId(4);
+ const a5 = createAccountWithId(5);
+ const a6 = createAccountWithId(6);
+ const a7 = createAccountWithId(7);
+
+ const reviewers = [a1, a2, a3, a4, a5, a6, a7];
+ const change = {
+ ...createParsedChange(),
+ attention_set: {'6': {account: a6}},
+ labels: {
+ 'Code-Review': {
+ all: [
+ {...a2, value: 1},
+ {...a4, value: 1},
+ {...a5, value: -1},
+ ],
+ },
+ },
+ };
+ assert.sameOrderedMembers(
+ reviewers.sort((r1, r2) => sortReviewers(r1, r2, change, a7)),
+ [
+ a7, // self
+ a6, // is in the attention set
+ a5, // human user, has voted -1
+ a4, // human user, has voted +1
+ a3, // human user, has not voted
+ a2, // service user, has voted
+ a1, // service user, has not voted
+ ]
+ );
+ });
+
test('getMentionReason', () => {
let comment = {
...createComment(),
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 9062ac7f47..4490afa7f0 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -11,15 +11,17 @@ import {
ChangeInfo,
AccountInfo,
RelatedChangeAndCommitInfo,
+ ChangeStates,
} from '../types/common';
import {ParsedChangeInfo} from '../types/types';
-import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
import {getUserId, isServiceUser} from './account-util';
// This can be wrong! See WARNING above
interface ChangeStatusesOptions {
mergeable: boolean; // This can be wrong! See WARNING above
submitEnabled: boolean; // This can be wrong! See WARNING above
+ /** Is there a reverting change and if so, what status has it? */
+ revertingChangeStatus?: ChangeStatus;
}
export const ChangeDiffType = {
@@ -109,11 +111,11 @@ export function listChangesOptionsToHex(...args: number[]) {
}
export function changeBaseURL(
- project: string,
+ repo: string,
changeNum: NumericChangeId,
patchNum: PatchSetNum
): string {
- let v = `${getBaseUrl()}/changes/${encodeURIComponent(project)}~${changeNum}`;
+ let v = `${getBaseUrl()}/changes/${encodeURIComponent(repo)}~${changeNum}`;
if (patchNum) {
v += `/revisions/${patchNum}`;
}
@@ -154,19 +156,22 @@ export function getChangeNumber(
export function changeStatuses(
change: ChangeInfo,
- opt_options?: ChangeStatusesOptions
+ options?: ChangeStatusesOptions
): ChangeStates[] {
const states = [];
if (change.status === ChangeStatus.MERGED) {
+ if (options?.revertingChangeStatus === ChangeStatus.MERGED) {
+ return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED];
+ }
+ if (options?.revertingChangeStatus !== undefined) {
+ return [ChangeStates.MERGED, ChangeStates.REVERT_CREATED];
+ }
return [ChangeStates.MERGED];
}
if (change.status === ChangeStatus.ABANDONED) {
return [ChangeStates.ABANDONED];
}
- if (
- change.mergeable === false ||
- (opt_options && opt_options.mergeable === false)
- ) {
+ if (change.mergeable === false || (options && options.mergeable === false)) {
// 'mergeable' prop may not always exist (@see Issue 6819)
states.push(ChangeStates.MERGE_CONFLICT);
} else if (change.contains_git_conflicts) {
@@ -181,12 +186,12 @@ export function changeStatuses(
// If there are any pre-defined statuses, only return those. Otherwise,
// will determine the derived status.
- if (states.length || !opt_options) {
+ if (states.length || !options) {
return states;
}
// If no missing requirements, either active or ready to submit.
- if (change.submittable && opt_options.submitEnabled) {
+ if (change.submittable && options.submitEnabled) {
states.push(ChangeStates.READY_TO_SUBMIT);
} else {
// Otherwise it is active.
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
index 70e6fd648e..f76814575b 100644
--- a/polygerrit-ui/app/utils/change-util_test.ts
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -5,7 +5,6 @@
*/
import {assert} from '@open-wc/testing';
import {ChangeStatus} from '../constants/constants';
-import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
import '../test/common-test-setup';
import {
createAccountWithId,
@@ -15,6 +14,7 @@ import {
} from '../test/test-data-generators';
import {
AccountId,
+ ChangeStates,
CommitId,
NumericChangeId,
PatchSetNum,
@@ -129,6 +129,32 @@ suite('change-util tests', () => {
assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
});
+ test('Merged and Reverted status', () => {
+ const change = {
+ ...createChange(),
+ revisions: createRevisions(1),
+ current_revision: 'rev1' as CommitId,
+ status: ChangeStatus.MERGED,
+ };
+ assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]);
+ assert.deepEqual(
+ changeStatuses(change, {
+ revertingChangeStatus: ChangeStatus.NEW,
+ mergeable: true,
+ submitEnabled: true,
+ }),
+ [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]
+ );
+ assert.deepEqual(
+ changeStatuses(change, {
+ revertingChangeStatus: ChangeStatus.MERGED,
+ mergeable: true,
+ submitEnabled: true,
+ }),
+ [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]
+ );
+ });
+
test('Abandoned status', () => {
const change = {
...createChange(),
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index f531698d69..ee1a44c2e7 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -4,13 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
- CommentBasics,
CommentInfo,
PatchSetNum,
- RobotCommentInfo,
- Timestamp,
UrlEncodedCommentId,
- CommentRange,
PatchRange,
PARENT,
ContextLine,
@@ -18,79 +14,28 @@ import {
RevisionPatchSetNum,
AccountInfo,
AccountDetailInfo,
- ChangeMessageInfo,
VotingRangeInfo,
FixSuggestionInfo,
FixId,
+ PatchSetNumber,
+ CommentThread,
+ DraftInfo,
+ ChangeMessage,
+ isRobot,
+ isDraft,
+ Comment,
+ CommentIdToCommentThreadMap,
+ SavingState,
+ NewDraftInfo,
+ isNew,
} from '../types/common';
import {CommentSide, SpecialFilePath} from '../constants/constants';
import {parseDate} from './date-util';
-import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api';
import {isMergeParent, getParentIndex} from './patch-set-util';
import {DiffInfo} from '../types/diff';
-import {LineNumber} from '../api/diff';
import {FormattedReviewerUpdateInfo} from '../types/types';
import {extractMentionedUsers} from './account-util';
-
-export interface DraftCommentProps {
- // This must be true for all drafts. Drafts received from the backend will be
- // modified immediately with __draft:true before allowing them to get into
- // the application state.
- __draft: boolean;
-}
-
-export interface UnsavedCommentProps {
- // This must be true for all unsaved comment drafts. An unsaved draft is
- // always just local to a comment component like <gr-comment> or
- // <gr-comment-thread>. Unsaved drafts will never appear in the application
- // state.
- __unsaved: boolean;
-}
-
-export type DraftInfo = CommentInfo & DraftCommentProps;
-
-export type UnsavedInfo = CommentBasics & UnsavedCommentProps;
-
-export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo;
-
-// TODO: Replace the CommentMap type with just an array of paths.
-export type CommentMap = {[path: string]: boolean};
-
-export function isRobot<T extends CommentBasics>(
- x: T | DraftInfo | RobotCommentInfo | undefined
-): x is RobotCommentInfo {
- return !!x && !!(x as RobotCommentInfo).robot_id;
-}
-
-export function isDraft<T extends CommentBasics>(
- x: T | DraftInfo | undefined
-): x is DraftInfo {
- return !!x && !!(x as DraftInfo).__draft;
-}
-
-export function isUnsaved<T extends CommentBasics>(
- x: T | UnsavedInfo | undefined
-): x is UnsavedInfo {
- return !!x && !!(x as UnsavedInfo).__unsaved;
-}
-
-export function isDraftOrUnsaved<T extends CommentBasics>(
- x: T | DraftInfo | UnsavedInfo | undefined
-): x is UnsavedInfo | DraftInfo {
- return isDraft(x) || isUnsaved(x);
-}
-
-interface SortableComment {
- updated: Timestamp;
- id: UrlEncodedCommentId;
-}
-
-export interface ChangeMessage extends ChangeMessageInfo {
- // TODO(TS): maybe should be an enum instead
- type: string;
- expanded: boolean;
- commentThreads: CommentThread[];
-}
+import {assertIsDefined, uuid} from './common-util';
export function isFormattedReviewerUpdate(
message: ChangeMessage
@@ -105,43 +50,89 @@ export const NEWLINE_PATTERN = /\n/g;
export const PATCH_SET_PREFIX_PATTERN =
/^(?:Uploaded\s*)?[Pp]atch [Ss]et \d+:\s*(.*)/;
-export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+/**
+ * We need a way to uniquely identify drafts. That is easy for all drafts that
+ * were already known to the backend at the time of change page load: They will
+ * have an `id` that we can use.
+ *
+ * For newly created drafts we start by setting a `client_id`, so that we can
+ * identify the draft even, if no `id` is available yet.
+ *
+ * If a comment with a `client_id` gets saved, then id gets an `id`, but we have
+ * to keep using the `client_id`, because that is what the UI is already using,
+ * e.g. in `repeat()` directives.
+ */
+export function id(comment: Comment): UrlEncodedCommentId {
+ if (isDraft(comment)) {
+ if (isNew(comment)) {
+ assertIsDefined(comment.client_id);
+ return comment.client_id;
+ }
+ if (comment.client_id) {
+ return comment.client_id;
+ }
+ }
+ assertIsDefined(comment.id);
+ return comment.id;
+}
+
+export function sortComments<T extends Comment>(comments: T[]): T[] {
return comments.slice(0).sort((c1, c2) => {
+ const n1 = isNew(c1);
+ const n2 = isNew(c2);
+ if (n1 !== n2) return n1 ? 1 : -1;
+
const d1 = isDraft(c1);
const d2 = isDraft(c2);
if (d1 !== d2) return d1 ? 1 : -1;
- const date1 = parseDate(c1.updated);
- const date2 = parseDate(c2.updated);
- const dateDiff = date1.valueOf() - date2.valueOf();
- if (dateDiff !== 0) return dateDiff;
+ if (c1.updated && c2.updated) {
+ const date1 = parseDate(c1.updated);
+ const date2 = parseDate(c2.updated);
+ const dateDiff = date1.valueOf() - date2.valueOf();
+ if (dateDiff !== 0) return dateDiff;
+ }
- const id1 = c1.id;
- const id2 = c2.id;
+ const id1 = id(c1);
+ const id2 = id(c2);
return id1.localeCompare(id2);
});
}
-export function createUnsavedComment(thread: CommentThread): UnsavedInfo {
+export function createNew(
+ message?: string,
+ unresolved?: boolean
+): NewDraftInfo {
+ const newDraft: NewDraftInfo = {
+ savingState: SavingState.OK,
+ client_id: uuid() as UrlEncodedCommentId,
+ id: undefined,
+ updated: undefined,
+ };
+ if (message !== undefined) newDraft.message = message;
+ if (unresolved !== undefined) newDraft.unresolved = unresolved;
+ return newDraft;
+}
+
+export function createNewPatchsetLevel(
+ patchNum?: PatchSetNumber,
+ message?: string,
+ unresolved?: boolean
+): DraftInfo {
return {
- path: thread.path,
- patch_set: thread.patchNum,
- side: thread.commentSide ?? CommentSide.REVISION,
- line: typeof thread.line === 'number' ? thread.line : undefined,
- range: thread.range,
- parent: thread.mergeParentNum,
- message: '',
- unresolved: true,
- __unsaved: true,
+ ...createNew(message, unresolved),
+ patch_set: patchNum,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
};
}
-export function createUnsavedReply(
+export function createNewReply(
replyingTo: CommentInfo,
message: string,
unresolved: boolean
-): UnsavedInfo {
+): DraftInfo {
return {
+ ...createNew(message, unresolved),
path: replyingTo.path,
patch_set: replyingTo.patch_set,
side: replyingTo.side,
@@ -149,13 +140,10 @@ export function createUnsavedReply(
range: replyingTo.range,
parent: replyingTo.parent,
in_reply_to: replyingTo.id,
- message,
- unresolved,
- __unsaved: true,
};
}
-export function createCommentThreads(comments: CommentInfo[]) {
+export function createCommentThreads(comments: Comment[]) {
const sortedComments = sortComments(comments);
const threads: CommentThread[] = [];
const idThreadMap: CommentIdToCommentThreadMap = {};
@@ -165,7 +153,7 @@ export function createCommentThreads(comments: CommentInfo[]) {
const thread = idThreadMap[comment.in_reply_to];
if (thread) {
thread.comments.push(comment);
- if (comment.id) idThreadMap[comment.id] = thread;
+ if (id(comment)) idThreadMap[id(comment)] = thread;
continue;
}
}
@@ -182,58 +170,17 @@ export function createCommentThreads(comments: CommentInfo[]) {
path: comment.path,
line: comment.line,
range: comment.range,
- rootId: comment.id,
+ rootId: id(comment),
};
if (!comment.line && !comment.range) {
newThread.line = 'FILE';
}
threads.push(newThread);
- if (comment.id) idThreadMap[comment.id] = newThread;
+ if (id(comment)) idThreadMap[id(comment)] = newThread;
}
return threads;
}
-export interface CommentThread {
- /**
- * This can only contain at most one draft. And if so, then it is the last
- * comment in this list. This must not contain unsaved drafts.
- */
- comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>;
- /**
- * Identical to the id of the first comment. If this is undefined, then the
- * thread only contains an unsaved draft.
- */
- rootId?: UrlEncodedCommentId;
- /**
- * Note that all location information is typically identical to that of the
- * first comment, but not for ported comments!
- */
- path: string;
- commentSide: CommentSide;
- /* mergeParentNum is the merge parent number only valid for merge commits
- when commentSide is PARENT.
- mergeParentNum is undefined for auto merge commits
- Same as `parent` in CommentInfo.
- */
- mergeParentNum?: number;
- patchNum?: RevisionPatchSetNum;
- /* Different from CommentInfo, which just keeps the line undefined for
- FILE comments. */
- line?: LineNumber;
- range?: CommentRange;
- /**
- * Was the thread ported over from its original location to a newer patchset?
- * If yes, then the location information above contains the ported location,
- * but the comments still have the original location set.
- */
- ported?: boolean;
- /**
- * Only relevant when ported:true. Means that no ported range could be
- * computed. `line` and `range` can be undefined then.
- */
- rangeInfoLost?: boolean;
-}
-
export function equalLocation(t1?: CommentThread, t2?: CommentThread) {
if (t1 === t2) return true;
if (t1 === undefined || t2 === undefined) return false;
@@ -249,22 +196,24 @@ export function equalLocation(t1?: CommentThread, t2?: CommentThread) {
);
}
-export function getLastComment(thread: CommentThread): CommentInfo | undefined {
+export function getLastComment(
+ thread: CommentThread
+): CommentInfo | DraftInfo | undefined {
const len = thread.comments.length;
return thread.comments[len - 1];
}
export function getLastPublishedComment(
thread: CommentThread
-): CommentInfo | undefined {
- const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c));
+): CommentInfo | DraftInfo | undefined {
+ const publishedComments = thread.comments.filter(c => !isDraft(c));
const len = publishedComments.length;
return publishedComments[len - 1];
}
export function getFirstComment(
thread: CommentThread
-): CommentInfo | undefined {
+): CommentInfo | DraftInfo | undefined {
return thread.comments[0];
}
@@ -289,6 +238,14 @@ export function isDraftThread(thread: CommentThread): boolean {
return isDraft(getLastComment(thread));
}
+/**
+ * Returns true, if the thread consists only of one comment that has not yet
+ * been saved to the backend.
+ */
+export function isNewThread(thread: CommentThread): boolean {
+ return isNew(getFirstComment(thread));
+}
+
export function isMentionedThread(
thread: CommentThread,
account?: AccountInfo
@@ -371,10 +328,7 @@ export function isInRevisionOfPatchRange(
/**
* Whether the given comment should be included in the given patch range.
*/
-export function isInPatchRange(
- comment: CommentBasics,
- range: PatchRange
-): boolean {
+export function isInPatchRange(comment: Comment, range: PatchRange): boolean {
return (
isInBaseOfPatchRange(comment, range) ||
isInRevisionOfPatchRange(comment, range)
@@ -481,8 +435,8 @@ export function addPath<T>(comments: {[path: string]: T[]} = {}): {
}
/**
- * Add __draft:true to all drafts returned from server so that they can be told
- * apart from published comments easily.
+ * Add `savingState: SavingState.OK` to all drafts returned from server so that
+ * they can be told apart from published comments easily.
*/
export function addDraftProp(
draftsByPath: {[path: string]: CommentInfo[]} = {}
@@ -490,13 +444,13 @@ export function addDraftProp(
const updated: {[path: string]: DraftInfo[]} = {};
for (const filePath of Object.keys(draftsByPath)) {
updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => {
- return {...draft, __draft: true};
+ return {...draft, savingState: SavingState.OK};
});
}
return updated;
}
-export function reportingDetails(comment: CommentBasics) {
+export function reportingDetails(comment: Comment) {
return {
id: comment?.id,
message_length: comment?.message?.trim().length,
@@ -504,11 +458,12 @@ export function reportingDetails(comment: CommentBasics) {
unresolved: comment?.unresolved,
path_length: comment?.path?.length,
line: comment?.range?.start_line ?? comment?.line,
- unsaved: isUnsaved(comment),
+ unsaved: isNew(comment),
};
}
-export const USER_SUGGESTION_START_PATTERN = '```suggestion\n';
+export const USER_SUGGESTION_INFO_STRING = 'suggestion';
+export const USER_SUGGESTION_START_PATTERN = `\`\`\`${USER_SUGGESTION_INFO_STRING}\n`;
// This can either mean a user or a checks provided fix.
// "Provided" means that the fix is sent along with the request
@@ -521,13 +476,17 @@ export function hasUserSuggestion(comment: Comment) {
return comment.message?.includes(USER_SUGGESTION_START_PATTERN) ?? false;
}
-export function getUserSuggestion(comment: Comment) {
- if (!comment.message) return;
+export function getUserSuggestionFromString(content: string) {
const start =
- comment.message.indexOf(USER_SUGGESTION_START_PATTERN) +
+ content.indexOf(USER_SUGGESTION_START_PATTERN) +
USER_SUGGESTION_START_PATTERN.length;
- const end = comment.message.indexOf('\n```', start);
- return comment.message.substring(start, end);
+ const end = content.indexOf('\n```', start);
+ return content.substring(start, end);
+}
+
+export function getUserSuggestion(comment: Comment) {
+ if (!comment.message) return;
+ return getUserSuggestionFromString(comment.message);
}
export function getContentInCommentRange(
@@ -583,3 +542,17 @@ export function getMentionedThreads(
.includes(account.email)
);
}
+
+export function findComment(
+ comments: {
+ [path: string]: (CommentInfo | DraftInfo)[];
+ },
+ commentId: UrlEncodedCommentId
+) {
+ if (!commentId) return undefined;
+ let comment;
+ for (const path of Object.keys(comments)) {
+ comment = comment || comments[path].find(c => c.id === commentId);
+ }
+ return comment;
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts
index 0a8aa82167..7bf0c1ec63 100644
--- a/polygerrit-ui/app/utils/comment-util_test.ts
+++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -16,6 +16,8 @@ import {
createUserFixSuggestion,
PROVIDED_FIX_ID,
getMentionedThreads,
+ isNewThread,
+ createNew,
} from './comment-util';
import {
createAccountWithEmail,
@@ -24,6 +26,9 @@ import {
} from '../test/test-data-generators';
import {CommentSide} from '../constants/constants';
import {
+ Comment,
+ DraftInfo,
+ SavingState,
PARENT,
RevisionPatchSetNum,
Timestamp,
@@ -69,6 +74,17 @@ suite('comment-util', () => {
);
});
+ test('isNewThread', () => {
+ let thread = createCommentThread([createComment()]);
+ assert.isFalse(isNewThread(thread));
+
+ thread = createCommentThread([createComment(), createNew()]);
+ assert.isFalse(isNewThread(thread));
+
+ thread = createCommentThread([createNew()]);
+ assert.isTrue(isNewThread(thread));
+ });
+
suite('getPatchRangeForCommentUrl', () => {
test('comment created with side=PARENT does not navigate to latest ps', () => {
const comment = {
@@ -127,13 +143,13 @@ suite('comment-util', () => {
});
test('comments sorting', () => {
- const comments = [
+ const comments: Comment[] = [
{
id: 'new_draft' as UrlEncodedCommentId,
message: 'i do not like either of you',
- __draft: true,
+ savingState: SavingState.OK,
updated: '2015-12-20 15:01:20.396000000' as Timestamp,
- },
+ } as DraftInfo,
{
id: 'sallys_confession' as UrlEncodedCommentId,
message: 'i like you, jack',
@@ -145,7 +161,7 @@ suite('comment-util', () => {
message: 'i like you, too',
updated: '2015-12-24 15:01:20.396000000' as Timestamp,
line: 1,
- in_reply_to: 'sallys_confession',
+ in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
},
];
const sortedComments = sortComments(comments);
@@ -156,7 +172,7 @@ suite('comment-util', () => {
suite('createCommentThreads', () => {
test('creates threads from individual comments', () => {
- const comments = [
+ const comments: Comment[] = [
{
id: 'sallys_confession' as UrlEncodedCommentId,
message: 'i like you, jack',
@@ -177,7 +193,7 @@ suite('comment-util', () => {
{
id: 'new_draft' as UrlEncodedCommentId,
message: 'i do not like either of you' as UrlEncodedCommentId,
- __draft: true,
+ savingState: SavingState.OK,
updated: '2015-12-20 15:01:20.396000000' as Timestamp,
patch_set: 1 as RevisionPatchSetNum,
path: 'some/path',
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 183d16733a..c89bdff206 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -175,3 +175,10 @@ export async function copyToClipbard(text: string, copyTargetName?: string) {
await navigator.clipboard.writeText(text);
fireAlert(document, `${copyTargetName ?? text} was copied to clipboard`);
}
+
+/**
+ * Produces strings such as `y364b4tm28n`.
+ */
+export function uuid() {
+ return Math.random().toString(36).substring(2);
+}
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
index 72e6cb7baa..d95d24a68b 100644
--- a/polygerrit-ui/app/utils/date-util.ts
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -83,21 +83,17 @@ export function isWithinHalfYear(now: Date, date: Date) {
return diff < 180 * Duration.DAY;
}
-// TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
-// but this require some changes in the code. During JS->TS migration
-// we want to avoid code changes where possible, so for simplicity we
-// define it with almost all fields mandatory
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts
interface DateTimeFormatParts {
- year: string;
- month: string;
- day: string;
- hour: string;
- minute: string;
- second: string;
- dayPeriod: string;
- dayperiod?: string;
- // Object can have other properties, but our code doesn't use it
- [key: string]: string | undefined;
+ year?: string;
+ month?: string;
+ day?: string;
+ hour?: string;
+ minute?: string;
+ second?: string;
+ // AM or PM
+ dayPeriod?: string;
+ weekday?: string;
}
export function formatDate(date: Date, format: string) {
@@ -117,6 +113,14 @@ export function formatDate(date: Date, format: string) {
}
}
+ if (format.includes('ddd')) {
+ if (format.includes('dddd')) {
+ options.weekday = 'long';
+ } else {
+ options.weekday = 'short';
+ }
+ }
+
if (format.includes('DD')) {
options.day = '2-digit';
}
@@ -146,15 +150,38 @@ export function formatDate(date: Date, format: string) {
locale = 'en-GB';
}
- const dtf = new Intl.DateTimeFormat(locale, options);
- const parts = dtf
- .formatToParts(date)
- .filter(o => o.type !== 'literal')
- .reduce((acc, o: Intl.DateTimeFormatPart) => {
- acc[o.type] = o.value;
- return acc;
- }, {} as DateTimeFormatParts);
- if (format.includes('YY')) {
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts
+ const dtfParts = new Intl.DateTimeFormat(locale, options).formatToParts(date);
+ const parts: DateTimeFormatParts = {};
+ for (const entry of dtfParts) {
+ switch (entry.type) {
+ case 'year':
+ parts.year = entry.value;
+ break;
+ case 'month':
+ parts.month = entry.value;
+ break;
+ case 'day':
+ parts.day = entry.value;
+ break;
+ case 'hour':
+ parts.hour = entry.value;
+ break;
+ case 'minute':
+ parts.minute = entry.value;
+ break;
+ case 'second':
+ parts.second = entry.value;
+ break;
+ case 'dayPeriod':
+ parts.dayPeriod = entry.value;
+ break;
+ case 'weekday':
+ parts.weekday = entry.value;
+ break;
+ }
+ }
+ if (parts.year && format.includes('YY')) {
if (format.includes('YYYY')) {
format = format.replace('YYYY', parts.year);
} else {
@@ -162,41 +189,50 @@ export function formatDate(date: Date, format: string) {
}
}
- if (format.includes('DD')) {
+ if (parts.day && format.includes('DD')) {
format = format.replace('DD', parts.day);
}
- if (format.includes('HH')) {
+ if (parts.hour && format.includes('HH')) {
format = format.replace('HH', parts.hour);
}
- if (format.includes('h')) {
+ if (parts.hour && format.includes('h')) {
format = format.replace('h', parts.hour);
}
- if (format.includes('mm')) {
+ if (parts.minute && format.includes('mm')) {
format = format.replace('mm', parts.minute);
}
- if (format.includes('ss')) {
+ if (parts.second && format.includes('ss')) {
format = format.replace('ss', parts.second);
}
- if (format.includes('A')) {
- if (parts.dayperiod) {
- // Workaround for chrome 70 and below
- format = format.replace('A', parts.dayperiod.toUpperCase());
- } else {
- format = format.replace('A', parts.dayPeriod.toUpperCase());
- }
+ if (parts.dayPeriod && format.includes('A')) {
+ format = format.replace('A', parts.dayPeriod.toUpperCase());
}
- if (format.includes('MM')) {
+
+ // Month and weekday must be last, because they will yield characters that
+ // could be interpreted as format strings, e.g. `h` in `Thursday` would
+ // otherwise be replaced by "hours".
+
+ if (parts.month && format.includes('MM')) {
if (format.includes('MMM')) {
format = format.replace('MMM', parts.month);
} else {
format = format.replace('MM', parts.month);
}
}
+
+ if (parts.weekday && format.includes('ddd')) {
+ if (format.includes('dddd')) {
+ format = format.replace('dddd', parts.weekday);
+ } else {
+ format = format.replace('ddd', parts.weekday);
+ }
+ }
+
return format;
}
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
index 8e802b70dc..8d16655e38 100644
--- a/polygerrit-ui/app/utils/date-util_test.ts
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -194,6 +194,18 @@ suite('date-util tests', () => {
)
);
});
+
+ test('weekday', () => {
+ assert.equal(
+ '2013-07-03 Wed',
+ formatDate(new Date('Jul 03 2013 12:14:00'), 'YYYY-MM-DD ddd')
+ );
+ assert.equal(
+ '2013-07-03 Wednesday',
+ formatDate(new Date('Jul 03 2013 00:15:00'), 'YYYY-MM-DD dddd')
+ );
+ });
+
test('h:mm:ss A shows correctly midnight and midday', () => {
const timeFormat = 'h:mm A';
assert.equal(
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
index 7c39e1add5..850509fdc9 100644
--- a/polygerrit-ui/app/utils/display-name-util.ts
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -56,21 +56,21 @@ export function getAccountDisplayName(
account: AccountInfo
) {
const reviewerName = getDisplayName(config, account);
- const reviewerEmail = _accountEmail(account.email);
+ const reviewerEmail = accountEmail(account.email);
const reviewerStatus = account.status ? '(' + account.status + ')' : '';
return [reviewerName, reviewerEmail, reviewerStatus]
.filter(p => p.length > 0)
.join(' ');
}
-function _accountEmail(email?: string) {
+function accountEmail(email?: string) {
if (typeof email !== 'undefined') {
return '<' + email + '>';
}
return '';
}
-export const _testOnly_accountEmail = _accountEmail;
+export const _testOnly_accountEmail = accountEmail;
export function getGroupDisplayName(group: GroupInfo) {
return `${group.name || ''} (group)`;
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 0b53f61a89..056238a1a9 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -182,25 +182,37 @@ export function getEventPath<T extends MouseEvent>(e?: T) {
}
/**
- * Are any ancestors of the element (or the element itself) members of the
- * given class.
+ * Are any ancestors of the element (or the element itself) tagged with the
+ * given css class?
*
+ * We are walking up the DOM using `element.parentElement`, but are not crossing
+ * Shadow DOM boundaries, if there are any.
*/
export function descendedFromClass(
- element: Element,
+ element: Element | undefined,
className: string,
stopElement?: Element
) {
- let isDescendant = element.classList.contains(className);
- while (
- !isDescendant &&
- element.parentElement &&
- (!stopElement || element.parentElement !== stopElement)
- ) {
- isDescendant = element.classList.contains(className);
- element = element.parentElement;
+ return parentWithClass(element, className, stopElement) !== undefined;
+}
+
+/**
+ * Returns an ancestor of the element (or the element itself) tagged with the
+ * given css class - or undefined.
+ *
+ * We are walking up the DOM using `element.parentElement`, but are not crossing
+ * Shadow DOM boundaries, if there are any.
+ */
+export function parentWithClass(
+ element: Element | undefined,
+ className: string,
+ stopElement?: Element
+) {
+ while (element && (!stopElement || element !== stopElement)) {
+ if (element.classList.contains(className)) return element;
+ element = element.parentElement ?? undefined;
}
- return isDescendant;
+ return undefined;
}
/**
@@ -453,7 +465,7 @@ export function shouldSuppress(e: KeyboardEvent): boolean {
const path: EventTarget[] = e.composedPath() ?? [];
for (const el of path) {
if (!isElementTarget(el)) continue;
- if (el.tagName === 'GR-OVERLAY') return true;
+ if (el.tagName === 'DIALOG') return true;
}
return false;
}
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
index 4b525481eb..fe185be5e0 100644
--- a/polygerrit-ui/app/utils/dom-util_test.ts
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -59,11 +59,13 @@ export class TestElement extends LitElement {
}
async function createFixture() {
- return await fixture<HTMLElement>(html` <div id="test" class="a b c">
- <a class="testBtn" style="color:red;"></a>
- <dom-util-test-element></dom-util-test-element>
- <span class="ss"></span>
- </div>`);
+ return await fixture<HTMLElement>(html`
+ <div id="test" class="a b c d">
+ <a class="testBtn" style="color:red;"></a>
+ <dom-util-test-element></dom-util-test-element>
+ <span class="ss"></span>
+ </div>
+ `);
}
suite('dom-util tests', () => {
@@ -127,7 +129,7 @@ suite('dom-util tests', () => {
path = getEventPath(e as MouseEvent);
});
aLink.click();
- assert.equal(path, 'html>body>div>div#test.a.b.c>a.testBtn');
+ assert.equal(path, 'html>body>div>div#test.a.b.c.d>a.testBtn');
});
});
@@ -150,14 +152,44 @@ suite('dom-util tests', () => {
});
suite('descendedFromClass', () => {
- test('basic tests', async () => {
+ test('descends from itself', async () => {
const element = await createFixture();
const testEl = queryAndAssert(element, 'dom-util-test-element');
- // .c is a child of .a and not vice versa.
- assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
- assert.isFalse(descendedFromClass(queryAndAssert(testEl, '.a'), 'c'));
+ assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'c'));
+ assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.b'), 'b'));
+ assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.a'), 'a'));
+ });
+
+ test('.c in .b in .a', async () => {
+ const element = await createFixture();
+ const testEl = queryAndAssert(element, 'dom-util-test-element');
+ const a = queryAndAssert(testEl, '.a');
+ const b = queryAndAssert(testEl, '.b');
+ const c = queryAndAssert(testEl, '.c');
+ assert.isTrue(descendedFromClass(a, 'a'));
+ assert.isTrue(descendedFromClass(b, 'a'));
+ assert.isTrue(descendedFromClass(c, 'a'));
+ assert.isFalse(descendedFromClass(a, 'b'));
+ assert.isTrue(descendedFromClass(b, 'b'));
+ assert.isTrue(descendedFromClass(c, 'b'));
+ assert.isFalse(descendedFromClass(a, 'c'));
+ assert.isFalse(descendedFromClass(b, 'c'));
+ assert.isTrue(descendedFromClass(c, 'c'));
+ });
- // Stops at stop element.
+ test('stops at shadow root', async () => {
+ const element = await createFixture();
+ const testEl = queryAndAssert(element, 'dom-util-test-element');
+ const a = queryAndAssert(testEl, '.a');
+ // div.d is a parent of testEl, but `descendedFromClass` does not cross
+ // the shadow root boundary of <dom-util-test-element>. So div.a inside
+ // the shadow root is not considered to descend from div.d outside of it.
+ assert.isFalse(descendedFromClass(a, 'd'));
+ });
+
+ test('stops at stop element', async () => {
+ const element = await createFixture();
+ const testEl = queryAndAssert(element, 'dom-util-test-element');
assert.isFalse(
descendedFromClass(
queryAndAssert(testEl, '.c'),
@@ -297,15 +329,6 @@ suite('dom-util tests', () => {
});
});
- test('suppress shortcut event from children of <gr-overlay>', async () => {
- const overlay = document.createElement('gr-overlay');
- const div = document.createElement('div');
- overlay.appendChild(div);
- await keyEventOn(div, e => {
- assert.isTrue(shouldSuppress(e));
- });
- });
-
test('suppress "enter" shortcut event from <gr-button>', async () => {
await keyEventOn(
document.createElement('gr-button'),
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index 714955bbac..af545e7f47 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -6,47 +6,34 @@
import {FetchRequest} from '../types/types';
import {
DialogChangeEventDetail,
- EventType,
SwitchTabEventDetail,
TabState,
} from '../types/events';
-export function fireEvent(target: EventTarget, type: string) {
- target.dispatchEvent(
- new CustomEvent(type, {
- composed: true,
- bubbles: true,
- })
- );
-}
-
export type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
- HTMLElementEventMap[K] extends CustomEvent<infer DT>
- ? unknown extends DT
- ? never
- : DT
- : never;
+ HTMLElementEventMap[K] extends CustomEvent<infer DT> ? DT : never;
type DocumentEventDetailType<K extends keyof DocumentEventMap> =
- DocumentEventMap[K] extends CustomEvent<infer DT>
- ? unknown extends DT
- ? never
- : DT
- : never;
+ DocumentEventMap[K] extends CustomEvent<infer DT> ? DT : never;
export function fire<K extends keyof DocumentEventMap>(
- target: Document,
+ target: Document | undefined,
type: K,
detail: DocumentEventDetailType<K>
): void;
export function fire<K extends keyof HTMLElementEventMap>(
- target: EventTarget,
+ target: EventTarget | undefined,
type: K,
detail: HTMLElementEventDetailType<K>
): void;
-export function fire<T>(target: EventTarget, type: string, detail: T) {
+export function fire<T>(
+ target: EventTarget | undefined,
+ type: string,
+ detail: T
+) {
+ if (!target) return;
target.dispatchEvent(
new CustomEvent<T>(type, {
detail,
@@ -56,28 +43,60 @@ export function fire<T>(target: EventTarget, type: string, detail: T) {
);
}
+export function fireNoBubble<K extends keyof HTMLElementEventMap, T>(
+ target: EventTarget,
+ type: K,
+ detail: T
+) {
+ target.dispatchEvent(
+ new CustomEvent<T>(type, {
+ detail,
+ composed: true,
+ bubbles: false,
+ })
+ );
+}
+
+export function fireNoBubbleNoCompose<K extends keyof HTMLElementEventMap, T>(
+ target: EventTarget,
+ type: K,
+ detail: T
+) {
+ target.dispatchEvent(
+ new CustomEvent<T>(type, {
+ detail,
+ composed: false,
+ bubbles: false,
+ })
+ );
+}
+
export function fireAlert(target: EventTarget, message: string) {
- fire(target, EventType.SHOW_ALERT, {message, showDismiss: true});
+ fire(target, 'show-alert', {message, showDismiss: true});
+}
+
+export function fireError(target: EventTarget, message: string) {
+ fire(target, 'show-error', {message});
}
export function firePageError(response?: Response | null) {
if (response === null) response = undefined;
- fire(document, EventType.PAGE_ERROR, {response});
+ fire(document, 'page-error', {response});
}
export function fireServerError(response: Response, request?: FetchRequest) {
- fire(document, EventType.SERVER_ERROR, {
+ fire(document, 'server-error', {
response,
request,
});
}
export function fireNetworkError(error: Error) {
- fire(document, EventType.NETWORK_ERROR, {error});
+ fire(document, 'network-error', {error});
}
-export function fireTitleChange(target: EventTarget, title: string) {
- fire(target, EventType.TITLE_CHANGE, {title});
+export function fireTitleChange(title: string) {
+ fire(document, 'title-change', {title});
}
// TODO(milutin) - remove once new gr-dialog will do it out of the box
@@ -86,11 +105,11 @@ export function fireDialogChange(
target: EventTarget,
detail: DialogChangeEventDetail
) {
- fire(target, EventType.DIALOG_CHANGE, detail);
+ fire(target, 'dialog-change', detail);
}
export function fireIronAnnounce(target: EventTarget, text: string) {
- fire(target, EventType.IRON_ANNOUNCE, {text});
+ fire(target, 'iron-announce', {text});
}
export function fireShowTab(
@@ -100,15 +119,11 @@ export function fireShowTab(
tabState?: TabState
) {
const detail: SwitchTabEventDetail = {tab, scrollIntoView, tabState};
- fire(target, EventType.SHOW_TAB, detail);
-}
-
-export function fireCloseFixPreview(target: EventTarget, fixApplied: boolean) {
- fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
+ fire(target, 'show-tab', detail);
}
-export function fireReload(target: EventTarget, clearPatchset?: boolean) {
- fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
+export function fireReload(target: EventTarget) {
+ fire(target, 'reload', {});
}
export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
diff --git a/polygerrit-ui/app/utils/file-util.ts b/polygerrit-ui/app/utils/file-util.ts
new file mode 100644
index 0000000000..246ac20bb6
--- /dev/null
+++ b/polygerrit-ui/app/utils/file-util.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** See also Patch.java for the backend equivalent. */
+export enum FileMode {
+ /** Mode indicating an entry is a symbolic link. */
+ SYMLINK = 0o120000,
+
+ /** Mode indicating an entry is a non-executable file. */
+ REGULAR_FILE = 0o100644,
+
+ /** Mode indicating an entry is an executable file. */
+ EXECUTABLE_FILE = 0o100755,
+
+ /** Mode indicating an entry is a submodule commit in another repository. */
+ GITLINK = 0o160000,
+}
+
+export function fileModeToString(mode?: number, includeNumber = true): string {
+ const str = fileModeStr(mode);
+ const num = mode?.toString(8);
+ return `${str}${includeNumber && str ? ` (${num})` : ''}`;
+}
+
+function fileModeStr(mode?: number): string {
+ if (mode === FileMode.SYMLINK) return 'symlink';
+ if (mode === FileMode.REGULAR_FILE) return 'regular';
+ if (mode === FileMode.EXECUTABLE_FILE) return 'executable';
+ if (mode === FileMode.GITLINK) return 'gitlink';
+ return '';
+}
+
+export function expandFileMode(input?: string) {
+ if (!input) return input;
+ for (const modeNum of Object.values(FileMode) as FileMode[]) {
+ const modeStr = modeNum?.toString(8);
+ if (input.includes(modeStr)) {
+ return input.replace(modeStr, `${fileModeToString(modeNum)}`);
+ }
+ }
+ return input;
+}
diff --git a/polygerrit-ui/app/utils/file-util_test.ts b/polygerrit-ui/app/utils/file-util_test.ts
new file mode 100644
index 0000000000..aeab0268bb
--- /dev/null
+++ b/polygerrit-ui/app/utils/file-util_test.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {assert} from '@open-wc/testing';
+import '../test/common-test-setup';
+import {expandFileMode, FileMode, fileModeToString} from './file-util';
+
+suite('file-util tests', () => {
+ test('fileModeToString', () => {
+ const check = (
+ mode: number | undefined,
+ str: string,
+ includeNumber = true
+ ) => assert.equal(fileModeToString(mode, includeNumber), str);
+
+ check(undefined, '');
+ check(0, '');
+ check(1, '');
+ check(FileMode.REGULAR_FILE, 'regular', false);
+ check(FileMode.EXECUTABLE_FILE, 'executable', false);
+ check(FileMode.SYMLINK, 'symlink', false);
+ check(FileMode.GITLINK, 'gitlink', false);
+ check(FileMode.REGULAR_FILE, 'regular (100644)');
+ check(FileMode.EXECUTABLE_FILE, 'executable (100755)');
+ check(FileMode.SYMLINK, 'symlink (120000)');
+ check(FileMode.GITLINK, 'gitlink (160000)');
+ });
+
+ test('expandFileMode', () => {
+ assert.deepEqual(['asdf'].map(expandFileMode), ['asdf']);
+ assert.deepEqual(
+ ['old mode 100644', 'new mode 100755'].map(expandFileMode),
+ ['old mode regular (100644)', 'new mode executable (100755)']
+ );
+ });
+});
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index aaa35a4b65..8929e9ca28 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -153,7 +153,14 @@ export function hasVoted(label: LabelInfo, account: AccountInfo) {
return false;
}
-export function canVote(label: DetailedLabelInfo, account: AccountInfo) {
+// This method is checking labels.all from change detail,
+// that shows only permitted voting for reviewers or CC.
+// It doesn't have permitted votes for owner. You
+// can see permitted labels for logged in user in change.permitted_labels
+export function canReviewerVote(
+ label: DetailedLabelInfo,
+ account: AccountInfo
+) {
const approvalInfo = getApprovalInfo(label, account);
if (!approvalInfo) return false;
if (approvalInfo.permitted_voting_range) {
diff --git a/polygerrit-ui/app/utils/link-util.ts b/polygerrit-ui/app/utils/link-util.ts
index afc6745352..48e9c073e3 100644
--- a/polygerrit-ui/app/utils/link-util.ts
+++ b/polygerrit-ui/app/utils/link-util.ts
@@ -31,14 +31,8 @@ function getRewriteResultsFromConfig(
): RewriteResult[] {
const enabledRewrites = Object.values(repoCommentLinks).filter(
commentLinkInfo =>
- commentLinkInfo.enabled !== false &&
- (commentLinkInfo.link !== undefined || commentLinkInfo.html !== undefined)
+ commentLinkInfo.enabled !== false && commentLinkInfo.link !== undefined
);
- // Always linkify URLs starting with https?://
- enabledRewrites.push({
- match: '(https?://\\S+[\\w/])',
- link: '$1',
- });
return enabledRewrites.flatMap(rewrite => {
const regexp = new RegExp(rewrite.match, 'g');
const partialResults: RewriteResult[] = [];
@@ -118,25 +112,19 @@ function getReplacementText(
matchedText: string,
rewrite: CommentLinkInfo
): string {
- if (rewrite.link !== undefined) {
- const replacementHref = rewrite.link.startsWith('/')
- ? `${getBaseUrl()}${rewrite.link}`
- : rewrite.link;
- const regexp = new RegExp(rewrite.match, 'g');
- return matchedText.replace(
- regexp,
- createLinkTemplate(
- replacementHref,
- rewrite.text ?? '$&',
- rewrite.prefix,
- rewrite.suffix
- )
- );
- } else if (rewrite.html !== undefined) {
- return matchedText.replace(new RegExp(rewrite.match, 'g'), rewrite.html);
- } else {
- throw new Error('commentLinkInfo is not a link or html rewrite');
- }
+ const replacementHref = rewrite.link.startsWith('/')
+ ? `${getBaseUrl()}${rewrite.link}`
+ : rewrite.link;
+ const regexp = new RegExp(rewrite.match, 'g');
+ return matchedText.replace(
+ regexp,
+ createLinkTemplate(
+ replacementHref,
+ rewrite.text ?? '$&',
+ rewrite.prefix,
+ rewrite.suffix
+ )
+ );
}
function createLinkTemplate(
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts
index 1f4e894f31..e4e719b6c5 100644
--- a/polygerrit-ui/app/utils/link-util_test.ts
+++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -12,17 +12,6 @@ suite('link-util tests', () => {
}
suite('link rewrites', () => {
- test('default linking', () => {
- assert.equal(
- linkifyUrlsAndApplyRewrite('http://www.google.com', {}),
- link('http://www.google.com', 'http://www.google.com')
- );
- assert.equal(
- linkifyUrlsAndApplyRewrite('https://www.google.com', {}),
- link('https://www.google.com', 'https://www.google.com')
- );
- });
-
test('without text', () => {
assert.equal(
linkifyUrlsAndApplyRewrite('foo', {
@@ -76,56 +65,6 @@ suite('link-util tests', () => {
});
});
- suite('html rewrites', () => {
- test('basic case', () => {
- assert.equal(
- linkifyUrlsAndApplyRewrite('foo', {
- foo: {
- match: '(foo)',
- html: '<div>$1</div>',
- },
- }),
- '<div>foo</div>'
- );
- });
-
- test('only inserts', () => {
- assert.equal(
- linkifyUrlsAndApplyRewrite('foo', {
- foo: {
- match: 'foo',
- html: 'foo bar',
- },
- }),
- 'foo bar'
- );
- });
-
- test('only deletes', () => {
- assert.equal(
- linkifyUrlsAndApplyRewrite('foo bar baz', {
- bar: {
- match: 'bar',
- html: '',
- },
- }),
- 'foo baz'
- );
- });
-
- test('multiple matches', () => {
- assert.equal(
- linkifyUrlsAndApplyRewrite('foo foo', {
- foo: {
- match: '(foo)',
- html: '<div>$1</div>',
- },
- }),
- '<div>foo</div> <div>foo</div>'
- );
- });
- });
-
test('for overlapping rewrites prefer the latest ending', () => {
assert.equal(
linkifyUrlsAndApplyRewrite('foobarbaz', {
@@ -135,14 +74,14 @@ suite('link-util tests', () => {
},
foobarbaz: {
match: 'foobarbaz',
- html: '<div>foobarbaz.gov</div>',
+ link: 'foobarbaz.gov',
},
foobar: {
match: 'foobar',
link: 'foobar.gov',
},
}),
- '<div>foobarbaz.gov</div>'
+ link('foobarbaz', 'foobarbaz.gov')
);
});
@@ -155,14 +94,14 @@ suite('link-util tests', () => {
},
foobarbaz: {
match: 'foobarbaz',
- html: '<div>FooBarBaz.gov</div>',
+ link: 'FooBarBaz.gov',
},
foobar: {
match: 'barbaz',
link: 'BarBaz.gov',
},
}),
- '<div>FooBarBaz.gov</div>'
+ link('foobarbaz', 'FooBarBaz.gov')
);
});
@@ -171,18 +110,18 @@ suite('link-util tests', () => {
linkifyUrlsAndApplyRewrite('foobarbaz', {
foo: {
match: 'foo',
- html: 'FOO',
+ link: 'FOO',
},
oobarba: {
match: 'oobarba',
- html: 'OOBARBA',
+ link: 'OOBARBA',
},
baz: {
match: 'baz',
- html: 'BAZ',
+ link: 'BAZ',
},
}),
- 'FOObarBAZ'
+ `${link('foo', 'FOO')}bar${link('baz', 'BAZ')}`
);
});
@@ -191,18 +130,27 @@ suite('link-util tests', () => {
linkifyUrlsAndApplyRewrite('bugs: 123 234 345', {
bug1: {
match: '(bugs:) (\\d+)',
- html: '$1 <div>bug/$2</div>',
+ prefix: '$1 ',
+ link: 'bug/$2',
+ text: 'bug/$2',
},
bug2: {
match: '(bugs:) (\\d+) (\\d+)',
- html: '$1 $2 <div>bug/$3</div>',
+ prefix: '$1 $2 ',
+ link: 'bug/$3',
+ text: 'bug/$3',
},
bug3: {
match: '(bugs:) (\\d+) (\\d+) (\\d+)',
- html: '$1 $2 $3 <div>bug/$4</div>',
+ prefix: '$1 $2 $3 ',
+ link: 'bug/$4',
+ text: 'bug/$4',
},
}),
- 'bugs: <div>bug/123</div> <div>bug/234</div> <div>bug/345</div>'
+ `bugs: ${link('bug/123', 'bug/123')} ${link('bug/234', 'bug/234')} ${link(
+ 'bug/345',
+ 'bug/345'
+ )}`
);
});
});
diff --git a/polygerrit-ui/app/utils/lit-util.ts b/polygerrit-ui/app/utils/lit-util.ts
deleted file mode 100644
index 7ffab89bff..0000000000
--- a/polygerrit-ui/app/utils/lit-util.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-/**
- * @license
- * Copyright 2022 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {html} from 'lit';
-
-/**
- * This is a patched version of html`` to work around this Chrome bug:
- * https://bugs.chromium.org/p/v8/issues/detail?id=13190.
- *
- * The problem is that Chrome should guarantee that the TemplateStringsArray
- * is always the same instance, if the strings themselves are equal, but that
- * guarantee seems to be broken. So we are maintaining a map from
- * "concatenated strings" to TemplateStringsArray. If "concatenated strings"
- * are equal, then return the already known instance of TemplateStringsArray,
- * so html`` can use its strict equality check on it.
- */
-export class HtmlPatched {
- constructor(private readonly reporter?: (key: string) => void) {}
-
- /**
- * If `strings` are in this set, then we are sure that they are also in the
- * map, and that we will not run into the issue of "same key, but different
- * strings array". So this set allows us to optimize performance a bit, and
- * call the native html`` function early.
- */
- private readonly lookupSet = new Set<TemplateStringsArray>();
-
- private readonly lookupMap = new Map<string, TemplateStringsArray>();
-
- /**
- * Proxies lit's html`` tagges template literal. See
- * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
- * https://lit.dev/docs/libraries/standalone-templates/
- *
- * Example: If you call html`a${1}b${2}c`, then
- * ['a', 'b', 'c'] are the "strings", and 1, 2 are the "values".
- */
- html(strings: TemplateStringsArray, ...values: unknown[]) {
- if (this.lookupSet.has(strings)) {
- return this.nativeHtml(strings, ...values);
- }
-
- const key = strings.join('\0');
- const oldStrings = this.lookupMap.get(key);
-
- if (oldStrings === undefined) {
- this.lookupSet.add(strings);
- this.lookupMap.set(key, strings);
- return this.nativeHtml(strings, ...values);
- }
-
- if (oldStrings === strings) {
- return this.nativeHtml(strings, ...values);
- }
-
- // Without using HtmlPatcher html`` would be called with `strings`,
- // which will be considered different, although actually being equal.
- console.warn(`HtmlPatcher was required for '${key.substring(0, 100)}'.`);
- this.reporter?.(key);
- return this.nativeHtml(oldStrings, ...values);
- }
-
- // Allows spying on calls in tests.
- nativeHtml(strings: TemplateStringsArray, ...values: unknown[]) {
- return html(strings, ...values);
- }
-}
diff --git a/polygerrit-ui/app/utils/lit-util_test.ts b/polygerrit-ui/app/utils/lit-util_test.ts
deleted file mode 100644
index 17197f0353..0000000000
--- a/polygerrit-ui/app/utils/lit-util_test.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import {assert} from '@open-wc/testing';
-import '../test/common-test-setup';
-import {HtmlPatched} from './lit-util';
-
-function tsa(strings: string[]): TemplateStringsArray {
- return strings as unknown as TemplateStringsArray;
-}
-
-suite('lit-util HtmlPatched tests', () => {
- let patched: HtmlPatched;
- let nativeHtmlSpy: sinon.SinonSpy;
- let reporterSpy: sinon.SinonSpy;
-
- setup(async () => {
- reporterSpy = sinon.spy();
- patched = new HtmlPatched(reporterSpy);
- nativeHtmlSpy = sinon.spy(patched, 'nativeHtml');
- });
-
- test('simple call', () => {
- const instance1 = tsa(['1']);
- patched.html(instance1, 'a value');
- assert.equal(nativeHtmlSpy.callCount, 1);
- assert.equal(reporterSpy.callCount, 0);
- assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[0], instance1);
- assert.strictEqual(nativeHtmlSpy.getCalls()[0].args[1], 'a value');
- });
-
- test('two calls, same instance', () => {
- const instance1 = tsa(['1']);
- patched.html(instance1, 'a value');
- patched.html(instance1, 'a value');
- assert.equal(nativeHtmlSpy.callCount, 2);
- assert.equal(reporterSpy.callCount, 0);
- assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
- assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
- });
-
- test('two calls, different strings', () => {
- const instance1 = tsa(['1']);
- const instance2 = tsa(['2']);
- patched.html(instance1, 'a value');
- patched.html(instance2, 'a value');
- assert.equal(nativeHtmlSpy.callCount, 2);
- assert.equal(reporterSpy.callCount, 0);
- assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
- assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance2);
- });
-
- test('two calls, same strings, different instances', () => {
- const instance1 = tsa(['1']);
- const instance2 = tsa(['1']);
- patched.html(instance1, 'a value');
- patched.html(instance2, 'a value');
- assert.equal(nativeHtmlSpy.callCount, 2);
- assert.equal(reporterSpy.callCount, 1);
- assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1);
- assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1);
- });
-
- test('many calls', () => {
- const instance1a = tsa(['1']);
- const instance1b = tsa(['1']);
- const instance1c = tsa(['1']);
- const instance2a = tsa(['asdf', 'qwer']);
- const instance2b = tsa(['asdf', 'qwer']);
- const instance2c = tsa(['asdf', 'qwer']);
- const instance3a = tsa(['asd', 'fqwer']);
- const instance3b = tsa(['asd', 'fqwer']);
- const instance3c = tsa(['asd', 'fqwer']);
-
- patched.html(instance1a, 'a value');
- patched.html(instance1a, 'a value');
- patched.html(instance1b, 'a value');
- patched.html(instance1b, 'a value');
- patched.html(instance1c, 'a value');
- patched.html(instance1c, 'a value');
- patched.html(instance2a, 'a value');
- patched.html(instance2a, 'a value');
- patched.html(instance2b, 'a value');
- patched.html(instance2b, 'a value');
- patched.html(instance2c, 'a value');
- patched.html(instance2c, 'a value');
- patched.html(instance3a, 'a value');
- patched.html(instance3a, 'a value');
- patched.html(instance3b, 'a value');
- patched.html(instance3b, 'a value');
- patched.html(instance3c, 'a value');
- patched.html(instance3c, 'a value');
-
- assert.equal(nativeHtmlSpy.callCount, 18);
- assert.equal(reporterSpy.callCount, 12);
-
- assert.strictEqual(nativeHtmlSpy.getCalls()[0].firstArg, instance1a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[1].firstArg, instance1a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[2].firstArg, instance1a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[3].firstArg, instance1a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[4].firstArg, instance1a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[5].firstArg, instance1a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[6].firstArg, instance2a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[7].firstArg, instance2a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[8].firstArg, instance2a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[9].firstArg, instance2a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[10].firstArg, instance2a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[11].firstArg, instance2a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[12].firstArg, instance3a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[13].firstArg, instance3a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[14].firstArg, instance3a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[15].firstArg, instance3a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[16].firstArg, instance3a);
- assert.strictEqual(nativeHtmlSpy.getCalls()[17].firstArg, instance3a);
- });
-});
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts
new file mode 100644
index 0000000000..22a5e4d638
--- /dev/null
+++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * Copyright 2023 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {getRevertCreatedChangeIds} from './message-util';
+import {assert} from '@open-wc/testing';
+import {MessageTag} from '../constants/constants';
+import {ChangeId, ReviewInputTag} from '../api/rest-api';
+import {createChangeMessage} from '../test/test-data-generators';
+
+suite('message-util tests', () => {
+ test('getRevertCreatedChangeIds', () => {
+ const messages = [
+ {
+ ...createChangeMessage(),
+ message: 'Created a revert of this change as 123',
+ tag: MessageTag.TAG_REVERT as ReviewInputTag,
+ },
+ {
+ ...createChangeMessage(),
+ message: 'Created a revert of this change as xyz',
+ tag: MessageTag.TAG_REVERT as ReviewInputTag,
+ },
+ {
+ ...createChangeMessage(),
+ message: 'Created a revert of this change as abc',
+ tag: undefined,
+ },
+ ];
+
+ assert.deepEqual(getRevertCreatedChangeIds(messages), [
+ '123' as ChangeId,
+ 'xyz' as ChangeId,
+ ]);
+ });
+});
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
deleted file mode 100644
index 78e78edce2..0000000000
--- a/polygerrit-ui/app/utils/page-wrapper-utils.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * @license
- * Copyright 2020 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-// @ts-ignore: Bazel is not yet configured to download the types
-import pagejs from 'page';
-
-// Reexport page.js. To make it work rollup patches page.js and replace "this"
-// to "window". Otherwise, it can't assign global property. We can't import
-// page.mjs because typescript doesn't support mjs extensions
-export interface Page {
- (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
- (pageCallback: PageCallback): void;
- show(url: string): void;
- redirect(url: string): void;
- replace(path: string, state: null, init: boolean, dispatch: boolean): void;
- base(url: string): void;
- start(): void;
- exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
-}
-
-// See https://visionmedia.github.io/page.js/ for details
-export interface PageContext {
- canonicalPath: string;
- path: string;
- querystring: string;
- pathname: string;
- hash: string;
- params: {[paramIndex: string]: string};
-}
-
-export type PageNextCallback = () => void;
-
-export type PageCallback = (
- context: PageContext,
- next: PageNextCallback
-) => void;
-
-// TODO: Convert page usages to the real types and remove this file of wrapper
-// types. Also remove workarounds in rollup config.
-export const page = pagejs as unknown as Page;
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 6c46921c15..7f3b6ebece 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -35,11 +35,6 @@ export interface PatchSet {
wip?: boolean;
}
-interface PatchRange {
- patchNum?: RevisionPatchSetNum;
- basePatchNum?: BasePatchSetNum;
-}
-
/**
* Whether the given patch is a numbered parent of a merge (i.e. a negative
* number).
@@ -64,7 +59,7 @@ export function isPatchSetNum(patchset: string) {
export function convertToPatchSetNum(
patchset: string | undefined
): PatchSetNum | undefined {
- if (patchset === undefined) return patchset;
+ if (!patchset) return undefined;
if (!isPatchSetNum(patchset)) {
console.error('string is not of type PatchSetNum');
}
@@ -206,7 +201,7 @@ export function computeAllPatchSets(
};
});
}
- return _computeWipForPatchSets(change, patchNums);
+ return computeWipForPatchSets(change, patchNums);
}
/**
@@ -218,7 +213,7 @@ export function computeAllPatchSets(
* @return The given list of patch set objects, with the
* wip property set on each of them
*/
-function _computeWipForPatchSets(
+function computeWipForPatchSets(
change: ChangeInfo | ParsedChangeInfo,
patchNums: PatchSet[]
) {
@@ -249,7 +244,7 @@ function _computeWipForPatchSets(
return patchNums;
}
-export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
+export const _testOnly_computeWipForPatchSets = computeWipForPatchSets;
export function computeLatestPatchNum(
allPatchSets?: PatchSet[]
@@ -294,10 +289,6 @@ export function hasEditBasedOnCurrentPatchSet(
return allPatchSets[0].num === EDIT;
}
-export function hasEditPatchsetLoaded(patchRange: PatchRange) {
- return patchRange.patchNum === EDIT;
-}
-
/**
* @param revisions A sorted array of revisions.
*
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 11161239b7..b007d4772e 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {SpecialFilePath, FileInfoStatus} from '../constants/constants';
-import {FileInfo} from '../types/common';
+import {FileInfo, FileNameToFileInfoMap} from '../types/common';
import {hasOwnProperty} from './common-util';
export function specialFilePathCompare(a: string, b: string) {
@@ -55,7 +55,7 @@ export function shouldHideFile(file: string) {
// In case there are files with comments on them but they are unchanged, then
// we explicitly displays the file to render the comments with Unchanged status
export function addUnmodifiedFiles(
- files: {[filename: string]: FileInfo},
+ files: FileNameToFileInfoMap,
commentedPaths: {[fileName: string]: boolean}
) {
if (!commentedPaths) return;
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
index 50f5c0e4a8..cdd8182a98 100644
--- a/polygerrit-ui/app/utils/path-list-util_test.ts
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -12,9 +12,9 @@ import {
specialFilePathCompare,
truncatePath,
} from './path-list-util';
-import {FileInfo} from '../api/rest-api';
import {hasOwnProperty} from './common-util';
import {assert} from '@open-wc/testing';
+import {FileNameToFileInfoMap} from '../types/common';
suite('path-list-utl tests', () => {
test('special sort', () => {
@@ -117,7 +117,7 @@ suite('path-list-utl tests', () => {
'file1.txt': true,
};
- const files: {[filename: string]: FileInfo} = {
+ const files: FileNameToFileInfoMap = {
'file2.txt': {
status: FileInfoStatus.REWRITTEN,
size_delta: 10,
@@ -144,7 +144,7 @@ suite('path-list-utl tests', () => {
assert.equal(shortenedPath, expectedPath);
});
- test('truncatePath with opt_threshold', () => {
+ test('truncatePath with threshold', () => {
let path = 'level1/level2/level3/level4/file.js';
let shortenedPath = truncatePath(path, 2);
// The expected path is truncated with an ellipsis.
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index b6f1ad14f6..81dcde175b 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -14,14 +14,18 @@ export function pluralize(count: number, noun: string): string {
return `${count} ${noun}` + (count > 1 ? 's' : '');
}
-export function addQuotesWhen(string: string, cond: boolean): string {
- return cond ? `"${string}"` : string;
-}
-
export function charsOnly(s: string): string {
return s.replace(/[^a-zA-Z]+/g, '');
}
+export function isCharacterLetter(ch: string): boolean {
+ return ch.length === 1 && ch.toLowerCase() !== ch.toUpperCase();
+}
+
+export function isUpperCase(ch: string): boolean {
+ return ch === ch.toUpperCase();
+}
+
export function ordinal(n?: number): string {
if (n === undefined) return '';
if (n % 10 === 1 && n % 100 !== 11) return `${n}st`;
@@ -30,6 +34,15 @@ export function ordinal(n?: number): string {
return `${n}th`;
}
+/** Escape operator value to avoid affecting overall query.
+ *
+ * Escapes quotes (") and backslashes (\). Wraps in quotes so the value can
+ * contain spaces and colons.
+ */
+export function escapeAndWrapSearchOperatorValue(value: string): string {
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
+}
+
/**
* This converts any inputed value into string.
*
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index c6c65b13bf..d6c4187280 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -10,6 +10,7 @@ import {
ordinal,
listForSentence,
diffFilePaths,
+ escapeAndWrapSearchOperatorValue,
} from './string-util';
suite('string-util tests', () => {
@@ -84,4 +85,11 @@ suite('string-util tests', () => {
fileName: 'COMMIT_MSG',
});
});
+
+ test('escapeAndWrapSearchOperatorValue', () => {
+ assert.equal(
+ escapeAndWrapSearchOperatorValue('"value of \\: \\"something"'),
+ '"\\"value of \\\\: \\\\\\"something\\""'
+ );
+ });
});
diff --git a/polygerrit-ui/app/utils/submit-requirement-util.ts b/polygerrit-ui/app/utils/submit-requirement-util.ts
index 6672712d5a..ff99b7ff00 100644
--- a/polygerrit-ui/app/utils/submit-requirement-util.ts
+++ b/polygerrit-ui/app/utils/submit-requirement-util.ts
@@ -3,10 +3,7 @@
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-
import {SubmitRequirementExpressionInfo} from '../api/rest-api';
-import {Execution} from '../constants/reporting';
-import {getAppContext} from '../services/app-context';
export enum SubmitRequirementExpressionAtomStatus {
UNKNOWN = 'UNKNOWN',
@@ -55,13 +52,8 @@ function splitExpressionIntoParts(
const result: SubmitRequirementExpressionPart[] = [];
let currentIndex = 0;
for (const {start, end, isPassing} of matchedAtoms) {
- if (start < currentIndex) {
- getAppContext().reportingService.reportExecution(
- Execution.REACHABLE_CODE,
- 'Overlapping atom matches in submit requirement expression.'
- );
- continue;
- }
+ // We don't handle overlapping matches, but this can happen.
+ if (start < currentIndex) continue;
if (start > currentIndex) {
result.push({
value: expression.slice(currentIndex, start),
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 739d04b90c..2f9bc0cca3 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -8,45 +8,15 @@ import {
BasePatchSetNum,
PARENT,
RevisionPatchSetNum,
- ServerInfo,
} from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
import {AuthType} from '../api/rest-api';
-const PROBE_PATH = '/Documentation/index.html';
-const DOCS_BASE_PATH = '/Documentation';
-
export function getBaseUrl(): string {
// window is not defined in service worker, therefore no CANONICAL_PATH
if (typeof window === 'undefined') return '';
return self.CANONICAL_PATH || '';
}
-export interface PatchRangeParams {
- patchNum?: RevisionPatchSetNum;
- basePatchNum?: BasePatchSetNum;
-}
-
-export function rootUrl() {
- return `${getBaseUrl()}/`;
-}
-
-/**
- * Given an object of parameters, potentially including a `patchNum` or a
- * `basePatchNum` or both, return a string representation of that range. If
- * no range is indicated in the params, the empty string is returned.
- */
-export function getPatchRangeExpression(params: PatchRangeParams) {
- let range = '';
- if (params.patchNum) {
- range = `${params.patchNum}`;
- }
- if (params.basePatchNum && params.basePatchNum !== PARENT) {
- range = `${params.basePatchNum}..${range}`;
- }
- return range;
-}
-
/**
* Return the url to use for login. If the server configuration
* contains the `loginUrl` in the `auth` section then that custom url
@@ -78,58 +48,95 @@ export function loginUrl(authConfig: AuthInfo | undefined): string {
}
}
-function sanitizeRelativeUrl(relativeUrl: string): string {
- return relativeUrl.startsWith('/') ? relativeUrl : `/${relativeUrl}`;
+export interface PatchRangeParams {
+ patchNum?: RevisionPatchSetNum;
+ basePatchNum?: BasePatchSetNum;
}
-export function prependOrigin(path: string): string {
- if (path.startsWith('http')) return path;
- if (path.startsWith('/')) return window.location.origin + path;
- throw new Error(`Cannot prepend origin to relative path '${path}'.`);
+export function rootUrl() {
+ return `${getBaseUrl()}/`;
}
-let getDocsBaseUrlCachedPromise: Promise<string | null> | undefined;
-
/**
- * Get the docs base URL from either the server config or by probing.
- *
- * @return A promise that resolves with the docs base URL.
+ * Given an object of parameters, potentially including a `patchNum` or a
+ * `basePatchNum` or both, return a string representation of that range. If
+ * no range is indicated in the params, the empty string is returned.
*/
-export function getDocsBaseUrl(
- config: ServerInfo | undefined,
- restApi: RestApiService
-): Promise<string | null> {
- if (!getDocsBaseUrlCachedPromise) {
- getDocsBaseUrlCachedPromise = new Promise(resolve => {
- if (config?.gerrit?.doc_url) {
- resolve(config.gerrit.doc_url);
- } else {
- restApi.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
- resolve(ok ? getBaseUrl() + DOCS_BASE_PATH : null);
- });
- }
- });
+export function getPatchRangeExpression(params: PatchRangeParams) {
+ let range = '';
+ if (params.patchNum) {
+ range = `${params.patchNum}`;
}
- return getDocsBaseUrlCachedPromise;
+ if (params.basePatchNum && params.basePatchNum !== PARENT) {
+ range = `${params.basePatchNum}..${range}`;
+ }
+ return range;
}
-export function _testOnly_clearDocsBaseUrlCache() {
- getDocsBaseUrlCachedPromise = undefined;
+function sanitizeRelativeUrl(relativeUrl: string): string {
+ return relativeUrl.startsWith('/') ? relativeUrl : `/${relativeUrl}`;
+}
+
+export function prependOrigin(path: string): string {
+ if (path.startsWith('http')) return path;
+ if (path.startsWith('/')) return window.location.origin + path;
+ throw new Error(`Cannot prepend origin to relative path '${path}'.`);
}
/**
- * Pretty-encodes a URL. Double-encodes the string, and then replaces
- * benevolent characters for legibility.
+ * Encodes *parts* of a URL. See inline comments below for the details.
+ * Note specifically that ? & = # are encoded. So this is very close to
+ * encodeURIComponent() with some tweaks.
*/
-export function encodeURL(url: string, replaceSlashes?: boolean): string {
- // @see Issue 4255 regarding double-encoding.
- let output = encodeURIComponent(encodeURIComponent(url));
- // @see Issue 4577 regarding more readable URLs.
- output = output.replace(/%253A/g, ':');
- output = output.replace(/%2520/g, '+');
- if (replaceSlashes) {
- output = output.replace(/%252F/g, '/');
- }
+export function encodeURL(url: string): string {
+ // gr-page decodes the entire URL, and then decodes once more the
+ // individual regex matching groups. It uses `decodeURIComponent()`, which
+ // will choke on singular `%` chars without two trailing digits. We prefer
+ // to not double encode *everything* (just for readaiblity and simplicity),
+ // but `%` *must* be double encoded.
+ let output = url.replaceAll('%', '%25');
+ // `+` also requires double encoding, because `%2B` would be decoded to `+`
+ // and then replaced by ` `.
+ output = output.replaceAll('+', '%2B');
+
+ // This escapes ALL characters EXCEPT:
+ // A–Z a–z 0–9 - _ . ! ~ * ' ( )
+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
+ output = encodeURIComponent(output);
+
+ // If we would use `encodeURI()` instead of `encodeURIComponent()`, then we
+ // would also NOT encode:
+ // ; / ? : @ & = + $ , #
+ //
+ // That would be more readable, but for example ? and & have special meaning
+ // in the URL, so they must be encoded. Let's discuss all these chars and
+ // decide whether we have to encode them or not.
+ //
+ // ? & = # have to be encoded. Otherwise we might mess up the URL.
+ //
+ // : @ do not have to be encoded, because we are only dealing with path,
+ // query and fragment of the URL, not with scheme, user, host, port.
+ // For search queries it is much nicer to not encode those chars, think of
+ // searching for `owner:spearce@spearce.org`.
+ //
+ // / does not have to be encoded, because we don't care about individual path
+ // components. File path and repo names are so much nicer to read without /
+ // being encoded!
+ //
+ // + must be encoded, because we want to use it instead of %20 for spaces, see
+ // below.
+ //
+ // ; $ , probably don't have to be encoded, but we don't bother about them
+ // much, so we don't reverse the encoding here, but we don't think it would
+ // cause any harm, if we did.
+ output = output.replace(/%3A/g, ':');
+ output = output.replace(/%40/g, '@');
+ output = output.replace(/%2F/g, '/');
+
+ // gr-page replaces `+` by ` ` in addition to calling `decodeURIComponent()`.
+ // So we can use `+` to increase readability.
+ output = output.replace(/%20/g, '+');
+
return output;
}
@@ -137,6 +144,10 @@ export function encodeURL(url: string, replaceSlashes?: boolean): string {
* Single decode for URL components. Will decode plus signs ('+') to spaces.
* Note: because this function decodes once, it is not the inverse of
* encodeURL.
+ *
+ * This function must only be used for decoding data returned by the REST API.
+ * Don't use it for decoding browser URLs. The only place for decoding browser
+ * URLs must gr-page.ts.
*/
export function singleDecodeURL(url: string): string {
const withoutPlus = url.replace(/\+/g, '%20');
diff --git a/polygerrit-ui/app/utils/url-util_test.ts b/polygerrit-ui/app/utils/url-util_test.ts
index 826268659b..e2ca617c7d 100644
--- a/polygerrit-ui/app/utils/url-util_test.ts
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -3,37 +3,23 @@
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {
- AuthType,
- BasePatchSetNum,
- RevisionPatchSetNum,
- ServerInfo,
-} from '../api/rest-api';
+import {AuthType, BasePatchSetNum, RevisionPatchSetNum} from '../api/rest-api';
import '../test/common-test-setup';
import {
- createAuth,
- createGerritInfo,
- createServerInfo,
-} from '../test/test-data-generators';
-import {
- getBaseUrl,
- getDocsBaseUrl,
- _testOnly_clearDocsBaseUrlCache,
encodeURL,
+ getBaseUrl,
+ getPatchRangeExpression,
+ loginUrl,
+ PatchRangeParams,
singleDecodeURL,
toPath,
toPathname,
toSearchParams,
- getPatchRangeExpression,
- PatchRangeParams,
- loginUrl,
} from './url-util';
-import {getAppContext, AppContext} from '../services/app-context';
-import {stubRestApi} from '../test/test-utils';
import {assert} from '@open-wc/testing';
+import {createAuth} from '../test/test-data-generators';
suite('url-util tests', () => {
- let appContext: AppContext;
suite('getBaseUrl tests', () => {
let originalCanonicalPath: string | undefined;
@@ -53,7 +39,6 @@ suite('url-util tests', () => {
suite('loginUrl tests', () => {
const authConfig = createAuth();
- const customLoginUrl = '/custom';
test('default url if auth.loginUrl is not defined', () => {
const current = encodeURIComponent(
@@ -71,8 +56,9 @@ suite('url-util tests', () => {
window.location.search +
window.location.hash
);
-
+ const customLoginUrl = '/custom';
authConfig.login_url = customLoginUrl;
+
authConfig.auth_type = AuthType.LDAP;
assert.deepEqual(loginUrl(authConfig), defaultUrl);
authConfig.auth_type = AuthType.OPENID_SSO;
@@ -82,7 +68,9 @@ suite('url-util tests', () => {
});
test('use auth.loginUrl when defined', () => {
+ const customLoginUrl = '/custom';
authConfig.login_url = customLoginUrl;
+
authConfig.auth_type = AuthType.HTTP;
assert.deepEqual(loginUrl(authConfig), customLoginUrl);
authConfig.auth_type = AuthType.HTTP_LDAP;
@@ -96,84 +84,26 @@ suite('url-util tests', () => {
});
});
- suite('getDocsBaseUrl tests', () => {
- setup(() => {
- _testOnly_clearDocsBaseUrlCache();
- appContext = getAppContext();
- });
-
- test('null config', async () => {
- const probePathMock = stubRestApi('probePath').resolves(true);
- const docsBaseUrl = await getDocsBaseUrl(
- undefined,
- appContext.restApiService
- );
- assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
- assert.equal(docsBaseUrl, '/Documentation');
- });
-
- test('no doc config', async () => {
- const probePathMock = stubRestApi('probePath').resolves(true);
- const config: ServerInfo = {
- ...createServerInfo(),
- gerrit: createGerritInfo(),
- };
- const docsBaseUrl = await getDocsBaseUrl(
- config,
- appContext.restApiService
- );
- assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
- assert.equal(docsBaseUrl, '/Documentation');
- });
-
- test('has doc config', async () => {
- const probePathMock = stubRestApi('probePath').resolves(true);
- const config: ServerInfo = {
- ...createServerInfo(),
- gerrit: {...createGerritInfo(), doc_url: 'foobar'},
- };
- const docsBaseUrl = await getDocsBaseUrl(
- config,
- appContext.restApiService
- );
- assert.isFalse(probePathMock.called);
- assert.equal(docsBaseUrl, 'foobar');
- });
-
- test('no probe', async () => {
- const probePathMock = stubRestApi('probePath').resolves(false);
- const docsBaseUrl = await getDocsBaseUrl(
- undefined,
- appContext.restApiService
- );
- assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
- assert.isNotOk(docsBaseUrl);
- });
- });
-
suite('url encoding and decoding tests', () => {
suite('encodeURL', () => {
- test('double encodes', () => {
- assert.equal(encodeURL('abc?123'), 'abc%253F123');
- assert.equal(encodeURL('def/ghi'), 'def%252Fghi');
- assert.equal(encodeURL('jkl'), 'jkl');
- assert.equal(encodeURL(''), '');
+ test('does not encode alphanumeric chars', () => {
+ assert.equal(encodeURL("AZaz09-_.!~*'()"), "AZaz09-_.!~*'()");
});
- test('does not convert colons', () => {
- assert.equal(encodeURL('mno:pqr'), 'mno:pqr');
+ test('double encodes %', () => {
+ assert.equal(encodeURL('abc%def'), 'abc%2525def');
});
- test('converts spaces to +', () => {
- assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
+ test('double encodes +', () => {
+ assert.equal(encodeURL('abc+def'), 'abc%252Bdef');
});
- test('does not convert slashes when configured', () => {
- assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
+ test('does not encode colon and slash', () => {
+ assert.equal(encodeURL(':/'), ':/');
});
- test('does not convert slashes when configured', () => {
- assert.equal(encodeURL('stu/vwx', true), 'stu/vwx');
+ test('encodes spaces as +', () => {
+ assert.equal(encodeURL('words with spaces'), 'words+with+spaces');
});
});
diff --git a/polygerrit-ui/app/utils/weblink-util.ts b/polygerrit-ui/app/utils/weblink-util.ts
index 1e9315ce55..17ad44bdb7 100644
--- a/polygerrit-ui/app/utils/weblink-util.ts
+++ b/polygerrit-ui/app/utils/weblink-util.ts
@@ -3,51 +3,26 @@
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
-import {CommitId, ServerInfo} from '../api/rest-api';
-
-export interface WebLink {
- name?: string;
- label: string;
- url: string;
-}
-
-export interface GeneratedWebLink {
- name?: string;
- label?: string;
- url?: string;
-}
-
-export function getPatchSetWeblink(
- commit?: CommitId,
- weblinks?: GeneratedWebLink[],
- config?: ServerInfo
-): GeneratedWebLink | undefined {
- if (!commit) return undefined;
- const name = commit.slice(0, 7);
- const weblink = getBrowseCommitWeblink(weblinks, config);
- if (!weblink?.url) return {name};
- return {name, url: weblink.url};
-}
+import {ServerInfo, WebLinkInfo} from '../api/rest-api';
// visible for testing
-export function getCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
+export function getCodeBrowserWeblink(weblinks: WebLinkInfo[]) {
// is an ordered allowed list of web link types that provide direct
// links to the commit in the url property.
- const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+ const codeBrowserLinks = ['gitiles', 'browse', 'gitweb', 'code search'];
for (let i = 0; i < codeBrowserLinks.length; i++) {
const weblink = weblinks.find(
- weblink => weblink.name === codeBrowserLinks[i]
+ weblink => weblink.name?.toLowerCase() === codeBrowserLinks[i]
);
if (weblink) return weblink;
}
return undefined;
}
-// visible for testing
export function getBrowseCommitWeblink(
- weblinks?: GeneratedWebLink[],
+ weblinks?: WebLinkInfo[],
config?: ServerInfo
-): GeneratedWebLink | undefined {
+): WebLinkInfo | undefined {
if (!weblinks) return undefined;
// Use primary weblink if configured and exists.
@@ -61,9 +36,9 @@ export function getBrowseCommitWeblink(
}
export function getChangeWeblinks(
- weblinks?: GeneratedWebLink[],
+ weblinks?: WebLinkInfo[],
config?: ServerInfo
-): GeneratedWebLink[] {
+): WebLinkInfo[] {
if (!weblinks?.length) return [];
const commitWeblink = getBrowseCommitWeblink(weblinks, config);
return weblinks.filter(
diff --git a/polygerrit-ui/app/utils/weblink-util_test.ts b/polygerrit-ui/app/utils/weblink-util_test.ts
index be97cfd2fa..63842e21f7 100644
--- a/polygerrit-ui/app/utils/weblink-util_test.ts
+++ b/polygerrit-ui/app/utils/weblink-util_test.ts
@@ -16,17 +16,20 @@ suite('weblink util tests', () => {
test('getCodeBrowserWeblink', () => {
assert.deepEqual(
getCodeBrowserWeblink([
- {name: 'gitweb'},
- {name: 'gitiles'},
- {name: 'browse'},
- {name: 'test'},
+ {name: 'gitweb', url: 'http://www.test.com'},
+ {name: 'gitiles', url: 'http://www.test.com'},
+ {name: 'browse', url: 'http://www.test.com'},
+ {name: 'test', url: 'http://www.test.com'},
]),
- {name: 'gitiles'}
+ {name: 'gitiles', url: 'http://www.test.com'}
);
assert.deepEqual(
- getCodeBrowserWeblink([{name: 'gitweb'}, {name: 'test'}]),
- {name: 'gitweb'}
+ getCodeBrowserWeblink([
+ {name: 'gitweb', url: 'http://www.test.com'},
+ {name: 'test', url: 'http://www.test.com'},
+ ]),
+ {name: 'gitweb', url: 'http://www.test.com'}
);
});
diff --git a/polygerrit-ui/app/workers/service-worker-class.ts b/polygerrit-ui/app/workers/service-worker-class.ts
index 218744db86..03b6b902bc 100644
--- a/polygerrit-ui/app/workers/service-worker-class.ts
+++ b/polygerrit-ui/app/workers/service-worker-class.ts
@@ -18,6 +18,7 @@ import {
} from './service-worker-indexdb';
import {createDashboardUrl} from '../models/views/dashboard';
import {createChangeUrl} from '../models/views/change';
+import {noAwait} from '../utils/async-util';
export class ServiceWorker {
constructor(
@@ -130,16 +131,22 @@ export class ServiceWorker {
// User can have different service workers for different origins/hosts.
// TODO(milutin): Check if this works properly with getBaseUrl()
const data = {url: `${self.location.origin}${changeUrl}`};
-
- // TODO(milutin): Add gerrit host icon
- this.ctx.registration.showNotification(change.subject, {body, data});
+ const icon = `${self.location.origin}/favicon.ico`;
+ this.ctx.registration.showNotification(change.subject, {
+ body,
+ data,
+ icon,
+ });
+ this.sendReport('notify about 1 change');
}
private showNotificationForDashboard(numOfChangesToNotifyAbout: number) {
- const title = `You are in the attention set for ${numOfChangesToNotifyAbout} changes.`;
+ const title = `You are in the attention set for ${numOfChangesToNotifyAbout} new changes.`;
const dashboardUrl = createDashboardUrl({});
const data = {url: `${self.location.origin}${dashboardUrl}`};
- this.ctx.registration.showNotification(title, {data});
+ const icon = `${self.location.origin}/favicon.ico`;
+ this.ctx.registration.showNotification(title, {data, icon});
+ this.sendReport(`notify about ${numOfChangesToNotifyAbout} changes`);
}
// private but used in test
@@ -154,6 +161,7 @@ export class ServiceWorker {
const prevLatestUpdateTimestampMs = this.latestUpdateTimestampMs;
this.latestUpdateTimestampMs = Date.now();
await this.saveState();
+ noAwait(this.sendReport('polling'));
const changes = await this.getLatestAttentionSetChanges();
const latestAttentionChanges = filterAttentionChangesAfter(
changes,
@@ -173,4 +181,19 @@ export class ServiceWorker {
const changes = payload.parsed as unknown as ParsedChangeInfo[] | undefined;
return changes ?? [];
}
+
+ /**
+ * Send report event to 1 client (last focused one). The client will use
+ * gr-reporting service to send event to metric event collectors.
+ */
+ async sendReport(eventName: string) {
+ const clientsArr = await this.ctx.clients.matchAll({type: 'window'});
+ const lastFocusedClient = clientsArr?.[0];
+ if (!lastFocusedClient) return;
+
+ lastFocusedClient.postMessage({
+ type: ServiceWorkerMessageType.REPORTING,
+ eventName,
+ });
+ }
}
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts
index 4cbd7bbe14..33a19d9d65 100644
--- a/polygerrit-ui/app/workers/service-worker-class_test.ts
+++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -105,7 +105,7 @@ suite('service worker class tests', () => {
assert.isTrue(showNotificationMock.calledOnce);
assert.isTrue(
showNotificationMock.calledWithMatch(
- 'You are in the attention set for 2 changes.'
+ 'You are in the attention set for 2 new changes.'
)
);
});
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index c8375ae0c4..e056a357d0 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -161,7 +161,7 @@
dependencies:
"@polymer/polymer" "^3.0.0"
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
@@ -520,11 +520,6 @@ code-point-at@^1.0.0:
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
-codemirror-minified@^5.65.0:
- version "5.65.0"
- resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.65.0.tgz#283f21655d6fc3477e64532c86a657bbc2063c19"
- integrity sha512-AxpxR5XolsvgAjwE1BspomW6fhj541BxMyj0HT5TmeketKJ/kPSEiTZes/cQgHvHOmGB4clbR67Mz/ORrjYkMQ==
-
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -672,11 +667,6 @@ is-fullwidth-code-point@^2.0.0:
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
-isarray@0.0.1:
- version "0.0.1"
- resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
- integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -808,25 +798,11 @@ once@^1.3.0, once@^1.3.1:
dependencies:
wrappy "1"
-page@^1.11.6:
- version "1.11.6"
- resolved "https://registry.yarnpkg.com/page/-/page-1.11.6.tgz#5ef4efc7073749b8085ccdaa0dcd7c9e0de12fe3"
- integrity sha512-P6e2JfzkBrPeFCIPplLP7vDDiU84RUUZMrWdsH4ZBGJ8OosnwFkcUkBHp1DTIjuipLliw9yQn/ZJsXZvarsO+g==
- dependencies:
- path-to-regexp "~1.2.1"
-
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
-path-to-regexp@~1.2.1:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.2.1.tgz#b33705c140234d873c8721c7b9fd8b541ed3aff9"
- integrity sha1-szcFwUAjTYc8hyHHuf2LVB7Tr/k=
- dependencies:
- isarray "0.0.1"
-
"polymer-bridges@file:../../polymer-bridges":
version "1.0.0"
@@ -988,10 +964,10 @@ util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
-web-vitals@^2.1.4:
- version "2.1.4"
- resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-2.1.4.tgz#76563175a475a5e835264d373704f9dde718290c"
- integrity sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==
+web-vitals@^3.0.0:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.1.1.tgz#bb124a03df7a135617f495c5bb7dbc30ecf2cce3"
+ integrity sha512-qvllU+ZeQChqzBhZ1oyXmWsjJ8a2jHYpH8AMaVuf29yscOPZfTQTjQFRX6+eADTdsDE8IanOZ0cetweHMs8/2A==
webidl-conversions@^3.0.0:
version "3.0.1"
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 1287d0c4fa..6fa4d0f57d 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -11,6 +11,7 @@
"@open-wc/testing": "^3.1.6",
"@web/dev-server-esbuild": "^0.3.2",
"@web/test-runner": "^0.14.0",
+ "@web/test-runner-playwright": "^0.9.0",
"@web/test-runner-visual-regression": "^0.6.6",
"accessibility-developer-tools": "^2.12.0",
"karma": "^6.3.20",
@@ -25,6 +26,7 @@
"test": "web-test-runner",
"test:screenshot": "web-test-runner --run-screenshots",
"test:screenshot-update": "web-test-runner --update-screenshots --files",
+ "test:browsers": "web-test-runner --playwright --browsers webkit firefox chromium",
"test:coverage": "web-test-runner --coverage",
"test:watch": "web-test-runner --watch",
"test:single": "web-test-runner --watch --files",
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index ca6943d9f7..35409a87d8 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -965,7 +965,7 @@
"@jridgewell/sourcemap-codec" "^1.4.10"
"@jridgewell/trace-mapping" "^0.3.9"
-"@jridgewell/resolve-uri@^3.0.3":
+"@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78"
integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==
@@ -975,11 +975,19 @@
resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
-"@jridgewell/sourcemap-codec@^1.4.10":
+"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10":
version "1.4.14"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24"
integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==
+"@jridgewell/trace-mapping@^0.3.12":
+ version "0.3.17"
+ resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985"
+ integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==
+ dependencies:
+ "@jridgewell/resolve-uri" "3.1.0"
+ "@jridgewell/sourcemap-codec" "1.4.14"
+
"@jridgewell/trace-mapping@^0.3.9":
version "0.3.15"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.15.tgz#aba35c48a38d3fd84b37e66c9c0423f9744f9774"
@@ -1883,6 +1891,16 @@
picomatch "^2.2.2"
v8-to-istanbul "^8.0.0"
+"@web/test-runner-coverage-v8@^0.5.0":
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/@web/test-runner-coverage-v8/-/test-runner-coverage-v8-0.5.0.tgz#d1b033fd4baddaf5636a41cd017e321a338727a6"
+ integrity sha512-4eZs5K4JG7zqWEhVSO8utlscjbVScV7K6JVwoWWcObFTGAaBMbDVzwGRimyNSzvmfTdIO/Arze4CeUUfCl4iLQ==
+ dependencies:
+ "@web/test-runner-core" "^0.10.20"
+ istanbul-lib-coverage "^3.0.0"
+ picomatch "^2.2.2"
+ v8-to-istanbul "^9.0.1"
+
"@web/test-runner-mocha@^0.7.5":
version "0.7.5"
resolved "https://registry.yarnpkg.com/@web/test-runner-mocha/-/test-runner-mocha-0.7.5.tgz#696f8cb7f5118a72bd7ac5778367ae3bd3fb92cd"
@@ -1891,6 +1909,15 @@
"@types/mocha" "^8.2.0"
"@web/test-runner-core" "^0.10.20"
+"@web/test-runner-playwright@^0.9.0":
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/@web/test-runner-playwright/-/test-runner-playwright-0.9.0.tgz#c13b71ecfe763ae5d15dff586a35a9840c238b1f"
+ integrity sha512-RhWkz1CY3KThHoX89yZ/gz9wDSPujxd2wMWNxqhov4y/XDI+0TS44TWKBfWXnuvlQFZPi8JFT7KibCo3pb/Mcg==
+ dependencies:
+ "@web/test-runner-core" "^0.10.20"
+ "@web/test-runner-coverage-v8" "^0.5.0"
+ playwright "^1.22.2"
+
"@web/test-runner-visual-regression@^0.6.6":
version "0.6.6"
resolved "https://registry.yarnpkg.com/@web/test-runner-visual-regression/-/test-runner-visual-regression-0.6.6.tgz#4a4dc734f360cba66a005e07b4a1c0a9ef956444"
@@ -4626,6 +4653,18 @@ pkg-dir@4.2.0:
dependencies:
find-up "^4.0.0"
+playwright-core@1.27.1:
+ version "1.27.1"
+ resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.27.1.tgz#840ef662e55a3ed759d8b5d3d00a5f885a7184f4"
+ integrity sha512-9EmeXDncC2Pmp/z+teoVYlvmPWUC6ejSSYZUln7YaP89Z6lpAaiaAnqroUt/BoLo8tn7WYShcfaCh+xofZa44Q==
+
+playwright@^1.22.2:
+ version "1.27.1"
+ resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.27.1.tgz#4eecac5899566c589d4220ca8acc16abe8a67450"
+ integrity sha512-xXYZ7m36yTtC+oFgqH0eTgullGztKSRMb4yuwLPl8IYSmgBM88QiB+3IWb1mRIC9/NNwcgbG0RwtFlg+EAFQHQ==
+ dependencies:
+ playwright-core "1.27.1"
+
pngjs@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-6.0.0.tgz#ca9e5d2aa48db0228a52c419c3308e87720da821"
@@ -5555,6 +5594,15 @@ v8-to-istanbul@^8.0.0:
convert-source-map "^1.6.0"
source-map "^0.7.3"
+v8-to-istanbul@^9.0.1:
+ version "9.0.1"
+ resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.0.1.tgz#b6f994b0b5d4ef255e17a0d17dc444a9f5132fa4"
+ integrity sha512-74Y4LqY74kLE6IFyIjPtkSTWzUZmj8tdHT9Ii/26dvQ6K9Dl2NbEfj0XgU2sHCtKgt5VupqhlO/5aWuqS+IY1w==
+ dependencies:
+ "@jridgewell/trace-mapping" "^0.3.12"
+ "@types/istanbul-lib-coverage" "^2.0.1"
+ convert-source-map "^1.6.0"
+
valid-url@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200"
diff --git a/proto/cache.proto b/proto/cache.proto
index 83c2ce28d2..7063ee53eb 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -198,14 +198,7 @@ message ChangeNotesStateProto {
string server_id = 20;
bool has_server_id = 21;
- message AssigneeStatusUpdateProto {
- // Epoch millis.
- int64 timestamp_millis = 1;
- int32 updated_by = 2;
- int32 current_assignee = 3;
- bool has_current_assignee = 4;
- }
- repeated AssigneeStatusUpdateProto assignee_update = 22;
+ reserved 22; // assignee_update;
// An update to the attention set of the change. See class AttentionSetUpdate
// for context.
@@ -535,9 +528,10 @@ message SubscribeSectionProto {
// Serialized form of com.google.gerrit.entities.StoredCommentLinkInfo.
// Next ID: 10
message StoredCommentLinkInfoProto {
+ reserved 4; // html
+
string name = 1;
string match = 2;
- string html = 4;
bool enabled = 5;
bool override_only = 6;
string link = 3;
@@ -696,7 +690,7 @@ message FileDiffKeyProto {
// Serialized form of
// com.google.gerrit.server.patch.filediff.FileDiffOutput
-// Next ID: 13
+// Next ID: 15
message FileDiffOutputProto {
// Next ID: 5
message Edit {
@@ -728,4 +722,6 @@ message FileDiffOutputProto {
bytes new_commit = 10;
ComparisonType comparison_type = 11;
bool negative = 12;
+ string old_mode = 13; // ENUM as string
+ string new_mode = 14; // ENUM as string
}
diff --git a/proto/entities.proto b/proto/entities.proto
index 191cca7551..f89e0f05c4 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -45,7 +45,6 @@ message Change {
optional string topic = 14;
optional string original_subject = 17;
optional string submission_id = 18;
- optional Account_Id assignee = 19;
optional bool is_private = 20;
optional bool work_in_progress = 21;
optional bool review_started = 22;
@@ -59,6 +58,7 @@ message Change {
reserved 11; // nbrPatchSets
reserved 15; // lastSha1MergeTested
reserved 16; // mergeable
+ reserved 19; // assignee
reserved 101; // note_db_state
}
@@ -98,6 +98,7 @@ message PatchSet {
optional string groups = 6;
optional string push_certificate = 8;
optional string description = 9;
+ optional Account_Id real_uploader_account_id = 10;
// Deleted fields, should not be reused:
reserved 5; // draft
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index 2c256ffc26..0cc3da0368 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -43,6 +43,31 @@ EOF
fi
}
+function test_empty_with_cutoff {
+ rm -f input
+ cat << EOF > input
+# Please enter the commit message for your changes.
+# ------------------------ >8 ------------------------
+# Do not modify or remove the line above.
+# Everything below it will be ignored.
+diff --git a/file.txt b/file.txt
+index 625fd613d9..03aeba3b21 100755
+--- a/file.txt
++++ b/file.txt
+@@ -38,6 +38,7 @@
+ context
+ line
+
++hello, world
+
+ context
+ line
+EOF
+ if ${hook} input ; then
+ fail "must fail on empty message"
+ fi
+}
+
function test_keep_cutoff_line {
if ! prereq_modern_git ; then
echo "old version of Git detected; skipping scissors test."
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
index 1a355eb20e..b6aca6f07a 100644
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -21,3 +21,4 @@ viewCaches = View Caches
viewConnections = View Connections
viewPlugins = View Plugins
viewQueue = View Queue
+viewSecondaryEmails = View Secondary Emails
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 98ab4b21ca..4b621b5670 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -77,13 +77,8 @@
{for $line, $index in $comment.lines}
{if $index == 0}
{if $comment.startLine != 0}
- {$comment.link}
+ {$comment.link}{sp}:{\n}
{/if}
-
- // Insert a space before the newline so that Gmail does not mistakenly
- // link the following line with the file link. See issue 9201.
- {sp}{\n}
-
{$comment.linePrefix}
{else}
{$comment.linePrefixEmpty}
diff --git a/resources/com/google/gerrit/server/mail/SetAssignee.soy b/resources/com/google/gerrit/server/mail/SetAssignee.soy
deleted file mode 100644
index 83aa580057..0000000000
--- a/resources/com/google/gerrit/server/mail/SetAssignee.soy
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template.SetAssignee}
-
-/**
- * The .SetAssignee template will determine the contents of the email related
- * to a user being assigned to a change.
- */
-{template SetAssignee kind="text"}
- {@param change: ?}
- {@param email: ?}
- {@param fromName: ?}
- {@param patchSet: ?}
- {@param projectName: ?}
- Hello{sp}
- {$email.assigneeName},
-
- {\n}
- {\n}
-
- {$fromName} has assigned a change to you.
-
- {sp}Please visit
-
- {\n}
- {\n}
-
- {sp}{sp}{sp}{sp}{$email.changeUrl}
-
- {\n}
- {\n}
-
- to view the change.
-
- {\n}
- {\n}
-
- Change subject: {$change.subject}{\n}
- ......................................................................{\n}
-
- {\n}
-
- {$email.changeDetail}{\n}
-
- {if $email.sshHost}
- {\n}
- {sp}{sp}git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
- {sp}{$patchSet.refName}
- {\n}
- {/if}
-
- {if $email.includeDiff}
- {\n}
- {$email.unifiedDiff}
- {\n}
- {/if}
-{/template}
diff --git a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
deleted file mode 100644
index 5435cab9ae..0000000000
--- a/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-{namespace com.google.gerrit.server.mail.template.SetAssigneeHtml}
-
-import * as mailTemplate from 'com/google/gerrit/server/mail/Private.soy';
-
-{template SetAssigneeHtml}
- {@param diffLines: ?}
- {@param email: ?}
- {@param fromName: ?}
- {@param patchSet: ?}
- {@param projectName: ?}
- <p>
- {$fromName} has <strong>assigned</strong> a change to{sp}
- {$email.assigneeName}.{sp}
- </p>
-
- {if $email.changeUrl}
- <p>
- {call mailTemplate.ViewChangeButton data="all" /}
- </p>
- {/if}
-
- {call mailTemplate.Pre}
- {param content: $email.changeDetail /}
- {/call}
-
- {if $email.sshHost}
- {call mailTemplate.Pre}
- {param content kind="html"}
- git pull ssh:{print '//'}{$email.sshHost}/{$projectName}
- {sp}{$patchSet.refName}
- {/param}
- {/call}
- {/if}
-
- {if $email.includeDiff}
- {call mailTemplate.UnifiedDiff}
- {param diffLines: $diffLines /}
- {/call}
- {/if}
-{/template}
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index d9fd1f1e8f..0154d43caf 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -50,7 +50,7 @@ dest="$1.tmp.${random}"
trap 'rm -f "$dest" "$dest-2"' EXIT
-if ! git stripspace --strip-comments < "$1" > "${dest}" ; then
+if ! cat "$1" | sed -e '/>8/q' | git stripspace --strip-comments > "${dest}" ; then
echo "cannot strip comments from $1"
exit 1
fi
diff --git a/tools/BUILD b/tools/BUILD
index d649cd7515..70d431509f 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -46,11 +46,13 @@ java_package_configuration(
"-XepDisableWarningsInGeneratedCode",
# The XepDisableWarningsInGeneratedCode disables only warnings, but
# not errors. We should manually exclude all files generated by
- # AutoValue; such files always start $AutoValue_.....
+ # AutoValue; such files always start AutoValue_..., $AutoValue_...,
+ # $$AutoValue_... or AutoValueGson_...
# XepExcludedPaths is a regexp. If you need more paths - use | as
# separator.
- "-XepExcludedPaths:.*/\\\\$$AutoValue_.*\\.java",
+ "-XepExcludedPaths:.*/\\\\$$?\\\\$$?AutoValue(Gson)?_.*\\.java",
"-Xep:AlmostJavadoc:ERROR",
+ "-Xep:AlreadyChecked:ERROR",
"-Xep:AlwaysThrows:ERROR",
"-Xep:AmbiguousMethodReference:ERROR",
"-Xep:AnnotateFormatMethod:ERROR",
@@ -68,7 +70,7 @@ java_package_configuration(
"-Xep:AutoValueConstructorOrderChecker:ERROR",
"-Xep:AutoValueFinalMethods:ERROR",
"-Xep:AutoValueImmutableFields:ERROR",
- # "-Xep:AutoValueSubclassLeaked:WARN",
+ "-Xep:AutoValueSubclassLeaked:ERROR",
"-Xep:BadAnnotationImplementation:ERROR",
"-Xep:BadComparable:ERROR",
"-Xep:BadImport:ERROR",
@@ -125,7 +127,7 @@ java_package_configuration(
"-Xep:DoNotCallSuggester:ERROR",
"-Xep:DoNotClaimAnnotations:ERROR",
"-Xep:DoNotMock:ERROR",
- "-Xep:DoNotMockAutoValue:WARN",
+ "-Xep:DoNotMockAutoValue:ERROR",
"-Xep:DoubleBraceInitialization:ERROR",
"-Xep:DoubleCheckedLocking:ERROR",
"-Xep:DuplicateMapKeys:ERROR",
@@ -146,7 +148,7 @@ java_package_configuration(
"-Xep:EqualsUsingHashCode:ERROR",
"-Xep:EqualsWrongThing:ERROR",
"-Xep:ErroneousThreadPoolConstructorChecker:ERROR",
- "-Xep:EscapedEntity:WARN",
+ "-Xep:EscapedEntity:ERROR",
"-Xep:ExpectedExceptionChecker:ERROR",
"-Xep:ExtendingJUnitAssert:ERROR",
"-Xep:ExtendsAutoValue:ERROR",
@@ -243,7 +245,7 @@ java_package_configuration(
"-Xep:JavaLocalTimeGetNano:ERROR",
"-Xep:JavaPeriodGetDays:ERROR",
"-Xep:JavaTimeDefaultTimeZone:ERROR",
- "-Xep:JavaUtilDate:WARN",
+ "-Xep:JavaUtilDate:ERROR",
"-Xep:JdkObsolete:ERROR",
"-Xep:JodaConstructors:ERROR",
"-Xep:JodaDateTimeConstants:ERROR",
@@ -353,6 +355,7 @@ java_package_configuration(
"-Xep:RestrictedApiChecker:ERROR",
"-Xep:RethrowReflectiveOperationExceptionAsLinkageError:ERROR",
"-Xep:ReturnFromVoid:ERROR",
+ "-Xep:ReturnMissingNullable:ERROR",
"-Xep:ReturnValueIgnored:ERROR",
"-Xep:RxReturnValueIgnored:ERROR",
"-Xep:SameNameButDifferent:ERROR",
@@ -428,6 +431,7 @@ java_package_configuration(
"-Xep:WrongOneof:ERROR",
"-Xep:XorPower:ERROR",
"-Xep:ZoneIdOfZ:ERROR",
+ "-Xlint:unchecked",
],
packages = ["error_prone_packages"],
)
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index 703d5b7173..28de4ec452 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -122,7 +122,7 @@ _asciidoc_attrs = {
),
"_exe": attr.label(
default = Label("//java/com/google/gerrit/asciidoctor:asciidoc"),
- cfg = "host",
+ cfg = "exec",
allow_files = True,
executable = True,
),
diff --git a/tools/deps.bzl b/tools/deps.bzl
index 9a17ca803e..133d06d570 100644
--- a/tools/deps.bzl
+++ b/tools/deps.bzl
@@ -1,9 +1,8 @@
load("//tools/bzl:maven_jar.bzl", "GERRIT", "maven_jar")
-load("@bazel_tools//tools/build_defs/repo:java.bzl", "java_import_external")
CAFFEINE_VERS = "2.9.2"
ANTLR_VERS = "3.5.2"
-COMMONMARK_VERS = "0.10.0"
+COMMONMARK_VERSION = "0.21.0"
FLEXMARK_VERS = "0.50.50"
GREENMAIL_VERS = "1.5.5"
MAIL_VERS = "1.6.0"
@@ -15,7 +14,7 @@ AUTO_VALUE_VERSION = "1.7.4"
AUTO_VALUE_GSON_VERSION = "1.3.1"
PROLOG_VERS = "1.4.4"
PROLOG_REPO = GERRIT
-GITILES_VERS = "1.0.0"
+GITILES_VERS = "1.1.0"
GITILES_REPO = GERRIT
# When updating Bouncy Castle, also update it in bazlets.
@@ -115,8 +114,8 @@ def java_dependencies():
maven_jar(
name = "commons-codec",
- artifact = "commons-codec:commons-codec:1.10",
- sha1 = "4b95f4897fa13f2cd904aee711aeafc0c5295cd8",
+ artifact = "commons-codec:commons-codec:1.15",
+ sha1 = "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d",
)
# When upgrading commons-compress, also upgrade tukaani-xz
@@ -173,26 +172,26 @@ def java_dependencies():
# commonmark must match the version used in Gitiles
maven_jar(
name = "commonmark",
- artifact = "com.atlassian.commonmark:commonmark:" + COMMONMARK_VERS,
- sha1 = "119cb7bedc3570d9ecb64ec69ab7686b5c20559b",
+ artifact = "org.commonmark:commonmark:" + COMMONMARK_VERSION,
+ sha1 = "c98f0473b17c87fe4fa2fc62a7c6523a2fe018f0",
)
maven_jar(
name = "cm-autolink",
- artifact = "com.atlassian.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERS,
- sha1 = "a6056a5efbd68f57d420bc51bbc54b28a5d3c56b",
+ artifact = "org.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERSION,
+ sha1 = "55c0312cf443fa3d5af0daeeeca00d6deee3cf90",
)
maven_jar(
name = "gfm-strikethrough",
- artifact = "com.atlassian.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERS,
- sha1 = "40837da951b421b545edddac57012e15fcc9e63c",
+ artifact = "org.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERSION,
+ sha1 = "953f4b71e133a98fcca93f3c3f4e58b895b76d1f",
)
maven_jar(
name = "gfm-tables",
- artifact = "com.atlassian.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERS,
- sha1 = "c075db2a3301100cf70c7dced8ecf86b494458a2",
+ artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION,
+ sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a",
)
maven_jar(
@@ -348,8 +347,8 @@ def java_dependencies():
# Transitive dependency of flexmark and gitiles
maven_jar(
name = "autolink",
- artifact = "org.nibor.autolink:autolink:0.7.0",
- sha1 = "649f9f13422cf50c926febe6035662ae25dc89b2",
+ artifact = "org.nibor.autolink:autolink:0.10.0",
+ sha1 = "6579ea7079be461e5ffa99f33222a632711cc671",
)
maven_jar(
@@ -378,8 +377,8 @@ def java_dependencies():
maven_jar(
name = "jsoup",
- artifact = "org.jsoup:jsoup:1.9.2",
- sha1 = "5e3bda828a80c7a21dfbe2308d1755759c2fd7b4",
+ artifact = "org.jsoup:jsoup:1.14.3",
+ sha1 = "c43a81e18e6d0eb71951aa031d55d5c293c531a6",
)
maven_jar(
@@ -528,14 +527,14 @@ def java_dependencies():
artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
attach_source = False,
repository = GITILES_REPO,
- sha1 = "f46833f8aa6f33ce3e443c8a414c295559eaf43e",
+ sha1 = "31c1a6e5d92b57bb2f9db24e1032145961c09a8d",
)
maven_jar(
name = "gitiles-servlet",
artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
repository = GITILES_REPO,
- sha1 = "90e107da00c2cd32490dd9ae8e3fb1ee095ea675",
+ sha1 = "c6550362c5c22d8e07edd4e2151ee12594082e76",
)
# prettify must match the version used in Gitiles
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 0cd3031ca8..4b8f657f08 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.7.7-SNAPSHOT</version>
+ <version>3.8.3-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 14825238c0..191a114e18 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.7.7-SNAPSHOT</version>
+ <version>3.8.3-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 8d70a581fc..901cf47795 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.7.7-SNAPSHOT</version>
+ <version>3.8.3-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 2f353d4abb..4f1b566938 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.7.7-SNAPSHOT</version>
+ <version>3.8.3-SNAPSHOT</version>
<packaging>war</packaging>
<name>Gerrit Code Review - WAR</name>
<description>Gerrit WAR</description>
diff --git a/tools/migration/html_to_link_commentlink.md b/tools/migration/html_to_link_commentlink.md
new file mode 100644
index 0000000000..45570ac61d
--- /dev/null
+++ b/tools/migration/html_to_link_commentlink.md
@@ -0,0 +1,47 @@
+# Overview
+
+**Raw html substitution will no longer be an option for comment links.**
+
+The raw-html option for commentlink sections is deprecated and removed.
+Example:
+
+```
+[commentlink "issue b/"]
+ match = (^|\\s)b/(\\d+)
+ html = $1<a href=\"http://b/issue?id=$2&query=$2\" target=\"_blank\">b/$2</a>
+```
+
+Before it allowed to find and replace text matches in commit messages and
+comments with arbitrary html. When misconfigured this has in the past enabled
+injecting undesired html code and XSS attacks by writing a comment.
+
+Even though the sanitization of the resulting html has improved. This feature is
+more powerful than needed. In almost all cases across host configurations html
+is only used to either configure text of the link, or limit the link to wrap
+only a portion of the matched text.
+
+To fill the gap in functionality from deprecating the option additional optional
+parameters (prefix, suffix and text) have been added. They allow to generate
+links that look like:
+```
+ PREFIX<a href="LINK">TEXT</a>SUFFIX
+```
+With substitution being strictly plaintext and all html escaped.
+
+The comment link section in project configs (in refs/meta/config) never
+supported the raw-html option and don't need to be updated.
+
+# Config migration command
+
+```
+CONFIG_FILE=<path to gerrit.config file>
+perl -0pe 's/([ \t]*)html\s*=\s*\"(.*)<a.* href=(?:\\\"(\S+)\\\"|(\S+)(?=\s|>))(?: .*)?>(.*)<\/a>(.*)(?<!\\)\"/$1link = \"$3$4\"\n$1prefix = \"$2\"\n$1text = \"$5\"\n$1suffix = \"$6\"/g' $CONFIG_FILE |
+perl -0pe 's/([ \t]*)html\s*=\s*(\S.*)?<a.* href=(?:\\\"(\S+)\\\"|(\S+)(?=\s|>))(?: .*)?>(.*)<\/a>(.*\S)?/$1link = \"$3$4\"\n$1prefix = \"$2\"\n$1text = \"$5\"\n$1suffix = \"$6\"/g' |
+perl -ne 'print if !/\s*(prefix|suffix|text)\s*=\s*\"\"/'
+```
+
+The command does 3 simple string replace passes:
+
+1. Replace `html=<value>` with quote-escaped value.
+2. Replace `html=<value>` with value without quotes.
+3. Remove empty `prefix`, `suffix`, `text` fields.
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 7dfb23e33e..642a74946c 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -97,6 +97,9 @@ export class LicenseMapGenerator {
*/
public generateMap(nodeModulesFiles: ReadonlyArray<string>): LicensesMap {
const installedPackages = this.getInstalledPackages(nodeModulesFiles);
+ // Static packages that are not inside `node_modules` directories.
+ // gr-page.ts was derived from page.js, so we reproduce the original LICENSE.
+ installedPackages.push({name: 'polygerrit-gr-page', version: 'current', rootPath: 'polygerrit-ui/app/elements/core/gr-router/', files: ['gr-page.ts']});
const licensedFilesGroupedByLicense = this.getLicensedFilesGroupedByLicense(installedPackages);
const result: LicensesMap = {};
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 3ad74a86a1..7f26ef3493 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -135,8 +135,8 @@ def declare_nongoogle_deps():
maven_jar(
name = "error-prone-annotations",
- artifact = "com.google.errorprone:error_prone_annotations:2.10.0",
- sha1 = "9bc20b94d3ac42489cf6ce1e42509c86f6f861a1",
+ artifact = "com.google.errorprone:error_prone_annotations:2.15.0",
+ sha1 = "38c8485a652f808c8c149150da4e5c2b0bd17f9a",
)
FLOGGER_VERS = "0.7.4"
diff --git a/version.bzl b/version.bzl
index 14e5b72ab9..7e5d30e5a4 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.7.7-SNAPSHOT"
+GERRIT_VERSION = "3.8.3-SNAPSHOT"