Merge branch 'stable-3.13' into stable-3.14 * stable-3.13: Allow disabling AI review prompt Fix menu editor toggling link target checkbox on existing menu item Fix Delete vote button not checking removable_labels. Add missing access-control documentation for deleteGroup Note: commit "691949b Allow disabling AI review prompt" was merged as a noop (as stated by its commit message), since from stable-3.14 there is already an explicit ACL-based control for AI features. Release-Notes: skip Change-Id: I599092627d49e61f1512da5a29fec11f875f6b34
diff --git a/.bazelignore b/.bazelignore index aac80af..13bcfb8 100644 --- a/.bazelignore +++ b/.bazelignore
@@ -1,4 +1,5 @@ eclipse-out +modules/jgit node_modules polygerrit-ui/node_modules plugins/node_modules
diff --git a/.bazelrc b/.bazelrc index d9d9fb8..7a2ed39 100644 --- a/.bazelrc +++ b/.bazelrc
@@ -1,7 +1,14 @@ # TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel # https://issues.gerritcodereview.com/issues/303819949 -common --noenable_bzlmod +# Remove hybrid mode option once bzlmod migration is completed +common --enable_workspace +common --enable_bzlmod --lockfile_mode=error common --incompatible_enable_proto_toolchain_resolution +common --@protobuf//bazel/toolchains:prefer_prebuilt_protoc +# Enable Gerrit-tree-only plugin checks (standalone plugin builds skip them). +common --@com_googlesource_gerrit_bazlets//flags:in_gerrit_tree=true +common --incompatible_disallow_struct_provider_syntax=false +common --incompatible_disallow_empty_glob=false build --workspace_status_command="python3 ./tools/workspace_status.py" build --repository_cache=~/.gerritcodereview/bazel-cache/repository
diff --git a/.bazelversion b/.bazelversion index e8be684..acd405b 100644 --- a/.bazelversion +++ b/.bazelversion
@@ -1 +1 @@ -7.6.1 +8.6.0
diff --git a/.gitignore b/.gitignore index e04444d..64e0e49 100644 --- a/.gitignore +++ b/.gitignore
@@ -8,6 +8,7 @@ *.swp *~ .DS_Store +CLAUDE.md js-to-ts.sh /.apt_generated /.apt_generated_tests
diff --git a/Documentation/BUILD b/Documentation/BUILD index 85ddbe7..9ab713a 100644 --- a/Documentation/BUILD +++ b/Documentation/BUILD
@@ -1,5 +1,6 @@ load("//tools/bzl:asciidoc.bzl", "documentation_attributes", "genasciidoc", "genasciidoc_zip") load("//tools/bzl:license.bzl", "license_map") +load("//tools/bzl:war_checks.bzl", "war_jars_allowlist_test") package(default_visibility = ["//visibility:public"]) @@ -31,15 +32,24 @@ filegroup( name = "resources", - srcs = glob([ - "images/*.jpg", - "images/*.png", - ]) + [ + srcs = glob( + [ + "images/*.jpg", + "images/*.png", + ], + allow_empty = True, + ) + [ ":prettify_files", "//:LICENSES.txt", ], ) +war_jars_allowlist_test( + name = "check_release_war_jars", + allowlist = ":release_war_jars.txt", + war_jars_manifest = "//:release.war.jars.txt", +) + license_map( name = "licenses", json_maps = [
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt index c8f9926..d391312 100644 --- a/Documentation/access-control.txt +++ b/Documentation/access-control.txt
@@ -121,23 +121,52 @@ [[service_users]] === Service Users -This is the Gerrit "batch" identity. The capabilities -link:access-control.html#capability_priority['Priority BATCH'] and -link:access-control.html#capability_streamEvents['Stream Events'] -are assigned to this predefined group on Gerrit site creation. +The `Service Users` group is used to identify service users (aka bots). -The members of this group are not expected to perform interactive -operations on the Gerrit web front-end. +All service users should be added to this group so that they can be identified +as such. -However, sometimes such a user may need a separate thread pool in -order to prevent it from grabbing threads from the interactive users. +The members of this group are not expected to perform interactive operations on +the Gerrit web UI. -These users live in a second thread pool, which separates operations -made by the service users from the ones made by the interactive -users. This ensures that the interactive users can keep working when -resources are tight. +Service users have the following special behaviour: -Before Gerrit 3.3, the 'Service Users' group was named 'Non-Interactive Users'. +* They are link:user-attention-set.html#bots[never added to the attention set]. +* They are not suggested as reviewers on changes (unless + link:config-gerrit.html#suggest.skipServiceUsers[suggest.skipServiceUsers] in + the Gerrit config is set to `false`). +* Their vote is not required when + link:config-submit-requirements.html#require-code-review-approvals-from-all-human-reviewers-example[ + requiring `Code-Review` approvals from all reviewers]. +* In the REST API, service user accounts are tagged with `SERVICE_USER` (see the + `tags` field in link:rest-api-accounts.html#account-info[AccountInfo]). +* Change indexing is done synchronously for service users only if + link:config-gerrit.html#index.indexChangesAsync[asynchronous change indexing] + is enabled in the Gerrit config (and the + `GerritBackendFeature__do_change_indexing_asynchronously_for_non_service_users` + experiment is enabled). +* For Gerrit servers at Google, querying the change index uses strong reads only + for service users. Other users may get results that are stale by a few + seconds. +* Some metrics allow to distinguish between requests from service users and + other users: the + link:metrics.html#rest-api-latency[http/server/rest_api/server_latency] + metric (see field `user_kind`), the + link:metrics.html#push-latency[receivecommits/latency_per_push] metric (see + field `user_kind`), the + link:metrics.html#per-change-push-latency[receivecommits/latency_per_push_per_change] + metric (see field `user_kind`), the + link:metrics.html#push-reject-count[receivecommits/reject_count] metric (see + field `kind`) + +By default the `Service Users` group has: + +* The `BATCH` link:access-control.html#capability_priority['Priority'] global + capability so that requests of these users are served from a separate thread + pool (prevents that service users exhaust all threads for interactive users). + +* The link:access-control.html#capability_streamEvents['Stream Events'] global + capability so that these users can listen to stream events. [[blocked_users]] === Blocked Users @@ -224,6 +253,7 @@ Reference-level access control is also possible. +[[reference_patterns]] Permissions can be set on a single reference name to match one branch (e.g. `refs/heads/master`), or on a reference namespace (e.g. `+refs/heads/*+`) to match any branch starting with that @@ -1015,6 +1045,54 @@ assigned). +[[category_ai_review]] +=== AI Review + +This category controls who is able to use AI-assisted review features, +such as the "Review Agent" and "Create AI Review Prompt" buttons. + +This permission is only effective when the `UiFeature__enable_ai_chat` +experiment is enabled. + +[NOTE] +Unlike all other Gerrit permissions which use a default-deny model +(access is denied unless an explicit 'ALLOW' rule grants it), the +`aiReview` permission uses a *default-allow* model during the experiment +phase. This means: + +* When no `aiReview` rules are configured, all users have access. +* 'DENY' and 'BLOCK' rules can be used on their own to restrict specific + groups, without needing to first grant 'ALLOW' to everyone else. +* When 'ALLOW' rules are present, the permission switches to the standard + model: only users matching an 'ALLOW' rule have access. +* Site administrators are subject to the same rules as other users. + +This non-standard behavior will be removed when the experiment ends, at +which point `aiReview` will follow the standard default-deny model like +all other permissions. + +To restrict AI review to a specific group on all branches: + +---- + [access "refs/heads/*"] + aiReview = group AI Reviewers +---- + +To deny a specific group while keeping access for everyone else: + +---- + [access "refs/heads/*"] + aiReview = deny group Restricted Users +---- + +To block a group across all child projects (cannot be overridden): + +---- + [access "refs/heads/*"] + aiReview = block group Restricted Users +---- + + [[example_roles]] == Examples of typical roles in a project
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt index feaa137..1ab3119 100755 --- a/Documentation/backend_licenses.txt +++ b/Documentation/backend_licenses.txt
@@ -49,6 +49,7 @@ * commons:codec * commons:compress * commons:dbcp +* commons:io * commons:lang3 * commons:net * commons:pool
diff --git a/Documentation/cmd-reload-config.txt b/Documentation/cmd-reload-config.txt index 6d652f5..ff65f57 100644 --- a/Documentation/cmd-reload-config.txt +++ b/Documentation/cmd-reload-config.txt
@@ -15,7 +15,7 @@ Not all configuration values can be picked up by this command. Which config sections and values that are supported is documented here: -link:config-gerrit.html[Configuration] +link:config-gerrit.html#reloadConfig[Configuration] _The output shows only modified config values that are picked up by Gerrit and applied._
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt index 84a3339..f57665c 100644 --- a/Documentation/config-gerrit.txt +++ b/Documentation/config-gerrit.txt
@@ -1541,6 +1541,15 @@ [[change]] === Section change +[[change.addChangeReviewFootersToCommitMessage]]change.addChangeReviewFootersToCommitMessage:: ++ +If set to `true`, review approvals (e.g. `Reviewed-by`, `Tested-by`) are added +to the commit message as footers when the change is merged. This only applies to +`REBASE_ALWAYS` and `CHERRY_PICK` +link:config-project-config.html#submit-type[submit types]. ++ +Default is `true`. + [[change.allowBlame]]change.allowBlame:: + Allow blame on side by side diff. If set to `false`, blame cannot be used. @@ -1610,6 +1619,13 @@ + By default 5,000. +[[change.maxCommentsPerUser]]change.maxCommentsPerUser:: ++ +Maximum number of comments allowed per user on a single change. +Additional comments from the same user are rejected. ++ +By default 0, which means no limit. + [[change.maxFiles]]change.maxFiles:: + Maximum number of files allowed per change. Larger changes are rejected and must @@ -2584,6 +2600,13 @@ + By default, `20`. +[[flows.maxPendingPerChange]]flows.maxPendingPerChange:: ++ +Controls how many pending flows a change can have at most. 0 or negative values +mean "unlimited". ++ +By default, `3`. + [[gc]] === Section gc @@ -3674,12 +3697,27 @@ If the file doesn't exist or can't be read the default robots.txt file bundled with the .war will be used instead. +[[httpd.webManifestFile]]httpd.webManifestFile:: ++ +Location of an external web manifest file to be used instead of the generated one. ++ +If not absolute, the path is resolved relative to `$site_path`. ++ +If the file doesn't exist or can't be read the default generated manifest will be used instead. + [[httpd.registerMBeans]]httpd.registerMBeans:: + Enable (or disable) registration of Jetty MBeans for Java JMX. + By default, `false`. +[[httpd.sameSite]]httpd.sameSite:: ++ +Set the `SameSite` cookie attribute on the cookies created by Gerrit. +Allowed values are: `None`, `Lax` and `Strict`. ++ +By default unset. + [[index]] === Section index @@ -4276,22 +4314,6 @@ disabled = ExperimentKey ---- -[[experiments.ui_feature_get_ai_prompt]] -==== UiFeature__get_ai_prompt - -Controls the visibility of the AI Review Prompt action in the change screen UI. - -When enabled, the "Create AI Review Prompt" button and dialog are shown in the UI. - -This experiment is enabled by default. It can be disabled explicitly: - ----- -[experiments] - disabled = UiFeature__get_ai_prompt ----- - -Disabling this experiment hides the AI Review Prompt action from the UI. - [[ldap]] === Section ldap @@ -5743,11 +5765,11 @@ + * `USER` + -Gerrit will set the From header to use the current user's -Full Name and Preferred Email. This may cause messages to be -classified as spam if the user's domain has SPF or DKIM enabled -and <<sendemail.smtpServer,sendemail.smtpServer>> is not a trusted -relay for that domain. You can specify +Gerrit will set the From header name and address, and the SMTP `MAIL FROM` +(envelope sender) address, to use the current user's Full Name and Preferred +Email. This may cause messages to be classified as spam if the user's domain has +SPF or DKIM enabled and <<sendemail.smtpServer,sendemail.smtpServer>> is not a +trusted relay for that domain. You can specify <<sendemail.allowedDomain,sendemail.allowedDomain>> to instruct Gerrit to only send as USER if USER is from those domains. + @@ -5759,16 +5781,17 @@ + * `SERVER` + -Gerrit will set the From header to the same name and address -it records in any commits Gerrit creates. This is set by -<<user.name,user.name>> and <<user.email,user.email>>, or guessed -from the local operating system. +Gerrit will set the From header name and address, and the SMTP `MAIL FROM` +(envelope sender) address, to the same values it records in any commits Gerrit +creates. This is set by <<user.name,user.name>> and <<user.email,user.email>>, +or guessed from the local operating system. + * `Code Review <review@example.com>` + If set to a name and email address in brackets, Gerrit will use this name and email address for any messages, overriding the name that may have been selected for commits by user.name and user.email. +This address is also used in the SMTP `MAIL FROM` (envelope sender). Optionally, the name portion may contain the placeholder `${user}`, which is replaced by the Full Name of the current user. @@ -6323,6 +6346,24 @@ + Set to 0 to disable this check. +[[submitRequirement]] +=== Section submitRequirement + +[[submitRequirement.requireOperatorForUpdate]]submitRequirement.requireOperatorForUpdate:: +While updating submit requirements, control if operator should be required in +expression term or not. ++ +By default false. ++ + +[[submitRequirement.requireOperatorForEvaluation]]submitRequirement.requireOperatorForEvaluation:: +While accessing a change, control if operator should be required while evaluating +submit requirements expression or not. ++ +By default false. ++ + + [[suggest]] === Section suggest @@ -6737,6 +6778,9 @@ + Email address that Gerrit refers to itself as when it creates a new Git commit, such as a merge commit during change submission. +When <<sendemail.from,sendemail.from>> is `SERVER` or `MIXED`, +this is the email address used in the SMTP `MAIL FROM` field +(also known as envelope sender or return-path) when Gerrit sends email. + If not set, Gerrit generates this as "gerrit@``hostname``", where `hostname` is the hostname of the system Gerrit is running on.
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt index 879251a..765b299 100644 --- a/Documentation/config-labels.txt +++ b/Documentation/config-labels.txt
@@ -270,9 +270,10 @@ New label definitions should explicitly set the `function` attribute to a non-blocking value since the default is `MaxWithBlock`. -If your project has a -blocking label function, we highly encourage you to change it to `NoBlock` and -add a submit-requirement for the same label. See the +If you have a submit-requirement configured for a label, you *must* change the +label function to `NoBlock`. We also highly encourage you to convert all +blocking labels to use the `NoBlock` function and replace the blocking behavior +by adding a submit-requirement for the same label. See the link:config-submit-requirements.html#code-review-example[submit-requirements documentation] for more details. @@ -579,7 +580,7 @@ Additionally, the `branch` modifier has no effect when the submit rule is customized in the rules.pl of the project or inherited from parent projects. Branch can be a ref pattern similar to what is documented -link:access-control.html#reference[here], but must not contain `${username}` or +link:access-control.html#reference_patterns[here], but must not contain `${username}` or `${shardeduserid}`. [[label_ignoreSelfApproval]]
diff --git a/Documentation/config-submit-requirements.txt b/Documentation/config-submit-requirements.txt index bb4b020..239ccf6 100644 --- a/Documentation/config-submit-requirements.txt +++ b/Documentation/config-submit-requirements.txt
@@ -163,7 +163,7 @@ * the label was approved: `label:My-Label=MAX` * the label has no veto: `-label:My-Label=MIN` * the label was not self-approved: `label:My-Label=MAX,user=non_uploader` -* the label was approved by multiple users: `label:My-Label,count>1` +* the label was approved by multiple users: `label:My-Label&count>1` Submit requirements that check votes for a single label often have the same name as the label, e.g.: @@ -432,6 +432,9 @@ and operators available for link:user-search.html[searching changes]. In addition to that, submit requirements support extra operators. +[NOTE] +Submit Requirement expressions are validated to ensure they contain a valid operator +with format <operator>:<value> based on link:config-gerrit.txt#submitRequirement[config parameters]. [[submit_requirements_operators]] === Submit Requirements Operators @@ -461,7 +464,7 @@ is used for the evaluation of such patterns. [[operator_distinctvoters]] -distinctvoters:'[Label1,Label2,...,LabelN],value=MAX,count>1':: +distinctvoters:'[Label1,Label2,...,LabelN]&value=MAX&count>1':: + An operator that allows checking for distinct voters across more than one label. + @@ -469,12 +472,12 @@ Count is mandatory. + Examples: -`distinctvoters:[Code-Review,Trust],value=MAX,count>1` +`distinctvoters:[Code-Review,Trust]&value=MAX&count>1` + -`distinctvoters:[Code-Review,Trust,API-Review],count>2` +`distinctvoters:[Code-Review,Trust,API-Review]&count>2` [[operator_label_with_users_arg]] -label:'<label><operator><value>,users=human_reviewers':: +label:'<label><operator><value>&users=human_reviewers':: + Extension of the link:user-search.html#labels[label] predicate that allows matching changes that have a matching vote from all human @@ -493,7 +496,7 @@ (because a human review is required but no human reviewer is present). + Examples: -`label:Code-Review=MAX,users=human_reviewers` +label:Code-Review=MAX&users=human_reviewers + `label:Code-Review>=1,users=human_reviewers` + @@ -612,7 +615,7 @@ [submit-requirement "Code-Review"] description = A maximum vote from a non-uploader is required for the \ 'Code-Review' label. A minimum vote is blocking. - submittableIf = label:Code-Review=MAX,user=non_uploader AND -label:Code-Review=MIN + submittableIf = label:Code-Review=MAX&user=non_uploader AND -label:Code-Review=MIN canOverrideInChildProjects = true ---- @@ -684,8 +687,8 @@ description = A 'Code-Review' vote is required from all human \ reviewers (service users that are reviewers are \ ignored). - applicableIf = -label:Code-Review>=1,users=human_reviewers - submittableIf = label:Code-Review>=1,users=human_reviewers + applicableIf = -label:Code-Review>=1&users=human_reviewers + submittableIf = label:Code-Review>=1&users=human_reviewers ---- It is possible to configure the 'Want-Code-Review-From-All' submit @@ -708,7 +711,7 @@ description = A 'Code-Review' vote is required from all human \ reviewers (service users that are reviewers are \ ignored). - applicableIf = footer:\"Want-Code-Review: all\" -label:Code-Review>=1,users=human_reviewers + applicableIf = footer:\"Want-Code-Review: all\" -label:Code-Review>=1&users=human_reviewers submittableIf = label:Code-Review>=1,users=human_reviewers ----
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt index 246aafc..3361ebc 100644 --- a/Documentation/dev-bazel.txt +++ b/Documentation/dev-bazel.txt
@@ -75,6 +75,37 @@ [[build]] == Building on the Command Line +=== Bazel module lockfile strictness + +Gerrit uses Bazel modules with a checked-in lockfile (`MODULE.bazel.lock`). +To avoid accidental desynchronization between `MODULE.bazel` and the lockfile, +the build runs with strict lockfile checking: + +---- +--lockfile_mode=error +---- + +With this mode, Bazel will fail the build if the lockfile is out of date. + +If you see an error like: + +---- +ERROR: Missing checksum for registry file ... not permitted with --lockfile_mode=error +---- + +it means that `MODULE.bazel.lock` needs to be updated. + +To update the lockfile explicitly, run: + +``` + $ bazelisk mod deps --lockfile_mode=update +``` + +and commit the resulting changes to `MODULE.bazel.lock`. + +Note: Changes that do not modify `MODULE.bazel` should normally not modify +`MODULE.bazel.lock`. Any lockfile update should be intentional and reviewed. + === Gerrit Development WAR File To build the Gerrit web application: @@ -287,6 +318,90 @@ Note: Follow link:#npm-binary[NPM Binaries] for adding npm package dependencies licenses. This is for js_licenses.txt. +[[release-war-metadata]] +== Release WAR metadata outputs + +The `pkg_war` rule emits deterministic metadata files describing the +contents of a generated WAR. These files are produced without +materializing or unpacking the WAR archive and are intended for checks +and diagnostics. + +=== <name>.war.jars.txt + +This file contains the normalized, version-agnostic identifiers of +third-party JARs that would be packaged into the WAR. + +It is used by the `//Documentation:check_release_war_jars` test as a +guardrail against unintended changes to the runtime dependency set +shipped in `release.war`. + +The identifiers are normalized to remain stable across dependency +upgrades (for example by stripping version suffixes and excluding +Gerrit-internal artifacts). + +=== <name>.war.entries.txt + +This file lists the full entry paths of all JAR files as they would +appear inside the WAR (for example `WEB-INF/lib/foo.jar` or +`WEB-INF/pgm-lib/bar.jar`). + +This output is intended as a lightweight diagnostic aid and for future +checks on WAR layout. It allows inspection of: +* which JARs are packaged +* where they are placed inside the WAR (lib vs pgm-lib) +* whether duplicate entries or unintended version skews (multiple + versions of the same library) are introduced + +without requiring the WAR to be built or unpacked. + +At present, this file is not consumed by an automated test, but it is +kept to support debugging and potential future validation of WAR +structure. + +[[release-war-jars]] +== Release WAR dependency allowlist + +The set of third-party JARs packaged into `release.war` is checked by the +`//Documentation:check_release_war_jars` test. This test acts as a guardrail +to detect unintended changes to the runtime dependency set shipped in Gerrit +releases. + +To run the check: + +---- + bazelisk test //Documentation:check_release_war_jars +---- + +If the test fails with: + +---- + WAR packaged third-party JAR set changed +---- + +the runtime dependency set of `release.war` has changed. + +If the change is expected and has been reviewed, refresh the allowlist: + +---- + bazelisk build //:release.war.jars.txt + cp bazel-bin/release.war.jars.txt Documentation/release_war_jars.txt +---- + +Changes to the packaged dependency set may also require updating license +metadata. All runtime dependencies that are shipped in `release.war` must be +reachable from the Bazel dependency graph so that license checks can locate +them. + +When adding or modifying dependencies: +* Ensure new runtime dependencies are wired explicitly (for example as + `runtime_deps`) into the appropriate `//lib` wrapper targets. +* Update or add license metadata as needed. +* Run the license check: + +---- + bazelisk test //Documentation:check_licenses +---- + [[tests]] == Running Unit Tests @@ -487,101 +602,92 @@ export http_proxy=http://<proxy_user_id>:<proxy_password>@<proxy_server>:<proxy_port> ---- -Redirection to local mirrors of Maven Central and the Gerrit storage -bucket is supported by defining specific properties in -`local.properties`, a file that is not tracked by Git: +== Adding and/or updating Maven dependencies +Maven dependencies are maintained in two separate files: `tools/deps.toml` and +`tools/nongoogle.toml`. Depending on whether Google will use the dependency +or not, dependencies should be added to one or the other file. If unsure, the +dependency should be added to `tools/deps.toml`. A Googler will check the dependency +during review and provide feedback. + +When adding a new maven dependency add a line like this: + +[source,toml] ---- - echo download.GERRIT = http://nexus.my-company.com/ >>local.properties - echo download.MAVEN_CENTRAL = http://nexus.my-company.com/ >>local.properties +[libraries] +commons-codec = { module = "commons-codec:commons-codec", version = "1.15" } ---- -The `local.properties` file may be placed in the root of the gerrit repository -being built, or in `~/.gerritcodereview/`. The file in the root of the gerrit -repository has precedence. +To update a dependency, change the corresponding version in the above mentioned +toml-files. + +In both cases, the dependencies have to be repinned. To do that, run: + +[source,bash] +---- +REPIN=1 bazel run @external_deps//:pin +---- == Building against unpublished Maven JARs To build against unpublished Maven JARs, like PrologCafe, the custom JARs must -be installed in the local Maven repository (`mvn clean install`) and -`maven_jar()` must be updated to point to the `MAVEN_LOCAL` Maven repository for -that artifact: +be installed in the local Maven repository (`mvn clean install`) and the local +repository has to be added to the `maven.install()`-call in `MODULE.bazel`: [source,python] ---- - maven_jar( - name = 'prolog-runtime', - artifact = 'com.googlecode.prolog-cafe:prolog-runtime:42', - repository = MAVEN_LOCAL, - ) + maven.install( + artifacts = [], + repositories = [ + "file://$HOME/.m2/repository", + "https://repo1.maven.org/maven2", + "https://gerrit-maven.storage.googleapis.com", + ], + ... + ) ---- == Building against artifacts from custom Maven repositories -To build against custom Maven repositories, two modes of operations are -supported: with rewrite in local.properties and without. - -Without rewrite the URL of custom Maven repository can be directly passed -to the maven_jar() function: +The URL of custom Maven repository can be directly passed to the `maven.install` +function: [source,python] ---- GERRIT_FORGE = 'http://gerritforge.com/snapshot' - maven_jar( - name = 'gitblit', - artifact = 'com.gitblit:gitblit:1.4.0', - sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa', - repository = GERRIT_FORGE, - ) + maven.install( + artifacts = [], + repositories = [ + GERRIT_FORGE, + "https://repo1.maven.org/maven2", + "https://gerrit-maven.storage.googleapis.com", + ], + ... + ) ---- -When the custom URL has to be rewritten, then the same logic as with Gerrit -known Maven repository is used: Repo name must be defined that matches an entry -in local.properties file: - ----- - download.GERRIT_FORGE = http://my.company.mirror/gerrit-forge ----- - -And corresponding WORKSPACE excerpt: - -[source,python] ----- - GERRIT_FORGE = 'GERRIT_FORGE:' - - maven_jar( - name = 'gitblit', - artifact = 'com.gitblit:gitblit:1.4.0', - sha1 = '1b130dbf5578ace37507430a4a523f6594bf34fa', - repository = GERRIT_FORGE, - ) ----- +Note, that these repositories will apply to all artifacts being used. == Building against SNAPSHOT Maven JARs To build against SNAPSHOT Maven JARs, the complete SNAPSHOT version must be used: -[source,python] +[source,toml] ---- - maven_jar( - name = "pac4j-core", - artifact = "org.pac4j:pac4j-core:3.5.0-SNAPSHOT-20190112.120241-16", - sha1 = "da2b1cb68a8f87bfd40813179abd368de9f3a746", - ) +[libraries] +commons-codec = { module = "commons-codec:commons-codec", version = "1.15-SNAPSHOT-20190112.120241-16" } ---- [[bazel-local-caches]] To accelerate builds, several caches are activated per default: -* ~/.gerritcodereview/bazel-cache/downloaded-artifacts * ~/.gerritcodereview/bazel-cache/repository * ~/.gerritcodereview/bazel-cache/cas -The `downloaded-artifacts` cache can be relocated by setting the -`GERRIT_CACHE_HOME` environment variable. The other two can be adjusted with -`bazelisk build` options `--repository_cache` and `--disk_cache` respectively. +The cache locations can be adjusted with `bazelisk build` options +`--repository_cache` and `--disk_cache` respectively. Currently none of these caches have a maximum size limit. See link:https://github.com/bazelbuild/bazel/issues/5139[this bazel issue,role=external,window=_blank] for
diff --git a/Documentation/dev-build-plugins.txt b/Documentation/dev-build-plugins.txt index 219861f..c0cf52e 100644 --- a/Documentation/dev-build-plugins.txt +++ b/Documentation/dev-build-plugins.txt
@@ -83,24 +83,151 @@ === Plugins with external dependencies === -If the plugin has external dependencies, then they must be included from Gerrit's -own WORKSPACE file. This can be achieved by including them in `external_plugin_deps.bzl`. -During the build in Gerrit tree, this file must be copied over the dummy one in -`plugins` directory. +[NOTE] +As of Gerrit 3.14 using the `external_plugin_deps.bzl` file for adding external +dependencies of plugins to the build has been deprecated. This feature will be +removed with Gerrit 3.15. Please migrate to using Bazel modules as described +below. The documentation of the deprecated `external_plugin_deps.bzl` +functionality has been moved to a dedicated section below. -Example for content of `external_plugin_deps.bzl` file: +If a plugin requires external Java dependencies, it can install them in its +`MODULE.bazel` using `rules_jvm_external`. The Maven repository containing the +plugin runtime dependencies must be plugin-scoped (for example +`<plugin>_plugin_deps`) and must not use a shared repository name across +plugins when built in-tree with Bzlmod. Such a `MODULE.bazel` file +might look as follows: ---- -load("//tools/bzl:maven_jar.bzl", "maven_jar") +module(name = "gerrit-oauth-provider") -def external_plugin_deps(): - maven_jar( - name = 'org_apache_tika_tika_core', - artifact = 'org.apache.tika:tika-core:1.12', - sha1 = '5ab95580d22fe1dee79cffbcd98bb509a32da09b', - ) +bazel_dep(name = "rules_jvm_external", version = "6.10") + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") + +maven.install( + name = "oauth_plugin_deps", + artifacts = [ + "com.fasterxml.jackson.core:jackson-databind:2.10.2", + "com.github.scribejava:scribejava-apis:6.9.0", + "com.sap.cloud.security:java-security:3.6.0", + ], + duplicate_version_warning = "error", + excluded_artifacts = [ + "commons-io:commons-io", + "com.github.ben-manes.caffeine:caffeine", + "org.slf4j:slf4j-api", + ], + fail_if_repin_required = True, + fail_on_missing_checksum = True, + fetch_sources = True, + lock_file = "//:oauth_plugin_deps.lock.json", + repositories = [ + "https://repo1.maven.org/maven2", + ], + version_conflict_policy = "pinned", +) + +use_repo(maven, "oauth_plugin_deps") ---- +If the plugin has external dependencies, its Bazel module must be loaded from +Gerrit's own `MODULE.bazel` file and the plugin-scoped Maven repository +imported via `use_repo()`. Gerrit must not define a `maven.install()` for +plugin runtime dependencies, as plugins may contribute their own lock files. +This can be achieved by loading the plugin's Bazel module in Gerrit's own +module (`MODULE.bazel` file), e.g.: + +---- +bazel_dep(name = "gerrit-plugin-oauth") +local_path_override( + module_name = "gerrit-plugin-oauth", + path = "plugins/oauth", +) + +use_repo(maven, "oauth_plugin_deps") +---- + +After creating the `MODULE.bazel` file, create and update the plugin’s Maven +lock file: + +---- +cd plugins/oauth +touch oauth_plugin_deps.lock.json +bazelisk run @oauth_plugin_deps//:pin +---- + +The generated Maven lock file (for example `oauth_plugin_deps.lock.json`) is owned +by the plugin and must be checked into the plugin repository. + +When the plugin is built in-tree, Gerrit imports the plugin-scoped Maven +repository via `use_repo()`, but does not resolve or repin its +dependencies. The plugin Maven lock file remains owned and maintained by +the plugin in both standalone and in-tree build modes. + +If Bazel module lockfile mode is enabled (for example via +`--lockfile_mode=error`), the Bzlmod module graph lock file +(`MODULE.bazel.lock`) must be updated from the workspace root: + +---- +cd plugins/oauth +bazelisk mod deps --lockfile_mode=update +---- + +==== Wiring plugin modules into the in-tree build + +Plugins that declare external dependencies via `rules_jvm_external` +must expose their Maven repository to Gerrit's root Bazel module when +built in-tree. + +Gerrit provides the file `plugins/external_plugin_deps.MODULE.bazel` +which is included from the root `MODULE.bazel`. Plugin modules can be +wired into the in-tree build by referencing their module and importing +their plugin-scoped Maven repository. + +Example for the `oauth` plugin: + +---- +bazel_dep(name = "gerrit-plugin-oauth") +local_path_override( + module_name = "gerrit-plugin-oauth", + path = "plugins/oauth", +) + +use_repo(maven, "oauth_plugin_deps") +---- + +Plugins may provide their own `external_plugin_deps.MODULE.bazel` +fragment containing these declarations. When building locally, the +fragment can be linked into the Gerrit tree: + +---- +cd gerrit/plugins +rm external_plugin_deps.MODULE.bazel +ln -s oauth/external_plugin_deps.MODULE.bazel external_plugin_deps.MODULE.bazel +---- + +This makes the plugin's Bazel module and its plugin-scoped Maven +repository visible to Gerrit's root module. + +To support multiple plugins, include their `external_plugin_deps.MODULE.bazel` +files. + +For example, to import external dependencies for the `oauth` and `javamelody` +plugins, add the following lines to `plugins/external_plugin_deps.MODULE.bazel`: + +---- +include("//plugins/javamelody:external_plugin_deps.MODULE.bazel") +include("//plugins/oauth:external_plugin_deps.MODULE.bazel") +---- + +[NOTE] +When multiple plugins are built in-tree, `rules_jvm_external` merges +`maven.install()` tags with the same name across all modules. Plugin runtime +dependencies must therefore be declared in plugin-scoped repositories +(e.g. `<plugin>_plugin_deps`) that own their lock files. Shared repository +names must not define a `lock_file` in more than one module, otherwise the +build will fail during module extension evaluation. + === Bundle custom plugin in release.war === To bundle custom plugin(s) in the link:dev-bazel.html#release[release.war] artifact, @@ -127,6 +254,44 @@ ] ---- +[NOTE] +Since `tools/bzl/plugins.bzl` is part of Gerrit's source code and the version of +the war is based on the state of the git repository that is built; you should +commit this change before building, otherwise the version will be marked as +'dirty'. + +== Bazel standalone driven + +Only few plugins support that mode for now: + +---- +cd reviewers +bazel build reviewers +---- + +== Managing external dependencies using `external_plugin_deps.bzl` == + +[NOTE] +This functionality has been deprecated. + +If the plugin has external dependencies, then they can be included from Gerrit's +own WORKSPACE file. This can be achieved by including them in `external_plugin_deps.bzl`. +During the build in Gerrit tree, this file must be copied over the dummy one in +`plugins` directory. + +Example for content of `external_plugin_deps.bzl` file: + +---- +load("//tools/bzl:maven_jar.bzl", "maven_jar") + +def external_plugin_deps(): + maven_jar( + name = 'org_apache_tika_tika_core', + artifact = 'org.apache.tika:tika-core:1.12', + sha1 = '5ab95580d22fe1dee79cffbcd98bb509a32da09b', + ) +---- + If the plugin(s) being bundled in the release have external dependencies, include them in `plugins/external_plugin_deps`. Create symbolic link from plugin's own `external_plugin_deps()` file in plugins directory and prefix the file with @@ -149,21 +314,6 @@ uploadvalidator_deps() ---- -[NOTE] -Since `tools/bzl/plugins.bzl` and `plugins/external_plugin_deps.bzl` are part of -Gerrit's source code and the version of the war is based on the state of the git -repository that is built; you should commit this change before building, otherwise -the version will be marked as 'dirty'. - -== Bazel standalone driven - -Only few plugins support that mode for now: - ----- -cd reviewers -bazel build reviewers ----- - GERRIT ------ Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt index 107473a..1656beb 100644 --- a/Documentation/dev-contributing.txt +++ b/Documentation/dev-contributing.txt
@@ -1,12 +1,16 @@ :linkattrs: = Gerrit Code Review - Contributing -[[cla]] -== Contributor License Agreement +[[prerequisites]] +== Prerequisites -In order to contribute to Gerrit a link:dev-cla.html[Contributor -License Agreement,role=external,window=_blank] must be completed before -contributions are accepted. +In order to contribute to Gerrit and have contributions accepted by +the server, you must with your Google account: + +* complete a link:dev-cla.html[Contributor License Agreement,role=external,window=_blank] +* subscribe to the + link:https://groups.google.com/d/forum/repo-discuss[repo-discuss,role=external,window=_blank] + mailing list [[contribution-processes]] == Contribution Processes
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt index aadfe1c..3b1c8e1 100644 --- a/Documentation/dev-plugins.txt +++ b/Documentation/dev-plugins.txt
@@ -99,14 +99,18 @@ manifest. `Gerrit-Module` supplies bindings to the core server; `Gerrit-SshModule` supplies SSH commands to the SSH server (if enabled); `Gerrit-HttpModule` supplies servlets and filters to the HTTP -server (if enabled). If no modules are named automatic registration -will be performed by scanning all classes in the plugin JAR for +server (if enabled) and `Gerrit-ApiModule` supplies bindings that +exposed to all the other plugins loaded in Gerrit, which can be +used for implementing cross-plugin communication. +If no modules are named automatic registration will be performed +by scanning all classes in the plugin JAR for `@Listen` and `@Export("")` annotations. ---- Gerrit-Module: tld.example.project.CoreModuleClassName Gerrit-SshModule: tld.example.project.SshModuleClassName Gerrit-HttpModule: tld.example.project.HttpModuleClassName +Gerrit-ApiModule: tls.example.project.ApiModuleClassName ---- === Batch runtime @@ -129,7 +133,8 @@ === Cross-Plugin communication -A plugin can optionally declare an API to be used by other plugins. +A plugin can declare an API to be used by other plugins using the `Gerrit-ApiModule` +declaration in the `MANIFEST.MF`: --- Gerrit-ApiModule: tld.example.project.APIClassName @@ -141,6 +146,11 @@ However, these are not the only classes available for consumption; API plugins can also define and provide interfaces and concrete classes for other plugins. +Note that it is also possible to replace DynamicItem bound by one plugin by +another binding in another plugin. This can be used when exposing a DynamicItem +via an API module: we can bind a default impelmentation in the plugin declaring +the DynamicItem and then replace the implementation by another plugin. + This enables plugins to influence other plugins by customizing or extending the their behaviour. @@ -2293,6 +2303,21 @@ } ---- +[[cache-def]] +== Cache Definitions + +Plugins can request a view of the definitions of all registered caches by +injecting a `DynamicMap<CacheDef<?,?>>`. This will allow plugins to inspect what +caches are currently registered and what their schema looks like. + +[source,java] +---- +@Inject +public MyClass(DynamicMap<CacheDef<?, ?>> cacheMap) { + // your code +} +---- + [[secure-store]] == SecureStore
diff --git a/Documentation/dev-release-deploy-config.txt b/Documentation/dev-release-deploy-config.txt index 8ac9f19..7ef1806 100644 --- a/Documentation/dev-release-deploy-config.txt +++ b/Documentation/dev-release-deploy-config.txt
@@ -1,8 +1,8 @@ :linkattrs: = Deploy Gerrit Artifacts -[[deploy-configuration-setting-maven-central]] -== Deploy Configuration settings for Maven Central +[[create-maven-central-credentials]] +== Create Maven Central credentials Some Gerrit artifacts (e.g. the Gerrit WAR file, the Gerrit Plugin API and the Gerrit Extension API) are published on Maven Central in the @@ -13,37 +13,31 @@ * Create an account by following instructions at link:https://central.sonatype.org/register/central-portal/#create-an-account[Maven -Central Poral,role=external,window=_blank]. +Central Portal,role=external,window=_blank]. + Sonatype is the company that runs Maven Central and you need a Sonatype account to be able to upload artifacts to Maven Central. + Once you login you must generate a new user token that allows you to make requests via the publisher API. +You can follow instructions link:https://central.sonatype.org/publish/generate-portal-token/[here] +on how to do this. + -You can generate a token -link:https://central.sonatype.com/account[here,role=external,window=_blank] - -* Configure your Portal user user and token in `~/.m2/settings.xml`: +Take note of this user and token. You will need to use them when running the release +pipeline as described in the link:dev-release.html#gerrit-release-job[relevant documentation]. + ----- -<server> - <id>OSSRH-staging</id> - <username>USER</username> - <password>PASSWORD</password> -</server> ----- - * Request permissions to upload artifacts to the `com.google.gerrit` repository on Maven Central: + -Ask for this permission by adding a comment on the -link:https://issues.sonatype.org/browse/OSSRH-7392[OSSRH-7392,role=external,window=_blank] Jira -ticket at Sonatype. +To acquire permission to upload artifacts to the `com.google.gerrit` repository on Maven Central +You must follow instructions +link:https://central.sonatype.org/faq/what-happened-to-issues-sonatype-org/#i-used-to-registerupdate-my-ossrh-account-at-issuessonatypeorg-what-do-i-do-now[here]. + + The request needs to be approved by someone who already has this permission by commenting on the same issue. +[[generate-and-publish-gpg-key]] * Generate and publish a PGP key + A PGP key is needed to be able to sign the release artifacts before @@ -60,26 +54,8 @@ link:https://gerrit.googlesource.com/homepage/+/master/pages/site/releases/public-keys.md[key list,role=external,window=_blank] on the homepage. + -The PGP passphrase can be put in `~/.m2/settings.xml`: -+ ----- -<settings> - <profiles> - <profile> - <id>gpg</id> - <properties> - <gpg.executable>gpg2</gpg.executable> - <gpg.passphrase>mypassphrase</gpg.passphrase> - </properties> - </profile> - </profiles> - <activeProfiles> - <activeProfile>gpg</activeProfile> - </activeProfiles> -</settings> ----- -+ -It can also be included in the key chain on OS X. +The GPG private key and your passphrase will be required when running the release +pipeline as described in the link:dev-release.html#gerrit-release-job[relevant documentation]. [[deploy-configuration-settings-xml]] == Deploy Configuration in Maven `settings.xml`
diff --git a/Documentation/dev-release-subproject.txt b/Documentation/dev-release-subproject.txt index fad1681..6224732 100644 --- a/Documentation/dev-release-subproject.txt +++ b/Documentation/dev-release-subproject.txt
@@ -32,8 +32,8 @@ mvn deploy ---- -* Change the `id`, `bin_sha1`, and `src_sha1` values in the `maven_jar` -for the subproject in `/WORKSPACE` to the `SNAPSHOT` version. +* Change the version of the subproject in either `tools/deps.toml` or +`tools/nongoogle.toml` to the `SNAPSHOT` version. + When Gerrit gets released, a release of the subproject has to be done and Gerrit has to reference the released subproject version.
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt index 85371100..b3f001a 100644 --- a/Documentation/dev-release.txt +++ b/Documentation/dev-release.txt
@@ -127,99 +127,78 @@ submitted timely. The final (links) change may take more time to complete, as this underlying release process unfolds. +[[gerrit-release-job]] == Create the Actual Release -[[update-versions]] -=== Update Versions and Create Release Tag +There are several steps involved in creating the actual release. +In order to minimise room for error, the release should be done by executing +a fully automated jenkins job. -Before doing the release build, the `GERRIT_VERSION` in the `version.bzl` -file must be updated, e.g. change it from `$version-SNAPSHOT` to `$version`. +The Jenkins job is defined +link:https://gerrit.googlesource.com/gerrit-ci-scripts/+/refs/heads/master/jenkins-internal/gerrit-release.groovy[here]. -In addition the version must be updated in a number of `*_pom.xml` files. +Ideally, for security reasons, it should be run from a node that is unreachable from the internet, +is not the local laptop normally used for other purposes and does not receive +any data from external sources, such as e-mails or other messages, unless the +data is explicitly downloaded as part of the release process. +The following credentials must be made available in the Jenkins vault, with the following credential ids. -To do this run the `./tools/version.py` script and provide the new -version as parameter, e.g.: +* ossrh-staging-api.central.sonatype.com ----- - version=2.15 - ./tools/version.py $version ----- +The `userAndPassword` secret populated with the credentials you created following the instructions defined +link:dev-release-deploy-config.html#create-maven-central-credentials[here] +and should be created _ad-hoc_ for the release plan execution and *not reused* +from previous releases. -Commit the changes and create a signed release tag on the new commit: +> **NOTE**: As soon as the full release plan is complete, the Maven credentials +> should be destroyed and removed from the Maven portal to minimize the risk +> of a [supply-chain attack](https://www.sonatype.com/blog/ongoing-npm-software-supply-chain-attack-exposes-new-risks). ----- - git tag -s -m "v$version" "v$version" ----- +* gerrit.googlesource.com -If unable to tag, make sure that git is locally -link:https://medium.com/@rwbutler/signing-commits-using-gpg-on-macos-7210362d15[ -configured with your user's key,role=external,window=_blank]. These are the -macOS instructions but such commands should be portable enough. Setting -`GPG_TTY` this way or similar might also be necessary: +The `userAndPassword` secret populated with the user and password of the git identity that will be used +to read, push and sign tags during the gerrit release. +Note that this user has to match the identity associated to the GPG credentials used for signing. ----- - export GPG_TTY=$(tty) ----- +> **NOTE**: Do not use the maintainer credentials to minimize the collateral +> damage if these are stolen. -Tag the plugins: +* gpg-key ----- - git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"' ----- +A `secret` text credentials containing the GPG private key obtained when +link:dev-release-deploy-config.html#generate-and-publish-gpg-key[generating the GPG key]. -[[build-gerrit]] -=== Build Gerrit +It requires the following parameters: -* Build the Gerrit WAR, API JARs and documentation: -+ ----- - bazel build release Documentation:searchfree - ./tools/maven/api.sh war_install - ./tools/maven/api.sh install ----- +* GCLOUD_AUTH_TOKEN -* Verify the WAR version: -+ ----- - java -jar bazel-bin/release.war --version ----- +the Gcloud authentication token used to upload the gerrit.war and its documentation to gcloud. +The Gcloud Auth token is obtained via 'gcloud auth login' and then 'gcloud auth print-access-token'. -* Try upgrading a test site and launching the daemon. +* GPG_PASSPHRASE -* Verify the plugin versions: -+ ----- - java -jar bazel-bin/release.war init --list-plugins ----- +The GPG passphrase obtained when link:dev-release-deploy-config.html#generate-and-publish-gpg-key[generating the GPG +key]. -[[publish-gerrit]] -=== Publish the Gerrit Release +* VERSION +Gerrit semantic release number to upgrade to. +Example: `3.13.0-rc2`. + +* BRANCH +Gerrit branch name where the release must be cut from +Example: `master` or `stable-3.12`. + +* NEXT_VERSION: +Next SNAPSHOT version after release. +Example: 3.12.3-SNAPSHOT + +* MIGRATION_VERSION: +The release job will run a test migration from an earlier Gerrit version performing some base smoke tests. +Example: 3.12.2 [[publish-to-maven-central]] -==== Publish the Gerrit artifacts to Maven Central -* Make sure you have done the -link:dev-release-deploy-config.html#deploy-configuration-setting-maven-central[ -configuration] for deploying to Maven Central. - -* Make sure that the version is updated in the `version.bzl` file and in -the `*_pom.xml` files as described in the link:#update-versions[Update -Versions and Create Release Tag] section. - -* Push the WAR to Maven Central: -+ ----- - ./tools/maven/api.sh war_deploy ----- - -* Push the plugin artifacts to Maven Central: -+ ----- - ./tools/maven/api.sh deploy ----- - -* To where the artifacts are uploaded depends on the `GERRIT_VERSION` in -the `version.bzl` file: +* To where the artifacts are uploaded depends on the `VERSION` provided as parameter. ** SNAPSHOT versions are directly uploaded into the Sonatype snapshots repository and no further action is needed: @@ -231,12 +210,10 @@ * Verify the staging repository -** Go to the link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank] and +** Go to the link:https://central.sonatype.com/publishing[Maven Central Repository,role=external,window=_blank] and sign in with your Sonatype credentials. -** Click on 'Build Promotion' in the left navigation bar under -'Staging Repositories' and find the `comgooglegerrit-XXXX` staging -repository. +** In the `Deployment Info` section and you will find the `com.google.gerrit` staging repository. ** Verify its content + @@ -244,19 +221,17 @@ also replace uploaded artifacts. If something is wrong with the staging repository you can drop it by selecting it and clicking on `Drop`. -** Run Sonatype validations on the staging repository +** Run Maven Central validations on the staging repository + -Select the staging repository and click on `Close`. This runs the -Sonatype validations on the staging repository. The repository will +Select the staging repository and click on `Publish`. This runs the +Maven Central validations on the staging repository. The repository will only be closed if everything is OK. A closed repository cannot be modified anymore, but you may still drop it if you find any issues. ** Test closed staging repository + -Once a repository is closed you can find the URL to it in the `Summary` -section, e.g. https://oss.sonatype.org/content/repositories/comgooglegerrit-1029[role=external,window=_blank]. -+ -Use this URL for further testing of the artifacts in this repository, +You can download any artifact from the `Component Summary` section for further +testing of the artifacts in this repository, e.g. to try building a plugin against the plugin API in this repository update the version in the `*_pom.xml` and configure the repository: + @@ -300,16 +275,6 @@ ** Select `com.google.gerrit` as `Project`. -[[publish-to-google-storage]] -==== Publish the Gerrit WAR to the Google Cloud Storage - -* Go to the link:https://console.cloud.google.com/storage/browser/gerrit-releases/?project=api-project-164060093628[ -gerrit-releases bucket in the Google cloud storage console,role=external,window=_blank]. - -* Make sure you are signed in with your Gmail account. - -* Manually upload the Gerrit WAR file by using the `Upload` button. - [[push-stable]] ==== Push the Stable Branch @@ -323,21 +288,6 @@ * Create a change updating the `defaultbranch` field in the `.gitreview` to match the branch name created. -[[push-tag]] -==== Push the Release Tag - -Push the new Release Tag: - ----- - git push gerrit-review tag v$version ----- - -Push the new Release Tag on the plugins: - ----- - git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"' ----- - [[publish-typescript-plugin-api]] ==== Publish TypeScript Plugin API @@ -352,17 +302,6 @@ link:https://gerrit-review.googlesource.com/c/gerrit/+/340069[ example change,role=external,window=_blank] -[[upload-documentation]] -==== Upload the Documentation - -* Extract the documentation files from the zip file generated from -`bazel build searchfree`: `bazel-bin/Documentation/searchfree.zip`. - -* Upload the files manually via web browser to the appropriate folder -in the -link:https://console.cloud.google.com/storage/browser/gerrit-documentation/?project=api-project-164060093628[ -gerrit-documentation,role=external,window=_blank] storage bucket. - [[finalize-release-notes]] === Finalize the Release Notes @@ -399,22 +338,6 @@ ~/gerrit-release-tools/release-announcement.py --help ---- -[[increase-version]] -=== Increase Gerrit Version for Current Development - -All new development that is done in the `master` branch will be included in the -next Gerrit release. The Gerrit version should be set to the snapshot version -for the next release. - -Use the `version` tool to set the version in the `version.bzl` file: - ----- - ./tools/version.py 2.6-SNAPSHOT ----- - -Verify that the changes made by the tool are sane, then commit them, push -the change for review on the master branch, and get it merged. - [[merge-stable]] === Merge `stable` into `master`
diff --git a/Documentation/dev-roles.txt b/Documentation/dev-roles.txt index 36fd46e..d1fee3f 100644 --- a/Documentation/dev-roles.txt +++ b/Documentation/dev-roles.txt
@@ -205,7 +205,7 @@ link:https://calendar.google.com/calendar?cid=Z29vZ2xlLmNvbV91YmIxcGxhNmlqNzg1b3FianI2MWg0dmRpc0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t[ community calendar,role=external,window=_blank] * discuss with other maintainers on the private maintainers mailing - list and Slack channel + list and Discord channel In addition, maintainers from Google can:
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt index baf8e6a..9cfed09 100644 --- a/Documentation/js_licenses.txt +++ b/Documentation/js_licenses.txt
@@ -751,56 +751,9 @@ ---- -[[Polymer-2014]] -Polymer-2014 - -* @polymer/paper-ripple -* @polymer/paper-styles - -[[Polymer-2014_license]] ----- -Copyright (c) 2014 The Polymer Project Authors. All rights reserved. - -This code may only be used under the BSD style license found at -http://polymer.github.io/LICENSE.txt The complete set of authors may be found at -http://polymer.github.io/AUTHORS.txt The complete set of contributors may be -found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as -part of the polymer project is also subject to an additional IP rights grant -found at http://polymer.github.io/PATENTS.txt - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ----- - - [[Polymer-2015]] Polymer-2015 -* @polymer/font-roboto * @polymer/font-roboto-local - only the following file(s): ** README.md ** bower.json @@ -813,16 +766,6 @@ ** package.json ** roboto.js ** update-fonts.sh -* @polymer/iron-a11y-keys-behavior -* @polymer/iron-behaviors -* @polymer/iron-checked-element-behavior -* @polymer/iron-flex-layout -* @polymer/iron-form-element-behavior -* @polymer/iron-meta -* @polymer/iron-validatable-behavior -* @polymer/paper-behaviors -* @polymer/paper-button -* @polymer/paper-item [[Polymer-2015_license]] ---- @@ -1570,6 +1513,38 @@ ---- +[[highlightjs-ttcn3]] +highlightjs-ttcn3 + +* highlightjs-ttcn3 + +[[highlightjs-ttcn3_license]] +---- +MIT License + +Copyright (c) 2024 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de> + +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. + +---- + + [[highlightjs-vue]] highlightjs-vue @@ -1659,6 +1634,7 @@ ## Marked +Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) Permission is hereby granted, free of charge, to any person obtaining a copy @@ -1681,8 +1657,8 @@ ## Markdown -Copyright © 2004, John Gruber -http://daringfireball.net/ +Copyright © 2004, John Gruber +http://daringfireball.net/ All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt index d907106..7e762ed 100644 --- a/Documentation/licenses.txt +++ b/Documentation/licenses.txt
@@ -49,6 +49,7 @@ * commons:codec * commons:compress * commons:dbcp +* commons:io * commons:lang3 * commons:net * commons:pool @@ -2876,56 +2877,9 @@ ---- -[[Polymer-2014]] -Polymer-2014 - -* @polymer/paper-ripple -* @polymer/paper-styles - -[[Polymer-2014_license]] ----- -Copyright (c) 2014 The Polymer Project Authors. All rights reserved. - -This code may only be used under the BSD style license found at -http://polymer.github.io/LICENSE.txt The complete set of authors may be found at -http://polymer.github.io/AUTHORS.txt The complete set of contributors may be -found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as -part of the polymer project is also subject to an additional IP rights grant -found at http://polymer.github.io/PATENTS.txt - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ----- - - [[Polymer-2015]] Polymer-2015 -* @polymer/font-roboto * @polymer/font-roboto-local - only the following file(s): ** README.md ** bower.json @@ -2938,16 +2892,6 @@ ** package.json ** roboto.js ** update-fonts.sh -* @polymer/iron-a11y-keys-behavior -* @polymer/iron-behaviors -* @polymer/iron-checked-element-behavior -* @polymer/iron-flex-layout -* @polymer/iron-form-element-behavior -* @polymer/iron-meta -* @polymer/iron-validatable-behavior -* @polymer/paper-behaviors -* @polymer/paper-button -* @polymer/paper-item [[Polymer-2015_license]] ---- @@ -3695,6 +3639,38 @@ ---- +[[highlightjs-ttcn3]] +highlightjs-ttcn3 + +* highlightjs-ttcn3 + +[[highlightjs-ttcn3_license]] +---- +MIT License + +Copyright (c) 2024 by sysmocom - s.f.m.c. GmbH <info@sysmocom.de> + +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. + +---- + + [[highlightjs-vue]] highlightjs-vue @@ -3784,6 +3760,7 @@ ## Marked +Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/) Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/) Permission is hereby granted, free of charge, to any person obtaining a copy @@ -3806,8 +3783,8 @@ ## Markdown -Copyright © 2004, John Gruber -http://daringfireball.net/ +Copyright © 2004, John Gruber +http://daringfireball.net/ All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt index 299f21c..7da1c18 100644 --- a/Documentation/metrics.txt +++ b/Documentation/metrics.txt
@@ -109,15 +109,29 @@ upload ** `type`: type of push (create/replace, autoclose) + +[[push-latency]] * `receivecommits/latency_per_push`: processing delay for a processing single push ** `type`: type of push (create/replace, autoclose, normal) +** `user_kind`: + User kind (SERVICE_USER: member of the Gerrit internal 'Service Users' group, + HUMAN_USER: any user that was not classified as a service user). + +[[per-change-push-latency]] * `receivecommits/latency_per_push_per_change`: Processing delay per push divided by the number of changes in said push. (Only includes pushes which contain changes.) ** `type`: type of push (create/replace, autoclose, normal) +** `user_kind`: + User kind (SERVICE_USER: member of the Gerrit internal 'Service Users' group, + HUMAN_USER: any user that was not classified as a service user). + +* `receivecommits/latency_for_scheduling`: Delay for scheduling ReceiveCommits + (how long it takes from ReceiveCommits being submitted to the executor to the + executor running it). * `receivecommits/timeout`: rate of push timeouts * `receivecommits/ps_revision_missing`: errors due to patch set revision missing * `receivecommits/push_count`: number of pushes @@ -128,6 +142,8 @@ ** `type`: The type of the update (CREATE, UPDATE, CREATE/UPDATE, UPDATE_NONFASTFORWARD, DELETE). + +[[push-reject-count]] * `receivecommits/reject_count`: number of rejected pushes ** `kind`: The push kind ('magic push'/'magic push by service user' if it was a push for @@ -310,9 +326,19 @@ HTTP status code ** `cause`: The cause of the error. + +[[rest-api-latency]] * `http/server/rest_api/server_latency`: REST API call latency by view. ** `view`: view implementation class +** `access_path`: + The access path through which the user accessed Gerrit (REST_API, WEB_BROWSER + or UNKNOWN). +** `user_kind`: + User kind (SERVICE_USER: member of the Gerrit internal 'Service Users' group, + HUMAN_USER: any user that was not classified as a service user and anonymous + users). + * `http/server/rest_api/response_bytes`: Size of REST API response on network (may be gzip compressed) by view. ** `view`: @@ -364,12 +390,13 @@ Each queue provides the following metrics: -* `queue/<queue_name>/pool_size`: Current number of threads in the pool -* `queue/<queue_name>/max_pool_size`: Maximum allowed number of threads in the +* `queue/<queue_name>/pool_size`: Current number of non parked threads in the pool +* `queue/<queue_name>/max_pool_size`: Maximum allowed number of non parked threads in the pool -* `queue/<queue_name>/active_threads`: Number of threads that are actively +* `queue/<queue_name>/parked_threads`: Current number of threads that are parked +* `queue/<queue_name>/active_threads`: Current number of threads that are actively executing tasks -* `queue/<queue_name>/scheduled_tasks`: Number of scheduled tasks in the queue +* `queue/<queue_name>/scheduled_tasks`: Current number of scheduled tasks in the queue * `queue/<queue_name>/total_scheduled_tasks_count`: Total number of tasks that have been scheduled * `queue/<queue_name>/total_completed_tasks_count`: Total number of tasks that
diff --git a/Documentation/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt index 25eedf2..2c9fa6d 100644 --- a/Documentation/pg-plugin-dev.txt +++ b/Documentation/pg-plugin-dev.txt
@@ -166,14 +166,6 @@ The low-level DOM API methods are the base of all UI customization. -=== attributeHelper -`plugin.attributeHelper(element)` - -Alternative for -link:https://polymer-library.polymer-project.org/3.0/docs/devguide/data-binding[Polymer data -binding,role=external,window=_blank] for plugins that don't use Polymer. Can be used to bind element -attribute changes to callbacks. - === hook `plugin.hook(endpointName, opt_options)`
diff --git a/Documentation/release_war_jars.txt b/Documentation/release_war_jars.txt new file mode 100644 index 0000000..76927bf --- /dev/null +++ b/Documentation/release_war_jars.txt
@@ -0,0 +1,89 @@ +antlr-runtime +aopalliance +apache-mime4j-core +apache-mime4j-dom +args4j +asm +asm-analysis +asm-commons +asm-tree +asm-util +autolink +automaton-1.12 +bcpg-jdk18on +bcpkix-jdk18on +bcprov-jdk18on +bcutil-jdk18on +blame-cache +caffeine +caffeine-guava +commons-codec +commons-compress +commons-dbcp +commons-io +commons-lang3 +commons-net +commons-pool +commons-text +commons-validator +failureaccess +flexmark-all-0.64.0-lib +flogger +flogger-log4j-backend +flogger-system-backend +google-extensions +gson +guava-33.5.0-jre +guava-retrying +guice +guice-assistedinject +guice-servlet +h2 +httpclient +httpcore +icu4j +j2objc-annotations +jakarta.inject-api +JavaEWAH +javax.inject +javax.servlet-api +jcl-over-slf4j +jetty-http +jetty-io +jetty-jmx +jetty-security +jetty-server +jetty-servlet +jetty-util +jetty-util-ajax +jgit +jsoup +jsr305 +lucene-analysis-common +lucene-backward-codecs +lucene-core +lucene-misc +lucene-queryparser +metrics-core +mime-util +mina-core +nekohtml +openid4java +org_apache_commons_net_libnet +prolog-cafeteria +prolog-compiler +prolog-io +prolog-runtime +protobuf-java +reload4j +RoaringBitmap +shims +slf4j-api +slf4j-reload4j +soy-2024-01 +sshd-mina +sshd-osgi +sshd-sftp +types +xercesImpl +xz
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt index c2ad7a0..637318c 100644 --- a/Documentation/rest-api-accounts.txt +++ b/Documentation/rest-api-accounts.txt
@@ -1532,6 +1532,7 @@ "allow_browser_notifications": true, "allow_suggest_code_while_commenting": true, "allow_autocompleting_comments": true, + "ai_chat_selected_model": "test-ai-model", "diff_page_sidebar": "plugin-foo", "default_base_for_merges": "FIRST_PARENT", "my": [ @@ -1588,6 +1589,7 @@ "allow_browser_notifications": false, "allow_suggest_code_while_commenting": false, "allow_autocompleting_comments": false, + "ai_chat_selected_model": "test-ai-model", "diff_page_sidebar": "NONE", "diff_view": "SIDE_BY_SIDE", "mute_common_path_prefixes": true, @@ -3005,6 +3007,9 @@ |`allow_autocompleting_comments` |not set if `false`| Whether to receive autocompletions while writing comments. This feature needs a plugin implementation. +|`ai_chat_selected_model` |optional| +The name of the AI model selected for the AI chat. This feature needs a plugin +implementation. |`diff_page_sidebar` |optional| String indicating which sidebar should be open on the diff page. Set to "NONE" if no sidebars should be open. Plugin-supplied sidebars will be prefixed with
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt index 4598b88..7466e65 100644 --- a/Documentation/rest-api-changes.txt +++ b/Documentation/rest-api-changes.txt
@@ -350,7 +350,9 @@ [[submittable]] -- * `SUBMITTABLE`: include the `submittable` field in link:#change-info[ChangeInfo], - which can be used to tell if the change is reviewed and ready for submit. + which can be used to tell if the change is reviewed and ready for submit. Only + changes with `status` is `NEW` can be submittable, for `MERGED` and + `ABANDONED` changes this alway evaluates to `false`. -- [[web-links]] @@ -3845,6 +3847,35 @@ If no flow service is bound (i.e. if no plugin that provides a flow service is installed) `405 Method Not Allowed` is returned. +[[flows-actions]] +=== List Flows Actions +-- +'GET /changes/link:#change-id[{change-id}]/flows-actions/' +-- + +Lists the flows actions that are configured for the given change. + +As result a list of link:#flow-action-type-info[FlowActionTypeInfo] entries is returned. + +.Request +---- + GET /changes/myProject~65178/flows-actions/ HTTP/1.0 +---- + +.Response +---- + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + [ + { + "name": "add-reviewer" + } + ] +---- + [[create-flow]] === Create Flow -- @@ -4410,7 +4441,11 @@ see a merged change which doesn't have the necessary approvals to fulfill the submit requirements. +See the description of the link:access-control.html#category_remove_label[ +Remove Label] permission for when users are allowed to remove votes. + The request returns: + * '204 No Content' if the vote is deleted successfully; * '404 Not Found' when the vote to be deleted is zero or not present. * '409 Conflict' when the change is merged and hence deleting votes is not @@ -4447,6 +4482,134 @@ [[revision-endpoints]] == Revision Endpoints +[[get-revision]] +=== Get Revision +-- +'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]' +-- + +Retrieves a revision of a change. + +.Request +---- + GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1 HTTP/1.0 +---- + +As response a link:#revision-info[RevisionInfo] entity is returned that +describes the revision. All fields that require a link:#query-options[change +query option] to be set when the `RevisionInfo` is returned as part of +`ChangeInfo` are populated. + +.Response +---- + HTTP/1.1 200 OK + Content-Disposition: attachment + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "kind": "REWORK", + "_number": 1, + "ref": "refs/changes/97/97/1", + "fetch": { + "git": { + "url": "git://localhost/gerrit", + "ref": "refs/changes/97/97/1", + "commands": { + "Checkout": "git fetch git://localhost/gerrit refs/changes/97/97/1 \u0026\u0026 git checkout FETCH_HEAD", + "Cherry-Pick": "git fetch git://localhost/gerrit refs/changes/97/97/1 \u0026\u0026 git cherry-pick FETCH_HEAD", + "Format-Patch": "git fetch git://localhost/gerrit refs/changes/97/97/1 \u0026\u0026 git format-patch -1 --stdout FETCH_HEAD", + "Pull": "git pull git://localhost/gerrit refs/changes/97/97/1" + } + }, + "http": { + "url": "http://myuser@127.0.0.1:8080/gerrit", + "ref": "refs/changes/97/97/1", + "commands": { + "Checkout": "git fetch http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1 \u0026\u0026 git checkout FETCH_HEAD", + "Cherry-Pick": "git fetch http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1 \u0026\u0026 git cherry-pick FETCH_HEAD", + "Format-Patch": "git fetch http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1 \u0026\u0026 git format-patch -1 --stdout FETCH_HEAD", + "Pull": "git pull http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1" + } + }, + "ssh": { + "url": "ssh://myuser@*:29418/gerrit", + "ref": "refs/changes/97/97/1", + "commands": { + "Checkout": "git fetch ssh://myuser@*:29418/gerrit refs/changes/97/97/1 \u0026\u0026 git checkout FETCH_HEAD", + "Cherry-Pick": "git fetch ssh://myuser@*:29418/gerrit refs/changes/97/97/1 \u0026\u0026 git cherry-pick FETCH_HEAD", + "Format-Patch": "git fetch ssh://myuser@*:29418/gerrit refs/changes/97/97/1 \u0026\u0026 git format-patch -1 --stdout FETCH_HEAD", + "Pull": "git pull ssh://myuser@*:29418/gerrit refs/changes/97/97/1" + } + } + }, + "commit": { + "commit": "674ac754f91e64a0efb8087e59a176484bd534d1", + "parents": [ + { + "commit": "1eee2c9d8f352483781e772f35dc586a69ff5646", + "subject": "Migrate contributor agreements to All-Projects." + } + ], + "author": { + "name": "Shawn O. Pearce", + "email": "sop@google.com", + "date": "2012-04-24 18:08:08.000000000", + "tz": -420 + }, + "committer": { + "name": "Shawn O. Pearce", + "email": "sop@google.com", + "date": "2012-04-24 18:08:08.000000000", + "tz": -420 + }, + "subject": "Use an EventBus to manage star icons", + "message": "Use an EventBus to manage star icons\n\nImage widgets that need to ..." + }, + "files": { + "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeCache.java": { + "lines_deleted": 8, + "size_delta": -412, + "size": 7782 + }, + "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeDetailCache.java": { + "lines_inserted": 1, + "size_delta": 23, + "size": 6762 + }, + "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeScreen.java": { + "lines_inserted": 11, + "lines_deleted": 19, + "size_delta": -298, + "size": 47023 + }, + "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeTable.java": { + "lines_inserted": 23, + "lines_deleted": 20, + "size_delta": 132, + "size": 17727 + }, + "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarCache.java": { + "status": "D", + "lines_deleted": 139, + "size_delta": -5512, + "size": 13098 + }, + "gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java": { + "status": "A", + "lines_inserted": 204, + "size_delta": 8345, + "size": 8345 + }, + "gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/Screen.java": { + "lines_deleted": 9, + "size_delta": -343, + "size": 5385 + } + } + } +---- + [[get-commit]] === Get Commit -- @@ -7415,6 +7578,9 @@ |`branch` || The name of the target branch. + The `refs/heads/` prefix is omitted. +|`full_branch` || +The full name of the target branch. + +Always starts with `refs/`. |`topic` |optional|The topic to which this change belongs. |`attention_set` |optional| The map that maps link:rest-api-accounts.html#account-id[account IDs] @@ -7490,12 +7656,15 @@ Actions the caller might be able to perform on this revision. The information is a map of view name to link:#action-info[ActionInfo] entities. -|`submit_records` || +|`submit_records` |optional| List of the link:rest-api-changes.html#submit-record-info[SubmitRecordInfo] -containing the submit records for the change at the latest patchset. +containing the submit records for the change at the latest patchset. This field +is deprecated in favour of `submit_requirements`. +Only set if link:#submit-requirements[`SUBMIT_REQUIREMENTS`] is requested. |`requirements` |optional| List of the link:rest-api-changes.html#requirement[requirements] to be met before this change can be submitted. This field is deprecated in favour of `submit_requirements`. +Only set if link:#submit-requirements[`SUBMIT_REQUIREMENTS`] is requested. |`submit_requirements` |optional| List of the link:#submit-requirement-result-info[SubmitRequirementResultInfo] containing the evaluated submit requirements for the change. @@ -7579,6 +7748,8 @@ When present, change is marked as Work In Progress. |`has_review_started` |optional, not set if `false`| When present, change has been marked Ready at some point in time. +|`can_ai_review` |optional| +Set to `true` if AI review is allowed on the change. |`revert_of` |optional| The change number of the change that this change reverts. |`submission_id` |optional| @@ -8540,6 +8711,17 @@ are supported and their format depends on the flow service implementation. |========================= +[[flow-action-type]] +=== FlowActionTypeInfo +The `FlowActionTypeInfo` entity describes an action that may be used in a flow. + +[options="header",cols="1,6"] +|========================= +|Field Name |Description +|`name` |The name of the action. +|========================= + + [[flow-expression-info]] === FlowExpressionInfo The `FlowExpressionInfo` entity contains information about a flow expression. A @@ -8633,7 +8815,9 @@ === IncludedInInfo The `IncludedInInfo` entity contains information about the branches a change was merged into and tags it was tagged with. The branch or tag -must have 'refs/head/' or 'refs/tags/' prefix respectively. +must have 'refs/heads/' or 'refs/tags/' prefix respectively. + +Branch and tag lists are sorted in lexicographical order. [options="header",cols="1,^1,5"] |======================= @@ -9166,6 +9350,12 @@ |`updated_by`| The account which modified state of the reviewer in question as link:rest-api-accounts.html#account-info[AccountInfo] entity. +|`real_updated_by`| +The account which actually modified the state of the reviewer in question as +link:rest-api-accounts.html#account-info[AccountInfo] entity. This will be +different from `updated_by` in case of impersonation. For example, if Alice +impersonates Bob and changes the state of a reviewer, `updated_by` will be +Bob and `real_updated_by` will be Alice. |`reviewer`| The reviewer added or removed from the change as an link:rest-api-accounts.html#account-info[AccountInfo] entity. For @@ -9327,6 +9517,11 @@ The Gerrit server may be configured to link:config-gerrit.html#addreviewer.maxWithoutConfirmation[require a confirmation] when adding a group as reviewer that has many members. +|`on_behalf_of` |optional| +link:rest-api-accounts.html#account-id[\{account-id\}] the reviewer +should be added on behalf of. To use this option the caller must +have been granted `RUN_AS` permission. + +If not set, the default is the caller. |`notify` |optional| Notify handling that defines to whom email notifications should be sent after the reviewer is added. +
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt index dbbf0e32..e8bb0f7 100644 --- a/Documentation/rest-api-config.txt +++ b/Documentation/rest-api-config.txt
@@ -1860,6 +1860,55 @@ )]}' ---- +[[flush-index]] +=== Flush Index +-- +'POST /config/server/indexes/link:#index-name[{index-name}]/flush' +-- + +Flushes all pending index updates to persistent storage immediately. +In contrast to link:config-gerrit.html#index.name.commitWithin[index.name.commitWithin], +which schedules index commits, this API forces the flush at call time. + +This endpoint allows administrators to explicitly finalize buffered index +updates. While Gerrit normally manages index commits automatically, this +endpoint can be used for maintenance or integration workflows that require +all pending updates to be durably written. + +This endpoint is available for all index types whose backend supports the `flushAndCommit` capability: + +* `changes` +* `accounts` +* `groups` +* `projects` + +The default Lucene index backend supports `flushAndCommit`. + +This endpoint requires the +link:access-control.html#capability_maintainServer[Maintain Server] +capability. + +.Request +---- + POST /config/server/indexes/changes/flush HTTP/1.1 +---- + +.Response +---- + HTTP/1.1 204 No Content +---- + +.Error Responses + +* `404 Not Found` + Returned if the specified index name does not exist. + +* `501 Not Implemented` + Returned if the index backend does not support the `flushAndCommit` operation. + +* `500 Internal Server Error` + Returned if an `IOException` occurs while flushing or committing index data. + [[cleanup.changes]] === Cleanup of stale changes
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt index 4a0a602..e265eaa 100644 --- a/Documentation/rest-api-projects.txt +++ b/Documentation/rest-api-projects.txt
@@ -4149,6 +4149,68 @@ } ---- +[[migrate-labels]] +=== Migrate label functions to submit requirements +-- +'POST /projects/link:#project-name[\{project-name\}]/migrate-labels' +-- + +Migrates labels with functions to submit requirements. The migration result is +committed into the `refs/meta/config` branch and thus immediately active. As a +response it returns link:#migrate-labels-info[MigrateLabelsInfo] entity +describing the outcome of the migration. + +The caller must be a project owner. + +.Request +---- + POST /projects/testproj/migrate-labels HTTP/1.0 + Content-Type: application/json; charset=UTF-8 +---- + +.Response +---- + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + )]}' + {"status": "MIGRATED"} +---- + + +[[migrate-labels-change]] +=== Create change which migrate label functions to submit requirements +-- +'POST /projects/link:#project-name[\{project-name\}]/migrate-labels:review' +-- + +Creates a change for review which migrates labels with functions to submit requirements. +As a response it returns link:#migrate-labels-review-info[MigrageLabelsReviewInfo] entity +describing the outcome of the migration. + +.Request +---- + POST /projects/testproj/migrate-labels HTTP/1.0 + Content-Type: application/json; charset=UTF-8 +---- + +.Response +---- + HTTP/1.1 200 OK + Content-Type: application/json; charset=UTF-8 + + )]}' + { + "status": "MIGRATED", + "change": { + "id": "testproj~12345", + ... + } + } +---- + + + [[ids]] == IDs @@ -4952,11 +5014,14 @@ |============================= |Field Name | |Description |`remove` |optional| -A list of deductions to be applied to the project access as -link:rest-api-access.html#project-access-info[ProjectAccessInfo] entities. +A map of deductions to be applied to the project access, mapping refs to +link:rest-api-access.html#access-section-info[AccessSectionInfo] entities. +AccessSectionInfo entities can either be empty (whole section removed), +individual permissions can be specified without rules (so whole permission +setting removed), or fully specific down to the rule level. |`add` |optional| -A list of additions to be applied to the project access as -link:rest-api-access.html#project-access-info[ProjectAccessInfo] entities. +A map of additions to be applied to the project access, mapping refs to +link:rest-api-access.html#access-section-info[AccessSectionInfo] entities. |`message` |optional| A commit message for this change. |`parent` |optional| @@ -5265,6 +5330,40 @@ a date in the future. |========================= + +[[migrate-labels-info]] +=== MigrateLabelsInfo +The `MigrateLabelsInfo` entity contains information about an outcome of labels +function migration. + +[options="header",cols="1,^2,4"] +|============================= +|Field Name ||Description +|`status` ||The status of the migration. Takes one of the following values: +`MIGRATED`, +`HAS_PROLOG`, +`PREVIOUSLY_MIGRATED`, +`NO_CHANGE` +|============================= + +[[migrate-labels-review-info]] +=== MigrateLabelsReviewInfo +The `MigrateLabelsReviewInfo` entity contains information about an outcome of creating +a change for labels function migration. + +[options="header",cols="1,^2,4"] +|============================= +|Field Name ||Description +|`status` ||The status of the migration. Takes one of the following values: +`MIGRATED`, +`HAS_PROLOG`, +`PREVIOUSLY_MIGRATED`, +`NO_CHANGE` +|`change` |optional|The change created. +It is a link:rest-api-changes.html#change-info[ChangeInfo] entity +and is set only when the `status` value is `MIGRATED`. +|============================= + GERRIT ------ Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-request-cancellation-and-deadlines.txt b/Documentation/user-request-cancellation-and-deadlines.txt index 487bc13..8726564 100644 --- a/Documentation/user-request-cancellation-and-deadlines.txt +++ b/Documentation/user-request-cancellation-and-deadlines.txt
@@ -17,9 +17,7 @@ waiting for the response, Gerrit shouldn't spend resources to compute it. Detecting cancelled requests is not easily possible with all protocols that a -client may use. At the moment Gerrit only detects request cancellations for git -pushes, but not for other request types (in particular cancelled requests are -not detected for REST calls over HTTP, SSH commands and git clone/fetch). +client may use. [[server-side-deadlines]] == Server-side deadlines @@ -30,6 +28,15 @@ automatically aborted. In this case the client gets a proper error message informing the user about the exceeded deadline. +[NOTE] +==== +Deadlines are enforced cooperatively and may not interrupt execution immediately. +Cancellation is only evaluated at explicit check points; long-running blocking +operations may continue until the next check. +Admins should not assume a hard kill after exceeding deadline, and they should +not rely on deadlines as a precise resource cutoff. +==== + Clients may override server-side deadlines by setting a link:#client-provided-deadlines[deadline] on the request. This means, if a request fails due to an exceeded server-side deadline, the client may repeat the @@ -164,7 +171,10 @@ |======================= This means clients always get a proper error message telling the user why the -request has been aborted. +request has been aborted. A deadline response indicates an exceeded deadline, +not the actual execution outcome or that all work was rolled back. +Clients should treat deadline errors as indeterminate and not assume success or +failure. Errors due to aborted requests are usually not counted as internal server errors, but the link:metrics.html#cancellations[cancellation metrics] may be used to
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt index e95d823..8197a96 100644 --- a/Documentation/user-search.txt +++ b/Documentation/user-search.txt
@@ -199,6 +199,18 @@ + Changes that have been, or need to be, reviewed by a user in 'GROUP'. +[[reviewercount]] +reviewercount:'RELATION''COUNT':: ++ +True if the number of reviewers satisfies the given relation +for the given number of reviewers. ++ +For example, reviewers:>2 will be true for any change which has at least +3 reviewers. ++ +Valid relations are >=, >, \<=, <, or no relation, which will match if the +number of reviewers is exactly equal. + [[commit]] commit:'SHA-1':: + @@ -758,12 +770,14 @@ * The label name. Example: `label:Code-Review`. -* The label name followed by a ',' followed by a reviewer id or a +* The label name followed by a '&' followed by a reviewer id or a group id. To make it clear whether a user or group is being looked for, precede the value by a user or group argument identifier ('user=' or 'group='). If an LDAP group is being referenced make sure to use 'ldap/<groupname>'. +* ',' has been deprecated as a separator in favour of '&'. + A label name must be followed by either a score with optional operator, or a label status. The easiest way to explain this is by example. @@ -812,20 +826,20 @@ + Matches changes with label voted with any score. -`label:Code-Review=+1,count=2`:: +`label:Code-Review=+1&count=2`:: + Matches changes with exactly two +1 votes to the code-review label. The {MAX, -MIN, ANY} votes can also be used, for example `label:Code-Review=MAX,count=2` is -equivalent to `label:Code-Review=2,count=2` (if 2 is the maximum positive vote +MIN, ANY} votes can also be used, for example `label:Code-Review=MAX&count=2` is +equivalent to `label:Code-Review=2&count=2` (if 2 is the maximum positive vote for the code review label). The maximum supported value for `count` is 5. `count=0` is not allowed and the query request will fail with `400 Bad Request`. -`label:Code-Review=+1,count>=2`:: +`label:Code-Review=+1&count>=2`:: + Matches changes having two or more +1 votes to the code-review label. Can also be used with the {MAX, MIN, ANY} label votes. All operators `>`, `>=`, `<`, `<=` are supported. -Note that a query like `label:Code-Review=+1,count<x` will not match with +Note that a query like `label:Code-Review=+1&count<x` will not match with changes having zero +1 votes to this label. `label:Non-Author-Code-Review=need` (deprecated):: @@ -848,34 +862,34 @@ `OVERRIDDEN`. Users are encouraged not to rely on this operator since submit records are deprecated. -`label:Code-Review=+2,aname`:: -`label:Code-Review=ok,aname`:: +`label:Code-Review=+2&aname`:: +`label:Code-Review=ok&aname`:: + Matches changes with a +2 code review where the reviewer or group is aname. -`label:Code-Review=2,user=jsmith`:: +`label:Code-Review=2&user=jsmith`:: + Matches changes with a +2 code review where the reviewer is jsmith. -`label:Code-Review=+2,user=owner`:: -`label:Code-Review=ok,user=owner`:: -`label:Code-Review=+2,owner`:: -`label:Code-Review=ok,owner`:: +`label:Code-Review=+2&user=owner`:: +`label:Code-Review=ok&user=owner`:: +`label:Code-Review=+2&owner`:: +`label:Code-Review=ok&owner`:: + The special "owner" parameter corresponds to the change owner. Matches all changes that have a +2 vote from the change owner. [[non_uploader]] -`label:Code-Review=+2,user=non_uploader`:: -`label:Code-Review=ok,user=non_uploader`:: -`label:Code-Review=+2,non_uploader`:: -`label:Code-Review=ok,non_uploader`:: +`label:Code-Review=+2&user=non_uploader`:: +`label:Code-Review=ok&user=non_uploader`:: +`label:Code-Review=+2&non_uploader`:: +`label:Code-Review=ok&non_uploader`:: + The special "non_uploader" parameter corresponds to any user who's not the uploader of the latest patchset. Matches all changes that have a +2 vote from a non upoader. -`label:Code-Review=+1,group=ldap/linux.workflow`:: +`label:Code-Review=+1&group=ldap/linux.workflow`:: + Matches changes with a +1 code review where the reviewer is in the ldap/linux.workflow group.
diff --git a/Documentation/user-submodules.txt b/Documentation/user-submodules.txt index a2a080b..072f79d 100644 --- a/Documentation/user-submodules.txt +++ b/Documentation/user-submodules.txt
@@ -218,37 +218,66 @@ several protocols, such as plain git and HTTP(S) as well as SSH, one can use relative submodules. This means that instead of providing the entire URL to the submodule a relative path is stated in the -.gitmodules file. +`.gitmodules` file. -Gerrit will try to match the entire project name of the submodule -including directories. Therefore it is important to supply the full -path name of the Gerrit project, not only relative to the super -repository. See the following example: +For Gerrit to use relative project name matching, the project path need to +start with `../`. Otherwise, Gerrit wouldn't know if the path would be +`super/sub` or `super.git/sub`. See the following example: We have a super repository placed under a sub directory. - +---- product/super_repository.git +---- -To this repository we wish add a submodule "deeper" into the directory -structure. - +To this repository we wish add two submodules. +---- product/framework/subcomponent.git + product/super_repository/deeper.git +---- -Now we need to edit the .gitmodules to include the complete path to -the Gerrit project. Observe that we need to use two "../" to include -the complete Gerrit project path. +Now we need to edit the `.gitmodules` file to include relative paths to +the Gerrit project. Observe that the paths need to start with `../`. +.product/super_repository.git .gitmodules +---- + [submodule "framework/subcomponent"] + path = subcomponent.git + url = ../framework/subcomponent.git + branch = master - path = subcomponent.git - url = ../../product/framework/subcomponent.git - branch = master + [submodule "super_repository/deeper"] + path = deeper.git + url = ../super_repository/deeper.git + branch = . +---- -In contrast the following will not setup proper submodule -subscription, even if the submodule will be successfully cloned by git -from Gerrit. +The following urls also work. +.product/super_repository.git .gitmodules +---- + [submodule "framework/subcomponent"] + path = subcomponent.git + url = ../../product/framework/subcomponent.git + branch = . - path = subcomponent.git - url = ../framework/subcomponent.git - branch = master + [submodule "super_repository/deeper"] + path = deeper.git + url = ../../product/super_repository/deeper.git + branch = master +---- + +In contrast the following urls will not setup proper submodule +subscription. +.INCORRECT .gitmodules +---- + [submodule "framework/subcomponent"] + path = subcomponent.git + url = deeper.git + branch = . + + [submodule "super_repository/deeper"] + path = subcomponent.git + url = ./deeper.git + branch = . +---- == Removing Subscriptions
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt index 415d27c..16ab0d7 100644 --- a/Documentation/user-upload.txt +++ b/Documentation/user-upload.txt
@@ -518,7 +518,7 @@ [[push_replace]] -=== Replace Changes +=== Update Changes To add an additional patch set to a change, ensure Change-Id lines were created in the original commit messages, and just use @@ -719,9 +719,9 @@ For more details on using `repo upload`, see `repo help upload`. [[repo_replace]] -=== Replace Changes +=== Update Changes -To replace changes, ensure Change-Id lines were created in the +To update changes, ensure Change-Id lines were created in the commit messages, and just use `repo upload`. Gerrit Code Review will automatically match the commits back to their original changes by taking advantage of their Change-Id lines.
diff --git a/GEMINI.md b/GEMINI.md index 3103f18..9d27731 100644 --- a/GEMINI.md +++ b/GEMINI.md
@@ -1,93 +1,113 @@ # Gemini Project Profile: gerrit -This document provides a summary of the development environment for the gerrit project. +## Project Overview -## Overview - -Gerrit is a web-based code review tool, which integrates with Git and allows developers to review, approve, and merge code changes. - -- **Primary Language (Backend)**: Java -- **Primary Language (Frontend)**: TypeScript (in `polygerrit-ui`) -- **Build Tool**: Bazel -- **Package Manager**: `yarn` (for frontend dependencies) +Gerrit is a web-based code review system for Git. Backend is Java 21, frontend is TypeScript/Lit components, built with Bazel 7.6.1. ## Sub-projects -This repository contains multiple sub-projects. For more detailed information, please refer to the `GEMINI.md` file within each sub-project directory. - - **`polygerrit-ui`**: The frontend web application. See `polygerrit-ui/GEMINI.md` for details on the frontend development environment. -## Backend (Java) +## Build Commands -The core backend logic is written in Java and is located in the `java/` directory. The main package is `com.google.gerrit`. Key sub-packages include: - -- `acceptance`: Acceptance tests -- `server`: Core server logic, including servlets, REST API endpoints, and change processing. -- `git`: Git-related operations and management. -- `sshd`: SSH server implementation for Git operations and administration. -- `index`: Indexing and search functionality. -- `auth`: Authentication and authorization logic. - -### Testing (Java) - -Java tests are located in the `javatests/` directory, mirroring the structure of the `java/` directory. Key sub-packages include: - -- `acceptance`: Acceptance tests -- `integration`: Integration tests -- `server`: Tests for the core server logic. -- `git`: Tests for Git-related operations. - -### Running Tests - -You can run tests using `bazel test`. - -**Run a specific test:** ```bash -bazel test //javatests/com/google/gerrit/acceptance/rest/project:ListLabelsIT +# Development WAR (output: bazel-bin/gerrit.war) +bazel build gerrit + +# Release WAR with UI, core plugins, docs (output: bazel-bin/release.war) +bazel build release + +# Build specific plugin +bazel build plugins/<name> ``` -**Run a test suite:** +## Running Tests + +### Java Tests + ```bash -bazel test //javatests/com/google/gerrit/httpd:httpd_tests -``` -```bash -bazel test //javatests/com/google/gerrit/acceptance/server/change:server_change +# All tests +bazel test --build_tests_only //... + +# Specific test target +bazel test //javatests/com/google/gerrit/acceptance/rest/account:rest_account + +# Single test method +bazel test --test_output=streamed \ + --test_filter=com.google.gerrit.acceptance.api.change.ChangeIT.getAmbiguous \ + //javatests/com/google/gerrit/acceptance/api/change:ChangeIT + +# Tests by tag (api, git, rest, server, ssh, notedb, edit, pgm, annotation) +bazel test --test_tag_filters=api,git //... + +# Exclude flaky tests +bazel test --test_tag_filters=-flaky //... + +# Force re-run (ignore cache) +bazel test --cache_test_results=NO //... + +# Debug tests (attach debugger to port 5005) +bazel test --java_debug //javatests/... ``` -**Run a specific test method:** -This command runs a single test method and streams the output. +## Linting & Formatting + ```bash -bazel test --test_output=streamed --test_filter=com.google.gerrit.server.fixes.fixCalculator.FixCalculatorVariousTest.intraline //javatests/com/google/gerrit/server:server_tests +# Java - Google Java Format (required) +./tools/gjf.sh setup # First time setup +./tools/gjf.sh run # Format changed files +npm run gjf # Alternative ``` -**Run tests with a filter:** +## Local Development + ```bash -bazel test //javatests/com/google/gerrit/acceptance/rest/change:rest_change_other --test_filter=SuggestReviewersIT +# Set Java path (Java 21) +# Example for macOS ARM64 - check $(bazel info output_base)/external/ for your platform's directory +export JAVA_HOME=$(bazel info output_base)/external/rules_java~~toolchains~remotejdk21_macos_aarch64 +export GERRIT_SITE=~/gerrit_testsite + +# Initialize test site (first time only) +$JAVA_HOME/bin/java -jar bazel-bin/gerrit.war init --batch --dev -d $GERRIT_SITE + +# Run daemon (localhost:8080) +$JAVA_HOME/bin/java \ + -DsourceRoot=$(bazel info workspace) \ + -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE --console-log + +# Run daemon with dev frontend (use frontend from localhost:8081) +$JAVA_HOME/bin/java \ + -DsourceRoot=$(bazel info workspace) \ + -jar bazel-bin/gerrit.war daemon -d $GERRIT_SITE --console-log --dev-cdn http://localhost:8081 ``` -### Code Formatting (Java) +## Code Architecture -The project uses `google-java-format` to format Java code, via the wrapper script `tools/gjf.sh`. +### Backend (java/com/google/gerrit/) -#### Formatting modified Java files +- **server/**: Core server logic, change handling, review workflows +- **httpd/**: REST API endpoints (servlet-based) +- **git/**: JGit wrapper for repository operations +- **index/**: Search backends (Lucene) +- **entities/**: Core domain models (Change, PatchSet, Account) +- **extensions/**: Plugin extension APIs +- **pgm/**: CLI programs and commands +- **acceptance/**: Test acceptance framework -To format all modified Java files: -```bash -tools/gjf.sh run -``` +### Plugins (plugins/) -## Documentation +Core plugins as Git submodules: replication, hooks, delete-project, gitiles, webhooks, etc. +Plugins extend via extension APIs in java/com/google/gerrit/extensions/. -The `Documentation/` directory contains a wealth of information about Gerrit, including: +## Code Style -- **User Guide**: `user-*.txt` files explain how to use Gerrit. -- **Administrator Guide**: `config-*.txt` and `install-*.txt` files provide information for administrators. -- **Developer Guide**: `dev-*.txt` files contain information for developers working on Gerrit itself. -- **REST API**: `rest-api-*.txt` files document the REST API endpoints. -- **Commands**: `cmd-*.txt` files document the available command-line tools. +- Java: Google Java Style Guide, use `./tools/gjf.sh run` before committing +- Commit messages: max 72 chars/line, present tense, include Change-Id (added by git hook) +- **Release-Notes footer required**: Every commit must have `Release-Notes:` footer. Use `Release-Notes: skip` for small fixes/refactorings, or add a summary for notable changes -## Git +## Key Patterns -Every commit message must contain a `Release-Notes:` footer. Small changes, fixes or refactorings can just have -`Release-Notes: skip`. If the change is relevant for being called out in release notes, then append a short -summary to the `Release-Notes:` footer. +- Dependency injection via Guice +- REST endpoints return Java objects serialized to JSON +- Changes stored in NoteDb (Git-based storage) +- Event-driven architecture for plugins
diff --git a/MODULE.bazel b/MODULE.bazel index 0b932b8..1d4a1a7 100644 --- a/MODULE.bazel +++ b/MODULE.bazel
@@ -1,2 +1,46 @@ # TODO(davido): Migrate all dependencies from WORKSPACE to MODULE.bazel # https://issues.gerritcodereview.com/issues/303819949 +module(name = "gerrit") + +# Core Bazel deps. +bazel_dep(name = "bazel_features", version = "1.39.0") +bazel_dep(name = "platforms", version = "1.0.0") +bazel_dep(name = "rules_jvm_external", version = "6.10") + +# Language rules. +bazel_dep(name = "rules_java", version = "8.16.1") +bazel_dep(name = "rules_proto", version = "7.1.0") +bazel_dep(name = "rules_python", version = "1.8.0-rc1") +bazel_dep(name = "rules_shell", version = "0.6.1") + +# Libraries / toolchains. +bazel_dep(name = "protobuf", version = "33.4") + +# In-tree modules. +bazel_dep(name = "jgit") +local_path_override( + module_name = "jgit", + path = "./modules/jgit", +) + +# Toolchain setup. +bazel_dep(name = "rbe_autoconfig") +git_override( + module_name = "rbe_autoconfig", + commit = "c4d733d0399ebf2ee1ffb897be5fbaba23738e04", + remote = "https://github.com/davido/rbe_autoconfig.git", +) + +register_toolchains("//tools:all") + +# Plugin packaging support: bazlets pin and generated Gerrit API version repo. +include("//tools:bazlets.MODULE.bazel") + +# Repository rules for in-tree sources. +include("//tools:repos.MODULE.bazel") + +# External dependency wiring is split out. +include("//tools:java_deps.MODULE.bazel") + +# Wiring for external dependencies contributed by in-tree plugins. +include("//plugins:external_plugin_deps.MODULE.bazel")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock new file mode 100644 index 0000000..ce00ad7 --- /dev/null +++ b/MODULE.bazel.lock
@@ -0,0 +1,984 @@ +{ + "lockFileVersion": 24, + "registryFileHashes": { + "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497", + "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2", + "https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589", + "https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16", + "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed", + "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.0/MODULE.bazel": "d1086e248cda6576862b4b3fe9ad76a214e08c189af5b42557a6e1888812c5d5", + "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1", + "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/MODULE.bazel": "c43c16ca2c432566cdb78913964497259903ebe8fb7d9b57b38e9f1425b427b8", + "https://bcr.bazel.build/modules/abseil-cpp/20250814.0/source.json": "b88bff599ceaf0f56c264c749b1606f8485cec3b8c38ba30f88a4df9af142861", + "https://bcr.bazel.build/modules/abseil-py/2.1.0/MODULE.bazel": "5ebe5bf853769c65707e5c28f216798f7a4b1042015e6a36e6d03094d94bec8a", + "https://bcr.bazel.build/modules/abseil-py/2.1.0/source.json": "0e8fc4f088ce07099c1cd6594c20c7ddbb48b4b3c0849b7d94ba94be88ff042b", + "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896", + "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85", + "https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442", + "https://bcr.bazel.build/modules/apple_support/1.23.1/source.json": "d888b44312eb0ad2c21a91d026753f330caa48a25c9b2102fae75eb2b0dcfdd2", + "https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b", + "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd", + "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8", + "https://bcr.bazel.build/modules/bazel_features/1.13.0/MODULE.bazel": "c14c33c7c3c730612bdbe14ebbb5e61936b6f11322ea95a6e91cd1ba962f94df", + "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d", + "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d", + "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a", + "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58", + "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b", + "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a", + "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65", + "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d", + "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9", + "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87", + "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6", + "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d", + "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93", + "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", + "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a", + "https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8", + "https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686", + "https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a", + "https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d", + "https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651", + "https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138", + "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", + "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", + "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0", + "https://bcr.bazel.build/modules/bazel_worker_api/0.0.1/MODULE.bazel": "02a13b77321773b2042e70ee5e4c5e099c8ddee4cf2da9cd420442c36938d4bd", + "https://bcr.bazel.build/modules/bazel_worker_api/0.0.4/MODULE.bazel": "460aa12d01231a80cce03c548287b433b321d205b0028ae596728c35e5ee442e", + "https://bcr.bazel.build/modules/bazel_worker_api/0.0.4/source.json": "d353c410d47a8b65d09fa98e83d57ebec257a2c2b9c6e42d6fda1cb25e5464a5", + "https://bcr.bazel.build/modules/bazel_worker_java/0.0.4/MODULE.bazel": "82494a01018bb7ef06d4a17ec4cd7a758721f10eb8b6c820a818e70d669500db", + "https://bcr.bazel.build/modules/bazel_worker_java/0.0.4/source.json": "a2d30458fd86cf022c2b6331e652526fa08e17573b2f5034a9dbcacdf9c2583c", + "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84", + "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8", + "https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8", + "https://bcr.bazel.build/modules/gazelle/0.33.0/MODULE.bazel": "a13a0f279b462b784fb8dd52a4074526c4a2afe70e114c7d09066097a46b3350", + "https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a", + "https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0", + "https://bcr.bazel.build/modules/gazelle/0.40.0/MODULE.bazel": "42ba5378ebe845fca43989a53186ab436d956db498acde790685fe0e8f9c6146", + "https://bcr.bazel.build/modules/gazelle/0.40.0/source.json": "1e5ef6e4d8b9b6836d93273c781e78ff829ea2e077afef7a57298040fa4f010a", + "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb", + "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4", + "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6", + "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f", + "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108", + "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46", + "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713", + "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0", + "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000", + "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", + "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9", + "https://bcr.bazel.build/modules/package_metadata/0.0.7/MODULE.bazel": "7adb03933fc8401f495800cf4eafcff0edc6da0ff55c7db223ef69d19f689486", + "https://bcr.bazel.build/modules/package_metadata/0.0.7/source.json": "50639625e937b56115012674c797cca7a05a96b4878c87d803c13dc2b31de8a0", + "https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5", + "https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f", + "https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee", + "https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37", + "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615", + "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814", + "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d", + "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc", + "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580", + "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96", + "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7", + "https://bcr.bazel.build/modules/protobuf/23.1/MODULE.bazel": "88b393b3eb4101d18129e5db51847cd40a5517a53e81216144a8c32dfeeca52a", + "https://bcr.bazel.build/modules/protobuf/24.4/MODULE.bazel": "7bc7ce5f2abf36b3b7b7c8218d3acdebb9426aeb35c2257c96445756f970eb12", + "https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c", + "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d", + "https://bcr.bazel.build/modules/protobuf/27.2/MODULE.bazel": "32450b50673882e4c8c3d10a83f3bc82161b213ed2f80d17e38bece8f165c295", + "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df", + "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92", + "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e", + "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95", + "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0", + "https://bcr.bazel.build/modules/protobuf/3.19.2/MODULE.bazel": "532ffe5f2186b69fdde039efe6df13ba726ff338c6bc82275ad433013fa10573", + "https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858", + "https://bcr.bazel.build/modules/protobuf/31.1/MODULE.bazel": "379a389bb330b7b8c1cdf331cc90bf3e13de5614799b3b52cdb7c6f389f6b38e", + "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d", + "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42", + "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79", + "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34", + "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680", + "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a", + "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4", + "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa", + "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8", + "https://bcr.bazel.build/modules/rules_android/0.6.6/MODULE.bazel": "b0fb569752aab65ab1a9db0a8f6cfaf5aa1754965e17e95dcf0e4d88e192a68d", + "https://bcr.bazel.build/modules/rules_android/0.6.6/source.json": "a9d8dc2d5a102dc03269a94acc886a4cab82cdcb9ccbc77b0f665d6d17a6ae09", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a", + "https://bcr.bazel.build/modules/rules_apple/3.16.0/source.json": "d8b5fe461272018cc07cfafce11fe369c7525330804c37eec5a82f84cd475366", + "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647", + "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002", + "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191", + "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac", + "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc", + "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87", + "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a", + "https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c", + "https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f", + "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e", + "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5", + "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513", + "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8", + "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c", + "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", + "https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54", + "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", + "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", + "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8", + "https://bcr.bazel.build/modules/rules_go/0.41.0/MODULE.bazel": "55861d8e8bb0e62cbd2896f60ff303f62ffcb0eddb74ecb0e5c0cbe36fc292c8", + "https://bcr.bazel.build/modules/rules_go/0.42.0/MODULE.bazel": "8cfa875b9aa8c6fce2b2e5925e73c1388173ea3c32a0db4d2b4804b453c14270", + "https://bcr.bazel.build/modules/rules_go/0.46.0/MODULE.bazel": "3477df8bdcc49e698b9d25f734c4f3a9f5931ff34ee48a2c662be168f5f2d3fd", + "https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0", + "https://bcr.bazel.build/modules/rules_go/0.51.0-rc2/MODULE.bazel": "edfc3a9cea7bedb0eaaff37b0d7817c1a4bf72b3c615580b0ffcee6c52690fd4", + "https://bcr.bazel.build/modules/rules_go/0.51.0-rc2/source.json": "6b5cd0b3da2bd0e6949580851db990a04af0a285f072b9a0f059424457cd8cc9", + "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74", + "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86", + "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39", + "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963", + "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6", + "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31", + "https://bcr.bazel.build/modules/rules_java/7.1.0/MODULE.bazel": "30d9135a2b6561c761bd67bd4990da591e6bdc128790ce3e7afd6a3558b2fb64", + "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a", + "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6", + "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab", + "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2", + "https://bcr.bazel.build/modules/rules_java/7.4.0/MODULE.bazel": "a592852f8a3dd539e82ee6542013bf2cadfc4c6946be8941e189d224500a8934", + "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe", + "https://bcr.bazel.build/modules/rules_java/8.11.0/MODULE.bazel": "c3d280bc5ff1038dcb3bacb95d3f6b83da8dd27bba57820ec89ea4085da767ad", + "https://bcr.bazel.build/modules/rules_java/8.13.0/MODULE.bazel": "0444ebf737d144cf2bb2ccb368e7f1cce735264285f2a3711785827c1686625e", + "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615", + "https://bcr.bazel.build/modules/rules_java/8.16.1/MODULE.bazel": "0f20b1cecaa8e52f60a8f071e59a20b4e3b9a67f6c56c802ea256f6face692d3", + "https://bcr.bazel.build/modules/rules_java/8.16.1/source.json": "072f8d11264edc499621be2dc9ea01d6395db5aa6f8799c034ae01a3e857f2e4", + "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017", + "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939", + "https://bcr.bazel.build/modules/rules_java/8.6.0/MODULE.bazel": "9c064c434606d75a086f15ade5edb514308cccd1544c2b2a89bbac4310e41c71", + "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2", + "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7", + "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909", + "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036", + "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d", + "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4", + "https://bcr.bazel.build/modules/rules_jvm_external/6.10/MODULE.bazel": "33e636ca6bc9ee0fa090a38aa33c631ded2d8cf6fead4124181d1b35dc474f7c", + "https://bcr.bazel.build/modules/rules_jvm_external/6.10/source.json": "c191249787625db72616a3fb3cc2786ab57355a2e3b615402b8b3b66b0f995b7", + "https://bcr.bazel.build/modules/rules_jvm_external/6.2/MODULE.bazel": "36a6e52487a855f33cb960724eb56547fa87e2c98a0474c3acad94339d7f8e99", + "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0", + "https://bcr.bazel.build/modules/rules_jvm_external/6.6/MODULE.bazel": "153042249c7060536dc95b6bb9f9bb8063b8a0b0cb7acdb381bddbc2374aed55", + "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.5/MODULE.bazel": "043a16a572f610558ec2030db3ff0c9938574e7dd9f58bded1bb07c0192ef025", + "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3", + "https://bcr.bazel.build/modules/rules_kotlin/2.1.3/MODULE.bazel": "ce7def6d576aa8d3a9c6d10e13b4d157296229674371f67dbf788dae0afae3d5", + "https://bcr.bazel.build/modules/rules_kotlin/2.1.3/source.json": "0b0dc9400f14b5fbb13d278ad3bf0413cdbaf0da0db337e055b855e35b878a3b", + "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0", + "https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d", + "https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c", + "https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb", + "https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff", + "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a", + "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06", + "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7", + "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483", + "https://bcr.bazel.build/modules/rules_proto/6.0.0/MODULE.bazel": "b531d7f09f58dce456cd61b4579ce8c86b38544da75184eadaf0a7cb7966453f", + "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73", + "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96", + "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e", + "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f", + "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300", + "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382", + "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed", + "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58", + "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937", + "https://bcr.bazel.build/modules/rules_python/0.37.1/MODULE.bazel": "3faeb2d9fa0a81f8980643ee33f212308f4d93eea4b9ce6f36d0b742e71e9500", + "https://bcr.bazel.build/modules/rules_python/0.37.2/MODULE.bazel": "b5ffde91410745750b6c13be1c5dc4555ef5bc50562af4a89fd77807fdde626a", + "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c", + "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7", + "https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43", + "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6", + "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8", + "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8", + "https://bcr.bazel.build/modules/rules_python/1.8.0-rc1/MODULE.bazel": "d5348333fd8be9589c3ea8d9110fd00b1c7d84bc7b505668307cbe9f105e9c8f", + "https://bcr.bazel.build/modules/rules_python/1.8.0-rc1/source.json": "d26719d5b92a569eaa387bad9667a042d4810193f279ea64590c9d76ae38f3ba", + "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/MODULE.bazel": "d44fec647d0aeb67b9f3b980cf68ba634976f3ae7ccd6c07d790b59b87a4f251", + "https://bcr.bazel.build/modules/rules_robolectric/4.14.1.2/source.json": "37c10335f2361c337c5c1f34ed36d2da70534c23088062b33a8bdaab68aa9dea", + "https://bcr.bazel.build/modules/rules_shell/0.1.2/MODULE.bazel": "66e4ca3ce084b04af0b9ff05ff14cab4e5df7503973818bb91cbc6cda08d32fc", + "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c", + "https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b", + "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592", + "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b", + "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c", + "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046", + "https://bcr.bazel.build/modules/rules_swift/2.1.1/source.json": "40fc69dfaac64deddbb75bd99cdac55f4427d9ca0afbe408576a65428427a186", + "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8", + "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c", + "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef", + "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd", + "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c", + "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7", + "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5", + "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", + "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb", + "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", + "https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9", + "https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0", + "https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/MODULE.bazel": "eec517b5bbe5492629466e11dae908d043364302283de25581e3eb944326c4ca", + "https://bcr.bazel.build/modules/zlib/1.3.1.bcr.5/source.json": "22bc55c47af97246cfc093d0acf683a7869377de362b5d1c552c2c2e16b7a806", + "https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198" + }, + "selectedYankedVersions": {}, + "moduleExtensions": { + "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": { + "general": { + "bzlTransitiveDigest": "NFQjcZF+fAvf5fDH+pqsx4JrfzP9PuHBz6S6ZutIbnw=", + "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=", + "recordedFileInputs": { + "@@pybind11_bazel+//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34" + }, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "pybind11": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel", + "strip_prefix": "pybind11-2.12.0", + "urls": [ + "https://github.com/pybind/pybind11/archive/v2.12.0.zip" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "pybind11_bazel+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_android+//bzlmod_extensions:apksig.bzl%apksig_extension": { + "general": { + "bzlTransitiveDigest": "By9qVNN7G4oL1vYOJXye7Dp/CbR2ar9oxAW8WXAVcVw=", + "usagesDigest": "xq6OVkELeJvOgYo3oY/sUBsGFbcqdV+9BYiNgSPV/po=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "apksig": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://android.googlesource.com/platform/tools/apksig/+archive/24e3075e68ebe17c0b529bb24bfda819db5e2f3b.tar.gz", + "build_file": "@@rules_android+//bzlmod_extensions:apksig.BUILD" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_android+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_android+//bzlmod_extensions:com_android_dex.bzl%com_android_dex_extension": { + "general": { + "bzlTransitiveDigest": "rvWbJQc8jInfIAaXIMhSOqUlwM9HVeLey6q0ISvg08Y=", + "usagesDigest": "toF8IFMu98H/VU2p1sfVC5fVXVYJunpbbmtM6tOsQXY=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "com_android_dex": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://android.googlesource.com/platform/dalvik/+archive/5a81c499a569731e2395f7c8d13c0e0d4e17a2b6.tar.gz", + "build_file": "@@rules_android+//bzlmod_extensions:com_android_dex.BUILD" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_android+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_android+//rules/android_sdk_repository:rule.bzl%android_sdk_repository_extension": { + "general": { + "bzlTransitiveDigest": "NAy+0M15JNVEBb8Tny6t7j3lKqTnsAMjoBB6LJ+C370=", + "usagesDigest": "g9Ur6X6qhf9a8MmY9qXU/jFjkyk/aZVBegI0yVMF0z4=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "androidsdk": { + "repoRuleId": "@@rules_android+//rules/android_sdk_repository:rule.bzl%_android_sdk_repository", + "attributes": {} + } + }, + "recordedRepoMappingEntries": [] + } + }, + "@@rules_apple+//apple:apple.bzl%provisioning_profile_repository_extension": { + "general": { + "bzlTransitiveDigest": "DBjF8z9KnkAVkDon8si62fhfjje60FibbeIt+zE+BWw=", + "usagesDigest": "vsJl8Rw5NL+5Ag2wdUDoTeRF/5klkXO8545Iy7U1Q08=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "local_provisioning_profiles": { + "repoRuleId": "@@rules_apple+//apple/internal:local_provisioning_profiles.bzl%provisioning_profile_repository", + "attributes": {} + } + }, + "recordedRepoMappingEntries": [ + [ + "apple_support+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "bazel_tools", + "rules_cc", + "rules_cc+" + ], + [ + "rules_apple+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "rules_apple+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_apple+", + "build_bazel_apple_support", + "apple_support+" + ], + [ + "rules_apple+", + "build_bazel_rules_swift", + "rules_swift+" + ], + [ + "rules_cc+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_cc+", + "cc_compatibility_proxy", + "rules_cc++compatibility_proxy+cc_compatibility_proxy" + ], + [ + "rules_cc+", + "rules_cc", + "rules_cc+" + ], + [ + "rules_cc++compatibility_proxy+cc_compatibility_proxy", + "rules_cc", + "rules_cc+" + ], + [ + "rules_swift+", + "bazel_skylib", + "bazel_skylib+" + ], + [ + "rules_swift+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_swift+", + "build_bazel_apple_support", + "apple_support+" + ], + [ + "rules_swift+", + "build_bazel_rules_swift", + "rules_swift+" + ], + [ + "rules_swift+", + "build_bazel_rules_swift_local_config", + "rules_swift++non_module_deps+build_bazel_rules_swift_local_config" + ] + ] + } + }, + "@@rules_apple+//apple:extensions.bzl%non_module_deps": { + "general": { + "bzlTransitiveDigest": "4xtddSlWIQdtVNVuvOI62fJfQVETHZCVWFvYYwQHMR4=", + "usagesDigest": "M3VqFpeTCo4qmrNKGZw0dxBHvTYDrfV3cscGzlSAhQ4=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "xctestrunner": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/google/xctestrunner/archive/b7698df3d435b6491b4b4c0f9fc7a63fbed5e3a6.tar.gz" + ], + "strip_prefix": "xctestrunner-b7698df3d435b6491b4b4c0f9fc7a63fbed5e3a6", + "sha256": "ae3a063c985a8633cb7eb566db21656f8db8eb9a0edb8c182312c7f0db53730d" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_apple+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": { + "general": { + "bzlTransitiveDigest": "HJP3wKFbPhB1mSYjJS6kbXEiP+OQxvsBdqpvyJN6I3s=", + "usagesDigest": "qTwqmKKUfWcPdvM0waG+CPWrxsbeAWVeUxavm7tEk9E=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "com_github_jetbrains_kotlin_git": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository", + "attributes": { + "urls": [ + "https://github.com/JetBrains/kotlin/releases/download/v2.1.0/kotlin-compiler-2.1.0.zip" + ], + "sha256": "b6698d5728ad8f9edcdd01617d638073191d8a03139cc538a391b4e3759ad297" + } + }, + "com_github_jetbrains_kotlin": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_capabilities_repository", + "attributes": { + "git_repository_name": "com_github_jetbrains_kotlin_git", + "compiler_version": "2.1.0" + } + }, + "com_github_google_ksp": { + "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:ksp.bzl%ksp_compiler_plugin_repository", + "attributes": { + "urls": [ + "https://github.com/google/ksp/releases/download/2.1.0-1.0.28/artifacts.zip" + ], + "sha256": "fc27b08cadc061a4a989af01cbeccb613feef1995f4aad68f2be0f886a3ee251", + "strip_version": "2.1.0-1.0.28" + } + }, + "com_github_pinterest_ktlint": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file", + "attributes": { + "sha256": "a9f923be58fbd32670a17f0b729b1df804af882fa57402165741cb26e5440ca1", + "urls": [ + "https://github.com/pinterest/ktlint/releases/download/1.3.1/ktlint" + ], + "executable": true + } + }, + "kotlinx_serialization_core_jvm": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "29c821a8d4e25cbfe4f2ce96cdd4526f61f8f4e69a135f9612a34a81d93b65f1", + "urls": [ + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-core-jvm/1.6.3/kotlinx-serialization-core-jvm-1.6.3.jar" + ] + } + }, + "kotlinx_serialization_json": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "8c0016890a79ab5980dd520a5ab1a6738023c29aa3b6437c482e0e5fdc06dab1", + "urls": [ + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json/1.6.3/kotlinx-serialization-json-1.6.3.jar" + ] + } + }, + "kotlinx_serialization_json_jvm": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_jar", + "attributes": { + "sha256": "d3234179bcff1886d53d67c11eca47f7f3cf7b63c349d16965f6db51b7f3dd9a", + "urls": [ + "https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json-jvm/1.6.3/kotlinx-serialization-json-jvm-1.6.3.jar" + ] + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_kotlin+", + "bazel_tools", + "bazel_tools" + ] + ] + } + }, + "@@rules_python+//python/extensions:config.bzl%config": { + "general": { + "bzlTransitiveDigest": "TRGIl0CDmorwyNiblOYyhWuyKzi/kWFHT2uIofq7o9Y=", + "usagesDigest": "tIEieEA/gbsjNF3L/Oouyg6UdqGOVxFsPqBjFxkTAKM=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "rules_python_internal": { + "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo", + "attributes": { + "transition_setting_generators": {}, + "transition_settings": [] + } + }, + "pypi__build": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e2/03/f3c8ba0a6b6e30d7d18c40faab90807c9bb5e9a1e3b2fe2008af624a9c97/build-1.2.1-py3-none-any.whl", + "sha256": "75e10f767a433d9a86e50d83f418e83efc18ede923ee5ff7df93b6cb0306c5d4", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__click": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", + "sha256": "ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__colorama": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", + "sha256": "4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__importlib_metadata": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/2d/0a/679461c511447ffaf176567d5c496d1de27cbe34a87df6677d7171b2fbd4/importlib_metadata-7.1.0-py3-none-any.whl", + "sha256": "30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__installer": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/e5/ca/1172b6638d52f2d6caa2dd262ec4c811ba59eee96d54a7701930726bce18/installer-0.7.0-py3-none-any.whl", + "sha256": "05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__more_itertools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/50/e2/8e10e465ee3987bb7c9ab69efb91d867d93959095f4807db102d07995d94/more_itertools-10.2.0-py3-none-any.whl", + "sha256": "686b06abe565edfab151cb8fd385a05651e1fdf8f0a14191e4439283421f8684", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__packaging": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", + "sha256": "2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pep517": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/25/6e/ca4a5434eb0e502210f591b97537d322546e4833dcb4d470a48c375c5540/pep517-0.13.1-py3-none-any.whl", + "sha256": "31b206f67165b3536dd577c5c3f1518e8fbaf38cbc57efff8369a392feff1721", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/8a/6a/19e9fe04fca059ccf770861c7d5721ab4c2aebc539889e97c7977528a53b/pip-24.0-py3-none-any.whl", + "sha256": "ba0d021a166865d2265246961bec0152ff124de910c5cc39f1156ce3fa7c69dc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pip_tools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/0d/dc/38f4ce065e92c66f058ea7a368a9c5de4e702272b479c0992059f7693941/pip_tools-7.4.1-py3-none-any.whl", + "sha256": "4c690e5fbae2f21e87843e89c26191f0d9454f362d8acdbd695716493ec8b3a9", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__pyproject_hooks": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/ae/f3/431b9d5fe7d14af7a32340792ef43b8a714e7726f1d7b69cc4e8e7a3f1d7/pyproject_hooks-1.1.0-py3-none-any.whl", + "sha256": "7ceeefe9aec63a1064c18d939bdc3adf2d8aa1988a510afec15151578b232aa2", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__setuptools": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/90/99/158ad0609729111163fc1f674a5a42f2605371a4cf036d0441070e2f7455/setuptools-78.1.1-py3-none-any.whl", + "sha256": "c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__tomli": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", + "sha256": "939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__wheel": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/7d/cd/d7460c9a869b16c3dd4e1e403cce337df165368c71d6af229a74699622ce/wheel-0.43.0-py3-none-any.whl", + "sha256": "55c570405f142630c6b9f72fe09d9b67cf1477fcf543ae5b8dcb1f5b7377da81", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + }, + "pypi__zipp": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "url": "https://files.pythonhosted.org/packages/da/55/a03fd7240714916507e1fcf7ae355bd9d9ed2e6db492595f1a67f61681be/zipp-3.18.2-py3-none-any.whl", + "sha256": "dce197b859eb796242b0622af1b8beb0a722d52aa2f57133ead08edd5bf5374e", + "type": "zip", + "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n name = \"lib\",\n srcs = glob([\"**/*.py\"]),\n data = glob([\"**/*\"], exclude=[\n # These entries include those put into user-installed dependencies by\n # data_exclude to avoid non-determinism.\n \"**/*.py\",\n \"**/*.pyc\",\n \"**/*.pyc.*\", # During pyc creation, temp files named *.pyc.NNN are created\n \"**/*.dist-info/RECORD\",\n \"BUILD\",\n \"WORKSPACE\",\n ]),\n # This makes this directory a top-level in the python import\n # search path for anything that depends on this.\n imports = [\".\"],\n)\n" + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_python+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_python+", + "pypi__build", + "rules_python++config+pypi__build" + ], + [ + "rules_python+", + "pypi__click", + "rules_python++config+pypi__click" + ], + [ + "rules_python+", + "pypi__colorama", + "rules_python++config+pypi__colorama" + ], + [ + "rules_python+", + "pypi__importlib_metadata", + "rules_python++config+pypi__importlib_metadata" + ], + [ + "rules_python+", + "pypi__installer", + "rules_python++config+pypi__installer" + ], + [ + "rules_python+", + "pypi__more_itertools", + "rules_python++config+pypi__more_itertools" + ], + [ + "rules_python+", + "pypi__packaging", + "rules_python++config+pypi__packaging" + ], + [ + "rules_python+", + "pypi__pep517", + "rules_python++config+pypi__pep517" + ], + [ + "rules_python+", + "pypi__pip", + "rules_python++config+pypi__pip" + ], + [ + "rules_python+", + "pypi__pip_tools", + "rules_python++config+pypi__pip_tools" + ], + [ + "rules_python+", + "pypi__pyproject_hooks", + "rules_python++config+pypi__pyproject_hooks" + ], + [ + "rules_python+", + "pypi__setuptools", + "rules_python++config+pypi__setuptools" + ], + [ + "rules_python+", + "pypi__tomli", + "rules_python++config+pypi__tomli" + ], + [ + "rules_python+", + "pypi__wheel", + "rules_python++config+pypi__wheel" + ], + [ + "rules_python+", + "pypi__zipp", + "rules_python++config+pypi__zipp" + ] + ] + } + }, + "@@rules_python+//python/uv:uv.bzl%uv": { + "general": { + "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=", + "usagesDigest": "s63+dBGiTSbvuV/QBtGNrbYox+e7K5QXThW1NgBreis=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "uv": { + "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo", + "attributes": { + "toolchain_type": "'@@rules_python+//python/uv:uv_toolchain_type'", + "toolchain_names": [ + "none" + ], + "toolchain_implementations": { + "none": "'@@rules_python+//python:none'" + }, + "toolchain_compatible_with": { + "none": [ + "@platforms//:incompatible" + ] + }, + "toolchain_target_settings": {} + } + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_python+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_python+", + "platforms", + "platforms" + ] + ] + } + }, + "@@rules_swift+//swift:extensions.bzl%non_module_deps": { + "general": { + "bzlTransitiveDigest": "6axDCXf6fQoPav8hojnUBxGA0FAMqLvtpC1cRsisCdw=", + "usagesDigest": "mhACFnrdMv9Wi0Mt67bxocJqviRkDSV+Ee5Mqdj5akA=", + "recordedFileInputs": {}, + "recordedDirentsInputs": {}, + "envVariables": {}, + "generatedRepoSpecs": { + "com_github_apple_swift_protobuf": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-protobuf/archive/1.20.2.tar.gz" + ], + "sha256": "3fb50bd4d293337f202d917b6ada22f9548a0a0aed9d9a4d791e6fbd8a246ebb", + "strip_prefix": "swift-protobuf-1.20.2/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_protobuf/BUILD.overlay" + } + }, + "com_github_grpc_grpc_swift": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/grpc/grpc-swift/archive/1.16.0.tar.gz" + ], + "sha256": "58b60431d0064969f9679411264b82e40a217ae6bd34e17096d92cc4e47556a5", + "strip_prefix": "grpc-swift-1.16.0/", + "build_file": "@@rules_swift+//third_party:com_github_grpc_grpc_swift/BUILD.overlay" + } + }, + "com_github_apple_swift_docc_symbolkit": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-docc-symbolkit/archive/refs/tags/swift-5.10-RELEASE.tar.gz" + ], + "sha256": "de1d4b6940468ddb53b89df7aa1a81323b9712775b0e33e8254fa0f6f7469a97", + "strip_prefix": "swift-docc-symbolkit-swift-5.10-RELEASE", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_docc_symbolkit/BUILD.overlay" + } + }, + "com_github_apple_swift_nio": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio/archive/2.42.0.tar.gz" + ], + "sha256": "e3304bc3fb53aea74a3e54bd005ede11f6dc357117d9b1db642d03aea87194a0", + "strip_prefix": "swift-nio-2.42.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_http2": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-http2/archive/1.26.0.tar.gz" + ], + "sha256": "f0edfc9d6a7be1d587e5b403f2d04264bdfae59aac1d74f7d974a9022c6d2b25", + "strip_prefix": "swift-nio-http2-1.26.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_http2/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_transport_services": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-transport-services/archive/1.15.0.tar.gz" + ], + "sha256": "f3498dafa633751a52b9b7f741f7ac30c42bcbeb3b9edca6d447e0da8e693262", + "strip_prefix": "swift-nio-transport-services-1.15.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_transport_services/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_extras": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-extras/archive/1.4.0.tar.gz" + ], + "sha256": "4684b52951d9d9937bb3e8ccd6b5daedd777021ef2519ea2f18c4c922843b52b", + "strip_prefix": "swift-nio-extras-1.4.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_extras/BUILD.overlay" + } + }, + "com_github_apple_swift_log": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-log/archive/1.4.4.tar.gz" + ], + "sha256": "48fe66426c784c0c20031f15dc17faf9f4c9037c192bfac2f643f65cb2321ba0", + "strip_prefix": "swift-log-1.4.4/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_log/BUILD.overlay" + } + }, + "com_github_apple_swift_nio_ssl": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-nio-ssl/archive/2.23.0.tar.gz" + ], + "sha256": "4787c63f61dd04d99e498adc3d1a628193387e41efddf8de19b8db04544d016d", + "strip_prefix": "swift-nio-ssl-2.23.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_nio_ssl/BUILD.overlay" + } + }, + "com_github_apple_swift_collections": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-collections/archive/1.0.4.tar.gz" + ], + "sha256": "d9e4c8a91c60fb9c92a04caccbb10ded42f4cb47b26a212bc6b39cc390a4b096", + "strip_prefix": "swift-collections-1.0.4/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_collections/BUILD.overlay" + } + }, + "com_github_apple_swift_atomics": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "urls": [ + "https://github.com/apple/swift-atomics/archive/1.1.0.tar.gz" + ], + "sha256": "1bee7f469f7e8dc49f11cfa4da07182fbc79eab000ec2c17bfdce468c5d276fb", + "strip_prefix": "swift-atomics-1.1.0/", + "build_file": "@@rules_swift+//third_party:com_github_apple_swift_atomics/BUILD.overlay" + } + }, + "build_bazel_rules_swift_index_import": { + "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive", + "attributes": { + "build_file": "@@rules_swift+//third_party:build_bazel_rules_swift_index_import/BUILD.overlay", + "canonical_id": "index-import-5.8", + "urls": [ + "https://github.com/MobileNativeFoundation/index-import/releases/download/5.8.0.1/index-import.tar.gz" + ], + "sha256": "28c1ffa39d99e74ed70623899b207b41f79214c498c603915aef55972a851a15" + } + }, + "build_bazel_rules_swift_local_config": { + "repoRuleId": "@@rules_swift+//swift/internal:swift_autoconfiguration.bzl%swift_autoconfiguration", + "attributes": {} + } + }, + "recordedRepoMappingEntries": [ + [ + "rules_swift+", + "bazel_tools", + "bazel_tools" + ], + [ + "rules_swift+", + "build_bazel_rules_swift", + "rules_swift+" + ] + ] + } + } + }, + "facts": {} +}
diff --git a/WORKSPACE b/WORKSPACE.bzlmod similarity index 79% rename from WORKSPACE rename to WORKSPACE.bzlmod index 666137b..004dafb 100644 --- a/WORKSPACE +++ b/WORKSPACE.bzlmod
@@ -24,16 +24,18 @@ name = "gerrit", ) -load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") -load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file") +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") load("//plugins:external_plugin_deps.bzl", "external_plugin_deps") -load("//tools:nongoogle.bzl", "declare_nongoogle_deps") -load("//tools:deps.bzl", "CAFFEINE_VERS", "java_dependencies") +# Gerrit-specific patched rules_nodejs 5.8.5 release artifact. +# Windows-specific paths are disabled since Gerrit only supports Linux +# and macOS builders. http_archive( name = "build_bazel_rules_nodejs", - sha256 = "a1295b168f183218bc88117cf00674bcd102498f294086ff58318f830dd9d9d1", - urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/5.8.5/rules_nodejs-5.8.5.tar.gz"], + sha256 = "cd4f06efb688067a2a891c9b5647184b12708dcff36bebabeddce666f5422e7f", + urls = [ + "https://github.com/davido/rules_nodejs/releases/download/v5.8.5-unixonly/rules_nodejs-5.8.5-gerrit.tar.gz", + ], ) load("@build_bazel_rules_nodejs//:repositories.bzl", "build_bazel_rules_nodejs_dependencies") @@ -66,46 +68,6 @@ firefox = True, ) -declare_nongoogle_deps() - -load("//tools:defs.bzl", "gerrit_init") - -gerrit_init() - -# Java-Prettify external repository consumed from git submodule -local_repository( - name = "java-prettify", - path = "modules/java-prettify", -) - -# JGit external repository consumed from git submodule -local_repository( - name = "jgit", - path = "modules/jgit", -) - -java_dependencies() - -CAFFEINE_GUAVA_SHA256 = "6e48965614557ba4d3c55a197e20c38f23a20032ef8aace37e95ed64d2ebc9a6" - -# TODO(davido): Rename guava.jar to caffeine-guava.jar on fetch to prevent potential -# naming collision between caffeine guava adapter and guava library itself. -# Remove this renaming procedure, once this upstream issue is fixed: -# https://github.com/ben-manes/caffeine/issues/364. -http_file( - name = "caffeine-guava-renamed", - canonical_id = "caffeine-guava-" + CAFFEINE_VERS + ".jar-" + CAFFEINE_GUAVA_SHA256, - downloaded_file_path = "caffeine-guava-" + CAFFEINE_VERS + ".jar", - sha256 = CAFFEINE_GUAVA_SHA256, - urls = [ - "https://repo1.maven.org/maven2/com/github/ben-manes/caffeine/guava/" + - CAFFEINE_VERS + - "/guava-" + - CAFFEINE_VERS + - ".jar", - ], -) - load("@build_bazel_rules_nodejs//:index.bzl", "node_repositories", "yarn_install") NODE_20_REPO = {
diff --git a/antlr3/com/google/gerrit/index/query/Query.g b/antlr3/com/google/gerrit/index/query/Query.g index ea521f9..82bd3da 100644 --- a/antlr3/com/google/gerrit/index/query/Query.g +++ b/antlr3/com/google/gerrit/index/query/Query.g
@@ -178,7 +178,7 @@ // '#' permit | '$' | '%' - | '&' + // '&' permit | '\'' | '(' | ')' // '*' permit
diff --git a/contrib/maintenance/gerrit/site.py b/contrib/maintenance/gerrit/site.py index 51d567f..faf6c02 100644 --- a/contrib/maintenance/gerrit/site.py +++ b/contrib/maintenance/gerrit/site.py
@@ -33,7 +33,7 @@ def get_base_path(self): if not self.base_path: with GitConfigReader( - os.path.join(self.get_etc_path(), "gerrit.config") + os.path.join(self.get_etc_path(), "gerrit.config"), [] ) as cfg: config_base_path = cfg.get("gerrit", None, "basePath", "git") if os.path.isabs(config_base_path):
diff --git a/contrib/maintenance/git/config.py b/contrib/maintenance/git/config.py index cdcb2b0..0a0f00b 100644 --- a/contrib/maintenance/git/config.py +++ b/contrib/maintenance/git/config.py
@@ -27,14 +27,16 @@ class GitConfigReader: - def __init__(self, config_path): + def __init__(self, config_path, cmd_options): self.path = config_path + self.cmd_options = cmd_options self.contents = {} def __enter__(self): LOG.debug("reader") self.file = open(self.path, "r", encoding="utf-8") self._parse() + self._parse_cmd_options() return self def __exit__(self, exc_type, exc_val, exc_tb): @@ -109,6 +111,23 @@ ] LOG.debug("Parsed config: %s", self.contents) + def _parse_cmd_options(self): + for option in self.cmd_options: + key, value = option.split("=", 1) + key_parts = key.split(".") + if len(key_parts) == 2: + section = key_parts[0].lower() + subsection = DEFAULT_SUBSECTION + key = key_parts[1].lower() + elif len(key_parts) == 3: + section = key_parts[0].lower() + subsection = key_parts[1].lower() + key = key_parts[2].lower() + else: + raise GitConfigException(f"Invalid git config option: {option}") + self._ensure_full_section(section, subsection) + self.contents[section][subsection][key] = value + def _ensure_full_section(self, section, subsection): if section not in self.contents: self.contents[section] = {} @@ -118,7 +137,7 @@ class GitConfigWriter(GitConfigReader): def __init__(self, config_path): - super().__init__(config_path) + super().__init__(config_path, []) def __enter__(self): self.file = open(self.path, "r+", encoding="utf-8")
diff --git a/contrib/maintenance/git/gc.py b/contrib/maintenance/git/gc.py index fd671e2..857b3ab 100644 --- a/contrib/maintenance/git/gc.py +++ b/contrib/maintenance/git/gc.py
@@ -41,6 +41,9 @@ class GCStep(abc.ABC): + def __init__(self, git_config: GitConfigReader): + self.git_config = git_config + @abc.abstractmethod def run(self, repo_dir): pass @@ -62,7 +65,9 @@ class PreservePacksInitStep(GCStep): def run(self, repo_dir): - with GitConfigReader(os.path.join(repo_dir, "config")) as config_reader: + with GitConfigReader( + os.path.join(repo_dir, "config"), self.git_config + ) as config_reader: is_prune_preserved = config_reader.get("gc", None, "prunepreserved", False) is_preserve_old_packs = config_reader.get( "gc", None, "preserveoldpacks", False @@ -119,14 +124,11 @@ return f"{filename}.old-{ext[1:]}" -DEFAULT_INIT_STEPS = [GCLockHandlingInitStep(), PreservePacksInitStep()] - - class DeleteEmptyRefDirsCleanupStep(GCStep): def run(self, repo_dir): refs_path = os.path.join(repo_dir, "refs") self.to_delete = {} - for dir, dirnames, filenames in os.walk(refs_path, topdown=False): + for dir, _, _ in os.walk(refs_path, topdown=False): relative = os.path.relpath(dir, refs_path) depth = len(relative.split(os.sep)) if ( @@ -148,7 +150,7 @@ def rmdir(self, dir): try: os.rmdir(dir) - except (FileNotFoundError, OSError) as e: + except OSError as e: LOG.warning("Couldn't delete %s: %s", dir, e) @@ -180,20 +182,20 @@ ) -DEFAULT_AFTER_STEPS = [ - DeleteEmptyRefDirsCleanupStep(), - DeleteStaleIncomingPacksCleanupStep(), -] - - class GitGarbageCollectionProvider: @staticmethod def get(pack_refs=True, git_config=None): - init_steps = DEFAULT_INIT_STEPS.copy() - after_steps = DEFAULT_AFTER_STEPS.copy() + init_steps = [ + GCLockHandlingInitStep(git_config), + PreservePacksInitStep(git_config), + ] + after_steps = [ + DeleteEmptyRefDirsCleanupStep(git_config), + DeleteStaleIncomingPacksCleanupStep(git_config), + ] if pack_refs: - after_steps.append(PackAllRefsAfterStep()) + after_steps.append(PackAllRefsAfterStep(git_config)) return GitGarbageCollection(init_steps, after_steps, git_config)
diff --git a/contrib/maintenance/tests/git/test_config.py b/contrib/maintenance/tests/git/test_config.py index f8039d6..9f930a2 100644 --- a/contrib/maintenance/tests/git/test_config.py +++ b/contrib/maintenance/tests/git/test_config.py
@@ -50,12 +50,12 @@ def test_list_config(repo_with_config): - with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader: assert reader.list() == CONFIG_DICT def test_get_config(repo_with_config): - with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader: assert ( reader.get("section", None, "key") == CONFIG_DICT["section"]["default"]["key"] @@ -76,13 +76,24 @@ assert not reader.get("another_section", "default", "another_boolean") +def test_get_config_with_override(repo_with_config): + with GitConfigReader( + os.path.join(repo_with_config, "config"), ["section.key=override"] + ) as reader: + assert reader.get("section", None, "key") == "override" + assert ( + reader.get("section", "subsection", "another_key") + == CONFIG_DICT["section"]["subsection"]["another_key"] + ) + + def test_set_config(repo_with_config): with GitConfigWriter(os.path.join(repo_with_config, "config")) as writer: writer.set("new", None, "key", "value") writer.set("new", "new_sub", "key", "val") writer.write() - with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader: assert reader.get("new", None, "key") == "value" assert reader.get("new", "new_sub", "key") == "val" @@ -94,7 +105,7 @@ writer.add("section", "subsection", "other_key", "val") writer.write() - with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader: assert reader.get("new", None, "key") == "value" assert reader.get("section", None, "key") == "value2" assert reader.get("section", "subsection", "other_key") == "val" @@ -106,7 +117,7 @@ writer.unset("section", "subsection", "another_key") writer.write() - with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader: config = reader.list() assert DEFAULT_SUBSECTION not in config["section"] assert "another_key" not in config["section"]["subsection"] @@ -117,6 +128,6 @@ writer.remove("section", "subsection", "other_key", "test") writer.write() - with GitConfigReader(os.path.join(repo_with_config, "config")) as reader: + with GitConfigReader(os.path.join(repo_with_config, "config"), []) as reader: config = reader.list() assert "test" not in config["section"]["subsection"]["other_key"]
diff --git a/contrib/maintenance/tests/git/test_gc.py b/contrib/maintenance/tests/git/test_gc.py index 83c4fda..3f80211 100644 --- a/contrib/maintenance/tests/git/test_gc.py +++ b/contrib/maintenance/tests/git/test_gc.py
@@ -17,7 +17,7 @@ import git.repo -from datetime import datetime, timedelta +from datetime import datetime from pathlib import Path from git.gc import ( MAX_AGE_EMPTY_REF_DIRS, @@ -42,7 +42,7 @@ with open(lock_file, "w") as f: f.write("1234") - task = GCLockHandlingInitStep() + task = GCLockHandlingInitStep([]) task.run(repo) assert os.path.exists(lock_file) @@ -54,7 +54,7 @@ def test_PreservePacksInitStep(repo): - task = PreservePacksInitStep() + task = PreservePacksInitStep([]) pack_path = os.path.join(repo, "objects", "pack") preserved_pack_path = os.path.join(pack_path, "preserved") @@ -101,6 +101,26 @@ assert not os.path.exists(fake_preserved_idx) +def test_PreservePacksInitStepWithOverride(repo): + task = PreservePacksInitStep(["gc.preserveOldPacks=true"]) + + pack_path = os.path.join(repo, "objects", "pack") + preserved_pack_path = os.path.join(pack_path, "preserved") + + fake_pack = os.path.join(pack_path, "pack-fake.pack") + fake_preserved_pack = os.path.join(preserved_pack_path, "pack-fake.old-pack") + fake_idx = os.path.join(pack_path, "pack-fake.idx") + fake_preserved_idx = os.path.join(preserved_pack_path, "pack-fake.old-idx") + + Path(fake_pack).touch() + Path(fake_idx).touch() + + task.run(repo) + + assert os.path.exists(fake_preserved_pack) + assert os.path.exists(fake_preserved_idx) + + def test_DeleteEmptyRefDirsCleanupStep(repo): delete_path = os.path.join(repo, "refs", "heads", "delete") os.makedirs(delete_path) @@ -110,7 +130,7 @@ os.makedirs(keep_path) Path(os.path.join(keep_path, "abcd1234")).touch() - task = DeleteEmptyRefDirsCleanupStep() + task = DeleteEmptyRefDirsCleanupStep([]) task.run(repo) assert os.path.exists(delete_path) @@ -138,7 +158,7 @@ _modify_last_modified(tags_path, DOUBLE_MAX_AGE_EMPTY_REF_DIRS) _modify_last_modified(delete_change_path, DOUBLE_MAX_AGE_EMPTY_REF_DIRS) - task = DeleteEmptyRefDirsCleanupStep() + task = DeleteEmptyRefDirsCleanupStep([]) task.run(repo) assert not os.path.exists(delete_change_path) @@ -148,7 +168,7 @@ def test_DeleteStaleIncomingPacksCleanupStep(repo): - task = DeleteStaleIncomingPacksCleanupStep() + task = DeleteStaleIncomingPacksCleanupStep([]) objects_path = os.path.join(repo, "objects") pack_path = os.path.join(objects_path, "pack") @@ -190,7 +210,7 @@ loose_ref_count += 1 git.repo.push(local_repo, "origin", f"HEAD:refs/heads/test{loose_ref_count}") - task = PackAllRefsAfterStep() + task = PackAllRefsAfterStep([]) task.run(repo) assert len(os.listdir(os.path.join(repo, "refs", "heads"))) == 0
diff --git a/external_deps.lock.json b/external_deps.lock.json new file mode 100644 index 0000000..2d2051f --- /dev/null +++ b/external_deps.lock.json
@@ -0,0 +1,5301 @@ +{ + "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", + "__INPUT_ARTIFACTS_HASH": { + "antlr:antlr": 1249073166, + "aopalliance:aopalliance": -1416324331, + "args4j:args4j": 484840357, + "ch.qos.reload4j:reload4j": -1216325797, + "com.github.ben-manes.caffeine:caffeine": 1735183231, + "com.github.ben-manes.caffeine:guava": -2131703186, + "com.github.rholder:guava-retrying": 1309513165, + "com.google.auto.factory:auto-factory": -1233360965, + "com.google.auto.service:auto-service-annotations": 953830824, + "com.google.auto.value:auto-value": 1146221104, + "com.google.auto.value:auto-value-annotations": -1101276935, + "com.google.auto:auto-common": -832702775, + "com.google.code.findbugs:jsr305": -1992157670, + "com.google.code.gson:gson": 245099245, + "com.google.common.html.types:types": 1474029483, + "com.google.errorprone:error_prone_annotations": -1409032738, + "com.google.errorprone:error_prone_type_annotations": -1223112919, + "com.google.escapevelocity:escapevelocity": -17168624, + "com.google.flogger:flogger": -825712792, + "com.google.flogger:flogger-log4j-backend": 1730247028, + "com.google.flogger:flogger-system-backend": 1522076251, + "com.google.flogger:google-extensions": -355130314, + "com.google.gitiles:blame-cache": -1424275928, + "com.google.gitiles:gitiles-servlet": 1993647825, + "com.google.guava:failureaccess": -2032498474, + "com.google.guava:guava": -1756621521, + "com.google.guava:guava-testlib": -203887467, + "com.google.inject.extensions:guice-assistedinject": -1667539622, + "com.google.inject.extensions:guice-servlet": 569202692, + "com.google.inject:guice": -1660789120, + "com.google.j2objc:j2objc-annotations": -727464895, + "com.google.jimfs:jimfs": -1004381565, + "com.google.protobuf:protobuf-java": 1906581597, + "com.google.template:soy": -1478719887, + "com.google.truth.extensions:truth-java8-extension": -129319374, + "com.google.truth.extensions:truth-liteproto-extension": 1463279446, + "com.google.truth.extensions:truth-proto-extension": 1270333764, + "com.google.truth:truth": -790731381, + "com.googlecode.javaewah:JavaEWAH": 1414179411, + "com.googlecode.prolog-cafe:prolog-cafeteria": 1472963375, + "com.googlecode.prolog-cafe:prolog-compiler": -348984778, + "com.googlecode.prolog-cafe:prolog-io": -1081577073, + "com.googlecode.prolog-cafe:prolog-runtime": 1849117715, + "com.h2database:h2": 867138720, + "com.ibm.icu:icu4j": -802150924, + "com.icegreen:greenmail": -1151466560, + "com.jcraft:jsch": 1133842314, + "com.jcraft:jzlib": 864660349, + "com.ryanharter.auto.value:auto-value-gson-extension": 520453879, + "com.ryanharter.auto.value:auto-value-gson-factory": 1068512450, + "com.ryanharter.auto.value:auto-value-gson-runtime": 187755792, + "com.squareup:javapoet": 1762437360, + "com.sun.mail:javax.mail": -1087889454, + "com.vladsch.flexmark:flexmark": -550438914, + "com.vladsch.flexmark:flexmark-all": -2127316941, + "com.vladsch.flexmark:flexmark-ext-abbreviation": -475593117, + "com.vladsch.flexmark:flexmark-ext-admonition": -1153966935, + "com.vladsch.flexmark:flexmark-ext-anchorlink": 839917508, + "com.vladsch.flexmark:flexmark-ext-aside": 1788183869, + "com.vladsch.flexmark:flexmark-ext-attributes": -237003540, + "com.vladsch.flexmark:flexmark-ext-autolink": -775675714, + "com.vladsch.flexmark:flexmark-ext-definition": -1063903512, + "com.vladsch.flexmark:flexmark-ext-emoji": -1842781365, + "com.vladsch.flexmark:flexmark-ext-enumerated-reference": 1966555987, + "com.vladsch.flexmark:flexmark-ext-escaped-character": 756675428, + "com.vladsch.flexmark:flexmark-ext-footnotes": 1651146392, + "com.vladsch.flexmark:flexmark-ext-gfm-issues": 1508621966, + "com.vladsch.flexmark:flexmark-ext-gfm-strikethrough": 1031891129, + "com.vladsch.flexmark:flexmark-ext-gfm-tasklist": -416237513, + "com.vladsch.flexmark:flexmark-ext-gfm-users": 851191150, + "com.vladsch.flexmark:flexmark-ext-gitlab": -1211270640, + "com.vladsch.flexmark:flexmark-ext-ins": -1319904013, + "com.vladsch.flexmark:flexmark-ext-jekyll-front-matter": -1599974514, + "com.vladsch.flexmark:flexmark-ext-jekyll-tag": 1016014859, + "com.vladsch.flexmark:flexmark-ext-macros": -1189519940, + "com.vladsch.flexmark:flexmark-ext-media-tags": -1282079881, + "com.vladsch.flexmark:flexmark-ext-resizable-image": -1722924994, + "com.vladsch.flexmark:flexmark-ext-superscript": 744030475, + "com.vladsch.flexmark:flexmark-ext-tables": 459921658, + "com.vladsch.flexmark:flexmark-ext-toc": -855709875, + "com.vladsch.flexmark:flexmark-ext-typographic": 1893270313, + "com.vladsch.flexmark:flexmark-ext-wikilink": 464227519, + "com.vladsch.flexmark:flexmark-ext-xwiki-macros": -923730559, + "com.vladsch.flexmark:flexmark-ext-yaml-front-matter": -838154288, + "com.vladsch.flexmark:flexmark-ext-youtube-embedded": 1971650569, + "com.vladsch.flexmark:flexmark-html2md-converter": 2051980162, + "com.vladsch.flexmark:flexmark-jira-converter": 1004522922, + "com.vladsch.flexmark:flexmark-pdf-converter": -1276341834, + "com.vladsch.flexmark:flexmark-profile-pegdown": -927609279, + "com.vladsch.flexmark:flexmark-util": -1104989973, + "com.vladsch.flexmark:flexmark-util-ast": 86246816, + "com.vladsch.flexmark:flexmark-util-builder": -318289735, + "com.vladsch.flexmark:flexmark-util-collection": -1853480582, + "com.vladsch.flexmark:flexmark-util-data": 1324722118, + "com.vladsch.flexmark:flexmark-util-dependency": 1614591655, + "com.vladsch.flexmark:flexmark-util-format": 1729104083, + "com.vladsch.flexmark:flexmark-util-html": -2133556665, + "com.vladsch.flexmark:flexmark-util-misc": 2125496840, + "com.vladsch.flexmark:flexmark-util-options": 476423996, + "com.vladsch.flexmark:flexmark-util-sequence": 1008552413, + "com.vladsch.flexmark:flexmark-util-visitor": -1002447604, + "com.vladsch.flexmark:flexmark-youtrack-converter": -1024344568, + "commons-beanutils:commons-beanutils": -399736270, + "commons-codec:commons-codec": -1724312797, + "commons-collections:commons-collections": -1308259318, + "commons-dbcp:commons-dbcp": -873877417, + "commons-digester:commons-digester": 1688456634, + "commons-io:commons-io": -1192334111, + "commons-logging:commons-logging": 416626737, + "commons-net:commons-net": 1227155931, + "commons-pool:commons-pool": -2015226625, + "commons-validator:commons-validator": -89255997, + "dk.brics:automaton": -733058, + "eu.medsea.mimeutil:mime-util": -1364938376, + "io.dropwizard.metrics:metrics-core": 1542162680, + "io.github.java-diff-utils:java-diff-utils": 1296992014, + "io.sweers.autotransient:autotransient": 1591678970, + "jakarta.inject:jakarta.inject-api": 190564362, + "javax.activation:activation": -940616209, + "javax.annotation:jsr250-api": -593155132, + "javax.inject:javax.inject": -297932879, + "javax.servlet:javax.servlet-api": 669233360, + "junit:junit": -744267592, + "log4j:log4j": 182326902, + "net.bytebuddy:byte-buddy": 1859026651, + "net.bytebuddy:byte-buddy-agent": 1422085603, + "net.java.dev.jna:jna": 929040997, + "net.java.dev.jna:jna-platform": 1235639073, + "net.minidev:json-smart": -1043043954, + "net.sf.jopt-simple:jopt-simple": 906822697, + "net.sourceforge.nekohtml:nekohtml": 1723624706, + "org.antlr:antlr": -2055062274, + "org.antlr:antlr-runtime": -2145792567, + "org.antlr:stringtemplate": -752719922, + "org.apache.commons:commons-compress": -1289113474, + "org.apache.commons:commons-lang3": 109544183, + "org.apache.commons:commons-math3": -1738699872, + "org.apache.commons:commons-text": -1886494041, + "org.apache.httpcomponents:fluent-hc": 58615850, + "org.apache.httpcomponents:httpclient": 1643860045, + "org.apache.httpcomponents:httpcore": -1696303652, + "org.apache.james:apache-mime4j-core": -753273784, + "org.apache.james:apache-mime4j-dom": -1889888125, + "org.apache.lucene:lucene-analysis-common": 582251708, + "org.apache.lucene:lucene-backward-codecs": 1414616849, + "org.apache.lucene:lucene-core": 299918137, + "org.apache.lucene:lucene-facet": -125344464, + "org.apache.lucene:lucene-misc": -1756758452, + "org.apache.lucene:lucene-queries": 1341801569, + "org.apache.lucene:lucene-queryparser": 2090205297, + "org.apache.lucene:lucene-sandbox": 1803509280, + "org.apache.mina:mina-core": -955969490, + "org.apache.sshd:sshd-common": 1844247612, + "org.apache.sshd:sshd-core": 602454664, + "org.apache.sshd:sshd-mina": -1925771193, + "org.apache.sshd:sshd-osgi": 1671034928, + "org.apache.sshd:sshd-sftp": 2079258759, + "org.asciidoctor:asciidoctorj": -457860213, + "org.assertj:assertj-core": -1145412507, + "org.bouncycastle:bcpg-jdk18on": 235240928, + "org.bouncycastle:bcpkix-jdk18on": 1954093523, + "org.bouncycastle:bcprov-jdk18on": 402064210, + "org.bouncycastle:bcutil-jdk18on": 1337943403, + "org.commonmark:commonmark": 1129543740, + "org.commonmark:commonmark-ext-autolink": -1853742120, + "org.commonmark:commonmark-ext-gfm-strikethrough": 350394231, + "org.commonmark:commonmark-ext-gfm-tables": 1881582931, + "org.eclipse.jetty.ee8:jetty-ee8-nested": -685134473, + "org.eclipse.jetty.ee8:jetty-ee8-security": 2052418638, + "org.eclipse.jetty.ee8:jetty-ee8-servlet": -1368555033, + "org.eclipse.jetty:jetty-http": 1984891007, + "org.eclipse.jetty:jetty-io": 1765684893, + "org.eclipse.jetty:jetty-jmx": -1704061949, + "org.eclipse.jetty:jetty-security": 1160320567, + "org.eclipse.jetty:jetty-server": 280305722, + "org.eclipse.jetty:jetty-servlet": 697742931, + "org.eclipse.jetty:jetty-session": 292732683, + "org.eclipse.jetty:jetty-util": -1520256775, + "org.eclipse.jetty:jetty-util-ajax": -726746674, + "org.hamcrest:hamcrest": 1547523135, + "org.jruby:jruby-complete": -2103568068, + "org.json:json": -811907600, + "org.jsoup:jsoup": -1061087167, + "org.mockito:mockito-core": 629099222, + "org.nibor.autolink:autolink": -342487050, + "org.objenesis:objenesis": 748376655, + "org.openid4java:openid4java": -842286787, + "org.openjdk.jmh:jmh-core": 983716932, + "org.openjdk.jmh:jmh-generator-annprocess": -1162360421, + "org.ow2.asm:asm": 1206815935, + "org.ow2.asm:asm-analysis": 53497832, + "org.ow2.asm:asm-commons": 1607605466, + "org.ow2.asm:asm-tree": -1365652182, + "org.ow2.asm:asm-util": 838800782, + "org.roaringbitmap:RoaringBitmap": -1626007605, + "org.roaringbitmap:shims": 1581618356, + "org.slf4j:jcl-over-slf4j": -509492287, + "org.slf4j:slf4j-api": -197708565, + "org.slf4j:slf4j-ext": 890682116, + "org.slf4j:slf4j-log4j12": -630224096, + "org.slf4j:slf4j-reload4j": -1466046388, + "org.slf4j:slf4j-simple": -487947767, + "org.tukaani:xz": 64286142, + "repositories": 2019057769, + "xerces:xercesImpl": -1165914651, + "xml-apis:xml-apis": -113825062 + }, + "__RESOLVED_ARTIFACTS_HASH": { + "antlr:antlr": 2120497295, + "aopalliance:aopalliance": -1268465981, + "aopalliance:aopalliance:jar:sources": 442956464, + "args4j:args4j": 1165073517, + "args4j:args4j:jar:sources": -1061197748, + "ch.qos.reload4j:reload4j": 1742597253, + "ch.qos.reload4j:reload4j:jar:sources": -505557081, + "com.beust:jcommander": -86812675, + "com.beust:jcommander:jar:sources": -377940076, + "com.github.ben-manes.caffeine:caffeine": 386266583, + "com.github.ben-manes.caffeine:caffeine:jar:sources": 824791053, + "com.github.ben-manes.caffeine:guava": -586447867, + "com.github.ben-manes.caffeine:guava:jar:sources": -2102940169, + "com.github.rholder:guava-retrying": -193656863, + "com.github.rholder:guava-retrying:jar:sources": 402259526, + "com.google.auto.factory:auto-factory": 117391549, + "com.google.auto.factory:auto-factory:jar:sources": 1626334411, + "com.google.auto.service:auto-service-annotations": -2030804522, + "com.google.auto.service:auto-service-annotations:jar:sources": 1880995980, + "com.google.auto.value:auto-value": 1610847974, + "com.google.auto.value:auto-value-annotations": 818774630, + "com.google.auto.value:auto-value-annotations:jar:sources": -1313058273, + "com.google.auto.value:auto-value:jar:sources": -1087092342, + "com.google.auto:auto-common": 1232278285, + "com.google.auto:auto-common:jar:sources": 1238804620, + "com.google.code.findbugs:jsr305": 1028218835, + "com.google.code.findbugs:jsr305:jar:sources": 1130389911, + "com.google.code.gson:gson": -1287986166, + "com.google.code.gson:gson:jar:sources": -1236668210, + "com.google.common.html.types:types": -560413073, + "com.google.common.html.types:types:jar:sources": 1541167117, + "com.google.errorprone:error_prone_annotations": -2118374750, + "com.google.errorprone:error_prone_annotations:jar:sources": -88858373, + "com.google.flogger:flogger": 2071094150, + "com.google.flogger:flogger-log4j-backend": -1752942291, + "com.google.flogger:flogger-log4j-backend:jar:sources": 167088335, + "com.google.flogger:flogger-system-backend": 170636970, + "com.google.flogger:flogger-system-backend:jar:sources": 267353177, + "com.google.flogger:flogger:jar:sources": -399970922, + "com.google.flogger:google-extensions": 683610995, + "com.google.flogger:google-extensions:jar:sources": -362108063, + "com.google.gitiles:blame-cache": -1735353948, + "com.google.gitiles:blame-cache:jar:sources": -370259038, + "com.google.gitiles:gitiles-servlet": -1411302355, + "com.google.gitiles:gitiles-servlet:jar:sources": 192620509, + "com.google.guava:failureaccess": -121989663, + "com.google.guava:failureaccess:jar:sources": 2092951686, + "com.google.guava:guava": -1983533712, + "com.google.guava:guava-testlib": 869030181, + "com.google.guava:guava-testlib:jar:sources": -1116521441, + "com.google.guava:guava:jar:sources": 1163674882, + "com.google.guava:listenablefuture": -181371066, + "com.google.inject.extensions:guice-assistedinject": -191835468, + "com.google.inject.extensions:guice-assistedinject:jar:sources": 1682505664, + "com.google.inject.extensions:guice-servlet": -1455734849, + "com.google.inject.extensions:guice-servlet:jar:sources": 1055060429, + "com.google.inject:guice": -1714385564, + "com.google.inject:guice:jar:sources": 1581524423, + "com.google.j2objc:j2objc-annotations": -1833492981, + "com.google.j2objc:j2objc-annotations:jar:sources": 1596689722, + "com.google.jimfs:jimfs": -542987600, + "com.google.jimfs:jimfs:jar:sources": 1692501412, + "com.google.jsinterop:jsinterop-annotations": 1916195800, + "com.google.jsinterop:jsinterop-annotations:jar:sources": -1738684177, + "com.google.protobuf:protobuf-java": -1428941287, + "com.google.protobuf:protobuf-java:jar:sources": -121171885, + "com.google.template:soy": 844020978, + "com.google.template:soy:jar:sources": 934091432, + "com.google.truth.extensions:truth-java8-extension": 2072975728, + "com.google.truth.extensions:truth-java8-extension:jar:sources": -404559504, + "com.google.truth.extensions:truth-liteproto-extension": -47385409, + "com.google.truth.extensions:truth-liteproto-extension:jar:sources": -96134878, + "com.google.truth.extensions:truth-proto-extension": 92287925, + "com.google.truth.extensions:truth-proto-extension:jar:sources": -1961892523, + "com.google.truth:truth": 1710077790, + "com.google.truth:truth:jar:sources": -1194452779, + "com.googlecode.javaewah:JavaEWAH": 157530325, + "com.googlecode.javaewah:JavaEWAH:jar:sources": 728434282, + "com.googlecode.prolog-cafe:prolog-cafeteria": 1166081954, + "com.googlecode.prolog-cafe:prolog-cafeteria:jar:sources": 186391213, + "com.googlecode.prolog-cafe:prolog-compiler": -741003406, + "com.googlecode.prolog-cafe:prolog-compiler:jar:sources": 126666107, + "com.googlecode.prolog-cafe:prolog-io": -900599131, + "com.googlecode.prolog-cafe:prolog-io:jar:sources": -1440088888, + "com.googlecode.prolog-cafe:prolog-runtime": 828309397, + "com.googlecode.prolog-cafe:prolog-runtime:jar:sources": -1157091735, + "com.h2database:h2": 682770038, + "com.h2database:h2:jar:sources": -675004040, + "com.ibm.icu:icu4j": -1964869803, + "com.ibm.icu:icu4j:jar:sources": 864352766, + "com.icegreen:greenmail": 798674118, + "com.icegreen:greenmail:jar:sources": -1978817971, + "com.jcraft:jsch": 1986987929, + "com.jcraft:jsch:jar:sources": 707308247, + "com.jcraft:jzlib": 1581025040, + "com.jcraft:jzlib:jar:sources": 807243431, + "com.ryanharter.auto.value:auto-value-gson-extension": -887048407, + "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources": 1925708692, + "com.ryanharter.auto.value:auto-value-gson-factory": -1576123811, + "com.ryanharter.auto.value:auto-value-gson-factory:jar:sources": -768025607, + "com.ryanharter.auto.value:auto-value-gson-runtime": 1467427582, + "com.ryanharter.auto.value:auto-value-gson-runtime:jar:sources": -831897407, + "com.squareup:javapoet": 1313128977, + "com.squareup:javapoet:jar:sources": -1200205912, + "com.sun.mail:javax.mail": -382507283, + "com.sun.mail:javax.mail:jar:sources": 1073816789, + "com.vladsch.flexmark:flexmark-all:jar:lib": 347351331, + "commons-codec:commons-codec": 1048744614, + "commons-codec:commons-codec:jar:sources": -1637317619, + "commons-dbcp:commons-dbcp": -1946826524, + "commons-dbcp:commons-dbcp:jar:sources": -1238094905, + "commons-io:commons-io": -344095518, + "commons-io:commons-io:jar:sources": 1651105036, + "commons-net:commons-net": 29132340, + "commons-net:commons-net:jar:sources": 1898783167, + "commons-pool:commons-pool": -3173032, + "commons-pool:commons-pool:jar:sources": 1763080609, + "commons-validator:commons-validator": -1615658806, + "commons-validator:commons-validator:jar:sources": 1267489728, + "dk.brics:automaton": 1622735787, + "dk.brics:automaton:jar:sources": 1946522418, + "eu.medsea.mimeutil:mime-util": 1650226474, + "io.dropwizard.metrics:metrics-core": -392790881, + "io.dropwizard.metrics:metrics-core:jar:sources": 159424675, + "io.github.java-diff-utils:java-diff-utils": 457660153, + "io.github.java-diff-utils:java-diff-utils:jar:sources": -619235043, + "io.sweers.autotransient:autotransient": 1340991802, + "io.sweers.autotransient:autotransient:jar:sources": 80080981, + "jakarta.inject:jakarta.inject-api": 851002214, + "jakarta.inject:jakarta.inject-api:jar:sources": -1570274750, + "javax.activation:activation": -1092171588, + "javax.activation:activation:jar:sources": 1310123688, + "javax.inject:javax.inject": -1960241368, + "javax.inject:javax.inject:jar:sources": -34689928, + "javax.servlet:javax.servlet-api": 1796323811, + "javax.servlet:javax.servlet-api:jar:sources": 137081253, + "junit:junit": 238187285, + "junit:junit:jar:sources": 1084731434, + "net.bytebuddy:byte-buddy": 397933364, + "net.bytebuddy:byte-buddy-agent": -835790122, + "net.bytebuddy:byte-buddy-agent:jar:sources": -1511145134, + "net.bytebuddy:byte-buddy:jar:sources": 1647745115, + "net.java.dev.jna:jna": -1916526385, + "net.java.dev.jna:jna-platform": 459618853, + "net.java.dev.jna:jna-platform:jar:sources": -2077304647, + "net.java.dev.jna:jna:jar:sources": 1518406952, + "net.minidev:json-smart": -1913865264, + "net.minidev:json-smart:jar:sources": 475976688, + "net.sf.jopt-simple:jopt-simple": 1531230776, + "net.sf.jopt-simple:jopt-simple:jar:sources": -1087190884, + "net.sourceforge.nekohtml:nekohtml": 589300296, + "net.sourceforge.nekohtml:nekohtml:jar:sources": 1455941061, + "org.antlr:ST4": 943007212, + "org.antlr:ST4:jar:sources": 1076411807, + "org.antlr:antlr": -10615566, + "org.antlr:antlr-runtime": -20800628, + "org.antlr:antlr-runtime:jar:sources": 1652502717, + "org.antlr:antlr:jar:sources": -1207538557, + "org.antlr:stringtemplate": -1826141279, + "org.antlr:stringtemplate:jar:sources": 831821310, + "org.apache.commons:commons-compress": -1341777504, + "org.apache.commons:commons-compress:jar:sources": 128824058, + "org.apache.commons:commons-lang3": 1593572986, + "org.apache.commons:commons-lang3:jar:sources": 1589181154, + "org.apache.commons:commons-math3": 1532637713, + "org.apache.commons:commons-math3:jar:sources": 1655744467, + "org.apache.commons:commons-text": -1205775100, + "org.apache.commons:commons-text:jar:sources": 1247506591, + "org.apache.httpcomponents:fluent-hc": 1857459163, + "org.apache.httpcomponents:fluent-hc:jar:sources": 1555727994, + "org.apache.httpcomponents:httpclient": -940371367, + "org.apache.httpcomponents:httpclient:jar:sources": 1069741198, + "org.apache.httpcomponents:httpcore": -279989236, + "org.apache.httpcomponents:httpcore:jar:sources": 1796128621, + "org.apache.james:apache-mime4j-core": 596731567, + "org.apache.james:apache-mime4j-core:jar:sources": 1221134342, + "org.apache.james:apache-mime4j-dom": 2052814931, + "org.apache.james:apache-mime4j-dom:jar:sources": 2056952874, + "org.apache.lucene:lucene-analysis-common": 1674710283, + "org.apache.lucene:lucene-analysis-common:jar:sources": 334029180, + "org.apache.lucene:lucene-backward-codecs": -508942990, + "org.apache.lucene:lucene-backward-codecs:jar:sources": 1999324390, + "org.apache.lucene:lucene-core": -1994692613, + "org.apache.lucene:lucene-core:jar:sources": -164995544, + "org.apache.lucene:lucene-misc": -739909715, + "org.apache.lucene:lucene-misc:jar:sources": -128984682, + "org.apache.lucene:lucene-queryparser": -1416736164, + "org.apache.lucene:lucene-queryparser:jar:sources": 1960943881, + "org.apache.mina:mina-core": -1457162716, + "org.apache.mina:mina-core:jar:sources": 1763715353, + "org.apache.sshd:sshd-mina": -1732444781, + "org.apache.sshd:sshd-mina:jar:sources": 458343698, + "org.apache.sshd:sshd-osgi": -1946993842, + "org.apache.sshd:sshd-osgi:jar:sources": -1934894876, + "org.apache.sshd:sshd-sftp": 2069855147, + "org.apache.sshd:sshd-sftp:jar:sources": 1450030982, + "org.asciidoctor:asciidoctorj": -943976727, + "org.asciidoctor:asciidoctorj:jar:sources": -1022891917, + "org.assertj:assertj-core": -990295630, + "org.assertj:assertj-core:jar:sources": 1906472612, + "org.bouncycastle:bcpg-jdk18on": -733747199, + "org.bouncycastle:bcpg-jdk18on:jar:sources": -1694649401, + "org.bouncycastle:bcpkix-jdk18on": -1768219399, + "org.bouncycastle:bcpkix-jdk18on:jar:sources": -1872178221, + "org.bouncycastle:bcprov-jdk18on": 2094444272, + "org.bouncycastle:bcprov-jdk18on:jar:sources": -486883993, + "org.bouncycastle:bcutil-jdk18on": -1524758277, + "org.bouncycastle:bcutil-jdk18on:jar:sources": 522880491, + "org.checkerframework:checker-compat-qual": -1678975214, + "org.checkerframework:checker-compat-qual:jar:sources": -673395382, + "org.checkerframework:checker-qual": -1657280421, + "org.checkerframework:checker-qual:jar:sources": 324669507, + "org.commonmark:commonmark": -1467575831, + "org.commonmark:commonmark-ext-autolink": -1808977749, + "org.commonmark:commonmark-ext-autolink:jar:sources": -1324197024, + "org.commonmark:commonmark-ext-gfm-strikethrough": 1872267513, + "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources": 1099767676, + "org.commonmark:commonmark-ext-gfm-tables": -435057552, + "org.commonmark:commonmark-ext-gfm-tables:jar:sources": -1247551792, + "org.commonmark:commonmark:jar:sources": -1275158082, + "org.eclipse.jetty.ee8:jetty-ee8-nested": 1225428153, + "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": 1999421701, + "org.eclipse.jetty.ee8:jetty-ee8-security": 667551496, + "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources": 447244400, + "org.eclipse.jetty.ee8:jetty-ee8-servlet": 368157283, + "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources": -2138255372, + "org.eclipse.jetty.toolchain:jetty-servlet-api": 626738376, + "org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources": -717561233, + "org.eclipse.jetty:jetty-http": -2051325092, + "org.eclipse.jetty:jetty-http:jar:sources": -882391521, + "org.eclipse.jetty:jetty-io": 1774579468, + "org.eclipse.jetty:jetty-io:jar:sources": -231665913, + "org.eclipse.jetty:jetty-jmx": -385762105, + "org.eclipse.jetty:jetty-jmx:jar:sources": -1468671741, + "org.eclipse.jetty:jetty-security": 1698636651, + "org.eclipse.jetty:jetty-security:jar:sources": 118665539, + "org.eclipse.jetty:jetty-server": -477112374, + "org.eclipse.jetty:jetty-server:jar:sources": -710016839, + "org.eclipse.jetty:jetty-servlet": -974397932, + "org.eclipse.jetty:jetty-servlet:jar:sources": 1281350506, + "org.eclipse.jetty:jetty-session": 1978843064, + "org.eclipse.jetty:jetty-session:jar:sources": 244883860, + "org.eclipse.jetty:jetty-util": 2086696316, + "org.eclipse.jetty:jetty-util-ajax": -451213174, + "org.eclipse.jetty:jetty-util-ajax:jar:sources": 1686109257, + "org.eclipse.jetty:jetty-util:jar:sources": 958996619, + "org.hamcrest:hamcrest": -1550813651, + "org.hamcrest:hamcrest-core": -1198150244, + "org.hamcrest:hamcrest-core:jar:sources": -1576492927, + "org.hamcrest:hamcrest:jar:sources": -1807813747, + "org.jruby:jruby-complete": 407729900, + "org.jruby:jruby-complete:jar:sources": 554963637, + "org.jsoup:jsoup": 789840847, + "org.jsoup:jsoup:jar:sources": 1377510617, + "org.jspecify:jspecify": -797399878, + "org.jspecify:jspecify:jar:sources": 1011232509, + "org.mockito:mockito-core": 1840231411, + "org.mockito:mockito-core:jar:sources": -1422053423, + "org.nibor.autolink:autolink": 1237374319, + "org.nibor.autolink:autolink:jar:sources": 1695391615, + "org.objenesis:objenesis": -1055367721, + "org.objenesis:objenesis:jar:sources": 707329220, + "org.openid4java:openid4java": 1656473252, + "org.openid4java:openid4java:jar:sources": 789962531, + "org.openjdk.jmh:jmh-core": -381500549, + "org.openjdk.jmh:jmh-core:jar:sources": -1014071512, + "org.openjdk.jmh:jmh-generator-annprocess": -1315975534, + "org.openjdk.jmh:jmh-generator-annprocess:jar:sources": 81479330, + "org.ow2.asm:asm": 1540593910, + "org.ow2.asm:asm-analysis": 1735619699, + "org.ow2.asm:asm-analysis:jar:sources": 609078251, + "org.ow2.asm:asm-commons": -231881389, + "org.ow2.asm:asm-commons:jar:sources": 1140590651, + "org.ow2.asm:asm-tree": 1288506580, + "org.ow2.asm:asm-tree:jar:sources": -1050091087, + "org.ow2.asm:asm-util": 247171895, + "org.ow2.asm:asm-util:jar:sources": 354115585, + "org.ow2.asm:asm:jar:sources": 511988412, + "org.roaringbitmap:RoaringBitmap": 506688526, + "org.roaringbitmap:RoaringBitmap:jar:sources": 1956160126, + "org.roaringbitmap:shims": -1352997269, + "org.roaringbitmap:shims:jar:sources": -981899312, + "org.slf4j:jcl-over-slf4j": 877987105, + "org.slf4j:jcl-over-slf4j:jar:sources": -1530791116, + "org.slf4j:slf4j-api": -1567566485, + "org.slf4j:slf4j-api:jar:sources": -1906225874, + "org.slf4j:slf4j-ext": -795464934, + "org.slf4j:slf4j-ext:jar:sources": 1756640360, + "org.slf4j:slf4j-reload4j": 874028571, + "org.slf4j:slf4j-reload4j:jar:sources": 1517828526, + "org.slf4j:slf4j-simple": 2142683014, + "org.slf4j:slf4j-simple:jar:sources": 334190933, + "org.tukaani:xz": 1462559085, + "org.tukaani:xz:jar:sources": 1791731254, + "xerces:xercesImpl": -723395208, + "xerces:xercesImpl:jar:sources": -127516017 + }, + "artifacts": { + "antlr:antlr": { + "shasums": { + "jar": "88fbda4b912596b9f56e8e12e580cc954bacfb51776ecfddd3e18fc1cf56dc4c" + }, + "version": "2.7.7" + }, + "aopalliance:aopalliance": { + "shasums": { + "jar": "0addec670fedcd3f113c5c8091d783280d23f75e3acb841b61a9cdb079376a08", + "sources": "e6ef91d439ada9045f419c77543ebe0416c3cdfc5b063448343417a3e4a72123" + }, + "version": "1.0" + }, + "args4j:args4j": { + "shasums": { + "jar": "91ddeaba0b24adce72291c618c00bbdce1c884755f6c4dba9c5c46e871c69ed6", + "sources": "a337a37bc6fc9a2d81c952f4c6ebd5db12351e994d00e112d7fe87a4dd707204" + }, + "version": "2.33" + }, + "ch.qos.reload4j:reload4j": { + "shasums": { + "jar": "a7e86ae144541d04d24d1b7123d7425415477d50f387bb868e5c955aeaedd96f", + "sources": "d49655933914425105028146ca042813fed55aa0c69efe438a605178b0a1399a" + }, + "version": "1.2.26" + }, + "com.beust:jcommander": { + "shasums": { + "jar": "019c12fec1ce5c02cbabb150f6ac8a86d92a0ecc9c89a549e5537283e863000c", + "sources": "6682ba2de6bac5950c99c7ebda6c5bfd6dde4026619fcb16f4f8de869ca78579" + }, + "version": "1.35" + }, + "com.github.ben-manes.caffeine:caffeine": { + "shasums": { + "jar": "ff0245864c6d38c2129981b5f0efc8146057fe4a55497c2345aeded46a2513b9", + "sources": "9d48808f2b13b48a134f9135dcc14ff8486a72de01677a537f6adaf7e6cd77ca" + }, + "version": "2.9.2" + }, + "com.github.ben-manes.caffeine:guava": { + "shasums": { + "jar": "6e48965614557ba4d3c55a197e20c38f23a20032ef8aace37e95ed64d2ebc9a6", + "sources": "b613b0d3f7587da0ff1eb19410d6309fd3764f607cfa1b0682aae1515a73afab" + }, + "version": "2.9.2" + }, + "com.github.rholder:guava-retrying": { + "shasums": { + "jar": "5f6049a70c6c2fb56a5f3a3e1350ce1e853b5c6ccaba01fbf9c6835d72ac9484", + "sources": "3e1e871288340a2cf5af6b8caf747cce17fc496451968e665d66d71a5da987a9" + }, + "version": "2.0.0" + }, + "com.google.auto.factory:auto-factory": { + "shasums": { + "jar": "d59fb7ada5962a480abf0b81d4d2a14a2952f17c026732359af8b585e531c16c", + "sources": "c6098f8976b8833cf40edc36d1e3f7cd5cbb474c018185650b5ca4e24e713e6a" + }, + "version": "1.0.1" + }, + "com.google.auto.service:auto-service-annotations": { + "shasums": { + "jar": "c7bec54b7b5588b5967e870341091c5691181d954cf2039f1bf0a6eeb837473b", + "sources": "b013ca159b0fea3a0041d3d5fbb3b7e49a819da80a172a01fb17dd28fd98e72b" + }, + "version": "1.0.1" + }, + "com.google.auto.value:auto-value": { + "shasums": { + "jar": "aaf8d637bfed3c420436b9facf1b7a88d12c8785374e4202382783005319c2c3", + "sources": "4bff06fe077d68f964bd5e05f020ed78fd7870730441e403a2eb306360c4890a" + }, + "version": "1.11.0" + }, + "com.google.auto.value:auto-value-annotations": { + "shasums": { + "jar": "5a055ce4255333b3346e1a8703da5bf8ff049532286fdcd31712d624abe111dd", + "sources": "d7941e5f19bb38afcfa85350d57e5245856c23c98c2bbe32f6d31b5577f2bc33" + }, + "version": "1.11.0" + }, + "com.google.auto:auto-common": { + "shasums": { + "jar": "f50b1ce8a41fad31a8a819c052f8ffa362ea0a3dbe9ef8f7c7dc9a36d4738a59", + "sources": "173f0a89b59e20a3219074a13d1656d7e207391438459521d11b0adcb814769e" + }, + "version": "1.2.2" + }, + "com.google.code.findbugs:jsr305": { + "shasums": { + "jar": "c885ce34249682bc0236b4a7d56efcc12048e6135a5baf7a9cde8ad8cda13fcd", + "sources": "56c80429d828bfaaefbf0358334c2629228a0ca25c073707be589e9d6c9406e6" + }, + "version": "3.0.1" + }, + "com.google.code.gson:gson": { + "shasums": { + "jar": "dd0ce1b55a3ed2080cb70f9c655850cda86c206862310009dcb5e5c95265a5e0", + "sources": "058974b69cb7b0a04712278e11870e84ee8cd8fb5f551bd8401e72ba6638bfef" + }, + "version": "2.13.2" + }, + "com.google.common.html.types:types": { + "shasums": { + "jar": "7d81d47117284457c8760f9372a47d6cdf45398d9e9a3dfbfd108fc57937aebe", + "sources": "67bd953b29c5ed25e1b02885aed8de17b8c0bd4c97059e89d27dd88c636efc06" + }, + "version": "1.0.8" + }, + "com.google.errorprone:error_prone_annotations": { + "shasums": { + "jar": "b67be81ff4b956401146e14eaf1526bc435a9480f2546e91eb45b796631a8a99", + "sources": "04767f1e647ef132c5a7c24fb67ecf169ac90dd92024393669e95f86ef2161d7" + }, + "version": "2.46.0" + }, + "com.google.flogger:flogger": { + "shasums": { + "jar": "bebe7cd82be6c8d5208d6e960cd4344ea10672132ef06f5d4c71a48ab442b963", + "sources": "46ec404ace4db71b3657bae1219e58e2b4a65917afe2ba6de0b1eee0a8778e07" + }, + "version": "0.8" + }, + "com.google.flogger:flogger-log4j-backend": { + "shasums": { + "jar": "922835d7c0dabb6fbd54acdc0c04af2fbea1eacd870e96b799538aae9e3324a6", + "sources": "5ec4c2627f427013e72144ce8135d07f808cbd9a85e45bcbe1f3ce859781af85" + }, + "version": "0.8" + }, + "com.google.flogger:flogger-system-backend": { + "shasums": { + "jar": "eb4428e483c5332381778d78c6a19da63b4fef3fa7e40f62dadabea0d7600cb4", + "sources": "8985d66e053713e4f8125dfb77256aee2d290f692dc9564d8f2bd4a6027f1655" + }, + "version": "0.8" + }, + "com.google.flogger:google-extensions": { + "shasums": { + "jar": "0dcaf56444dd96f97713e43619cd3342b4ff8532bda21ffdf7a9eb7eb37e6c5a", + "sources": "bf52cbcbf9576a84b5652c6913f708ff55ae41cd5ad07d44a9b07ec5477a1947" + }, + "version": "0.8" + }, + "com.google.gitiles:blame-cache": { + "shasums": { + "jar": "41dc6fe7d9967d3726cca4ff5e92247cd8c8da5ddc61b730077b8778c4829fcf", + "sources": "9e4d550f35331762434ec1bbd448d335971f00c8ee58c91500bc8fecad156a46" + }, + "version": "1.6.0" + }, + "com.google.gitiles:gitiles-servlet": { + "shasums": { + "jar": "08562ea7d57d881042e0598722e011e1c40bb69317065f2f5a67277f9c946be3", + "sources": "1a6556cde47342a29b93c0ebca969c728ec60b97cbee40bdf4a795ef8a0eb2b2" + }, + "version": "1.6.0" + }, + "com.google.guava:failureaccess": { + "shasums": { + "jar": "cbfc3906b19b8f55dd7cfd6dfe0aa4532e834250d7f080bd8d211a3e246b59cb", + "sources": "6fef4dfd2eb9f961655f2a3c4ea87c023618d9fcbfb6b104c17862e5afe66b97" + }, + "version": "1.0.3" + }, + "com.google.guava:guava": { + "shasums": { + "jar": "1e301f0c52ac248b0b14fdc3d12283c77252d4d6f48521d572e7d8c4c2cc4ac7", + "sources": "79423ae87a2203950e0e3ce2a00682b3b8d8557e631bbf662dba5494fe3b55cb" + }, + "version": "33.5.0-jre" + }, + "com.google.guava:guava-testlib": { + "shasums": { + "jar": "d98a26c3c32803007ca4a1126f36d66cc0ba4b4471eb650ed2e329a2b49b285b", + "sources": "b535fa90b57f7b44167383e5c60d73f4086c6cbab474fb6cadb29df9d241b321" + }, + "version": "33.5.0-jre" + }, + "com.google.guava:listenablefuture": { + "shasums": { + "jar": "b372a037d4230aa57fbeffdef30fd6123f9c0c2db85d0aced00c91b974f33f99" + }, + "version": "9999.0-empty-to-avoid-conflict-with-guava" + }, + "com.google.inject.extensions:guice-assistedinject": { + "shasums": { + "jar": "f4d264534f213af7beb493273f72f02ef9eb4b229d71a0a80f003b7349dee6f9", + "sources": "be0f353df20cb375c30b2030de49ac93a6e171f4abbc66b94961baaf584ddfe3" + }, + "version": "6.0.0" + }, + "com.google.inject.extensions:guice-servlet": { + "shasums": { + "jar": "0450043d0770b816957856ff9d08209c31f801a31e820e53e45ac29468514002", + "sources": "60496058708186f6a27de1f553387337c90c5d2d0939326de175996fcf97baeb" + }, + "version": "6.0.0" + }, + "com.google.inject:guice": { + "shasums": { + "jar": "b4d4f7ec5e8fc17b4f98dee9d3f6cf6ae3ae13e2e5ed4b2f7bbf09bc4bb675d5", + "sources": "656b82a85535ada22d251fbc4ab3e786e66997510d03325d168bc193c2148c09" + }, + "version": "6.0.0" + }, + "com.google.j2objc:j2objc-annotations": { + "shasums": { + "jar": "2994a7eb78f2710bd3d3bfb639b2c94e219cedac0d4d084d516e78c16dddecf6", + "sources": "2cd9022a77151d0b574887635cdfcdf3b78155b602abc89d7f8e62aba55cfb4f" + }, + "version": "1.1" + }, + "com.google.jimfs:jimfs": { + "shasums": { + "jar": "de16d5c8489729a8512f1a02fbd81f58f89249b72066987da4cc5c87ecb9f72d", + "sources": "1f1b3209cb85d6cecadc0ed36831ba175557a4bd66798bd2f12cd2ee3d39129e" + }, + "version": "1.2" + }, + "com.google.jsinterop:jsinterop-annotations": { + "shasums": { + "jar": "b2cc45519d62a1144f8cd932fa0c2c30a944c3ae9f060934587a337d81b391c8", + "sources": "082d7a7cba06f0543b7d0085929897f343054acd8491a4d7020ab433d4f6daf5" + }, + "version": "1.0.1" + }, + "com.google.protobuf:protobuf-java": { + "shasums": { + "jar": "3ca892fd6ea8b37d01bb6917dbc0bf2637548b756753f65a28d4f1d4d982347f", + "sources": "ed30fe6a51c7c15a6f123448304c97185f2039f2aeca9d5e3b4f53de3a4c813c" + }, + "version": "4.33.4" + }, + "com.google.template:soy": { + "shasums": { + "jar": "f68ae7f2daa8c343075210a21014ccdf042435a2d5c0dacb39f7a78b8ea88491", + "sources": "0e886bf691a90d0fbe9e19e3a0eec5c7e72e4386f209e86988b6e867ba1e4441" + }, + "version": "2024-01-30" + }, + "com.google.truth.extensions:truth-java8-extension": { + "shasums": { + "jar": "fc06f188c2c99991b010c986010b62109dae61f96ead46234642c294a09b70ae", + "sources": "fc8fed1434426471a26ca12a21b163e3eb85274082327a54a537bd1fe0d18f25" + }, + "version": "1.4.4" + }, + "com.google.truth.extensions:truth-liteproto-extension": { + "shasums": { + "jar": "503bd6ecbf1d508ff05407b798b0e3044fe914e8d9971de97d46442411f56512", + "sources": "3addbea057d6dca8506b4ea15bbd2b7859052ab2f097351b7439462ffe9afbe1" + }, + "version": "1.4.4" + }, + "com.google.truth.extensions:truth-proto-extension": { + "shasums": { + "jar": "2e142983d689fc8ff1b4fc5f1b6c71d9b99339237ba7275937a934493b2b3f79", + "sources": "2ed4584e68c902a5df39d396222015c947ab21ddf90103e212b35e79b07b6c9a" + }, + "version": "1.4.4" + }, + "com.google.truth:truth": { + "shasums": { + "jar": "52c86cddadc31bc8457c1e15689fc6b75e2e97ce2a83d8b54b795d556d489f8c", + "sources": "32da2ce3fd5f2622cda8bdecc316ee1634b376a8a330c910e4e46831f2c7a4f3" + }, + "version": "1.4.4" + }, + "com.googlecode.javaewah:JavaEWAH": { + "shasums": { + "jar": "b19204331ac6e204befaf508a68f52ec6b4638d9d9bacddbebd435f9c4d500f2", + "sources": "dff89879de1a8f5ec14b75f4ee2e32a544afd75dffa8d4d8d2494a0d981c993c" + }, + "version": "1.1.12" + }, + "com.googlecode.prolog-cafe:prolog-cafeteria": { + "shasums": { + "jar": "5eada879343bd6338ae35657766f54017329e1960990c1cd0beea8596117cf71", + "sources": "284b318080cbe0a84d7933a22bfea6a16802886acce0297b70fd83d7aec8db2b" + }, + "version": "1.4.4" + }, + "com.googlecode.prolog-cafe:prolog-compiler": { + "shasums": { + "jar": "530129db72cccfda5fc98560b869851aab0532ff6fb0e9c923df8c795306d382", + "sources": "02a8966d616b41646ed9d200ca4b10000f3b664d8c3c4395dafa1c993716ce34" + }, + "version": "1.4.4" + }, + "com.googlecode.prolog-cafe:prolog-io": { + "shasums": { + "jar": "c4036f0c54245a73bfc730eff90943084a4590e026d10c1890973d113edee3fe", + "sources": "99b84b51f44ef86418976434674a52f92d251887328ed38fd3eeaef5bb81989a" + }, + "version": "1.4.4" + }, + "com.googlecode.prolog-cafe:prolog-runtime": { + "shasums": { + "jar": "532631e8c3ec8e81c671f28e67003f2cff7082c54d435f538c7a02103431eeff", + "sources": "04a2f4e124ea862f12e104b321f347d7b081e2f27b0f5960579306dd2a573ea0" + }, + "version": "1.4.4" + }, + "com.h2database:h2": { + "shasums": { + "jar": "29b70e427cc1c40cdc376283adbb0cc62853073797bb5fe5761f81fe73d57ce0", + "sources": "a853be74b3f6d63438c53d19e2c368f158ee622499422ba586d1f8bc17b843e2" + }, + "version": "2.4.240" + }, + "com.ibm.icu:icu4j": { + "shasums": { + "jar": "70627c3ff4b9077f6c3dc2156fb480821f9c1a041b645373f2a0391aae3552a1", + "sources": "18bdaaf05f8820f9ed4fa81da6c6f70de78980986e942cf54e6111d013208978" + }, + "version": "78.2" + }, + "com.icegreen:greenmail": { + "shasums": { + "jar": "a7d69a4be28fa159a12abfe53f04f7ae545483adfe3bb8b83d0891cd3619a409", + "sources": "2401a2f2e88efde8644aab0984c5a7379a15c7b01a328962fd1bc592551f8386" + }, + "version": "1.5.5" + }, + "com.jcraft:jsch": { + "shasums": { + "jar": "d492b15a6d2ea3f1cc39c422c953c40c12289073dbe8360d98c0f6f9ec74fc44", + "sources": "e01ff2d282aa1b492bbb6187b3e363cd20a6ef51a6f23ae0ec4be179570a8480" + }, + "version": "0.1.55" + }, + "com.jcraft:jzlib": { + "shasums": { + "jar": "89b1360f407381bf61fde411019d8cbd009ebb10cff715f3669017a031027560", + "sources": "35ebd67941ce7024e6e7d80b60a4252a9687fa0f909a7079ac904bef6c1658cf" + }, + "version": "1.1.3" + }, + "com.ryanharter.auto.value:auto-value-gson-extension": { + "shasums": { + "jar": "261be84be30a56994e132d718a85efcd579197a2edb9426b84c5722c56955eca", + "sources": "abe7a77c93aeef313984e09e0696cbfb8c1e30ef08a61fb15c8b5bdf1eec026c" + }, + "version": "1.3.1" + }, + "com.ryanharter.auto.value:auto-value-gson-factory": { + "shasums": { + "jar": "5a76c3d401c984999d59868f08df05a15613d1428f7764fed80b722e2a277f6c", + "sources": "6e4c3717370348b6180fbc1883270b67c33434414bac392ab7864b4e71f7ff71" + }, + "version": "1.3.1" + }, + "com.ryanharter.auto.value:auto-value-gson-runtime": { + "shasums": { + "jar": "84ee23b7989d4bf19930b5bd3d03c0f2efb9e73bcee3a0208a9d1b2e1979c049", + "sources": "37f75066c279912a10c091e38ae6bfbbfdca217076eae64561a94cc35badde17" + }, + "version": "1.3.1" + }, + "com.squareup:javapoet": { + "shasums": { + "jar": "4c7517e848a71b36d069d12bb3bf46a70fd4cda3105d822b0ed2e19c00b69291", + "sources": "d1699067787846453fdcc104aeba3946f070fb2c167cfb3445838e4c86bb1f11" + }, + "version": "1.13.0" + }, + "com.sun.mail:javax.mail": { + "shasums": { + "jar": "e338cd5cb4ab79a94cc2ebb6291c23c4e2334901490c131b700bf50c1115dd55", + "sources": "3bee30c0aa5d5b2978f19d9da4ddaba45f6e887fa263819897cb8b2948f8800f" + }, + "version": "1.6.0" + }, + "com.vladsch.flexmark:flexmark-all": { + "shasums": { + "lib": "5e5ae8457f1589e16ea90b1106b2ee165193ae9b3bbc158f126bd5fdd198c5f0" + }, + "version": "0.64.0" + }, + "commons-codec:commons-codec": { + "shasums": { + "jar": "ba005f304cef92a3dede24a38ad5ac9b8afccf0d8f75839d6c1338634cf7f6e4", + "sources": "6c50e3dd81284139baddf94b3d0f78d25135eea0853f6495267196cdcf5949e3" + }, + "version": "1.18.0" + }, + "commons-dbcp:commons-dbcp": { + "shasums": { + "jar": "a6e2d83551d0e5b59aa942359f3010d35e79365e6552ad3dbaa6776e4851e4f6", + "sources": "c5b337b9d3177473da7795ef437b5dfda9f2575be374029491964a69bab551d7" + }, + "version": "1.4" + }, + "commons-io:commons-io": { + "shasums": { + "jar": "7d643a2afea8b058b762aa6fb90e5b256f6c729739f8b3784c3370ddc609e88d", + "sources": "5fd4d4493dd1eee5c6f314159dbac7fda1988a4ab037feacbeb2ecb3033c43b4" + }, + "version": "2.21.0" + }, + "commons-net:commons-net": { + "shasums": { + "jar": "d3b3866c61a47ba3bf040ab98e60c3010d027da0e7a99e1755e407dd47bc2702", + "sources": "5024a2b980df7bec5f837177775ebf0e5bec25374deaca65f02571fe65ed52f5" + }, + "version": "3.6" + }, + "commons-pool:commons-pool": { + "shasums": { + "jar": "31033df4b06efc7e449a657cc335a202e4ec845657c6e77a7684735c5941a3e7", + "sources": "aa4b9d6e8f616b08bd9b73406ac24c1347dafa1442a63f7442fe62e4db225b1f" + }, + "version": "1.5.5" + }, + "commons-validator:commons-validator": { + "shasums": { + "jar": "bd62795d7068a69cbea333f6dbf9c9c1a6ad7521443fb57202a44874f240ba25", + "sources": "9d4d052237a3b010138b853d8603d996cc3f89a6b3f793c5a50b93481cd8dea2" + }, + "version": "1.6" + }, + "dk.brics:automaton": { + "shasums": { + "jar": "d75c864362cb9fdb741809c2317a4dcfa7433c9043d773269b4a7b1b1c2234f9", + "sources": "861956f2845f449949f92fc141171c4e41d4215e02f3264806e7b1b89d9c28c2" + }, + "version": "1.12-1" + }, + "eu.medsea.mimeutil:mime-util": { + "shasums": { + "jar": "7512022ecd4228458a0ab456f9fcddac21f0759f1b07100c3528174eb63bdcaf" + }, + "version": "2.1.3" + }, + "io.dropwizard.metrics:metrics-core": { + "shasums": { + "jar": "3c70d19049d10f474ab803706b9c0677bcf5fd9e1afeb4f6c496f62e38b27678", + "sources": "46aa16c84f91d7309839710c53a30ff37684a92c518f02e18fe7d23c9739f828" + }, + "version": "4.2.37" + }, + "io.github.java-diff-utils:java-diff-utils": { + "shasums": { + "jar": "620403030d676a4a27f780a3acec7438dee1b1651a1c804fa6bb11bb07399a6f", + "sources": "1307a36819f8dac34187402947e2a9e850b9e7ce95dd5044524e4860c3378ab0" + }, + "version": "4.16" + }, + "io.sweers.autotransient:autotransient": { + "shasums": { + "jar": "914ce84508410ee1419514925f93b1855a9f7a7b5b5d02fc07f411d2a45f1bba", + "sources": "52ee9457f13ce7cf902dde81651b62867d0ffa9476b2ac2a65496b0e4dd7a539" + }, + "version": "1.0.0" + }, + "jakarta.inject:jakarta.inject-api": { + "shasums": { + "jar": "f7dc98062fccf14126abb751b64fab12c312566e8cbdc8483598bffcea93af7c", + "sources": "44f4c73fda69f8b7d87136f0f789f042f54e8ff506d40aa126199baf3752d1c9" + }, + "version": "2.0.1" + }, + "javax.activation:activation": { + "shasums": { + "jar": "ae475120e9fcd99b4b00b38329bd61cdc5eb754eee03fe66c01f50e137724f99", + "sources": "8f0625a411700ec64163f8d4bba860475519acb9799f47139c7f49740fd93703" + }, + "version": "1.1.1" + }, + "javax.inject:javax.inject": { + "shasums": { + "jar": "91c77044a50c481636c32d916fd89c9118a72195390452c81065080f957de7ff", + "sources": "c4b87ee2911c139c3daf498a781967f1eb2e75bc1a8529a2e7b328a15d0e433e" + }, + "version": "1" + }, + "javax.servlet:javax.servlet-api": { + "shasums": { + "jar": "83a03dd877d3674576f0da7b90755c8524af099ccf0607fc61aa971535ad7c60", + "sources": "a2826761ae88535afb0a3a049eacc846494e22627a1dcf14de37b956ca8748d6" + }, + "version": "4.0.1" + }, + "junit:junit": { + "shasums": { + "jar": "8e495b634469d64fb8acfa3495a065cbacc8a0fff55ce1e31007be4c16dc57d3", + "sources": "34181df6482d40ea4c046b063cb53c7ffae94bdf1b1d62695bdf3adf9dea7e3a" + }, + "version": "4.13.2" + }, + "net.bytebuddy:byte-buddy": { + "shasums": { + "jar": "e50ba78d8fd22e832c7a87bfa84cbdf93476ff4901b6e985ff66ebbde83f7f8a", + "sources": "c052324e5a6d8659b4f854581d0d5aed82c58baee48a34d53b816e6443e54cad" + }, + "version": "1.18.5" + }, + "net.bytebuddy:byte-buddy-agent": { + "shasums": { + "jar": "12b55548c2301b3ca57dbcf820fbbca7ad6effd4c1d5189b8a34deef3bab8064", + "sources": "227a50865fa0d060f98bc01ec21eb5c7561a5558862450e971307bab9fe14ba4" + }, + "version": "1.18.5" + }, + "net.java.dev.jna:jna": { + "shasums": { + "jar": "260c4b1e22b1db9e110ee441c4f13ce115f841fa48c41d78750986214b395557", + "sources": "0b9224e215b3c6a464959e3f994ddd64c14d46fb4014facd6afa1cc18e469466" + }, + "version": "5.18.1" + }, + "net.java.dev.jna:jna-platform": { + "shasums": { + "jar": "ad14c1b1ec4f43d396231219dfa635ebf828f738eac9f890ea1bc07795892d9a", + "sources": "5ffcac4b35114c6539ab9485592a90153ddeefb60e675dd9e8a2ee24e54ec1bc" + }, + "version": "5.18.1" + }, + "net.minidev:json-smart": { + "shasums": { + "jar": "cebda25c3191aa441673c43d7a5a9567aa5d86a10101ae915a885c90bcee8771", + "sources": "7ba6ecb7ac2b651136e0bd5c8d763ecbf7baa7e6196edd2c26e296e01195db38" + }, + "version": "1.1.1" + }, + "net.sf.jopt-simple:jopt-simple": { + "shasums": { + "jar": "df26cc58f235f477db07f753ba5a3ab243ebe5789d9f89ecf68dd62ea9a66c28", + "sources": "06b283801a5a94ef697b7f2c79a048c4e2f848b3daddda61cab74d882bdd97a5" + }, + "version": "5.0.4" + }, + "net.sourceforge.nekohtml:nekohtml": { + "shasums": { + "jar": "f44f6f6b355dfb083cdbd5b33c9d7920fef0c77281095df30a2b18ba5eb60d69", + "sources": "2cf7f735408ff35d87c20f89100f0372ed4f02fbd924f8e0bf37c477f734042e" + }, + "version": "1.9.10" + }, + "org.antlr:ST4": { + "shasums": { + "jar": "58caabc40c9f74b0b5993fd868e0f64a50c0759094e6a251aaafad98edfc7a3b", + "sources": "3ef0657835d918c2c2e982f917144ad29f528ba474565b1bc8191ee5fb6cdf1b" + }, + "version": "4.0.8" + }, + "org.antlr:antlr": { + "shasums": { + "jar": "5ac36c2acfb0a0f3d37dafe20b5b570f2643e2d000c648d44503c2738be643df", + "sources": "e28deef40b740401f7d7075de24ff95451bdd7a9bace3e98a1389bfd8a154d34" + }, + "version": "3.5.2" + }, + "org.antlr:antlr-runtime": { + "shasums": { + "jar": "ce3fc8ecb10f39e9a3cddcbb2ce350d272d9cd3d0b1e18e6fe73c3b9389c8734", + "sources": "3a8fde6cabadd1f6c6dcddc92edbe17501448e0553fee893cfc62becce57531a" + }, + "version": "3.5.2" + }, + "org.antlr:stringtemplate": { + "shasums": { + "jar": "8056d5586e1b18d3def6347b5d020a85722d850bb9f4d7a9aafe4f842c651ef9", + "sources": "0d11ceb967d8b8adc0ec9aecbe3b3acb051f9c4b07430dffb25f1c3a8f5ca342" + }, + "version": "4.0.2" + }, + "org.apache.commons:commons-compress": { + "shasums": { + "jar": "e1522945218456f3649a39bc4afd70ce4bd466221519dba7d378f2141a4642ca", + "sources": "6de9de4559f12bba6d41789c72f6a2a424514f2d2a3f7f49e2a3c52414db9632" + }, + "version": "1.28.0" + }, + "org.apache.commons:commons-lang3": { + "shasums": { + "jar": "4eeeae8d20c078abb64b015ec158add383ac581571cddc45c68f0c9ae0230720", + "sources": "b15732a13e40df7f07c30f2cb8572874798e8dde581f1398943d2ad3765bafaa" + }, + "version": "3.18.0" + }, + "org.apache.commons:commons-math3": { + "shasums": { + "jar": "1e56d7b058d28b65abd256b8458e3885b674c1d588fa43cd7d1cbb9c7ef2b308", + "sources": "e2ff85a3c360d56c51a7021614a194f3fbaf224054642ac535016f118322934d" + }, + "version": "3.6.1" + }, + "org.apache.commons:commons-text": { + "shasums": { + "jar": "58d2da30f058512a1e7f914e39241deca4dff5c27a085b4ed2faa9e7208067f6", + "sources": "6fa2f1db12dc4147094be928a5b54d567cea287df54e4c76b7dfb6096c05439d" + }, + "version": "1.15.0" + }, + "org.apache.httpcomponents:fluent-hc": { + "shasums": { + "jar": "6916043081fd7b0acb3e50a12a0e82187063d7a6dfed3d2fb2f3296b580ab0f8", + "sources": "01958f48652d94ef4aaad8d0fa0cbc4f6b1383b76a185c2a7e5134e3e3b34dba" + }, + "version": "4.5.14" + }, + "org.apache.httpcomponents:httpclient": { + "shasums": { + "jar": "c8bc7e1c51a6d4ce72f40d2ebbabf1c4b68bfe76e732104b04381b493478e9d6", + "sources": "55b01f9f4cbec9ac646866a4b64b176570d79e293a556796b5b0263d047ef8e6" + }, + "version": "4.5.14" + }, + "org.apache.httpcomponents:httpcore": { + "shasums": { + "jar": "6c9b3dd142a09dc468e23ad39aad6f75a0f2b85125104469f026e52a474e464f", + "sources": "705f8cf3671093b6c1db16bbf6971a7ef400e3819784f1af53e5bc3e67b5a9a0" + }, + "version": "4.4.16" + }, + "org.apache.james:apache-mime4j-core": { + "shasums": { + "jar": "aeb313efda27ae902964a3fcdbd235f91ea13c7a5b193dd752f626b073da230f", + "sources": "d45fe485b0cb5819a2e8e860c3fc7508beb5cd3b42a9190f2493798cbe2fc8ab" + }, + "version": "0.8.1" + }, + "org.apache.james:apache-mime4j-dom": { + "shasums": { + "jar": "25547c2ebd438669f3b60ad04e5befb123994a685453ada66fb61dcc9d396800", + "sources": "df2d902a9229a54648ab8ed4372ee142cc19a6731c928be70afb017a30399b86" + }, + "version": "0.8.1" + }, + "org.apache.lucene:lucene-analysis-common": { + "shasums": { + "jar": "8e768c9b2a3870f1fc2655181516699e719a56b9aaf8664226a11ae7d90cb4e9", + "sources": "c14727f25cc1a6c73d90720531309672300ca473f2d1f52e74281bfef4299c63" + }, + "version": "10.4.0" + }, + "org.apache.lucene:lucene-backward-codecs": { + "shasums": { + "jar": "4e77973982b8e24e4357b18e75f54cffe74b1ab7a354b1b81e1d23d6265de493", + "sources": "909b951a578828c40120eb7c12a12a02677b9974b88d47993517eb82089ec059" + }, + "version": "10.4.0" + }, + "org.apache.lucene:lucene-core": { + "shasums": { + "jar": "8f894d211a8123938ccb9ff6827d136747e0eb6b1782ada6ac9086aa911b52e2", + "sources": "2411eab5a52ef845327fae889a7ed14f2d75d0c765bedb30156fe95195e05e16" + }, + "version": "10.4.0" + }, + "org.apache.lucene:lucene-misc": { + "shasums": { + "jar": "399f56e1bc2e08d927505139f9f459f1e33059e177eace080ea2bbcd88505fd5", + "sources": "a7a398e89dd21881c3bfa44c5a517ee93678b6260dc40185dd7e65afddaa5ade" + }, + "version": "10.4.0" + }, + "org.apache.lucene:lucene-queryparser": { + "shasums": { + "jar": "4635f2a14e9c01574c4cf9ad60e018ab2b041b7889369107a991ef950648c847", + "sources": "70615c5d3f3e1a610716176c599fc79e0687a7879ede2c117ce22efde92ec9b0" + }, + "version": "10.4.0" + }, + "org.apache.mina:mina-core": { + "shasums": { + "jar": "39b2dfc8e84380bf7adab657d3d5e1625cb6592a885ebdb854ec5c6f7a3ec88d", + "sources": "6c7823b8ed5a8d3511b8fb7ba6166ab825a45784741cd15c9991a75e54ad0dba" + }, + "version": "2.2.4" + }, + "org.apache.sshd:sshd-mina": { + "shasums": { + "jar": "9ac956852adadcc5857e702bbc3accd0b4f22dc95b4d32a3c3e7d842478cabe6", + "sources": "763d0820dfc362c8ae6cfa5b10a49b349d855f60e5f792dad0a0497c13dc2f75" + }, + "version": "2.17.1" + }, + "org.apache.sshd:sshd-osgi": { + "shasums": { + "jar": "77c7c19d86dd59c63cedc9fea79708df264ea779c30076ce5dcfc51dc941a78b", + "sources": "b51cb4bc79d4b63b5ba1f72bb8e71b24f85597f1d1a8de4f49b5adb452d3a6cb" + }, + "version": "2.17.1" + }, + "org.apache.sshd:sshd-sftp": { + "shasums": { + "jar": "84727e3ac45458e1efde1058269964e2526aa3d520584870ba007adb20534b3c", + "sources": "6282c3628c62cfcec6578611b0c106f206fc418385b06b899cb8fb79aece5200" + }, + "version": "2.17.1" + }, + "org.asciidoctor:asciidoctorj": { + "shasums": { + "jar": "599fd7281344ee8085caac3b8e99dc1ea1d8443918dc10f5dc0790db6a7cff7d", + "sources": "4ab024122aa75097053965908eeb675c2c9a00c63032072e9e5f3164d7d7ee4a" + }, + "version": "1.5.8.1" + }, + "org.assertj:assertj-core": { + "shasums": { + "jar": "c4a445426c3c2861666863b842cc4ec7bbb1c4226fefd370b6d2fe83d6c4ff0f", + "sources": "5ba6de05730cf76021001f8437f35db4cb5b513465d4ace8c3a6fcd68d9a19ee" + }, + "version": "3.27.7" + }, + "org.bouncycastle:bcpg-jdk18on": { + "shasums": { + "jar": "4077fd4517761c98a81944c70a376ce73f4eb3e44c03db1eb5d699fc28ab48aa", + "sources": "fd5057e4ae59e8ac68917e6bf865ad92c335ec9e8b0fd973e0bb82f7d8e9d79b" + }, + "version": "1.83" + }, + "org.bouncycastle:bcpkix-jdk18on": { + "shasums": { + "jar": "d3c4c6b700c74ef8164bb15e549d939721b8f14fc0ff89fe19b220243bcfcbd8", + "sources": "f1f0f0565a5d84f0bdde5e812d260a9abacf9be2f57c49e461bfe9ff58340bb9" + }, + "version": "1.83" + }, + "org.bouncycastle:bcprov-jdk18on": { + "shasums": { + "jar": "82cf3a2af766c3bc874f6d36b9f20a8b99a8f09762dc776e8a227a45d8daaafb", + "sources": "0c335c8d599b92e264f4591087ca104615de0339bbba46ecc3a51af032de0b16" + }, + "version": "1.83" + }, + "org.bouncycastle:bcutil-jdk18on": { + "shasums": { + "jar": "ee7d0eb4e74de70a735f7fb36b604dd5c6ad35720d50b914604db042114a0185", + "sources": "935984a8b1d1893dd033a9f237b85898bcfa6ae7890a214f10bade935cd3536f" + }, + "version": "1.83" + }, + "org.checkerframework:checker-compat-qual": { + "shasums": { + "jar": "d76b9afea61c7c082908023f0cbc1427fab9abd2df915c8b8a3e7a509bccbc6d", + "sources": "68011773fd60cfc7772508134086787210ba2a1443e3f9c3f5d4233a226c3346" + }, + "version": "2.5.3" + }, + "org.checkerframework:checker-qual": { + "shasums": { + "jar": "a4dc882ca6aac496d33381e5e5eb0604c45483b004bc3eac9368f1bb17cb2512", + "sources": "5f93b80bad8b625f33a240079413e8690667568d6f099c4dbb9f4f50b8e11fad" + }, + "version": "3.10.0" + }, + "org.commonmark:commonmark": { + "shasums": { + "jar": "679338e0b7fc15c02d275d598654b01a149893bc28a87992e90123c8d06af25b", + "sources": "6380700a0c031e8ec2e3d1a582f9028037eb775ad543485f99cb1c29341936eb" + }, + "version": "0.24.0" + }, + "org.commonmark:commonmark-ext-autolink": { + "shasums": { + "jar": "013ba4f3ba4850a1de35935d1501587c518f764331d279805da33473e79f5f33", + "sources": "37709c64e0d9b84cb80fb9795cb74a4bf018cdb9c1f31f343e10e96dd0db1d1f" + }, + "version": "0.24.0" + }, + "org.commonmark:commonmark-ext-gfm-strikethrough": { + "shasums": { + "jar": "7385cb637f04dc4cbda4ddca9c2fcd2af7ac536a50e4c8d2c77f4748bb14bf41", + "sources": "f9f1d0627ccd9994b3aa5c1cdbb247a3383983fd36b56753d1abe3994a3d7a5f" + }, + "version": "0.24.0" + }, + "org.commonmark:commonmark-ext-gfm-tables": { + "shasums": { + "jar": "b54dc332f931e6d07c2766144c087b08f3693677e368151a67020b4e95bb4b99", + "sources": "15525448cb137992c00ece73d320f45187a187bc906ba967ccc958f42e92c3b9" + }, + "version": "0.24.0" + }, + "org.eclipse.jetty.ee8:jetty-ee8-nested": { + "shasums": { + "jar": "0bcc1d92d6dc482bac55979b6c9d7756f8b061295fc41cf2713354f9bd464c38", + "sources": "82f199d42bbfaecdf1574347191f9177af11d4ea692760cd0a84d0b8c89b4a53" + }, + "version": "12.1.5" + }, + "org.eclipse.jetty.ee8:jetty-ee8-security": { + "shasums": { + "jar": "789ba4518cd2848563473d636270b42c541d34cf79bed36c84e3ce811f76c2cd", + "sources": "f232e995398de354b6d4c8fe67b60a708bae0b0cccfe933787ed8966886e3139" + }, + "version": "12.1.5" + }, + "org.eclipse.jetty.ee8:jetty-ee8-servlet": { + "shasums": { + "jar": "3c27e08483b1f2860e727c7577e094e63af595159dabe28022dd4e10e37f12b0", + "sources": "4a2e1e23c428c562bc456cc62e8bb0848f571c7505460b56927d51240e899474" + }, + "version": "12.1.5" + }, + "org.eclipse.jetty.toolchain:jetty-servlet-api": { + "shasums": { + "jar": "d90bf1f8a9d2ba89f4510bb51e1516dcf94ef6dc034e00f233654abdd78f2210", + "sources": "4184a1bf70ea545bceb0a5aef1061e1f9986d9fe8f22a1cc77cf22931fcb9029" + }, + "version": "4.0.6" + }, + "org.eclipse.jetty:jetty-http": { + "shasums": { + "jar": "02c6514977f0051dfdecf8d0799acf7a88fd8008a5fd9320a92f2e5db45d297b", + "sources": "1851f55b408241a6ae692730dd9bda9d1ecf7f0be6a9ccc471affa5bf8d07b9c" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-io": { + "shasums": { + "jar": "f6246a2cf0abcee7f0971217c0ce4cd30d8ce15a91530363457113907ab38690", + "sources": "f21960b5fe18c1fa4281aa0ba1ce8f2f7d4d8f00e64e08d536b6d6577ca92489" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-jmx": { + "shasums": { + "jar": "24b24e205b7f1a7812c781e95ac6154b044e09abf0d13432777fc0e7fbd2f4ac", + "sources": "5627d14d6d36d68f14a2765adfafe18852099e19821fe73a68adb93ed8819eb5" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-security": { + "shasums": { + "jar": "af923d4f395a73bf8ddcb754f42d7617c6b7055e37e5a6b625ed894f73107ae9", + "sources": "0f8718fe938f0c8a5c3098129b67d13381c1fef0d56765627ccc2e017fc2654b" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-server": { + "shasums": { + "jar": "ba957ae07da647023cfa52c923732aea1c67f5273a594cee1863365dfebb9a02", + "sources": "089098dce0a947401a52bd00056427e1a2e348a71340a00e496c3139c14bafcb" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-servlet": { + "shasums": { + "jar": "c5e9517974dec9e4606b2d810f4995ea81091b1e24bd9640cb45d8b2aefd722c", + "sources": "d3214106ebbfa9034aa041ea26caa080a99c494ae6275f88c95bc63796bc0367" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-session": { + "shasums": { + "jar": "ebe4f30c6fe7656294d884e1dc8eea4c77b6c861e3c010847a76fd78164ae166", + "sources": "629906b99619eee119e44ff3c786c89ef20350958e16546fd62fb4ec7f08c6d9" + }, + "version": "12.1.5" + }, + "org.eclipse.jetty:jetty-util": { + "shasums": { + "jar": "6ccbf678716778e316cc097d8aada4fe2a2e16c0bbfd8a1763204d6724b423f4", + "sources": "77d5935c637276d08da2e1141a7fe4d9db4a2d072b6b418b625b261009d0cb4c" + }, + "version": "9.4.57.v20241219" + }, + "org.eclipse.jetty:jetty-util-ajax": { + "shasums": { + "jar": "67af50dd7714803b1bcdfabac181dd8b279d0ba6ba7fd27ea80c5b2099016542", + "sources": "19888b386e9d4e81c81ce94465e8263680e1abcd83f324ba1992d7ded099abad" + }, + "version": "9.4.57.v20241219" + }, + "org.hamcrest:hamcrest": { + "shasums": { + "jar": "5d66b6a4a680755cb6ed7cb104fa7835ef644667586ff0737adeb977c39ecdbc", + "sources": "7a4050b1898f7e1aa395cf2be78fb6683f9e2766fcb8e1507926b204fa24d1bf" + }, + "version": "3.0" + }, + "org.hamcrest:hamcrest-core": { + "shasums": { + "jar": "66fdef91e9739348df7a096aa384a5685f4e875584cce89386a7a47251c4d8e9", + "sources": "e223d2d8fbafd66057a8848cc94222d63c3cedd652cc48eddc0ab5c39c0f84df" + }, + "version": "1.3" + }, + "org.jruby:jruby-complete": { + "shasums": { + "jar": "bdf1a7ca08161d42262b3dad5888ba6fecaf9a2da7ff9170489224f3525cb457", + "sources": "72de5a81b60da3db86753ac6c62a5297a6f47665df93c6480633222d6fc13ff5" + }, + "version": "9.1.17.0" + }, + "org.jsoup:jsoup": { + "shasums": { + "jar": "92af19ec57cc77637db4490f0f5011f0444d353209ce36083bac428f9b81a39c", + "sources": "8a8120654188ccb1a10ca7b84d2be13340cb7500743b257896193c192a2ed8e3" + }, + "version": "1.14.3" + }, + "org.jspecify:jspecify": { + "shasums": { + "jar": "1fad6e6be7557781e4d33729d49ae1cdc8fdda6fe477bb0cc68ce351eafdfbab", + "sources": "adf0898191d55937fb3192ba971826f4f294292c4a960740f3c27310e7b70296" + }, + "version": "1.0.0" + }, + "org.mockito:mockito-core": { + "shasums": { + "jar": "03db23de742cbca42aa3d6127fdace560fac37b036d931870801f84c288bd286", + "sources": "d1b3d1cfe46502804a5e73fe21f6bf385697c317efedd6b582431ef1dc7068f7" + }, + "version": "5.21.0" + }, + "org.nibor.autolink:autolink": { + "shasums": { + "jar": "39c6588948ab31b98ab1fea4a6abab37243f387cb48cb50ae599410effb70038", + "sources": "08db5e7510f752de55fd91c05e7a3199f6789633dc423ce313bd3f71eebdf9cf" + }, + "version": "0.11.0" + }, + "org.objenesis:objenesis": { + "shasums": { + "jar": "95488102feaf2e2858adf6b299353677dac6c15294006f8ed1c5556f8e3cd251", + "sources": "896fa899a262c2f0f7e661848025fad22349300a5247ac175510993a9a5eede9" + }, + "version": "3.4" + }, + "org.openid4java:openid4java": { + "shasums": { + "jar": "477702693a55ac4070a38171e7d52f1bb86de97b040eaff59f7ee3edf42edb2a", + "sources": "49c25c9fcbbaf42f5b9eee2bb884d33a7e89a5039281cc057da2eb63dc02c5a6" + }, + "version": "1.0.0" + }, + "org.openjdk.jmh:jmh-core": { + "shasums": { + "jar": "dc0eaf2bbf0036a70b60798c785d6e03a9daf06b68b8edb0f1ba9eb3421baeb3", + "sources": "fd4beda07b3b94cd0e32199401bbb2d9ed3371a770c8c320761b9442ff3e8e05" + }, + "version": "1.37" + }, + "org.openjdk.jmh:jmh-generator-annprocess": { + "shasums": { + "jar": "6a5604b5b804e0daca1145df1077609321687734a8b49387e49f10557c186c77", + "sources": "cc1b661fb209ae1a433e331e8e78bab680674153b0a6ac69d47d11c60fb5e47e" + }, + "version": "1.37" + }, + "org.ow2.asm:asm": { + "shasums": { + "jar": "6f3828a215c920059a5efa2fb55c233d6c54ec5cadca99ce1b1bdd10077c7ddd", + "sources": "057e39aa1800b25bc8944846a376509990f49b7fe1e07192b1d6e48e1a780eb2" + }, + "version": "9.9.1" + }, + "org.ow2.asm:asm-analysis": { + "shasums": { + "jar": "6260bffc8ec008dd1b713702c7994e2c94d188a3da5bef9e87278a16df6a7522", + "sources": "ff731d401ea2407759ea19b4b025800d32495a51a912f2553d987cddda424773" + }, + "version": "9.9.1" + }, + "org.ow2.asm:asm-commons": { + "shasums": { + "jar": "c2319e014ce7199f2b7f7d56d6bb991863168c3f4b6cd6c9f542a4937ef7ef88", + "sources": "196e1b24b51f35fe9b09c930e159830dcded8b113ab2b7394d8ac353752d8a00" + }, + "version": "9.9.1" + }, + "org.ow2.asm:asm-tree": { + "shasums": { + "jar": "0f3555096b720b820bbacab0b515589bee0200bee099bda14c561738ae837ba1", + "sources": "9d1fe261fa1d29904ca9dbc76878396e76bc225191676a8c16ad2669a205321a" + }, + "version": "9.9.1" + }, + "org.ow2.asm:asm-util": { + "shasums": { + "jar": "c5ebbbeaf68126af094b42fa4800f59bc4413abd02d95b9aefad722cd257e207", + "sources": "dd19b2285e6dad31a3b1c8bc2e55d8248d83eef0f7ba28372db288444705ae86" + }, + "version": "9.9.1" + }, + "org.roaringbitmap:RoaringBitmap": { + "shasums": { + "jar": "9c5ffbbed50cacb3bcff67aaa0e51f8d2ebb83157bc3932d2ae0cac9844a5afa", + "sources": "b19a73d8373991216f5a2adf44252afe1e519f6f93e7658a95bc60f4fbcaeb82" + }, + "version": "0.9.44" + }, + "org.roaringbitmap:shims": { + "shasums": { + "jar": "a9242e7144043cd50901e5473b4176f4ff04a40397599acf783959effb06d19e", + "sources": "39bb5834aec16cfc04b7967b388bc99d719a25a8830714e9f5487da36d428966" + }, + "version": "0.9.44" + }, + "org.slf4j:jcl-over-slf4j": { + "shasums": { + "jar": "affd06771589ebfe454bb11315a4f466ecaa135b95f3e7939534cf1d2fd7064c", + "sources": "197984bfbccbf5c9201b8152c98d0d8f77253fb4ef1c59a57cf3e28d5ecf119c" + }, + "version": "2.0.17" + }, + "org.slf4j:slf4j-api": { + "shasums": { + "jar": "7b751d952061954d5abfed7181c1f645d336091b679891591d63329c622eb832", + "sources": "db0d08d8efa05ad19d174d747bd9d8e68dbb02b596812fe7b3a681656e346694" + }, + "version": "2.0.17" + }, + "org.slf4j:slf4j-ext": { + "shasums": { + "jar": "e2e4fc169a0b65df4f12f7c577efd4f47619914eb8c5571418b65769d6ff9cb4", + "sources": "828b2e58bf64bcaae4ee98c84372a27e1307b35e0903a972b266f33c551d2479" + }, + "version": "2.0.17" + }, + "org.slf4j:slf4j-reload4j": { + "shasums": { + "jar": "62dfd069563dbce91d276c9a38d557da49df7be15b03bb898b63b5390c6bb889", + "sources": "58faf8a8a2892fae5b699277c20c38291da093444a0618b2ba08aca016e6d6c5" + }, + "version": "2.0.17" + }, + "org.slf4j:slf4j-simple": { + "shasums": { + "jar": "ddfea59ac074c6d3e24ac2c38622d2d963895e17f70b38ed4bdae4d780be6964", + "sources": "30b660e79419bfcebd678e75bdfe3644eaf325f50253a68395d93634da5953df" + }, + "version": "2.0.17" + }, + "org.tukaani:xz": { + "shasums": { + "jar": "0a4077f6aeae2865532a564807af8d30c26acc6f63b7928d93bd7ab1f2190449", + "sources": "aab470d8c28e718859f9c1f333ed693f926cd44d8e55851c0dda27dd4a6d568c" + }, + "version": "1.11" + }, + "xerces:xercesImpl": { + "shasums": { + "jar": "6fc991829af1708d15aea50c66f0beadcd2cfeb6968e0b2f55c1b0909883fe16", + "sources": "3c531edfc074e3e0885e5d4a777a9e7317e108028be50ef6e893a5a9cf3e12c2" + }, + "version": "2.12.2" + } + }, + "dependencies": { + "com.github.ben-manes.caffeine:caffeine": [ + "com.google.errorprone:error_prone_annotations", + "org.checkerframework:checker-qual" + ], + "com.github.ben-manes.caffeine:guava": [ + "com.github.ben-manes.caffeine:caffeine", + "com.google.guava:guava" + ], + "com.github.rholder:guava-retrying": [ + "com.google.code.findbugs:jsr305", + "com.google.guava:guava" + ], + "com.google.auto.factory:auto-factory": [ + "com.google.auto.service:auto-service-annotations", + "com.google.auto.value:auto-value-annotations", + "com.google.auto:auto-common", + "com.google.guava:guava", + "com.squareup:javapoet", + "javax.inject:javax.inject" + ], + "com.google.auto:auto-common": [ + "com.google.guava:guava" + ], + "com.google.code.gson:gson": [ + "com.google.errorprone:error_prone_annotations" + ], + "com.google.common.html.types:types": [ + "com.google.code.findbugs:jsr305", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "com.google.jsinterop:jsinterop-annotations", + "com.google.protobuf:protobuf-java" + ], + "com.google.flogger:flogger": [ + "org.checkerframework:checker-compat-qual" + ], + "com.google.flogger:flogger-log4j-backend": [ + "com.google.flogger:flogger", + "com.google.flogger:flogger-system-backend" + ], + "com.google.flogger:flogger-system-backend": [ + "com.google.flogger:flogger", + "org.checkerframework:checker-compat-qual" + ], + "com.google.flogger:google-extensions": [ + "com.google.flogger:flogger", + "com.google.flogger:flogger-system-backend" + ], + "com.google.guava:guava": [ + "com.google.errorprone:error_prone_annotations", + "com.google.guava:failureaccess", + "com.google.guava:listenablefuture", + "com.google.j2objc:j2objc-annotations", + "org.jspecify:jspecify" + ], + "com.google.guava:guava-testlib": [ + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "com.google.j2objc:j2objc-annotations", + "junit:junit", + "org.jspecify:jspecify" + ], + "com.google.inject.extensions:guice-assistedinject": [ + "com.google.errorprone:error_prone_annotations", + "com.google.inject:guice" + ], + "com.google.inject.extensions:guice-servlet": [ + "com.google.inject:guice" + ], + "com.google.inject:guice": [ + "aopalliance:aopalliance", + "com.google.guava:guava", + "jakarta.inject:jakarta.inject-api", + "javax.inject:javax.inject" + ], + "com.google.jimfs:jimfs": [ + "com.google.guava:guava" + ], + "com.google.template:soy": [ + "args4j:args4j", + "com.google.code.findbugs:jsr305", + "com.google.code.gson:gson", + "com.google.common.html.types:types", + "com.google.errorprone:error_prone_annotations", + "com.google.flogger:flogger", + "com.google.flogger:flogger-system-backend", + "com.google.flogger:google-extensions", + "com.google.guava:guava", + "com.google.inject:guice", + "com.google.protobuf:protobuf-java", + "com.ibm.icu:icu4j", + "javax.inject:javax.inject", + "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-util" + ], + "com.google.truth.extensions:truth-java8-extension": [ + "com.google.truth:truth" + ], + "com.google.truth.extensions:truth-liteproto-extension": [ + "com.google.auto.value:auto-value-annotations", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "com.google.truth:truth", + "org.jspecify:jspecify" + ], + "com.google.truth.extensions:truth-proto-extension": [ + "com.google.auto.value:auto-value-annotations", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "com.google.protobuf:protobuf-java", + "com.google.truth.extensions:truth-liteproto-extension", + "com.google.truth:truth", + "org.jspecify:jspecify" + ], + "com.google.truth:truth": [ + "com.google.auto.value:auto-value-annotations", + "com.google.errorprone:error_prone_annotations", + "com.google.guava:guava", + "junit:junit", + "org.jspecify:jspecify", + "org.ow2.asm:asm" + ], + "com.icegreen:greenmail": [ + "com.sun.mail:javax.mail", + "junit:junit", + "org.slf4j:slf4j-api" + ], + "com.ryanharter.auto.value:auto-value-gson-extension": [ + "com.google.auto.value:auto-value", + "com.google.auto.value:auto-value-annotations", + "com.google.code.gson:gson", + "com.ryanharter.auto.value:auto-value-gson-runtime", + "com.squareup:javapoet" + ], + "com.ryanharter.auto.value:auto-value-gson-factory": [ + "com.google.auto.value:auto-value-annotations", + "com.google.code.gson:gson", + "com.ryanharter.auto.value:auto-value-gson-extension", + "com.squareup:javapoet" + ], + "com.ryanharter.auto.value:auto-value-gson-runtime": [ + "com.google.code.gson:gson", + "io.sweers.autotransient:autotransient" + ], + "com.sun.mail:javax.mail": [ + "javax.activation:activation" + ], + "commons-dbcp:commons-dbcp": [ + "commons-pool:commons-pool" + ], + "eu.medsea.mimeutil:mime-util": [ + "org.slf4j:slf4j-api" + ], + "io.dropwizard.metrics:metrics-core": [ + "org.slf4j:slf4j-api" + ], + "junit:junit": [ + "org.hamcrest:hamcrest-core" + ], + "net.java.dev.jna:jna-platform": [ + "net.java.dev.jna:jna" + ], + "net.sourceforge.nekohtml:nekohtml": [ + "xerces:xercesImpl" + ], + "org.antlr:ST4": [ + "org.antlr:antlr-runtime" + ], + "org.antlr:antlr": [ + "org.antlr:ST4", + "org.antlr:antlr-runtime" + ], + "org.antlr:stringtemplate": [ + "org.antlr:antlr-runtime" + ], + "org.apache.commons:commons-compress": [ + "commons-codec:commons-codec", + "commons-io:commons-io", + "org.apache.commons:commons-lang3" + ], + "org.apache.commons:commons-text": [ + "org.apache.commons:commons-lang3" + ], + "org.apache.httpcomponents:fluent-hc": [ + "org.apache.httpcomponents:httpclient" + ], + "org.apache.httpcomponents:httpclient": [ + "commons-codec:commons-codec", + "org.apache.httpcomponents:httpcore" + ], + "org.apache.james:apache-mime4j-dom": [ + "org.apache.james:apache-mime4j-core" + ], + "org.apache.lucene:lucene-analysis-common": [ + "org.apache.lucene:lucene-core" + ], + "org.apache.lucene:lucene-backward-codecs": [ + "org.apache.lucene:lucene-core" + ], + "org.apache.lucene:lucene-misc": [ + "org.apache.lucene:lucene-core" + ], + "org.apache.lucene:lucene-queryparser": [ + "org.apache.lucene:lucene-core" + ], + "org.apache.mina:mina-core": [ + "org.slf4j:slf4j-api" + ], + "org.apache.sshd:sshd-mina": [ + "org.apache.mina:mina-core", + "org.slf4j:jcl-over-slf4j", + "org.slf4j:slf4j-api" + ], + "org.apache.sshd:sshd-osgi": [ + "org.slf4j:jcl-over-slf4j", + "org.slf4j:slf4j-api" + ], + "org.apache.sshd:sshd-sftp": [ + "org.slf4j:jcl-over-slf4j", + "org.slf4j:slf4j-api" + ], + "org.asciidoctor:asciidoctorj": [ + "com.beust:jcommander", + "org.jruby:jruby-complete" + ], + "org.assertj:assertj-core": [ + "net.bytebuddy:byte-buddy" + ], + "org.bouncycastle:bcpg-jdk18on": [ + "org.bouncycastle:bcprov-jdk18on", + "org.bouncycastle:bcutil-jdk18on" + ], + "org.bouncycastle:bcpkix-jdk18on": [ + "org.bouncycastle:bcutil-jdk18on" + ], + "org.bouncycastle:bcutil-jdk18on": [ + "org.bouncycastle:bcprov-jdk18on" + ], + "org.commonmark:commonmark-ext-autolink": [ + "org.commonmark:commonmark", + "org.nibor.autolink:autolink" + ], + "org.commonmark:commonmark-ext-gfm-strikethrough": [ + "org.commonmark:commonmark" + ], + "org.commonmark:commonmark-ext-gfm-tables": [ + "org.commonmark:commonmark" + ], + "org.eclipse.jetty.ee8:jetty-ee8-nested": [ + "org.eclipse.jetty.toolchain:jetty-servlet-api", + "org.eclipse.jetty:jetty-http", + "org.eclipse.jetty:jetty-security", + "org.eclipse.jetty:jetty-server", + "org.eclipse.jetty:jetty-session", + "org.slf4j:slf4j-api" + ], + "org.eclipse.jetty.ee8:jetty-ee8-security": [ + "org.eclipse.jetty.ee8:jetty-ee8-nested", + "org.slf4j:slf4j-api" + ], + "org.eclipse.jetty.ee8:jetty-ee8-servlet": [ + "org.eclipse.jetty.ee8:jetty-ee8-nested", + "org.eclipse.jetty.ee8:jetty-ee8-security", + "org.slf4j:slf4j-api" + ], + "org.eclipse.jetty:jetty-http": [ + "org.eclipse.jetty:jetty-io", + "org.eclipse.jetty:jetty-util" + ], + "org.eclipse.jetty:jetty-io": [ + "org.eclipse.jetty:jetty-util" + ], + "org.eclipse.jetty:jetty-jmx": [ + "org.eclipse.jetty:jetty-util" + ], + "org.eclipse.jetty:jetty-security": [ + "org.eclipse.jetty:jetty-server" + ], + "org.eclipse.jetty:jetty-server": [ + "javax.servlet:javax.servlet-api", + "org.eclipse.jetty:jetty-http", + "org.eclipse.jetty:jetty-io" + ], + "org.eclipse.jetty:jetty-servlet": [ + "org.eclipse.jetty:jetty-security", + "org.eclipse.jetty:jetty-util-ajax" + ], + "org.eclipse.jetty:jetty-session": [ + "org.eclipse.jetty:jetty-server", + "org.slf4j:slf4j-api" + ], + "org.eclipse.jetty:jetty-util-ajax": [ + "org.eclipse.jetty:jetty-util" + ], + "org.mockito:mockito-core": [ + "net.bytebuddy:byte-buddy", + "net.bytebuddy:byte-buddy-agent", + "org.objenesis:objenesis" + ], + "org.openid4java:openid4java": [ + "com.google.inject:guice", + "net.sourceforge.nekohtml:nekohtml", + "org.apache.httpcomponents:httpclient", + "xerces:xercesImpl" + ], + "org.openjdk.jmh:jmh-core": [ + "net.sf.jopt-simple:jopt-simple", + "org.apache.commons:commons-math3" + ], + "org.openjdk.jmh:jmh-generator-annprocess": [ + "org.openjdk.jmh:jmh-core" + ], + "org.ow2.asm:asm-analysis": [ + "org.ow2.asm:asm-tree" + ], + "org.ow2.asm:asm-commons": [ + "org.ow2.asm:asm", + "org.ow2.asm:asm-tree" + ], + "org.ow2.asm:asm-tree": [ + "org.ow2.asm:asm" + ], + "org.ow2.asm:asm-util": [ + "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-tree" + ], + "org.roaringbitmap:RoaringBitmap": [ + "org.roaringbitmap:shims" + ], + "org.slf4j:jcl-over-slf4j": [ + "org.slf4j:slf4j-api" + ], + "org.slf4j:slf4j-ext": [ + "org.slf4j:slf4j-api" + ], + "org.slf4j:slf4j-reload4j": [ + "ch.qos.reload4j:reload4j", + "org.slf4j:slf4j-api" + ], + "org.slf4j:slf4j-simple": [ + "org.slf4j:slf4j-api" + ] + }, + "packages": { + "antlr:antlr": [ + "antlr", + "antlr.ASdebug", + "antlr.actions.cpp", + "antlr.actions.csharp", + "antlr.actions.java", + "antlr.actions.python", + "antlr.build", + "antlr.collections", + "antlr.collections.impl", + "antlr.debug", + "antlr.debug.misc", + "antlr.preprocessor" + ], + "aopalliance:aopalliance": [ + "org.aopalliance.aop", + "org.aopalliance.intercept" + ], + "args4j:args4j": [ + "org.kohsuke.args4j", + "org.kohsuke.args4j.spi" + ], + "ch.qos.reload4j:reload4j": [ + "org.apache.log4j", + "org.apache.log4j.chainsaw", + "org.apache.log4j.config", + "org.apache.log4j.helpers", + "org.apache.log4j.jdbc", + "org.apache.log4j.net", + "org.apache.log4j.or", + "org.apache.log4j.or.jms", + "org.apache.log4j.or.sax", + "org.apache.log4j.pattern", + "org.apache.log4j.rewrite", + "org.apache.log4j.spi", + "org.apache.log4j.varia", + "org.apache.log4j.xml" + ], + "com.beust:jcommander": [ + "com.beust.jcommander", + "com.beust.jcommander.converters", + "com.beust.jcommander.defaultprovider", + "com.beust.jcommander.internal", + "com.beust.jcommander.validators" + ], + "com.github.ben-manes.caffeine:caffeine": [ + "com.github.benmanes.caffeine", + "com.github.benmanes.caffeine.base", + "com.github.benmanes.caffeine.cache", + "com.github.benmanes.caffeine.cache.stats" + ], + "com.github.ben-manes.caffeine:guava": [ + "com.github.benmanes.caffeine.guava" + ], + "com.github.rholder:guava-retrying": [ + "com.github.rholder.retry" + ], + "com.google.auto.factory:auto-factory": [ + "com.google.auto.factory", + "com.google.auto.factory.processor" + ], + "com.google.auto.service:auto-service-annotations": [ + "com.google.auto.service" + ], + "com.google.auto.value:auto-value": [ + "autovalue.shaded.com.google.auto.common", + "autovalue.shaded.com.google.auto.service", + "autovalue.shaded.com.google.common.annotations", + "autovalue.shaded.com.google.common.base", + "autovalue.shaded.com.google.common.cache", + "autovalue.shaded.com.google.common.collect", + "autovalue.shaded.com.google.common.escape", + "autovalue.shaded.com.google.common.eventbus", + "autovalue.shaded.com.google.common.graph", + "autovalue.shaded.com.google.common.hash", + "autovalue.shaded.com.google.common.html", + "autovalue.shaded.com.google.common.io", + "autovalue.shaded.com.google.common.math", + "autovalue.shaded.com.google.common.net", + "autovalue.shaded.com.google.common.primitives", + "autovalue.shaded.com.google.common.reflect", + "autovalue.shaded.com.google.common.util.concurrent", + "autovalue.shaded.com.google.common.xml", + "autovalue.shaded.com.google.errorprone.annotations", + "autovalue.shaded.com.google.errorprone.annotations.concurrent", + "autovalue.shaded.com.google.escapevelocity", + "autovalue.shaded.com.google.j2objc.annotations", + "autovalue.shaded.com.squareup.javapoet", + "autovalue.shaded.net.ltgt.gradle.incap", + "autovalue.shaded.org.checkerframework.checker.nullness.qual", + "autovalue.shaded.org.checkerframework.framework.qual", + "autovalue.shaded.org.objectweb.asm", + "com.google.auto.value.extension", + "com.google.auto.value.extension.memoized.processor", + "com.google.auto.value.extension.serializable.processor", + "com.google.auto.value.extension.serializable.serializer", + "com.google.auto.value.extension.serializable.serializer.impl", + "com.google.auto.value.extension.serializable.serializer.interfaces", + "com.google.auto.value.extension.serializable.serializer.runtime", + "com.google.auto.value.extension.toprettystring.processor", + "com.google.auto.value.processor" + ], + "com.google.auto.value:auto-value-annotations": [ + "com.google.auto.value", + "com.google.auto.value.extension.memoized", + "com.google.auto.value.extension.serializable", + "com.google.auto.value.extension.toprettystring" + ], + "com.google.auto:auto-common": [ + "com.google.auto.common" + ], + "com.google.code.findbugs:jsr305": [ + "javax.annotation", + "javax.annotation.concurrent", + "javax.annotation.meta" + ], + "com.google.code.gson:gson": [ + "com.google.gson", + "com.google.gson.annotations", + "com.google.gson.internal", + "com.google.gson.internal.bind", + "com.google.gson.internal.bind.util", + "com.google.gson.internal.reflect", + "com.google.gson.internal.sql", + "com.google.gson.reflect", + "com.google.gson.stream" + ], + "com.google.common.html.types:types": [ + "com.google.common.html.types", + "com.google.common.html.types.testing", + "com.google.common.html.types.testing.assertions" + ], + "com.google.errorprone:error_prone_annotations": [ + "com.google.errorprone.annotations", + "com.google.errorprone.annotations.concurrent" + ], + "com.google.flogger:flogger": [ + "com.google.common.flogger", + "com.google.common.flogger.backend", + "com.google.common.flogger.context", + "com.google.common.flogger.parameter", + "com.google.common.flogger.parser", + "com.google.common.flogger.util" + ], + "com.google.flogger:flogger-log4j-backend": [ + "com.google.common.flogger.backend.log4j" + ], + "com.google.flogger:flogger-system-backend": [ + "com.google.common.flogger.backend.system" + ], + "com.google.flogger:google-extensions": [ + "com.google.common.flogger" + ], + "com.google.gitiles:blame-cache": [ + "com.google.gitiles.blame.cache" + ], + "com.google.gitiles:gitiles-servlet": [ + "com.google.gitiles", + "com.google.gitiles.blame", + "com.google.gitiles.doc", + "com.google.gitiles.doc.html" + ], + "com.google.guava:failureaccess": [ + "com.google.common.util.concurrent.internal" + ], + "com.google.guava:guava": [ + "com.google.common.annotations", + "com.google.common.base", + "com.google.common.base.internal", + "com.google.common.cache", + "com.google.common.collect", + "com.google.common.escape", + "com.google.common.eventbus", + "com.google.common.graph", + "com.google.common.hash", + "com.google.common.html", + "com.google.common.io", + "com.google.common.math", + "com.google.common.net", + "com.google.common.primitives", + "com.google.common.reflect", + "com.google.common.util.concurrent", + "com.google.common.xml", + "com.google.thirdparty.publicsuffix" + ], + "com.google.guava:guava-testlib": [ + "com.google.common.collect.testing", + "com.google.common.collect.testing.features", + "com.google.common.collect.testing.google", + "com.google.common.collect.testing.suites", + "com.google.common.collect.testing.testers", + "com.google.common.escape.testing", + "com.google.common.testing", + "com.google.common.util.concurrent.testing" + ], + "com.google.inject.extensions:guice-assistedinject": [ + "com.google.inject.assistedinject", + "com.google.inject.assistedinject.internal" + ], + "com.google.inject.extensions:guice-servlet": [ + "com.google.inject.servlet" + ], + "com.google.inject:guice": [ + "com.google.inject", + "com.google.inject.binder", + "com.google.inject.internal", + "com.google.inject.internal.aop", + "com.google.inject.internal.util", + "com.google.inject.matcher", + "com.google.inject.multibindings", + "com.google.inject.name", + "com.google.inject.spi", + "com.google.inject.util" + ], + "com.google.j2objc:j2objc-annotations": [ + "com.google.j2objc.annotations" + ], + "com.google.jimfs:jimfs": [ + "com.google.common.jimfs" + ], + "com.google.jsinterop:jsinterop-annotations": [ + "jsinterop.annotations" + ], + "com.google.protobuf:protobuf-java": [ + "com.google.protobuf", + "com.google.protobuf.compiler" + ], + "com.google.template:soy": [ + "com.google.template.soy", + "com.google.template.soy.base", + "com.google.template.soy.base.internal", + "com.google.template.soy.basetree", + "com.google.template.soy.basicdirectives", + "com.google.template.soy.basicfunctions", + "com.google.template.soy.bididirectives", + "com.google.template.soy.bidifunctions", + "com.google.template.soy.conformance", + "com.google.template.soy.coredirectives", + "com.google.template.soy.css", + "com.google.template.soy.data", + "com.google.template.soy.data.internal", + "com.google.template.soy.data.internalutils", + "com.google.template.soy.data.ordainers", + "com.google.template.soy.data.restricted", + "com.google.template.soy.error", + "com.google.template.soy.examples", + "com.google.template.soy.exprtree", + "com.google.template.soy.i18ndirectives", + "com.google.template.soy.incrementaldomsrc", + "com.google.template.soy.internal.base", + "com.google.template.soy.internal.exemptions", + "com.google.template.soy.internal.i18n", + "com.google.template.soy.internal.proto", + "com.google.template.soy.internal.targetexpr", + "com.google.template.soy.internal.util", + "com.google.template.soy.javagencode", + "com.google.template.soy.javagencode.javatypes", + "com.google.template.soy.jbcsrc", + "com.google.template.soy.jbcsrc.api", + "com.google.template.soy.jbcsrc.internal", + "com.google.template.soy.jbcsrc.restricted", + "com.google.template.soy.jbcsrc.runtime", + "com.google.template.soy.jbcsrc.shared", + "com.google.template.soy.jssrc", + "com.google.template.soy.jssrc.dsl", + "com.google.template.soy.jssrc.internal", + "com.google.template.soy.jssrc.restricted", + "com.google.template.soy.logging", + "com.google.template.soy.msgs", + "com.google.template.soy.msgs.internal", + "com.google.template.soy.msgs.restricted", + "com.google.template.soy.parseinfo", + "com.google.template.soy.parsepasses.contextautoesc", + "com.google.template.soy.passes", + "com.google.template.soy.passes.htmlmatcher", + "com.google.template.soy.plugin.internal", + "com.google.template.soy.plugin.java", + "com.google.template.soy.plugin.java.internal", + "com.google.template.soy.plugin.java.restricted", + "com.google.template.soy.plugin.javascript.restricted", + "com.google.template.soy.plugin.python.restricted", + "com.google.template.soy.plugin.restricted", + "com.google.template.soy.pysrc", + "com.google.template.soy.pysrc.internal", + "com.google.template.soy.pysrc.restricted", + "com.google.template.soy.shared", + "com.google.template.soy.shared.internal", + "com.google.template.soy.shared.internal.gencode", + "com.google.template.soy.shared.restricted", + "com.google.template.soy.sharedpasses.opti", + "com.google.template.soy.sharedpasses.render", + "com.google.template.soy.soyparse", + "com.google.template.soy.soytree", + "com.google.template.soy.soytree.defn", + "com.google.template.soy.templatecall", + "com.google.template.soy.tofu", + "com.google.template.soy.tofu.internal", + "com.google.template.soy.treebuilder", + "com.google.template.soy.types", + "com.google.template.soy.types.ast", + "com.google.template.soy.xliffmsgplugin" + ], + "com.google.truth.extensions:truth-liteproto-extension": [ + "com.google.common.truth.extensions.proto" + ], + "com.google.truth.extensions:truth-proto-extension": [ + "com.google.common.truth.extensions.proto" + ], + "com.google.truth:truth": [ + "com.google.common.truth" + ], + "com.googlecode.javaewah:JavaEWAH": [ + "com.googlecode.javaewah", + "com.googlecode.javaewah.datastructure", + "com.googlecode.javaewah.symmetric", + "com.googlecode.javaewah32", + "com.googlecode.javaewah32.symmetric" + ], + "com.googlecode.prolog-cafe:prolog-cafeteria": [ + "com.googlecode.prolog_cafe.builtin", + "com.googlecode.prolog_cafe.repl" + ], + "com.googlecode.prolog-cafe:prolog-compiler": [ + "com.googlecode.prolog_cafe.compiler", + "com.googlecode.prolog_cafe.compiler.am2j", + "com.googlecode.prolog_cafe.compiler.pl2am" + ], + "com.googlecode.prolog-cafe:prolog-io": [ + "com.googlecode.prolog_cafe.builtin" + ], + "com.googlecode.prolog-cafe:prolog-runtime": [ + "com.googlecode.prolog_cafe.builtin", + "com.googlecode.prolog_cafe.exceptions", + "com.googlecode.prolog_cafe.lang" + ], + "com.h2database:h2": [ + "org.h2", + "org.h2.api", + "org.h2.bnf", + "org.h2.bnf.context", + "org.h2.command", + "org.h2.command.ddl", + "org.h2.command.dml", + "org.h2.command.query", + "org.h2.compress", + "org.h2.constraint", + "org.h2.engine", + "org.h2.expression", + "org.h2.expression.aggregate", + "org.h2.expression.analysis", + "org.h2.expression.condition", + "org.h2.expression.function", + "org.h2.expression.function.table", + "org.h2.fulltext", + "org.h2.index", + "org.h2.jdbc", + "org.h2.jdbc.meta", + "org.h2.jdbcx", + "org.h2.jmx", + "org.h2.message", + "org.h2.mode", + "org.h2.mvstore", + "org.h2.mvstore.cache", + "org.h2.mvstore.db", + "org.h2.mvstore.rtree", + "org.h2.mvstore.tx", + "org.h2.mvstore.type", + "org.h2.result", + "org.h2.schema", + "org.h2.security", + "org.h2.security.auth", + "org.h2.security.auth.impl", + "org.h2.server", + "org.h2.server.pg", + "org.h2.server.web", + "org.h2.store", + "org.h2.store.fs", + "org.h2.store.fs.async", + "org.h2.store.fs.disk", + "org.h2.store.fs.encrypt", + "org.h2.store.fs.mem", + "org.h2.store.fs.niomapped", + "org.h2.store.fs.niomem", + "org.h2.store.fs.rec", + "org.h2.store.fs.retry", + "org.h2.store.fs.split", + "org.h2.store.fs.zip", + "org.h2.table", + "org.h2.tools", + "org.h2.util", + "org.h2.util.geometry", + "org.h2.util.json", + "org.h2.value", + "org.h2.value.lob" + ], + "com.ibm.icu:icu4j": [ + "com.ibm.icu.dev.tool.docs", + "com.ibm.icu.impl", + "com.ibm.icu.impl.breakiter", + "com.ibm.icu.impl.coll", + "com.ibm.icu.impl.data", + "com.ibm.icu.impl.duration", + "com.ibm.icu.impl.duration.impl", + "com.ibm.icu.impl.locale", + "com.ibm.icu.impl.number", + "com.ibm.icu.impl.number.parse", + "com.ibm.icu.impl.number.range", + "com.ibm.icu.impl.personname", + "com.ibm.icu.impl.text", + "com.ibm.icu.impl.units", + "com.ibm.icu.lang", + "com.ibm.icu.math", + "com.ibm.icu.message2", + "com.ibm.icu.number", + "com.ibm.icu.segmenter", + "com.ibm.icu.text", + "com.ibm.icu.util" + ], + "com.icegreen:greenmail": [ + "com.icegreen.greenmail", + "com.icegreen.greenmail.base", + "com.icegreen.greenmail.configuration", + "com.icegreen.greenmail.foedus.util", + "com.icegreen.greenmail.imap", + "com.icegreen.greenmail.imap.commands", + "com.icegreen.greenmail.junit", + "com.icegreen.greenmail.mail", + "com.icegreen.greenmail.pop3", + "com.icegreen.greenmail.pop3.commands", + "com.icegreen.greenmail.server", + "com.icegreen.greenmail.smtp", + "com.icegreen.greenmail.smtp.commands", + "com.icegreen.greenmail.store", + "com.icegreen.greenmail.user", + "com.icegreen.greenmail.util" + ], + "com.jcraft:jsch": [ + "com.jcraft.jsch", + "com.jcraft.jsch.jce", + "com.jcraft.jsch.jcraft", + "com.jcraft.jsch.jgss" + ], + "com.jcraft:jzlib": [ + "com.jcraft.jzlib" + ], + "com.ryanharter.auto.value:auto-value-gson-extension": [ + "autovaluegson.shaded.com.google.auto.common", + "autovaluegson.shaded.com.google.common.annotations", + "autovaluegson.shaded.com.google.common.base", + "autovaluegson.shaded.com.google.common.cache", + "autovaluegson.shaded.com.google.common.collect", + "autovaluegson.shaded.com.google.common.escape", + "autovaluegson.shaded.com.google.common.eventbus", + "autovaluegson.shaded.com.google.common.graph", + "autovaluegson.shaded.com.google.common.hash", + "autovaluegson.shaded.com.google.common.html", + "autovaluegson.shaded.com.google.common.io", + "autovaluegson.shaded.com.google.common.math", + "autovaluegson.shaded.com.google.common.net", + "autovaluegson.shaded.com.google.common.primitives", + "autovaluegson.shaded.com.google.common.reflect", + "autovaluegson.shaded.com.google.common.util.concurrent", + "autovaluegson.shaded.com.google.common.xml", + "com.ryanharter.auto.value.gson" + ], + "com.ryanharter.auto.value:auto-value-gson-factory": [ + "autovaluegson.factory.shaded.com.google.auto.common", + "autovaluegson.factory.shaded.com.google.common.annotations", + "autovaluegson.factory.shaded.com.google.common.base", + "autovaluegson.factory.shaded.com.google.common.cache", + "autovaluegson.factory.shaded.com.google.common.collect", + "autovaluegson.factory.shaded.com.google.common.escape", + "autovaluegson.factory.shaded.com.google.common.eventbus", + "autovaluegson.factory.shaded.com.google.common.graph", + "autovaluegson.factory.shaded.com.google.common.hash", + "autovaluegson.factory.shaded.com.google.common.html", + "autovaluegson.factory.shaded.com.google.common.io", + "autovaluegson.factory.shaded.com.google.common.math", + "autovaluegson.factory.shaded.com.google.common.net", + "autovaluegson.factory.shaded.com.google.common.primitives", + "autovaluegson.factory.shaded.com.google.common.reflect", + "autovaluegson.factory.shaded.com.google.common.util.concurrent", + "autovaluegson.factory.shaded.com.google.common.xml", + "com.ryanharter.auto.value.gson.factory" + ], + "com.ryanharter.auto.value:auto-value-gson-runtime": [ + "com.ryanharter.auto.value.gson", + "com.ryanharter.auto.value.gson.internal" + ], + "com.squareup:javapoet": [ + "com.squareup.javapoet" + ], + "com.sun.mail:javax.mail": [ + "com.sun.mail.auth", + "com.sun.mail.handlers", + "com.sun.mail.iap", + "com.sun.mail.imap", + "com.sun.mail.imap.protocol", + "com.sun.mail.pop3", + "com.sun.mail.smtp", + "com.sun.mail.util", + "com.sun.mail.util.logging", + "javax.mail", + "javax.mail.event", + "javax.mail.internet", + "javax.mail.search", + "javax.mail.util" + ], + "com.vladsch.flexmark:flexmark-all:jar:lib": [ + "com.vladsch.flexmark.ast", + "com.vladsch.flexmark.ast.util", + "com.vladsch.flexmark.ext.abbreviation", + "com.vladsch.flexmark.ext.abbreviation.internal", + "com.vladsch.flexmark.ext.admonition", + "com.vladsch.flexmark.ext.admonition.internal", + "com.vladsch.flexmark.ext.anchorlink", + "com.vladsch.flexmark.ext.anchorlink.internal", + "com.vladsch.flexmark.ext.aside", + "com.vladsch.flexmark.ext.aside.internal", + "com.vladsch.flexmark.ext.attributes", + "com.vladsch.flexmark.ext.attributes.internal", + "com.vladsch.flexmark.ext.autolink", + "com.vladsch.flexmark.ext.autolink.internal", + "com.vladsch.flexmark.ext.definition", + "com.vladsch.flexmark.ext.definition.internal", + "com.vladsch.flexmark.ext.emoji", + "com.vladsch.flexmark.ext.emoji.internal", + "com.vladsch.flexmark.ext.enumerated.reference", + "com.vladsch.flexmark.ext.enumerated.reference.internal", + "com.vladsch.flexmark.ext.escaped.character", + "com.vladsch.flexmark.ext.escaped.character.internal", + "com.vladsch.flexmark.ext.footnotes", + "com.vladsch.flexmark.ext.footnotes.internal", + "com.vladsch.flexmark.ext.gfm.issues", + "com.vladsch.flexmark.ext.gfm.issues.internal", + "com.vladsch.flexmark.ext.gfm.strikethrough", + "com.vladsch.flexmark.ext.gfm.strikethrough.internal", + "com.vladsch.flexmark.ext.gfm.tasklist", + "com.vladsch.flexmark.ext.gfm.tasklist.internal", + "com.vladsch.flexmark.ext.gfm.users", + "com.vladsch.flexmark.ext.gfm.users.internal", + "com.vladsch.flexmark.ext.gitlab", + "com.vladsch.flexmark.ext.gitlab.internal", + "com.vladsch.flexmark.ext.ins", + "com.vladsch.flexmark.ext.ins.internal", + "com.vladsch.flexmark.ext.jekyll.front.matter", + "com.vladsch.flexmark.ext.jekyll.front.matter.internal", + "com.vladsch.flexmark.ext.jekyll.tag", + "com.vladsch.flexmark.ext.jekyll.tag.internal", + "com.vladsch.flexmark.ext.macros", + "com.vladsch.flexmark.ext.macros.internal", + "com.vladsch.flexmark.ext.media.tags", + "com.vladsch.flexmark.ext.media.tags.internal", + "com.vladsch.flexmark.ext.resizable.image", + "com.vladsch.flexmark.ext.resizable.image.internal", + "com.vladsch.flexmark.ext.superscript", + "com.vladsch.flexmark.ext.superscript.internal", + "com.vladsch.flexmark.ext.tables", + "com.vladsch.flexmark.ext.tables.internal", + "com.vladsch.flexmark.ext.toc", + "com.vladsch.flexmark.ext.toc.internal", + "com.vladsch.flexmark.ext.typographic", + "com.vladsch.flexmark.ext.typographic.internal", + "com.vladsch.flexmark.ext.wikilink", + "com.vladsch.flexmark.ext.wikilink.internal", + "com.vladsch.flexmark.ext.xwiki.macros", + "com.vladsch.flexmark.ext.xwiki.macros.internal", + "com.vladsch.flexmark.ext.yaml.front.matter", + "com.vladsch.flexmark.ext.yaml.front.matter.internal", + "com.vladsch.flexmark.ext.youtube.embedded", + "com.vladsch.flexmark.ext.youtube.embedded.internal", + "com.vladsch.flexmark.formatter", + "com.vladsch.flexmark.formatter.internal", + "com.vladsch.flexmark.html", + "com.vladsch.flexmark.html.renderer", + "com.vladsch.flexmark.jira.converter", + "com.vladsch.flexmark.jira.converter.internal", + "com.vladsch.flexmark.parser", + "com.vladsch.flexmark.parser.block", + "com.vladsch.flexmark.parser.core", + "com.vladsch.flexmark.parser.core.delimiter", + "com.vladsch.flexmark.parser.delimiter", + "com.vladsch.flexmark.parser.internal", + "com.vladsch.flexmark.pdf.converter", + "com.vladsch.flexmark.profile.pegdown", + "com.vladsch.flexmark.util.ast", + "com.vladsch.flexmark.util.builder", + "com.vladsch.flexmark.util.collection", + "com.vladsch.flexmark.util.collection.iteration", + "com.vladsch.flexmark.util.data", + "com.vladsch.flexmark.util.dependency", + "com.vladsch.flexmark.util.format", + "com.vladsch.flexmark.util.format.options", + "com.vladsch.flexmark.util.html", + "com.vladsch.flexmark.util.html.ui", + "com.vladsch.flexmark.util.misc", + "com.vladsch.flexmark.util.options", + "com.vladsch.flexmark.util.sequence", + "com.vladsch.flexmark.util.sequence.builder", + "com.vladsch.flexmark.util.sequence.builder.tree", + "com.vladsch.flexmark.util.sequence.mappers", + "com.vladsch.flexmark.util.visitor", + "com.vladsch.flexmark.youtrack.converter", + "com.vladsch.flexmark.youtrack.converter.internal" + ], + "commons-codec:commons-codec": [ + "org.apache.commons.codec", + "org.apache.commons.codec.binary", + "org.apache.commons.codec.cli", + "org.apache.commons.codec.digest", + "org.apache.commons.codec.language", + "org.apache.commons.codec.language.bm", + "org.apache.commons.codec.net" + ], + "commons-dbcp:commons-dbcp": [ + "org.apache.commons.dbcp", + "org.apache.commons.dbcp.cpdsadapter", + "org.apache.commons.dbcp.datasources", + "org.apache.commons.dbcp.managed", + "org.apache.commons.jocl" + ], + "commons-io:commons-io": [ + "org.apache.commons.io", + "org.apache.commons.io.build", + "org.apache.commons.io.channels", + "org.apache.commons.io.charset", + "org.apache.commons.io.comparator", + "org.apache.commons.io.file", + "org.apache.commons.io.file.attribute", + "org.apache.commons.io.file.spi", + "org.apache.commons.io.filefilter", + "org.apache.commons.io.function", + "org.apache.commons.io.input", + "org.apache.commons.io.input.buffer", + "org.apache.commons.io.monitor", + "org.apache.commons.io.output", + "org.apache.commons.io.serialization" + ], + "commons-net:commons-net": [ + "org.apache.commons.net", + "org.apache.commons.net.bsd", + "org.apache.commons.net.chargen", + "org.apache.commons.net.daytime", + "org.apache.commons.net.discard", + "org.apache.commons.net.echo", + "org.apache.commons.net.finger", + "org.apache.commons.net.ftp", + "org.apache.commons.net.ftp.parser", + "org.apache.commons.net.imap", + "org.apache.commons.net.io", + "org.apache.commons.net.nntp", + "org.apache.commons.net.ntp", + "org.apache.commons.net.pop3", + "org.apache.commons.net.smtp", + "org.apache.commons.net.telnet", + "org.apache.commons.net.tftp", + "org.apache.commons.net.time", + "org.apache.commons.net.util", + "org.apache.commons.net.whois" + ], + "commons-pool:commons-pool": [ + "org.apache.commons.pool", + "org.apache.commons.pool.impl" + ], + "commons-validator:commons-validator": [ + "org.apache.commons.validator", + "org.apache.commons.validator.routines", + "org.apache.commons.validator.routines.checkdigit", + "org.apache.commons.validator.util" + ], + "dk.brics:automaton": [ + "dk.brics.automaton" + ], + "eu.medsea.mimeutil:mime-util": [ + "eu.medsea.mimeutil", + "eu.medsea.mimeutil.detector", + "eu.medsea.mimeutil.handler", + "eu.medsea.util" + ], + "io.dropwizard.metrics:metrics-core": [ + "com.codahale.metrics" + ], + "io.github.java-diff-utils:java-diff-utils": [ + "com.github.difflib", + "com.github.difflib.algorithm", + "com.github.difflib.algorithm.myers", + "com.github.difflib.patch", + "com.github.difflib.text", + "com.github.difflib.text.deltamerge", + "com.github.difflib.unifieddiff" + ], + "io.sweers.autotransient:autotransient": [ + "io.sweers.autotransient" + ], + "jakarta.inject:jakarta.inject-api": [ + "jakarta.inject" + ], + "javax.activation:activation": [ + "com.sun.activation.registries", + "com.sun.activation.viewers", + "javax.activation" + ], + "javax.inject:javax.inject": [ + "javax.inject" + ], + "javax.servlet:javax.servlet-api": [ + "javax.servlet", + "javax.servlet.annotation", + "javax.servlet.descriptor", + "javax.servlet.http" + ], + "junit:junit": [ + "junit.extensions", + "junit.framework", + "junit.runner", + "junit.textui", + "org.junit", + "org.junit.experimental", + "org.junit.experimental.categories", + "org.junit.experimental.max", + "org.junit.experimental.results", + "org.junit.experimental.runners", + "org.junit.experimental.theories", + "org.junit.experimental.theories.internal", + "org.junit.experimental.theories.suppliers", + "org.junit.function", + "org.junit.internal", + "org.junit.internal.builders", + "org.junit.internal.management", + "org.junit.internal.matchers", + "org.junit.internal.requests", + "org.junit.internal.runners", + "org.junit.internal.runners.model", + "org.junit.internal.runners.rules", + "org.junit.internal.runners.statements", + "org.junit.matchers", + "org.junit.rules", + "org.junit.runner", + "org.junit.runner.manipulation", + "org.junit.runner.notification", + "org.junit.runners", + "org.junit.runners.model", + "org.junit.runners.parameterized", + "org.junit.validator" + ], + "net.bytebuddy:byte-buddy": [ + "net.bytebuddy", + "net.bytebuddy.agent.builder", + "net.bytebuddy.asm", + "net.bytebuddy.build", + "net.bytebuddy.description", + "net.bytebuddy.description.annotation", + "net.bytebuddy.description.enumeration", + "net.bytebuddy.description.field", + "net.bytebuddy.description.method", + "net.bytebuddy.description.modifier", + "net.bytebuddy.description.module", + "net.bytebuddy.description.type", + "net.bytebuddy.dynamic", + "net.bytebuddy.dynamic.loading", + "net.bytebuddy.dynamic.scaffold", + "net.bytebuddy.dynamic.scaffold.inline", + "net.bytebuddy.dynamic.scaffold.subclass", + "net.bytebuddy.implementation", + "net.bytebuddy.implementation.attribute", + "net.bytebuddy.implementation.auxiliary", + "net.bytebuddy.implementation.bind", + "net.bytebuddy.implementation.bind.annotation", + "net.bytebuddy.implementation.bytecode", + "net.bytebuddy.implementation.bytecode.assign", + "net.bytebuddy.implementation.bytecode.assign.primitive", + "net.bytebuddy.implementation.bytecode.assign.reference", + "net.bytebuddy.implementation.bytecode.collection", + "net.bytebuddy.implementation.bytecode.constant", + "net.bytebuddy.implementation.bytecode.member", + "net.bytebuddy.jar.asm", + "net.bytebuddy.jar.asm.commons", + "net.bytebuddy.jar.asm.signature", + "net.bytebuddy.jar.asmjdkbridge", + "net.bytebuddy.matcher", + "net.bytebuddy.pool", + "net.bytebuddy.utility", + "net.bytebuddy.utility.dispatcher", + "net.bytebuddy.utility.nullability", + "net.bytebuddy.utility.privilege", + "net.bytebuddy.utility.visitor" + ], + "net.bytebuddy:byte-buddy-agent": [ + "net.bytebuddy.agent", + "net.bytebuddy.agent.utility.nullability" + ], + "net.java.dev.jna:jna": [ + "com.sun.jna", + "com.sun.jna.internal", + "com.sun.jna.ptr", + "com.sun.jna.win32" + ], + "net.java.dev.jna:jna-platform": [ + "com.sun.jna.platform", + "com.sun.jna.platform.bsd", + "com.sun.jna.platform.dnd", + "com.sun.jna.platform.linux", + "com.sun.jna.platform.mac", + "com.sun.jna.platform.unix", + "com.sun.jna.platform.unix.aix", + "com.sun.jna.platform.unix.solaris", + "com.sun.jna.platform.win32", + "com.sun.jna.platform.win32.COM", + "com.sun.jna.platform.win32.COM.tlb", + "com.sun.jna.platform.win32.COM.tlb.imp", + "com.sun.jna.platform.win32.COM.util", + "com.sun.jna.platform.win32.COM.util.annotation", + "com.sun.jna.platform.wince" + ], + "net.minidev:json-smart": [ + "net.minidev.json", + "net.minidev.json.parser" + ], + "net.sf.jopt-simple:jopt-simple": [ + "joptsimple", + "joptsimple.internal", + "joptsimple.util" + ], + "net.sourceforge.nekohtml:nekohtml": [ + "org.cyberneko.html", + "org.cyberneko.html.filters", + "org.cyberneko.html.parsers", + "org.cyberneko.html.xercesbridge" + ], + "org.antlr:ST4": [ + "org.stringtemplate.v4", + "org.stringtemplate.v4.compiler", + "org.stringtemplate.v4.debug", + "org.stringtemplate.v4.gui", + "org.stringtemplate.v4.misc" + ], + "org.antlr:antlr": [ + "org.antlr", + "org.antlr.analysis", + "org.antlr.codegen", + "org.antlr.grammar.v3", + "org.antlr.misc", + "org.antlr.tool" + ], + "org.antlr:antlr-runtime": [ + "org.antlr.runtime", + "org.antlr.runtime.debug", + "org.antlr.runtime.misc", + "org.antlr.runtime.tree" + ], + "org.antlr:stringtemplate": [ + "org.stringtemplate.v4", + "org.stringtemplate.v4.compiler", + "org.stringtemplate.v4.debug", + "org.stringtemplate.v4.gui", + "org.stringtemplate.v4.misc" + ], + "org.apache.commons:commons-compress": [ + "org.apache.commons.compress", + "org.apache.commons.compress.archivers", + "org.apache.commons.compress.archivers.ar", + "org.apache.commons.compress.archivers.arj", + "org.apache.commons.compress.archivers.cpio", + "org.apache.commons.compress.archivers.dump", + "org.apache.commons.compress.archivers.examples", + "org.apache.commons.compress.archivers.jar", + "org.apache.commons.compress.archivers.sevenz", + "org.apache.commons.compress.archivers.tar", + "org.apache.commons.compress.archivers.zip", + "org.apache.commons.compress.changes", + "org.apache.commons.compress.compressors", + "org.apache.commons.compress.compressors.brotli", + "org.apache.commons.compress.compressors.bzip2", + "org.apache.commons.compress.compressors.deflate", + "org.apache.commons.compress.compressors.deflate64", + "org.apache.commons.compress.compressors.gzip", + "org.apache.commons.compress.compressors.lz4", + "org.apache.commons.compress.compressors.lz77support", + "org.apache.commons.compress.compressors.lzma", + "org.apache.commons.compress.compressors.lzw", + "org.apache.commons.compress.compressors.pack200", + "org.apache.commons.compress.compressors.snappy", + "org.apache.commons.compress.compressors.xz", + "org.apache.commons.compress.compressors.z", + "org.apache.commons.compress.compressors.zstandard", + "org.apache.commons.compress.harmony", + "org.apache.commons.compress.harmony.archive.internal.nls", + "org.apache.commons.compress.harmony.pack200", + "org.apache.commons.compress.harmony.unpack200", + "org.apache.commons.compress.harmony.unpack200.bytecode", + "org.apache.commons.compress.harmony.unpack200.bytecode.forms", + "org.apache.commons.compress.java.util.jar", + "org.apache.commons.compress.parallel", + "org.apache.commons.compress.utils" + ], + "org.apache.commons:commons-lang3": [ + "org.apache.commons.lang3", + "org.apache.commons.lang3.arch", + "org.apache.commons.lang3.builder", + "org.apache.commons.lang3.compare", + "org.apache.commons.lang3.concurrent", + "org.apache.commons.lang3.concurrent.locks", + "org.apache.commons.lang3.event", + "org.apache.commons.lang3.exception", + "org.apache.commons.lang3.function", + "org.apache.commons.lang3.math", + "org.apache.commons.lang3.mutable", + "org.apache.commons.lang3.reflect", + "org.apache.commons.lang3.stream", + "org.apache.commons.lang3.text", + "org.apache.commons.lang3.text.translate", + "org.apache.commons.lang3.time", + "org.apache.commons.lang3.tuple", + "org.apache.commons.lang3.util" + ], + "org.apache.commons:commons-math3": [ + "org.apache.commons.math3", + "org.apache.commons.math3.analysis", + "org.apache.commons.math3.analysis.differentiation", + "org.apache.commons.math3.analysis.function", + "org.apache.commons.math3.analysis.integration", + "org.apache.commons.math3.analysis.integration.gauss", + "org.apache.commons.math3.analysis.interpolation", + "org.apache.commons.math3.analysis.polynomials", + "org.apache.commons.math3.analysis.solvers", + "org.apache.commons.math3.complex", + "org.apache.commons.math3.dfp", + "org.apache.commons.math3.distribution", + "org.apache.commons.math3.distribution.fitting", + "org.apache.commons.math3.exception", + "org.apache.commons.math3.exception.util", + "org.apache.commons.math3.filter", + "org.apache.commons.math3.fitting", + "org.apache.commons.math3.fitting.leastsquares", + "org.apache.commons.math3.fraction", + "org.apache.commons.math3.genetics", + "org.apache.commons.math3.geometry", + "org.apache.commons.math3.geometry.enclosing", + "org.apache.commons.math3.geometry.euclidean.oned", + "org.apache.commons.math3.geometry.euclidean.threed", + "org.apache.commons.math3.geometry.euclidean.twod", + "org.apache.commons.math3.geometry.euclidean.twod.hull", + "org.apache.commons.math3.geometry.hull", + "org.apache.commons.math3.geometry.partitioning", + "org.apache.commons.math3.geometry.partitioning.utilities", + "org.apache.commons.math3.geometry.spherical.oned", + "org.apache.commons.math3.geometry.spherical.twod", + "org.apache.commons.math3.linear", + "org.apache.commons.math3.ml.clustering", + "org.apache.commons.math3.ml.clustering.evaluation", + "org.apache.commons.math3.ml.distance", + "org.apache.commons.math3.ml.neuralnet", + "org.apache.commons.math3.ml.neuralnet.oned", + "org.apache.commons.math3.ml.neuralnet.sofm", + "org.apache.commons.math3.ml.neuralnet.sofm.util", + "org.apache.commons.math3.ml.neuralnet.twod", + "org.apache.commons.math3.ml.neuralnet.twod.util", + "org.apache.commons.math3.ode", + "org.apache.commons.math3.ode.events", + "org.apache.commons.math3.ode.nonstiff", + "org.apache.commons.math3.ode.sampling", + "org.apache.commons.math3.optim", + "org.apache.commons.math3.optim.linear", + "org.apache.commons.math3.optim.nonlinear.scalar", + "org.apache.commons.math3.optim.nonlinear.scalar.gradient", + "org.apache.commons.math3.optim.nonlinear.scalar.noderiv", + "org.apache.commons.math3.optim.nonlinear.vector", + "org.apache.commons.math3.optim.nonlinear.vector.jacobian", + "org.apache.commons.math3.optim.univariate", + "org.apache.commons.math3.optimization", + "org.apache.commons.math3.optimization.direct", + "org.apache.commons.math3.optimization.fitting", + "org.apache.commons.math3.optimization.general", + "org.apache.commons.math3.optimization.linear", + "org.apache.commons.math3.optimization.univariate", + "org.apache.commons.math3.primes", + "org.apache.commons.math3.random", + "org.apache.commons.math3.special", + "org.apache.commons.math3.stat", + "org.apache.commons.math3.stat.clustering", + "org.apache.commons.math3.stat.correlation", + "org.apache.commons.math3.stat.descriptive", + "org.apache.commons.math3.stat.descriptive.moment", + "org.apache.commons.math3.stat.descriptive.rank", + "org.apache.commons.math3.stat.descriptive.summary", + "org.apache.commons.math3.stat.inference", + "org.apache.commons.math3.stat.interval", + "org.apache.commons.math3.stat.ranking", + "org.apache.commons.math3.stat.regression", + "org.apache.commons.math3.transform", + "org.apache.commons.math3.util" + ], + "org.apache.commons:commons-text": [ + "org.apache.commons.text", + "org.apache.commons.text.diff", + "org.apache.commons.text.io", + "org.apache.commons.text.lookup", + "org.apache.commons.text.matcher", + "org.apache.commons.text.numbers", + "org.apache.commons.text.similarity", + "org.apache.commons.text.translate" + ], + "org.apache.httpcomponents:fluent-hc": [ + "org.apache.http.client.fluent" + ], + "org.apache.httpcomponents:httpclient": [ + "org.apache.http.auth", + "org.apache.http.auth.params", + "org.apache.http.client", + "org.apache.http.client.config", + "org.apache.http.client.entity", + "org.apache.http.client.methods", + "org.apache.http.client.params", + "org.apache.http.client.protocol", + "org.apache.http.client.utils", + "org.apache.http.conn", + "org.apache.http.conn.params", + "org.apache.http.conn.routing", + "org.apache.http.conn.scheme", + "org.apache.http.conn.socket", + "org.apache.http.conn.ssl", + "org.apache.http.conn.util", + "org.apache.http.cookie", + "org.apache.http.cookie.params", + "org.apache.http.impl.auth", + "org.apache.http.impl.client", + "org.apache.http.impl.conn", + "org.apache.http.impl.conn.tsccm", + "org.apache.http.impl.cookie", + "org.apache.http.impl.execchain" + ], + "org.apache.httpcomponents:httpcore": [ + "org.apache.http", + "org.apache.http.annotation", + "org.apache.http.concurrent", + "org.apache.http.config", + "org.apache.http.entity", + "org.apache.http.impl", + "org.apache.http.impl.bootstrap", + "org.apache.http.impl.entity", + "org.apache.http.impl.io", + "org.apache.http.impl.pool", + "org.apache.http.io", + "org.apache.http.message", + "org.apache.http.params", + "org.apache.http.pool", + "org.apache.http.protocol", + "org.apache.http.ssl", + "org.apache.http.util" + ], + "org.apache.james:apache-mime4j-core": [ + "org.apache.james.mime4j", + "org.apache.james.mime4j.codec", + "org.apache.james.mime4j.io", + "org.apache.james.mime4j.parser", + "org.apache.james.mime4j.stream", + "org.apache.james.mime4j.util" + ], + "org.apache.james:apache-mime4j-dom": [ + "org.apache.james.mime4j.dom", + "org.apache.james.mime4j.dom.address", + "org.apache.james.mime4j.dom.datetime", + "org.apache.james.mime4j.dom.field", + "org.apache.james.mime4j.field", + "org.apache.james.mime4j.field.address", + "org.apache.james.mime4j.field.contentdisposition.parser", + "org.apache.james.mime4j.field.contenttype.parser", + "org.apache.james.mime4j.field.datetime.parser", + "org.apache.james.mime4j.field.language.parser", + "org.apache.james.mime4j.field.mimeversion.parser", + "org.apache.james.mime4j.field.structured.parser", + "org.apache.james.mime4j.internal", + "org.apache.james.mime4j.message" + ], + "org.apache.lucene:lucene-analysis-common": [ + "org.apache.lucene.analysis.ar", + "org.apache.lucene.analysis.bg", + "org.apache.lucene.analysis.bn", + "org.apache.lucene.analysis.boost", + "org.apache.lucene.analysis.br", + "org.apache.lucene.analysis.ca", + "org.apache.lucene.analysis.charfilter", + "org.apache.lucene.analysis.cjk", + "org.apache.lucene.analysis.ckb", + "org.apache.lucene.analysis.classic", + "org.apache.lucene.analysis.commongrams", + "org.apache.lucene.analysis.compound", + "org.apache.lucene.analysis.compound.hyphenation", + "org.apache.lucene.analysis.core", + "org.apache.lucene.analysis.custom", + "org.apache.lucene.analysis.cz", + "org.apache.lucene.analysis.da", + "org.apache.lucene.analysis.de", + "org.apache.lucene.analysis.el", + "org.apache.lucene.analysis.email", + "org.apache.lucene.analysis.en", + "org.apache.lucene.analysis.es", + "org.apache.lucene.analysis.et", + "org.apache.lucene.analysis.eu", + "org.apache.lucene.analysis.fa", + "org.apache.lucene.analysis.fi", + "org.apache.lucene.analysis.fr", + "org.apache.lucene.analysis.ga", + "org.apache.lucene.analysis.gl", + "org.apache.lucene.analysis.hi", + "org.apache.lucene.analysis.hu", + "org.apache.lucene.analysis.hunspell", + "org.apache.lucene.analysis.hy", + "org.apache.lucene.analysis.id", + "org.apache.lucene.analysis.in", + "org.apache.lucene.analysis.it", + "org.apache.lucene.analysis.lt", + "org.apache.lucene.analysis.lv", + "org.apache.lucene.analysis.minhash", + "org.apache.lucene.analysis.miscellaneous", + "org.apache.lucene.analysis.morph", + "org.apache.lucene.analysis.ne", + "org.apache.lucene.analysis.ngram", + "org.apache.lucene.analysis.nl", + "org.apache.lucene.analysis.no", + "org.apache.lucene.analysis.path", + "org.apache.lucene.analysis.pattern", + "org.apache.lucene.analysis.payloads", + "org.apache.lucene.analysis.pt", + "org.apache.lucene.analysis.query", + "org.apache.lucene.analysis.reverse", + "org.apache.lucene.analysis.ro", + "org.apache.lucene.analysis.ru", + "org.apache.lucene.analysis.shingle", + "org.apache.lucene.analysis.sinks", + "org.apache.lucene.analysis.snowball", + "org.apache.lucene.analysis.sr", + "org.apache.lucene.analysis.sv", + "org.apache.lucene.analysis.synonym", + "org.apache.lucene.analysis.synonym.word2vec", + "org.apache.lucene.analysis.ta", + "org.apache.lucene.analysis.te", + "org.apache.lucene.analysis.th", + "org.apache.lucene.analysis.tr", + "org.apache.lucene.analysis.util", + "org.apache.lucene.analysis.wikipedia", + "org.apache.lucene.collation", + "org.apache.lucene.collation.tokenattributes", + "org.tartarus.snowball", + "org.tartarus.snowball.ext" + ], + "org.apache.lucene:lucene-backward-codecs": [ + "org.apache.lucene.backward_codecs", + "org.apache.lucene.backward_codecs.compressing", + "org.apache.lucene.backward_codecs.lucene100", + "org.apache.lucene.backward_codecs.lucene101", + "org.apache.lucene.backward_codecs.lucene102", + "org.apache.lucene.backward_codecs.lucene103", + "org.apache.lucene.backward_codecs.lucene40.blocktree", + "org.apache.lucene.backward_codecs.lucene50", + "org.apache.lucene.backward_codecs.lucene50.compressing", + "org.apache.lucene.backward_codecs.lucene60", + "org.apache.lucene.backward_codecs.lucene70", + "org.apache.lucene.backward_codecs.lucene80", + "org.apache.lucene.backward_codecs.lucene84", + "org.apache.lucene.backward_codecs.lucene86", + "org.apache.lucene.backward_codecs.lucene87", + "org.apache.lucene.backward_codecs.lucene90", + "org.apache.lucene.backward_codecs.lucene90.blocktree", + "org.apache.lucene.backward_codecs.lucene91", + "org.apache.lucene.backward_codecs.lucene912", + "org.apache.lucene.backward_codecs.lucene92", + "org.apache.lucene.backward_codecs.lucene94", + "org.apache.lucene.backward_codecs.lucene95", + "org.apache.lucene.backward_codecs.lucene99", + "org.apache.lucene.backward_codecs.packed", + "org.apache.lucene.backward_codecs.store" + ], + "org.apache.lucene:lucene-core": [ + "org.apache.lucene.analysis", + "org.apache.lucene.analysis.standard", + "org.apache.lucene.analysis.tokenattributes", + "org.apache.lucene.codecs", + "org.apache.lucene.codecs.compressing", + "org.apache.lucene.codecs.hnsw", + "org.apache.lucene.codecs.lucene103.blocktree", + "org.apache.lucene.codecs.lucene104", + "org.apache.lucene.codecs.lucene90", + "org.apache.lucene.codecs.lucene90.compressing", + "org.apache.lucene.codecs.lucene94", + "org.apache.lucene.codecs.lucene95", + "org.apache.lucene.codecs.lucene99", + "org.apache.lucene.codecs.perfield", + "org.apache.lucene.document", + "org.apache.lucene.geo", + "org.apache.lucene.index", + "org.apache.lucene.internal.hppc", + "org.apache.lucene.internal.tests", + "org.apache.lucene.internal.vectorization", + "org.apache.lucene.search", + "org.apache.lucene.search.comparators", + "org.apache.lucene.search.knn", + "org.apache.lucene.search.similarities", + "org.apache.lucene.store", + "org.apache.lucene.util", + "org.apache.lucene.util.automaton", + "org.apache.lucene.util.bkd", + "org.apache.lucene.util.compress", + "org.apache.lucene.util.fst", + "org.apache.lucene.util.graph", + "org.apache.lucene.util.hnsw", + "org.apache.lucene.util.mutable", + "org.apache.lucene.util.packed", + "org.apache.lucene.util.quantization" + ], + "org.apache.lucene:lucene-misc": [ + "org.apache.lucene.misc", + "org.apache.lucene.misc.document", + "org.apache.lucene.misc.index", + "org.apache.lucene.misc.search", + "org.apache.lucene.misc.store", + "org.apache.lucene.misc.util", + "org.apache.lucene.misc.util.fst" + ], + "org.apache.lucene:lucene-queryparser": [ + "org.apache.lucene.queryparser.charstream", + "org.apache.lucene.queryparser.classic", + "org.apache.lucene.queryparser.complexPhrase", + "org.apache.lucene.queryparser.ext", + "org.apache.lucene.queryparser.flexible.core", + "org.apache.lucene.queryparser.flexible.core.builders", + "org.apache.lucene.queryparser.flexible.core.config", + "org.apache.lucene.queryparser.flexible.core.messages", + "org.apache.lucene.queryparser.flexible.core.nodes", + "org.apache.lucene.queryparser.flexible.core.parser", + "org.apache.lucene.queryparser.flexible.core.processors", + "org.apache.lucene.queryparser.flexible.core.util", + "org.apache.lucene.queryparser.flexible.messages", + "org.apache.lucene.queryparser.flexible.precedence", + "org.apache.lucene.queryparser.flexible.precedence.processors", + "org.apache.lucene.queryparser.flexible.standard", + "org.apache.lucene.queryparser.flexible.standard.builders", + "org.apache.lucene.queryparser.flexible.standard.config", + "org.apache.lucene.queryparser.flexible.standard.nodes", + "org.apache.lucene.queryparser.flexible.standard.nodes.intervalfn", + "org.apache.lucene.queryparser.flexible.standard.parser", + "org.apache.lucene.queryparser.flexible.standard.processors", + "org.apache.lucene.queryparser.simple", + "org.apache.lucene.queryparser.surround.parser", + "org.apache.lucene.queryparser.surround.query", + "org.apache.lucene.queryparser.xml", + "org.apache.lucene.queryparser.xml.builders" + ], + "org.apache.mina:mina-core": [ + "org.apache.mina.core", + "org.apache.mina.core.buffer", + "org.apache.mina.core.buffer.matcher", + "org.apache.mina.core.file", + "org.apache.mina.core.filterchain", + "org.apache.mina.core.future", + "org.apache.mina.core.polling", + "org.apache.mina.core.service", + "org.apache.mina.core.session", + "org.apache.mina.core.write", + "org.apache.mina.filter", + "org.apache.mina.filter.buffer", + "org.apache.mina.filter.codec", + "org.apache.mina.filter.codec.demux", + "org.apache.mina.filter.codec.prefixedstring", + "org.apache.mina.filter.codec.serialization", + "org.apache.mina.filter.codec.statemachine", + "org.apache.mina.filter.codec.textline", + "org.apache.mina.filter.errorgenerating", + "org.apache.mina.filter.executor", + "org.apache.mina.filter.firewall", + "org.apache.mina.filter.keepalive", + "org.apache.mina.filter.logging", + "org.apache.mina.filter.ssl", + "org.apache.mina.filter.statistic", + "org.apache.mina.filter.stream", + "org.apache.mina.filter.util", + "org.apache.mina.handler", + "org.apache.mina.handler.chain", + "org.apache.mina.handler.demux", + "org.apache.mina.handler.multiton", + "org.apache.mina.handler.stream", + "org.apache.mina.proxy", + "org.apache.mina.proxy.event", + "org.apache.mina.proxy.filter", + "org.apache.mina.proxy.handlers", + "org.apache.mina.proxy.handlers.http", + "org.apache.mina.proxy.handlers.http.basic", + "org.apache.mina.proxy.handlers.http.digest", + "org.apache.mina.proxy.handlers.http.ntlm", + "org.apache.mina.proxy.handlers.socks", + "org.apache.mina.proxy.session", + "org.apache.mina.proxy.utils", + "org.apache.mina.transport.socket", + "org.apache.mina.transport.socket.nio", + "org.apache.mina.transport.vmpipe", + "org.apache.mina.util", + "org.apache.mina.util.byteaccess" + ], + "org.apache.sshd:sshd-mina": [ + "org.apache.sshd.mina" + ], + "org.apache.sshd:sshd-osgi": [ + "org.apache.sshd.agent", + "org.apache.sshd.agent.common", + "org.apache.sshd.agent.local", + "org.apache.sshd.agent.unix", + "org.apache.sshd.certificate", + "org.apache.sshd.client", + "org.apache.sshd.client.auth", + "org.apache.sshd.client.auth.hostbased", + "org.apache.sshd.client.auth.keyboard", + "org.apache.sshd.client.auth.password", + "org.apache.sshd.client.auth.pubkey", + "org.apache.sshd.client.channel", + "org.apache.sshd.client.channel.exit", + "org.apache.sshd.client.config", + "org.apache.sshd.client.config.hosts", + "org.apache.sshd.client.config.keys", + "org.apache.sshd.client.future", + "org.apache.sshd.client.global", + "org.apache.sshd.client.kex", + "org.apache.sshd.client.keyverifier", + "org.apache.sshd.client.session", + "org.apache.sshd.client.session.forward", + "org.apache.sshd.client.simple", + "org.apache.sshd.client.subsystem", + "org.apache.sshd.common", + "org.apache.sshd.common.auth", + "org.apache.sshd.common.channel", + "org.apache.sshd.common.channel.exception", + "org.apache.sshd.common.channel.throttle", + "org.apache.sshd.common.cipher", + "org.apache.sshd.common.compression", + "org.apache.sshd.common.config", + "org.apache.sshd.common.config.keys", + "org.apache.sshd.common.config.keys.impl", + "org.apache.sshd.common.config.keys.loader", + "org.apache.sshd.common.config.keys.loader.openssh", + "org.apache.sshd.common.config.keys.loader.openssh.kdf", + "org.apache.sshd.common.config.keys.loader.pem", + "org.apache.sshd.common.config.keys.loader.ssh2", + "org.apache.sshd.common.config.keys.u2f", + "org.apache.sshd.common.config.keys.writer", + "org.apache.sshd.common.config.keys.writer.openssh", + "org.apache.sshd.common.digest", + "org.apache.sshd.common.file", + "org.apache.sshd.common.file.nativefs", + "org.apache.sshd.common.file.nonefs", + "org.apache.sshd.common.file.root", + "org.apache.sshd.common.file.util", + "org.apache.sshd.common.file.virtualfs", + "org.apache.sshd.common.forward", + "org.apache.sshd.common.future", + "org.apache.sshd.common.global", + "org.apache.sshd.common.helpers", + "org.apache.sshd.common.io", + "org.apache.sshd.common.io.nio2", + "org.apache.sshd.common.kex", + "org.apache.sshd.common.kex.dh", + "org.apache.sshd.common.kex.extension", + "org.apache.sshd.common.kex.extension.parser", + "org.apache.sshd.common.keyprovider", + "org.apache.sshd.common.mac", + "org.apache.sshd.common.net", + "org.apache.sshd.common.random", + "org.apache.sshd.common.session", + "org.apache.sshd.common.session.helpers", + "org.apache.sshd.common.signature", + "org.apache.sshd.common.util", + "org.apache.sshd.common.util.buffer", + "org.apache.sshd.common.util.buffer.keys", + "org.apache.sshd.common.util.closeable", + "org.apache.sshd.common.util.functors", + "org.apache.sshd.common.util.helper", + "org.apache.sshd.common.util.io", + "org.apache.sshd.common.util.io.der", + "org.apache.sshd.common.util.io.functors", + "org.apache.sshd.common.util.io.input", + "org.apache.sshd.common.util.io.output", + "org.apache.sshd.common.util.io.resource", + "org.apache.sshd.common.util.logging", + "org.apache.sshd.common.util.net", + "org.apache.sshd.common.util.security", + "org.apache.sshd.common.util.security.bouncycastle", + "org.apache.sshd.common.util.security.eddsa", + "org.apache.sshd.common.util.security.eddsa.bouncycastle", + "org.apache.sshd.common.util.security.eddsa.generic", + "org.apache.sshd.common.util.threads", + "org.apache.sshd.core", + "org.apache.sshd.server", + "org.apache.sshd.server.auth", + "org.apache.sshd.server.auth.gss", + "org.apache.sshd.server.auth.hostbased", + "org.apache.sshd.server.auth.keyboard", + "org.apache.sshd.server.auth.password", + "org.apache.sshd.server.auth.pubkey", + "org.apache.sshd.server.channel", + "org.apache.sshd.server.command", + "org.apache.sshd.server.config", + "org.apache.sshd.server.config.keys", + "org.apache.sshd.server.forward", + "org.apache.sshd.server.global", + "org.apache.sshd.server.jaas", + "org.apache.sshd.server.kex", + "org.apache.sshd.server.keyprovider", + "org.apache.sshd.server.session", + "org.apache.sshd.server.shell", + "org.apache.sshd.server.subsystem", + "org.apache.sshd.server.x11" + ], + "org.apache.sshd:sshd-sftp": [ + "org.apache.sshd.sftp", + "org.apache.sshd.sftp.client", + "org.apache.sshd.sftp.client.extensions", + "org.apache.sshd.sftp.client.extensions.helpers", + "org.apache.sshd.sftp.client.extensions.openssh", + "org.apache.sshd.sftp.client.extensions.openssh.helpers", + "org.apache.sshd.sftp.client.fs", + "org.apache.sshd.sftp.client.fs.impl", + "org.apache.sshd.sftp.client.impl", + "org.apache.sshd.sftp.common", + "org.apache.sshd.sftp.common.extensions", + "org.apache.sshd.sftp.common.extensions.openssh", + "org.apache.sshd.sftp.server" + ], + "org.asciidoctor:asciidoctorj": [ + "org.asciidoctor", + "org.asciidoctor.ast", + "org.asciidoctor.ast.impl", + "org.asciidoctor.cli", + "org.asciidoctor.converter", + "org.asciidoctor.converter.internal", + "org.asciidoctor.converter.spi", + "org.asciidoctor.extension", + "org.asciidoctor.extension.internal", + "org.asciidoctor.extension.spi", + "org.asciidoctor.internal", + "org.asciidoctor.log", + "org.asciidoctor.log.internal" + ], + "org.assertj:assertj-core": [ + "org.assertj.core.annotation", + "org.assertj.core.annotations", + "org.assertj.core.api", + "org.assertj.core.api.exception", + "org.assertj.core.api.filter", + "org.assertj.core.api.iterable", + "org.assertj.core.api.junit.jupiter", + "org.assertj.core.api.recursive", + "org.assertj.core.api.recursive.assertion", + "org.assertj.core.api.recursive.comparison", + "org.assertj.core.condition", + "org.assertj.core.configuration", + "org.assertj.core.data", + "org.assertj.core.description", + "org.assertj.core.error", + "org.assertj.core.error.array2d", + "org.assertj.core.error.future", + "org.assertj.core.error.uri", + "org.assertj.core.extractor", + "org.assertj.core.groups", + "org.assertj.core.internal", + "org.assertj.core.internal.annotation", + "org.assertj.core.matcher", + "org.assertj.core.presentation", + "org.assertj.core.util", + "org.assertj.core.util.diff", + "org.assertj.core.util.diff.myers", + "org.assertj.core.util.introspection", + "org.assertj.core.util.xml" + ], + "org.bouncycastle:bcpg-jdk18on": [ + "org.bouncycastle.apache.bzip2", + "org.bouncycastle.bcpg", + "org.bouncycastle.bcpg.attr", + "org.bouncycastle.bcpg.sig", + "org.bouncycastle.gpg", + "org.bouncycastle.gpg.keybox", + "org.bouncycastle.gpg.keybox.bc", + "org.bouncycastle.gpg.keybox.jcajce", + "org.bouncycastle.openpgp", + "org.bouncycastle.openpgp.api", + "org.bouncycastle.openpgp.api.bc", + "org.bouncycastle.openpgp.api.exception", + "org.bouncycastle.openpgp.api.jcajce", + "org.bouncycastle.openpgp.api.util", + "org.bouncycastle.openpgp.bc", + "org.bouncycastle.openpgp.examples", + "org.bouncycastle.openpgp.jcajce", + "org.bouncycastle.openpgp.operator", + "org.bouncycastle.openpgp.operator.bc", + "org.bouncycastle.openpgp.operator.jcajce" + ], + "org.bouncycastle:bcpkix-jdk18on": [ + "org.bouncycastle.cert", + "org.bouncycastle.cert.bc", + "org.bouncycastle.cert.cmp", + "org.bouncycastle.cert.crmf", + "org.bouncycastle.cert.crmf.bc", + "org.bouncycastle.cert.crmf.jcajce", + "org.bouncycastle.cert.dane", + "org.bouncycastle.cert.dane.fetcher", + "org.bouncycastle.cert.jcajce", + "org.bouncycastle.cert.ocsp", + "org.bouncycastle.cert.ocsp.jcajce", + "org.bouncycastle.cert.path", + "org.bouncycastle.cert.path.validations", + "org.bouncycastle.cert.selector", + "org.bouncycastle.cert.selector.jcajce", + "org.bouncycastle.cmc", + "org.bouncycastle.cms", + "org.bouncycastle.cms.bc", + "org.bouncycastle.cms.jcajce", + "org.bouncycastle.dvcs", + "org.bouncycastle.eac", + "org.bouncycastle.eac.jcajce", + "org.bouncycastle.eac.operator", + "org.bouncycastle.eac.operator.jcajce", + "org.bouncycastle.est", + "org.bouncycastle.est.jcajce", + "org.bouncycastle.its", + "org.bouncycastle.its.bc", + "org.bouncycastle.its.jcajce", + "org.bouncycastle.its.operator", + "org.bouncycastle.mime", + "org.bouncycastle.mime.encoding", + "org.bouncycastle.mime.smime", + "org.bouncycastle.mozilla", + "org.bouncycastle.mozilla.jcajce", + "org.bouncycastle.openssl", + "org.bouncycastle.openssl.bc", + "org.bouncycastle.openssl.jcajce", + "org.bouncycastle.operator", + "org.bouncycastle.operator.bc", + "org.bouncycastle.operator.jcajce", + "org.bouncycastle.pkcs", + "org.bouncycastle.pkcs.bc", + "org.bouncycastle.pkcs.jcajce", + "org.bouncycastle.pkix", + "org.bouncycastle.pkix.jcajce", + "org.bouncycastle.pkix.util", + "org.bouncycastle.pkix.util.filter", + "org.bouncycastle.tsp", + "org.bouncycastle.tsp.cms", + "org.bouncycastle.tsp.ers", + "org.bouncycastle.voms" + ], + "org.bouncycastle:bcprov-jdk18on": [ + "org.bouncycastle", + "org.bouncycastle.asn1", + "org.bouncycastle.asn1.anssi", + "org.bouncycastle.asn1.bc", + "org.bouncycastle.asn1.cryptopro", + "org.bouncycastle.asn1.gm", + "org.bouncycastle.asn1.nist", + "org.bouncycastle.asn1.ocsp", + "org.bouncycastle.asn1.pkcs", + "org.bouncycastle.asn1.sec", + "org.bouncycastle.asn1.teletrust", + "org.bouncycastle.asn1.ua", + "org.bouncycastle.asn1.util", + "org.bouncycastle.asn1.x500", + "org.bouncycastle.asn1.x500.style", + "org.bouncycastle.asn1.x509", + "org.bouncycastle.asn1.x509.qualified", + "org.bouncycastle.asn1.x509.sigi", + "org.bouncycastle.asn1.x9", + "org.bouncycastle.crypto", + "org.bouncycastle.crypto.agreement", + "org.bouncycastle.crypto.agreement.ecjpake", + "org.bouncycastle.crypto.agreement.jpake", + "org.bouncycastle.crypto.agreement.kdf", + "org.bouncycastle.crypto.agreement.srp", + "org.bouncycastle.crypto.commitments", + "org.bouncycastle.crypto.constraints", + "org.bouncycastle.crypto.digests", + "org.bouncycastle.crypto.ec", + "org.bouncycastle.crypto.encodings", + "org.bouncycastle.crypto.engines", + "org.bouncycastle.crypto.examples", + "org.bouncycastle.crypto.fpe", + "org.bouncycastle.crypto.generators", + "org.bouncycastle.crypto.hpke", + "org.bouncycastle.crypto.io", + "org.bouncycastle.crypto.kems", + "org.bouncycastle.crypto.macs", + "org.bouncycastle.crypto.modes", + "org.bouncycastle.crypto.modes.gcm", + "org.bouncycastle.crypto.modes.kgcm", + "org.bouncycastle.crypto.paddings", + "org.bouncycastle.crypto.params", + "org.bouncycastle.crypto.parsers", + "org.bouncycastle.crypto.prng", + "org.bouncycastle.crypto.prng.drbg", + "org.bouncycastle.crypto.signers", + "org.bouncycastle.crypto.threshold", + "org.bouncycastle.crypto.tls", + "org.bouncycastle.crypto.util", + "org.bouncycastle.i18n", + "org.bouncycastle.i18n.filter", + "org.bouncycastle.iana", + "org.bouncycastle.internal.asn1.bsi", + "org.bouncycastle.internal.asn1.cms", + "org.bouncycastle.internal.asn1.cryptlib", + "org.bouncycastle.internal.asn1.eac", + "org.bouncycastle.internal.asn1.edec", + "org.bouncycastle.internal.asn1.gnu", + "org.bouncycastle.internal.asn1.iana", + "org.bouncycastle.internal.asn1.isara", + "org.bouncycastle.internal.asn1.isismtt", + "org.bouncycastle.internal.asn1.iso", + "org.bouncycastle.internal.asn1.kisa", + "org.bouncycastle.internal.asn1.microsoft", + "org.bouncycastle.internal.asn1.misc", + "org.bouncycastle.internal.asn1.nsri", + "org.bouncycastle.internal.asn1.ntt", + "org.bouncycastle.internal.asn1.oiw", + "org.bouncycastle.internal.asn1.rosstandart", + "org.bouncycastle.jcajce", + "org.bouncycastle.jcajce.interfaces", + "org.bouncycastle.jcajce.io", + "org.bouncycastle.jcajce.provider.asymmetric", + "org.bouncycastle.jcajce.provider.asymmetric.compositesignatures", + "org.bouncycastle.jcajce.provider.asymmetric.dh", + "org.bouncycastle.jcajce.provider.asymmetric.dsa", + "org.bouncycastle.jcajce.provider.asymmetric.dstu", + "org.bouncycastle.jcajce.provider.asymmetric.ec", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost", + "org.bouncycastle.jcajce.provider.asymmetric.ecgost12", + "org.bouncycastle.jcajce.provider.asymmetric.edec", + "org.bouncycastle.jcajce.provider.asymmetric.elgamal", + "org.bouncycastle.jcajce.provider.asymmetric.gost", + "org.bouncycastle.jcajce.provider.asymmetric.ies", + "org.bouncycastle.jcajce.provider.asymmetric.mldsa", + "org.bouncycastle.jcajce.provider.asymmetric.mlkem", + "org.bouncycastle.jcajce.provider.asymmetric.rsa", + "org.bouncycastle.jcajce.provider.asymmetric.slhdsa", + "org.bouncycastle.jcajce.provider.asymmetric.util", + "org.bouncycastle.jcajce.provider.asymmetric.x509", + "org.bouncycastle.jcajce.provider.config", + "org.bouncycastle.jcajce.provider.digest", + "org.bouncycastle.jcajce.provider.drbg", + "org.bouncycastle.jcajce.provider.kdf", + "org.bouncycastle.jcajce.provider.kdf.hkdf", + "org.bouncycastle.jcajce.provider.kdf.pbepbkdf2", + "org.bouncycastle.jcajce.provider.kdf.scrypt", + "org.bouncycastle.jcajce.provider.keystore", + "org.bouncycastle.jcajce.provider.keystore.bc", + "org.bouncycastle.jcajce.provider.keystore.bcfks", + "org.bouncycastle.jcajce.provider.keystore.pkcs12", + "org.bouncycastle.jcajce.provider.keystore.util", + "org.bouncycastle.jcajce.provider.symmetric", + "org.bouncycastle.jcajce.provider.symmetric.util", + "org.bouncycastle.jcajce.provider.util", + "org.bouncycastle.jcajce.spec", + "org.bouncycastle.jcajce.util", + "org.bouncycastle.jce", + "org.bouncycastle.jce.exception", + "org.bouncycastle.jce.interfaces", + "org.bouncycastle.jce.netscape", + "org.bouncycastle.jce.provider", + "org.bouncycastle.jce.spec", + "org.bouncycastle.math", + "org.bouncycastle.math.ec", + "org.bouncycastle.math.ec.custom.djb", + "org.bouncycastle.math.ec.custom.gm", + "org.bouncycastle.math.ec.custom.sec", + "org.bouncycastle.math.ec.endo", + "org.bouncycastle.math.ec.rfc7748", + "org.bouncycastle.math.ec.rfc8032", + "org.bouncycastle.math.ec.tools", + "org.bouncycastle.math.field", + "org.bouncycastle.math.raw", + "org.bouncycastle.pqc.asn1", + "org.bouncycastle.pqc.crypto", + "org.bouncycastle.pqc.crypto.bike", + "org.bouncycastle.pqc.crypto.cmce", + "org.bouncycastle.pqc.crypto.crystals.dilithium", + "org.bouncycastle.pqc.crypto.falcon", + "org.bouncycastle.pqc.crypto.frodo", + "org.bouncycastle.pqc.crypto.hqc", + "org.bouncycastle.pqc.crypto.lms", + "org.bouncycastle.pqc.crypto.mayo", + "org.bouncycastle.pqc.crypto.mldsa", + "org.bouncycastle.pqc.crypto.mlkem", + "org.bouncycastle.pqc.crypto.newhope", + "org.bouncycastle.pqc.crypto.ntru", + "org.bouncycastle.pqc.crypto.ntruprime", + "org.bouncycastle.pqc.crypto.picnic", + "org.bouncycastle.pqc.crypto.rainbow", + "org.bouncycastle.pqc.crypto.saber", + "org.bouncycastle.pqc.crypto.slhdsa", + "org.bouncycastle.pqc.crypto.snova", + "org.bouncycastle.pqc.crypto.sphincs", + "org.bouncycastle.pqc.crypto.sphincsplus", + "org.bouncycastle.pqc.crypto.util", + "org.bouncycastle.pqc.crypto.xmss", + "org.bouncycastle.pqc.crypto.xwing", + "org.bouncycastle.pqc.jcajce.interfaces", + "org.bouncycastle.pqc.jcajce.provider", + "org.bouncycastle.pqc.jcajce.provider.bike", + "org.bouncycastle.pqc.jcajce.provider.cmce", + "org.bouncycastle.pqc.jcajce.provider.dilithium", + "org.bouncycastle.pqc.jcajce.provider.falcon", + "org.bouncycastle.pqc.jcajce.provider.frodo", + "org.bouncycastle.pqc.jcajce.provider.hqc", + "org.bouncycastle.pqc.jcajce.provider.kyber", + "org.bouncycastle.pqc.jcajce.provider.lms", + "org.bouncycastle.pqc.jcajce.provider.mayo", + "org.bouncycastle.pqc.jcajce.provider.newhope", + "org.bouncycastle.pqc.jcajce.provider.ntru", + "org.bouncycastle.pqc.jcajce.provider.ntruprime", + "org.bouncycastle.pqc.jcajce.provider.picnic", + "org.bouncycastle.pqc.jcajce.provider.saber", + "org.bouncycastle.pqc.jcajce.provider.snova", + "org.bouncycastle.pqc.jcajce.provider.sphincs", + "org.bouncycastle.pqc.jcajce.provider.sphincsplus", + "org.bouncycastle.pqc.jcajce.provider.util", + "org.bouncycastle.pqc.jcajce.provider.xmss", + "org.bouncycastle.pqc.jcajce.spec", + "org.bouncycastle.pqc.math.ntru", + "org.bouncycastle.pqc.math.ntru.parameters", + "org.bouncycastle.util", + "org.bouncycastle.util.encoders", + "org.bouncycastle.util.io", + "org.bouncycastle.util.io.pem", + "org.bouncycastle.util.test", + "org.bouncycastle.x509", + "org.bouncycastle.x509.extension", + "org.bouncycastle.x509.util" + ], + "org.bouncycastle:bcutil-jdk18on": [ + "org.bouncycastle.asn1.bsi", + "org.bouncycastle.asn1.cmc", + "org.bouncycastle.asn1.cmp", + "org.bouncycastle.asn1.cms", + "org.bouncycastle.asn1.cms.ecc", + "org.bouncycastle.asn1.crmf", + "org.bouncycastle.asn1.cryptlib", + "org.bouncycastle.asn1.dvcs", + "org.bouncycastle.asn1.eac", + "org.bouncycastle.asn1.edec", + "org.bouncycastle.asn1.esf", + "org.bouncycastle.asn1.ess", + "org.bouncycastle.asn1.est", + "org.bouncycastle.asn1.gnu", + "org.bouncycastle.asn1.iana", + "org.bouncycastle.asn1.icao", + "org.bouncycastle.asn1.isara", + "org.bouncycastle.asn1.isismtt", + "org.bouncycastle.asn1.isismtt.ocsp", + "org.bouncycastle.asn1.isismtt.x509", + "org.bouncycastle.asn1.iso", + "org.bouncycastle.asn1.kisa", + "org.bouncycastle.asn1.microsoft", + "org.bouncycastle.asn1.misc", + "org.bouncycastle.asn1.mod", + "org.bouncycastle.asn1.mozilla", + "org.bouncycastle.asn1.nsri", + "org.bouncycastle.asn1.ntt", + "org.bouncycastle.asn1.oiw", + "org.bouncycastle.asn1.rosstandart", + "org.bouncycastle.asn1.smime", + "org.bouncycastle.asn1.tsp", + "org.bouncycastle.oer", + "org.bouncycastle.oer.its", + "org.bouncycastle.oer.its.etsi102941", + "org.bouncycastle.oer.its.etsi102941.basetypes", + "org.bouncycastle.oer.its.etsi103097", + "org.bouncycastle.oer.its.etsi103097.extension", + "org.bouncycastle.oer.its.ieee1609dot2", + "org.bouncycastle.oer.its.ieee1609dot2.basetypes", + "org.bouncycastle.oer.its.ieee1609dot2dot1", + "org.bouncycastle.oer.its.template.etsi102941", + "org.bouncycastle.oer.its.template.etsi102941.basetypes", + "org.bouncycastle.oer.its.template.etsi103097", + "org.bouncycastle.oer.its.template.etsi103097.extension", + "org.bouncycastle.oer.its.template.ieee1609dot2", + "org.bouncycastle.oer.its.template.ieee1609dot2.basetypes", + "org.bouncycastle.oer.its.template.ieee1609dot2dot1" + ], + "org.checkerframework:checker-compat-qual": [ + "org.checkerframework.checker.nullness.compatqual" + ], + "org.checkerframework:checker-qual": [ + "org.checkerframework.checker.builder.qual", + "org.checkerframework.checker.calledmethods.qual", + "org.checkerframework.checker.compilermsgs.qual", + "org.checkerframework.checker.fenum.qual", + "org.checkerframework.checker.formatter.qual", + "org.checkerframework.checker.guieffect.qual", + "org.checkerframework.checker.i18n.qual", + "org.checkerframework.checker.i18nformatter.qual", + "org.checkerframework.checker.index.qual", + "org.checkerframework.checker.initialization.qual", + "org.checkerframework.checker.interning.qual", + "org.checkerframework.checker.lock.qual", + "org.checkerframework.checker.nullness.qual", + "org.checkerframework.checker.optional.qual", + "org.checkerframework.checker.propkey.qual", + "org.checkerframework.checker.regex.qual", + "org.checkerframework.checker.signature.qual", + "org.checkerframework.checker.signedness.qual", + "org.checkerframework.checker.tainting.qual", + "org.checkerframework.checker.units.qual", + "org.checkerframework.common.aliasing.qual", + "org.checkerframework.common.initializedfields.qual", + "org.checkerframework.common.reflection.qual", + "org.checkerframework.common.returnsreceiver.qual", + "org.checkerframework.common.subtyping.qual", + "org.checkerframework.common.util.report.qual", + "org.checkerframework.common.value.qual", + "org.checkerframework.dataflow.qual", + "org.checkerframework.framework.qual" + ], + "org.commonmark:commonmark": [ + "org.commonmark", + "org.commonmark.internal", + "org.commonmark.internal.inline", + "org.commonmark.internal.renderer", + "org.commonmark.internal.renderer.text", + "org.commonmark.internal.util", + "org.commonmark.node", + "org.commonmark.parser", + "org.commonmark.parser.beta", + "org.commonmark.parser.block", + "org.commonmark.parser.delimiter", + "org.commonmark.renderer", + "org.commonmark.renderer.html", + "org.commonmark.renderer.markdown", + "org.commonmark.renderer.text", + "org.commonmark.text" + ], + "org.commonmark:commonmark-ext-autolink": [ + "org.commonmark.ext.autolink", + "org.commonmark.ext.autolink.internal" + ], + "org.commonmark:commonmark-ext-gfm-strikethrough": [ + "org.commonmark.ext.gfm.strikethrough", + "org.commonmark.ext.gfm.strikethrough.internal" + ], + "org.commonmark:commonmark-ext-gfm-tables": [ + "org.commonmark.ext.gfm.tables", + "org.commonmark.ext.gfm.tables.internal" + ], + "org.eclipse.jetty.ee8:jetty-ee8-nested": [ + "org.eclipse.jetty.ee8.nested", + "org.eclipse.jetty.ee8.nested.jmx" + ], + "org.eclipse.jetty.ee8:jetty-ee8-security": [ + "org.eclipse.jetty.ee8.security", + "org.eclipse.jetty.ee8.security.authentication" + ], + "org.eclipse.jetty.ee8:jetty-ee8-servlet": [ + "org.eclipse.jetty.ee8.servlet", + "org.eclipse.jetty.ee8.servlet.jmx", + "org.eclipse.jetty.ee8.servlet.listener" + ], + "org.eclipse.jetty.toolchain:jetty-servlet-api": [ + "javax.servlet", + "javax.servlet.annotation", + "javax.servlet.descriptor", + "javax.servlet.http" + ], + "org.eclipse.jetty:jetty-http": [ + "org.eclipse.jetty.http", + "org.eclipse.jetty.http.compression", + "org.eclipse.jetty.http.pathmap" + ], + "org.eclipse.jetty:jetty-io": [ + "org.eclipse.jetty.io", + "org.eclipse.jetty.io.jmx", + "org.eclipse.jetty.io.ssl" + ], + "org.eclipse.jetty:jetty-jmx": [ + "org.eclipse.jetty.jmx", + "org.eclipse.jetty.util.log.jmx" + ], + "org.eclipse.jetty:jetty-security": [ + "org.eclipse.jetty.security", + "org.eclipse.jetty.security.authentication" + ], + "org.eclipse.jetty:jetty-server": [ + "org.eclipse.jetty.server", + "org.eclipse.jetty.server.handler", + "org.eclipse.jetty.server.handler.gzip", + "org.eclipse.jetty.server.handler.jmx", + "org.eclipse.jetty.server.jmx", + "org.eclipse.jetty.server.nio", + "org.eclipse.jetty.server.resource", + "org.eclipse.jetty.server.session" + ], + "org.eclipse.jetty:jetty-servlet": [ + "org.eclipse.jetty.servlet", + "org.eclipse.jetty.servlet.jmx", + "org.eclipse.jetty.servlet.listener" + ], + "org.eclipse.jetty:jetty-session": [ + "org.eclipse.jetty.session" + ], + "org.eclipse.jetty:jetty-util": [ + "org.eclipse.jetty.util", + "org.eclipse.jetty.util.annotation", + "org.eclipse.jetty.util.component", + "org.eclipse.jetty.util.compression", + "org.eclipse.jetty.util.log", + "org.eclipse.jetty.util.preventers", + "org.eclipse.jetty.util.resource", + "org.eclipse.jetty.util.security", + "org.eclipse.jetty.util.ssl", + "org.eclipse.jetty.util.statistic", + "org.eclipse.jetty.util.thread", + "org.eclipse.jetty.util.thread.strategy" + ], + "org.eclipse.jetty:jetty-util-ajax": [ + "org.eclipse.jetty.util.ajax" + ], + "org.hamcrest:hamcrest": [ + "org.hamcrest", + "org.hamcrest.beans", + "org.hamcrest.collection", + "org.hamcrest.comparator", + "org.hamcrest.core", + "org.hamcrest.internal", + "org.hamcrest.io", + "org.hamcrest.number", + "org.hamcrest.object", + "org.hamcrest.text", + "org.hamcrest.xml" + ], + "org.hamcrest:hamcrest-core": [ + "org.hamcrest", + "org.hamcrest.core", + "org.hamcrest.internal" + ], + "org.jruby:jruby-complete": [ + "com.headius.invokebinder", + "com.headius.invokebinder.transform", + "com.headius.modulator", + "com.headius.modulator.impl", + "com.headius.options", + "com.headius.options.example", + "com.headius.unsafe.fences", + "com.jcraft.jzlib", + "com.kenai.constantine", + "com.kenai.constantine.platform", + "com.kenai.jffi", + "com.kenai.jffi.internal", + "com.kenai.jnr.x86asm", + "com.martiansoftware.nailgun", + "com.martiansoftware.nailgun.builtins", + "jnr.constants", + "jnr.constants.platform", + "jnr.constants.platform.darwin", + "jnr.constants.platform.fake", + "jnr.constants.platform.freebsd", + "jnr.constants.platform.linux", + "jnr.constants.platform.openbsd", + "jnr.constants.platform.solaris", + "jnr.constants.platform.windows", + "jnr.enxio.channels", + "jnr.ffi", + "jnr.ffi.annotations", + "jnr.ffi.byref", + "jnr.ffi.mapper", + "jnr.ffi.provider", + "jnr.ffi.provider.converters", + "jnr.ffi.provider.jffi", + "jnr.ffi.provider.jffi.platform.aarch64.linux", + "jnr.ffi.provider.jffi.platform.arm.linux", + "jnr.ffi.provider.jffi.platform.i386.darwin", + "jnr.ffi.provider.jffi.platform.i386.freebsd", + "jnr.ffi.provider.jffi.platform.i386.linux", + "jnr.ffi.provider.jffi.platform.i386.openbsd", + "jnr.ffi.provider.jffi.platform.i386.solaris", + "jnr.ffi.provider.jffi.platform.i386.windows", + "jnr.ffi.provider.jffi.platform.mips.linux", + "jnr.ffi.provider.jffi.platform.mipsel.linux", + "jnr.ffi.provider.jffi.platform.ppc.aix", + "jnr.ffi.provider.jffi.platform.ppc.darwin", + "jnr.ffi.provider.jffi.platform.ppc.linux", + "jnr.ffi.provider.jffi.platform.ppc64.linux", + "jnr.ffi.provider.jffi.platform.ppc64le.linux", + "jnr.ffi.provider.jffi.platform.s390.linux", + "jnr.ffi.provider.jffi.platform.s390x.linux", + "jnr.ffi.provider.jffi.platform.sparc.solaris", + "jnr.ffi.provider.jffi.platform.sparcv9.linux", + "jnr.ffi.provider.jffi.platform.sparcv9.solaris", + "jnr.ffi.provider.jffi.platform.x86_64.darwin", + "jnr.ffi.provider.jffi.platform.x86_64.freebsd", + "jnr.ffi.provider.jffi.platform.x86_64.linux", + "jnr.ffi.provider.jffi.platform.x86_64.openbsd", + "jnr.ffi.provider.jffi.platform.x86_64.solaris", + "jnr.ffi.provider.jffi.platform.x86_64.windows", + "jnr.ffi.types", + "jnr.ffi.util", + "jnr.ffi.util.ref", + "jnr.ffi.util.ref.internal", + "jnr.netdb", + "jnr.posix", + "jnr.posix.util", + "jnr.posix.windows", + "jnr.unixsocket", + "jnr.x86asm", + "org.jcodings", + "org.jcodings.ascii", + "org.jcodings.constants", + "org.jcodings.exception", + "org.jcodings.specific", + "org.jcodings.spi", + "org.jcodings.transcode", + "org.jcodings.transcode.specific", + "org.jcodings.unicode", + "org.jcodings.util", + "org.joda.time", + "org.joda.time.base", + "org.joda.time.chrono", + "org.joda.time.convert", + "org.joda.time.field", + "org.joda.time.format", + "org.joda.time.tz", + "org.joni", + "org.joni.ast", + "org.joni.bench", + "org.joni.constants", + "org.joni.exception", + "org.jruby", + "org.jruby.anno", + "org.jruby.ant", + "org.jruby.api", + "org.jruby.ast", + "org.jruby.ast.executable", + "org.jruby.ast.java_signature", + "org.jruby.ast.types", + "org.jruby.ast.util", + "org.jruby.ast.visitor", + "org.jruby.common", + "org.jruby.compiler", + "org.jruby.compiler.impl", + "org.jruby.compiler.util", + "org.jruby.dirgra", + "org.jruby.embed", + "org.jruby.embed.bsf", + "org.jruby.embed.internal", + "org.jruby.embed.io", + "org.jruby.embed.jsr223", + "org.jruby.embed.osgi", + "org.jruby.embed.osgi.internal", + "org.jruby.embed.osgi.utils", + "org.jruby.embed.util", + "org.jruby.embed.variable", + "org.jruby.exceptions", + "org.jruby.ext", + "org.jruby.ext.bigdecimal", + "org.jruby.ext.cgi.escape", + "org.jruby.ext.coverage", + "org.jruby.ext.digest", + "org.jruby.ext.etc", + "org.jruby.ext.fcntl", + "org.jruby.ext.ffi", + "org.jruby.ext.ffi.io", + "org.jruby.ext.ffi.jffi", + "org.jruby.ext.fiber", + "org.jruby.ext.io.wait", + "org.jruby.ext.jruby", + "org.jruby.ext.mathn", + "org.jruby.ext.net.protocol", + "org.jruby.ext.nkf", + "org.jruby.ext.pathname", + "org.jruby.ext.rbconfig", + "org.jruby.ext.ripper", + "org.jruby.ext.securerandom", + "org.jruby.ext.socket", + "org.jruby.ext.stringio", + "org.jruby.ext.strscan", + "org.jruby.ext.tempfile", + "org.jruby.ext.thread", + "org.jruby.ext.timeout", + "org.jruby.ext.tracepoint", + "org.jruby.ext.zlib", + "org.jruby.internal.runtime", + "org.jruby.internal.runtime.methods", + "org.jruby.ir", + "org.jruby.ir.dataflow", + "org.jruby.ir.dataflow.analyses", + "org.jruby.ir.instructions", + "org.jruby.ir.instructions.boxing", + "org.jruby.ir.instructions.defined", + "org.jruby.ir.instructions.specialized", + "org.jruby.ir.interpreter", + "org.jruby.ir.listeners", + "org.jruby.ir.operands", + "org.jruby.ir.passes", + "org.jruby.ir.persistence", + "org.jruby.ir.persistence.util", + "org.jruby.ir.representations", + "org.jruby.ir.runtime", + "org.jruby.ir.targets", + "org.jruby.ir.transformations.inlining", + "org.jruby.ir.util", + "org.jruby.java.addons", + "org.jruby.java.codegen", + "org.jruby.java.dispatch", + "org.jruby.java.invokers", + "org.jruby.java.proxies", + "org.jruby.java.util", + "org.jruby.javasupport", + "org.jruby.javasupport.binding", + "org.jruby.javasupport.bsf", + "org.jruby.javasupport.ext", + "org.jruby.javasupport.proxy", + "org.jruby.javasupport.util", + "org.jruby.lexer", + "org.jruby.lexer.yacc", + "org.jruby.main", + "org.jruby.management", + "org.jruby.me.qmx.jitescript", + "org.jruby.me.qmx.jitescript.util", + "org.jruby.org.objectweb.asm", + "org.jruby.org.objectweb.asm.commons", + "org.jruby.org.objectweb.asm.signature", + "org.jruby.org.objectweb.asm.tree", + "org.jruby.org.objectweb.asm.tree.analysis", + "org.jruby.org.objectweb.asm.util", + "org.jruby.parser", + "org.jruby.platform", + "org.jruby.runtime", + "org.jruby.runtime.backtrace", + "org.jruby.runtime.builtin", + "org.jruby.runtime.callback", + "org.jruby.runtime.callsite", + "org.jruby.runtime.component", + "org.jruby.runtime.encoding", + "org.jruby.runtime.invokedynamic", + "org.jruby.runtime.ivars", + "org.jruby.runtime.load", + "org.jruby.runtime.marshal", + "org.jruby.runtime.opto", + "org.jruby.runtime.profile", + "org.jruby.runtime.profile.builtin", + "org.jruby.runtime.scope", + "org.jruby.specialized", + "org.jruby.threading", + "org.jruby.util", + "org.jruby.util.cli", + "org.jruby.util.collections", + "org.jruby.util.encoding", + "org.jruby.util.func", + "org.jruby.util.io", + "org.jruby.util.log", + "org.jruby.util.unsafe" + ], + "org.jsoup:jsoup": [ + "org.jsoup", + "org.jsoup.helper", + "org.jsoup.internal", + "org.jsoup.nodes", + "org.jsoup.parser", + "org.jsoup.safety", + "org.jsoup.select" + ], + "org.jspecify:jspecify": [ + "org.jspecify.annotations" + ], + "org.mockito:mockito-core": [ + "org.mockito", + "org.mockito.configuration", + "org.mockito.creation.instance", + "org.mockito.exceptions.base", + "org.mockito.exceptions.misusing", + "org.mockito.exceptions.stacktrace", + "org.mockito.exceptions.verification", + "org.mockito.exceptions.verification.junit", + "org.mockito.exceptions.verification.opentest4j", + "org.mockito.hamcrest", + "org.mockito.internal", + "org.mockito.internal.configuration", + "org.mockito.internal.configuration.injection", + "org.mockito.internal.configuration.injection.filter", + "org.mockito.internal.configuration.injection.scanner", + "org.mockito.internal.configuration.plugins", + "org.mockito.internal.creation", + "org.mockito.internal.creation.bytebuddy", + "org.mockito.internal.creation.bytebuddy.access", + "org.mockito.internal.creation.bytebuddy.codegen", + "org.mockito.internal.creation.instance", + "org.mockito.internal.creation.proxy", + "org.mockito.internal.creation.settings", + "org.mockito.internal.creation.util", + "org.mockito.internal.debugging", + "org.mockito.internal.exceptions", + "org.mockito.internal.exceptions.stacktrace", + "org.mockito.internal.exceptions.util", + "org.mockito.internal.framework", + "org.mockito.internal.hamcrest", + "org.mockito.internal.handler", + "org.mockito.internal.invocation", + "org.mockito.internal.invocation.finder", + "org.mockito.internal.invocation.mockref", + "org.mockito.internal.junit", + "org.mockito.internal.listeners", + "org.mockito.internal.matchers", + "org.mockito.internal.matchers.apachecommons", + "org.mockito.internal.matchers.text", + "org.mockito.internal.progress", + "org.mockito.internal.reporting", + "org.mockito.internal.runners", + "org.mockito.internal.runners.util", + "org.mockito.internal.session", + "org.mockito.internal.stubbing", + "org.mockito.internal.stubbing.answers", + "org.mockito.internal.stubbing.defaultanswers", + "org.mockito.internal.util", + "org.mockito.internal.util.collections", + "org.mockito.internal.util.concurrent", + "org.mockito.internal.util.io", + "org.mockito.internal.util.reflection", + "org.mockito.internal.verification", + "org.mockito.internal.verification.api", + "org.mockito.internal.verification.argumentmatching", + "org.mockito.internal.verification.checkers", + "org.mockito.invocation", + "org.mockito.junit", + "org.mockito.listeners", + "org.mockito.mock", + "org.mockito.plugins", + "org.mockito.quality", + "org.mockito.session", + "org.mockito.stubbing", + "org.mockito.verification" + ], + "org.nibor.autolink:autolink": [ + "org.nibor.autolink", + "org.nibor.autolink.internal" + ], + "org.objenesis:objenesis": [ + "org.objenesis", + "org.objenesis.instantiator", + "org.objenesis.instantiator.android", + "org.objenesis.instantiator.annotations", + "org.objenesis.instantiator.basic", + "org.objenesis.instantiator.gcj", + "org.objenesis.instantiator.perc", + "org.objenesis.instantiator.sun", + "org.objenesis.instantiator.util", + "org.objenesis.strategy" + ], + "org.openid4java:openid4java": [ + "org.openid4java", + "org.openid4java.association", + "org.openid4java.consumer", + "org.openid4java.discovery", + "org.openid4java.discovery.html", + "org.openid4java.discovery.xrds", + "org.openid4java.discovery.xri", + "org.openid4java.discovery.yadis", + "org.openid4java.message", + "org.openid4java.message.ax", + "org.openid4java.message.pape", + "org.openid4java.message.sreg", + "org.openid4java.server", + "org.openid4java.util" + ], + "org.openjdk.jmh:jmh-core": [ + "org.openjdk.jmh", + "org.openjdk.jmh.annotations", + "org.openjdk.jmh.generators.core", + "org.openjdk.jmh.infra", + "org.openjdk.jmh.profile", + "org.openjdk.jmh.results", + "org.openjdk.jmh.results.format", + "org.openjdk.jmh.runner", + "org.openjdk.jmh.runner.format", + "org.openjdk.jmh.runner.link", + "org.openjdk.jmh.runner.options", + "org.openjdk.jmh.util", + "org.openjdk.jmh.util.lines" + ], + "org.openjdk.jmh:jmh-generator-annprocess": [ + "org.openjdk.jmh.generators", + "org.openjdk.jmh.generators.annotations" + ], + "org.ow2.asm:asm": [ + "org.objectweb.asm", + "org.objectweb.asm.signature" + ], + "org.ow2.asm:asm-analysis": [ + "org.objectweb.asm.tree.analysis" + ], + "org.ow2.asm:asm-commons": [ + "org.objectweb.asm.commons" + ], + "org.ow2.asm:asm-tree": [ + "org.objectweb.asm.tree" + ], + "org.ow2.asm:asm-util": [ + "org.objectweb.asm.util" + ], + "org.roaringbitmap:RoaringBitmap": [ + "org.roaringbitmap", + "org.roaringbitmap.art", + "org.roaringbitmap.buffer", + "org.roaringbitmap.insights", + "org.roaringbitmap.longlong" + ], + "org.roaringbitmap:shims": [ + "org.roaringbitmap" + ], + "org.slf4j:jcl-over-slf4j": [ + "org.apache.commons.logging", + "org.apache.commons.logging.impl" + ], + "org.slf4j:slf4j-api": [ + "org.slf4j", + "org.slf4j.event", + "org.slf4j.helpers", + "org.slf4j.spi" + ], + "org.slf4j:slf4j-ext": [ + "org.slf4j", + "org.slf4j.agent", + "org.slf4j.cal10n", + "org.slf4j.ext", + "org.slf4j.instrumentation", + "org.slf4j.profiler" + ], + "org.slf4j:slf4j-reload4j": [ + "org.slf4j.reload4j" + ], + "org.slf4j:slf4j-simple": [ + "org.slf4j.simple" + ], + "org.tukaani:xz": [ + "org.tukaani.xz", + "org.tukaani.xz.check", + "org.tukaani.xz.common", + "org.tukaani.xz.delta", + "org.tukaani.xz.index", + "org.tukaani.xz.lz", + "org.tukaani.xz.lzma", + "org.tukaani.xz.rangecoder", + "org.tukaani.xz.simple" + ], + "xerces:xercesImpl": [ + "org.apache.html.dom", + "org.apache.wml", + "org.apache.wml.dom", + "org.apache.xerces.dom", + "org.apache.xerces.dom.events", + "org.apache.xerces.dom3.as", + "org.apache.xerces.impl", + "org.apache.xerces.impl.dtd", + "org.apache.xerces.impl.dtd.models", + "org.apache.xerces.impl.dv", + "org.apache.xerces.impl.dv.dtd", + "org.apache.xerces.impl.dv.util", + "org.apache.xerces.impl.dv.xs", + "org.apache.xerces.impl.io", + "org.apache.xerces.impl.msg", + "org.apache.xerces.impl.validation", + "org.apache.xerces.impl.xpath", + "org.apache.xerces.impl.xpath.regex", + "org.apache.xerces.impl.xs", + "org.apache.xerces.impl.xs.identity", + "org.apache.xerces.impl.xs.models", + "org.apache.xerces.impl.xs.opti", + "org.apache.xerces.impl.xs.traversers", + "org.apache.xerces.impl.xs.util", + "org.apache.xerces.jaxp", + "org.apache.xerces.jaxp.datatype", + "org.apache.xerces.jaxp.validation", + "org.apache.xerces.parsers", + "org.apache.xerces.stax", + "org.apache.xerces.stax.events", + "org.apache.xerces.util", + "org.apache.xerces.xinclude", + "org.apache.xerces.xni", + "org.apache.xerces.xni.grammars", + "org.apache.xerces.xni.parser", + "org.apache.xerces.xpointer", + "org.apache.xerces.xs", + "org.apache.xerces.xs.datatypes", + "org.apache.xml.serialize", + "org.w3c.dom.html" + ] + }, + "repositories": { + "https://repo1.maven.org/maven2/": [ + "antlr:antlr", + "aopalliance:aopalliance", + "aopalliance:aopalliance:jar:sources", + "args4j:args4j", + "args4j:args4j:jar:sources", + "ch.qos.reload4j:reload4j", + "ch.qos.reload4j:reload4j:jar:sources", + "com.beust:jcommander", + "com.beust:jcommander:jar:sources", + "com.github.ben-manes.caffeine:caffeine", + "com.github.ben-manes.caffeine:caffeine:jar:sources", + "com.github.ben-manes.caffeine:guava", + "com.github.ben-manes.caffeine:guava:jar:sources", + "com.github.rholder:guava-retrying", + "com.github.rholder:guava-retrying:jar:sources", + "com.google.auto.factory:auto-factory", + "com.google.auto.factory:auto-factory:jar:sources", + "com.google.auto.service:auto-service-annotations", + "com.google.auto.service:auto-service-annotations:jar:sources", + "com.google.auto.value:auto-value", + "com.google.auto.value:auto-value-annotations", + "com.google.auto.value:auto-value-annotations:jar:sources", + "com.google.auto.value:auto-value:jar:sources", + "com.google.auto:auto-common", + "com.google.auto:auto-common:jar:sources", + "com.google.code.findbugs:jsr305", + "com.google.code.findbugs:jsr305:jar:sources", + "com.google.code.gson:gson", + "com.google.code.gson:gson:jar:sources", + "com.google.common.html.types:types", + "com.google.common.html.types:types:jar:sources", + "com.google.errorprone:error_prone_annotations", + "com.google.errorprone:error_prone_annotations:jar:sources", + "com.google.flogger:flogger", + "com.google.flogger:flogger-log4j-backend", + "com.google.flogger:flogger-log4j-backend:jar:sources", + "com.google.flogger:flogger-system-backend", + "com.google.flogger:flogger-system-backend:jar:sources", + "com.google.flogger:flogger:jar:sources", + "com.google.flogger:google-extensions", + "com.google.flogger:google-extensions:jar:sources", + "com.google.gitiles:blame-cache", + "com.google.gitiles:blame-cache:jar:sources", + "com.google.gitiles:gitiles-servlet", + "com.google.gitiles:gitiles-servlet:jar:sources", + "com.google.guava:failureaccess", + "com.google.guava:failureaccess:jar:sources", + "com.google.guava:guava", + "com.google.guava:guava-testlib", + "com.google.guava:guava-testlib:jar:sources", + "com.google.guava:guava:jar:sources", + "com.google.guava:listenablefuture", + "com.google.inject.extensions:guice-assistedinject", + "com.google.inject.extensions:guice-assistedinject:jar:sources", + "com.google.inject.extensions:guice-servlet", + "com.google.inject.extensions:guice-servlet:jar:sources", + "com.google.inject:guice", + "com.google.inject:guice:jar:sources", + "com.google.j2objc:j2objc-annotations", + "com.google.j2objc:j2objc-annotations:jar:sources", + "com.google.jimfs:jimfs", + "com.google.jimfs:jimfs:jar:sources", + "com.google.jsinterop:jsinterop-annotations", + "com.google.jsinterop:jsinterop-annotations:jar:sources", + "com.google.protobuf:protobuf-java", + "com.google.protobuf:protobuf-java:jar:sources", + "com.google.template:soy", + "com.google.template:soy:jar:sources", + "com.google.truth.extensions:truth-java8-extension", + "com.google.truth.extensions:truth-java8-extension:jar:sources", + "com.google.truth.extensions:truth-liteproto-extension", + "com.google.truth.extensions:truth-liteproto-extension:jar:sources", + "com.google.truth.extensions:truth-proto-extension", + "com.google.truth.extensions:truth-proto-extension:jar:sources", + "com.google.truth:truth", + "com.google.truth:truth:jar:sources", + "com.googlecode.javaewah:JavaEWAH", + "com.googlecode.javaewah:JavaEWAH:jar:sources", + "com.googlecode.prolog-cafe:prolog-cafeteria", + "com.googlecode.prolog-cafe:prolog-cafeteria:jar:sources", + "com.googlecode.prolog-cafe:prolog-compiler", + "com.googlecode.prolog-cafe:prolog-compiler:jar:sources", + "com.googlecode.prolog-cafe:prolog-io", + "com.googlecode.prolog-cafe:prolog-io:jar:sources", + "com.googlecode.prolog-cafe:prolog-runtime", + "com.googlecode.prolog-cafe:prolog-runtime:jar:sources", + "com.h2database:h2", + "com.h2database:h2:jar:sources", + "com.ibm.icu:icu4j", + "com.ibm.icu:icu4j:jar:sources", + "com.icegreen:greenmail", + "com.icegreen:greenmail:jar:sources", + "com.jcraft:jsch", + "com.jcraft:jsch:jar:sources", + "com.jcraft:jzlib", + "com.jcraft:jzlib:jar:sources", + "com.ryanharter.auto.value:auto-value-gson-extension", + "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources", + "com.ryanharter.auto.value:auto-value-gson-factory", + "com.ryanharter.auto.value:auto-value-gson-factory:jar:sources", + "com.ryanharter.auto.value:auto-value-gson-runtime", + "com.ryanharter.auto.value:auto-value-gson-runtime:jar:sources", + "com.squareup:javapoet", + "com.squareup:javapoet:jar:sources", + "com.sun.mail:javax.mail", + "com.sun.mail:javax.mail:jar:sources", + "com.vladsch.flexmark:flexmark-all:jar:lib", + "commons-codec:commons-codec", + "commons-codec:commons-codec:jar:sources", + "commons-dbcp:commons-dbcp", + "commons-dbcp:commons-dbcp:jar:sources", + "commons-io:commons-io", + "commons-io:commons-io:jar:sources", + "commons-net:commons-net", + "commons-net:commons-net:jar:sources", + "commons-pool:commons-pool", + "commons-pool:commons-pool:jar:sources", + "commons-validator:commons-validator", + "commons-validator:commons-validator:jar:sources", + "dk.brics:automaton", + "dk.brics:automaton:jar:sources", + "eu.medsea.mimeutil:mime-util", + "io.dropwizard.metrics:metrics-core", + "io.dropwizard.metrics:metrics-core:jar:sources", + "io.github.java-diff-utils:java-diff-utils", + "io.github.java-diff-utils:java-diff-utils:jar:sources", + "io.sweers.autotransient:autotransient", + "io.sweers.autotransient:autotransient:jar:sources", + "jakarta.inject:jakarta.inject-api", + "jakarta.inject:jakarta.inject-api:jar:sources", + "javax.activation:activation", + "javax.activation:activation:jar:sources", + "javax.inject:javax.inject", + "javax.inject:javax.inject:jar:sources", + "javax.servlet:javax.servlet-api", + "javax.servlet:javax.servlet-api:jar:sources", + "junit:junit", + "junit:junit:jar:sources", + "net.bytebuddy:byte-buddy", + "net.bytebuddy:byte-buddy-agent", + "net.bytebuddy:byte-buddy-agent:jar:sources", + "net.bytebuddy:byte-buddy:jar:sources", + "net.java.dev.jna:jna", + "net.java.dev.jna:jna-platform", + "net.java.dev.jna:jna-platform:jar:sources", + "net.java.dev.jna:jna:jar:sources", + "net.minidev:json-smart", + "net.minidev:json-smart:jar:sources", + "net.sf.jopt-simple:jopt-simple", + "net.sf.jopt-simple:jopt-simple:jar:sources", + "net.sourceforge.nekohtml:nekohtml", + "net.sourceforge.nekohtml:nekohtml:jar:sources", + "org.antlr:ST4", + "org.antlr:ST4:jar:sources", + "org.antlr:antlr", + "org.antlr:antlr-runtime", + "org.antlr:antlr-runtime:jar:sources", + "org.antlr:antlr:jar:sources", + "org.antlr:stringtemplate", + "org.antlr:stringtemplate:jar:sources", + "org.apache.commons:commons-compress", + "org.apache.commons:commons-compress:jar:sources", + "org.apache.commons:commons-lang3", + "org.apache.commons:commons-lang3:jar:sources", + "org.apache.commons:commons-math3", + "org.apache.commons:commons-math3:jar:sources", + "org.apache.commons:commons-text", + "org.apache.commons:commons-text:jar:sources", + "org.apache.httpcomponents:fluent-hc", + "org.apache.httpcomponents:fluent-hc:jar:sources", + "org.apache.httpcomponents:httpclient", + "org.apache.httpcomponents:httpclient:jar:sources", + "org.apache.httpcomponents:httpcore", + "org.apache.httpcomponents:httpcore:jar:sources", + "org.apache.james:apache-mime4j-core", + "org.apache.james:apache-mime4j-core:jar:sources", + "org.apache.james:apache-mime4j-dom", + "org.apache.james:apache-mime4j-dom:jar:sources", + "org.apache.lucene:lucene-analysis-common", + "org.apache.lucene:lucene-analysis-common:jar:sources", + "org.apache.lucene:lucene-backward-codecs", + "org.apache.lucene:lucene-backward-codecs:jar:sources", + "org.apache.lucene:lucene-core", + "org.apache.lucene:lucene-core:jar:sources", + "org.apache.lucene:lucene-misc", + "org.apache.lucene:lucene-misc:jar:sources", + "org.apache.lucene:lucene-queryparser", + "org.apache.lucene:lucene-queryparser:jar:sources", + "org.apache.mina:mina-core", + "org.apache.mina:mina-core:jar:sources", + "org.apache.sshd:sshd-mina", + "org.apache.sshd:sshd-mina:jar:sources", + "org.apache.sshd:sshd-osgi", + "org.apache.sshd:sshd-osgi:jar:sources", + "org.apache.sshd:sshd-sftp", + "org.apache.sshd:sshd-sftp:jar:sources", + "org.asciidoctor:asciidoctorj", + "org.asciidoctor:asciidoctorj:jar:sources", + "org.assertj:assertj-core", + "org.assertj:assertj-core:jar:sources", + "org.bouncycastle:bcpg-jdk18on", + "org.bouncycastle:bcpg-jdk18on:jar:sources", + "org.bouncycastle:bcpkix-jdk18on", + "org.bouncycastle:bcpkix-jdk18on:jar:sources", + "org.bouncycastle:bcprov-jdk18on", + "org.bouncycastle:bcprov-jdk18on:jar:sources", + "org.bouncycastle:bcutil-jdk18on", + "org.bouncycastle:bcutil-jdk18on:jar:sources", + "org.checkerframework:checker-compat-qual", + "org.checkerframework:checker-compat-qual:jar:sources", + "org.checkerframework:checker-qual", + "org.checkerframework:checker-qual:jar:sources", + "org.commonmark:commonmark", + "org.commonmark:commonmark-ext-autolink", + "org.commonmark:commonmark-ext-autolink:jar:sources", + "org.commonmark:commonmark-ext-gfm-strikethrough", + "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources", + "org.commonmark:commonmark-ext-gfm-tables", + "org.commonmark:commonmark-ext-gfm-tables:jar:sources", + "org.commonmark:commonmark:jar:sources", + "org.eclipse.jetty.ee8:jetty-ee8-nested", + "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources", + "org.eclipse.jetty.ee8:jetty-ee8-security", + "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources", + "org.eclipse.jetty.ee8:jetty-ee8-servlet", + "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources", + "org.eclipse.jetty.toolchain:jetty-servlet-api", + "org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources", + "org.eclipse.jetty:jetty-http", + "org.eclipse.jetty:jetty-http:jar:sources", + "org.eclipse.jetty:jetty-io", + "org.eclipse.jetty:jetty-io:jar:sources", + "org.eclipse.jetty:jetty-jmx", + "org.eclipse.jetty:jetty-jmx:jar:sources", + "org.eclipse.jetty:jetty-security", + "org.eclipse.jetty:jetty-security:jar:sources", + "org.eclipse.jetty:jetty-server", + "org.eclipse.jetty:jetty-server:jar:sources", + "org.eclipse.jetty:jetty-servlet", + "org.eclipse.jetty:jetty-servlet:jar:sources", + "org.eclipse.jetty:jetty-session", + "org.eclipse.jetty:jetty-session:jar:sources", + "org.eclipse.jetty:jetty-util", + "org.eclipse.jetty:jetty-util-ajax", + "org.eclipse.jetty:jetty-util-ajax:jar:sources", + "org.eclipse.jetty:jetty-util:jar:sources", + "org.hamcrest:hamcrest", + "org.hamcrest:hamcrest-core", + "org.hamcrest:hamcrest-core:jar:sources", + "org.hamcrest:hamcrest:jar:sources", + "org.jruby:jruby-complete", + "org.jruby:jruby-complete:jar:sources", + "org.jsoup:jsoup", + "org.jsoup:jsoup:jar:sources", + "org.jspecify:jspecify", + "org.jspecify:jspecify:jar:sources", + "org.mockito:mockito-core", + "org.mockito:mockito-core:jar:sources", + "org.nibor.autolink:autolink", + "org.nibor.autolink:autolink:jar:sources", + "org.objenesis:objenesis", + "org.objenesis:objenesis:jar:sources", + "org.openid4java:openid4java", + "org.openid4java:openid4java:jar:sources", + "org.openjdk.jmh:jmh-core", + "org.openjdk.jmh:jmh-core:jar:sources", + "org.openjdk.jmh:jmh-generator-annprocess", + "org.openjdk.jmh:jmh-generator-annprocess:jar:sources", + "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-analysis:jar:sources", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-commons:jar:sources", + "org.ow2.asm:asm-tree", + "org.ow2.asm:asm-tree:jar:sources", + "org.ow2.asm:asm-util", + "org.ow2.asm:asm-util:jar:sources", + "org.ow2.asm:asm:jar:sources", + "org.roaringbitmap:RoaringBitmap", + "org.roaringbitmap:RoaringBitmap:jar:sources", + "org.roaringbitmap:shims", + "org.roaringbitmap:shims:jar:sources", + "org.slf4j:jcl-over-slf4j", + "org.slf4j:jcl-over-slf4j:jar:sources", + "org.slf4j:slf4j-api", + "org.slf4j:slf4j-api:jar:sources", + "org.slf4j:slf4j-ext", + "org.slf4j:slf4j-ext:jar:sources", + "org.slf4j:slf4j-reload4j", + "org.slf4j:slf4j-reload4j:jar:sources", + "org.slf4j:slf4j-simple", + "org.slf4j:slf4j-simple:jar:sources", + "org.tukaani:xz", + "org.tukaani:xz:jar:sources", + "xerces:xercesImpl", + "xerces:xercesImpl:jar:sources" + ], + "https://gerrit-maven.storage.googleapis.com/": [ + "antlr:antlr", + "aopalliance:aopalliance", + "aopalliance:aopalliance:jar:sources", + "args4j:args4j", + "args4j:args4j:jar:sources", + "ch.qos.reload4j:reload4j", + "ch.qos.reload4j:reload4j:jar:sources", + "com.beust:jcommander", + "com.beust:jcommander:jar:sources", + "com.github.ben-manes.caffeine:caffeine", + "com.github.ben-manes.caffeine:caffeine:jar:sources", + "com.github.ben-manes.caffeine:guava", + "com.github.ben-manes.caffeine:guava:jar:sources", + "com.github.rholder:guava-retrying", + "com.github.rholder:guava-retrying:jar:sources", + "com.google.auto.factory:auto-factory", + "com.google.auto.factory:auto-factory:jar:sources", + "com.google.auto.service:auto-service-annotations", + "com.google.auto.service:auto-service-annotations:jar:sources", + "com.google.auto.value:auto-value", + "com.google.auto.value:auto-value-annotations", + "com.google.auto.value:auto-value-annotations:jar:sources", + "com.google.auto.value:auto-value:jar:sources", + "com.google.auto:auto-common", + "com.google.auto:auto-common:jar:sources", + "com.google.code.findbugs:jsr305", + "com.google.code.findbugs:jsr305:jar:sources", + "com.google.code.gson:gson", + "com.google.code.gson:gson:jar:sources", + "com.google.common.html.types:types", + "com.google.common.html.types:types:jar:sources", + "com.google.errorprone:error_prone_annotations", + "com.google.errorprone:error_prone_annotations:jar:sources", + "com.google.flogger:flogger", + "com.google.flogger:flogger-log4j-backend", + "com.google.flogger:flogger-log4j-backend:jar:sources", + "com.google.flogger:flogger-system-backend", + "com.google.flogger:flogger-system-backend:jar:sources", + "com.google.flogger:flogger:jar:sources", + "com.google.flogger:google-extensions", + "com.google.flogger:google-extensions:jar:sources", + "com.google.gitiles:blame-cache", + "com.google.gitiles:blame-cache:jar:sources", + "com.google.gitiles:gitiles-servlet", + "com.google.gitiles:gitiles-servlet:jar:sources", + "com.google.guava:failureaccess", + "com.google.guava:failureaccess:jar:sources", + "com.google.guava:guava", + "com.google.guava:guava-testlib", + "com.google.guava:guava-testlib:jar:sources", + "com.google.guava:guava:jar:sources", + "com.google.guava:listenablefuture", + "com.google.inject.extensions:guice-assistedinject", + "com.google.inject.extensions:guice-assistedinject:jar:sources", + "com.google.inject.extensions:guice-servlet", + "com.google.inject.extensions:guice-servlet:jar:sources", + "com.google.inject:guice", + "com.google.inject:guice:jar:sources", + "com.google.j2objc:j2objc-annotations", + "com.google.j2objc:j2objc-annotations:jar:sources", + "com.google.jimfs:jimfs", + "com.google.jimfs:jimfs:jar:sources", + "com.google.jsinterop:jsinterop-annotations", + "com.google.jsinterop:jsinterop-annotations:jar:sources", + "com.google.protobuf:protobuf-java", + "com.google.protobuf:protobuf-java:jar:sources", + "com.google.template:soy", + "com.google.template:soy:jar:sources", + "com.google.truth.extensions:truth-java8-extension", + "com.google.truth.extensions:truth-java8-extension:jar:sources", + "com.google.truth.extensions:truth-liteproto-extension", + "com.google.truth.extensions:truth-liteproto-extension:jar:sources", + "com.google.truth.extensions:truth-proto-extension", + "com.google.truth.extensions:truth-proto-extension:jar:sources", + "com.google.truth:truth", + "com.google.truth:truth:jar:sources", + "com.googlecode.javaewah:JavaEWAH", + "com.googlecode.javaewah:JavaEWAH:jar:sources", + "com.googlecode.prolog-cafe:prolog-cafeteria", + "com.googlecode.prolog-cafe:prolog-cafeteria:jar:sources", + "com.googlecode.prolog-cafe:prolog-compiler", + "com.googlecode.prolog-cafe:prolog-compiler:jar:sources", + "com.googlecode.prolog-cafe:prolog-io", + "com.googlecode.prolog-cafe:prolog-io:jar:sources", + "com.googlecode.prolog-cafe:prolog-runtime", + "com.googlecode.prolog-cafe:prolog-runtime:jar:sources", + "com.h2database:h2", + "com.h2database:h2:jar:sources", + "com.ibm.icu:icu4j", + "com.ibm.icu:icu4j:jar:sources", + "com.icegreen:greenmail", + "com.icegreen:greenmail:jar:sources", + "com.jcraft:jsch", + "com.jcraft:jsch:jar:sources", + "com.jcraft:jzlib", + "com.jcraft:jzlib:jar:sources", + "com.ryanharter.auto.value:auto-value-gson-extension", + "com.ryanharter.auto.value:auto-value-gson-extension:jar:sources", + "com.ryanharter.auto.value:auto-value-gson-factory", + "com.ryanharter.auto.value:auto-value-gson-factory:jar:sources", + "com.ryanharter.auto.value:auto-value-gson-runtime", + "com.ryanharter.auto.value:auto-value-gson-runtime:jar:sources", + "com.squareup:javapoet", + "com.squareup:javapoet:jar:sources", + "com.sun.mail:javax.mail", + "com.sun.mail:javax.mail:jar:sources", + "com.vladsch.flexmark:flexmark-all:jar:lib", + "commons-codec:commons-codec", + "commons-codec:commons-codec:jar:sources", + "commons-dbcp:commons-dbcp", + "commons-dbcp:commons-dbcp:jar:sources", + "commons-io:commons-io", + "commons-io:commons-io:jar:sources", + "commons-net:commons-net", + "commons-net:commons-net:jar:sources", + "commons-pool:commons-pool", + "commons-pool:commons-pool:jar:sources", + "commons-validator:commons-validator", + "commons-validator:commons-validator:jar:sources", + "dk.brics:automaton", + "dk.brics:automaton:jar:sources", + "eu.medsea.mimeutil:mime-util", + "io.dropwizard.metrics:metrics-core", + "io.dropwizard.metrics:metrics-core:jar:sources", + "io.github.java-diff-utils:java-diff-utils", + "io.github.java-diff-utils:java-diff-utils:jar:sources", + "io.sweers.autotransient:autotransient", + "io.sweers.autotransient:autotransient:jar:sources", + "jakarta.inject:jakarta.inject-api", + "jakarta.inject:jakarta.inject-api:jar:sources", + "javax.activation:activation", + "javax.activation:activation:jar:sources", + "javax.inject:javax.inject", + "javax.inject:javax.inject:jar:sources", + "javax.servlet:javax.servlet-api", + "javax.servlet:javax.servlet-api:jar:sources", + "junit:junit", + "junit:junit:jar:sources", + "net.bytebuddy:byte-buddy", + "net.bytebuddy:byte-buddy-agent", + "net.bytebuddy:byte-buddy-agent:jar:sources", + "net.bytebuddy:byte-buddy:jar:sources", + "net.java.dev.jna:jna", + "net.java.dev.jna:jna-platform", + "net.java.dev.jna:jna-platform:jar:sources", + "net.java.dev.jna:jna:jar:sources", + "net.minidev:json-smart", + "net.minidev:json-smart:jar:sources", + "net.sf.jopt-simple:jopt-simple", + "net.sf.jopt-simple:jopt-simple:jar:sources", + "net.sourceforge.nekohtml:nekohtml", + "net.sourceforge.nekohtml:nekohtml:jar:sources", + "org.antlr:ST4", + "org.antlr:ST4:jar:sources", + "org.antlr:antlr", + "org.antlr:antlr-runtime", + "org.antlr:antlr-runtime:jar:sources", + "org.antlr:antlr:jar:sources", + "org.antlr:stringtemplate", + "org.antlr:stringtemplate:jar:sources", + "org.apache.commons:commons-compress", + "org.apache.commons:commons-compress:jar:sources", + "org.apache.commons:commons-lang3", + "org.apache.commons:commons-lang3:jar:sources", + "org.apache.commons:commons-math3", + "org.apache.commons:commons-math3:jar:sources", + "org.apache.commons:commons-text", + "org.apache.commons:commons-text:jar:sources", + "org.apache.httpcomponents:fluent-hc", + "org.apache.httpcomponents:fluent-hc:jar:sources", + "org.apache.httpcomponents:httpclient", + "org.apache.httpcomponents:httpclient:jar:sources", + "org.apache.httpcomponents:httpcore", + "org.apache.httpcomponents:httpcore:jar:sources", + "org.apache.james:apache-mime4j-core", + "org.apache.james:apache-mime4j-core:jar:sources", + "org.apache.james:apache-mime4j-dom", + "org.apache.james:apache-mime4j-dom:jar:sources", + "org.apache.lucene:lucene-analysis-common", + "org.apache.lucene:lucene-analysis-common:jar:sources", + "org.apache.lucene:lucene-backward-codecs", + "org.apache.lucene:lucene-backward-codecs:jar:sources", + "org.apache.lucene:lucene-core", + "org.apache.lucene:lucene-core:jar:sources", + "org.apache.lucene:lucene-misc", + "org.apache.lucene:lucene-misc:jar:sources", + "org.apache.lucene:lucene-queryparser", + "org.apache.lucene:lucene-queryparser:jar:sources", + "org.apache.mina:mina-core", + "org.apache.mina:mina-core:jar:sources", + "org.apache.sshd:sshd-mina", + "org.apache.sshd:sshd-mina:jar:sources", + "org.apache.sshd:sshd-osgi", + "org.apache.sshd:sshd-osgi:jar:sources", + "org.apache.sshd:sshd-sftp", + "org.apache.sshd:sshd-sftp:jar:sources", + "org.asciidoctor:asciidoctorj", + "org.asciidoctor:asciidoctorj:jar:sources", + "org.assertj:assertj-core", + "org.assertj:assertj-core:jar:sources", + "org.bouncycastle:bcpg-jdk18on", + "org.bouncycastle:bcpg-jdk18on:jar:sources", + "org.bouncycastle:bcpkix-jdk18on", + "org.bouncycastle:bcpkix-jdk18on:jar:sources", + "org.bouncycastle:bcprov-jdk18on", + "org.bouncycastle:bcprov-jdk18on:jar:sources", + "org.bouncycastle:bcutil-jdk18on", + "org.bouncycastle:bcutil-jdk18on:jar:sources", + "org.checkerframework:checker-compat-qual", + "org.checkerframework:checker-compat-qual:jar:sources", + "org.checkerframework:checker-qual", + "org.checkerframework:checker-qual:jar:sources", + "org.commonmark:commonmark", + "org.commonmark:commonmark-ext-autolink", + "org.commonmark:commonmark-ext-autolink:jar:sources", + "org.commonmark:commonmark-ext-gfm-strikethrough", + "org.commonmark:commonmark-ext-gfm-strikethrough:jar:sources", + "org.commonmark:commonmark-ext-gfm-tables", + "org.commonmark:commonmark-ext-gfm-tables:jar:sources", + "org.commonmark:commonmark:jar:sources", + "org.eclipse.jetty.ee8:jetty-ee8-nested", + "org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources", + "org.eclipse.jetty.ee8:jetty-ee8-security", + "org.eclipse.jetty.ee8:jetty-ee8-security:jar:sources", + "org.eclipse.jetty.ee8:jetty-ee8-servlet", + "org.eclipse.jetty.ee8:jetty-ee8-servlet:jar:sources", + "org.eclipse.jetty.toolchain:jetty-servlet-api", + "org.eclipse.jetty.toolchain:jetty-servlet-api:jar:sources", + "org.eclipse.jetty:jetty-http", + "org.eclipse.jetty:jetty-http:jar:sources", + "org.eclipse.jetty:jetty-io", + "org.eclipse.jetty:jetty-io:jar:sources", + "org.eclipse.jetty:jetty-jmx", + "org.eclipse.jetty:jetty-jmx:jar:sources", + "org.eclipse.jetty:jetty-security", + "org.eclipse.jetty:jetty-security:jar:sources", + "org.eclipse.jetty:jetty-server", + "org.eclipse.jetty:jetty-server:jar:sources", + "org.eclipse.jetty:jetty-servlet", + "org.eclipse.jetty:jetty-servlet:jar:sources", + "org.eclipse.jetty:jetty-session", + "org.eclipse.jetty:jetty-session:jar:sources", + "org.eclipse.jetty:jetty-util", + "org.eclipse.jetty:jetty-util-ajax", + "org.eclipse.jetty:jetty-util-ajax:jar:sources", + "org.eclipse.jetty:jetty-util:jar:sources", + "org.hamcrest:hamcrest", + "org.hamcrest:hamcrest-core", + "org.hamcrest:hamcrest-core:jar:sources", + "org.hamcrest:hamcrest:jar:sources", + "org.jruby:jruby-complete", + "org.jruby:jruby-complete:jar:sources", + "org.jsoup:jsoup", + "org.jsoup:jsoup:jar:sources", + "org.jspecify:jspecify", + "org.jspecify:jspecify:jar:sources", + "org.mockito:mockito-core", + "org.mockito:mockito-core:jar:sources", + "org.nibor.autolink:autolink", + "org.nibor.autolink:autolink:jar:sources", + "org.objenesis:objenesis", + "org.objenesis:objenesis:jar:sources", + "org.openid4java:openid4java", + "org.openid4java:openid4java:jar:sources", + "org.openjdk.jmh:jmh-core", + "org.openjdk.jmh:jmh-core:jar:sources", + "org.openjdk.jmh:jmh-generator-annprocess", + "org.openjdk.jmh:jmh-generator-annprocess:jar:sources", + "org.ow2.asm:asm", + "org.ow2.asm:asm-analysis", + "org.ow2.asm:asm-analysis:jar:sources", + "org.ow2.asm:asm-commons", + "org.ow2.asm:asm-commons:jar:sources", + "org.ow2.asm:asm-tree", + "org.ow2.asm:asm-tree:jar:sources", + "org.ow2.asm:asm-util", + "org.ow2.asm:asm-util:jar:sources", + "org.ow2.asm:asm:jar:sources", + "org.roaringbitmap:RoaringBitmap", + "org.roaringbitmap:RoaringBitmap:jar:sources", + "org.roaringbitmap:shims", + "org.roaringbitmap:shims:jar:sources", + "org.slf4j:jcl-over-slf4j", + "org.slf4j:jcl-over-slf4j:jar:sources", + "org.slf4j:slf4j-api", + "org.slf4j:slf4j-api:jar:sources", + "org.slf4j:slf4j-ext", + "org.slf4j:slf4j-ext:jar:sources", + "org.slf4j:slf4j-reload4j", + "org.slf4j:slf4j-reload4j:jar:sources", + "org.slf4j:slf4j-simple", + "org.slf4j:slf4j-simple:jar:sources", + "org.tukaani:xz", + "org.tukaani:xz:jar:sources", + "xerces:xercesImpl", + "xerces:xercesImpl:jar:sources" + ] + }, + "services": { + "com.google.auto.factory:auto-factory": { + "javax.annotation.processing.Processor": [ + "com.google.auto.factory.processor.AutoFactoryProcessor" + ] + }, + "com.google.auto.value:auto-value": { + "com.google.auto.value.extension.AutoValueExtension": [ + "com.google.auto.value.extension.memoized.processor.MemoizeExtension", + "com.google.auto.value.extension.serializable.processor.SerializableAutoValueExtension", + "com.google.auto.value.extension.toprettystring.processor.ToPrettyStringExtension" + ], + "com.google.auto.value.extension.serializable.serializer.interfaces.SerializerExtension": [ + "com.google.auto.value.extension.serializable.serializer.impl.ImmutableListSerializerExtension", + "com.google.auto.value.extension.serializable.serializer.impl.ImmutableMapSerializerExtension", + "com.google.auto.value.extension.serializable.serializer.impl.OptionalSerializerExtension" + ], + "javax.annotation.processing.Processor": [ + "com.google.auto.value.extension.memoized.processor.MemoizedValidator", + "com.google.auto.value.extension.toprettystring.processor.ToPrettyStringValidator", + "com.google.auto.value.processor.AutoAnnotationProcessor", + "com.google.auto.value.processor.AutoBuilderProcessor", + "com.google.auto.value.processor.AutoOneOfProcessor", + "com.google.auto.value.processor.AutoValueBuilderProcessor", + "com.google.auto.value.processor.AutoValueProcessor" + ] + }, + "com.google.flogger:flogger-log4j-backend": { + "com.google.common.flogger.backend.system.BackendFactory": [ + "com.google.common.flogger.backend.log4j.Log4jBackendFactory" + ] + }, + "com.google.jimfs:jimfs": { + "java.nio.file.spi.FileSystemProvider": [ + "com.google.common.jimfs.SystemJimfsFileSystemProvider" + ] + }, + "com.h2database:h2": { + "java.sql.Driver": [ + "org.h2.Driver" + ] + }, + "com.h2database:h2:jar:sources": { + "java.sql.Driver": [ + "org.h2.Driver" + ] + }, + "com.ryanharter.auto.value:auto-value-gson-extension": { + "com.google.auto.value.extension.AutoValueExtension": [ + "com.ryanharter.auto.value.gson.AutoValueGsonExtension" + ] + }, + "com.ryanharter.auto.value:auto-value-gson-factory": { + "javax.annotation.processing.Processor": [ + "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor" + ] + }, + "net.sourceforge.nekohtml:nekohtml:jar:sources": { + "org.apache.xerces.xni.parser.XMLParserConfiguration": [ + "org.cyberneko.html.HTMLConfiguration" + ] + }, + "org.apache.james:apache-mime4j-dom": { + "org.apache.james.mime4j.dom.MessageServiceFactory": [ + "org.apache.james.mime4j.message.MessageServiceFactoryImpl" + ] + }, + "org.apache.james:apache-mime4j-dom:jar:sources": { + "org.apache.james.mime4j.dom.MessageServiceFactory": [ + "org.apache.james.mime4j.message.MessageServiceFactoryImpl" + ] + }, + "org.apache.lucene:lucene-analysis-common": { + "org.apache.lucene.analysis.CharFilterFactory": [ + "org.apache.lucene.analysis.charfilter.HTMLStripCharFilterFactory", + "org.apache.lucene.analysis.charfilter.MappingCharFilterFactory", + "org.apache.lucene.analysis.cjk.CJKWidthCharFilterFactory", + "org.apache.lucene.analysis.fa.PersianCharFilterFactory", + "org.apache.lucene.analysis.pattern.PatternReplaceCharFilterFactory" + ], + "org.apache.lucene.analysis.TokenFilterFactory": [ + "org.apache.lucene.analysis.ar.ArabicNormalizationFilterFactory", + "org.apache.lucene.analysis.ar.ArabicStemFilterFactory", + "org.apache.lucene.analysis.bg.BulgarianStemFilterFactory", + "org.apache.lucene.analysis.bn.BengaliNormalizationFilterFactory", + "org.apache.lucene.analysis.bn.BengaliStemFilterFactory", + "org.apache.lucene.analysis.boost.DelimitedBoostTokenFilterFactory", + "org.apache.lucene.analysis.br.BrazilianStemFilterFactory", + "org.apache.lucene.analysis.cjk.CJKBigramFilterFactory", + "org.apache.lucene.analysis.cjk.CJKWidthFilterFactory", + "org.apache.lucene.analysis.ckb.SoraniNormalizationFilterFactory", + "org.apache.lucene.analysis.ckb.SoraniStemFilterFactory", + "org.apache.lucene.analysis.classic.ClassicFilterFactory", + "org.apache.lucene.analysis.commongrams.CommonGramsFilterFactory", + "org.apache.lucene.analysis.commongrams.CommonGramsQueryFilterFactory", + "org.apache.lucene.analysis.compound.DictionaryCompoundWordTokenFilterFactory", + "org.apache.lucene.analysis.compound.HyphenationCompoundWordTokenFilterFactory", + "org.apache.lucene.analysis.core.DecimalDigitFilterFactory", + "org.apache.lucene.analysis.core.FlattenGraphFilterFactory", + "org.apache.lucene.analysis.core.LowerCaseFilterFactory", + "org.apache.lucene.analysis.core.StopFilterFactory", + "org.apache.lucene.analysis.core.TypeTokenFilterFactory", + "org.apache.lucene.analysis.core.UpperCaseFilterFactory", + "org.apache.lucene.analysis.cz.CzechStemFilterFactory", + "org.apache.lucene.analysis.de.GermanLightStemFilterFactory", + "org.apache.lucene.analysis.de.GermanMinimalStemFilterFactory", + "org.apache.lucene.analysis.de.GermanNormalizationFilterFactory", + "org.apache.lucene.analysis.de.GermanStemFilterFactory", + "org.apache.lucene.analysis.el.GreekLowerCaseFilterFactory", + "org.apache.lucene.analysis.el.GreekStemFilterFactory", + "org.apache.lucene.analysis.en.EnglishMinimalStemFilterFactory", + "org.apache.lucene.analysis.en.EnglishPossessiveFilterFactory", + "org.apache.lucene.analysis.en.KStemFilterFactory", + "org.apache.lucene.analysis.en.PorterStemFilterFactory", + "org.apache.lucene.analysis.es.SpanishLightStemFilterFactory", + "org.apache.lucene.analysis.es.SpanishMinimalStemFilterFactory", + "org.apache.lucene.analysis.es.SpanishPluralStemFilterFactory", + "org.apache.lucene.analysis.fa.PersianNormalizationFilterFactory", + "org.apache.lucene.analysis.fa.PersianStemFilterFactory", + "org.apache.lucene.analysis.fi.FinnishLightStemFilterFactory", + "org.apache.lucene.analysis.fr.FrenchLightStemFilterFactory", + "org.apache.lucene.analysis.fr.FrenchMinimalStemFilterFactory", + "org.apache.lucene.analysis.ga.IrishLowerCaseFilterFactory", + "org.apache.lucene.analysis.gl.GalicianMinimalStemFilterFactory", + "org.apache.lucene.analysis.gl.GalicianStemFilterFactory", + "org.apache.lucene.analysis.hi.HindiNormalizationFilterFactory", + "org.apache.lucene.analysis.hi.HindiStemFilterFactory", + "org.apache.lucene.analysis.hu.HungarianLightStemFilterFactory", + "org.apache.lucene.analysis.hunspell.HunspellStemFilterFactory", + "org.apache.lucene.analysis.id.IndonesianStemFilterFactory", + "org.apache.lucene.analysis.in.IndicNormalizationFilterFactory", + "org.apache.lucene.analysis.it.ItalianLightStemFilterFactory", + "org.apache.lucene.analysis.lv.LatvianStemFilterFactory", + "org.apache.lucene.analysis.minhash.MinHashFilterFactory", + "org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilterFactory", + "org.apache.lucene.analysis.miscellaneous.CapitalizationFilterFactory", + "org.apache.lucene.analysis.miscellaneous.CodepointCountFilterFactory", + "org.apache.lucene.analysis.miscellaneous.ConcatenateGraphFilterFactory", + "org.apache.lucene.analysis.miscellaneous.DateRecognizerFilterFactory", + "org.apache.lucene.analysis.miscellaneous.DelimitedTermFrequencyTokenFilterFactory", + "org.apache.lucene.analysis.miscellaneous.DropIfFlaggedFilterFactory", + "org.apache.lucene.analysis.miscellaneous.FingerprintFilterFactory", + "org.apache.lucene.analysis.miscellaneous.FixBrokenOffsetsFilterFactory", + "org.apache.lucene.analysis.miscellaneous.HyphenatedWordsFilterFactory", + "org.apache.lucene.analysis.miscellaneous.KeepWordFilterFactory", + "org.apache.lucene.analysis.miscellaneous.KeywordMarkerFilterFactory", + "org.apache.lucene.analysis.miscellaneous.KeywordRepeatFilterFactory", + "org.apache.lucene.analysis.miscellaneous.LengthFilterFactory", + "org.apache.lucene.analysis.miscellaneous.LimitTokenCountFilterFactory", + "org.apache.lucene.analysis.miscellaneous.LimitTokenOffsetFilterFactory", + "org.apache.lucene.analysis.miscellaneous.LimitTokenPositionFilterFactory", + "org.apache.lucene.analysis.miscellaneous.ProtectedTermFilterFactory", + "org.apache.lucene.analysis.miscellaneous.RemoveDuplicatesTokenFilterFactory", + "org.apache.lucene.analysis.miscellaneous.ScandinavianFoldingFilterFactory", + "org.apache.lucene.analysis.miscellaneous.ScandinavianNormalizationFilterFactory", + "org.apache.lucene.analysis.miscellaneous.StemmerOverrideFilterFactory", + "org.apache.lucene.analysis.miscellaneous.TrimFilterFactory", + "org.apache.lucene.analysis.miscellaneous.TruncateTokenFilterFactory", + "org.apache.lucene.analysis.miscellaneous.TypeAsSynonymFilterFactory", + "org.apache.lucene.analysis.miscellaneous.WordDelimiterFilterFactory", + "org.apache.lucene.analysis.miscellaneous.WordDelimiterGraphFilterFactory", + "org.apache.lucene.analysis.ngram.EdgeNGramFilterFactory", + "org.apache.lucene.analysis.ngram.NGramFilterFactory", + "org.apache.lucene.analysis.no.NorwegianLightStemFilterFactory", + "org.apache.lucene.analysis.no.NorwegianMinimalStemFilterFactory", + "org.apache.lucene.analysis.no.NorwegianNormalizationFilterFactory", + "org.apache.lucene.analysis.pattern.PatternCaptureGroupFilterFactory", + "org.apache.lucene.analysis.pattern.PatternReplaceFilterFactory", + "org.apache.lucene.analysis.pattern.PatternTypingFilterFactory", + "org.apache.lucene.analysis.payloads.DelimitedPayloadTokenFilterFactory", + "org.apache.lucene.analysis.payloads.NumericPayloadTokenFilterFactory", + "org.apache.lucene.analysis.payloads.TokenOffsetPayloadTokenFilterFactory", + "org.apache.lucene.analysis.payloads.TypeAsPayloadTokenFilterFactory", + "org.apache.lucene.analysis.pt.PortugueseLightStemFilterFactory", + "org.apache.lucene.analysis.pt.PortugueseMinimalStemFilterFactory", + "org.apache.lucene.analysis.pt.PortugueseStemFilterFactory", + "org.apache.lucene.analysis.reverse.ReverseStringFilterFactory", + "org.apache.lucene.analysis.ro.RomanianNormalizationFilterFactory", + "org.apache.lucene.analysis.ru.RussianLightStemFilterFactory", + "org.apache.lucene.analysis.shingle.FixedShingleFilterFactory", + "org.apache.lucene.analysis.shingle.ShingleFilterFactory", + "org.apache.lucene.analysis.snowball.SnowballPorterFilterFactory", + "org.apache.lucene.analysis.sr.SerbianNormalizationFilterFactory", + "org.apache.lucene.analysis.sv.SwedishLightStemFilterFactory", + "org.apache.lucene.analysis.sv.SwedishMinimalStemFilterFactory", + "org.apache.lucene.analysis.synonym.SynonymFilterFactory", + "org.apache.lucene.analysis.synonym.SynonymGraphFilterFactory", + "org.apache.lucene.analysis.synonym.word2vec.Word2VecSynonymFilterFactory", + "org.apache.lucene.analysis.te.TeluguNormalizationFilterFactory", + "org.apache.lucene.analysis.te.TeluguStemFilterFactory", + "org.apache.lucene.analysis.tr.ApostropheFilterFactory", + "org.apache.lucene.analysis.tr.TurkishLowerCaseFilterFactory", + "org.apache.lucene.analysis.util.ElisionFilterFactory" + ], + "org.apache.lucene.analysis.TokenizerFactory": [ + "org.apache.lucene.analysis.classic.ClassicTokenizerFactory", + "org.apache.lucene.analysis.core.KeywordTokenizerFactory", + "org.apache.lucene.analysis.core.LetterTokenizerFactory", + "org.apache.lucene.analysis.core.WhitespaceTokenizerFactory", + "org.apache.lucene.analysis.email.UAX29URLEmailTokenizerFactory", + "org.apache.lucene.analysis.ngram.EdgeNGramTokenizerFactory", + "org.apache.lucene.analysis.ngram.NGramTokenizerFactory", + "org.apache.lucene.analysis.path.PathHierarchyTokenizerFactory", + "org.apache.lucene.analysis.pattern.PatternTokenizerFactory", + "org.apache.lucene.analysis.pattern.SimplePatternSplitTokenizerFactory", + "org.apache.lucene.analysis.pattern.SimplePatternTokenizerFactory", + "org.apache.lucene.analysis.th.ThaiTokenizerFactory", + "org.apache.lucene.analysis.wikipedia.WikipediaTokenizerFactory" + ] + }, + "org.apache.lucene:lucene-backward-codecs": { + "org.apache.lucene.codecs.Codec": [ + "org.apache.lucene.backward_codecs.lucene100.Lucene100Codec", + "org.apache.lucene.backward_codecs.lucene101.Lucene101Codec", + "org.apache.lucene.backward_codecs.lucene103.Lucene103Codec", + "org.apache.lucene.backward_codecs.lucene80.Lucene80Codec", + "org.apache.lucene.backward_codecs.lucene84.Lucene84Codec", + "org.apache.lucene.backward_codecs.lucene86.Lucene86Codec", + "org.apache.lucene.backward_codecs.lucene87.Lucene87Codec", + "org.apache.lucene.backward_codecs.lucene90.Lucene90Codec", + "org.apache.lucene.backward_codecs.lucene91.Lucene91Codec", + "org.apache.lucene.backward_codecs.lucene912.Lucene912Codec", + "org.apache.lucene.backward_codecs.lucene92.Lucene92Codec", + "org.apache.lucene.backward_codecs.lucene94.Lucene94Codec", + "org.apache.lucene.backward_codecs.lucene95.Lucene95Codec", + "org.apache.lucene.backward_codecs.lucene99.Lucene99Codec" + ], + "org.apache.lucene.codecs.DocValuesFormat": [ + "org.apache.lucene.backward_codecs.lucene80.Lucene80DocValuesFormat" + ], + "org.apache.lucene.codecs.KnnVectorsFormat": [ + "org.apache.lucene.backward_codecs.lucene102.Lucene102BinaryQuantizedVectorsFormat", + "org.apache.lucene.backward_codecs.lucene102.Lucene102HnswBinaryQuantizedVectorsFormat", + "org.apache.lucene.backward_codecs.lucene90.Lucene90HnswVectorsFormat", + "org.apache.lucene.backward_codecs.lucene91.Lucene91HnswVectorsFormat", + "org.apache.lucene.backward_codecs.lucene92.Lucene92HnswVectorsFormat", + "org.apache.lucene.backward_codecs.lucene94.Lucene94HnswVectorsFormat", + "org.apache.lucene.backward_codecs.lucene95.Lucene95HnswVectorsFormat", + "org.apache.lucene.backward_codecs.lucene99.Lucene99HnswScalarQuantizedVectorsFormat", + "org.apache.lucene.backward_codecs.lucene99.Lucene99ScalarQuantizedVectorsFormat" + ], + "org.apache.lucene.codecs.PostingsFormat": [ + "org.apache.lucene.backward_codecs.lucene101.Lucene101PostingsFormat", + "org.apache.lucene.backward_codecs.lucene103.Lucene103PostingsFormat", + "org.apache.lucene.backward_codecs.lucene50.Lucene50PostingsFormat", + "org.apache.lucene.backward_codecs.lucene84.Lucene84PostingsFormat", + "org.apache.lucene.backward_codecs.lucene90.Lucene90PostingsFormat", + "org.apache.lucene.backward_codecs.lucene912.Lucene912PostingsFormat", + "org.apache.lucene.backward_codecs.lucene99.Lucene99PostingsFormat" + ] + }, + "org.apache.lucene:lucene-core": { + "org.apache.lucene.analysis.TokenizerFactory": [ + "org.apache.lucene.analysis.standard.StandardTokenizerFactory" + ], + "org.apache.lucene.codecs.Codec": [ + "org.apache.lucene.codecs.lucene104.Lucene104Codec" + ], + "org.apache.lucene.codecs.DocValuesFormat": [ + "org.apache.lucene.codecs.lucene90.Lucene90DocValuesFormat" + ], + "org.apache.lucene.codecs.KnnVectorsFormat": [ + "org.apache.lucene.codecs.lucene104.Lucene104HnswScalarQuantizedVectorsFormat", + "org.apache.lucene.codecs.lucene104.Lucene104ScalarQuantizedVectorsFormat", + "org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsFormat" + ], + "org.apache.lucene.codecs.PostingsFormat": [ + "org.apache.lucene.codecs.lucene104.Lucene104PostingsFormat" + ], + "org.apache.lucene.index.SortFieldProvider": [ + "org.apache.lucene.search.SortField$Provider", + "org.apache.lucene.search.SortedNumericSortField$Provider", + "org.apache.lucene.search.SortedSetSortField$Provider" + ] + }, + "org.apache.sshd:sshd-mina": { + "org.apache.sshd.common.io.IoServiceFactoryFactory": [ + "org.apache.sshd.mina.MinaServiceFactoryFactory" + ] + }, + "org.apache.sshd:sshd-mina:jar:sources": { + "org.apache.sshd.common.io.IoServiceFactoryFactory": [ + "org.apache.sshd.mina.MinaServiceFactoryFactory" + ] + }, + "org.apache.sshd:sshd-osgi": { + "java.nio.file.spi.FileSystemProvider": [ + "org.apache.sshd.common.file.root.RootedFileSystemProvider" + ] + }, + "org.apache.sshd:sshd-sftp": { + "java.nio.file.spi.FileSystemProvider": [ + "org.apache.sshd.sftp.client.fs.SftpFileSystemProvider" + ], + "org.apache.sshd.server.subsystem.SubsystemFactory": [ + "org.apache.sshd.sftp.server.SftpSubsystemFactory" + ] + }, + "org.apache.sshd:sshd-sftp:jar:sources": { + "java.nio.file.spi.FileSystemProvider": [ + "org.apache.sshd.sftp.client.fs.SftpFileSystemProvider" + ], + "org.apache.sshd.server.subsystem.SubsystemFactory": [ + "org.apache.sshd.sftp.server.SftpSubsystemFactory" + ] + }, + "org.bouncycastle:bcprov-jdk18on": { + "java.security.Provider": [ + "org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider" + ] + }, + "org.bouncycastle:bcprov-jdk18on:jar:sources": { + "java.security.Provider": [ + "org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider" + ] + }, + "org.eclipse.jetty:jetty-http": { + "org.eclipse.jetty.http.HttpFieldPreEncoder": [ + "org.eclipse.jetty.http.Http1FieldPreEncoder" + ] + }, + "org.eclipse.jetty:jetty-http:jar:sources": { + "org.eclipse.jetty.http.HttpFieldPreEncoder": [ + "org.eclipse.jetty.http.Http1FieldPreEncoder" + ] + }, + "org.jruby:jruby-complete": { + "java.nio.charset.spi.CharsetProvider": [ + "org.jcodings.spi.Charsets" + ], + "java.nio.file.spi.FileTypeDetector": [ + "org.jruby.util.RubyFileTypeDetector" + ], + "javax.script.ScriptEngineFactory": [ + "org.jruby.embed.jsr223.JRubyEngineFactory" + ] + }, + "org.jruby:jruby-complete:jar:sources": { + "java.nio.file.spi.FileTypeDetector": [ + "org.jruby.util.RubyFileTypeDetector" + ], + "javax.script.ScriptEngineFactory": [ + "org.jruby.embed.jsr223.JRubyEngineFactory" + ] + }, + "org.openjdk.jmh:jmh-generator-annprocess": { + "javax.annotation.processing.Processor": [ + "org.openjdk.jmh.generators.BenchmarkProcessor" + ] + }, + "org.openjdk.jmh:jmh-generator-annprocess:jar:sources": { + "javax.annotation.processing.Processor": [ + "org.openjdk.jmh.generators.BenchmarkProcessor" + ] + }, + "org.slf4j:jcl-over-slf4j": { + "org.apache.commons.logging.LogFactory": [ + "org.apache.commons.logging.impl.SLF4JLogFactory" + ] + }, + "org.slf4j:jcl-over-slf4j:jar:sources": { + "org.apache.commons.logging.LogFactory": [ + "org.apache.commons.logging.impl.SLF4JLogFactory" + ] + }, + "org.slf4j:slf4j-reload4j": { + "org.slf4j.spi.SLF4JServiceProvider": [ + "org.slf4j.reload4j.Reload4jServiceProvider" + ] + }, + "org.slf4j:slf4j-reload4j:jar:sources": { + "org.slf4j.spi.SLF4JServiceProvider": [ + "org.slf4j.reload4j.Reload4jServiceProvider" + ] + }, + "org.slf4j:slf4j-simple": { + "org.slf4j.spi.SLF4JServiceProvider": [ + "org.slf4j.simple.SimpleServiceProvider" + ] + }, + "org.slf4j:slf4j-simple:jar:sources": { + "org.slf4j.spi.SLF4JServiceProvider": [ + "org.slf4j.simple.SimpleServiceProvider" + ] + }, + "xerces:xercesImpl": { + "javax.xml.datatype.DatatypeFactory": [ + "org.apache.xerces.jaxp.datatype.DatatypeFactoryImpl" + ], + "javax.xml.parsers.DocumentBuilderFactory": [ + "org.apache.xerces.jaxp.DocumentBuilderFactoryImpl" + ], + "javax.xml.parsers.SAXParserFactory": [ + "org.apache.xerces.jaxp.SAXParserFactoryImpl" + ], + "javax.xml.stream.XMLEventFactory": [ + "org.apache.xerces.stax.XMLEventFactoryImpl" + ], + "javax.xml.validation.SchemaFactory": [ + "org.apache.xerces.jaxp.validation.XMLSchemaFactory" + ], + "org.w3c.dom.DOMImplementationSourceList": [ + "org.apache.xerces.dom.DOMXSImplementationSourceImpl" + ], + "org.xml.sax.driver": [ + "org.apache.xerces.parsers.SAXParser" + ] + } + }, + "version": "3" +}
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java index a6e659b..2a4e5d9 100644 --- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java +++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -1763,7 +1763,7 @@ protected AutoCloseable installPlugin(String pluginName, Class<? extends Module> sysModuleClass) throws Exception { - return installPlugin(pluginName, sysModuleClass, null, null); + return installPlugin(pluginName, null, sysModuleClass, null, null); } protected AutoCloseable installPlugin( @@ -1772,16 +1772,29 @@ @Nullable Class<? extends Module> httpModuleClass, @Nullable Class<? extends Module> sshModuleClass) throws Exception { - return installPlugin(pluginName, sysModuleClass, httpModuleClass, sshModuleClass, null); + return installPlugin(pluginName, null, sysModuleClass, httpModuleClass, sshModuleClass, null); } protected AutoCloseable installPlugin( String pluginName, + @Nullable Class<? extends Module> apiModuleClass, + @Nullable Class<? extends Module> sysModuleClass, + @Nullable Class<? extends Module> httpModuleClass, + @Nullable Class<? extends Module> sshModuleClass) + throws Exception { + return installPlugin( + pluginName, apiModuleClass, sysModuleClass, httpModuleClass, sshModuleClass, null); + } + + protected AutoCloseable installPlugin( + String pluginName, + @Nullable Class<? extends Module> apiModuleClass, @Nullable Class<? extends Module> sysModuleClass, @Nullable Class<? extends Module> httpModuleClass, @Nullable Class<? extends Module> sshModuleClass, PluginContentScanner scanner) throws Exception { + checkStatic(apiModuleClass); checkStatic(sysModuleClass); checkStatic(httpModuleClass); checkStatic(sshModuleClass); @@ -1792,6 +1805,7 @@ pluginUserFactory.create(pluginName), scanner, getClass().getClassLoader(), + apiModuleClass != null ? apiModuleClass.getName() : null, sysModuleClass != null ? sysModuleClass.getName() : null, httpModuleClass != null ? httpModuleClass.getName() : null, sshModuleClass != null ? sshModuleClass.getName() : null,
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java index 8ce8c16..4159339 100644 --- a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java +++ b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
@@ -76,7 +76,14 @@ @Inject public MyPluginLogFile(MySystemLog mySystemLog, ServerInformation serverInfo) { - super(mySystemLog, serverInfo, logName, new PatternLayout("[%d] [%t] %m%n")); + super( + mySystemLog, + serverInfo, + logName, + new PatternLayout("[%d] [%t] %m%n"), + /* jsonLayout= */ null, + /* textLogging= */ true, + /* jsonLogging= */ false); } }
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java index 1e06919..a74120f 100644 --- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java +++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -76,7 +76,7 @@ } bind(MetricsReservoirConfig.class).to(MetricsReservoirConfigImpl.class).in(Scopes.SINGLETON); - bind(MetricMaker.class).to(TestMetricMaker.class); + bind(MetricMaker.class).toInstance(TestMetricMaker.getInstance()); listener().to(CreateSchema.class);
diff --git a/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java b/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java index 7e50b83..b600870 100644 --- a/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java +++ b/java/com/google/gerrit/acceptance/LightweightPluginDaemonTest.java
@@ -42,6 +42,7 @@ canonicalWebUrl.get() + "plugins/" + name, pluginUserFactory.create(name), getClass().getClassLoader(), + testPlugin.apiModule(), testPlugin.sysModule(), testPlugin.httpModule(), testPlugin.sshModule(),
diff --git a/java/com/google/gerrit/acceptance/ProjectResetter.java b/java/com/google/gerrit/acceptance/ProjectResetter.java index dcbcad4..0da9d30 100644 --- a/java/com/google/gerrit/acceptance/ProjectResetter.java +++ b/java/com/google/gerrit/acceptance/ProjectResetter.java
@@ -187,15 +187,15 @@ } } - @Inject private GitRepositoryManager repoManager; - @Inject private AllUsersName allUsersName; - @Inject @Nullable private AccountCreator accountCreator; - @Inject @Nullable private AccountCache accountCache; - @Inject @Nullable private GroupCache groupCache; - @Inject @Nullable private GroupIncludeCache groupIncludeCache; - @Inject @Nullable private GroupIndexer groupIndexer; - @Inject @Nullable private AccountIndexer accountIndexer; - @Inject @Nullable private ProjectCache projectCache; + private GitRepositoryManager repoManager; + private AllUsersName allUsersName; + @Nullable private AccountCreator accountCreator; + @Nullable private AccountCache accountCache; + @Nullable private GroupCache groupCache; + @Nullable private GroupIncludeCache groupIncludeCache; + @Nullable private GroupIndexer groupIndexer; + @Nullable private AccountIndexer accountIndexer; + @Nullable private ProjectCache projectCache; private final Multimap<Project.NameKey, String> refsPatternByProject; private final boolean deleteNewProjects;
diff --git a/java/com/google/gerrit/acceptance/TestExtensions.java b/java/com/google/gerrit/acceptance/TestExtensions.java index 97fcf0e..cbc43ce 100644 --- a/java/com/google/gerrit/acceptance/TestExtensions.java +++ b/java/com/google/gerrit/acceptance/TestExtensions.java
@@ -23,6 +23,7 @@ import com.google.common.collect.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.BranchNameKey; import com.google.gerrit.entities.Change; @@ -37,6 +38,7 @@ import com.google.gerrit.server.change.EmailReviewComments; import com.google.gerrit.server.events.CommitReceivedEvent; import com.google.gerrit.server.flow.Flow; +import com.google.gerrit.server.flow.FlowActionType; import com.google.gerrit.server.flow.FlowCreation; import com.google.gerrit.server.flow.FlowExpression; import com.google.gerrit.server.flow.FlowKey; @@ -190,6 +192,8 @@ */ private boolean rejectFlowDeletion; + private ImmutableList<FlowActionType> actions = ImmutableList.of(); + /** Makes the flow service reject all flow creations. */ public void rejectFlowCreation() { this.rejectFlowCreation = true; @@ -200,6 +204,10 @@ this.rejectFlowDeletion = true; } + public void setActions(ImmutableList<FlowActionType> actions) { + this.actions = actions; + } + @Override public Flow createFlow(FlowCreation flowCreation) throws FlowPermissionDeniedException, InvalidFlowException, StorageException { @@ -242,6 +250,12 @@ } @Override + public ImmutableList<FlowActionType> listActions( + Project.NameKey projectName, Change.Id changeId) throws StorageException { + return actions; + } + + @Override public Optional<Flow> getFlow(FlowKey flowKey) throws StorageException { return Optional.ofNullable(flows.get(flowKey)); } @@ -281,6 +295,7 @@ * given states/messages doesn't match with the number of stages in the flow * @return the updated flow */ + @CanIgnoreReturnValue public Flow evaluate( FlowKey flowKey, ImmutableList<FlowStageEvaluationStatus.State> stageStates,
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java index f7e0667..0fa257e 100644 --- a/java/com/google/gerrit/acceptance/TestMetricMaker.java +++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -60,6 +60,14 @@ */ @Singleton public class TestMetricMaker extends DisabledMetricMaker { + private static final TestMetricMaker INSTANCE = new TestMetricMaker(); + + private TestMetricMaker() {} + + public static TestMetricMaker getInstance() { + return INSTANCE; + } + private final ConcurrentHashMap<CounterKey, MutableLong> counts = new ConcurrentHashMap<>(); private final ConcurrentHashMap<CounterKey, MutableLong> timers = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Supplier<?>> callbackMetrics = new ConcurrentHashMap<>();
diff --git a/java/com/google/gerrit/acceptance/TestPlugin.java b/java/com/google/gerrit/acceptance/TestPlugin.java index cafc775..bda4937 100644 --- a/java/com/google/gerrit/acceptance/TestPlugin.java +++ b/java/com/google/gerrit/acceptance/TestPlugin.java
@@ -25,6 +25,8 @@ public @interface TestPlugin { String name(); + String apiModule() default ""; + String sysModule() default ""; String httpModule() default "";
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java index 94b1cc4..1aec6fd 100644 --- a/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java +++ b/java/com/google/gerrit/acceptance/testsuite/account/TestAccount.java
@@ -42,6 +42,11 @@ return ImmutableSet.copyOf(Sets.difference(emails(), ImmutableSet.of(preferredEmail().get()))); } + public String getLoggableName() { + // Logic should be kept in sync with IdentifiedUser#getLoggableName. + return username().orElseGet(() -> preferredEmail().orElseGet(() -> "a/" + accountId().get())); + } + static Builder builder() { return new AutoValue_TestAccount.Builder(); }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java index 3219ef8..f134ee4 100644 --- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java +++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -69,7 +69,6 @@ case NO_CODE_CHANGE, REWORK, TRIVIAL_REBASE, TRIVIAL_REBASE_WITH_MESSAGE_UPDATE, NO_CHANGE -> createChange(testRepo, user).getChangeId(); case MERGE_FIRST_PARENT_UPDATE -> createChangeForMergeCommit(testRepo, user); - default -> throw new IllegalStateException("unexpected change kind: " + kind); }; } @@ -84,25 +83,23 @@ switch (changeKind) { case NO_CODE_CHANGE -> { noCodeChange(changeId, testRepo, user); - return; } case REWORK -> { rework(changeId, testRepo, user); - return; } case TRIVIAL_REBASE -> { trivialRebase(changeId, testRepo, user, project); - return; } case MERGE_FIRST_PARENT_UPDATE -> { updateFirstParent(changeId, testRepo, user); - return; } case NO_CHANGE -> { noChange(changeId, testRepo, user); - return; } - default -> assertWithMessage("unexpected change kind: " + changeKind).fail(); + case TRIVIAL_REBASE_WITH_MESSAGE_UPDATE -> { + // TODO: this case wasn't implemented yet when the default case was removed + throw new UnsupportedOperationException("Unimplemented case: " + changeKind); + } } } @@ -118,14 +115,13 @@ Project.NameKey project) throws Exception { switch (changeKind) { - case REWORK: - case TRIVIAL_REBASE: - break; - case NO_CODE_CHANGE: - case NO_CHANGE: - case MERGE_FIRST_PARENT_UPDATE: - default: + case REWORK, TRIVIAL_REBASE -> {} + case NO_CODE_CHANGE, + NO_CHANGE, + MERGE_FIRST_PARENT_UPDATE, + TRIVIAL_REBASE_WITH_MESSAGE_UPDATE -> { assertWithMessage("unexpected change kind: " + changeKind).fail(); + } } testRepo.reset(projectOperations.project(project).getHead("master"));
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java index ad3dceb..85e2ee7 100644 --- a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java +++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -280,9 +280,6 @@ case CHANGE_IDENTIFIER -> getGroupsFromChange(parentCommit.changeIdentifier()); case COMMIT_SHA_1 -> ImmutableList.of(); case PATCHSET_ID -> getGroupsFromPatchset(parentCommit.patchsetId()); - default -> - throw new IllegalStateException( - String.format("No parent behavior implemented for %s.", parentCommit.getKind())); }; } @@ -362,9 +359,6 @@ case CHANGE_IDENTIFIER -> resolveChange(parentCommit.changeIdentifier()); case COMMIT_SHA_1 -> resolveCommitFromSha1(revWalk, parentCommit.commitSha1()); case PATCHSET_ID -> resolvePatchset(parentCommit.patchsetId()); - default -> - throw new IllegalStateException( - String.format("No parent behavior implemented for %s.", parentCommit.getKind())); }; }
diff --git a/java/com/google/gerrit/auth/BUILD b/java/com/google/gerrit/auth/BUILD index f04334d..19977db 100644 --- a/java/com/google/gerrit/auth/BUILD +++ b/java/com/google/gerrit/auth/BUILD
@@ -1,5 +1,4 @@ load("@rules_java//java:defs.bzl", "java_library") -load("//tools/bzl:javadoc.bzl", "java_doc") # Giant kitchen-sink target. #
diff --git a/java/com/google/gerrit/common/PageLinks.java b/java/com/google/gerrit/common/PageLinks.java index e638636..2102977 100644 --- a/java/com/google/gerrit/common/PageLinks.java +++ b/java/com/google/gerrit/common/PageLinks.java
@@ -161,15 +161,11 @@ } private static String status(Status status) { - switch (status) { - case ABANDONED: - return "status:abandoned"; - case MERGED: - return "status:merged"; - case NEW: - default: - return "status:open"; - } + return switch (status) { + case ABANDONED -> "status:abandoned"; + case MERGED -> "status:merged"; + case NEW -> "status:open"; + }; } private static String toChangeNoSlash(@Nullable Project.NameKey project, Change.Id c) {
diff --git a/java/com/google/gerrit/entities/Account.java b/java/com/google/gerrit/entities/Account.java index 0d0d158..ba90edb 100644 --- a/java/com/google/gerrit/entities/Account.java +++ b/java/com/google/gerrit/entities/Account.java
@@ -52,6 +52,8 @@ * @param fullName The full name of the user ("Given-name Surname" style). * @param displayName An optional display name of the user to be shown in the UI. * @param preferredEmail The email address the user prefers to be contacted through. + * @param avatarEmail The email address used for avatar lookup (e.g. Gravatar). If null, falls back + * to preferredEmail. * @param inactive Is this user inactive? This is used to avoid showing some users (eg. former * employees) in auto-suggest. * @param status The user-settable status of this account (e.g. busy, OOO, available) @@ -66,6 +68,7 @@ @Nullable String fullName, @Nullable String displayName, @Nullable String preferredEmail, + @Nullable String avatarEmail, boolean inactive, @Nullable String status, @Nullable String metaId, @@ -221,6 +224,16 @@ return !inactive(); } + /** + * Returns the email address to use for avatar lookup. + * + * @return avatarEmail if set, otherwise preferredEmail + */ + @Nullable + public String effectiveAvatarEmail() { + return avatarEmail() != null ? avatarEmail() : preferredEmail(); + } + public Builder toBuilder() { return new AutoBuilder_Account_Builder(this); } @@ -250,6 +263,11 @@ public abstract Builder setPreferredEmail(String preferredEmail); + @Nullable + public abstract String avatarEmail(); + + public abstract Builder setAvatarEmail(String avatarEmail); + public abstract boolean inactive(); public abstract Builder setInactive(boolean inactive); @@ -289,6 +307,7 @@ .add("fullName", fullName()) .add("displayName", displayName()) .add("preferredEmail", preferredEmail()) + .add("avatarEmail", avatarEmail()) .add("inactive", inactive()) .add("status", status()) .add("metaId", metaId())
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java index 0ba138e..4f7921d 100644 --- a/java/com/google/gerrit/entities/AccountGroup.java +++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -43,13 +43,11 @@ } public static UUID uuid(String n) { - return new AutoValue_AccountGroup_UUID(n); + return new UUID(n); } /** Globally unique identifier. */ - @AutoValue - public abstract static class UUID implements Comparable<UUID> { - abstract String uuid(); + public record UUID(String uuid) implements Comparable<UUID> { public String get() { return uuid(); @@ -92,24 +90,22 @@ } @Override - public final int compareTo(UUID o) { + public int compareTo(UUID o) { return uuid().compareTo(o.uuid()); } @Override - public final String toString() { + public String toString() { return KeyUtil.encode(get()); } } public static Id id(int id) { - return new AutoValue_AccountGroup_Id(id); + return new Id(id); } /** Synthetic key to link to within the database */ - @AutoValue - public abstract static class Id { - abstract int id(); + public record Id(int id) { public int get() { return id(); @@ -121,7 +117,7 @@ } @Override - public final String toString() { + public String toString() { return Integer.toString(get()); } }
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java index 1dc7149..9d2bc35 100644 --- a/java/com/google/gerrit/entities/Patch.java +++ b/java/com/google/gerrit/entities/Patch.java
@@ -21,7 +21,6 @@ import com.google.common.primitives.Ints; import com.google.gerrit.common.Nullable; import com.google.gerrit.common.UsedAt; -import com.google.gerrit.entities.Patch.FileMode; import java.util.List; /**
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java index 4d926b8..885edc6 100644 --- a/java/com/google/gerrit/entities/Permission.java +++ b/java/com/google/gerrit/entities/Permission.java
@@ -61,6 +61,7 @@ public static final String SUBMIT_AS = "submitAs"; public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState"; public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges"; + public static final String AI_REVIEW = "aiReview"; public static final boolean DEF_EXCLUSIVE_GROUP = false; @@ -99,6 +100,7 @@ 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)); + NAMES_LC.add(AI_REVIEW.toLowerCase(Locale.US)); LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL); LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase(Locale.US));
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java index 8cce85d..c2ff1ec 100644 --- a/java/com/google/gerrit/entities/PermissionRule.java +++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -96,16 +96,10 @@ } private static int action(PermissionRule a) { - switch (a.getAction()) { - case DENY: - return 0; - case ALLOW: - case BATCH: - case BLOCK: - case INTERACTIVE: - default: - return 1 + a.getAction().ordinal(); - } + return switch (a.getAction()) { + case DENY -> 0; + case ALLOW, BATCH, BLOCK, INTERACTIVE -> 1 + a.getAction().ordinal(); + }; } private static int range(PermissionRule a) {
diff --git a/java/com/google/gerrit/entities/converter/AccountProtoConverter.java b/java/com/google/gerrit/entities/converter/AccountProtoConverter.java index a56bf18..dbef101 100644 --- a/java/com/google/gerrit/entities/converter/AccountProtoConverter.java +++ b/java/com/google/gerrit/entities/converter/AccountProtoConverter.java
@@ -35,6 +35,7 @@ .setFullName(Strings.nullToEmpty(account.fullName())) .setDisplayName(Strings.nullToEmpty(account.displayName())) .setPreferredEmail(Strings.nullToEmpty(account.preferredEmail())) + .setAvatarEmail(Strings.nullToEmpty(account.avatarEmail())) .setStatus(Strings.nullToEmpty(account.status())) .setMetaId(Strings.nullToEmpty(account.metaId())) .setUniqueTag(Strings.nullToEmpty(account.uniqueTag())) @@ -48,6 +49,7 @@ .setFullName(Strings.emptyToNull(proto.getFullName())) .setDisplayName(Strings.emptyToNull(proto.getDisplayName())) .setPreferredEmail(Strings.emptyToNull(proto.getPreferredEmail())) + .setAvatarEmail(Strings.emptyToNull(proto.getAvatarEmail())) .setInactive(proto.getInactive()) .setStatus(Strings.emptyToNull(proto.getStatus())) .setMetaId(Strings.emptyToNull(proto.getMetaId()))
diff --git a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java index 7adb40d..7cb78fa 100644 --- a/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java +++ b/java/com/google/gerrit/entities/converter/HumanCommentProtoConverter.java
@@ -66,6 +66,7 @@ InFilePosition.newBuilder() .setFilePath(val.key.filename) .setSide(val.side <= 0 ? Side.PARENT : Side.REVISION); + inFilePos.setParentNumber(val.side <= 0 ? -val.side : 0); if (val.range != null) { inFilePos.setPositionRange(toRangeProto(val.range)); } @@ -132,8 +133,8 @@ accountIdConverter.fromProto(proto.getAccountId()), Instant.ofEpochMilli(proto.getWrittenOnMillis()), optInFilePosition.isPresent() - ? (short) optInFilePosition.get().getSide().getNumber() - : Side.REVISION_VALUE, + ? getSide(optInFilePosition.get()) + : (short) Side.REVISION_VALUE, proto.getCommentText(), proto.getServerId(), proto.getUnresolved(), @@ -194,6 +195,16 @@ return Entities.HumanComment.parser(); } + private static short getSide(InFilePosition pos) { + if (pos.getSide() == Side.PARENT) { + if (pos.hasParentNumber()) { + return (short) -pos.getParentNumber(); + } + return (short) 0; + } + return (short) 1; + } + @Override public Class<Entities.HumanComment> getProtoClass() { return Entities.HumanComment.class;
diff --git a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java index f4a66a0..c7589d2 100644 --- a/java/com/google/gerrit/entities/converter/SafeProtoConverter.java +++ b/java/com/google/gerrit/entities/converter/SafeProtoConverter.java
@@ -6,7 +6,7 @@ /** * An extension to {@link ProtoConverter} that enforces the Entity class and the Proto class to stay - * in sync. The enforcement is done by {@link SafeProtoConverterTest}. + * in sync. The enforcement is done by {@code SafeProtoConverterTest}. * * <p>Requirements: * @@ -16,7 +16,7 @@ * <li>The Java Entity class must be annotated with {@link ConvertibleToProto}. * </ul> * - * <p>All safe converters are tested using {@link SafeProtoConverterTest}. Therefore, unless your + * <p>All safe converters are tested using {@code SafeProtoConverterTest}. Therefore, unless your * Entity class has a {@code defaults()} method, or other methods besides simple getters and * setters, there is no need to explicitly test your safe converter. */
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD index 1258a13..9a7db2a 100644 --- a/java/com/google/gerrit/extensions/BUILD +++ b/java/com/google/gerrit/extensions/BUILD
@@ -1,11 +1,14 @@ load("@rules_java//java:defs.bzl", "java_binary", "java_library") -load("//tools:nongoogle.bzl", "GUAVA_DOC_URL") load("//tools/bzl:javadoc.bzl", "java_doc") _DOC_VERS = "6.1.0.202203080745-r" JGIT_DOC_URL = "https://archive.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs" +GUAVA_VERSION = "33.5.0-jre" + +GUAVA_DOC_URL = "https://guava.dev/releases/" + GUAVA_VERSION + "/api/docs/" + java_binary( name = "extension-api", main_class = "Dummy", @@ -36,6 +39,7 @@ "//lib:servlet-api", "//lib/auto:auto-value-annotations", "//lib/errorprone:annotations", + "//lib/flogger:api", "//lib/guice", "//lib/guice:guice-assistedinject", ],
diff --git a/java/com/google/gerrit/extensions/api/accounts/EmailInput.java b/java/com/google/gerrit/extensions/api/accounts/EmailInput.java index 9d6b3c5..852e6ea 100644 --- a/java/com/google/gerrit/extensions/api/accounts/EmailInput.java +++ b/java/com/google/gerrit/extensions/api/accounts/EmailInput.java
@@ -18,16 +18,20 @@ /** This entity contains information for registering a new email address. */ public class EmailInput { - /* The email address. If provided, must match the email address from the URL. */ + /** The email address. If provided, must match the email address from the URL. */ @DefaultInput public String email; - /* Whether the new email address should become the preferred email address of - * the user. Only supported if {@link #noConfirmation} is set or if the - * authentication type is DEVELOPMENT_BECOME_ANY_ACCOUNT.*/ + /** + * Whether the new email address should become the preferred email address of the user. Only + * supported if {@link #noConfirmation} is set or if the authentication type is + * DEVELOPMENT_BECOME_ANY_ACCOUNT. + */ public boolean preferred; - /* Whether the email address should be added without confirmation. In this - * case no verification email is sent to the user. Only Gerrit administrators - * are allowed to add email addresses without confirmation. */ + /** + * Whether the email address should be added without confirmation. In this case no verification + * email is sent to the user. Only Gerrit administrators are allowed to add email addresses + * without confirmation. + */ public boolean noConfirmation; }
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java index 7af343a..02fe189 100644 --- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java +++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -29,6 +29,7 @@ import com.google.gerrit.extensions.common.CommitMessageInfo; import com.google.gerrit.extensions.common.CommitMessageInput; import com.google.gerrit.extensions.common.EvaluateChangeQueryExpressionResultInfo; +import com.google.gerrit.extensions.common.FlowActionTypeInfo; import com.google.gerrit.extensions.common.FlowInfo; import com.google.gerrit.extensions.common.FlowInput; import com.google.gerrit.extensions.common.IsFlowsEnabledInfo; @@ -109,6 +110,9 @@ /** Get the flows of this change/ */ List<FlowInfo> flows() throws RestApiException; + /** Get the actions of this change/ */ + List<FlowActionTypeInfo> flowsActions() throws RestApiException; + EvaluateChangeQueryExpressionRequest evaluateChangeQueryExpression(); default void abandon() throws RestApiException {
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java index a7b511b..f2a54b1 100644 --- a/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java +++ b/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
@@ -26,6 +26,7 @@ public ReviewerState state; public NotifyHandling notify; public Map<RecipientType, NotifyInfo> notifyDetails; + public String onBehalfOf; public boolean confirmed() { return (confirmed != null) ? confirmed : false;
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java index a2f5ed1..5d61cb0 100644 --- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java +++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -29,6 +29,7 @@ import com.google.gerrit.extensions.common.EditInfo; import com.google.gerrit.extensions.common.FileInfo; import com.google.gerrit.extensions.common.MergeableInfo; +import com.google.gerrit.extensions.common.RevisionInfo; import com.google.gerrit.extensions.common.TestSubmitRuleInfo; import com.google.gerrit.extensions.common.TestSubmitRuleInput; import com.google.gerrit.extensions.restapi.BinaryResult; @@ -39,6 +40,8 @@ import java.util.Set; public interface RevisionApi { + RevisionInfo get() throws RestApiException; + String description() throws RestApiException; void description(String description) throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java index ffdc276..b93f752 100644 --- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java +++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -149,6 +149,7 @@ public Boolean allowBrowserNotifications; public Boolean allowSuggestCodeWhileCommenting; public Boolean allowAutocompletingComments; + public String aiChatSelectedModel; /** * The sidebar section that the user prefers to have open on the diff page, or "NONE" if all @@ -230,7 +231,8 @@ this.allowSuggestCodeWhileCommenting, other.allowSuggestCodeWhileCommenting) && equalBooleanPreferencesFields( this.allowAutocompletingComments, other.allowAutocompletingComments) - && Objects.equals(this.diffPageSidebar, other.diffPageSidebar); + && Objects.equals(this.diffPageSidebar, other.diffPageSidebar) + && Objects.equals(this.aiChatSelectedModel, other.aiChatSelectedModel); } @Override @@ -260,7 +262,8 @@ allowBrowserNotifications, allowSuggestCodeWhileCommenting, allowAutocompletingComments, - diffPageSidebar); + diffPageSidebar, + aiChatSelectedModel); } @Override @@ -291,6 +294,7 @@ .add("allowSuggestCodeWhileCommenting", allowSuggestCodeWhileCommenting) .add("allowAutocompletingComments", allowAutocompletingComments) .add("diffPageSidebar", diffPageSidebar) + .add("aiChatSelectedModel", aiChatSelectedModel) .toString(); } @@ -319,6 +323,7 @@ p.allowSuggestCodeWhileCommenting = true; p.allowAutocompletingComments = true; p.diffPageSidebar = "NONE"; + p.aiChatSelectedModel = null; return p; } }
diff --git a/java/com/google/gerrit/extensions/client/MenuItem.java b/java/com/google/gerrit/extensions/client/MenuItem.java index 0c7dd88..fea885b 100644 --- a/java/com/google/gerrit/extensions/client/MenuItem.java +++ b/java/com/google/gerrit/extensions/client/MenuItem.java
@@ -14,8 +14,10 @@ package com.google.gerrit.extensions.client; +import com.google.gerrit.common.ConvertibleToProto; import java.util.Objects; +@ConvertibleToProto public class MenuItem { public final String url; public final String name;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java index 28d99de..8800dec 100644 --- a/java/com/google/gerrit/extensions/common/ChangeInfo.java +++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -40,7 +40,21 @@ public String tripletId; public String project; + + /** + * The name of the target branch. + * + * <p>The {@code refs/heads/} prefix is omitted. + */ public String branch; + + /** + * The full name of the target branch. + * + * <p>Always starts with {@code refs/}. + */ + public String fullBranch; + public String topic; /** @@ -79,6 +93,7 @@ public Boolean isPrivate; public Boolean workInProgress; public Boolean hasReviewStarted; + public Boolean canAiReview; public Integer revertOf; public String submissionId; public Integer cherryPickOfChange;
diff --git a/java/com/google/gerrit/extensions/common/EmailInfo.java b/java/com/google/gerrit/extensions/common/EmailInfo.java index 96e8adf..54be2f1 100644 --- a/java/com/google/gerrit/extensions/common/EmailInfo.java +++ b/java/com/google/gerrit/extensions/common/EmailInfo.java
@@ -17,9 +17,14 @@ public class EmailInfo { public String email; public Boolean preferred; + public Boolean avatar; public Boolean pendingConfirmation; public void preferred(String e) { this.preferred = (e != null && e.equals(email)) ? true : null; } + + public void avatar(String e) { + this.avatar = (e != null && e.equals(email)) ? true : null; + } }
diff --git a/java/com/google/gerrit/extensions/common/FlowActionTypeInfo.java b/java/com/google/gerrit/extensions/common/FlowActionTypeInfo.java new file mode 100644 index 0000000..6652f44 --- /dev/null +++ b/java/com/google/gerrit/extensions/common/FlowActionTypeInfo.java
@@ -0,0 +1,34 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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; + +/** + * Representation of a flow action type in the REST API. + * + * <p>This class determines the JSON format of flow action types in the REST API. + * + * <p>An action type to be triggered when the condition of a flow expression becomes satisfied. + */ +public class FlowActionTypeInfo { + /** + * The name of the action type. + * + * <p>Which action types are supported depends on the flow service implementation. + */ + public String name; + + /** The parameters placeholder text to display in the UI. */ + public String parametersPlaceholder; +}
diff --git a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java index 8f2d38c..e14da7c 100644 --- a/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java +++ b/java/com/google/gerrit/extensions/common/ReviewerUpdateInfo.java
@@ -25,6 +25,7 @@ public Timestamp updated; public AccountInfo updatedBy; + public AccountInfo realUpdatedBy; public AccountInfo reviewer; public ReviewerState state; @@ -34,9 +35,14 @@ // Instant @SuppressWarnings("JdkObsolete") public ReviewerUpdateInfo( - Instant updated, AccountInfo updatedBy, AccountInfo reviewer, ReviewerState state) { + Instant updated, + AccountInfo updatedBy, + AccountInfo realUpdatedBy, + AccountInfo reviewer, + ReviewerState state) { this.updated = Timestamp.from(updated); this.updatedBy = updatedBy; + this.realUpdatedBy = realUpdatedBy; this.reviewer = reviewer; this.state = state; } @@ -47,6 +53,7 @@ ReviewerUpdateInfo reviewerUpdateInfo = (ReviewerUpdateInfo) o; return Objects.equals(updated, reviewerUpdateInfo.updated) && Objects.equals(updatedBy, reviewerUpdateInfo.updatedBy) + && Objects.equals(realUpdatedBy, reviewerUpdateInfo.realUpdatedBy) && Objects.equals(reviewer, reviewerUpdateInfo.reviewer) && Objects.equals(state, reviewerUpdateInfo.state); } @@ -55,6 +62,6 @@ @Override public int hashCode() { - return Objects.hash(updated, updatedBy, reviewer, state); + return Objects.hash(updated, updatedBy, realUpdatedBy, reviewer, state); } }
diff --git a/java/com/google/gerrit/extensions/registration/DynamicItem.java b/java/com/google/gerrit/extensions/registration/DynamicItem.java index 4cc22f9..4bfba50 100644 --- a/java/com/google/gerrit/extensions/registration/DynamicItem.java +++ b/java/com/google/gerrit/extensions/registration/DynamicItem.java
@@ -16,6 +16,7 @@ import static java.lang.annotation.RetentionPolicy.RUNTIME; +import com.google.common.flogger.FluentLogger; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.Nullable; import com.google.inject.Binder; @@ -42,6 +43,7 @@ * exception is thrown. */ public class DynamicItem<T> { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); /** Annotate a DynamicItem to be final and being bound at most once. */ @Target({ElementType.TYPE}) @@ -221,17 +223,10 @@ Extension<T> old = null; while (!ref.compareAndSet(old, item)) { old = ref.get(); - if (old != null - && !PluginName.GERRIT.equals(old.getPluginName()) - && !pluginName.equals(old.getPluginName())) { - // We allow to replace: - // 1. Gerrit core items, e.g. websession cache - // can be replaced by plugin implementation - // 2. Reload of current plugin - throw new ProvisionException( - String.format( - "%s already provided by %s, ignoring plugin %s", - this.key.getTypeLiteral(), old.getPluginName(), pluginName)); + if (old != null) { + logger.atFine().log( + "%s already provided by %s will be replaced by plugin %s", + key.getTypeLiteral(), old.getPluginName(), pluginName); } } return new ReloadableHandle(key, item, old);
diff --git a/java/com/google/gerrit/extensions/restapi/Response.java b/java/com/google/gerrit/extensions/restapi/Response.java index 85dd643..cc0c134 100644 --- a/java/com/google/gerrit/extensions/restapi/Response.java +++ b/java/com/google/gerrit/extensions/restapi/Response.java
@@ -14,6 +14,9 @@ package com.google.gerrit.extensions.restapi; +import static com.google.common.base.Preconditions.checkState; +import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; + import com.google.common.collect.ImmutableMultimap; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.util.concurrent.TimeUnit; @@ -70,6 +73,10 @@ /** Arbitrary status code with wrapped result. */ public static <T> Response<T> withStatusCode(int statusCode, T value) { + checkState( + statusCode < SC_INTERNAL_SERVER_ERROR, + "Status code must be < 500. To return an internal server error REST endpoint" + + " implementations should throw an exception"); return new Impl<>(statusCode, value); }
diff --git a/java/com/google/gerrit/git/RefUpdateUtil.java b/java/com/google/gerrit/git/RefUpdateUtil.java index cb9f0f3..2a5b0966 100644 --- a/java/com/google/gerrit/git/RefUpdateUtil.java +++ b/java/com/google/gerrit/git/RefUpdateUtil.java
@@ -15,6 +15,7 @@ package com.google.gerrit.git; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.flogger.LazyArgs.lazy; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Joiner; @@ -64,15 +65,18 @@ public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException { logger.atFine().log( "Executing ref updates: %s\n", - Joiner.on("\n") - .join( - bru.getCommands().stream() - .map( - cmd -> - String.format( - "%s (new tree ID: %s)", - cmd, getNewTreeId(rw, cmd).map(ObjectId::name).orElse("n/a"))) - .collect(toImmutableList()))); + lazy( + () -> + Joiner.on("\n") + .join( + bru.getCommands().stream() + .map( + cmd -> + String.format( + "%s (new tree ID: %s)", + cmd, + getNewTreeId(rw, cmd).map(ObjectId::name).orElse("n/a"))) + .collect(toImmutableList())))); bru.execute(rw, NullProgressMonitor.INSTANCE); checkResults(bru); } @@ -187,6 +191,7 @@ RefUpdate ru = repo.updateRef(refName); ru.setForceUpdate(true); ru.setCheckConflicting(false); + ru.setNewObjectId(ObjectId.zeroId()); switch (ru.delete()) { case FORCED: // Ref was deleted.
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java index 9d58d09..8645f9e 100644 --- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java +++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -36,6 +36,7 @@ import com.google.gerrit.server.account.AccountState; import com.google.gerrit.server.account.AuthRequest; import com.google.gerrit.server.account.AuthResult; +import com.google.gerrit.server.account.externalids.ExternalId; import com.google.gerrit.server.config.GerritServerConfig; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -155,7 +156,6 @@ return true; } } - authRequest = authRequestFactory.createForExternalUser(authInfo.username); Optional<AccountState> who = accountCache.getByUsername(authInfo.username).filter(a -> a.account().isActive()); if (!who.isPresent()) { @@ -167,6 +167,16 @@ } Account account = who.get().account(); + Optional<ExternalId> extId = + who.get().externalIds().stream() + .filter(e -> e.key().scheme().equals(authInfo.exportName)) + .findAny(); + if (extId.isPresent()) { + authRequest = authRequestFactory.create(extId.get().key()); + authRequest.setUserName(authInfo.username); + } else { + authRequest = authRequestFactory.createForExternalUser(authInfo.username); + } authRequest.setEmailAddress(account.preferredEmail()); authRequest.setDisplayName(account.fullName()); authRequest.setPassword(authInfo.tokenOrSecret);
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java index d0c8250..2233291 100644 --- a/java/com/google/gerrit/httpd/WebModule.java +++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -31,7 +31,6 @@ import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule; import com.google.gerrit.server.util.RequestScopePropagator; import com.google.inject.Inject; -import com.google.inject.ProvisionException; import com.google.inject.servlet.RequestScoped; import java.net.SocketAddress; @@ -93,7 +92,6 @@ // OAuth support is bound in WebAppInitializer and Daemon. // OpenID support is bound in WebAppInitializer and Daemon. } - default -> throw new ProvisionException("Unsupported loginType: " + authConfig.getAuthType()); } } }
diff --git a/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java b/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java index a306aa6..b5b9bfa 100644 --- a/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java +++ b/java/com/google/gerrit/httpd/plugins/PluginResourceKey.java
@@ -14,20 +14,14 @@ package com.google.gerrit.httpd.plugins; -import com.google.auto.value.AutoValue; import com.google.gerrit.httpd.resources.ResourceKey; import com.google.gerrit.server.plugins.Plugin; -@AutoValue -abstract class PluginResourceKey implements ResourceKey { +public record PluginResourceKey(Plugin.CacheKey plugin, String resource) implements ResourceKey { static PluginResourceKey create(Plugin p, String r) { - return new AutoValue_PluginResourceKey(p.getCacheKey(), r); + return new PluginResourceKey(p.getCacheKey(), r); } - public abstract Plugin.CacheKey plugin(); - - public abstract String resource(); - @Override public int weigh() { return resource().length() * 2;
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java index df88e03..a737fc2 100644 --- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java +++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -30,12 +30,12 @@ import com.google.gerrit.extensions.api.config.Server; import com.google.gerrit.extensions.client.ListChangesOption; import com.google.gerrit.extensions.client.ListOption; +import com.google.gerrit.extensions.common.ServerInfo; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage; import com.google.gerrit.json.OutputFormat; import com.google.gerrit.server.experiments.ExperimentFeatures; -import com.google.gerrit.server.experiments.ExperimentFeaturesConstants; import com.google.gson.Gson; import com.google.template.soy.data.SanitizedContent; import java.net.URI; @@ -69,16 +69,16 @@ String faviconPath, Map<String, String[]> urlParameterMap, Function<String, SanitizedContent> urlInScriptTagOrdainer, - String requestedURL) + String requestedURL, + ServerInfo serverInfo, + String serverVersion) throws URISyntaxException, RestApiException { ImmutableMap.Builder<String, Object> data = ImmutableMap.builder(); - boolean asyncSubmitRequirements = - experimentFeatures.isFeatureEnabled(ExperimentFeaturesConstants.ASYNC_SUBMIT_REQUIREMENTS); data.putAll( staticTemplateData( canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer)) .putAll( - dynamicTemplateData(gerritApi, requestedURL, canonicalURL, asyncSubmitRequirements)); + dynamicTemplateData(gerritApi, requestedURL, canonicalURL, serverInfo, serverVersion)); Set<String> enabledExperiments = new HashSet<>(); enabledExperiments.addAll(experimentFeatures.getEnabledExperimentFeatures()); // Add all experiments enabled through url @@ -117,17 +117,17 @@ GerritApi gerritApi, String requestedURL, String canonicalURL, - boolean asyncSubmitRequirements) + ServerInfo serverInfo, + String serverVersion) throws RestApiException, URISyntaxException { ImmutableMap.Builder<String, Object> data = ImmutableMap.builder(); Map<String, SanitizedContent> initialData = new HashMap<>(); - Server serverApi = gerritApi.config().server(); initialData.put( - addCanonicalUrl("/config/server/info", canonicalURL), - serializeObject(GSON, serverApi.getInfo())); + addCanonicalUrl("/config/server/info", canonicalURL), serializeObject(GSON, serverInfo)); initialData.put( addCanonicalUrl("/config/server/version", canonicalURL), - serializeObject(GSON, serverApi.getVersion())); + serializeObject(GSON, serverVersion)); + Server serverApi = gerritApi.config().server(); initialData.put( addCanonicalUrl("/config/server/top-menus", canonicalURL), serializeObject(GSON, serverApi.topMenus())); @@ -142,17 +142,13 @@ basePatchNum.equals(0) ? IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITHOUT_PARENTS : IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS_WITH_PARENTS); - if (asyncSubmitRequirements) { - changeDetailOptions.remove(ListChangesOption.SUBMIT_REQUIREMENTS); - changeDetailOptions.remove(ListChangesOption.SUBMITTABLE); - data.put( - "submitRequirementsHex", - ListOption.toHex( - ImmutableSet.of( - ListChangesOption.SUBMIT_REQUIREMENTS, - ListChangesOption.SUBMITTABLE, - ListChangesOption.SKIP_DIFFSTAT))); - } + data.put( + "submitRequirementsHex", + ListOption.toHex( + ImmutableSet.of( + ListChangesOption.SUBMIT_REQUIREMENTS, + ListChangesOption.SUBMITTABLE, + ListChangesOption.SKIP_DIFFSTAT))); data.put("defaultChangeDetailHex", ListOption.toHex(changeDetailOptions)); data.put( "changeRequestsPath", @@ -244,6 +240,9 @@ if (faviconPath != null) { data.put("faviconPath", faviconPath); } + data.put( + "manifestPath", + urlInScriptTagOrdainer.apply(Strings.nullToEmpty(canonicalPath) + "/manifest.webmanifest")); if (urlParameterMap.containsKey("ce")) { data.put("polyfillCE", "true");
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java index bb18859..12085e2 100644 --- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java +++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -101,10 +101,8 @@ ListChangesOption.DOWNLOAD_COMMANDS, ListChangesOption.MESSAGES, ListChangesOption.REVIEWER_UPDATES, - ListChangesOption.SUBMITTABLE, ListChangesOption.WEB_LINKS, - ListChangesOption.SKIP_DIFFSTAT, - ListChangesOption.SUBMIT_REQUIREMENTS); + ListChangesOption.SKIP_DIFFSTAT); public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS_WITH_PARENTS = ImmutableSet.<ListChangesOption>builder()
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java index fcb821e..12a2f82 100644 --- a/java/com/google/gerrit/httpd/raw/IndexServlet.java +++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -14,14 +14,18 @@ package com.google.gerrit.httpd.raw; +import static com.google.gerrit.server.config.ServerConfigCacheImpl.SINGLETON_KEY; import static java.nio.charset.StandardCharsets.UTF_8; import static javax.servlet.http.HttpServletResponse.SC_OK; +import com.google.common.cache.Cache; import com.google.common.collect.ImmutableMap; import com.google.common.io.Resources; import com.google.gerrit.common.Nullable; import com.google.gerrit.extensions.api.GerritApi; +import com.google.gerrit.extensions.common.ServerInfo; import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.server.config.ServerConfigCacheImpl.ServerConfigData; import com.google.gerrit.server.experiments.ExperimentFeatures; import com.google.template.soy.SoyFileSet; import com.google.template.soy.data.SanitizedContent; @@ -31,6 +35,7 @@ import java.io.OutputStream; import java.net.URISyntaxException; import java.util.Map; +import java.util.concurrent.ExecutionException; import java.util.function.Function; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; @@ -48,18 +53,21 @@ private final ExperimentFeatures experimentFeatures; private final SoySauce soySauce; private final Function<String, SanitizedContent> urlOrdainer; + private final Cache<String, ServerConfigData> serverConfigCache; IndexServlet( @Nullable String canonicalUrl, @Nullable String cdnPath, @Nullable String faviconPath, GerritApi gerritApi, - ExperimentFeatures experimentFeatures) { + ExperimentFeatures experimentFeatures, + Cache<String, ServerConfigData> serverConfigCache) { this.canonicalUrl = canonicalUrl; this.cdnPath = cdnPath; this.faviconPath = faviconPath; this.gerritApi = gerritApi; this.experimentFeatures = experimentFeatures; + this.serverConfigCache = serverConfigCache; this.soySauce = SoyFileSet.builder() .add(Resources.getResource(POLY_GERRIT_INDEX_HTML_SOY), POLY_GERRIT_INDEX_HTML_SOY) @@ -75,6 +83,17 @@ protected void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException { SoySauce.Renderer renderer; try { + ServerConfigData configData = + serverConfigCache.get( + SINGLETON_KEY, + () -> + ServerConfigData.create( + gerritApi.config().server().getInfo(), + gerritApi.config().server().getVersion())); + + ServerInfo serverInfo = configData.serverInfo(); + String serverVersion = configData.serverVersion(); + Map<String, String[]> parameterMap = req.getParameterMap(); // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent ImmutableMap<String, Object> templateData = @@ -86,10 +105,17 @@ faviconPath, parameterMap, urlOrdainer, - getRequestUrl(req)); + getRequestUrl(req), + serverInfo, + serverVersion); renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData); } catch (URISyntaxException | RestApiException e) { throw new IOException(e); + } catch (ExecutionException e) { + if (e.getCause() instanceof RestApiException) { + throw new IOException(e.getCause()); + } + throw new IOException(e.getCause() != null ? e.getCause() : e); } rsp.setCharacterEncoding(UTF_8.name());
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java index 42e10d8..c07e0f0 100644 --- a/java/com/google/gerrit/httpd/raw/StaticModule.java +++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -27,11 +27,14 @@ import com.google.gerrit.extensions.api.GerritApi; import com.google.gerrit.httpd.XsrfCookieFilter; import com.google.gerrit.httpd.raw.ResourceServlet.Resource; +import com.google.gerrit.json.OutputFormat; import com.google.gerrit.launcher.GerritLauncher; import com.google.gerrit.server.cache.CacheModule; import com.google.gerrit.server.config.CanonicalWebUrl; +import com.google.gerrit.server.config.GerritInstanceNameProvider; import com.google.gerrit.server.config.GerritOptions; import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.ServerConfigCacheImpl; import com.google.gerrit.server.config.SitePaths; import com.google.gerrit.server.experiments.ExperimentFeatures; import com.google.inject.Inject; @@ -47,6 +50,10 @@ import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.regex.Pattern; import javax.servlet.Filter; import javax.servlet.FilterChain; @@ -94,6 +101,8 @@ private static final String DOC_SERVLET = "DocServlet"; private static final String FAVICON_SERVLET = "FaviconServlet"; private static final String SERVICE_WORKER_SERVLET = "ServiceWorkerServlet"; + private static final String GERRIT_ICON_SERVLET = "GerritIconServlet"; + private static final String MANIFEST_SERVLET = "ManifestServlet"; private static final String POLYGERRIT_INDEX_SERVLET = "PolyGerritUiIndexServlet"; private static final String ROBOTS_TXT_SERVLET = "RobotsTxtServlet"; @@ -161,7 +170,9 @@ public void configureServlets() { serve("/robots.txt").with(named(ROBOTS_TXT_SERVLET)); serve("/favicon.ico").with(named(FAVICON_SERVLET)); + serve("/gerrit_icon.png").with(named(GERRIT_ICON_SERVLET)); serve("/service-worker.js").with(named(SERVICE_WORKER_SERVLET)); + serve("/manifest.webmanifest").with(named(MANIFEST_SERVLET)); } @Provides @@ -198,6 +209,17 @@ @Provides @Singleton + @Named(GERRIT_ICON_SERVLET) + HttpServlet getGerritIconServlet(@Named(CACHE) Cache<Path, Resource> cache) { + Paths p = getPaths(); + if (p.warFs != null) { + return new SingleFileServlet(cache, p.warFs.getPath("/gerrit_icon.png"), false); + } + return new SingleFileServlet(cache, webappSourcePath("gerrit_icon.png"), true); + } + + @Provides + @Singleton @Named(SERVICE_WORKER_SERVLET) HttpServlet getServiceWorkerServlet(@Named(CACHE) Cache<Path, Resource> cache) { Paths p = getPaths(); @@ -209,6 +231,25 @@ cache, webappSourcePath("polygerrit_ui/workers/service-worker.js"), true); } + @Provides + @Singleton + @Named(MANIFEST_SERVLET) + HttpServlet getManifestServlet( + @GerritServerConfig Config cfg, + SitePaths sitePaths, + @Named(CACHE) Cache<Path, Resource> cache, + GerritInstanceNameProvider instanceNameProvider) { + Path configPath = sitePaths.resolve(cfg.getString("httpd", null, "webManifestFile")); + if (configPath != null) { + if (exists(configPath) && isReadable(configPath)) { + return new SingleFileServlet(cache, configPath, true); + } + logger.atWarning().log("Cannot read httpd.webManifestFile, using default"); + } + return new ManifestServlet( + instanceNameProvider.get(), cfg.getString("gerrit", null, "faviconPath")); + } + private Path webappSourcePath(String name) { Paths p = getPaths(); if (p.unpackedWar != null) { @@ -234,10 +275,13 @@ @CanonicalWebUrl @Nullable String canonicalUrl, @GerritServerConfig Config cfg, GerritApi gerritApi, - ExperimentFeatures experimentFeatures) { + ExperimentFeatures experimentFeatures, + @Named(ServerConfigCacheImpl.CACHE_CONFIG) + Cache<String, ServerConfigCacheImpl.ServerConfigData> serverConfigCache) { String cdnPath = options.devCdn().orElseGet(() -> cfg.getString("gerrit", null, "cdnPath")); String faviconPath = cfg.getString("gerrit", null, "faviconPath"); - return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures); + return new IndexServlet( + canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures, serverConfigCache); } @Provides @@ -436,6 +480,39 @@ } } + static class ManifestServlet extends HttpServlet { + private static final long serialVersionUID = 1L; + private final String manifestJson; + + ManifestServlet(String instanceName, String faviconPath) { + Map<String, Object> map = new HashMap<>(); + map.put("name", instanceName); + map.put("short_name", instanceName); + map.put("start_url", "."); + map.put("display", "standalone"); + + Map<String, String> pngIcon = new HashMap<>(); + pngIcon.put("src", "gerrit_icon.png"); + pngIcon.put("sizes", "512x512"); + pngIcon.put("type", "image/png"); + pngIcon.put("purpose", "any"); + + List<Map<String, String>> icons = new ArrayList<>(); + icons.add(pngIcon); + map.put("icons", icons); + + this.manifestJson = OutputFormat.JSON_COMPACT.newGson().toJson(map); + } + + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { + resp.setContentType("application/manifest+json"); + resp.setHeader("Cache-Control", "public, max-age=900"); + resp.setStatus(HttpServletResponse.SC_OK); + resp.getWriter().write(manifestJson); + } + } + private static class GuiceFilterRequestWrapper extends HttpServletRequestWrapper { GuiceFilterRequestWrapper(HttpServletRequest req) { super(req);
diff --git a/java/com/google/gerrit/httpd/raw/ToolServlet.java b/java/com/google/gerrit/httpd/raw/ToolServlet.java index d25dba3..fa41fba 100644 --- a/java/com/google/gerrit/httpd/raw/ToolServlet.java +++ b/java/com/google/gerrit/httpd/raw/ToolServlet.java
@@ -58,7 +58,6 @@ switch (ent.getType()) { case FILE -> doGetFile(ent, rsp); case DIR -> doGetDirectory(ent, req, rsp); - default -> rsp.sendError(SC_NOT_FOUND); } }
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java index aa1b921..ff6b714 100644 --- a/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java +++ b/java/com/google/gerrit/httpd/restapi/RestApiMetrics.java
@@ -25,7 +25,9 @@ import com.google.gerrit.metrics.Field; import com.google.gerrit.metrics.Histogram1; import com.google.gerrit.metrics.MetricMaker; -import com.google.gerrit.metrics.Timer1; +import com.google.gerrit.metrics.Timer3; +import com.google.gerrit.server.account.ServiceUserClassifier; +import com.google.gerrit.server.account.UserKind; import com.google.gerrit.server.logging.Metadata; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -38,7 +40,7 @@ final Counter1<String> count; final Counter3<String, Integer, String> errorCount; - final Timer1<String> serverLatency; + final Timer3<String, String, UserKind> serverLatency; final Histogram1<String> responseBytes; @Inject @@ -47,6 +49,21 @@ Field.ofString("view", Metadata.Builder::className) .description("view implementation class") .build(); + Field<String> accessPathField = + Field.ofString("access_path", Metadata.Builder::requestType) + .description( + "The access path through which the user accessed Gerrit (REST_API, WEB_BROWSER or" + + " UNKNOWN).") + .build(); + Field<UserKind> userKindField = + Field.ofEnum(UserKind.class, "user_kind", Metadata.Builder::caller) + .description( + String.format( + "User kind (SERVICE_USER: member of the Gerrit internal '%s' group, HUMAN_USER:" + + " any user that was not classified as a service user and anonymous" + + " users)", + ServiceUserClassifier.SERVICE_USERS)) + .build(); count = metrics.newCounter( "http/server/rest_api/count", @@ -71,7 +88,9 @@ new Description("REST API call latency by view") .setCumulative() .setUnit(Units.MILLISECONDS), - viewField); + viewField, + accessPathField, + userKindField); responseBytes = metrics.newHistogram(
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java index 45a20b6..fcd8566 100644 --- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java +++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -101,6 +101,8 @@ import com.google.gerrit.server.OptionUtil; import com.google.gerrit.server.RequestInfo; import com.google.gerrit.server.RequestListener; +import com.google.gerrit.server.account.ServiceUserClassifier; +import com.google.gerrit.server.account.UserKind; import com.google.gerrit.server.audit.ExtendedHttpAuditEvent; import com.google.gerrit.server.cache.PerThreadCache; import com.google.gerrit.server.cancellation.RequestCancelledException; @@ -248,6 +250,7 @@ final DeadlineChecker.Factory deadlineCheckerFactory; final CancellationMetrics cancellationMetrics; final AclInfoController aclInfoController; + final ServiceUserClassifier serviceUserClassifier; final Provider<TraceContext> requestTraceContext; @Inject @@ -270,6 +273,7 @@ DeadlineChecker.Factory deadlineCheckerFactory, CancellationMetrics cancellationMetrics, AclInfoController aclInfoController, + ServiceUserClassifier serviceUserClassifier, @Named(REQUEST_TRACE_CONTEXT) Provider<TraceContext> requestTraceContext) { this.currentUser = currentUser; this.webSession = webSession; @@ -290,6 +294,7 @@ this.deadlineCheckerFactory = deadlineCheckerFactory; this.cancellationMetrics = cancellationMetrics; this.aclInfoController = aclInfoController; + this.serviceUserClassifier = serviceUserClassifier; this.requestTraceContext = requestTraceContext; } } @@ -763,7 +768,14 @@ globals.metrics.responseBytes.record(metric, responseBytes); } globals.metrics.serverLatency.record( - metric, System.nanoTime() - startNanos, TimeUnit.NANOSECONDS); + metric, + currentUser.getAccessPath().name(), + currentUser.isIdentifiedUser() + && globals.serviceUserClassifier.isServiceUser(currentUser.getAccountId()) + ? UserKind.SERVICE_USER + : UserKind.HUMAN_USER, + System.nanoTime() - startNanos, + TimeUnit.NANOSECONDS); globals.auditService.dispatch( new ExtendedHttpAuditEvent( sessionId,
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java index fdf806c..99728fe 100644 --- a/java/com/google/gerrit/index/Index.java +++ b/java/com/google/gerrit/index/Index.java
@@ -16,6 +16,7 @@ import com.google.common.collect.ImmutableList; import com.google.gerrit.exceptions.StorageException; +import com.google.gerrit.extensions.restapi.NotImplementedException; import com.google.gerrit.index.query.DataSource; import com.google.gerrit.index.query.FieldBundle; import com.google.gerrit.index.query.IndexPredicate; @@ -184,4 +185,16 @@ default boolean snapshot(String id) throws IOException { return false; } + + /** + * Flushes and commits pending changes to the index and syncs referenced index files. + * Implementations may override this to flush their state. If unsupported, the default + * implementation throws a {@link NotImplementedException} exception. + * + * @throws NotImplementedException if the backend does not support explicit flush/commit. + * @throws IOException if flushing to disk fails. + */ + default void flushAndCommit() throws IOException { + throw new NotImplementedException(); + } }
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java index 498b750..c36b204 100644 --- a/java/com/google/gerrit/index/query/QueryProcessor.java +++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -17,6 +17,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.flogger.LazyArgs.lazy; import static java.util.stream.Collectors.toList; import com.google.common.base.Throwables; @@ -327,18 +328,10 @@ try (TraceTimer ignored = TraceContext.newTimer("createQueryResult")) { String queryString = queryStrings != null ? queryStrings.get(i) : null; ImmutableList<T> matchesList = matches.get(i).toList(); - int matchCount = matchesList.size(); int limit = limits.get(i); logger.atFine().log( "Matches[%d]:\n%s", - i, matchesList.stream().map(this::formatForLogging).collect(toList())); - // TODO(brohlfs): Remove this extra logging by end of Q3 2023. - if (limit > 500 && userProvidedLimit <= 0 && matchCount > 100 && enforceVisibility) { - logger.atWarning().log( - "%s index query without provided limit. effective limit: %d, result count: %d," - + " query: %s", - schemaDef.getName(), getPermittedLimit(), matchCount, queryString); - } + i, lazy(() -> matchesList.stream().map(this::formatForLogging).collect(toList()))); out.add(QueryResult.create(queryString, predicates.get(i), limit, matchesList)); } }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java index 143bbcd..d48a876 100644 --- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java +++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -83,6 +83,7 @@ private final String indexName; private final Map<K, D> indexedDocuments; private int queryCount; + private int flushAndCommitCount; private List<Integer> resultsSizes; AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) { @@ -91,6 +92,7 @@ this.indexName = indexName; this.indexedDocuments = new HashMap<>(); this.queryCount = 0; + this.flushAndCommitCount = 0; this.resultsSizes = new ArrayList<>(); } @@ -132,6 +134,16 @@ } } + @Override + public void flushAndCommit() { + flushAndCommitCount += 1; + } + + @VisibleForTesting + public int getFlushAndCommitCount() { + return flushAndCommitCount; + } + public int getQueryCount() { return queryCount; }
diff --git a/java/com/google/gerrit/json/ImmutableListTypeAdapter.java b/java/com/google/gerrit/json/ImmutableListTypeAdapter.java new file mode 100644 index 0000000..1032c63 --- /dev/null +++ b/java/com/google/gerrit/json/ImmutableListTypeAdapter.java
@@ -0,0 +1,71 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.json; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonArray; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.google.inject.TypeLiteral; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +public class ImmutableListTypeAdapter implements JsonDeserializer<ImmutableList<?>> { + + // handle the situation when someone uses ImmutableList<?> + private static final ParameterizedType IMMUTABLE_LIST_OF_UNKNOWN = + (ParameterizedType) new TypeLiteral<ImmutableList<?>>() {}.getType(); + + @Override + public ImmutableList<?> deserialize( + JsonElement jsonArrayElement, + Type type, + JsonDeserializationContext jsonDeserializationContext) + throws JsonParseException { + + if (!jsonArrayElement.isJsonArray()) { + throw new JsonParseException( + "Expected a JSON Array being deserialized to an ImmutableList<>"); + } + + Type elementType = + type instanceof ParameterizedType parameterizedType + ? parameterizedType.getActualTypeArguments()[0] + : IMMUTABLE_LIST_OF_UNKNOWN.getActualTypeArguments()[0]; + + return getObjectBuilder( + jsonArrayElement.getAsJsonArray(), jsonDeserializationContext, elementType) + .build(); + } + + private static ImmutableList.Builder<Object> getObjectBuilder( + JsonArray jsonArray, + JsonDeserializationContext jsonDeserializationContext, + Type elementType) { + ImmutableList.Builder<Object> builder = ImmutableList.builder(); + jsonArray.forEach( + el -> { + if (el.isJsonNull()) { + throw new JsonParseException( + "ImmutableList<?> does not accept the null elements coming from Json array"); + } + Object element = jsonDeserializationContext.deserialize(el, elementType); + builder.add(element); + }); + return builder; + } +}
diff --git a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java index 55b88e1..9489181 100644 --- a/java/com/google/gerrit/lucene/AbstractLuceneIndex.java +++ b/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -240,6 +240,12 @@ } @Override + public void flushAndCommit() throws IOException { + writer.flush(); + writer.commit(); + } + + @Override public void markReady(boolean ready) { IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready); } @@ -337,7 +343,7 @@ release(searcher); } } catch (IOException e) { - logger.atSevere().withCause(e).log(e.getMessage()); + logger.atSevere().withCause(e).log("%s", e.getMessage()); throw new StorageException(e); } }
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java index 80a70c4..f0b056d 100644 --- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java +++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -308,6 +308,12 @@ return openIndex.snapshot(id) && closedIndex.snapshot(id); } + @Override + public void flushAndCommit() throws IOException { + openIndex.flushAndCommit(); + closedIndex.flushAndCommit(); + } + private Sort getSort() { return new Sort( new SortField(UPDATED_SORT_FIELD, SortField.Type.LONG, true), @@ -476,15 +482,15 @@ } } - /* + /** * Assign shard index values to the score documents. * - * <p>TopDocs.merge()'s API has been changed to stop allowing passing in a parameter to - * indicate if it should set shard indices for hits as they are seen during the merge - * process. This is done to simplify the API to be more dynamic in terms of passing in - * custom tie breakers. If shard indices are to be used for tie breaking docs with equal - * scores during TopDocs.merge(), then it is mandatory that the input ScoreDocs have their - * shard indices set to valid values prior to calling merge(). + * <p>TopDocs.merge()'s API has been changed to stop allowing passing in a parameter to indicate + * if it should set shard indices for hits as they are seen during the merge process. This is + * done to simplify the API to be more dynamic in terms of passing in custom tie breakers. If + * shard indices are to be used for tie breaking docs with equal scores during TopDocs.merge(), + * then it is mandatory that the input ScoreDocs have their shard indices set to valid values + * prior to calling merge(). * * @param doc document * @param shard index
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java index ac390f5..5f082c3 100644 --- a/java/com/google/gerrit/metrics/Timer0.java +++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -16,10 +16,12 @@ import static java.util.concurrent.TimeUnit.NANOSECONDS; +import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; import com.google.gerrit.extensions.registration.RegistrationHandle; import com.google.gerrit.server.cancellation.RequestStateContext; import com.google.gerrit.server.logging.LoggingContext; +import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.PerformanceLogRecord; import java.util.concurrent.TimeUnit; @@ -40,12 +42,13 @@ private final Timer0 timer; Context(Timer0 timer) { + super(timer.name, Metadata.empty()); this.timer = timer; } @Override - public void record(long elapsed) { - timer.record(elapsed, NANOSECONDS); + public void record(long elapsed, ImmutableList<String> parentOperations, Metadata metadata) { + timer.record(elapsed, NANOSECONDS, parentOperations, metadata); } } @@ -77,11 +80,30 @@ * @param unit time unit of the value */ public final void record(long value, TimeUnit unit) { + record( + value, + unit, + LoggingContext.getInstance().getRunningOperations().toOperationNames(), + Metadata.empty()); + } + + /** + * Record a value in the distribution. + * + * @param value value to record + * @param unit time unit of the value + * @param parentOperations the parent operations that called the operation for which the latency + * is recorded by this timer + * @param metadata metadata that should be recorded/logged + */ + private final void record( + long value, TimeUnit unit, ImmutableList<String> parentOperations, Metadata metadata) { long durationNanos = unit.toNanos(value); if (!suppressLogging) { LoggingContext.getInstance() - .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationNanos)); + .addPerformanceLogRecord( + () -> PerformanceLogRecord.create(name, durationNanos, parentOperations, metadata)); logger.atFinest().log("%s took %.2f ms", name, durationNanos / 1000000.0); }
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java index 04d6247..24ebeee 100644 --- a/java/com/google/gerrit/metrics/Timer1.java +++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -16,6 +16,7 @@ import static java.util.concurrent.TimeUnit.NANOSECONDS; +import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; import com.google.gerrit.extensions.registration.RegistrationHandle; import com.google.gerrit.server.cancellation.RequestStateContext; @@ -43,14 +44,15 @@ private final Timer1<F1> timer; private final F1 fieldValue; - Context(Timer1<F1> timer, F1 fieldValue) { + Context(Timer1<F1> timer, Metadata metadata, F1 fieldValue) { + super(timer.name, metadata); this.timer = timer; this.fieldValue = fieldValue; } @Override - public void record(long elapsed) { - timer.record(fieldValue, elapsed, NANOSECONDS); + public void record(long elapsed, ImmutableList<String> parentOperations, Metadata metadata) { + timer.record(fieldValue, elapsed, NANOSECONDS, parentOperations, metadata); } } @@ -75,7 +77,7 @@ if (!suppressLogging) { logger.atFine().log("Starting timer %s (%s = %s)", name, field.name(), fieldValue); } - return new Context<>(this, fieldValue); + return new Context<>(this, getMetadata(fieldValue), fieldValue); } /** @@ -86,16 +88,36 @@ * @param unit time unit of the value */ public final void record(F1 fieldValue, long value, TimeUnit unit) { - long durationNanos = unit.toNanos(value); + record( + fieldValue, + value, + unit, + LoggingContext.getInstance().getRunningOperations().toOperationNames(), + getMetadata(fieldValue)); + } - Metadata.Builder metadataBuilder = Metadata.builder(); - field.metadataMapper().accept(metadataBuilder, fieldValue); - Metadata metadata = metadataBuilder.build(); + /** + * Record a value in the distribution. + * + * @param fieldValue bucket to record the timer + * @param value value to record + * @param unit time unit of the value + * @param parentOperations the parent operations that called the operation for which the latency + * is recorded by this timer + * @param metadata metadata that should be recorded/logged + */ + private final void record( + F1 fieldValue, + long value, + TimeUnit unit, + ImmutableList<String> parentOperations, + Metadata metadata) { + long durationNanos = unit.toNanos(value); if (!suppressLogging) { LoggingContext.getInstance() .addPerformanceLogRecord( - () -> PerformanceLogRecord.create(name, durationNanos, metadata)); + () -> PerformanceLogRecord.create(name, durationNanos, parentOperations, metadata)); logger.atFinest().log( "%s (%s = %s) took %.2f ms", name, field.name(), fieldValue, durationNanos / 1000000.0); } @@ -104,6 +126,12 @@ RequestStateContext.abortIfCancelled(); } + private Metadata getMetadata(F1 fieldValue) { + Metadata.Builder metadataBuilder = Metadata.builder(); + field.metadataMapper().accept(metadataBuilder, fieldValue); + return metadataBuilder.build(); + } + /** Suppress logging (debug log and performance log) when values are recorded. */ public final Timer1<F1> suppressLogging() { this.suppressLogging = true;
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java index f526f05..6ef5d01 100644 --- a/java/com/google/gerrit/metrics/Timer2.java +++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -16,6 +16,7 @@ import static java.util.concurrent.TimeUnit.NANOSECONDS; +import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; import com.google.gerrit.extensions.registration.RegistrationHandle; import com.google.gerrit.server.cancellation.RequestStateContext; @@ -45,15 +46,16 @@ private final F1 fieldValue1; private final F2 fieldValue2; - Context(Timer2<F1, F2> timer, F1 fieldValue1, F2 fieldValue2) { + Context(Timer2<F1, F2> timer, Metadata metadata, F1 fieldValue1, F2 fieldValue2) { + super(timer.name, metadata); this.timer = timer; this.fieldValue1 = fieldValue1; this.fieldValue2 = fieldValue2; } @Override - public void record(long elapsed) { - timer.record(fieldValue1, fieldValue2, elapsed, NANOSECONDS); + public void record(long elapsed, ImmutableList<String> parentOperations, Metadata metadata) { + timer.record(fieldValue1, fieldValue2, elapsed, NANOSECONDS, parentOperations, metadata); } } @@ -83,7 +85,7 @@ "Starting timer %s (%s = %s, %s = %s)", name, field1.name(), fieldValue1, field2.name(), fieldValue2); } - return new Context<>(this, fieldValue1, fieldValue2); + return new Context<>(this, getMetadata(fieldValue1, fieldValue2), fieldValue1, fieldValue2); } /** @@ -95,17 +97,39 @@ * @param unit time unit of the value */ public final void record(F1 fieldValue1, F2 fieldValue2, long value, TimeUnit unit) { - long durationNanos = unit.toNanos(value); + record( + fieldValue1, + fieldValue2, + value, + unit, + LoggingContext.getInstance().getRunningOperations().toOperationNames(), + getMetadata(fieldValue1, fieldValue2)); + } - Metadata.Builder metadataBuilder = Metadata.builder(); - field1.metadataMapper().accept(metadataBuilder, fieldValue1); - field2.metadataMapper().accept(metadataBuilder, fieldValue2); - Metadata metadata = metadataBuilder.build(); + /** + * Record a value in the distribution. + * + * @param fieldValue1 bucket to record the timer + * @param fieldValue2 bucket to record the timer + * @param value value to record + * @param unit time unit of the value + * @param parentOperations the parent operations that called the operation for which the latency + * is recorded by this timer + * @param metadata metadata that should be recorded/logged + */ + private final void record( + F1 fieldValue1, + F2 fieldValue2, + long value, + TimeUnit unit, + ImmutableList<String> parentOperations, + Metadata metadata) { + long durationNanos = unit.toNanos(value); if (!suppressLogging) { LoggingContext.getInstance() .addPerformanceLogRecord( - () -> PerformanceLogRecord.create(name, durationNanos, metadata)); + () -> PerformanceLogRecord.create(name, durationNanos, parentOperations, metadata)); logger.atFinest().log( "%s (%s = %s, %s = %s) took %.2f ms", name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationNanos / 1000000.0); @@ -115,6 +139,13 @@ RequestStateContext.abortIfCancelled(); } + private Metadata getMetadata(F1 fieldValue1, F2 fieldValue2) { + Metadata.Builder metadataBuilder = Metadata.builder(); + field1.metadataMapper().accept(metadataBuilder, fieldValue1); + field2.metadataMapper().accept(metadataBuilder, fieldValue2); + return metadataBuilder.build(); + } + /** Suppress logging (debug log and performance log) when values are recorded. */ public final Timer2<F1, F2> suppressLogging() { this.suppressLogging = true;
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java index 1735dc8..705a80a 100644 --- a/java/com/google/gerrit/metrics/Timer3.java +++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -16,6 +16,7 @@ import static java.util.concurrent.TimeUnit.NANOSECONDS; +import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; import com.google.gerrit.extensions.registration.RegistrationHandle; import com.google.gerrit.server.cancellation.RequestStateContext; @@ -47,7 +48,8 @@ private final F2 fieldValue2; private final F3 fieldValue3; - Context(Timer3<F1, F2, F3> timer, F1 f1, F2 f2, F3 f3) { + Context(Timer3<F1, F2, F3> timer, Metadata metadata, F1 f1, F2 f2, F3 f3) { + super(timer.name, metadata); this.timer = timer; this.fieldValue1 = f1; this.fieldValue2 = f2; @@ -55,8 +57,9 @@ } @Override - public void record(long elapsed) { - timer.record(fieldValue1, fieldValue2, fieldValue3, elapsed, NANOSECONDS); + public void record(long elapsed, ImmutableList<String> parentOperations, Metadata metadata) { + timer.record( + fieldValue1, fieldValue2, fieldValue3, elapsed, NANOSECONDS, parentOperations, metadata); } } @@ -89,7 +92,12 @@ "Starting timer %s (%s = %s, %s = %s, %s = %s)", name, field1.name(), fieldValue1, field2.name(), fieldValue2, field3.name(), fieldValue3); } - return new Context<>(this, fieldValue1, fieldValue2, fieldValue3); + return new Context<>( + this, + getMetadata(fieldValue1, fieldValue2, fieldValue3), + fieldValue1, + fieldValue2, + fieldValue3); } /** @@ -103,18 +111,42 @@ */ public final void record( F1 fieldValue1, F2 fieldValue2, F3 fieldValue3, long value, TimeUnit unit) { - long durationNanos = unit.toNanos(value); + record( + fieldValue1, + fieldValue2, + fieldValue3, + value, + unit, + LoggingContext.getInstance().getRunningOperations().toOperationNames(), + getMetadata(fieldValue1, fieldValue2, fieldValue3)); + } - Metadata.Builder metadataBuilder = Metadata.builder(); - field1.metadataMapper().accept(metadataBuilder, fieldValue1); - field2.metadataMapper().accept(metadataBuilder, fieldValue2); - field3.metadataMapper().accept(metadataBuilder, fieldValue3); - Metadata metadata = metadataBuilder.build(); + /** + * Record a value in the distribution. + * + * @param fieldValue1 bucket to record the timer + * @param fieldValue2 bucket to record the timer + * @param fieldValue3 bucket to record the timer + * @param value value to record + * @param unit time unit of the value + * @param parentOperations the parent operations that called the operation for which the latency + * is recorded by this timer + * @param metadata metadata that should be recorded/logged + */ + private final void record( + F1 fieldValue1, + F2 fieldValue2, + F3 fieldValue3, + long value, + TimeUnit unit, + ImmutableList<String> parentOperations, + Metadata metadata) { + long durationNanos = unit.toNanos(value); if (!suppressLogging) { LoggingContext.getInstance() .addPerformanceLogRecord( - () -> PerformanceLogRecord.create(name, durationNanos, metadata)); + () -> PerformanceLogRecord.create(name, durationNanos, parentOperations, metadata)); logger.atFinest().log( "%s (%s = %s, %s = %s, %s = %s) took %.2f ms", name, @@ -131,6 +163,14 @@ RequestStateContext.abortIfCancelled(); } + private Metadata getMetadata(F1 fieldValue1, F2 fieldValue2, F3 fieldValue3) { + Metadata.Builder metadataBuilder = Metadata.builder(); + field1.metadataMapper().accept(metadataBuilder, fieldValue1); + field2.metadataMapper().accept(metadataBuilder, fieldValue2); + field3.metadataMapper().accept(metadataBuilder, fieldValue3); + return metadataBuilder.build(); + } + /** Suppress logging (debug log and performance log) when values are recorded. */ public final Timer3<F1, F2, F3> suppressLogging() { this.suppressLogging = true;
diff --git a/java/com/google/gerrit/metrics/TimerContext.java b/java/com/google/gerrit/metrics/TimerContext.java index 0e01de0..1e90766 100644 --- a/java/com/google/gerrit/metrics/TimerContext.java +++ b/java/com/google/gerrit/metrics/TimerContext.java
@@ -14,22 +14,35 @@ package com.google.gerrit.metrics; +import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; +import com.google.gerrit.server.logging.LoggingContext; +import com.google.gerrit.server.logging.Metadata; +import com.google.gerrit.server.logging.RunningOperations.RegistrationHandle; abstract class TimerContext implements AutoCloseable { private final long startNanos; private boolean stopped; + private Metadata metadata; + private final RegistrationHandle registrationHandle; - TimerContext() { + TimerContext(String timerName, Metadata metadata) { this.startNanos = System.nanoTime(); + this.metadata = metadata; + this.registrationHandle = + LoggingContext.getInstance().getRunningOperations().add(timerName, metadata); } /** * Record the elapsed time to the timer. * * @param elapsed Elapsed time in nanoseconds. + * @param parentOperations the parent operations that called the operation for which the latency + * is recorded by this timer + * @param metadata metadata that should be recorded/logged */ - public abstract void record(long elapsed); + public abstract void record( + long elapsed, ImmutableList<String> parentOperations, Metadata metadata); /** Returns the start time in system time nanoseconds. */ public long getStartTime() { @@ -47,7 +60,8 @@ if (!stopped) { stopped = true; long elapsed = System.nanoTime() - startNanos; - record(elapsed); + record(elapsed, registrationHandle.parentOperations(), metadata); + registrationHandle.remove(); return elapsed; } throw new IllegalStateException("Already stopped");
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD index c07047d..162e9a9 100644 --- a/java/com/google/gerrit/pgm/BUILD +++ b/java/com/google/gerrit/pgm/BUILD
@@ -44,6 +44,7 @@ "//java/com/google/gerrit/server/version", "//java/com/google/gerrit/sshd", "//lib:args4j", + "//lib:gson", "//lib:guava", "//lib:jgit", "//lib:jgit-ssh-apache", @@ -60,6 +61,5 @@ "//lib/prolog:cafeteria", "//lib/prolog:compiler", "//lib/prolog:runtime", - "@gson//jar", ], )
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java index b3e4dfc..316fee9 100644 --- a/java/com/google/gerrit/pgm/Daemon.java +++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -92,6 +92,7 @@ import com.google.gerrit.server.config.GerritRuntime; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.LogConfig; +import com.google.gerrit.server.config.SendEmailEnabledModule; import com.google.gerrit.server.config.SysExecutorModule; import com.google.gerrit.server.events.EventBroker.EventBrokerModule; import com.google.gerrit.server.events.StreamEventsApiListener.StreamEventsApiListenerModule; @@ -543,6 +544,7 @@ modules.add(new StartupChecksModule()); modules.add(new GerritInstanceNameModule()); modules.add(new GerritInstanceIdModule()); + modules.add(new SendEmailEnabledModule()); if (MoreObjects.firstNonNull(httpd, true)) { modules.add( new CanonicalWebUrlModule() {
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java index 5a00e99..65eb405 100644 --- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java +++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -38,6 +38,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; @@ -46,6 +47,7 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; import javax.servlet.DispatcherType; import javax.servlet.Filter; import javax.servlet.http.HttpSessionEvent; @@ -539,6 +541,12 @@ // It is meant to be used as simpler tiny deployment of custom-made // security enforcement (Security tokens, IP-based security filtering, others) String[] filterClassNames = cfg.getStringList("httpd", null, "filterClass"); + String sameSiteAttribute = cfg.getString("httpd", null, "sameSite"); + if (sameSiteAttribute != null) { + filterClassNames = + Stream.concat(Arrays.stream(filterClassNames), Stream.of(SameSiteFilter.class.getName())) + .toArray(String[]::new); + } for (String filterClassName : filterClassNames) { try { @SuppressWarnings("unchecked")
diff --git a/java/com/google/gerrit/pgm/http/jetty/SameSiteFilter.java b/java/com/google/gerrit/pgm/http/jetty/SameSiteFilter.java new file mode 100644 index 0000000..ae30cd2 --- /dev/null +++ b/java/com/google/gerrit/pgm/http/jetty/SameSiteFilter.java
@@ -0,0 +1,78 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.pgm.http.jetty; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import org.eclipse.jetty.http.HttpCookie; +import org.eclipse.jgit.lib.Config; + +@Singleton +public class SameSiteFilter implements Filter { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final String sameSite; + + @Inject + SameSiteFilter(@GerritServerConfig Config cfg) { + this.sameSite = cfg.getString("httpd", null, "sameSite"); + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException {} + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletResponse rsp = (HttpServletResponse) response; + if (sameSite == null) { + chain.doFilter(request, response); + return; + } + String sameSiteComment = + switch (sameSite.toLowerCase()) { + case "lax" -> HttpCookie.SAME_SITE_LAX_COMMENT; + case "strict" -> HttpCookie.SAME_SITE_STRICT_COMMENT; + case "none" -> HttpCookie.SAME_SITE_NONE_COMMENT; + default -> + throw new ServletException(String.format("Invalid sameSite value: %s", sameSite)); + }; + chain.doFilter( + request, + new HttpServletResponseWrapper(rsp) { + @Override + public void addCookie(Cookie cookie) { + logger.atFine().log("Setting SameSite attribute on: %s", cookie.getName()); + cookie.setComment(sameSiteComment); + super.addCookie(cookie); + } + }); + } + + @Override + public void destroy() {} +}
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdate.java b/java/com/google/gerrit/server/ChangeDraftUpdate.java index a3b13e5..d3cacfd 100644 --- a/java/com/google/gerrit/server/ChangeDraftUpdate.java +++ b/java/com/google/gerrit/server/ChangeDraftUpdate.java
@@ -31,14 +31,18 @@ ChangeDraftUpdate create( ChangeNotes notes, Account.Id accountId, + String loggableName, Account.Id realAccountId, + String realLoggableName, PersonIdent authorIdent, Instant when); ChangeDraftUpdate create( Change change, Account.Id accountId, + String loggableName, Account.Id realAccountId, + String realLoggableName, PersonIdent authorIdent, Instant when); }
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java index 44280a0..abd67be 100644 --- a/java/com/google/gerrit/server/IdentifiedUser.java +++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -338,9 +338,8 @@ public CurrentUser getUserForPermission() { if (permissionMode.equals(ImpersonationPermissionMode.THIS_USER)) { return this; - } else { - return realUser; } + return realUser; } @Override @@ -418,6 +417,16 @@ return state().account(); } + /** + * Returns the email address to use for avatar lookup. + * + * @return the avatar email if set, otherwise the preferred email, may be null if neither is set + */ + @Nullable + public String getEffectiveAvatarEmail() { + return getAccount().effectiveAvatarEmail(); + } + public boolean hasEmailAddress(String email) { if (validEmails.contains(email)) { return true;
diff --git a/java/com/google/gerrit/server/ReviewerStatusUpdate.java b/java/com/google/gerrit/server/ReviewerStatusUpdate.java index 5486359..824bbe78 100644 --- a/java/com/google/gerrit/server/ReviewerStatusUpdate.java +++ b/java/com/google/gerrit/server/ReviewerStatusUpdate.java
@@ -25,21 +25,49 @@ @AutoValue public abstract class ReviewerStatusUpdate { public static ReviewerStatusUpdate createForReviewer( - Instant ts, Account.Id updatedBy, Account.Id reviewer, ReviewerStateInternal state) { + Instant ts, + Account.Id updatedBy, + Account.Id realUpdatedBy, + Account.Id reviewer, + ReviewerStateInternal state) { return new AutoValue_ReviewerStatusUpdate( - ts, updatedBy, Optional.of(reviewer), Optional.empty(), state); + ts, + updatedBy, + Optional.ofNullable(realUpdatedBy), + Optional.of(reviewer), + Optional.empty(), + state); } public static ReviewerStatusUpdate createForReviewerByEmail( - Instant ts, Account.Id updatedBy, Address reviewerByEmail, ReviewerStateInternal state) { + Instant ts, + Account.Id updatedBy, + Account.Id realUpdatedBy, + Address reviewerByEmail, + ReviewerStateInternal state) { return new AutoValue_ReviewerStatusUpdate( - ts, updatedBy, Optional.empty(), Optional.of(reviewerByEmail), state); + ts, + updatedBy, + Optional.ofNullable(realUpdatedBy), + Optional.empty(), + Optional.of(reviewerByEmail), + state); } public abstract Instant date(); public abstract Account.Id updatedBy(); + /** + * The real user that performed the reviewer update. + * + * <p>This field is only set in case of impersonation using the RUN_AS permission. In case of + * impersonation, `updatedBy` will store the user that is being impersonated, and this field will + * store the caller. I.e. if User X is impersonating User Y, then `updatedBy` will be Y and + * `realUpdatedBy` will be X. + */ + public abstract Optional<Account.Id> realUpdatedBy(); + /** Not set if a reviewer for which no Gerrit account exists is added by email. */ public abstract Optional<Account.Id> reviewer();
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java index ca63565..73ca3f4 100644 --- a/java/com/google/gerrit/server/account/AccountControl.java +++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -39,6 +39,33 @@ public class AccountControl { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + /** The result of a visibility check along with a reason. */ + public static class VisibilityDecision { + private final boolean visible; + private final String reason; + + public static VisibilityDecision show(String reason) { + return new VisibilityDecision(true, reason); + } + + public static VisibilityDecision hide(String reason) { + return new VisibilityDecision(false, reason); + } + + private VisibilityDecision(boolean visible, String reason) { + this.visible = visible; + this.reason = reason; + } + + public boolean isVisible() { + return visible; + } + + public String getReason() { + return reason; + } + } + public static class Factory { private final PermissionBackend permissionBackend; private final ProjectCache projectCache; @@ -128,7 +155,7 @@ * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective * groups. */ - public boolean canSee(Account.Id otherUser) { + public VisibilityDecision canSeeWithReason(Account.Id otherUser) { return canSee( new OtherUser() { @Override @@ -150,37 +177,70 @@ * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective * groups. */ - public boolean canSee(AccountState otherUser) { + public boolean canSee(Account.Id otherUser) { return canSee( - new OtherUser() { - @Override - Account.Id getId() { - return otherUser.account().id(); - } + new OtherUser() { + @Override + Account.Id getId() { + return otherUser; + } - @Override - IdentifiedUser createUser() { - return userFactory.create(otherUser); - } - }); + @Override + IdentifiedUser createUser() { + return userFactory.create(otherUser); + } + }) + .isVisible(); } - private boolean canSee(OtherUser otherUser) { + /** + * Returns true if the current user is allowed to see the otherUser, based on the account + * visibility policy. Depending on the group membership realms supported, this may not be able to + * determine SAME_GROUP or VISIBLE_GROUP correctly (defaulting to not being visible). This is + * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective + * groups. + */ + public boolean canSee(AccountState otherUser) { + return canSee( + new OtherUser() { + @Override + Account.Id getId() { + return otherUser.account().id(); + } + + @Override + IdentifiedUser createUser() { + return userFactory.create(otherUser); + } + }) + .isVisible(); + } + + private VisibilityDecision canSee(OtherUser otherUser) { if (accountVisibility == AccountVisibility.ALL) { - logger.atFine().log( - "user %s can see account %d (accountVisibility = %s)", - user.getLoggableName(), otherUser.getId().get(), AccountVisibility.ALL); - return true; - } else if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser.getId())) { + String reason = + String.format( + "user %s can see account %d (accountVisibility = %s)", + user.getLoggableName(), otherUser.getId().get(), AccountVisibility.ALL); + logger.atFine().log("%s", reason); + return VisibilityDecision.show(reason); + } + if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser.getId())) { // I can always see myself. - logger.atFine().log( - "user %s can see own account %d", user.getLoggableName(), otherUser.getId().get()); - return true; - } else if (canViewAll()) { - logger.atFine().log( - "user %s can see account %d (view all accounts = true)", - user.getLoggableName(), otherUser.getId().get()); - return true; + String reason = + String.format( + "user %s can see account %d (user can see own account)", + user.getLoggableName(), otherUser.getId().get()); + logger.atFine().log("%s", reason); + return VisibilityDecision.show(reason); + } + if (canViewAll()) { + String reason = + String.format( + "user %s can see account %d (view all accounts = true)", + user.getLoggableName(), otherUser.getId().get()); + logger.atFine().log("%s", reason); + return VisibilityDecision.show(reason); } switch (accountVisibility) { @@ -201,20 +261,24 @@ } if (user.getEffectiveGroups().containsAnyOf(usersGroups)) { - logger.atFine().log( - "user %s can see account %d because they share a group (accountVisibility = %s)", - user.getLoggableName(), otherUser.getId().get(), AccountVisibility.SAME_GROUP); - return true; + String reason = + String.format( + "user %s can see account %d (they share a group (accountVisibility = %s))", + user.getLoggableName(), otherUser.getId().get(), AccountVisibility.SAME_GROUP); + logger.atFine().log("%s", reason); + return VisibilityDecision.show(reason); } - logger.atFine().log( - "user %s cannot see account %d because they don't share a group" - + " (accountVisibility = %s)", - user.getLoggableName(), otherUser.getId().get(), AccountVisibility.SAME_GROUP); + String reason = + String.format( + "user %s cannot see account %d (they don't share a group (accountVisibility =" + + " %s))", + user.getLoggableName(), otherUser.getId().get(), AccountVisibility.SAME_GROUP); + logger.atFine().log("%s", reason); logger.atFine().log("groups of user %s: %s", user.getLoggableName(), groupsOf(user)); logger.atFine().log( "groups of other user %s: %s", otherUser.getUser().getLoggableName(), usersGroups); - return false; + return VisibilityDecision.hide(reason); } case VISIBLE_GROUP: { @@ -222,33 +286,39 @@ for (AccountGroup.UUID usersGroup : usersGroups) { try { if (groupControlFactory.controlFor(usersGroup).isVisible()) { - logger.atFine().log( - "user %s can see account %d because it is member of the visible group %s" - + " (accountVisibility = %s)", - user.getLoggableName(), - otherUser.getId().get(), - usersGroup.get(), - AccountVisibility.VISIBLE_GROUP); - return true; + String reason = + String.format( + "user %s can see account %d (account is member of the visible group %s" + + " (accountVisibility = %s))", + user.getLoggableName(), + otherUser.getId().get(), + usersGroup.get(), + AccountVisibility.VISIBLE_GROUP); + logger.atFine().log("%s", reason); + return VisibilityDecision.show(reason); } } catch (NoSuchGroupException e) { continue; } } - logger.atFine().log( - "user %s cannot see account %d because none of its groups are visible" - + " (accountVisibility = %s)", - user.getLoggableName(), otherUser.getId().get(), AccountVisibility.VISIBLE_GROUP); + String reason = + String.format( + "user %s cannot see account %d (none of its groups are visible" + + " (accountVisibility = %s))", + user.getLoggableName(), otherUser.getId().get(), AccountVisibility.VISIBLE_GROUP); + logger.atFine().log("%s", reason); logger.atFine().log( "groups of other user %s: %s", otherUser.getUser().getLoggableName(), usersGroups); - return false; + return VisibilityDecision.hide(reason); } case NONE: - logger.atFine().log( - "user %s cannot see account %d (accountVisibility = %s)", - user.getLoggableName(), otherUser.getId().get(), AccountVisibility.NONE); - return false; + String reason = + String.format( + "user %s cannot see account %d (accountVisibility = %s)", + user.getLoggableName(), otherUser.getId().get(), AccountVisibility.NONE); + logger.atFine().log("%s", reason); + return VisibilityDecision.hide(reason); case ALL: default: throw new IllegalStateException("Bad AccountVisibility " + accountVisibility);
diff --git a/java/com/google/gerrit/server/account/AccountDelta.java b/java/com/google/gerrit/server/account/AccountDelta.java index 50f34a5..da28e62 100644 --- a/java/com/google/gerrit/server/account/AccountDelta.java +++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -83,6 +83,15 @@ public abstract Optional<String> getPreferredEmail(); /** + * Returns the new value for the avatar email. + * + * @return the new value for the avatar email, {@code Optional#empty()} if the avatar email is not + * being updated, {@code Optional#of("")} if the avatar email is unset (falls back to + * preferredEmail), the wrapped value is never {@code null} + */ + public abstract Optional<String> getAvatarEmail(); + + /** * Returns the new value for the active flag. * * @return the new value for the active flag, {@code Optional#empty()} if the active flag is not @@ -222,6 +231,15 @@ public abstract Builder setPreferredEmail(@Nullable String preferredEmail); /** + * Sets a new avatar email for the account. + * + * @param avatarEmail the new avatar email, if {@code null} or empty string the avatar email is + * unset (falls back to preferredEmail for avatar lookup) + */ + @CanIgnoreReturnValue + public abstract Builder setAvatarEmail(@Nullable String avatarEmail); + + /** * Sets the active flag for the account. * * @param active {@code true} if the account should be set to active, {@code false} if the @@ -570,6 +588,12 @@ } @Override + public Builder setAvatarEmail(String avatarEmail) { + delegate.setAvatarEmail(Strings.nullToEmpty(avatarEmail)); + return this; + } + + @Override public Builder setActive(boolean active) { delegate.setActive(active); return this;
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java index 3d4fb88..62981a2 100644 --- a/java/com/google/gerrit/server/account/AccountManager.java +++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -173,7 +173,8 @@ throw new AccountException("Authentication error, account not found"); } if (!act.get().isActive()) { - throw new AccountException("Authentication error, account inactive"); + throw new AccountException( + "Authentication error, account %s inactive".formatted(act.get().id())); } // return the identity to the caller.
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java index 9cc20d4..18fa21e 100644 --- a/java/com/google/gerrit/server/account/AccountProperties.java +++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -54,6 +54,7 @@ public static final String KEY_FULL_NAME = "fullName"; public static final String KEY_DISPLAY_NAME = "displayName"; public static final String KEY_PREFERRED_EMAIL = "preferredEmail"; + public static final String KEY_AVATAR_EMAIL = "avatarEmail"; public static final String KEY_STATUS = "status"; private final Account.Id accountId; @@ -95,6 +96,9 @@ String preferredEmail = get(accountConfig, KEY_PREFERRED_EMAIL); accountBuilder.setPreferredEmail(preferredEmail); + String avatarEmail = get(accountConfig, KEY_AVATAR_EMAIL); + accountBuilder.setAvatarEmail(avatarEmail); + accountBuilder.setStatus(get(accountConfig, KEY_STATUS)); accountBuilder.setMetaId(metaId != null ? metaId.name() : null); accountBuilder.setUniqueTag(accountBuilder.metaId()); @@ -113,6 +117,7 @@ accountDelta .getPreferredEmail() .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail)); + accountDelta.getAvatarEmail().ifPresent(avatarEmail -> set(cfg, KEY_AVATAR_EMAIL, avatarEmail)); accountDelta.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status)); }
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java index 009266d..1fb0271 100644 --- a/java/com/google/gerrit/server/account/AccountResolver.java +++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -26,6 +26,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; +import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.UsedAt; import com.google.gerrit.common.UsedAt.Project; import com.google.gerrit.entities.Account; @@ -78,6 +79,8 @@ */ @Singleton public class AccountResolver { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + public static class UnresolvableAccountException extends UnprocessableEntityException { private static final long serialVersionUID = 1L; private final Result result; @@ -305,7 +308,15 @@ private abstract class AccountIdSearcher implements Searcher<Account.Id> { @Override public final Stream<AccountState> search(Account.Id input) { - return accountCache.get(input).stream(); + Optional<AccountState> accountState = accountCache.get(input); + if (accountState.isPresent()) { + logger.atFine().log( + "%s: Matched account %s for input '%s'", + getClass().getSimpleName(), accountState.get().account().id(), input); + } else { + logger.atFine().log("%s: No match for input '%s'", getClass().getSimpleName(), input); + } + return accountState.stream(); } } @@ -328,13 +339,19 @@ @Override public Stream<AccountState> search(String input, CurrentUser asUser) { if (!asUser.isIdentifiedUser()) { + logger.atFine().log("%s: No match for input %s", getClass().getSimpleName(), input); return Stream.empty(); } - return Stream.of(asUser.asIdentifiedUser().state()); + AccountState accountState = asUser.asIdentifiedUser().state(); + logger.atFine().log( + "%s: Matched account %s for input %s", + getClass().getSimpleName(), accountState.account().id(), input); + return Stream.of(accountState); } @Override public boolean shortCircuitIfNoResults() { + logger.atFine().log("%s: shortCircuitIfNoResults=true", getClass().getSimpleName()); return true; } } @@ -352,6 +369,7 @@ @Override public boolean shortCircuitIfNoResults() { + logger.atFine().log("%s: shortCircuitIfNoResults=true", getClass().getSimpleName()); return true; } } @@ -367,6 +385,7 @@ @Override public boolean shortCircuitIfNoResults() { + logger.atFine().log("%s: shortCircuitIfNoResults=true", getClass().getSimpleName()); return true; } } @@ -379,7 +398,15 @@ @Override public Stream<AccountState> search(String input) { - return accountCache.getByUsername(input).stream(); + Optional<AccountState> accountState = accountCache.getByUsername(input); + if (accountState.isPresent()) { + logger.atFine().log( + "%s: Matched account %s for input '%s'", + getClass().getSimpleName(), accountState.get().account().id(), input); + } else { + logger.atFine().log("%s: No match for input '%s'", getClass().getSimpleName(), input); + } + return accountState.stream(); } @Override @@ -401,9 +428,19 @@ // TODO(dborowitz): This would probably work as a Searcher<Address> int lt = nameOrEmail.indexOf('<'); int gt = nameOrEmail.indexOf('>'); - ImmutableSet<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt)); + String email = nameOrEmail.substring(lt + 1, gt); + ImmutableSet<Account.Id> ids = emails.getAccountFor(email); + logger.atFine().log("%s: accounts for email %s: %s", getClass().getSimpleName(), email, ids); ImmutableList<AccountState> allMatches = toAccountStates(ids).collect(toImmutableList()); - if (allMatches.isEmpty() || allMatches.size() == 1) { + if (allMatches.isEmpty()) { + logger.atFine().log( + "%s: No match for email %s (input=%s)", getClass().getSimpleName(), email, nameOrEmail); + return Stream.empty(); + } + if (allMatches.size() == 1) { + logger.atFine().log( + "%s: Matched account %s for email %s (input=%s)", + getClass().getSimpleName(), allMatches.getFirst().account().id(), email, nameOrEmail); return allMatches.stream(); } @@ -411,6 +448,14 @@ // subset. Otherwise, all are equally non-matching, so return the full set. if (lt == 0) { // No name was specified in the input string. + logger.atFine().log( + "%s: Matched accounts %s for email %s (input=%s)", + getClass().getSimpleName(), + allMatches.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList()), + email, + nameOrEmail); return allMatches.stream(); } String name = nameOrEmail.substring(0, lt - 1); @@ -418,11 +463,33 @@ allMatches.stream() .filter(a -> name.equals(a.account().fullName())) .collect(toImmutableList()); - return !nameMatches.isEmpty() ? nameMatches.stream() : allMatches.stream(); + if (!nameMatches.isEmpty()) { + logger.atFine().log( + "%s: Matched accounts %s for email %s and name %s (input=%s)", + getClass().getSimpleName(), + nameMatches.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList()), + email, + name, + nameOrEmail); + return nameMatches.stream(); + } + + logger.atFine().log( + "%s: Matched accounts %s for email %s, no name matched (input=%s)", + getClass().getSimpleName(), + allMatches.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList()), + email, + nameOrEmail); + return allMatches.stream(); } @Override public boolean shortCircuitIfNoResults() { + logger.atFine().log("%s: shortCircuitIfNoResults=true", getClass().getSimpleName()); return true; } } @@ -440,63 +507,106 @@ @Override 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; + logger.atFine().log( + "%s: user %s can see secondary emails", + getClass().getSimpleName(), asUser.getLoggableName()); + ImmutableSet<Account.Id> ids = emails.getAccountFor(input); + if (!ids.isEmpty()) { + logger.atFine().log( + "%s: Matched accounts %s for input '%s'", getClass().getSimpleName(), ids, input); + } else { + logger.atFine().log("%s: No match for input '%s'", getClass().getSimpleName(), input); + } + return toAccountStates(ids); } } catch (PermissionBackendException e) { - // remains false - } - - if (canViewSecondaryEmails) { - return toAccountStates(emails.getAccountFor(input)); + // assume that the user cannot see secondary emails } // User cannot see secondary emails, hence search by preferred email only. List<AccountState> accountStates = accountQueryProvider.get().byPreferredEmail(input); if (accountStates.size() == 1) { + logger.atFine().log( + "%s: Matched account %s for input '%s'", + getClass().getSimpleName(), accountStates.getFirst().account().id(), input); return Stream.of(Iterables.getOnlyElement(accountStates)); } if (accountStates.size() > 1) { + logger.atFine().log( + "%s: Accounts with preferred email %s: %s", + getClass().getSimpleName(), + input, + accountStates.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList())); + // 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. + // that they do not own via an external ID or multiple accounts own the same email via + // different external IDs. In this case we return only the accounts + // that actually own the email via an external ID. for (AccountState accountState : accountStates) { + List<AccountState> accountStatesWithExternalIdForEmail = new ArrayList<>(); if (accountState.externalIds().stream() .map(ExternalId::email) .filter(Objects::nonNull) .anyMatch(email -> email.equals(input))) { - return Stream.of(accountState); + accountStatesWithExternalIdForEmail.add(accountState); + } + if (!accountStatesWithExternalIdForEmail.isEmpty()) { + logger.atFine().log( + "%s: Matched accounts %s for input '%s'", + getClass().getSimpleName(), + accountStatesWithExternalIdForEmail.stream() + .map(as -> as.account().id()) + .collect(toImmutableList()), + input); + return accountStatesWithExternalIdForEmail.stream(); } } // 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. + logger.atFine().log( + "%s: Matched accounts %s for input '%s' (none of the accounts owns the email via an" + + " external ID)", + getClass().getSimpleName(), + accountStates.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList()), + input); 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. + logger.atFine().log( + "%s: No account with preferred email %s", getClass().getSimpleName(), input); if (asUser.isIdentifiedUser() && asUser.asIdentifiedUser().state().externalIds().stream() .map(ExternalId::email) .filter(Objects::nonNull) .anyMatch(email -> email.equals(input))) { + logger.atFine().log( + "%s: Matched account %s for input %s (user can see their own secondary email)", + getClass().getSimpleName(), asUser.asIdentifiedUser().getAccountId(), input); return Stream.of(asUser.asIdentifiedUser().state()); } // No match. + logger.atFine().log("%s: No match for input %s", getClass().getSimpleName(), input); return Stream.empty(); } @Override public boolean shortCircuitIfNoResults() { + logger.atFine().log("%s: shortCircuitIfNoResults=true", getClass().getSimpleName()); return true; } } @@ -504,7 +614,15 @@ private class FromRealm extends AccountIdSearcher { @Override public Optional<Account.Id> tryParse(String input) throws IOException { - return Optional.ofNullable(realm.lookup(input)); + Account.Id accountId = realm.lookup(input); + if (accountId != null) { + logger.atFine().log( + "%s: Matched account %s for input %s", getClass().getSimpleName(), accountId, input); + return Optional.of(accountId); + } + + logger.atFine().log("%s: No match for input %s", getClass().getSimpleName(), input); + return Optional.empty(); } @Override @@ -525,7 +643,19 @@ @Override public Stream<AccountState> search(String input) { - return accountQueryProvider.get().byFullName(input).stream(); + List<AccountState> accountStates = accountQueryProvider.get().byFullName(input); + if (!accountStates.isEmpty()) { + logger.atFine().log( + "%s: Matched accounts %s for input '%s'", + getClass().getSimpleName(), + accountStates.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList()), + input); + } else { + logger.atFine().log("%s: No match for input '%s'", getClass().getSimpleName(), input); + } + return accountStates.stream(); } @Override @@ -563,7 +693,23 @@ } catch (PermissionBackendException e) { // remains false } - return accountQueryProvider.get().byDefault(input, canViewSecondaryEmails).stream(); + List<AccountState> accountStates = + accountQueryProvider.get().byDefault(input, canViewSecondaryEmails); + if (!accountStates.isEmpty()) { + logger.atFine().log( + "%s: Matched accounts %s for input '%s' (canViewSecondaryEmails=%s)", + getClass().getSimpleName(), + accountStates.stream() + .map(accountState -> accountState.account().id()) + .collect(toImmutableList()), + input, + canViewSecondaryEmails); + } else { + logger.atFine().log( + "%s: No match for input '%s' (canViewSecondaryEmails=%s)", + getClass().getSimpleName(), input, canViewSecondaryEmails); + } + return accountStates.stream(); } @Override @@ -857,6 +1003,9 @@ } private static boolean isActive(AccountState accountState) { + logger.atFine().log( + "account %s is %s", + accountState.account().id(), accountState.account().isActive() ? "active" : "inactive"); return accountState.account().isActive(); }
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java index 03c6631..bb7ee97 100644 --- a/java/com/google/gerrit/server/account/AuthRequest.java +++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -25,6 +25,7 @@ import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory; import com.google.inject.Inject; import com.google.inject.Singleton; +import java.util.Objects; import java.util.Optional; /** @@ -215,4 +216,37 @@ public void setActive(Boolean isActive) { this.active = isActive; } + + @Override + public int hashCode() { + return Objects.hash( + active, + authPlugin, + authProvider, + authProvidesAccountActiveStatus, + displayName, + emailAddress, + externalId, + password, + skipAuthentication, + userName); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (!(obj instanceof AuthRequest)) return false; + AuthRequest other = (AuthRequest) obj; + return active == other.active + && Objects.equals(authPlugin, other.authPlugin) + && Objects.equals(authProvider, other.authProvider) + && authProvidesAccountActiveStatus == other.authProvidesAccountActiveStatus + && Objects.equals(displayName, other.displayName) + && Objects.equals(emailAddress, other.emailAddress) + && Objects.equals(externalId, other.externalId) + && Objects.equals(password, other.password) + && skipAuthentication == other.skipAuthentication + && Objects.equals(userName, other.userName); + } }
diff --git a/java/com/google/gerrit/server/account/AuthToken.java b/java/com/google/gerrit/server/account/AuthToken.java index 390042b..31ea7ca 100644 --- a/java/com/google/gerrit/server/account/AuthToken.java +++ b/java/com/google/gerrit/server/account/AuthToken.java
@@ -65,7 +65,8 @@ private static void validateId(String id) throws InvalidAuthTokenException { if (!TOKEN_ID_PATTERN.matcher(id).matches()) { throw new InvalidAuthTokenException( - "Token ID must contain only letters, numbers, hyphens and underscores."); + "Token ID must start with a letter and contain only letters (a-z,A-Z], numbers [0-9], " + + "hyphens [-] and underscores [_]."); } } }
diff --git a/java/com/google/gerrit/server/account/DefaultRealm.java b/java/com/google/gerrit/server/account/DefaultRealm.java index 665e034..6f90572 100644 --- a/java/com/google/gerrit/server/account/DefaultRealm.java +++ b/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -51,17 +51,12 @@ case REGISTER_NEW_EMAIL -> authConfig.isAllowRegisterNewEmail() && Strings.emptyToNull(authConfig.getHttpEmailHeader()) == null; - default -> true; }; } - switch (field) { - case REGISTER_NEW_EMAIL: - return authConfig.isAllowRegisterNewEmail(); - case FULL_NAME: - case USER_NAME: - default: - return true; - } + return switch (field) { + case REGISTER_NEW_EMAIL -> authConfig.isAllowRegisterNewEmail(); + case FULL_NAME, USER_NAME -> true; + }; } @Override
diff --git a/java/com/google/gerrit/server/account/UserKind.java b/java/com/google/gerrit/server/account/UserKind.java new file mode 100644 index 0000000..1b472b7 --- /dev/null +++ b/java/com/google/gerrit/server/account/UserKind.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.account; + +/** User kind to classify the caller. */ +public enum UserKind { + /** User that was classified as a service user by {@link ServiceUserClassifier}. */ + SERVICE_USER, + + /** + * Human user, any user that was not classified as a service user by {@link ServiceUserClassifier} + * and anonymous users. + */ + HUMAN_USER; +}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java index eb9af6b..247efdc 100644 --- a/java/com/google/gerrit/server/account/externalids/ExternalId.java +++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -24,7 +24,6 @@ import com.google.common.hash.Hashing; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; -import com.google.gerrit.extensions.client.AuthType; import java.io.Serializable; import java.util.Collection; import java.util.Locale;
diff --git a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java index 4ae31cf..27d86ec 100644 --- a/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java +++ b/java/com/google/gerrit/server/account/externalids/storage/notedb/ExternalIdNotes.java
@@ -997,8 +997,8 @@ checkState( extId.equals(actualExtId), "external id %s should be removed, but it doesn't match the actual external id %s", - extId.toString(), - actualExtId.toString()); + extId, + actualExtId); noteMap.remove(noteId); }
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java index 9ce3d79..060a8a6 100644 --- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java +++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -54,6 +54,7 @@ import com.google.gerrit.extensions.common.CommitMessageInfo; import com.google.gerrit.extensions.common.CommitMessageInput; import com.google.gerrit.extensions.common.EvaluateChangeQueryExpressionResultInfo; +import com.google.gerrit.extensions.common.FlowActionTypeInfo; import com.google.gerrit.extensions.common.FlowInfo; import com.google.gerrit.extensions.common.FlowInput; import com.google.gerrit.extensions.common.Input; @@ -121,6 +122,7 @@ import com.google.gerrit.server.restapi.flow.CreateFlow; import com.google.gerrit.server.restapi.flow.FlowCollection; import com.google.gerrit.server.restapi.flow.IsFlowsEnabled; +import com.google.gerrit.server.restapi.flow.ListActions; import com.google.gerrit.server.restapi.flow.ListFlows; import com.google.gerrit.util.cli.CmdLineParser; import com.google.inject.Inject; @@ -190,6 +192,7 @@ private final PutMessage putMessage; private final CreateFlow createFlow; private final ListFlows listFlows; + private final ListActions listActions; private final IsFlowsEnabled isFlowsEnabled; private final Provider<EvaluateChangeQueryExpression> evaluateChangeQueryExpressionProvider; private final Provider<GetPureRevert> getPureRevertProvider; @@ -249,6 +252,7 @@ PutMessage putMessage, CreateFlow createFlow, ListFlows listFlows, + ListActions listActions, IsFlowsEnabled isFlowsEnabled, Provider<EvaluateChangeQueryExpression> evaluateChangeQueryExpressionProvider, Provider<GetPureRevert> getPureRevertProvider, @@ -306,6 +310,7 @@ this.putMessage = putMessage; this.createFlow = createFlow; this.listFlows = listFlows; + this.listActions = listActions; this.isFlowsEnabled = isFlowsEnabled; this.evaluateChangeQueryExpressionProvider = evaluateChangeQueryExpressionProvider; this.getPureRevertProvider = getPureRevertProvider; @@ -375,6 +380,15 @@ } @Override + public List<FlowActionTypeInfo> flowsActions() throws RestApiException { + try { + return listActions.apply(change).value(); + } catch (Exception e) { + throw asRestApiException("Cannot list actions", e); + } + } + + @Override public EvaluateChangeQueryExpressionRequest evaluateChangeQueryExpression() { return new EvaluateChangeQueryExpressionRequest() { @Override
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java index 1198ac9..01b14f2 100644 --- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java +++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -51,6 +51,7 @@ import com.google.gerrit.extensions.common.FileInfo; import com.google.gerrit.extensions.common.Input; import com.google.gerrit.extensions.common.MergeableInfo; +import com.google.gerrit.extensions.common.RevisionInfo; import com.google.gerrit.extensions.common.TestSubmitRuleInfo; import com.google.gerrit.extensions.common.TestSubmitRuleInput; import com.google.gerrit.extensions.restapi.BinaryResult; @@ -78,6 +79,7 @@ import com.google.gerrit.server.restapi.change.GetMergeList; import com.google.gerrit.server.restapi.change.GetPatch; import com.google.gerrit.server.restapi.change.GetRelated; +import com.google.gerrit.server.restapi.change.GetRevision; import com.google.gerrit.server.restapi.change.GetRevisionActions; import com.google.gerrit.server.restapi.change.ListPortedComments; import com.google.gerrit.server.restapi.change.ListPortedDrafts; @@ -111,6 +113,7 @@ private final GitRepositoryManager repoManager; private final Changes changes; + private final GetRevision getRevision; private final RevisionReviewers revisionReviewers; private final RevisionReviewerApiImpl.Factory revisionReviewerApi; private final CherryPick cherryPick; @@ -157,6 +160,7 @@ RevisionApiImpl( GitRepositoryManager repoManager, Changes changes, + GetRevision getRevision, RevisionReviewers revisionReviewers, RevisionReviewerApiImpl.Factory revisionReviewerApi, CherryPick cherryPick, @@ -200,6 +204,7 @@ @Assisted RevisionResource r) { this.repoManager = repoManager; this.changes = changes; + this.getRevision = getRevision; this.revisionReviewers = revisionReviewers; this.revisionReviewerApi = revisionReviewerApi; this.cherryPick = cherryPick; @@ -244,6 +249,15 @@ } @Override + public RevisionInfo get() throws RestApiException { + try { + return getRevision.apply(revision).value(); + } catch (Exception e) { + throw asRestApiException("Cannot get revision", e); + } + } + + @Override public ReviewResult review(ReviewInput in) throws RestApiException { try { return review.apply(revision, in).value();
diff --git a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java index 6d6bd01..1124e33 100644 --- a/java/com/google/gerrit/server/api/projects/ProjectsImpl.java +++ b/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -112,8 +112,6 @@ case ALL -> FilterType.ALL; case CODE -> FilterType.CODE; case PERMISSIONS -> FilterType.PERMISSIONS; - default -> - throw new BadRequestException("Unknown filter type: " + request.getFilterType()); }; lp.setFilterType(type);
diff --git a/java/com/google/gerrit/server/approval/ApprovalCopier.java b/java/com/google/gerrit/server/approval/ApprovalCopier.java index 6063d72..9d0fd15 100644 --- a/java/com/google/gerrit/server/approval/ApprovalCopier.java +++ b/java/com/google/gerrit/server/approval/ApprovalCopier.java
@@ -486,7 +486,7 @@ evaluateAtoms(copyConditionPredicate, ctx, passingAtoms, failingAtoms); return result; } catch (QueryParseException e) { - logger.atWarning().withCause(e).log( + logger.atSevere().withCause(e).log( "Unable to copy label because config is invalid. This should have been caught before."); return false; }
diff --git a/java/com/google/gerrit/server/cache/CacheModule.java b/java/com/google/gerrit/server/cache/CacheModule.java index 85c5cc56..f7ed408 100644 --- a/java/com/google/gerrit/server/cache/CacheModule.java +++ b/java/com/google/gerrit/server/cache/CacheModule.java
@@ -37,6 +37,7 @@ public static final String PERSISTENT_MODULE = "cache-persistent"; private static final TypeLiteral<Cache<?, ?>> ANY_CACHE = new TypeLiteral<>() {}; + private static final TypeLiteral<CacheDef<?, ?>> ANY_CACHE_DEF = new TypeLiteral<>() {}; /** * Declare a named in-memory cache. @@ -182,7 +183,7 @@ @SuppressWarnings("unchecked") Key<CacheDef<K, V>> cacheDefKey = (Key<CacheDef<K, V>>) Key.get(cacheDefType, named); bind(cacheDefKey).toInstance(m); - + bind(ANY_CACHE_DEF).annotatedWith(Exports.named(name)).to(cacheDefKey); m.maximumWeight(1024); } }
diff --git a/java/com/google/gerrit/server/change/ActionJson.java b/java/com/google/gerrit/server/change/ActionJson.java index 950d390..3bc3b6d 100644 --- a/java/com/google/gerrit/server/change/ActionJson.java +++ b/java/com/google/gerrit/server/change/ActionJson.java
@@ -119,6 +119,7 @@ ChangeInfo copy = new ChangeInfo(); copy.project = changeInfo.project; copy.branch = changeInfo.branch; + copy.fullBranch = changeInfo.fullBranch; copy.topic = changeInfo.topic; copy.attentionSet = changeInfo.attentionSet == null ? null : ImmutableMap.copyOf(changeInfo.attentionSet);
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java index 6690911..239fa7a 100644 --- a/java/com/google/gerrit/server/change/AddReviewersOp.java +++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -121,6 +121,11 @@ @Override public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException { change = ctx.getChange(); + + // Reviewer updates do not create change messages. In case of impersonation, we do not want to + // add an extra message to the log. + ctx.getUpdate(change.currentPatchSetId()).setSuppressImpersonationMessage(true); + if (!accountIds.isEmpty()) { if (state == CC) { addedCCs =
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java index 366bf1e..25c2279 100644 --- a/java/com/google/gerrit/server/change/ChangeInserter.java +++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -58,6 +58,7 @@ import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput; import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification; import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.events.CommitReceivedEvent; import com.google.gerrit.server.extensions.events.CommentAdded; @@ -68,6 +69,9 @@ import com.google.gerrit.server.git.validators.CommitValidationInfoListener; import com.google.gerrit.server.git.validators.CommitValidators; import com.google.gerrit.server.git.validators.TopicValidator; +import com.google.gerrit.server.logging.Metadata; +import com.google.gerrit.server.logging.TraceContext; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; import com.google.gerrit.server.mail.send.MessageIdGenerator; @@ -82,7 +86,6 @@ import com.google.gerrit.server.plugincontext.PluginSetContext; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.project.ProjectState; -import com.google.gerrit.server.ssh.NoSshInfo; import com.google.gerrit.server.update.ChangeContext; import com.google.gerrit.server.update.Context; import com.google.gerrit.server.update.InsertChangeOp; @@ -133,6 +136,7 @@ private final DiffOperationsForCommitValidation.Factory diffOperationsForCommitValidationFactory; private final PluginSetContext<ValidationOptionsListener> validationOptionsListeners; private final PluginSetContext<CommitValidationInfoListener> commitValidationInfoListeners; + private final boolean sendEmailEnabled; private final Change.Id changeId; private final PatchSet.Id psId; @@ -181,6 +185,7 @@ ChangeMessagesUtil cmUtil, EmailFactories emailFactories, @SendEmailExecutor ExecutorService sendEmailExecutor, + @SendEmailEnabled Boolean sendEmailEnabled, CommitValidators.Factory commitValidatorsFactory, TopicValidator topicValidator, CommentAdded commentAdded, @@ -223,6 +228,7 @@ this.approvals = Collections.emptyMap(); this.fireRevisionCreated = true; this.sendMail = true; + this.sendEmailEnabled = sendEmailEnabled; this.updateRef = true; } @@ -598,7 +604,7 @@ public void postUpdate(PostUpdateContext ctx) throws Exception { reviewerAdditions.postUpdate(ctx); NotifyResolver.Result notify = ctx.getNotify(change.getId()); - if (sendMail) { + if (sendMail && sendEmailEnabled) { Runnable sender = new Runnable() { @Override @@ -644,7 +650,15 @@ Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(sender)); } else { - sender.run(); + try (TraceTimer timer = + TraceContext.newTimer( + "ChangeInserterSynchronousEmailNotification", + Metadata.builder() + .projectName(change.getProject().get()) + .changeId(change.getId().get()) + .build())) { + sender.run(); + } } } @@ -714,7 +728,6 @@ permissionBackend.user(ctx.getUser()).project(ctx.getProject()), BranchNameKey.create(ctx.getProject(), refName), ctx.getIdentifiedUser(), - new NoSshInfo(), ctx.getRevWalk(), change) .patchSet(psId)
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java index 52c7454..89c610a 100644 --- a/java/com/google/gerrit/server/change/ChangeJson.java +++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -36,6 +36,8 @@ import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS; import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS; import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo; +import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.ENABLE_AI_CHAT; +import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS; import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly; import static com.google.gerrit.server.util.AttentionSetUtil.removalsOnly; import static java.util.stream.Collectors.toList; @@ -103,6 +105,7 @@ import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.TrackingFooters; +import com.google.gerrit.server.experiments.ExperimentFeatures; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.index.change.ChangeField; import com.google.gerrit.server.logging.Metadata; @@ -249,6 +252,7 @@ private final boolean includeMergeable; private final boolean lazyLoad; private final boolean cacheQueryResultsByChangeNum; + private final ExperimentFeatures experimentFeatures; private AccountLoader accountLoader; private FixInput fix; @@ -272,6 +276,7 @@ Metrics metrics, RevisionJson.Factory revisionJsonFactory, @GerritServerConfig Config cfg, + ExperimentFeatures experimentFeatures, @Assisted Iterable<ListChangesOption> options, @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) { this.repoManager = repoManager; @@ -296,7 +301,7 @@ this.pluginDefinedInfosFactory = pluginDefinedInfosFactory; this.cacheQueryResultsByChangeNum = cfg.getBoolean("index", "cacheQueryResultsByChangeNum", true); - + this.experimentFeatures = experimentFeatures; logger.atFine().log("options = %s", options); } @@ -611,6 +616,7 @@ if (c != null) { info.project = c.getProject().get(); info.branch = c.getDest().shortName(); + info.fullBranch = c.getDest().branch(); info.topic = c.getTopic(); info.changeId = c.getKey().get(); info.subject = c.getSubject(); @@ -663,6 +669,7 @@ Change in = cd.change(); out.project = in.getProject().get(); out.branch = in.getDest().shortName(); + out.fullBranch = in.getDest().branch(); out.currentRevisionNumber = in.currentPatchSetId().get(); out.topic = in.getTopic(); if (!cd.attentionSet().isEmpty()) { @@ -696,12 +703,22 @@ if (has(SUBMITTABLE)) { out.submittable = submittable(cd); } + } else { + // ABANDONED and MERGED changes are not considered submittable. + if (has(SUBMITTABLE)) { + out.submittable = false; + } } if (!has(SKIP_DIFFSTAT)) { - Optional<ChangedLines> changedLines = cd.changedLines(); - if (changedLines.isPresent()) { - out.insertions = changedLines.get().insertions; - out.deletions = changedLines.get().deletions; + try (TraceTimer timer = + TraceContext.newTimer( + "Compute diffstats", + Metadata.builder().changeId(cd.change().getId().get()).build())) { + Optional<ChangedLines> changedLines = cd.changedLines(); + if (changedLines.isPresent()) { + out.insertions = changedLines.get().insertions; + out.deletions = changedLines.get().deletions; + } } } out.isPrivate = in.isPrivate() ? true : null; @@ -726,8 +743,11 @@ out.reviewed = isReviewedByCurrentUser(cd, user); out.starred = isStarredByCurrentUser(cd, user); out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS)); - out.requirements = requirementsFor(cd); - out.submitRecords = submitRecordsFor(cd); + if (!experimentFeatures.isFeatureEnabled( + SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS, cd.project())) { + out.requirements = requirementsFor(cd); + out.submitRecords = submitRecordsFor(cd); + } if (has(SUBMIT_REQUIREMENTS)) { out.submitRequirements = submitRequirementsFor(cd); } @@ -798,6 +818,21 @@ if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) { actionJson.addChangeActions(out, cd); + // TODO(AI review experiment): Remove experiment gate when UiFeature__enable_ai_chat is + // removed. + if (experimentFeatures.isFeatureEnabled(ENABLE_AI_CHAT)) { + try { + out.canAiReview = + toBoolean( + permissionBackend + .user(userProvider.get()) + .change(cd) + .test(ChangePermission.AI_REVIEW)); + } catch (PermissionBackendException e) { + logger.atWarning().withCause(e).log( + "Failed to check AI review permission for change %s", cd.getId()); + } + } } if (has(TRACKING_IDS)) { @@ -848,6 +883,7 @@ new ReviewerUpdateInfo( c.date(), accountLoader.get(c.updatedBy()), + c.realUpdatedBy().map(accountLoader::get).orElse(null), accountLoader.get(c.reviewer().get()), c.state().asReviewerState())); } @@ -857,6 +893,7 @@ new ReviewerUpdateInfo( c.date(), accountLoader.get(c.updatedBy()), + c.realUpdatedBy().map(accountLoader::get).orElse(null), toAccountInfoByEmail(c.reviewerByEmail().get()), c.state().asReviewerState())); } @@ -1139,6 +1176,7 @@ } info.project = c.getProject().get(); info.branch = c.getDest().shortName(); + info.fullBranch = c.getDest().branch(); info.changeId = c.getKey().get(); info._number = c.getId().get(); info.subject = "***ERROR***";
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java index c228ebe..aef39b1 100644 --- a/java/com/google/gerrit/server/change/ChangeMessages.java +++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -18,7 +18,8 @@ public static String revertChangeDefaultMessage = "Revert \"{0}\"\n\nThis reverts commit {1}."; public static String revertSubmissionDefaultMessage = "This reverts commit {0}."; public static String revertSubmissionUserMessage = "Revert \"{0}\"\n\n{1}"; - public static String revertSubmissionOfRevertSubmissionUserMessage = "Revert^{0} \"{1}\"\n\n{2}"; + public static String revertSubmissionOfRevertSubmissionUserMessage = + "Revert^{0} \"{1}\"\n\n{2}\n\n{3}"; public static String reviewerCantSeeChange = "{0} does not have permission to see this change"; public static String reviewerInvalid = "{0} is not a valid user identifier";
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java index b1395b5..1e65164 100644 --- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java +++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -22,6 +22,9 @@ import com.google.gerrit.entities.PatchSet; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.server.ChangeMessagesUtil; +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.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator; @@ -68,9 +71,14 @@ @Override public boolean updateChange(ChangeContext ctx) throws PermissionBackendException, AuthException { + PatchSet.Id psId = ctx.getChange().currentPatchSetId(); + + // Reviewer updates do not create change messages. In case of impersonation, we do not want to + // add an extra message to the log. + ctx.getUpdate(psId).setSuppressImpersonationMessage(true); + removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), null); change = ctx.getChange(); - PatchSet.Id psId = ctx.getChange().currentPatchSetId(); ChangeUpdate update = ctx.getUpdate(psId); update.removeReviewerByEmail(reviewer); // The reviewer is not a registered Gerrit user, thus the email address can be used in @@ -100,7 +108,16 @@ outgoingEmail.setNotify(notify); outgoingEmail.setMessageId( messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId())); - outgoingEmail.send(); + + try (TraceTimer timer = + TraceContext.newTimer( + "DeleteReviewerByEmailSynchronousEmailNotification", + Metadata.builder() + .projectName(change.getProject().get()) + .changeId(change.getId().get()) + .build())) { + outgoingEmail.send(); + } } catch (Exception err) { logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId()); }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java index 4087f84..e0f94ee 100644 --- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java +++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -39,6 +39,9 @@ import com.google.gerrit.server.account.AccountState; import com.google.gerrit.server.approval.ApprovalsUtil; import com.google.gerrit.server.extensions.events.ReviewerDeleted; +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.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; import com.google.gerrit.server.mail.send.DeleteReviewerChangeEmailDecorator; @@ -123,6 +126,13 @@ PermissionBackendException, ResourceConflictException, IOException { + currChange = ctx.getChange(); + setPatchSet(psUtil.current(ctx.getNotes())); + + // Reviewer updates do not create change messages. In case of impersonation, we do not want to + // add an extra message to the log. + ctx.getUpdate(patchSet.id()).setSuppressImpersonationMessage(true); + Account.Id reviewerId = reviewer.id(); // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId); @@ -133,8 +143,6 @@ "Reviewer %s doesn't exist in the change, hence can't delete it", reviewer.getName())); } - currChange = ctx.getChange(); - setPatchSet(psUtil.current(ctx.getNotes())); LabelTypes labelTypes = projectCache @@ -270,6 +278,15 @@ outgoingEmail.setNotify(notify); outgoingEmail.setMessageId( messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId())); - outgoingEmail.send(); + + try (TraceTimer timer = + TraceContext.newTimer( + "DeleteReviewerSynchronousEmailNotification", + Metadata.builder() + .projectName(currChange.getProject().get()) + .changeId(currChange.getId().get()) + .build())) { + outgoingEmail.send(); + } } }
diff --git a/java/com/google/gerrit/server/change/EmailNewPatchSet.java b/java/com/google/gerrit/server/change/EmailNewPatchSet.java index 30d82a4..dc6935e 100644 --- a/java/com/google/gerrit/server/change/EmailNewPatchSet.java +++ b/java/com/google/gerrit/server/change/EmailNewPatchSet.java
@@ -16,6 +16,8 @@ import static com.google.gerrit.server.mail.EmailFactories.NEW_PATCHSET_ADDED; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableSet; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; @@ -29,6 +31,7 @@ import com.google.gerrit.extensions.client.ChangeKind; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; @@ -68,13 +71,15 @@ private final ExecutorService sendEmailExecutor; private final ThreadLocalRequestContext threadLocalRequestContext; - private final AsyncSender asyncSender; + private final Supplier<AsyncSender> asyncSenderSupplier; + private final boolean sendEmailEnabled; private RequestScopePropagator requestScopePropagator; @Inject EmailNewPatchSet( @SendEmailExecutor ExecutorService sendEmailExecutor, + @SendEmailEnabled Boolean sendEmailEnabled, ThreadLocalRequestContext threadLocalRequestContext, EmailFactories emailFactories, PatchSetInfoFactory patchSetInfoFactory, @@ -88,45 +93,49 @@ @Assisted ChangeKind changeKind, @Assisted ObjectId preUpdateMetaId) { this.sendEmailExecutor = sendEmailExecutor; + this.sendEmailEnabled = sendEmailEnabled; this.threadLocalRequestContext = threadLocalRequestContext; - MessageId messageId; - try { - messageId = - messageIdGenerator.fromChangeUpdateAndReason( - postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet"); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + this.asyncSenderSupplier = + Suppliers.memoize( + () -> { + MessageId messageId; + try { + messageId = + messageIdGenerator.fromChangeUpdateAndReason( + postUpdateContext.getRepoView(), patchSet.id(), "EmailReplacePatchSet"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } - Change.Id changeId = patchSet.id().changeId(); + Change.Id changeId = patchSet.id().changeId(); - // Getting the change data from PostUpdateContext retrieves a cached ChangeData - // instance. This ChangeData instance has been created when the change was (re)indexed - // due to the update, and hence has submit requirement results already cached (since - // (re)indexing triggers the evaluation of the submit requirements). - Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults = - postUpdateContext - .getChangeData(postUpdateContext.getProject(), changeId) - .submitRequirementsIncludingLegacy(); - this.asyncSender = - new AsyncSender( - postUpdateContext.getIdentifiedUser(), - emailFactories, - patchSetInfoFactory, - messageId, - postUpdateContext.getNotify(changeId), - postUpdateContext.getProject(), - changeId, - patchSet, - message, - postUpdateContext.getWhen(), - outdatedApprovals, - reviewers, - extraCcs, - changeKind, - preUpdateMetaId, - postUpdateSubmitRequirementResults); + // Getting the change data from PostUpdateContext retrieves a cached ChangeData + // instance. This ChangeData instance has been created when the change was (re)indexed + // due to the update, and hence has submit requirement results already cached (since + // (re)indexing triggers the evaluation of the submit requirements). + Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults = + postUpdateContext + .getChangeData(postUpdateContext.getProject(), changeId) + .submitRequirementsIncludingLegacy(); + return new AsyncSender( + postUpdateContext.getIdentifiedUser(), + emailFactories, + patchSetInfoFactory, + messageId, + postUpdateContext.getNotify(changeId), + postUpdateContext.getProject(), + changeId, + patchSet, + message, + postUpdateContext.getWhen(), + outdatedApprovals, + reviewers, + extraCcs, + changeKind, + preUpdateMetaId, + postUpdateSubmitRequirementResults); + }); } public EmailNewPatchSet setRequestScopePropagator(RequestScopePropagator requestScopePropagator) { @@ -135,15 +144,19 @@ } public void sendAsync() { + if (!sendEmailEnabled) { + return; + } @SuppressWarnings("unused") Future<?> possiblyIgnoredError = sendEmailExecutor.submit( requestScopePropagator != null - ? requestScopePropagator.wrap(asyncSender) + ? requestScopePropagator.wrap(asyncSenderSupplier.get()) : () -> { - RequestContext old = threadLocalRequestContext.setContext(asyncSender); + RequestContext old = + threadLocalRequestContext.setContext(asyncSenderSupplier.get()); try { - asyncSender.run(); + asyncSenderSupplier.get().run(); } finally { @SuppressWarnings("unused") var unused = threadLocalRequestContext.setContext(old);
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java index bb93cd3..880cca3 100644 --- a/java/com/google/gerrit/server/change/EmailReviewComments.java +++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -17,6 +17,8 @@ import static com.google.gerrit.server.CommentsUtil.COMMENT_ORDER; import static com.google.gerrit.server.mail.EmailFactories.COMMENTS_ADDED; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; @@ -28,6 +30,7 @@ import com.google.gerrit.entities.SubmitRequirementResult; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; @@ -82,11 +85,13 @@ } private final ExecutorService sendEmailsExecutor; - private final AsyncSender asyncSender; + private final Supplier<AsyncSender> asyncSenderSupplier; + private final boolean sendEmailEnabled; @Inject EmailReviewComments( @SendEmailExecutor ExecutorService executor, + @SendEmailEnabled Boolean sendEmailEnabled, PatchSetInfoFactory patchSetInfoFactory, EmailFactories emailFactories, ThreadLocalRequestContext requestContext, @@ -99,49 +104,56 @@ @Nullable @Assisted("patchSetComment") String patchSetComment, @Assisted List<LabelVote> labels) { this.sendEmailsExecutor = executor; + this.sendEmailEnabled = sendEmailEnabled; - MessageId messageId; - try { - messageId = - messageIdGenerator.fromChangeUpdateAndReason( - postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments"); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + this.asyncSenderSupplier = + Suppliers.memoize( + () -> { + MessageId messageId; + try { + messageId = + messageIdGenerator.fromChangeUpdateAndReason( + postUpdateContext.getRepoView(), patchSet.id(), "EmailReviewComments"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } - Change.Id changeId = patchSet.id().changeId(); + Change.Id changeId = patchSet.id().changeId(); - // Getting the change data from PostUpdateContext retrieves a cached ChangeData - // instance. This ChangeData instance has been created when the change was (re)indexed - // due to the update, and hence has submit requirement results already cached (since - // (re)indexing triggers the evaluation of the submit requirements). - Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults = - postUpdateContext - .getChangeData(postUpdateContext.getProject(), changeId) - .submitRequirementsIncludingLegacy(); - this.asyncSender = - new AsyncSender( - requestContext, - emailFactories, - patchSetInfoFactory, - postUpdateContext.getUser().asIdentifiedUser(), - messageId, - postUpdateContext.getNotify(changeId), - postUpdateContext.getProject(), - changeId, - patchSet, - preUpdateMetaId, - message, - postUpdateContext.getWhen(), - ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)), - patchSetComment, - ImmutableList.copyOf(labels), - postUpdateSubmitRequirementResults); + // Getting the change data from PostUpdateContext retrieves a cached ChangeData + // instance. This ChangeData instance has been created when the change was (re)indexed + // due to the update, and hence has submit requirement results already cached (since + // (re)indexing triggers the evaluation of the submit requirements). + Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults = + postUpdateContext + .getChangeData(postUpdateContext.getProject(), changeId) + .submitRequirementsIncludingLegacy(); + return new AsyncSender( + requestContext, + emailFactories, + patchSetInfoFactory, + postUpdateContext.getUser().asIdentifiedUser(), + messageId, + postUpdateContext.getNotify(changeId), + postUpdateContext.getProject(), + changeId, + patchSet, + preUpdateMetaId, + message, + postUpdateContext.getWhen(), + ImmutableList.copyOf(COMMENT_ORDER.sortedCopy(comments)), + patchSetComment, + ImmutableList.copyOf(labels), + postUpdateSubmitRequirementResults); + }); } public void sendAsync() { + if (!sendEmailEnabled) { + return; + } @SuppressWarnings("unused") - Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender); + Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSenderSupplier.get()); } /**
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java index 4866183..075dd82a 100644 --- a/java/com/google/gerrit/server/change/FileContentUtil.java +++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -307,32 +307,27 @@ public static String resolveContentType( ProjectState project, String path, FileMode fileMode, String mimeType) { - switch (fileMode) { + return switch (fileMode) { case FILE -> { if (Patch.COMMIT_MSG.equals(path)) { - return TEXT_X_GERRIT_COMMIT_MESSAGE; + yield TEXT_X_GERRIT_COMMIT_MESSAGE; } if (Patch.MERGE_LIST.equals(path)) { - return TEXT_X_GERRIT_MERGE_LIST; + yield TEXT_X_GERRIT_MERGE_LIST; } if (project != null) { for (ProjectState p : project.tree()) { String t = p.getConfig().getMimeTypes().getMimeType(path); if (t != null) { - return t; + yield t; } } } - return mimeType; + yield mimeType; } - case GITLINK -> { - return X_GIT_GITLINK; - } - case SYMLINK -> { - return X_GIT_SYMLINK; - } - default -> throw new IllegalStateException("file mode: " + fileMode); - } + case GITLINK -> X_GIT_GITLINK; + case SYMLINK -> X_GIT_SYMLINK; + }; } private Repository openRepository(ProjectState project)
diff --git a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java index 8f1fc1e..369d080 100644 --- a/java/com/google/gerrit/server/change/ModifyReviewersEmail.java +++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -22,8 +22,8 @@ import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Address; import com.google.gerrit.entities.Change; -import com.google.gerrit.entities.Project; import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; @@ -43,14 +43,17 @@ private final EmailFactories emailFactories; private final ExecutorService sendEmailsExecutor; private final MessageIdGenerator messageIdGenerator; + private final boolean sendEmailEnabled; @Inject ModifyReviewersEmail( EmailFactories emailFactories, @SendEmailExecutor ExecutorService sendEmailsExecutor, + @SendEmailEnabled Boolean sendEmailEnabled, MessageIdGenerator messageIdGenerator) { this.emailFactories = emailFactories; this.sendEmailsExecutor = sendEmailsExecutor; + this.sendEmailEnabled = sendEmailEnabled; this.messageIdGenerator = messageIdGenerator; } @@ -64,6 +67,9 @@ Collection<Address> copiedByEmail, Collection<Address> removedByEmail, NotifyResolver.Result notify) { + if (!sendEmailEnabled) { + return; + } // The user knows they added/removed themselves, don't bother emailing them. Account.Id userId = user.getAccountId(); ImmutableList<Account.Id> immutableToMail = @@ -83,8 +89,6 @@ // Make immutable copies of collections and hand over only immutable data types to the other // thread. - Change.Id cId = change.getId(); - Project.NameKey projectNameKey = change.getProject(); ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail); ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail); ImmutableList<Address> immutableRemovedByEmail = ImmutableList.copyOf(removedByEmail); @@ -103,7 +107,7 @@ startReviewEmail.addRemovedReviewers(immutableToRemove); startReviewEmail.addRemovedByEmailReviewers(immutableRemovedByEmail); ChangeEmail changeEmail = - emailFactories.createChangeEmail(projectNameKey, cId, startReviewEmail); + emailFactories.createChangeEmail(change, startReviewEmail); OutgoingEmail outgoingEmail = emailFactories.createOutgoingEmail(REVIEW_REQUESTED, changeEmail); outgoingEmail.setNotify(notify);
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java index 42d043c..b18ae7f 100644 --- a/java/com/google/gerrit/server/change/PatchSetInserter.java +++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -59,7 +59,6 @@ import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.server.plugincontext.PluginSetContext; import com.google.gerrit.server.project.ProjectCache; -import com.google.gerrit.server.ssh.NoSshInfo; import com.google.gerrit.server.update.BatchUpdateOp; import com.google.gerrit.server.update.ChangeContext; import com.google.gerrit.server.update.PostUpdateContext; @@ -497,7 +496,6 @@ permissionBackend.user(ctx.getUser()).project(ctx.getProject()), origNotes.getChange().getDest(), ctx.getIdentifiedUser(), - new NoSshInfo(), ctx.getRevWalk(), origNotes.getChange()) .patchSet(psId)
diff --git a/java/com/google/gerrit/server/change/ReviewerModifier.java b/java/com/google/gerrit/server/change/ReviewerModifier.java index 6cc2f87..f34a673 100644 --- a/java/com/google/gerrit/server/change/ReviewerModifier.java +++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -57,15 +57,12 @@ import com.google.gerrit.server.account.AccountLoader; import com.google.gerrit.server.account.AccountResolver; import com.google.gerrit.server.account.GroupMembers; -import com.google.gerrit.server.change.ReviewerModifier.FailureBehavior; -import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput; -import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification; -import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.group.GroupResolver; import com.google.gerrit.server.group.SystemGroupBackend; import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.TraceContext; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; import com.google.gerrit.server.mail.send.OutgoingEmailValidator; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.permissions.ChangePermission; @@ -690,9 +687,12 @@ } public void postUpdate(PostUpdateContext ctx) throws Exception { - for (ReviewerModification addition : modifications()) { - if (addition.op != null) { - addition.op.postUpdate(ctx); + try (TraceTimer timer = + TraceContext.newTimer("ReviewerMoodifier#postUpdate", Metadata.empty())) { + for (ReviewerModification addition : modifications()) { + if (addition.op != null) { + addition.op.postUpdate(ctx); + } } } }
diff --git a/java/com/google/gerrit/server/change/ReviewerOp.java b/java/com/google/gerrit/server/change/ReviewerOp.java index 12227c2..5bbabdf 100644 --- a/java/com/google/gerrit/server/change/ReviewerOp.java +++ b/java/com/google/gerrit/server/change/ReviewerOp.java
@@ -24,6 +24,9 @@ import com.google.gerrit.entities.PatchSet; import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.extensions.restapi.RestApiException; +import com.google.gerrit.server.logging.Metadata; +import com.google.gerrit.server.logging.TraceContext; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.server.update.BatchUpdateOp; import com.google.gerrit.server.update.ChangeContext; @@ -49,7 +52,9 @@ } public void sendEvent() { - eventSender.run(); + try (TraceTimer timer = TraceContext.newTimer("ReviewerOp#sendEvent", Metadata.empty())) { + eventSender.run(); + } } void setPatchSet(PatchSet patchSet) {
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java index b113a69..2530ee0 100644 --- a/java/com/google/gerrit/server/config/CachedPreferences.java +++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -38,7 +38,12 @@ */ public record CachedPreferences(CachedPreferencesProto config) { public static final CachedPreferences EMPTY = - fromCachedPreferencesProto(CachedPreferencesProto.getDefaultInstance()); + fromCachedPreferencesProto( + CachedPreferencesProto.newBuilder() + .setUserPreferences(UserPreferences.getDefaultInstance()) + .build()); + + private static final String EMPTY_GIT_CONFIG_STRING = new Config().toText(); public Optional<CachedPreferencesProto> nonEmptyConfig() { return config().equals(EMPTY.config()) ? Optional.empty() : Optional.of(config()); @@ -82,9 +87,11 @@ public Config asConfig() { try { + if (isEmpty()) { + return new Config(); + } switch (config().getPreferencesCase()) { case LEGACY_GIT_CONFIG, PREFERENCES_NOT_SET -> { - // continue below Config cfg = new Config(); cfg.fromText(config().getLegacyGitConfig()); return cfg; @@ -95,14 +102,16 @@ throw new StorageException(e); } throw new StorageException( - String.format( - "Cannot parse the given config as a CachedPreferencesProto proto. Got [%s]", config())); + String.format("Cannot parse the given config as a legacy git config. Got [%s]", config())); } public UserPreferences asUserPreferencesProto() { if (config().hasUserPreferences()) { return config().getUserPreferences(); } + if (isEmpty()) { + return EMPTY.config().getUserPreferences(); + } throw new StorageException( String.format( "Cannot parse the given config as a UserPreferences proto. Got [%s]", config())); @@ -133,4 +142,43 @@ return preferencesParser.getJavaDefaults(); } } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof CachedPreferences other)) { + return false; + } + if (this.isEmpty() && other.isEmpty()) { + return true; + } + return this.config().equals(other.config()); + } + + @Override + public int hashCode() { + if (this.isEmpty()) { + return EMPTY.config().hashCode(); + } + return config().hashCode(); + } + + @Override + public String toString() { + if (isEmpty()) { + return EMPTY_GIT_CONFIG_STRING; + } + return "config=" + config().toString(); + } + + private boolean isEmpty() { + return config() == null + || !config().isInitialized() + || config().equals(CachedPreferencesProto.getDefaultInstance()) + || (config().hasLegacyGitConfig() + && config().getLegacyGitConfig().equals(EMPTY_GIT_CONFIG_STRING)) + || this.config().equals(EMPTY.config()); + } }
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java index 4954853..12977c7 100644 --- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java +++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -171,8 +171,6 @@ case MODIFIED -> String.format("* %s = [%s => %s]", key, oldVal, newVal); case REMOVED -> String.format("- %s = %s", key, oldVal); case UNMODIFIED -> String.format(" %s = %s", key, newVal); - default -> - throw new IllegalStateException("Unexpected UpdateType: " + getUpdateType().name()); }; }
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java index c0bfa8b..ae25b81 100644 --- a/java/com/google/gerrit/server/config/ConfigUtil.java +++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -456,12 +456,12 @@ * * <p>To use this method with the proto config (see {@link * CachedPreferences#asUserPreferencesProto()}), the caller can first convert the proto to a java - * class usign one of the {@link UserPreferencesConverter} classes. + * class using one of the {@link UserPreferencesConverter} classes. * * <p>Fields marked with final or transient modifiers are skipped. * * @param original the original current user config - * @param updateDelta instance of class with config values that need to be uplied to the original + * @param updateDelta instance of class with config values that need to be applied to the original * config */ @UsedAt(Project.GOOGLE)
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java index 39dfba2..5f5db71 100644 --- a/java/com/google/gerrit/server/config/GerritGlobalModule.java +++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -117,6 +117,7 @@ import com.google.gerrit.server.auth.AuthBackend; import com.google.gerrit.server.auth.UniversalAuthBackend; import com.google.gerrit.server.avatar.AvatarProvider; +import com.google.gerrit.server.cache.CacheDef; import com.google.gerrit.server.cache.CacheRemovalListener; import com.google.gerrit.server.change.AbandonOp; import com.google.gerrit.server.change.AccountPatchReviewStore; @@ -283,6 +284,7 @@ install(PureRevertCache.module()); install(CommentContextCacheImpl.module()); install(SubmitRequirementsEvaluatorImpl.module()); + install(ServerConfigCacheImpl.module()); install(new AccessControlModule()); install(new AccountModule()); @@ -367,6 +369,7 @@ bind(GitReferenceUpdated.class); DynamicMap.mapOf(binder(), new TypeLiteral<Cache<?, ?>>() {}); + DynamicMap.mapOf(binder(), new TypeLiteral<CacheDef<?, ?>>() {}); DynamicSet.setOf(binder(), CacheRemovalListener.class); DynamicMap.mapOf(binder(), CapabilityDefinition.class); DynamicMap.mapOf(binder(), PluginProjectPermissionDefinition.class);
diff --git a/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java b/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java index ac3c53a..1b5c1c1 100644 --- a/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java +++ b/java/com/google/gerrit/server/config/MetricsReservoirConfigImpl.java
@@ -62,33 +62,33 @@ .orElse(RESERVOIR_ALPHA_DEFAULT); } - /* (non-Javadoc) - * @see com.google.gerrit.server.config.MetricsConfig#reservoirType() - */ + // (non-Javadoc) + // @see com.google.gerrit.server.config.MetricsConfig#reservoirType() + // @Override public ReservoirType reservoirType() { return reservoirType; } - /* (non-Javadoc) - * @see com.google.gerrit.server.config.MetricsConfig#reservoirWindow() - */ + // (non-Javadoc) + // @see com.google.gerrit.server.config.MetricsConfig#reservoirWindow() + // @Override public Duration reservoirWindow() { return reservoirWindow; } - /* (non-Javadoc) - * @see com.google.gerrit.server.config.MetricsConfig#reservoirSize() - */ + // (non-Javadoc) + // @see com.google.gerrit.server.config.MetricsConfig#reservoirSize() + // @Override public int reservoirSize() { return reservoirSize; } - /* (non-Javadoc) - * @see com.google.gerrit.server.config.MetricsConfig#reservoirAlpha() - */ + // (non-Javadoc) + // @see com.google.gerrit.server.config.MetricsConfig#reservoirAlpha() + // @Override public double reservoirAlpha() { return reservoirAlpha;
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java index d7f19b0..8a87d93 100644 --- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java +++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -27,6 +27,7 @@ import static com.google.gerrit.server.git.UserConfigSections.KEY_URL; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Lists; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; @@ -46,6 +47,21 @@ public class PreferencesParserUtil { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + public static final ImmutableList<MenuItem> DEFAULT_MY_MENU_ITEMS = + ImmutableList.of( + new MenuItem(/* name= */ "Dashboard", /* url= */ "/dashboard/self", /* target= */ null), + new MenuItem(/* name= */ "Draft Comments", /* url= */ "/q/has:draft", /* target= */ null), + new MenuItem(/* name= */ "Edits", /* url= */ "/q/has:edit", /* target= */ null), + new MenuItem( + /* name= */ "Watched Changes", + /* url= */ "/q/is:watched+is:open", + /* target= */ null), + new MenuItem( + /* name= */ "Starred Changes", /* url= */ "/q/is:starred", /* target= */ null), + new MenuItem( + /* name= */ "All Visible Changes", /* url= */ "/q/is:visible", /* target= */ null), + new MenuItem(/* name= */ "Groups", /* url= */ "/settings/#Groups", /* target= */ null)); + private PreferencesParserUtil() {} /** @@ -231,13 +247,7 @@ my = new ArrayList<>(); } if (my.isEmpty()) { - my.add(new MenuItem("Dashboard", "/dashboard/self", null)); - my.add(new MenuItem("Draft Comments", "/q/has:draft", null)); - my.add(new MenuItem("Edits", "/q/has:edit", null)); - my.add(new MenuItem("Watched Changes", "/q/is:watched+is:open", null)); - my.add(new MenuItem("Starred Changes", "/q/is:starred", null)); - my.add(new MenuItem("All Visible Changes", "/q/is:visible", null)); - my.add(new MenuItem("Groups", "/settings/#Groups", null)); + return DEFAULT_MY_MENU_ITEMS; } return my; }
diff --git a/java/com/google/gerrit/server/config/SendEmailEnabled.java b/java/com/google/gerrit/server/config/SendEmailEnabled.java new file mode 100644 index 0000000..f52349f --- /dev/null +++ b/java/com/google/gerrit/server/config/SendEmailEnabled.java
@@ -0,0 +1,25 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.config; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import com.google.inject.BindingAnnotation; +import java.lang.annotation.Retention; + +/** Marker on a {@link Boolean} holding whether email sending is enabled. */ +@Retention(RUNTIME) +@BindingAnnotation +public @interface SendEmailEnabled {}
diff --git a/java/com/google/gerrit/server/config/SendEmailEnabledModule.java b/java/com/google/gerrit/server/config/SendEmailEnabledModule.java new file mode 100644 index 0000000..9f4e071 --- /dev/null +++ b/java/com/google/gerrit/server/config/SendEmailEnabledModule.java
@@ -0,0 +1,30 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.config; + +import static com.google.inject.Scopes.SINGLETON; + +import com.google.inject.AbstractModule; + +/** Supports binding the {@link SendEmailEnabled} annotation. */ +public class SendEmailEnabledModule extends AbstractModule { + @Override + protected void configure() { + bind(Boolean.class) + .annotatedWith(SendEmailEnabled.class) + .toProvider(SendEmailEnabledProvider.class) + .in(SINGLETON); + } +}
diff --git a/java/com/google/gerrit/server/config/SendEmailEnabledProvider.java b/java/com/google/gerrit/server/config/SendEmailEnabledProvider.java new file mode 100644 index 0000000..a9a5a39 --- /dev/null +++ b/java/com/google/gerrit/server/config/SendEmailEnabledProvider.java
@@ -0,0 +1,36 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.config; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import org.eclipse.jgit.lib.Config; + +/** Provides whether email sending is enabled from {@code sendemail.enable}. */ +@Singleton +public class SendEmailEnabledProvider implements Provider<Boolean> { + private final boolean enabled; + + @Inject + public SendEmailEnabledProvider(@GerritServerConfig Config cfg) { + enabled = cfg.getBoolean("sendemail", null, "enable", true); + } + + @Override + public Boolean get() { + return enabled; + } +}
diff --git a/java/com/google/gerrit/server/config/ServerConfigCacheImpl.java b/java/com/google/gerrit/server/config/ServerConfigCacheImpl.java new file mode 100644 index 0000000..2707cc2 --- /dev/null +++ b/java/com/google/gerrit/server/config/ServerConfigCacheImpl.java
@@ -0,0 +1,47 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.config; + +import com.google.auto.value.AutoValue; +import com.google.gerrit.extensions.common.ServerInfo; +import com.google.gerrit.server.cache.CacheModule; +import com.google.inject.Module; +import java.time.Duration; + +public class ServerConfigCacheImpl { + public static final String CACHE_CONFIG = "server_config"; + public static final String SINGLETON_KEY = "GLOBAL"; + + @AutoValue + public abstract static class ServerConfigData { + public abstract ServerInfo serverInfo(); + + public abstract String serverVersion(); + + public static ServerConfigData create(ServerInfo serverInfo, String serverVersion) { + return new AutoValue_ServerConfigCacheImpl_ServerConfigData(serverInfo, serverVersion); + } + } + + public static Module module() { + return new CacheModule() { + @Override + protected void configure() { + cache(CACHE_CONFIG, String.class, ServerConfigData.class) + .expireAfterWrite(Duration.ofMinutes(5)); + } + }; + } +}
diff --git a/java/com/google/gerrit/server/config/UserPreferencesConverter.java b/java/com/google/gerrit/server/config/UserPreferencesConverter.java index 4b2f6d2..c6662f5 100644 --- a/java/com/google/gerrit/server/config/UserPreferencesConverter.java +++ b/java/com/google/gerrit/server/config/UserPreferencesConverter.java
@@ -111,7 +111,9 @@ if (info.my != null) { builder = builder.addAllMyMenuItems( - info.my.stream().map(i -> menuItemToProto(i)).collect(toImmutableList())); + info.my.stream() + .map(MenuItemConverter.MENU_ITEM_CONVERTER::toProto) + .collect(toImmutableList())); } if (info.changeTable != null) { builder = builder.addAllChangeTable(info.changeTable); @@ -128,6 +130,7 @@ setIfNotNull( builder, builder::setAllowAutocompletingComments, info.allowAutocompletingComments); builder = setIfNotNull(builder, builder::setDiffPageSidebar, info.diffPageSidebar); + builder = setIfNotNull(builder, builder::setAiChatSelectedModel, info.aiChatSelectedModel); return builder.build(); } @@ -183,7 +186,7 @@ res.my = proto.getMyMenuItemsCount() != 0 ? proto.getMyMenuItemsList().stream() - .map(p -> menuItemFromProto(p)) + .map(MenuItemConverter.MENU_ITEM_CONVERTER::fromProto) .collect(toImmutableList()) : null; res.changeTable = proto.getChangeTableCount() != 0 ? proto.getChangeTableList() : null; @@ -196,6 +199,8 @@ res.allowAutocompletingComments = proto.hasAllowAutocompletingComments() ? proto.getAllowAutocompletingComments() : null; res.diffPageSidebar = proto.hasDiffPageSidebar() ? proto.getDiffPageSidebar() : null; + res.aiChatSelectedModel = + proto.hasAiChatSelectedModel() ? proto.getAiChatSelectedModel() : null; return res; } @@ -204,28 +209,48 @@ return UserPreferences.GeneralPreferencesInfo.parser(); } - private static UserPreferences.GeneralPreferencesInfo.MenuItem menuItemToProto( - MenuItem javaItem) { - UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder = - UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder(); - builder = setIfNotNull(builder, builder::setName, trimSafe(javaItem.name)); - builder = setIfNotNull(builder, builder::setUrl, trimSafe(javaItem.url)); - builder = setIfNotNull(builder, builder::setTarget, trimSafe(javaItem.target)); - builder = setIfNotNull(builder, builder::setId, trimSafe(javaItem.id)); - return builder.build(); - } + public enum MenuItemConverter + implements SafeProtoConverter<UserPreferences.GeneralPreferencesInfo.MenuItem, MenuItem> { + MENU_ITEM_CONVERTER; - private static @Nullable String trimSafe(@Nullable String s) { - return s == null ? s : s.trim(); - } + @Override + public UserPreferences.GeneralPreferencesInfo.MenuItem toProto(MenuItem javaItem) { + UserPreferences.GeneralPreferencesInfo.MenuItem.Builder builder = + UserPreferences.GeneralPreferencesInfo.MenuItem.newBuilder(); + builder = setIfNotNull(builder, builder::setName, trimSafe(javaItem.name)); + builder = setIfNotNull(builder, builder::setUrl, trimSafe(javaItem.url)); + builder = setIfNotNull(builder, builder::setTarget, trimSafe(javaItem.target)); + builder = setIfNotNull(builder, builder::setId, trimSafe(javaItem.id)); + return builder.build(); + } - private static MenuItem menuItemFromProto( - UserPreferences.GeneralPreferencesInfo.MenuItem proto) { - return new MenuItem( - proto.hasName() ? proto.getName().trim() : null, - proto.hasUrl() ? proto.getUrl().trim() : null, - proto.hasTarget() ? proto.getTarget().trim() : null, - proto.hasId() ? proto.getId().trim() : null); + private static @Nullable String trimSafe(@Nullable String s) { + return s == null ? s : s.trim(); + } + + @Override + public MenuItem fromProto(UserPreferences.GeneralPreferencesInfo.MenuItem proto) { + return new MenuItem( + proto.hasName() ? proto.getName().trim() : null, + proto.hasUrl() ? proto.getUrl().trim() : null, + proto.hasTarget() ? proto.getTarget().trim() : null, + proto.hasId() ? proto.getId().trim() : null); + } + + @Override + public Parser<UserPreferences.GeneralPreferencesInfo.MenuItem> getParser() { + return UserPreferences.GeneralPreferencesInfo.MenuItem.parser(); + } + + @Override + public Class<UserPreferences.GeneralPreferencesInfo.MenuItem> getProtoClass() { + return UserPreferences.GeneralPreferencesInfo.MenuItem.class; + } + + @Override + public Class<MenuItem> getEntityClass() { + return MenuItem.class; + } } @Override
diff --git a/java/com/google/gerrit/server/diff/DiffInfoCreator.java b/java/com/google/gerrit/server/diff/DiffInfoCreator.java index 606e42b..2bc36c5 100644 --- a/java/com/google/gerrit/server/diff/DiffInfoCreator.java +++ b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
@@ -67,7 +67,7 @@ this.intraline = intraline; } - /* Returns the {@link DiffInfo} to display for end-users */ + /** Returns the {@link DiffInfo} to display for end-users */ public DiffInfo create(PatchScript ps, DiffSide sideA, DiffSide sideB) { DiffInfo result = new DiffInfo();
diff --git a/java/com/google/gerrit/server/edit/tree/BadContentLengthException.java b/java/com/google/gerrit/server/edit/tree/BadContentLengthException.java new file mode 100644 index 0000000..e0bcebd --- /dev/null +++ b/java/com/google/gerrit/server/edit/tree/BadContentLengthException.java
@@ -0,0 +1,27 @@ +// Copyright (C) 2024 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.edit.tree; + +/** + * Exception that is thrown if the provided content length doesn't match with the length of the + * provided content. + */ +public class BadContentLengthException extends RuntimeException { + private static final long serialVersionUID = 1L; + + public BadContentLengthException(String message, Throwable cause) { + super(message, cause); + } +}
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java index 6fe9206..eec764b5 100644 --- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java +++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -26,6 +26,7 @@ import com.google.common.io.ByteStreams; import com.google.gerrit.common.Nullable; import com.google.gerrit.extensions.restapi.RawInput; +import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.time.Instant; @@ -34,6 +35,7 @@ import org.eclipse.jgit.dircache.DirCacheEditor; import org.eclipse.jgit.dircache.DirCacheEntry; import org.eclipse.jgit.errors.InvalidObjectIdException; +import org.eclipse.jgit.internal.JGitText; import org.eclipse.jgit.lib.FileMode; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectInserter; @@ -151,8 +153,20 @@ if (contentLength < 0) { return objectInserter.insert(OBJ_BLOB, getNewContentBytes()); } - InputStream contentInputStream = newContent.getInputStream(); - return objectInserter.insert(OBJ_BLOB, contentLength, contentInputStream); + try { + InputStream contentInputStream = newContent.getInputStream(); + return objectInserter.insert(OBJ_BLOB, contentLength, contentInputStream); + } catch (EOFException e) { + if (e.getMessage().equals(JGitText.get().shortReadOfBlock)) { + throw new BadContentLengthException( + String.format( + "The provided content length %s for file %s doesn't match with the length of the" + + " provided content", + contentLength, filePath), + e); + } + throw e; + } } private byte[] getNewContentBytes() throws IOException {
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java index bd784cf..993c607 100644 --- a/java/com/google/gerrit/server/events/EventGsonProvider.java +++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,8 +15,10 @@ package com.google.gerrit.server.events; import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; import com.google.gerrit.entities.EntitiesAdapterFactory; import com.google.gerrit.entities.Project; +import com.google.gerrit.json.ImmutableListTypeAdapter; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.inject.Provider; @@ -29,6 +31,7 @@ .registerTypeAdapter(Event.class, new EventDeserializer()) .registerTypeAdapter(Supplier.class, new SupplierSerializer()) .registerTypeAdapter(Supplier.class, new SupplierDeserializer()) + .registerTypeAdapter(ImmutableList.class, new ImmutableListTypeAdapter()) .registerTypeAdapterFactory(EntitiesAdapterFactory.create()) .registerTypeHierarchyAdapter(Project.NameKey.class, new ProjectNameKeyAdapter()) .create();
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java index 6c2b71b..abfd48f 100644 --- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java +++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -23,17 +23,8 @@ public static String GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION = "GerritBackendFeature__attach_nonce_to_documentation"; - /** - * Enables the AI Review Prompt button and dialog in the change screen UI. - * - * <p>This feature is enabled by default but can be disabled via [experiments] disabled = - * UiFeature__get_ai_prompt. - */ - public static final String UI_FEATURE_GET_AI_PROMPT = "UiFeature__get_ai_prompt"; - - /** Features enabled by default in the current release. */ - public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = - ImmutableSet.of(UI_FEATURE_GET_AI_PROMPT); + /** Features, enabled by default in the current release. */ + public static final ImmutableSet<String> DEFAULT_ENABLED_FEATURES = ImmutableSet.of(); /** * If true, gerrit checks implicit merges on each merge operations. @@ -70,6 +61,14 @@ public static final String ALLOW_FIX_SUGGESTIONS_IN_COMMENTS = "GerritBackendFeature__allow_fix_suggestions_in_comments"; - /** Whether UI should request Submit Requirements separately from change detail. */ - public static final String ASYNC_SUBMIT_REQUIREMENTS = "UiFeature__async_submit_requirements"; + /** Whether submit_records should only be returned along with submit_requirements. */ + public static final String SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS = + "GerritBackendFeature__skip_submit_records_without_submit_requirements"; + + /** Whether to consider votes of deleted accounts. */ + public static final String CONSIDER_VOTES_OF_DELETED_ACCOUNTS = + "GerritBackendFeature__consider_votes_of_deleted_accounts"; + + /** Whether AI chat/review features are enabled in the UI. */ + public static final String ENABLE_AI_CHAT = "UiFeature__enable_ai_chat"; }
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java index c6661bd..a3424e6 100644 --- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java +++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -26,6 +26,9 @@ import com.google.gerrit.extensions.events.CommentAddedListener; import com.google.gerrit.server.GpgException; import com.google.gerrit.server.account.AccountState; +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.patch.PatchListNotAvailableException; import com.google.gerrit.server.patch.PatchListObjectTooLargeException; import com.google.gerrit.server.permissions.PermissionBackendException; @@ -62,25 +65,29 @@ if (listeners.isEmpty()) { return; } - try { - Event event = - new Event( - util.changeInfo(changeData), - util.revisionInfo(changeData.project(), ps), - util.accountInfo(author), - comment, - util.approvals(author, approvals, when), - util.approvals(author, oldApprovals, when), - when); - listeners.runEach(l -> l.onCommentAdded(event)); - } catch (PatchListObjectTooLargeException e) { - logger.atWarning().log("Couldn't fire event: %s", e.getMessage()); - } catch (PatchListNotAvailableException - | GpgException - | IOException - | StorageException - | PermissionBackendException e) { - logger.atSevere().withCause(e).log("Couldn't fire event"); + try (TraceTimer timer = + TraceContext.newTimer( + "Fire CommentAdded", Metadata.builder().changeId(changeData.getId().get()).build())) { + try { + Event event = + new Event( + util.changeInfo(changeData), + util.revisionInfo(changeData.project(), ps), + util.accountInfo(author), + comment, + util.approvals(author, approvals, when), + util.approvals(author, oldApprovals, when), + when); + listeners.runEach(l -> l.onCommentAdded(event)); + } catch (PatchListObjectTooLargeException e) { + logger.atWarning().log("Couldn't fire event: %s", e.getMessage()); + } catch (PatchListNotAvailableException + | GpgException + | IOException + | StorageException + | PermissionBackendException e) { + logger.atSevere().withCause(e).log("Couldn't fire event"); + } } }
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java index c9b9f7a..d39cefc 100644 --- a/java/com/google/gerrit/server/extensions/events/EventUtil.java +++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -142,6 +142,9 @@ logger.atWarning().withCause(e).log("could not parse list change option %s", c); } } - return result.build(); + ImmutableSet<ListChangesOption> changeListOptions = result.build(); + logger.atFine().log( + "change list options to populate ChangeInfo for events: %s", changeListOptions); + return changeListOptions; } }
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java index f6d5881..336a5e5 100644 --- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java +++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -25,6 +25,9 @@ import com.google.gerrit.server.GpgException; import com.google.gerrit.server.account.AccountState; import com.google.gerrit.server.change.NotifyResolver; +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.patch.PatchListNotAvailableException; import com.google.gerrit.server.patch.PatchListObjectTooLargeException; import com.google.gerrit.server.permissions.PermissionBackendException; @@ -74,23 +77,28 @@ if (listeners.isEmpty()) { return; } - try { - Event event = - new Event( - util.changeInfo(changeData), - util.revisionInfo(changeData, patchSet), - util.accountInfo(uploader), - when, - notify.handling()); - listeners.runEach(l -> l.onRevisionCreated(event)); - } catch (PatchListObjectTooLargeException e) { - logger.atWarning().log("Couldn't fire event: %s", e.getMessage()); - } catch (PatchListNotAvailableException - | GpgException - | IOException - | StorageException - | PermissionBackendException e) { - logger.atSevere().withCause(e).log("Couldn't fire event"); + try (TraceTimer timer = + TraceContext.newTimer( + "Fire RevisionCreated", + Metadata.builder().changeId(changeData.getId().get()).build())) { + try { + Event event = + new Event( + util.changeInfo(changeData), + util.revisionInfo(changeData, patchSet), + util.accountInfo(uploader), + when, + notify.handling()); + listeners.runEach(l -> l.onRevisionCreated(event)); + } catch (PatchListObjectTooLargeException e) { + logger.atWarning().log("Couldn't fire event: %s", e.getMessage()); + } catch (PatchListNotAvailableException + | GpgException + | IOException + | StorageException + | PermissionBackendException e) { + logger.atSevere().withCause(e).log("Couldn't fire event"); + } } }
diff --git a/java/com/google/gerrit/server/fixes/FixCalculator.java b/java/com/google/gerrit/server/fixes/FixCalculator.java index f7638b5..054dc6b 100644 --- a/java/com/google/gerrit/server/fixes/FixCalculator.java +++ b/java/com/google/gerrit/server/fixes/FixCalculator.java
@@ -93,68 +93,52 @@ "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar); } - /* - Algorithm: - Input: - Original text (aka srcText) - Sorted list of replacements in ascending order, where each replacement has: - srcRange - part of the original text to be - replaced, inserted or deleted (see {@link Comment.Range} for details) - replacement - text to be set instead of srcRange - Replacement ranges must not intersect. - - Output: - Final text (aka finalText) - List of Edit, where each Edit is an instance of {@link ReplaceEdit} - Each ReplaceEdit cover one or more lines in the original text - Each ReplaceEdit contains one or more Edit for intraline edits - See {@link ReplaceEdit} and {@link Edit} for details. - * - Note: The algorithm is implemented in this way to avoid string.replace operations. - It has complexity O(len(replacements) + max(len(originalText), len(finalText)) ) - - Main steps: - - set srcPos to start of the original text. It is like a cursor position in the original text. - - set dstPos to start of the final text. It is like a cursor position in the final text. - - the finalText initially empty - - - for each replacement: - - append text between a previous and a current replacement to the finalText - (because replacements were sorted, this part of text can't be changed by - following replacements). I.e. append substring of srcText between srcPos - and replacement.srcRange.start to the finalText - Update srcPos and dstPos - set them at the end of appended text - (i.e. srcPos points to the position before replacement.srcRange.start, - dstPos points to the position where replacement.text should be inserted) - - set dstReplacementStart = dstPos - - append replacement.text to the finalText. - Update srcPos and dstPos accordingly (i.e. srcPos points to the position after - replacement.srcRange, dstPos points to the position in the finalText after - the appended replacement.text). - - set dstReplacementEnd = dstPos - - dstRange = (dstReplacementStart, dstReplacementEnd) - is the range in the finalText. - - srcRange = (replacement.Start, replacement.End) - is the range in the original text * - - - If previously created ReplaceEdit ends on the same or previous line as srcRange.startLine, - then intraline edit is added to it (and ReplaceEdit endLine must be updated if needed); - srcRange and dstRange together is used to calculate intraline Edit - otherwise - create new ReplaceEdit and add intraline Edit to it - srcRange and dstRange together is used to calculate intraline Edit - - - append text after the last replacements, - i.e. add part of srcText after srcPos to the finalText - - - Return the finalText and all created ReplaceEdits - - Implementation notes: - 1) The intraline Edits inside ReplaceEdit stores positions relative to ReplaceEdit start. - 2) srcPos and dstPos tracks current position as 3 numbers: - - line number - - column number - - textPos - absolute position from the start of the text. The textPos is used to calculate - relative positions of Edit inside ReplaceEdit - */ + /** + * Algorithm: Input: Original text (aka srcText) Sorted list of replacements in ascending order, + * where each replacement has: srcRange - part of the original text to be replaced, inserted or + * deleted (see {@link com.google.gerrit.entities.Comment.Range} for details) replacement - text + * to be set instead of srcRange Replacement ranges must not intersect. + * + * <p>Output: Final text (aka finalText) List of Edit, where each Edit is an instance of {@link + * ReplaceEdit} Each ReplaceEdit cover one or more lines in the original text Each ReplaceEdit + * contains one or more Edit for intraline edits See {@link ReplaceEdit} and {@link Edit} for + * details. + * + * <p>Note: The algorithm is implemented in this way to avoid string.replace operations. It has + * complexity O(len(replacements) + max(len(originalText), len(finalText)) ) + * + * <p>Main steps: - set srcPos to start of the original text. It is like a cursor position in the + * original text. - set dstPos to start of the final text. It is like a cursor position in the + * final text. - the finalText initially empty + * + * <p>- for each replacement: - append text between a previous and a current replacement to the + * finalText (because replacements were sorted, this part of text can't be changed by following + * replacements). I.e. append substring of srcText between srcPos and replacement.srcRange.start + * to the finalText Update srcPos and dstPos - set them at the end of appended text (i.e. srcPos + * points to the position before replacement.srcRange.start, dstPos points to the position where + * replacement.text should be inserted) - set dstReplacementStart = dstPos - append + * replacement.text to the finalText. Update srcPos and dstPos accordingly (i.e. srcPos points to + * the position after replacement.srcRange, dstPos points to the position in the finalText after + * the appended replacement.text). - set dstReplacementEnd = dstPos - dstRange = + * (dstReplacementStart, dstReplacementEnd) - is the range in the finalText. - srcRange = + * (replacement.Start, replacement.End) - is the range in the original text * + * + * <p>- If previously created ReplaceEdit ends on the same or previous line as srcRange.startLine, + * then intraline edit is added to it (and ReplaceEdit endLine must be updated if needed); + * srcRange and dstRange together is used to calculate intraline Edit otherwise create new + * ReplaceEdit and add intraline Edit to it srcRange and dstRange together is used to calculate + * intraline Edit + * + * <p>- append text after the last replacements, i.e. add part of srcText after srcPos to the + * finalText + * + * <p>- Return the finalText and all created ReplaceEdits + * + * <p>Implementation notes: 1) The intraline Edits inside ReplaceEdit stores positions relative to + * ReplaceEdit start. 2) srcPos and dstPos tracks current position as 3 numbers: - line number - + * column number - textPos - absolute position from the start of the text. The textPos is used to + * calculate relative positions of Edit inside ReplaceEdit + */ private static class ContentBuilder { private static class FixRegion { int startSrcLine;
diff --git a/java/com/google/gerrit/server/flow/FlowActionType.java b/java/com/google/gerrit/server/flow/FlowActionType.java new file mode 100644 index 0000000..4bed63e --- /dev/null +++ b/java/com/google/gerrit/server/flow/FlowActionType.java
@@ -0,0 +1,62 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.flow; + +import com.google.auto.value.AutoValue; +import com.google.gerrit.common.Nullable; + +/** + * An action type to be triggered when the condition of a flow expression becomes satisfied. + * + * <p>Which action types are supported depends on the flow service implementation. + */ +@AutoValue +public abstract class FlowActionType { + /** + * The name of the action type. + * + * <p>Which action types are supported depends on the flow service implementation. + */ + public abstract String name(); + + /** The text to display in the UI as placeholder for the parameters input field. */ + @Nullable + public abstract String parametersPlaceholder(); + + /** Creates a {@link Builder} for this flow action type instance. */ + public abstract Builder toBuilder(); + + /** + * Creates a builder for building a flow action type. + * + * @param name The name of the action type. + * @return the builder for building the flow action type + */ + public static FlowActionType.Builder builder(String name) { + return new AutoValue_FlowActionType.Builder().name(name); + } + + @AutoValue.Builder + public abstract static class Builder { + /** Sets the name of the action type. */ + public abstract Builder name(String name); + + /** Sets the parameters placeholder of the action type. */ + public abstract Builder parametersPlaceholder(@Nullable String parametersPlaceholder); + + /** Builds the {@link FlowActionType}. */ + public abstract FlowActionType build(); + } +}
diff --git a/java/com/google/gerrit/server/flow/FlowService.java b/java/com/google/gerrit/server/flow/FlowService.java index 25fd799..22cae39 100644 --- a/java/com/google/gerrit/server/flow/FlowService.java +++ b/java/com/google/gerrit/server/flow/FlowService.java
@@ -98,4 +98,20 @@ */ ImmutableList<Flow> listFlows(Project.NameKey projectName, Change.Id changeId) throws StorageException; + + /** + * Lists the actions for one change. When configuring flows, the user specifies a condition and + * the actions that can be performed. Return the list of possible actions that have been + * configured for that instance. This allows building an action autocomplete in the UI. + * + * <p>The order of the returned actions is stable, but depends on the flow service implementation. + * + * @param projectName The name of the project that contains the change. + * @param changeId The ID of the change for which the actions should be listed. + * @return The actions of the change. The service may filter out actions that are not visible to + * the current user. + * @throws StorageException thrown if accessing the flow storage has failed + */ + ImmutableList<FlowActionType> listActions(Project.NameKey projectName, Change.Id changeId) + throws StorageException; }
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java index 291d5a9..58d862e 100644 --- a/java/com/google/gerrit/server/git/CommitUtil.java +++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -74,6 +74,8 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.eclipse.jgit.errors.ConfigInvalidException; import org.eclipse.jgit.errors.InvalidObjectIdException; @@ -96,6 +98,11 @@ public class CommitUtil { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + private static final Pattern patternRevertSubject = Pattern.compile("Revert \"(.+)\""); + private static final Pattern patternRevertSubjectWithNum = + Pattern.compile("Revert\\^(\\d+) \"(.+)\""); + private static final Pattern FOOTER_PATTERN = Pattern.compile("^[a-zA-Z0-9-]+:.*"); + private final GitRepositoryManager repoManager; private final Provider<PersonIdent> serverIdent; private final Sequences seq; @@ -172,6 +179,26 @@ public Change.Id createRevertChange( ChangeNotes notes, CurrentUser user, RevertInput input, Instant timestamp) throws RestApiException, UpdateException, ConfigInvalidException, IOException { + return createRevertChange(notes, user, input, timestamp, false); + } + + /** + * Allows creating a revert change with an option to specify if it is a submission revert. + * + * @param notes ChangeNotes of the change being reverted. + * @param user Current User performing the revert. + * @param input the RevertInput entity for conducting the revert. + * @param timestamp timestamp for the created change. + * @param isSubmission whether the revert is being done as part of reverting a submission. + * @return ObjectId that represents the newly created commit. + */ + public Change.Id createRevertChange( + ChangeNotes notes, + CurrentUser user, + RevertInput input, + Instant timestamp, + boolean isSubmission) + throws RestApiException, UpdateException, ConfigInvalidException, IOException { String message = Strings.emptyToNull(input.message); try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { try (Repository git = repoManager.openRepository(notes.getProjectName()); @@ -180,7 +207,8 @@ CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) { ObjectId generatedChangeId = CommitMessageUtil.generateChangeId(); CodeReviewCommit revertCommit = - createRevertCommit(message, notes, user, timestamp, oi, revWalk, generatedChangeId); + createRevertCommit( + message, notes, user, timestamp, oi, revWalk, generatedChangeId, isSubmission); return createRevertChangeFromCommit( revertCommit, input, notes, user, generatedChangeId, timestamp, oi, revWalk, git); } catch (RepositoryNotFoundException e) { @@ -201,12 +229,28 @@ public CodeReviewCommit createRevertCommit( String message, ChangeNotes notes, CurrentUser user, Instant ts) throws RestApiException, IOException { + return createRevertCommit(message, notes, user, ts, false); + } + + /** + * Wrapper function for creating a revert Commit with an option to specify if it is a submission. + * + * @param message Commit message for the revert commit. + * @param notes ChangeNotes of the change being reverted. + * @param user Current User performing the revert. + * @param ts Timestamp of creation for the commit. + * @param isSubmission whether the revert is being done as part of reverting a submission. + * @return that newly created revert commit. + */ + public CodeReviewCommit createRevertCommit( + String message, ChangeNotes notes, CurrentUser user, Instant ts, boolean isSubmission) + throws RestApiException, IOException { try (Repository git = repoManager.openRepository(notes.getProjectName()); ObjectInserter oi = git.newObjectInserter(); ObjectReader reader = oi.newReader(); CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) { - return createRevertCommit(message, notes, user, ts, oi, revWalk, null); + return createRevertCommit(message, notes, user, ts, oi, revWalk, null, isSubmission); } catch (RepositoryNotFoundException e) { throw new ResourceNotFoundException(notes.getProjectName().toString(), e); } @@ -245,7 +289,7 @@ return id; } - public static String getBugAndIssueFooters(RevCommit commit) { + private static String getBugAndIssueFooters(RevCommit commit) { StringBuilder footers = new StringBuilder(); for (FooterLine footerLine : commit.getFooterLines()) { String key = footerLine.getKey(); @@ -267,6 +311,7 @@ * @param revWalk Used for parsing the original commit. * @param generatedChangeId The changeId for the commit message, can be null since it is not * needed for commits, only for changes. + * @param isSubmission whether the revert is being done as part of reverting a submission. * @return ObjectId that represents the newly created commit. * @throws ResourceConflictException Can't revert the initial commit. * @throws IOException Thrown in case of I/O errors. @@ -278,7 +323,8 @@ Instant ts, ObjectInserter oi, CodeReviewRevWalk revWalk, - @Nullable ObjectId generatedChangeId) + @Nullable ObjectId generatedChangeId, + boolean isSubmission) throws ResourceConflictException, IOException { PatchSet patch = notes.getCurrentPatchSet(); @@ -296,26 +342,22 @@ Change changeToRevert = notes.getChange(); String subject = changeToRevert.getSubject(); - if (subject.length() > 63) { - subject = subject.substring(0, 59) + "..."; - } - if (message == null) { - message = - MessageFormat.format( - ChangeMessages.revertChangeDefaultMessage, subject, patch.commitId().name()); - } + + message = getRevertMessage(message, subject, patch.commitId().name(), isSubmission); String newFooters = getBugAndIssueFooters(commitToRevert); if (!newFooters.isEmpty()) { + Set<String> messageLines = + Arrays.stream(message.split("\n")) + .map(String::trim) + .map(String::toLowerCase) + .collect(Collectors.toSet()); StringBuilder footersToAdd = new StringBuilder(); for (String footer : Splitter.on('\n').split(newFooters)) { if (footer.isEmpty()) { continue; } - boolean alreadyExists = - Arrays.stream(message.split("\n")) - .anyMatch(line -> line.trim().equalsIgnoreCase(footer.trim())); - if (!alreadyExists) { + if (!messageLines.contains(footer.trim().toLowerCase())) { footersToAdd.append(footer).append("\n"); } } @@ -324,7 +366,7 @@ String trimmedMsg = message.trim(); List<String> lines = Splitter.on('\n').splitToList(trimmedMsg); String lastLine = lines.isEmpty() ? "" : lines.get(lines.size() - 1); - boolean endsWithFooter = lastLine.matches("^[a-zA-Z0-9-]+:.*"); + boolean endsWithFooter = FOOTER_PATTERN.matcher(lastLine).matches(); if (endsWithFooter) { message = trimmedMsg + "\n" + footersToAdd.toString().trim(); @@ -356,6 +398,53 @@ return revertCommit; } + public String getRevertMessage( + @Nullable String initialMessage, String subject, String commitId, boolean isSubmission) { + // If a message is specified in the non-submission case, it should be + // returned directly. + if (initialMessage != null && !isSubmission) { + return initialMessage; + } + + if (isSubmission) { + if (subject.length() > 60) { + subject = subject.substring(0, 56) + "..."; + } + } else { + if (subject.length() > 63) { + subject = subject.substring(0, 59) + "..."; + } + } + + Matcher matcher = patternRevertSubjectWithNum.matcher(subject); + boolean matchesNum = matcher.matches(); + if (!matchesNum) { + matcher = patternRevertSubject.matcher(subject); + } + + if (matchesNum || matcher.matches()) { + int nextNum = matchesNum ? Integer.parseInt(matcher.group(1)) + 1 : 2; + String originalSubject = matchesNum ? matcher.group(2) : matcher.group(1); + + return MessageFormat.format( + ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage, + nextNum, + originalSubject, + MessageFormat.format(ChangeMessages.revertSubmissionDefaultMessage, commitId), + initialMessage != null ? initialMessage : ""); + } + + if (initialMessage != null) { + // Note that the case of an initial message in the non-submission case + // was handled at the top of the function, so it is ok to always return + // the revertSubmission message here. + return MessageFormat.format( + ChangeMessages.revertSubmissionUserMessage, subject, initialMessage); + } + + return MessageFormat.format(ChangeMessages.revertChangeDefaultMessage, subject, commitId); + } + private Change.Id createRevertChangeFromCommit( CodeReviewCommit revertCommit, RevertInput input,
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java index 724eb78..9a2d3e9 100644 --- a/java/com/google/gerrit/server/git/MergeUtil.java +++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -147,6 +147,7 @@ private final boolean useGitattributesForMerge; private final PluggableCommitMessageGenerator commitMessageGenerator; private final ChangeUtil changeUtil; + private final boolean addChangeReviewFootersToCommitMessage; MergeUtil( @Provided @GerritServerConfig Config serverConfig, @@ -185,6 +186,8 @@ this.useContentMerge = useContentMerge; this.useRecursiveMerge = useRecursiveMerge(serverConfig); this.useGitattributesForMerge = useGitattributesForMerge(serverConfig); + this.addChangeReviewFootersToCommitMessage = + serverConfig.getBoolean("change", "addChangeReviewFootersToCommitMessage", true); } public CodeReviewCommit getFirstFastForward( @@ -617,7 +620,8 @@ * * <ul> * <li>Reviewed-on: <i>url</i> - * <li>Reviewed-by | Tested-by | <i>Other-Label-Name</i>: <i>reviewer</i> + * <li>Reviewed-by | Tested-by | <i>Other-Label-Name</i>: <i>reviewer</i> (if {@code + * addChangeReviewFootersToCommitMessage} is true) * <li>Change-Id * </ul> * @@ -663,63 +667,65 @@ } PatchSetApproval submitAudit = null; - for (PatchSetApproval a : safeGetApprovals(notes, psId)) { - if (a.value() <= 0) { - // Negative votes aren't counted. - continue; - } - - if (a.isLegacySubmit()) { - // Submit is treated specially, below (becomes committer) - // - if (submitAudit == null || a.granted().compareTo(submitAudit.granted()) > 0) { - submitAudit = a; - } - continue; - } - - final Account acc = identifiedUserFactory.create(a.accountId()).getAccount(); - final StringBuilder identbuf = new StringBuilder(); - if (acc.fullName() != null && acc.fullName().length() > 0) { - if (identbuf.length() > 0) { - identbuf.append(' '); - } - identbuf.append(acc.fullName()); - } - if (acc.preferredEmail() != null && acc.preferredEmail().length() > 0) { - if (isSignedOffBy(footers, acc.preferredEmail())) { + if (addChangeReviewFootersToCommitMessage) { + for (PatchSetApproval a : safeGetApprovals(notes, psId)) { + if (a.value() <= 0) { + // Negative votes aren't counted. continue; } - if (identbuf.length() > 0) { - identbuf.append(' '); - } - identbuf.append('<'); - identbuf.append(acc.preferredEmail()); - identbuf.append('>'); - } - if (identbuf.length() == 0) { - // Nothing reasonable to describe them by? Ignore them. - continue; - } - final String tag; - if (isCodeReview(a.labelId())) { - tag = "Reviewed-by"; - } else if (isVerified(a.labelId())) { - tag = "Tested-by"; - } else { - final Optional<LabelType> lt = project.getLabelTypes().byLabel(a.labelId()); - if (!lt.isPresent()) { + if (a.isLegacySubmit()) { + // Submit is treated specially, below (becomes committer) + // + if (submitAudit == null || a.granted().compareTo(submitAudit.granted()) > 0) { + submitAudit = a; + } continue; } - tag = lt.get().getName(); - } - if (!contains(footers, new FooterKey(tag), identbuf.toString())) { - msgbuf.append(tag); - msgbuf.append(": "); - msgbuf.append(identbuf); - msgbuf.append('\n'); + final Account acc = identifiedUserFactory.create(a.accountId()).getAccount(); + final StringBuilder identbuf = new StringBuilder(); + if (acc.fullName() != null && acc.fullName().length() > 0) { + if (identbuf.length() > 0) { + identbuf.append(' '); + } + identbuf.append(acc.fullName()); + } + if (acc.preferredEmail() != null && acc.preferredEmail().length() > 0) { + if (isSignedOffBy(footers, acc.preferredEmail())) { + continue; + } + if (identbuf.length() > 0) { + identbuf.append(' '); + } + identbuf.append('<'); + identbuf.append(acc.preferredEmail()); + identbuf.append('>'); + } + if (identbuf.length() == 0) { + // Nothing reasonable to describe them by? Ignore them. + continue; + } + + final String tag; + if (isCodeReview(a.labelId())) { + tag = "Reviewed-by"; + } else if (isVerified(a.labelId())) { + tag = "Tested-by"; + } else { + final Optional<LabelType> lt = project.getLabelTypes().byLabel(a.labelId()); + if (!lt.isPresent()) { + continue; + } + tag = lt.get().getName(); + } + + if (!contains(footers, new FooterKey(tag), identbuf.toString())) { + msgbuf.append(tag); + msgbuf.append(": "); + msgbuf.append(identbuf); + msgbuf.append('\n'); + } } } return msgbuf.toString(); @@ -927,14 +933,11 @@ } private static CommitMergeStatus getCommitMergeStatus(MergeBaseFailureReason reason) { - switch (reason) { - case MULTIPLE_MERGE_BASES_NOT_SUPPORTED: - case TOO_MANY_MERGE_BASES: - default: - return CommitMergeStatus.MANUAL_RECURSIVE_MERGE; - case CONFLICTS_DURING_MERGE_BASE_CALCULATION: - return CommitMergeStatus.PATH_CONFLICT; - } + return switch (reason) { + case MULTIPLE_MERGE_BASES_NOT_SUPPORTED, TOO_MANY_MERGE_BASES -> + CommitMergeStatus.MANUAL_RECURSIVE_MERGE; + case CONFLICTS_DURING_MERGE_BASE_CALCULATION -> CommitMergeStatus.PATH_CONFLICT; + }; } /**
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java index a3c8deb..3360c3f 100644 --- a/java/com/google/gerrit/server/git/MergedByPushOp.java +++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -25,6 +25,7 @@ import com.google.gerrit.entities.SubmissionId; import com.google.gerrit.server.ChangeMessagesUtil; import com.google.gerrit.server.PatchSetUtil; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.extensions.events.ChangeMerged; import com.google.gerrit.server.mail.EmailFactories; @@ -76,6 +77,7 @@ private final ExecutorService sendEmailExecutor; private final ChangeMerged changeMerged; private final MessageIdGenerator messageIdGenerator; + private final boolean sendEmailEnabled; private final PatchSet.Id psId; private final SubmissionId submissionId; @@ -95,6 +97,7 @@ EmailFactories emailFactories, PatchSetUtil psUtil, @SendEmailExecutor ExecutorService sendEmailExecutor, + @SendEmailEnabled Boolean sendEmailEnabled, ChangeMerged changeMerged, MessageIdGenerator messageIdGenerator, @Assisted RequestScopePropagator requestScopePropagator, @@ -107,6 +110,7 @@ this.emailFactories = emailFactories; this.psUtil = psUtil; this.sendEmailExecutor = sendEmailExecutor; + this.sendEmailEnabled = sendEmailEnabled; this.changeMerged = changeMerged; this.messageIdGenerator = messageIdGenerator; this.requestScopePropagator = requestScopePropagator; @@ -182,41 +186,44 @@ if (!correctBranch) { return; } - @SuppressWarnings("unused") // Runnable already handles errors - Future<?> possiblyIgnoredError = - sendEmailExecutor.submit( - requestScopePropagator.wrap( - new Runnable() { - @Override - public void run() { - try { - // The stickyApprovalDiff is always empty here since this is not supported - // for direct pushes. - ChangeEmail changeEmail = - emailFactories.createChangeEmail( - ctx.getProject(), - psId.changeId(), - emailFactories.createMergedChangeEmail( - /* stickyApprovalDiff= */ Optional.empty(), - /* modifiedFiles= */ List.of())); - changeEmail.setPatchSet(patchSet, info); - OutgoingEmail outgoingEmail = - emailFactories.createOutgoingEmail(CHANGE_MERGED, changeEmail); - outgoingEmail.setFrom(ctx.getAccountId()); - outgoingEmail.setMessageId( - messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id())); - outgoingEmail.send(); - } catch (Exception e) { - logger.atSevere().withCause(e).log( - "Cannot send email for submitted patch set %s", psId); - } - } - @Override - public String toString() { - return "send-email merged"; - } - })); + if (sendEmailEnabled) { + @SuppressWarnings("unused") // Runnable already handles errors + Future<?> possiblyIgnoredError = + sendEmailExecutor.submit( + requestScopePropagator.wrap( + new Runnable() { + @Override + public void run() { + try { + // The stickyApprovalDiff is always empty here since this is not supported + // for direct pushes. + ChangeEmail changeEmail = + emailFactories.createChangeEmail( + ctx.getProject(), + psId.changeId(), + emailFactories.createMergedChangeEmail( + /* stickyApprovalDiff= */ Optional.empty(), + /* modifiedFiles= */ List.of())); + changeEmail.setPatchSet(patchSet, info); + OutgoingEmail outgoingEmail = + emailFactories.createOutgoingEmail(CHANGE_MERGED, changeEmail); + outgoingEmail.setFrom(ctx.getAccountId()); + outgoingEmail.setMessageId( + messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id())); + outgoingEmail.send(); + } catch (Exception e) { + logger.atSevere().withCause(e).log( + "Cannot send email for submitted patch set %s", psId); + } + } + + @Override + public String toString() { + return "send-email merged"; + } + })); + } changeMerged.fire( ctx.getChangeData(change), patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
diff --git a/java/com/google/gerrit/server/git/PureRevertCache.java b/java/com/google/gerrit/server/git/PureRevertCache.java index 8a40618..05fd9bf 100644 --- a/java/com/google/gerrit/server/git/PureRevertCache.java +++ b/java/com/google/gerrit/server/git/PureRevertCache.java
@@ -166,7 +166,7 @@ Project.NameKey project = Project.nameKey(key.getProject()); try (Repository repo = repoManager.openRepository(project); - ObjectInserter oi = repo.newObjectInserter(); + ObjectInserter ins = new InMemoryInserter(repo); RevWalk rw = new RevWalk(repo)) { RevCommit claimedOriginalCommit; try { @@ -185,7 +185,7 @@ ThreeWayMerger merger = mergeUtilFactory .create(projectCache.get(project).orElseThrow(illegalState(project))) - .newThreeWayMerger(oi, repo); + .newThreeWayMerger(ins, repo); merger.setBase(claimedRevertCommit.getParent(0)); boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit); if (!success || merger.getResultTreeId() == null) { @@ -196,7 +196,7 @@ // Any differences between claimed original's parent and the rebase result indicate that // the claimedRevert is not a pure revert but made content changes try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) { - df.setReader(oi.newReader(), repo.getConfig()); + df.setReader(ins.newReader(), repo.getConfig()); List<DiffEntry> entries = df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId()); return entries.isEmpty();
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java index ffbb4d4..0e0e9c0 100644 --- a/java/com/google/gerrit/server/git/WorkQueue.java +++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -254,6 +254,8 @@ if (withMetrics) { logger.atInfo().log("Adding metrics for '%s' queue", queueName); executor.buildMetrics(queueName); + } else { + logger.atInfo().log("Creating '%s' queue without metrics", queueName); } executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(true); @@ -523,31 +525,41 @@ metrics.newCallbackMetric( getMetricName(queueName, "max_pool_size"), Long.class, - new Description("Maximum allowed number of threads in the pool") + new Description("Maximum allowed number of non parked threads in the pool") .setGauge() .setUnit("threads"), - () -> (long) getMaximumPoolSize())); + () -> (long) getMaximumPoolSize() - parked.size())); metricsRegistrationHandles.add( metrics.newCallbackMetric( getMetricName(queueName, "pool_size"), Long.class, - new Description("Current number of threads in the pool") + new Description("Current number of non parked threads in the pool") .setGauge() .setUnit("threads"), - () -> (long) getPoolSize())); + () -> (long) getPoolSize() - parked.size())); + metricsRegistrationHandles.add( + metrics.newCallbackMetric( + getMetricName(queueName, "parked_threads"), + Integer.class, + new Description("Current number of threads that are parked") + .setGauge() + .setUnit("threads"), + () -> parked.size())); metricsRegistrationHandles.add( metrics.newCallbackMetric( getMetricName(queueName, "active_threads"), Long.class, - new Description("Number number of threads that are actively executing tasks") + new Description("Current number of threads that are actively executing tasks") .setGauge() .setUnit("threads"), - () -> (long) getActiveCount())); + () -> (long) getActiveCount() - parked.size())); metricsRegistrationHandles.add( metrics.newCallbackMetric( getMetricName(queueName, "scheduled_tasks"), Integer.class, - new Description("Number of scheduled tasks in the queue").setGauge().setUnit("tasks"), + new Description("Current number of scheduled tasks in the queue") + .setGauge() + .setUnit("tasks"), () -> getQueue().size())); metricsRegistrationHandles.add( metrics.newCallbackMetric( @@ -636,10 +648,7 @@ } void remove(Task<?> task) { - boolean isRemoved = all.remove(task.getTaskId(), task); - if (isRemoved && !listeners.isEmpty()) { - cancelIfParked(task); - } + all.remove(task.getTaskId(), task); } void cancelIfParked(Task<?> task) { @@ -868,6 +877,7 @@ @CanIgnoreReturnValue public boolean cancel(boolean mayInterruptIfRunning) { if (task.cancel(mayInterruptIfRunning)) { + boolean isSetRunningDuringCancellation = false; // 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 @@ -876,6 +886,7 @@ // if (runnable instanceof CancelableRunnable) { if (runningState.compareAndSet(null, State.RUNNING)) { + isSetRunningDuringCancellation = true; ((CancelableRunnable) runnable).cancel(); } else if (runnable instanceof CanceledWhileRunning) { ((CanceledWhileRunning) runnable).setCanceledWhileRunning(); @@ -890,8 +901,11 @@ ((Future<?>) runnable).cancel(mayInterruptIfRunning); } - executor.remove(this); - executor.purge(); + if (isSetRunningDuringCancellation || runningState.get() == null) { + executor.remove(this); + executor.purge(); + } + executor.cancelIfParked(this); return true; } return false;
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java index 743407e..5db5c31 100644 --- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java +++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -33,10 +33,13 @@ import com.google.gerrit.metrics.Field; import com.google.gerrit.metrics.Histogram1; import com.google.gerrit.metrics.MetricMaker; -import com.google.gerrit.metrics.Timer1; +import com.google.gerrit.metrics.Timer0; +import com.google.gerrit.metrics.Timer2; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.PublishCommentsOp; import com.google.gerrit.server.RequestCounter; +import com.google.gerrit.server.account.ServiceUserClassifier; +import com.google.gerrit.server.account.UserKind; import com.google.gerrit.server.cache.PerThreadCache; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.ConfigUtil; @@ -69,6 +72,7 @@ import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.FactoryModuleBuilder; import com.google.inject.name.Named; +import java.io.EOFException; import java.io.IOException; import java.io.OutputStream; import java.util.Collection; @@ -78,12 +82,14 @@ import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import org.eclipse.jgit.errors.TooLargePackException; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.transport.PreReceiveHook; import org.eclipse.jgit.transport.ReceiveCommand; import org.eclipse.jgit.transport.ReceiveCommand.Result; import org.eclipse.jgit.transport.ReceivePack; +import org.eclipse.jgit.transport.UnpackErrorHandler; /** * Hook that delegates to {@link ReceiveCommits} in a worker thread. @@ -98,6 +104,8 @@ private static final String RECEIVE_OVERALL_TIMEOUT_NAME = "ReceiveCommitsOverallTimeout"; private static final String RECEIVE_CANCELLATION_TIMEOUT_NAME = "ReceiveCommitsCancellationTimeout"; + public static final String DEFAULT_EXCEEDED_SIZE_QUOTA_TEMPLATE = + "Push rejected: it would exceed the available repository size quota of %d bytes."; public interface Factory { AsyncReceiveCommits create( @@ -182,8 +190,9 @@ @Singleton private static class Metrics { private final Histogram1<PushType> changes; - private final Timer1<PushType> latencyPerChange; - private final Timer1<PushType> latencyPerPush; + private final Timer2<PushType, UserKind> latencyPerChange; + private final Timer2<PushType, UserKind> latencyPerPush; + private final Timer0 latencyForScheduling; private final Counter0 timeouts; @Inject @@ -202,6 +211,14 @@ Field.ofEnum(PushType.class, "type", Metadata.Builder::pushType) .description("type of push (create/replace, autoclose, normal)") .build(); + Field<UserKind> userKindField = + Field.ofEnum(UserKind.class, "user_kind", Metadata.Builder::caller) + .description( + String.format( + "User kind (SERVICE_USER: member of the Gerrit internal '%s' group," + + " HUMAN_USER: any user that was not classified as a service user)", + ServiceUserClassifier.SERVICE_USERS)) + .build(); latencyPerChange = metricMaker.newTimer( @@ -211,7 +228,8 @@ + "(Only includes pushes which contain changes.)") .setUnit(Units.MILLISECONDS) .setCumulative(), - pushTypeField); + pushTypeField, + userKindField); latencyPerPush = metricMaker.newTimer( @@ -219,7 +237,17 @@ new Description("processing delay for a processing single push") .setUnit(Units.MILLISECONDS) .setCumulative(), - pushTypeField); + pushTypeField, + userKindField); + + latencyForScheduling = + metricMaker.newTimer( + "receivecommits/latency_for_scheduling", + new Description( + "delay for scheduling ReceiveCommits (how long it takes from ReceiveCommits" + + " being submitted to the executor to the executor running it)") + .setUnit(Units.MILLISECONDS) + .setCumulative()); timeouts = metricMaker.newCounter( @@ -229,6 +257,7 @@ private final MultiProgressMonitor.Factory multiProgressMonitorFactory; private final Metrics metrics; + private final ServiceUserClassifier serviceUserClassifier; private final ReceiveCommits receiveCommits; private final PermissionBackend.ForProject perm; private final ReceivePack receivePack; @@ -256,6 +285,7 @@ LazyPostReceiveHookChain.Factory lazyPostReceive, ContributorAgreementsChecker contributorAgreements, Metrics metrics, + ServiceUserClassifier serviceUserClassifier, QuotaBackend quotaBackend, UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook, AllUsersName allUsersName, @@ -279,6 +309,7 @@ this.user = user; this.repo = repo; this.metrics = metrics; + this.serviceUserClassifier = serviceUserClassifier; // If the user lacks READ permission, some references may be filtered and hidden from view. // Check objects mentioned inside the incoming pack file are reachable from visible refs. Project.NameKey projectName = projectState.getNameKey(); @@ -333,7 +364,29 @@ REPOSITORY_SIZE_GROUP, projectName); throw new RuntimeException(e); } - availableTokens.availableTokens().ifPresent(receivePack::setMaxPackSizeLimit); + availableTokens + .availableTokens() + .ifPresent( + availBytes -> { + boolean isZeroBytesRemaining = availBytes == 0; + receivePack.setMaxPackSizeLimit(availBytes); + UnpackErrorHandler defaultErrorHandler = receivePack.getUnpackErrorHandler(); + receivePack.setUnpackErrorHandler( + t -> { + Throwable unpackException = t; + if (t instanceof TooLargePackException + || (t instanceof EOFException && isZeroBytesRemaining)) { + String defaultMessage = + String.format(DEFAULT_EXCEEDED_SIZE_QUOTA_TEMPLATE, availBytes); + unpackException = + new QuotaException( + availableTokens + .mostRestrictiveQuotaExceededMessage() + .orElse(defaultMessage)); + } + defaultErrorHandler.handleUnpackException(unpackException); + }); + }); } /** Determine if the user can upload commits. */ @@ -394,10 +447,16 @@ String currentThreadName = Thread.currentThread().getName(); MultiProgressMonitor monitor = newMultiProgressMonitor(multiProgressMonitorFactory, receiveCommits.getMessageSender()); + long startNanos = System.nanoTime(); Callable<ReceiveCommitsResult> callable = () -> { String oldName = Thread.currentThread().getName(); Thread.currentThread().setName(oldName + "-for-" + currentThreadName); + + // Record how long it takes from this callable being submitted to the executor to the + // executor calling it. + metrics.latencyForScheduling.record(System.nanoTime() - startNanos, NANOSECONDS); + try (PerThreadCache threadLocalCache = PerThreadCache.create()) { return receiveCommits.processCommands(commands, monitor); } finally { @@ -455,10 +514,14 @@ pushType = PushType.NORMAL; } } + UserKind userKind = + serviceUserClassifier.isServiceUser(user.getAccountId()) + ? UserKind.SERVICE_USER + : UserKind.HUMAN_USER; if (totalChanges > 0) { - metrics.latencyPerChange.record(pushType, deltaNanos / totalChanges, NANOSECONDS); + metrics.latencyPerChange.record(pushType, userKind, deltaNanos / totalChanges, NANOSECONDS); } - metrics.latencyPerPush.record(pushType, deltaNanos, NANOSECONDS); + metrics.latencyPerPush.record(pushType, userKind, deltaNanos, NANOSECONDS); } /**
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java index 5138db9..d0c32f4 100644 --- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java +++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -38,7 +38,6 @@ import com.google.gerrit.server.patch.DiffOperationsForCommitValidation; import com.google.gerrit.server.permissions.PermissionBackend; import com.google.gerrit.server.project.ProjectState; -import com.google.gerrit.server.ssh.SshInfo; import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; import java.io.IOException; @@ -58,7 +57,6 @@ private final PermissionBackend.ForProject permissions; private final Project project; private final BranchNameKey branch; - private final SshInfo sshInfo; interface Factory { BranchCommitValidator create( @@ -95,11 +93,9 @@ BranchCommitValidator( CommitValidators.Factory commitValidatorsFactory, PermissionBackend permissionBackend, - SshInfo sshInfo, @Assisted ProjectState projectState, @Assisted BranchNameKey branch, @Assisted IdentifiedUser user) { - this.sshInfo = sshInfo; this.user = user; this.branch = branch; this.commitValidatorsFactory = commitValidatorsFactory; @@ -198,7 +194,6 @@ permissions, branch, user.asIdentifiedUser(), - sshInfo, rejectCommits, receiveEvent.revWalk, change,
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java index 73b680b..848e259 100644 --- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java +++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -18,6 +18,7 @@ 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.flogger.LazyArgs.lazy; import static com.google.gerrit.entities.RefNames.REFS_CHANGES; import static com.google.gerrit.entities.RefNames.isConfigRef; import static com.google.gerrit.entities.RefNames.isRefsUsersSelf; @@ -956,7 +957,7 @@ logger.atFine().log( "Command results: %s", - commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))); + lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(",")))); } } @@ -1475,14 +1476,6 @@ case UPDATE -> parseUpdate(globalRevWalk, ins, cmd); case DELETE -> parseDelete(cmd); case UPDATE_NONFASTFORWARD -> parseRewind(globalRevWalk, ins, cmd); - default -> { - reject( - cmd, - RejectionReason.create( - MetricBucket.UNKNOWN_COMMAND_TYPE, - "prohibited by Gerrit: unknown command type " + cmd.getType())); - return; - } } if (cmd.getResult() != NOT_ATTEMPTED) { @@ -1592,13 +1585,6 @@ } } case DELETE -> {} - default -> - reject( - cmd, - RejectionReason.create( - MetricBucket.UNKNOWN_COMMAND_TYPE, - "prohibited by Gerrit: don't know how to handle config update of type " - + cmd.getType())); } } }
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java index 966904c..a15419a 100644 --- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java +++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -324,7 +324,7 @@ psDescription = magicBranch.message; approvals.putAll(magicBranch.labels); Set<String> hashtags = new HashSet<>(magicBranch.hashtags); - if (hashtags != null && !hashtags.isEmpty()) { + if (!hashtags.isEmpty()) { hashtags.addAll(notes.getHashtags()); update.setHashtags(hashtags); }
diff --git a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java index 15b445e..6704944 100644 --- a/java/com/google/gerrit/server/git/validators/CommentCountValidator.java +++ b/java/com/google/gerrit/server/git/validators/CommentCountValidator.java
@@ -16,48 +16,93 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; +import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Project; import com.google.gerrit.extensions.validators.CommentForValidation; import com.google.gerrit.extensions.validators.CommentValidationContext; import com.google.gerrit.extensions.validators.CommentValidationFailure; import com.google.gerrit.extensions.validators.CommentValidator; +import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.inject.Inject; +import com.google.inject.Provider; import org.eclipse.jgit.lib.Config; /** Limits number of comments to prevent space/time complexity issues. */ public class CommentCountValidator implements CommentValidator { + private final int maxComments; + private final int maxCommentsPerUser; private final ChangeNotes.Factory notesFactory; + private final Provider<CurrentUser> currentUserProvider; @Inject - CommentCountValidator(@GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) { + CommentCountValidator( + @GerritServerConfig Config serverConfig, + ChangeNotes.Factory notesFactory, + Provider<CurrentUser> currentUserProvider) { this.notesFactory = notesFactory; - maxComments = serverConfig.getInt("change", "maxComments", 5_000); + this.currentUserProvider = currentUserProvider; + this.maxComments = serverConfig.getInt("change", "maxComments", 5_000); + this.maxCommentsPerUser = serverConfig.getInt("change", "maxCommentsPerUser", 0); } @Override public ImmutableList<CommentValidationFailure> validateComments( CommentValidationContext ctx, ImmutableList<CommentForValidation> comments) { + ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder(); + ChangeNotes notes = notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId())); - int numExistingCommentsAndChangeMessages = - notes.getHumanComments().size() + notes.getChangeMessages().size(); - if (!comments.isEmpty() - && numExistingCommentsAndChangeMessages + comments.size() > maxComments) { + + int totalExistingComments = notes.getHumanComments().size() + notes.getChangeMessages().size(); + + CommentForValidation lastComment = Iterables.getLast(comments, null); + + if (!comments.isEmpty() && totalExistingComments + comments.size() > maxComments) { // This warning really applies to the set of all comments, but we need to pick one to attach // the message to. - CommentForValidation commentForFailureMessage = Iterables.getLast(comments); - failures.add( - commentForFailureMessage.failValidation( + lastComment.failValidation( String.format( "Exceeding maximum number of comments: %d (existing) + %d (new) > %d", - numExistingCommentsAndChangeMessages, comments.size(), maxComments))); + totalExistingComments, comments.size(), maxComments))); } + + // No per-user limit configured + if (maxCommentsPerUser <= 0) { + return failures.build(); + } + + // Identify the user + CurrentUser user = currentUserProvider.get(); + if (!user.isIdentifiedUser()) { + return failures.build(); + } + + Account.Id userId = user.asIdentifiedUser().getAccountId(); + + // Count existing comments per user only if needed + long existing = + notes.getHumanComments().values().stream() + .filter(c -> c.author != null && c.author.getId().equals(userId)) + .count(); + existing += + notes.getChangeMessages().stream() + .filter(cm -> cm.getAuthor() != null && cm.getAuthor().equals(userId)) + .count(); + + if (existing + comments.size() > maxCommentsPerUser) { + failures.add( + lastComment.failValidation( + String.format( + "Exceeding maximum comments per user: %d (existing) + %d (new) > %d", + existing, comments.size(), maxCommentsPerUser))); + } + return failures.build(); } }
diff --git a/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java index eb1414e..8e1f187 100644 --- a/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java +++ b/java/com/google/gerrit/server/git/validators/CommentSizeValidator.java
@@ -44,9 +44,6 @@ private boolean exceedsSizeLimit(CommentForValidation comment) { return switch (comment.getSource()) { case HUMAN -> commentSizeLimit > 0 && comment.getApproximateSize() > commentSizeLimit; - default -> - throw new RuntimeException( - "Unknown comment source (should not have compiled): " + comment.getSource()); }; } @@ -56,9 +53,6 @@ String.format( "Comment size exceeds limit (%d > %d)", comment.getApproximateSize(), commentSizeLimit); - default -> - throw new RuntimeException( - "Unknown comment source (should not have compiled): " + comment.getSource()); }; } }
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java index 72d1d87..a168dad 100644 --- a/java/com/google/gerrit/server/git/validators/CommitValidators.java +++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -67,8 +67,6 @@ import com.google.gerrit.server.project.ProjectConfig; import com.google.gerrit.server.project.ProjectState; import com.google.gerrit.server.query.approval.ApprovalQueryBuilder; -import com.google.gerrit.server.ssh.HostKey; -import com.google.gerrit.server.ssh.SshInfo; import com.google.gerrit.server.util.MagicBranch; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -150,7 +148,6 @@ PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user, - SshInfo sshInfo, NoteMap rejectCommits, RevWalk rw, @Nullable Change change, @@ -167,9 +164,7 @@ .add(new FileCountValidator(config, urlFormatter.get(), metricMaker)) .add(new CommitterUploaderValidator(user, perm, urlFormatter.get())) .add(new SignedOffByValidator(user, perm, projectState)) - .add( - new ChangeIdValidator( - changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change)) + .add(new ChangeIdValidator(changeUtil, projectState, urlFormatter.get(), config, change)) .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)) .add(new BannedCommitsValidator(rejectCommits)); @@ -190,7 +185,6 @@ PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user, - SshInfo sshInfo, RevWalk rw, @Nullable Change change) { PermissionBackend.ForRef perm = forProject.ref(branch.branch()); @@ -204,9 +198,7 @@ .add(new AuthorUploaderValidator(user, perm, urlFormatter.get())) .add(new FileCountValidator(config, urlFormatter.get(), metricMaker)) .add(new SignedOffByValidator(user, perm, projectState)) - .add( - new ChangeIdValidator( - changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change)) + .add(new ChangeIdValidator(changeUtil, projectState, urlFormatter.get(), config, change)) .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)); Iterator<PluginSetEntryContext<CommitValidationListener>> pluginValidatorsIt = @@ -387,24 +379,18 @@ private final ProjectState projectState; private final UrlFormatter urlFormatter; private final String installCommitMsgHookCommand; - private final SshInfo sshInfo; - private final IdentifiedUser user; private final Change change; public ChangeIdValidator( ChangeUtil changeUtil, ProjectState projectState, - IdentifiedUser user, UrlFormatter urlFormatter, Config config, - SshInfo sshInfo, Change change) { this.changeUtil = changeUtil; this.projectState = projectState; - this.user = user; this.urlFormatter = urlFormatter; installCommitMsgHookCommand = config.getString("gerrit", null, "installCommitMsgHookCommand"); - this.sshInfo = sshInfo; this.change = change; } @@ -499,53 +485,14 @@ if (installCommitMsgHookCommand != null) { return installCommitMsgHookCommand; } - final List<HostKey> hostKeys = sshInfo.getHostKeys(); - // If there are no SSH keys, the commit-msg hook must be installed via - // HTTP(S) Optional<String> webUrl = urlFormatter.getWebUrl(); + checkState(webUrl.isPresent()); - String httpHook = - String.format( - "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\"" - + " %stools/hooks/commit-msg ; chmod +x \"$f\"", - webUrl.get()); - - if (hostKeys.isEmpty()) { - checkState(webUrl.isPresent()); - return httpHook; - } - - // SSH keys exist, so the hook might be able to be installed with scp. - String sshHost; - int sshPort; - String host = hostKeys.get(0).getHost(); - int c = host.lastIndexOf(':'); - if (0 <= c) { - if (host.startsWith("*:")) { - checkState(webUrl.isPresent()); - sshHost = getGerritHost(webUrl.get()); - } else { - sshHost = host.substring(0, c); - } - sshPort = Integer.parseInt(host.substring(c + 1)); - } else { - sshHost = host; - sshPort = 22; - } - - // TODO(15944): Remove once both SFTP/SCP protocol are supported. - // - // In newer versions of OpenSSH, the default hook installation command will fail with a - // cryptic error because the scp binary defaults to a different protocol. - String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)"; - - String sshHook = - String.format( - "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg" - + " ${gitdir}/hooks/", - sshPort, user.getUserName().orElse("<USERNAME>"), sshHost); - return String.format(" %s\n%s\nor, for http(s):\n %s", sshHook, scpFlagHint, httpHook); + return String.format( + "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\"" + + " %stools/hooks/commit-msg ; chmod +x \"$f\"", + webUrl.get()); } }
diff --git a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java index c9972ef..c0a1362 100644 --- a/java/com/google/gerrit/server/git/validators/RefOperationValidators.java +++ b/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -35,7 +35,6 @@ import com.google.inject.assistedinject.Assisted; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.transport.ReceiveCommand; @@ -131,7 +130,6 @@ case DELETE -> "deletion"; case UPDATE -> "update"; case UPDATE_NONFASTFORWARD -> "non-fast-forward update"; - default -> type.toString().toLowerCase(Locale.US); }; }
diff --git a/java/com/google/gerrit/server/git/validators/TopicValidator.java b/java/com/google/gerrit/server/git/validators/TopicValidator.java index 46c56f3..d51e102 100644 --- a/java/com/google/gerrit/server/git/validators/TopicValidator.java +++ b/java/com/google/gerrit/server/git/validators/TopicValidator.java
@@ -17,6 +17,9 @@ import com.google.common.base.Strings; import com.google.gerrit.common.Nullable; import com.google.gerrit.server.config.GerritServerConfig; +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.query.change.InternalChangeQuery; import com.google.gerrit.server.validators.ValidationException; import com.google.inject.Inject; @@ -43,13 +46,16 @@ if (Strings.isNullOrEmpty(topic)) { return; } - int topicSize = queryProvider.get().noFields().byTopicOpen(topic).size(); - if (topicSize >= topicLimit) { - throw new ValidationException( - String.format( - "Topic '%s' already contains maximum number of allowed changes per 'topicLimit'" - + " server config value %d.", - topic, topicLimit)); + try (TraceTimer ignored = TraceContext.newTimer("Validate Topic Size", Metadata.empty())) { + int topicSize = + queryProvider.get().noFields().setLimit(topicLimit + 1).byTopicOpen(topic).size(); + if (topicSize >= topicLimit) { + throw new ValidationException( + String.format( + "Topic '%s' already contains maximum number of allowed changes per 'topicLimit'" + + " server config value %d.", + topic, topicLimit)); + } } } }
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java index a4da077..ab77c9d 100644 --- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java +++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -483,6 +483,7 @@ case RENAMED: case REJECTED_MISSING_OBJECT: case REJECTED_OTHER_REASON: + case REJECTED_CURRENT_BRANCH: default: throw new StorageException(String.format("Cannot delete %s: %s", ref, result.name())); }
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java index 6c30479..75142ac 100644 --- a/java/com/google/gerrit/server/index/change/ChangeField.java +++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -576,6 +576,17 @@ IndexedField.<ChangeData>integerBuilder("RevertOf") .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null); + /** The number of reviewers of this change. */ + public static final IndexedField<ChangeData, Integer> REVIEWER_COUNT_FIELD = + IndexedField.<ChangeData>integerBuilder("ReviewerCount") + .stored() + .build( + cd -> cd.getReviewerCount(), + (cd, field) -> cd.setReviewerCount(cd.getReviewerCount())); + + public static final IndexedField<ChangeData, Integer>.SearchSpec REVIEWER_COUNT_SPEC = + REVIEWER_COUNT_FIELD.integerRange(ChangeQueryBuilder.FIELD_REVIEWERCOUNT); + public static final IndexedField<ChangeData, Integer>.SearchSpec REVERT_OF = REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java index 9b752f9..aaa7535 100644 --- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java +++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -271,7 +271,15 @@ .build(); /** Upgrade Lucene to 10.x requires reindexing. */ - static final Schema<ChangeData> V87 = schema(V86); + @Deprecated static final Schema<ChangeData> V87 = schema(V86); + + /** Add REVIEWERS_COUNT_FIELD */ + static final Schema<ChangeData> V88 = + new Schema.Builder<ChangeData>() + .add(V87) + .addIndexedFields(ChangeField.REVIEWER_COUNT_FIELD) + .addSearchSpecs(ChangeField.REVIEWER_COUNT_SPEC) + .build(); /** * Name of the change index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/ioutil/BUILD b/java/com/google/gerrit/server/ioutil/BUILD index a783308..d71ce0c 100644 --- a/java/com/google/gerrit/server/ioutil/BUILD +++ b/java/com/google/gerrit/server/ioutil/BUILD
@@ -10,7 +10,6 @@ "//lib:automaton", "//lib:guava", "//lib:jgit", - "//lib:jgit-archive", "//lib/errorprone:annotations", ], )
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java index fce4844..a755960 100644 --- a/java/com/google/gerrit/server/logging/LoggingContext.java +++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -59,6 +59,12 @@ private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>(); + /** + * ThreadLocal variable to keep track of operations which are currently running. Allows to know + * the callers (aka parent operations) of an operation for the purpose of logging the callers. + */ + private static final ThreadLocal<RunningOperations> runningOperations = new ThreadLocal<>(); + private LoggingContext() {} /** This method is expected to be called via reflection (and might otherwise be unused). */ @@ -74,7 +80,17 @@ return new LoggingContextAwareRunnable( runnable, getInstance().getMutablePerformanceLogRecords(), - getInstance().getMutableAclRecords()); + getInstance().getMutableAclRecords(), + // We copy the currently running operations to the background thread so that so that these + // operations appear as callers of any operations that are executed in the background + // thread. Since we copy RunningOperations any operations that are newly added in the + // background thread do not affect the current thread. To avoid that the async operations + // are misinterpreted as sub-steps of the callers LoggingContextAwareRunnable has a generic + // operation to record the execution of the run method that sets the thread name in a + // metadata field. When formatting the operations (see Metadata#decorateOperation(String)) + // we include the thread name so that we can see from the caller chain where async calls are + // done. + getInstance().getRunningOperations().copy()); } public static <T> Callable<T> copy(Callable<T> callable) { @@ -85,7 +101,17 @@ return new LoggingContextAwareCallable<>( callable, getInstance().getMutablePerformanceLogRecords(), - getInstance().getMutableAclRecords()); + getInstance().getMutableAclRecords(), + // We copy the currently running operations to the background thread so that so that these + // operations appear as callers of any operations that are executed in the background + // thread. Since we copy RunningOperations any operations that are newly added in the + // background thread do not affect the current thread. To avoid that the async operations + // are misinterpreted as sub-steps of the callers LoggingContextAwareCallable has a generic + // operation to record the execution of the call method that sets the thread name in a + // metadata field. When formatting the operations (see Metadata#decorateOperation(String)) + // we include the thread name so that we can see from the caller chain where async calls are + // done. + getInstance().getRunningOperations().copy()); } public boolean isEmpty() { @@ -94,7 +120,8 @@ && performanceLogging.get() == null && (performanceLogRecords.get() == null || performanceLogRecords.get().isEmtpy()) && aclLogging.get() == null - && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty()); + && (aclLogRecords.get() == null || aclLogRecords.get().isEmpty()) + && (runningOperations.get() == null || runningOperations.get().isEmpty()); } public void clear() { @@ -105,6 +132,7 @@ performanceLogRecords.remove(); aclLogging.remove(); aclLogRecords.remove(); + runningOperations.remove(); } catch (RuntimeException e) { FluentLogger.forEnclosingClass() .atSevere() @@ -361,6 +389,19 @@ return records; } + public RunningOperations getRunningOperations() { + RunningOperations runningOperations = LoggingContext.runningOperations.get(); + if (runningOperations == null) { + runningOperations = new RunningOperations(); + LoggingContext.runningOperations.set(runningOperations); + } + return runningOperations; + } + + void setRunningOperations(RunningOperations runningOperations) { + LoggingContext.runningOperations.set(requireNonNull(runningOperations)); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -370,6 +411,7 @@ .add("performanceLogRecords", performanceLogRecords.get()) .add("aclLogging", aclLogging.get()) .add("aclLogRecords", aclLogRecords.get()) + .add("runningOperations", getRunningOperations()) .toString(); } }
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java index 4a2701a..4fb644e 100644 --- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java +++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -16,6 +16,7 @@ import com.google.common.collect.ImmutableSetMultimap; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; import java.util.concurrent.Callable; /** @@ -42,6 +43,7 @@ private final MutablePerformanceLogRecords mutablePerformanceLogRecords; private final boolean aclLogging; private final MutableAclLogRecords mutableAclLogRecords; + private final RunningOperations runningOperations; /** * Creates a LoggingContextAwareCallable that wraps the given {@link Callable}. @@ -51,11 +53,13 @@ * performance log records that are created from the runnable are added * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records * that are created from the runnable are added + * @param runningOperations the currently running operations */ LoggingContextAwareCallable( Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords, - MutableAclLogRecords mutableAclLogRecords) { + MutableAclLogRecords mutableAclLogRecords, + RunningOperations runningOperations) { this.callable = callable; this.callingThread = Thread.currentThread(); this.tags = LoggingContext.getInstance().getTagsAsMap(); @@ -64,6 +68,7 @@ this.mutablePerformanceLogRecords = mutablePerformanceLogRecords; this.aclLogging = LoggingContext.getInstance().isAclLogging(); this.mutableAclLogRecords = mutableAclLogRecords; + this.runningOperations = runningOperations; } @Override @@ -89,7 +94,16 @@ loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords); loggingCtx.aclLogging(aclLogging); loggingCtx.setMutableAclLogRecords(mutableAclLogRecords); - returnValue = callable.call(); + loggingCtx.setRunningOperations(runningOperations); + + // This operation allows us to see where async operations are done, see + // Metadata#decorateOperation(String) that includes the thread name when formatting operation + // names. + try (TraceTimer timer = + TraceContext.newTimer( + "Callable", Metadata.builder().thread(Thread.currentThread().getName()).build())) { + returnValue = callable.call(); + } } finally { // Cleanup logging context. This is important if the thread is pooled and reused. loggingCtx.clear();
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java index 5b5b47a..c43d869 100644 --- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java +++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -16,6 +16,7 @@ import com.google.common.collect.ImmutableSetMultimap; import com.google.common.flogger.FluentLogger; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; /** * Wrapper for a {@link Runnable} that copies the {@link LoggingContext} from the current thread to @@ -60,6 +61,7 @@ private final MutablePerformanceLogRecords mutablePerformanceLogRecords; private final boolean aclLogging; private final MutableAclLogRecords mutableAclLogRecords; + private final RunningOperations runningOperations; /** * Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}. @@ -69,11 +71,13 @@ * performance log records that are created from the runnable are added * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records * that are created from the runnable are added + * @param runningOperations the currently running operations */ LoggingContextAwareRunnable( Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords, - MutableAclLogRecords mutableAclLogRecords) { + MutableAclLogRecords mutableAclLogRecords, + RunningOperations runningOperations) { this.runnable = runnable; this.callingThread = Thread.currentThread(); this.tags = LoggingContext.getInstance().getTagsAsMap(); @@ -82,6 +86,7 @@ this.mutablePerformanceLogRecords = mutablePerformanceLogRecords; this.aclLogging = LoggingContext.getInstance().isAclLogging(); this.mutableAclLogRecords = mutableAclLogRecords; + this.runningOperations = runningOperations; } public Runnable unwrap() { @@ -110,7 +115,16 @@ loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords); loggingCtx.aclLogging(aclLogging); loggingCtx.setMutableAclLogRecords(mutableAclLogRecords); - runnable.run(); + loggingCtx.setRunningOperations(runningOperations); + + // This operation allows us to see where async operations are done, see + // Metadata#decorateOperation(String) that includes the thread name when formatting operation + // names. + try (TraceTimer timer = + TraceContext.newTimer( + "Runnable", Metadata.builder().thread(Thread.currentThread().getName()).build())) { + runnable.run(); + } } finally { // Cleanup logging context. This is important if the thread is pooled and reused. loggingCtx.clear();
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java index 60464e5..5a66dec 100644 --- a/java/com/google/gerrit/server/logging/Metadata.java +++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -26,6 +26,9 @@ /** Metadata that is provided to {@link PerformanceLogger}s as context for performance records. */ @AutoValue public abstract class Metadata { + // Keep in sync with PluginMetrics.PLUGIN_LATENCY_NAME. + public static final String PLUGIN_LATENCY_NAME = "plugin/latency"; + /** The numeric ID of an account. */ public abstract Optional<Integer> accountId(); @@ -178,11 +181,15 @@ /** The name of a REST view. */ public abstract Optional<String> restViewName(); - public abstract Optional<String> submitRequirementName(); - /** The SHA1 of Git commit. */ public abstract Optional<String> revision(); + /** The name of the submit requirement. */ + public abstract Optional<String> submitRequirementName(); + + /** The name of the thread. */ + public abstract Optional<String> thread(); + /** The username of an account. */ public abstract Optional<String> username(); @@ -207,7 +214,8 @@ * noteDbRefName=Optional.empty, noteDbSequenceType=Optional.empty, patchSetId=Optional.empty, * pluginMetadata=[], pluginName=Optional.empty, projectName=Optional.empty, * pushType=Optional.empty, requestType=Optional.empty, resourceCount=Optional.empty, - * restViewName=Optional.empty, revision=Optional.empty, username=Optional.empty} + * restViewName=Optional.empty, revision=Optional.empty, submitRequirementName=Optional.empty, + * thread=Optional.empty, username=Optional.empty} * </pre> * * <p>That's hard to read in logs. This is why this method @@ -280,12 +288,53 @@ .add("requestType", requestType().orElse(null)) .add("resourceCount", resourceCount().orElse(null)) .add("restViewName", restViewName().orElse(null)) - .add("submitRequirementName", submitRequirementName().orElse(null)) .add("revision", revision().orElse(null)) + .add("submitRequirementName", submitRequirementName().orElse(null)) + .add("thread", thread().orElse(null)) .add("username", username().orElse(null)) .toString(); } + /** + * Decorates the operation name with information from this metadata. + * + * <ul> + * <li>The operation name is prefixed with the thread name, if available in this metadata, so + * that async calls can be recognized. + * <li>For {@code plugin/latency} operations the plugin name and the class from this metadata + * are included. + * </ul> + * + * @param operationName the operation name that should be decorated + */ + public String decorateOperation(String operationName) { + StringBuilder s = new StringBuilder(); + + if (thread().isPresent()) { + s.append(String.format("[%s] ", thread().get())); + } + + s.append(operationName); + + if (PLUGIN_LATENCY_NAME.equals(operationName)) { + if (pluginName().isPresent()) { + if (className().isPresent()) { + s.append(String.format(" (%s:%s)", pluginName().get(), className().get())); + } else { + s.append(String.format(" (%s)", pluginName().get())); + } + } else if (className().isPresent()) { + s.append(String.format(" (%s)", className().get())); + } + } + + if (restViewName().isPresent()) { + s.append(String.format(" (view: %s)", restViewName().get())); + } + + return s.toString(); + } + public static Metadata.Builder builder() { return new AutoValue_Metadata.Builder(); } @@ -400,6 +449,8 @@ public abstract Builder submitRequirementName(@Nullable String srName); + public abstract Builder thread(@Nullable String thread); + public abstract Builder username(@Nullable String username); public abstract Metadata build();
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java index e2fe752..b3d89b0 100644 --- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java +++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -28,10 +28,12 @@ import com.google.gerrit.server.cancellation.PerformanceSummaryProvider; import com.google.gerrit.server.util.time.TimeUtil; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.logging.Level; +import java.util.stream.Stream; import org.eclipse.jgit.lib.Config; /** @@ -49,9 +51,6 @@ public class PerformanceLogContext implements AutoCloseable, PerformanceSummaryProvider { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - // Keep in sync with PluginMetrics.PLUGIN_LATENCY_NAME. - private static final String PLUGIN_LATENCY_NAME = "plugin/latency"; - /** Default for maximum number of operations for which the latency should be logged. */ private static final int DEFAULT_MAX_OPERATIONS_TO_LOG = 25; @@ -136,31 +135,23 @@ Map<String, PerformanceInfo> perRequestPerformanceInfo = new HashMap<>(); for (PerformanceLogRecord performanceLogRecord : performanceLogRecords) { - String pluginClass = - PLUGIN_LATENCY_NAME.equals(performanceLogRecord.operation()) - ? performanceLogRecord - .metadata() - .map(Metadata::className) - .map(clazz -> clazz.isPresent() ? " (" + clazz.get() + ")" : "") - .orElse("") - : ""; PerformanceInfo info = perRequestPerformanceInfo.computeIfAbsent( - performanceLogRecord.operation() + pluginClass, + performanceLogRecord.getDecoratedOperationName(), operationName -> new PerformanceInfo(operationName)); - info.add(performanceLogRecord.durationNanos()); + info.add(performanceLogRecord.durationNanos(), performanceLogRecord.parentOperations()); } ImmutableList<PerformanceInfo> performanceInfosWithLongestTotalDuration = perRequestPerformanceInfo.values().stream() - .filter(performanceLogInfo -> performanceLogInfo.totalDurationMillis() > 0) + .filter(performanceInfo -> performanceInfo.totalDurationMillis() > 0) .sorted(comparing(PerformanceInfo::totalDurationNanos).reversed()) .limit(maxOperationsToLog) .collect(toImmutableList()); ImmutableList<PerformanceInfo> performanceInfosForOperationsThatHaveBeenCalledMostOften = perRequestPerformanceInfo.values().stream() - .filter(performanceLogInfo -> performanceLogInfo.count() > 1) + .filter(performanceInfo -> performanceInfo.count() > 1) .sorted( comparing(PerformanceInfo::count) .thenComparing(PerformanceInfo::totalDurationNanos) @@ -176,11 +167,46 @@ Operations which have been called most often (max %s): %s + + Callers: + %s """, maxOperationsToLog, Joiner.on('\n').join(performanceInfosWithLongestTotalDuration), maxOperationsToLog, - Joiner.on('\n').join(performanceInfosForOperationsThatHaveBeenCalledMostOften))); + Joiner.on('\n').join(performanceInfosForOperationsThatHaveBeenCalledMostOften), + Joiner.on('\n') + .join( + formatCallers( + performanceInfosWithLongestTotalDuration, + performanceInfosForOperationsThatHaveBeenCalledMostOften)))); + } + + private ImmutableList<String> formatCallers( + ImmutableList<PerformanceInfo> performanceInfosWithLongestTotalDuration, + ImmutableList<PerformanceInfo> performanceInfosForOperationsThatHaveBeenCalledMostOften) { + // We want to show callers for all operations that are shown either in the "Operations with the + // highest latency" section (performanceInfosWithLongestTotalDuration) or in the "Operations + // which have been called most often" section + // (performanceInfosForOperationsThatHaveBeenCalledMostOften). + Stream<PerformanceInfo> performanceInfoStream = + Stream.concat( + performanceInfosWithLongestTotalDuration.stream(), + performanceInfosForOperationsThatHaveBeenCalledMostOften.stream()) + .distinct(); + + // For some operations there are no known caller (because they are root operations). Since we + // have no callers to show for them, we omit them. + performanceInfoStream = performanceInfoStream.filter(PerformanceInfo::hasKnownCallers); + + // Sort the operations in the "Callers" section alphabetically (ignoring the case). + performanceInfoStream = + performanceInfoStream.sorted( + comparing( + performanceInfo -> performanceInfo.getOperationName().toLowerCase(Locale.US))); + + // Create one string per operation that lists the callers of the operation. + return performanceInfoStream.map(PerformanceInfo::formatCallers).collect(toImmutableList()); } /** @@ -233,15 +259,38 @@ private int count; private long totalDurationNanos; + Map<String, Integer> countsPerParents = new HashMap<>(); + PerformanceInfo(String operationName) { this.operationName = operationName; this.count = 0; this.totalDurationNanos = 0; } - void add(long durationNanos) { + public String getOperationName() { + return operationName; + } + + void add(long durationNanos, ImmutableList<String> parentOperations) { this.count++; this.totalDurationNanos += durationNanos; + + if (!parentOperations.isEmpty()) { + String formattedParentOperations = formatParentOperations(parentOperations); + if (!countsPerParents.containsKey(formattedParentOperations)) { + countsPerParents.put(formattedParentOperations, 0); + } + countsPerParents.put( + formattedParentOperations, countsPerParents.get(formattedParentOperations) + 1); + } + } + + private String formatParentOperations(ImmutableList<String> parentOperations) { + return Joiner.on(" > ") + .join( + parentOperations.stream() + .map(o -> String.format("'%s'", o)) + .collect(toImmutableList())); } int count() { @@ -256,6 +305,30 @@ return TimeUnit.NANOSECONDS.toMillis(totalDurationNanos); } + boolean hasKnownCallers() { + return !countsPerParents.isEmpty(); + } + + String formatCallers() { + if (!hasKnownCallers()) { + return String.format("%s (%sx): n/a", operationName, count); + } + + return String.format( + "%s (%sx): \n%s", + operationName, + count, + Joiner.on('\n') + .join( + countsPerParents.entrySet().stream() + .sorted( + comparing((Map.Entry<String, Integer> e) -> e.getValue()) + .thenComparing(e -> e.getKey().toLowerCase(Locale.US)) + .reversed()) + .map(e -> String.format("* %sx %s", e.getValue(), e.getKey())) + .collect(toImmutableList()))); + } + @Override public String toString() { long totalDurationMillis = totalDurationMillis();
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java index 2f6c420..47b60f7 100644 --- a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java +++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -15,6 +15,7 @@ package com.google.gerrit.server.logging; import com.google.auto.value.AutoValue; +import com.google.common.collect.ImmutableList; import java.time.Instant; import java.util.Optional; @@ -31,11 +32,14 @@ * * @param operation the name of operation the is was performed * @param durationNanos the execution time in nanoseconds + * @param parentOperations the parent operations that called the operation for which this + * performance log is being created * @return the performance log record */ - public static PerformanceLogRecord create(String operation, long durationNanos) { + public static PerformanceLogRecord create( + String operation, long durationNanos, ImmutableList<String> parentOperations) { return new AutoValue_PerformanceLogRecord( - operation, durationNanos, Instant.now(), Optional.empty()); + operation, durationNanos, Instant.now(), parentOperations, Optional.empty()); } /** @@ -47,9 +51,12 @@ * @return the performance log record */ public static PerformanceLogRecord create( - String operation, long durationNanos, Metadata metadata) { + String operation, + long durationNanos, + ImmutableList<String> parentOperations, + Metadata metadata) { return new AutoValue_PerformanceLogRecord( - operation, durationNanos, Instant.now(), Optional.of(metadata)); + operation, durationNanos, Instant.now(), parentOperations, Optional.of(metadata)); } public abstract String operation(); @@ -58,8 +65,14 @@ public abstract Instant endTime(); + public abstract ImmutableList<String> parentOperations(); + public abstract Optional<Metadata> metadata(); + public String getDecoratedOperationName() { + return metadata().isPresent() ? metadata().get().decorateOperation(operation()) : operation(); + } + void writeTo(PerformanceLogger performanceLogger) { if (metadata().isPresent()) { performanceLogger.logNanos(operation(), durationNanos(), endTime(), metadata().get());
diff --git a/java/com/google/gerrit/server/logging/RunningOperations.java b/java/com/google/gerrit/server/logging/RunningOperations.java new file mode 100644 index 0000000..35ccf00 --- /dev/null +++ b/java/com/google/gerrit/server/logging/RunningOperations.java
@@ -0,0 +1,120 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.logging; + +import static com.google.common.collect.ImmutableList.toImmutableList; + +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import java.util.ArrayList; +import java.util.ListIterator; + +/** + * Class to keep track of operations which are currently running. Allows to get the callers (aka + * parent operations) of an operation for logging. + * + * <p>This class is not thread safe. + */ +public class RunningOperations { + public interface RegistrationHandle { + ImmutableList<String> parentOperations(); + + /** Delete this registration. */ + void remove(); + } + + private final ArrayList<Operation> operations; + + public RunningOperations() { + this.operations = new ArrayList<>(); + } + + /** + * Adds a new operation. + * + * <p>Allows to retrieve the callers of the operation (aka parent operations) via the returned + * registration handle (see {@link RegistrationHandle#parentOperations()}), for the purpose of + * logging them. + * + * <p>Callers must remove the operation when it's done by calling {@link + * RegistrationHandle#remove()} on the returned registration handle. + * + * @param operationName the name of the operation that is started + * @param metadata the metadata that should be recorded/logged for the operation + * @return registration handle that allows to retrieve the parent operations and that must be used + * to remove the operation when it is done + */ + public RegistrationHandle add(String operationName, Metadata metadata) { + // Remember the operations that were running at the moment when the new operation is added. + ImmutableList<String> parentOperations = toOperationNames(); + + int operationId = getOperationId(); + Operation operation = new Operation(operationId, operationName, metadata); + operations.add(operation); + + return new RegistrationHandle() { + @Override + public ImmutableList<String> parentOperations() { + return parentOperations; + } + + @Override + public void remove() { + // In most cases it's the last operation that needs to be removed. Iterate over the list in + // reverse order to find it faster. + ListIterator<Operation> listIterator = operations.listIterator(operations.size()); + while (listIterator.hasPrevious()) { + if (listIterator.previous().id() == operationId) { + listIterator.remove(); + return; + } + } + } + }; + } + + private int getOperationId() { + Operation lastOperation = Iterables.getLast(operations, null); + return lastOperation != null ? lastOperation.id() + 1 : 0; + } + + /** Returns the names of the currently running operations. */ + public ImmutableList<String> toOperationNames() { + return operations.stream().map(Operation::getDecoratedOperationName).collect(toImmutableList()); + } + + public boolean isEmpty() { + return operations.isEmpty(); + } + + /** Makes a copy of this instance to be used in other threads. */ + public RunningOperations copy() { + RunningOperations runningOperations = new RunningOperations(); + runningOperations.operations.addAll(operations); + return runningOperations; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("operations", toOperationNames()).toString(); + } + + record Operation(int id, String operationName, Metadata metadata) { + String getDecoratedOperationName() { + return metadata.decorateOperation(operationName); + } + } +}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java index 22f1613..71a0bda 100644 --- a/java/com/google/gerrit/server/logging/TraceContext.java +++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -27,9 +27,10 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.common.Nullable; import com.google.gerrit.server.cancellation.RequestStateContext; +import com.google.gerrit.server.logging.RunningOperations.RegistrationHandle; import java.util.Optional; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; +import java.util.function.BiConsumer; /** * TraceContext that allows to set logging tags and enforce logging. @@ -179,37 +180,50 @@ public static class TraceTimer implements AutoCloseable { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); - private final Consumer<Long> doneLogFn; + private final RegistrationHandle registrationHandle; + private final BiConsumer<Long, ImmutableList<String>> doneLogFn; private final Stopwatch stopwatch; private TraceTimer(String operation) { this( + operation, + Metadata.empty(), () -> logger.atFine().log("Starting timer %s", operation), - elapsedNanos -> { + (elapsedNanos, parentOperations) -> { LoggingContext.getInstance() .addPerformanceLogRecord( - () -> PerformanceLogRecord.create(operation, elapsedNanos)); + () -> PerformanceLogRecord.create(operation, elapsedNanos, parentOperations)); logger.atFine().log("timer %s took %.2f ms", operation, elapsedNanos / 1000000.0); }); } private TraceTimer(String operation, Metadata metadata) { this( + operation, + metadata, () -> logger.atFine().log( "Starting timer %s (%s)", operation, metadata.toStringForLogging()), - elapsedNanos -> { + (elapsedNanos, parentOperations) -> { LoggingContext.getInstance() .addPerformanceLogRecord( - () -> PerformanceLogRecord.create(operation, elapsedNanos, metadata)); + () -> + PerformanceLogRecord.create( + operation, elapsedNanos, parentOperations, metadata)); logger.atFine().log( "timer %s (%s) took %.2f ms", operation, metadata.toStringForLogging(), elapsedNanos / 1000000.0); }); } - private TraceTimer(Runnable startLogFn, Consumer<Long> doneLogFn) { + private TraceTimer( + String operation, + Metadata metadata, + Runnable startLogFn, + BiConsumer<Long, ImmutableList<String>> doneLogFn) { RequestStateContext.abortIfCancelled(); + this.registrationHandle = + LoggingContext.getInstance().getRunningOperations().add(operation, metadata); startLogFn.run(); this.doneLogFn = doneLogFn; this.stopwatch = Stopwatch.createStarted(); @@ -218,7 +232,9 @@ @Override public void close() { stopwatch.stop(); - doneLogFn.accept(stopwatch.elapsed(TimeUnit.NANOSECONDS)); + doneLogFn.accept( + stopwatch.elapsed(TimeUnit.NANOSECONDS), registrationHandle.parentOperations()); + registrationHandle.remove(); RequestStateContext.abortIfCancelled(); } }
diff --git a/java/com/google/gerrit/server/mail/EmailFactories.java b/java/com/google/gerrit/server/mail/EmailFactories.java index 0209f4f..47b6beb 100644 --- a/java/com/google/gerrit/server/mail/EmailFactories.java +++ b/java/com/google/gerrit/server/mail/EmailFactories.java
@@ -144,6 +144,9 @@ ChangeEmail createChangeEmail( Project.NameKey project, Change.Id changeId, ChangeEmailDecorator changeEmailDecorator); + /** Base email decorator for change-related emails. */ + ChangeEmail createChangeEmail(Change change, ChangeEmailDecorator changeEmailDecorator); + /** Email decorator for adding a key to the account. */ EmailDecorator createAddKeyEmail(IdentifiedUser user, AccountSshKey sshKey);
diff --git a/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java b/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java index 618dc6a..db0b3d2 100644 --- a/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java +++ b/java/com/google/gerrit/server/mail/send/AuthTokenExpiredEmailDecorator.java
@@ -14,9 +14,9 @@ package com.google.gerrit.server.mail.send; -import autovalue.shaded.com.google.common.base.Strings; import com.google.auto.factory.AutoFactory; import com.google.auto.factory.Provided; +import com.google.common.base.Strings; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; import com.google.gerrit.extensions.api.changes.RecipientType;
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java index 54c4061..26c71be 100644 --- a/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java +++ b/java/com/google/gerrit/server/mail/send/ChangeEmailImpl.java
@@ -120,6 +120,18 @@ this.changeEmailDecorator = changeEmailDecorator; } + public ChangeEmailImpl( + @Provided EmailArguments args, Change change, ChangeEmailDecorator changeEmailDecorator) { + this.args = args; + this.changeData = args.changeDataFactory.create(change); + this.change = changeData.change(); + emailOnlyAuthors = false; + emailOnlyAttentionSetIfEnabled = true; + currentAttentionSet = getAttentionSet(); + branch = changeData.change().getDest(); + this.changeEmailDecorator = changeEmailDecorator; + } + @Override public void markAsReply() { isThreadReply = true;
diff --git a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java index 0657ec2..079f660 100644 --- a/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java +++ b/java/com/google/gerrit/server/mail/send/DefaultEmailFactories.java
@@ -151,6 +151,11 @@ } @Override + public ChangeEmail createChangeEmail(Change change, ChangeEmailDecorator changeEmailDecorator) { + return changeEmailFactory.create(change, changeEmailDecorator); + } + + @Override public EmailDecorator createAddKeyEmail(IdentifiedUser user, AccountSshKey sshKey) { return addKeyEmailFactory.create(user, sshKey); }
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java index 7e5855d..c050c13 100644 --- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java +++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -27,6 +27,7 @@ import com.google.gerrit.exceptions.EmailException; import com.google.gerrit.server.config.ConfigUtil; import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.mail.Encryption; import com.google.gerrit.server.util.time.TimeUtil; import com.google.inject.AbstractModule; @@ -91,8 +92,8 @@ private int expiryDays; @Inject - SmtpEmailSender(@GerritServerConfig Config cfg) { - enabled = cfg.getBoolean("sendemail", null, "enable", true); + SmtpEmailSender(@GerritServerConfig Config cfg, @SendEmailEnabled Boolean enabled) { + this.enabled = enabled; connectTimeout = Ints.checkedCast( ConfigUtil.getTimeUnit(
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java index 0a82b4c..e62ef6c 100644 --- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java +++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -16,7 +16,9 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; +import static org.eclipse.jgit.util.ChangeIdUtil.indexOfFirstFooterLine; +import com.google.common.base.Strings; import com.google.common.flogger.FluentLogger; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Account; @@ -28,6 +30,7 @@ import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.InternalUser; +import com.google.gerrit.server.util.AccountTemplateUtil; import java.io.IOException; import java.time.Instant; import org.eclipse.jgit.lib.CommitBuilder; @@ -44,7 +47,9 @@ protected final ChangeNoteUtil noteUtil; protected final Account.Id accountId; + protected final String loggableName; protected final Account.Id realAccountId; + protected final String realLoggableName; protected final PersonIdent authorIdent; protected final Instant when; @@ -55,6 +60,7 @@ @Nullable protected PatchSet.Id psId; private ObjectId result; boolean rootOnly; + private boolean suppressImpersonationMessage; protected AbstractChangeUpdate( ChangeNotes notes, @@ -68,18 +74,31 @@ this.change = notes.getChange(); this.when = when; this.accountId = accountId(user); + this.loggableName = getFormattedLoggableName(user); Account.Id realAccountId = accountId(user.getRealUser()); this.realAccountId = realAccountId != null ? realAccountId : accountId; + this.realLoggableName = + realAccountId != null ? getFormattedLoggableName(user.getRealUser()) : loggableName; this.authorIdent = ident(noteUtil, serverIdent, user, when); } - AbstractChangeUpdate( + private static String getFormattedLoggableName(CurrentUser user) { + if (!user.isIdentifiedUser()) { + return user.getLoggableName(); + } + return AccountTemplateUtil.getAccountTemplate(user.getAccountId()); + } + + /** Copy constructor. */ + protected AbstractChangeUpdate( ChangeNoteUtil noteUtil, PersonIdent serverIdent, @Nullable ChangeNotes notes, @Nullable Change change, Account.Id accountId, + String loggableName, Account.Id realAccountId, + String realLoggableName, PersonIdent authorIdent, Instant when) { checkArgument( @@ -90,7 +109,9 @@ this.notes = notes; this.change = change != null ? change : notes.getChange(); this.accountId = accountId; + this.loggableName = loggableName; this.realAccountId = realAccountId; + this.realLoggableName = realLoggableName; this.authorIdent = authorIdent; this.when = when; } @@ -119,6 +140,10 @@ throw new IllegalStateException(); } + public void setSuppressImpersonationMessage(boolean suppress) { + this.suppressImpersonationMessage = suppress; + } + public Change.Id getId() { return change.getId(); } @@ -232,8 +257,11 @@ } else if (cb == NO_OP_UPDATE) { return null; // Impl is a no-op. } + cb.setAuthor(authorIdent); cb.setCommitter(new PersonIdent(serverIdent, when)); + addOptionalImpersonationMessage(cb); + setParentCommit(cb, curr); if (cb.getTreeId() == null) { if (curr.equals(z)) { @@ -267,6 +295,35 @@ protected abstract CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr) throws IOException; + private void addOptionalImpersonationMessage(CommitBuilder cb) { + if (suppressImpersonationMessage || realAccountId == null || realAccountId.equals(accountId)) { + return; + } + + if (Strings.isNullOrEmpty(cb.getMessage())) { + // No message for this operation. + return; + } + + String impersonationClause = + String.format( + "(Performed by %s on behalf of %s)", + AccountTemplateUtil.getAccountTemplate(realAccountId), + AccountTemplateUtil.getAccountTemplate(accountId)); + + String[] commitMsgLines = cb.getMessage().split("\n"); + int firstFooterLine = indexOfFirstFooterLine(commitMsgLines); + StringBuilder b = new StringBuilder(); + for (int i = 0; i < firstFooterLine; i++) { + b.append(commitMsgLines[i]).append('\n'); + } + b.append(impersonationClause).append("\n\n"); + for (int i = firstFooterLine; i < commitMsgLines.length; i++) { + b.append(commitMsgLines[i]).append('\n'); + } + cb.setMessage(b.toString().trim()); + } + static final CommitBuilder NO_OP_UPDATE = new CommitBuilder(); ObjectId getResult() {
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java index 38221ac..259b219 100644 --- a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java +++ b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
@@ -81,7 +81,9 @@ ChangeDraftNotesUpdate create( ChangeNotes notes, @Assisted("effective") Account.Id accountId, + @Assisted("effective") String loggableName, @Assisted("real") Account.Id realAccountId, + @Assisted("real") String realLoggableName, PersonIdent authorIdent, Instant when); @@ -89,7 +91,9 @@ ChangeDraftNotesUpdate create( Change change, @Assisted("effective") Account.Id accountId, + @Assisted("effective") String loggableName, @Assisted("real") Account.Id realAccountId, + @Assisted("real") String realLoggableName, PersonIdent authorIdent, Instant when); } @@ -240,10 +244,22 @@ ChangeNumberVirtualIdAlgorithm virtualIdFunc, @Assisted ChangeNotes notes, @Assisted("effective") Account.Id accountId, + @Assisted("effective") String loggableName, @Assisted("real") Account.Id realAccountId, + @Assisted("real") String realLoggableName, @Assisted PersonIdent authorIdent, @Assisted Instant when) { - super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when); + super( + noteUtil, + serverIdent, + notes, + null, + accountId, + loggableName, + realAccountId, + realLoggableName, + authorIdent, + when); this.draftsProject = allUsers; this.experimentFeatures = experimentFeatures; this.virtualIdFunc = virtualIdFunc; @@ -258,10 +274,22 @@ ChangeNumberVirtualIdAlgorithm virtualIdFunc, @Assisted Change change, @Assisted("effective") Account.Id accountId, + @Assisted("effective") String loggableName, @Assisted("real") Account.Id realAccountId, + @Assisted("real") String realLoggableName, @Assisted PersonIdent authorIdent, @Assisted Instant when) { - super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when); + super( + noteUtil, + serverIdent, + null, + change, + accountId, + loggableName, + realAccountId, + realLoggableName, + authorIdent, + when); this.draftsProject = allUsers; this.experimentFeatures = experimentFeatures; this.virtualIdFunc = virtualIdFunc; @@ -330,7 +358,9 @@ virtualIdFunc, new Change(getChange()), accountId, + loggableName, realAccountId, + realLoggableName, authorIdent, when); clonedUpdate.delete.putAll(delete);
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java index feb27a6..c46d2d5 100644 --- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java +++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -589,12 +589,13 @@ parseCopiedApproval(psId, commitTimestamp, line); } + Account.Id updater = accountId != null ? accountId : ownerId; for (ReviewerStateInternal state : ReviewerStateInternal.values()) { for (String line : commit.getFooterLineValues(state.getFooterKey())) { - parseReviewer(commitTimestamp, state, line); + parseReviewer(commitTimestamp, updater, realAccountId, state, line); } for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) { - parseReviewerByEmail(commitTimestamp, state, line); + parseReviewerByEmail(commitTimestamp, updater, realAccountId, state, line); } // Don't update timestamp when a reviewer was added, matching RevewDb // behavior. @@ -1345,7 +1346,12 @@ return parseIdent(a); } - private void parseReviewer(Instant ts, ReviewerStateInternal state, String line) + private void parseReviewer( + Instant ts, + Account.Id updater, + Account.Id realUpdater, + ReviewerStateInternal state, + String line) throws ConfigInvalidException { PersonIdent ident = RawParseUtils.parsePersonIdent(line); if (ident == null) { @@ -1353,7 +1359,7 @@ } Account.Id accountId = parseIdent(ident); ReviewerStatusUpdate update = - ReviewerStatusUpdate.createForReviewer(ts, ownerId, accountId, state); + ReviewerStatusUpdate.createForReviewer(ts, updater, realUpdater, accountId, state); reviewerUpdates.add(update); if (update.state() == ReviewerStateInternal.REMOVED) { removedReviewers.add(accountId); @@ -1364,7 +1370,12 @@ } } - private void parseReviewerByEmail(Instant ts, ReviewerStateInternal state, String line) + private void parseReviewerByEmail( + Instant ts, + Account.Id updater, + Account.Id realUpdater, + ReviewerStateInternal state, + String line) throws ConfigInvalidException { Address adr; try { @@ -1374,7 +1385,8 @@ cie.initCause(e); throw cie; } - reviewerUpdates.add(ReviewerStatusUpdate.createForReviewerByEmail(ts, ownerId, adr, state)); + reviewerUpdates.add( + ReviewerStatusUpdate.createForReviewerByEmail(ts, updater, realUpdater, adr, state)); if (!reviewersByEmail.containsRow(adr)) { reviewersByEmail.put(adr, state, ts); }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java index eb6c15a..9f38014 100644 --- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java +++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -614,6 +614,8 @@ .setTimestampMillis(u.date().toEpochMilli()) .setUpdatedBy(u.updatedBy().get()) .setState(REVIEWER_STATE_CONVERTER.reverse().convert(u.state())); + u.realUpdatedBy() + .ifPresent(realAccountId -> protoBuilder.setRealUpdatedBy(realAccountId.get())); u.reviewer() .ifPresent( accountId -> { @@ -763,6 +765,7 @@ ReviewerStatusUpdate.createForReviewerByEmail( Instant.ofEpochMilli(proto.getTimestampMillis()), Account.id(proto.getUpdatedBy()), + proto.getRealUpdatedBy() != 0 ? Account.id(proto.getRealUpdatedBy()) : null, Address.parse(proto.getReviewerByEmail()), REVIEWER_STATE_CONVERTER.convert(proto.getState()))); } else { @@ -775,6 +778,7 @@ ReviewerStatusUpdate.createForReviewer( Instant.ofEpochMilli(proto.getTimestampMillis()), Account.id(proto.getUpdatedBy()), + proto.getRealUpdatedBy() != 0 ? Account.id(proto.getRealUpdatedBy()) : null, Account.id(proto.getReviewer()), REVIEWER_STATE_CONVERTER.convert(proto.getState()))); }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java index c4878de..a17a68d 100644 --- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java +++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -436,11 +436,20 @@ if (draftUpdate == null) { ChangeNotes notes = getNotes(); if (notes != null) { - draftUpdate = draftUpdateFactory.create(notes, accountId, realAccountId, authorIdent, when); + draftUpdate = + draftUpdateFactory.create( + notes, accountId, loggableName, realAccountId, realLoggableName, authorIdent, when); } else { // tests will always take the notes != null path above. draftUpdate = - draftUpdateFactory.create(getChange(), accountId, realAccountId, authorIdent, when); + draftUpdateFactory.create( + getChange(), + accountId, + loggableName, + realAccountId, + realLoggableName, + authorIdent, + when); } } return draftUpdate; @@ -689,11 +698,19 @@ } for (RevisionNoteBuilder b : toUpdate.values()) { - for (Comment c : b.put.values()) { - if (existing.contains(c.key)) { - throw new StorageException("Cannot update existing published comment: " + c); - } - } + b.put + .values() + .removeIf( + c -> { + if (existing.contains(c.key)) { + logger.atWarning().log( + "Republishing of an existing published comment was requested: %s. Skipping" + + " this update.", + c); + return true; + } + return false; + }); } }
diff --git a/java/com/google/gerrit/server/notedb/RepoSequence.java b/java/com/google/gerrit/server/notedb/RepoSequence.java index 5087b91..fc7e450 100644 --- a/java/com/google/gerrit/server/notedb/RepoSequence.java +++ b/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -352,10 +352,11 @@ int next; if (blob.isEmpty()) { throw new IllegalStateException("Expected " + refName + " to exist"); - } else { - oldId = blob.get().id(); - next = blob.get().value(); } + + oldId = blob.get().id(); + next = blob.get().value(); + next = Math.max(floor, next); checkIsIncremental(next + count); @@ -408,9 +409,8 @@ int current; if (blob.isEmpty()) { throw new IllegalStateException("Expected " + refName + " to exist"); - } else { - current = blob.get().value(); } + current = blob.get().value(); return current; } catch (IOException e) { throw new StorageException(e);
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java index 33255e4..796b247 100644 --- a/java/com/google/gerrit/server/patch/DiffMappings.java +++ b/java/com/google/gerrit/server/patch/DiffMappings.java
@@ -57,7 +57,6 @@ // Name of deleted file is mentioned as newName. FileMapping.forDeletedFile(newName); case RENAMED, COPIED -> FileMapping.forRenamedFile(oldName, newName); - default -> throw new IllegalStateException("Unmapped diff type: " + changeType); }; }
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java index 0629940..9ee4bbe 100644 --- a/java/com/google/gerrit/server/patch/DiffOperations.java +++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -14,6 +14,7 @@ package com.google.gerrit.server.patch; +import com.google.common.collect.ImmutableList; import com.google.gerrit.common.Nullable; import com.google.gerrit.entities.Patch; import com.google.gerrit.entities.Patch.ChangeType; @@ -49,6 +50,24 @@ public interface DiffOperations { /** + * Returns the modified files of the given patch set. + * + * <p>The modified files are the paths that have been touched in the commit. + * + * <p>To compute the modified files the commit is compared against the parent that was specified + * by {@code parentNum}. If {@code parentNum} is {@code 0} the comparison is done against the + * default base (the first parent for normal changes, the auto-merge commit for merge changes and + * the empty commit for initial changes). + * + * <p>Getting the modified files with this method is much cheaper than using {@code + * #listModifiedFilesAgainstParent(...).keySet()} as this method does not only compute the + * modified files, but also the file diff for each of the modified files. + */ + ImmutableList<ModifiedFile> getModifiedFiles( + Project.NameKey project, ObjectId newCommit, int parentNum, boolean enableRenameDetection) + throws DiffNotAvailableException; + + /** * Returns the list of added, deleted or modified files between a commit against its base. The * {@link Patch#COMMIT_MSG} and {@link Patch#MERGE_LIST} (for merge commits) are also returned. *
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java index 392bf9d..85cd030 100644 --- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java +++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -14,6 +14,7 @@ package com.google.gerrit.server.patch; +import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap; import static com.google.gerrit.entities.Patch.COMMIT_MSG; import static com.google.gerrit.entities.Patch.MERGE_LIST; @@ -114,6 +115,37 @@ } @Override + public ImmutableList<ModifiedFile> getModifiedFiles( + Project.NameKey project, ObjectId newCommit, int parentNum, boolean enableRenameDetection) + throws DiffNotAvailableException { + try (Repository repo = repoManager.openRepository(project); + ObjectInserter ins = repo.newObjectInserter(); + ObjectReader reader = ins.newReader(); + RevWalk rw = new RevWalk(reader); + RepoView repoView = new RepoView(repo, rw, ins)) { + logger.atFine().log( + "Opened repo %s to get modified files for %s (inserter: %s)", + project, newCommit.name(), ins); + + // Use parentNum=0 to do the comparison against the default base. + // For non-merge commits the default base is the only parent (aka parent 1). + // Initial commits are supported when using parentNum=0. + // For merge commits the default base is the auto-merge commit. + return loadModifiedFilesAgainstParentIfNecessary( + project, newCommit, parentNum, repoView, ins, enableRenameDetection) + .values() + .stream() + .collect(toImmutableList()); + } catch (IOException e) { + throw new DiffNotAvailableException( + String.format( + "Unable to load modified files of commit %s in project %s", + newCommit.name(), project), + e); + } + } + + @Override public Map<String, FileDiffOutput> listModifiedFilesAgainstParent( Project.NameKey project, ObjectId newCommit, int parent, DiffOptions diffOptions) throws DiffNotAvailableException {
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java index 42bd883..0753604 100644 --- a/java/com/google/gerrit/server/patch/FilePathAdapter.java +++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -37,7 +37,6 @@ case DELETED, ADDED, MODIFIED -> null; case COPIED, RENAMED -> oldName.get(); case REWRITE -> oldName.isPresent() ? oldName.get() : null; - default -> throw new IllegalArgumentException("Unsupported type " + changeType); }; } @@ -49,7 +48,6 @@ return switch (changeType) { case DELETED -> oldName.get(); case ADDED, MODIFIED, REWRITE, COPIED, RENAMED -> newName.get(); - default -> throw new IllegalArgumentException("Unsupported type " + changeType); }; } }
diff --git a/java/com/google/gerrit/server/patch/PatchListEntry.java b/java/com/google/gerrit/server/patch/PatchListEntry.java index 837e7c4..7875485 100644 --- a/java/com/google/gerrit/server/patch/PatchListEntry.java +++ b/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -331,7 +331,6 @@ case DELETE -> Patch.ChangeType.DELETED; case RENAME -> Patch.ChangeType.RENAMED; case COPY -> Patch.ChangeType.COPIED; - default -> throw new IllegalArgumentException("Unsupported type " + hdr.getChangeType()); }; } @@ -341,7 +340,6 @@ switch (hdr.getPatchType()) { case UNIFIED -> Patch.PatchType.UNIFIED; case GIT_BINARY, BINARY -> Patch.PatchType.BINARY; - default -> throw new IllegalArgumentException("Unsupported type " + hdr.getPatchType()); }; if (pt != PatchType.BINARY) {
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java index 8dff536..1e7e0e8 100644 --- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java +++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -417,7 +417,7 @@ @Nullable private TreeWalk find(ObjectReader reader, String path, ObjectId within) throws IOException { - if (path == null || within == null) { + if (path == null || "/".equals(path) || within == null) { return null; } try (RevWalk rw = new RevWalk(reader)) {
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java index 745ce48..41bbc68 100644 --- a/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java +++ b/java/com/google/gerrit/server/patch/gitfilediff/FileHeaderUtil.java
@@ -120,7 +120,6 @@ case DELETE -> Patch.ChangeType.DELETED; case RENAME -> Patch.ChangeType.RENAMED; case COPY -> Patch.ChangeType.COPIED; - default -> throw new IllegalArgumentException("Unsupported type " + header.getChangeType()); }; } @@ -130,8 +129,6 @@ switch (header.getPatchType()) { case UNIFIED -> Patch.PatchType.UNIFIED; case GIT_BINARY, BINARY -> Patch.PatchType.BINARY; - default -> - throw new IllegalArgumentException("Unsupported type " + header.getPatchType()); }; if (patchType != PatchType.BINARY) {
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java index 30153c0..d9c4a7a 100644 --- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java +++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -309,20 +309,12 @@ } private static RawTextComparator comparatorFor(Whitespace ws) { - switch (ws) { - case IGNORE_ALL: - return RawTextComparator.WS_IGNORE_ALL; - - case IGNORE_TRAILING: - return RawTextComparator.WS_IGNORE_TRAILING; - - case IGNORE_LEADING_AND_TRAILING: - return RawTextComparator.WS_IGNORE_CHANGE; - - case IGNORE_NONE: - default: - return RawTextComparator.DEFAULT; - } + return switch (ws) { + case IGNORE_ALL -> RawTextComparator.WS_IGNORE_ALL; + case IGNORE_TRAILING -> RawTextComparator.WS_IGNORE_TRAILING; + case IGNORE_LEADING_AND_TRAILING -> RawTextComparator.WS_IGNORE_CHANGE; + case IGNORE_NONE -> RawTextComparator.DEFAULT; + }; } /**
diff --git a/java/com/google/gerrit/server/permissions/AbstractChangeControl.java b/java/com/google/gerrit/server/permissions/AbstractChangeControl.java index bff1454..d3703e8 100644 --- a/java/com/google/gerrit/server/permissions/AbstractChangeControl.java +++ b/java/com/google/gerrit/server/permissions/AbstractChangeControl.java
@@ -101,12 +101,21 @@ case SUBMIT_AS -> permissionBackend.user(getUser()).test(GlobalPermission.RUN_AS) || refControl.canPerform(changePermissionName(perm)); + case AI_REVIEW -> canAiReview(); }; } catch (StorageException e) { throw new PermissionBackendException("unavailable", e); } } + /** + * TODO(AI review experiment): When {@code UiFeature__enable_ai_chat} is removed, replace with + * {@code refControl.canPerform(Permission.AI_REVIEW)} to use standard default-deny model. + */ + private boolean canAiReview() { + return refControl.canPerformDefaultAllow(Permission.AI_REVIEW); + } + /** Can this user see this change? */ protected boolean isVisible() { // Does the user have READ permission on the destination?
diff --git a/java/com/google/gerrit/server/permissions/ChangePermission.java b/java/com/google/gerrit/server/permissions/ChangePermission.java index b9868dd..8b2567a 100644 --- a/java/com/google/gerrit/server/permissions/ChangePermission.java +++ b/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -75,7 +75,8 @@ REVERT, SUBMIT, SUBMIT_AS("submit on behalf of other users"), - TOGGLE_WORK_IN_PROGRESS_STATE; + TOGGLE_WORK_IN_PROGRESS_STATE, + AI_REVIEW; private final String description; private final String hint;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java index 02fa7c4..0113355 100644 --- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java +++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -83,14 +83,10 @@ @Override public WithUser user(CurrentUser user, ImpersonationPermissionMode permissionMode) { - switch (permissionMode) { - case REAL_USER -> { - return new WithUserImpl(requireNonNull(user.getRealUser(), "user")); - } - default -> { // THIS_USER - return new WithUserImpl(requireNonNull(user, "user")); - } - } + return switch (permissionMode) { + case REAL_USER -> new WithUserImpl(requireNonNull(user.getRealUser(), "user")); + case THIS_USER -> new WithUserImpl(requireNonNull(user, "user")); + }; } @Override
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java index ae6cb9d..4db3add 100644 --- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java +++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -65,13 +65,23 @@ .put(GlobalPermission.VIEW_SECONDARY_EMAILS, GlobalCapability.VIEW_SECONDARY_EMAILS) .build(); - static { - checkMapContainsAllEnumValues(CAPABILITIES, GlobalPermission.class); - } - private static final ImmutableBiMap<ProjectPermission, String> PROJECT_PERMISSIONS = ImmutableBiMap.<ProjectPermission, String>builder() + .put(ProjectPermission.ACCESS, "access") .put(ProjectPermission.READ, Permission.READ) + .put(ProjectPermission.CREATE_REF, "createRef") + .put(ProjectPermission.CREATE_TAG_REF, "createTagRef") + .put(ProjectPermission.CREATE_CHANGE, "createChange") + .put(ProjectPermission.RUN_RECEIVE_PACK, "runReceivePack") + .put(ProjectPermission.RUN_UPLOAD_PACK, "runUploadPack") + .put(ProjectPermission.READ_CONFIG, "readConfig") + .put(ProjectPermission.WRITE_CONFIG, "writeConfig") + .put(ProjectPermission.BAN_COMMIT, "banCommit") + .put(ProjectPermission.READ_REFLOG, "readReflog") + .put(ProjectPermission.PUSH_AT_LEAST_ONE_REF, "pushAtLeastOneRef") + .put( + ProjectPermission.UPDATE_CONFIG_WITHOUT_CREATING_CHANGE, + "updateConfigWithoutCreatingChange") .build(); private static final ImmutableBiMap<RefPermission, String> REF_PERMISSIONS = @@ -80,32 +90,52 @@ .put(RefPermission.CREATE, Permission.CREATE) .put(RefPermission.DELETE, Permission.DELETE) .put(RefPermission.UPDATE, Permission.PUSH) + .put(RefPermission.FORCE_UPDATE, "forceUpdate") + .put(RefPermission.SET_HEAD, "setHead") .put(RefPermission.FORGE_AUTHOR, Permission.FORGE_AUTHOR) .put(RefPermission.FORGE_COMMITTER, Permission.FORGE_COMMITTER) .put(RefPermission.FORGE_SERVER, Permission.FORGE_SERVER) + .put(RefPermission.MERGE, Permission.PUSH_MERGE) + .put(RefPermission.SKIP_VALIDATION, "skipValidation") + .put(RefPermission.CREATE_CHANGE, "createChange") .put(RefPermission.CREATE_TAG, Permission.CREATE_TAG) .put(RefPermission.CREATE_SIGNED_TAG, Permission.CREATE_SIGNED_TAG) + .put(RefPermission.UPDATE_BY_SUBMIT, "updateBySubmit") .put(RefPermission.READ_PRIVATE_CHANGES, Permission.VIEW_PRIVATE_CHANGES) + .put(RefPermission.READ_CONFIG, "readConfig") + .put(RefPermission.WRITE_CONFIG, "writeConfig") .build(); private static final ImmutableBiMap<ChangePermission, String> CHANGE_PERMISSIONS = ImmutableBiMap.<ChangePermission, String>builder() .put(ChangePermission.READ, Permission.READ) + .put(ChangePermission.RESTORE, "restore") + .put(ChangePermission.DELETE, Permission.DELETE) .put(ChangePermission.ABANDON, Permission.ABANDON) + .put(ChangePermission.EDIT_DESCRIPTION, "editDescription") .put(ChangePermission.EDIT_CUSTOM_KEYED_VALUES, Permission.EDIT_CUSTOM_KEYED_VALUES) .put(ChangePermission.EDIT_HASHTAGS, Permission.EDIT_HASHTAGS) .put(ChangePermission.EDIT_TOPIC_NAME, Permission.EDIT_TOPIC_NAME) .put(ChangePermission.REMOVE_REVIEWER, Permission.REMOVE_REVIEWER) .put(ChangePermission.ADD_PATCH_SET, Permission.ADD_PATCH_SET) .put(ChangePermission.REBASE, Permission.REBASE) + .put(ChangePermission.REBASE_ON_BEHALF_OF_UPLOADER, "rebaseOnBehalfOfUploader") .put(ChangePermission.REVERT, Permission.REVERT) .put(ChangePermission.SUBMIT, Permission.SUBMIT) .put(ChangePermission.SUBMIT_AS, Permission.SUBMIT_AS) .put( ChangePermission.TOGGLE_WORK_IN_PROGRESS_STATE, Permission.TOGGLE_WORK_IN_PROGRESS_STATE) + .put(ChangePermission.AI_REVIEW, Permission.AI_REVIEW) .build(); + static { + checkMapContainsAllEnumValues(CAPABILITIES, GlobalPermission.class); + checkMapContainsAllEnumValues(PROJECT_PERMISSIONS, ProjectPermission.class); + checkMapContainsAllEnumValues(REF_PERMISSIONS, RefPermission.class); + checkMapContainsAllEnumValues(CHANGE_PERMISSIONS, ChangePermission.class); + } + private static <T extends Enum<T>> void checkMapContainsAllEnumValues( ImmutableMap<T, String> actual, Class<T> clazz) { Set<T> expected = EnumSet.allOf(clazz);
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java index 4b8db1c..54bb529 100644 --- a/java/com/google/gerrit/server/permissions/PermissionCollection.java +++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -171,6 +171,28 @@ } } + /** + * Returns DENY rules for the given permission. + * + * <p>TODO(AI review experiment): Remove when {@code UiFeature__enable_ai_chat} is removed. Only + * used by {@link RefControl#canPerformDefaultAllow}. + */ + List<PermissionRule> getDenyRules(String perm) { + List<PermissionRule> result = new ArrayList<>(); + for (AccessSection s : accessSectionsUpward) { + Permission p = s.getPermission(perm); + if (p == null) { + continue; + } + for (PermissionRule pr : p.getRules()) { + if (pr.getAction() == PermissionRule.Action.DENY) { + result.add(pr); + } + } + } + return result; + } + /** Returns permissions in the right order for evaluating BLOCK status. */ List<List<Permission>> getBlockRules(String perm) { List<List<Permission>> ps = blockPerProjectByPermission.get(perm);
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java index 328d01e..9cab8b2 100644 --- a/java/com/google/gerrit/server/permissions/ProjectControl.java +++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -39,10 +39,12 @@ import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.account.GroupMembership; import com.google.gerrit.server.config.AllUsersName; +import com.google.gerrit.server.config.CapabilityConstants; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.config.GitReceivePackGroups; import com.google.gerrit.server.config.GitUploadPackGroups; import com.google.gerrit.server.group.SystemGroupBackend; +import com.google.gerrit.server.logging.LoggingContext; import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.TraceContext; import com.google.gerrit.server.logging.TraceContext.TraceTimer; @@ -303,6 +305,15 @@ Metadata.builder().projectName(getProject().getName()).build())) { // Admins should be able to read all refs. if (isAdmin()) { + LoggingContext.getInstance() + .addAclLogRecord( + String.format( + "'%s' can perform '%s' on all refs of project '%s'" + + " because they have the '%s' global capability", + getUser().getLoggableName(), + Permission.READ, + getProject().getName(), + CapabilityConstants.administrateServer)); return true; } @@ -464,7 +475,26 @@ private boolean can(ProjectPermission perm) { return switch (perm) { - case ACCESS -> user.isInternalUser() || isOwner() || canPerformOnAnyRef(Permission.READ); + case ACCESS -> { + if (user.isInternalUser()) { + yield true; + } + if (isAdmin()) { + LoggingContext.getInstance() + .addAclLogRecord( + String.format( + "'%s' can access project '%s'" + + " because they have the '%s' global capability", + getUser().getLoggableName(), + getProject().getName(), + CapabilityConstants.administrateServer)); + yield true; + } + if (isDeclaredOwner() && controlForRef(ALL).canPerform(Permission.OWNER)) { + yield true; + } + yield canPerformOnAnyRef(Permission.READ); + } case READ -> allRefsAreVisible(Collections.emptySet()); case CREATE_REF -> canAddRefs(); case CREATE_TAG_REF -> canAddTagRefs();
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java index 8165cab..b682620 100644 --- a/java/com/google/gerrit/server/permissions/RefControl.java +++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -30,6 +30,7 @@ import com.google.gerrit.extensions.conditions.BooleanCondition; import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.config.CapabilityConstants; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.logging.LoggingContext; import com.google.gerrit.server.notedb.ChangeNotes; @@ -197,6 +198,43 @@ return canPerform(permissionName, false, false); } + /** + * Default-allow variant of {@link #canPerform}: grants access unless explicitly restricted. + * + * <p>Unlike standard Gerrit permissions which are default-deny (require an explicit ALLOW), this + * method defaults to granting access. It only denies when the user matches an explicit DENY or + * BLOCK rule. When ALLOW rules are configured, it falls back to standard {@link #canPerform} + * evaluation. + * + * <p>This exists because the AI review feature uses an opt-out model during the experiment phase: + * all users have access unless an admin explicitly restricts specific groups via DENY or BLOCK. + * + * <p>TODO(AI review experiment): Remove when {@code UiFeature__enable_ai_chat} is removed. + * Replace call sites with {@link #canPerform}. + */ + boolean canPerformDefaultAllow(String permissionName) { + if (!relevant.getAllowRules(permissionName).isEmpty()) { + return canPerform(permissionName); + } + for (PermissionRule pr : relevant.getDenyRules(permissionName)) { + if (projectControl.match(pr, false)) { + if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) { + String logMessage = + String.format( + "'%s' cannot perform '%s' on project '%s' for ref '%s'" + + " because this permission is denied", + getUser().getLoggableName(), + permissionName, + projectControl.getProject().getName(), + refName); + LoggingContext.getInstance().addAclLogRecord(logMessage); + } + return false; + } + } + return !isBlocked(permissionName, false, false); + } + ForRef asForRef() { return new ForRefImpl(); } @@ -634,6 +672,16 @@ && (refName.equals(RefNames.REFS_CONFIG) || refName.startsWith(Constants.R_HEADS) || refName.startsWith(Constants.R_TAGS))) { + LoggingContext.getInstance() + .addAclLogRecord( + String.format( + "'%s' can perform '%s' on project '%s' for ref '%s'" + + " because they have the '%s' global capability", + getUser().getLoggableName(), + Permission.READ, + projectControl.getProject().getName(), + refName, + CapabilityConstants.administrateServer)); return true; }
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java index 552d8ee..a56c977 100644 --- a/java/com/google/gerrit/server/permissions/SectionSortCache.java +++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -17,7 +17,6 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import com.google.auto.value.AutoValue; -import com.google.auto.value.extension.memoized.Memoized; import com.google.common.cache.Cache; import com.google.common.collect.ImmutableList; import com.google.common.flogger.FluentLogger; @@ -121,11 +120,7 @@ } } - @AutoValue - abstract static class EntryKey { - public abstract String ref(); - - public abstract ImmutableList<String> patterns(); + public record EntryKey(String ref, ImmutableList<String> patterns) { static EntryKey create(String refName, List<AccessSection> sections) { ImmutableList.Builder<String> patterns = @@ -133,17 +128,7 @@ for (AccessSection s : sections) { patterns.add(s.getName()); } - return new AutoValue_SectionSortCache_EntryKey(refName, patterns.build()); - } - - @Memoized - @Override - public int hashCode() { - int hc = ref().hashCode(); - for (String n : patterns()) { - hc = hc * 31 + n.hashCode(); - } - return hc; + return new EntryKey(refName, patterns.build()); } }
diff --git a/java/com/google/gerrit/server/plugincontext/PluginContext.java b/java/com/google/gerrit/server/plugincontext/PluginContext.java index dbb772a..677a211 100644 --- a/java/com/google/gerrit/server/plugincontext/PluginContext.java +++ b/java/com/google/gerrit/server/plugincontext/PluginContext.java
@@ -112,7 +112,7 @@ @Singleton public static class PluginMetrics { - // Keep in sync with PerformanceLogContext.PLUGIN_LATENCY_NAME. + // Keep in sync with com.google.gerrit.server.logging.Metadata.PLUGIN_LATENCY_NAME. private static final String PLUGIN_LATENCY_NAME = "plugin/latency"; public static final PluginMetrics DISABLED_INSTANCE =
diff --git a/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/java/com/google/gerrit/server/plugins/JarPluginProvider.java index c180a19..0981c7b 100644 --- a/java/com/google/gerrit/server/plugins/JarPluginProvider.java +++ b/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -175,8 +175,7 @@ private ClassLoader parentFor(ApiType type) { return switch (type) { case PLUGIN -> pluginApiClassLoader; - // $CASES-OMITTED$ - default -> PluginUtil.parentFor(type); + case JS, EXTENSION -> PluginUtil.parentFor(type); }; }
diff --git a/java/com/google/gerrit/server/plugins/PluginUtil.java b/java/com/google/gerrit/server/plugins/PluginUtil.java index cead627..db837e5 100644 --- a/java/com/google/gerrit/server/plugins/PluginUtil.java +++ b/java/com/google/gerrit/server/plugins/PluginUtil.java
@@ -86,7 +86,6 @@ case EXTENSION -> PluginName.class.getClassLoader(); case PLUGIN -> PluginLoader.class.getClassLoader(); case JS -> JavaScriptPlugin.class.getClassLoader(); - default -> throw new IllegalArgumentException("Unsupported ApiType " + type); }; } }
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java index fb7cbe2..18ab2ab 100644 --- a/java/com/google/gerrit/server/plugins/ServerPlugin.java +++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -51,7 +51,7 @@ protected Class<? extends Module> batchModule; protected Class<? extends Module> sshModule; protected Class<? extends Module> httpModule; - private Class<? extends Module> apiModuleClass; + protected Class<? extends Module> apiModuleClass; private Injector apiInjector; private Injector sysInjector;
diff --git a/java/com/google/gerrit/server/plugins/TestServerPlugin.java b/java/com/google/gerrit/server/plugins/TestServerPlugin.java index a7ca88f..d67d5d9 100644 --- a/java/com/google/gerrit/server/plugins/TestServerPlugin.java +++ b/java/com/google/gerrit/server/plugins/TestServerPlugin.java
@@ -20,6 +20,7 @@ public class TestServerPlugin extends ServerPlugin { private final ClassLoader classLoader; + private String apiName; private String sysName; private String httpName; private String sshName; @@ -29,12 +30,23 @@ String pluginCanonicalWebUrl, PluginUser user, ClassLoader classLoader, + String apiName, String sysName, String httpName, String sshName, Path dataDir) throws InvalidPluginException { - this(name, pluginCanonicalWebUrl, user, null, classLoader, sysName, httpName, sshName, dataDir); + this( + name, + pluginCanonicalWebUrl, + user, + null, + classLoader, + apiName, + sysName, + httpName, + sshName, + dataDir); } public TestServerPlugin( @@ -43,6 +55,7 @@ PluginUser user, PluginContentScanner scanner, ClassLoader classLoader, + String apiName, String sysName, String httpName, String sshName, @@ -60,6 +73,7 @@ null, GerritRuntime.DAEMON); this.classLoader = classLoader; + this.apiName = apiName; this.sysName = sysName; this.httpName = httpName; this.sshName = sshName; @@ -68,6 +82,7 @@ private void loadGuiceModules() throws InvalidPluginException { try { + this.apiModuleClass = load(apiName, classLoader); this.sysModule = load(sysName, classLoader); this.httpModule = load(httpName, classLoader); this.sshModule = load(sshName, classLoader);
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java index 4f4b0d6..f2ac76c 100644 --- a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java +++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -230,45 +230,33 @@ private static ImmutableList<String> toExpressionAtomList(LabelType lt) { String ignoreSelfApproval = lt.isIgnoreSelfApproval() ? ",user=" + ChangeQueryBuilder.ARG_ID_NON_UPLOADER : ""; - switch (lt.getFunction()) { - case MAX_WITH_BLOCK: - return ImmutableList.of( - String.format("label:%s=MAX", lt.getName()) + ignoreSelfApproval, - String.format("-label:%s=MIN", lt.getName())); - case ANY_WITH_BLOCK: - return ImmutableList.of(String.format(String.format("-label:%s=MIN", lt.getName()))); - case MAX_NO_BLOCK: - return ImmutableList.of( - String.format(String.format("label:%s=MAX", lt.getName())) + ignoreSelfApproval); - case NO_BLOCK: - case NO_OP: - case PATCH_SET_LOCK: - default: - return ImmutableList.of(); - } + return switch (lt.getFunction()) { + case MAX_WITH_BLOCK -> + ImmutableList.of( + String.format("label:%s=MAX", lt.getName()) + ignoreSelfApproval, + String.format("-label:%s=MIN", lt.getName())); + case ANY_WITH_BLOCK -> + ImmutableList.of(String.format(String.format("-label:%s=MIN", lt.getName()))); + case MAX_NO_BLOCK -> + ImmutableList.of( + String.format(String.format("label:%s=MAX", lt.getName())) + ignoreSelfApproval); + case NO_BLOCK, NO_OP, PATCH_SET_LOCK -> ImmutableList.of(); + }; } private static Status mapStatus(Label label) { - SubmitRequirementExpressionResult.Status status = - switch (label.status) { - case OK, MAY -> Status.PASS; - case REJECT, NEED, IMPOSSIBLE -> Status.FAIL; - }; - return status; + return switch (label.status) { + case OK, MAY -> Status.PASS; + case REJECT, NEED, IMPOSSIBLE -> Status.FAIL; + }; } private static Status mapStatus(SubmitRecord submitRecord) { - switch (submitRecord.status) { - case OK: - case CLOSED: - case FORCED: - return Status.PASS; - case NOT_READY: - return Status.FAIL; - case RULE_ERROR: - default: - return Status.ERROR; - } + return switch (submitRecord.status) { + case OK, CLOSED, FORCED -> Status.PASS; + case NOT_READY -> Status.FAIL; + case RULE_ERROR -> Status.ERROR; + }; } private static SubmitRequirementExpressionResult createExpressionResult(
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java index 16cdc26..2b7a3c1 100644 --- a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java +++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -25,8 +25,10 @@ import com.google.gerrit.entities.SubmitRequirementExpression; import com.google.gerrit.entities.SubmitRequirementExpressionResult; import com.google.gerrit.entities.SubmitRequirementResult; +import com.google.gerrit.extensions.config.FactoryModule; import com.google.gerrit.index.query.Predicate; import com.google.gerrit.index.query.QueryParseException; +import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.TraceContext; import com.google.gerrit.server.logging.TraceContext.TraceTimer; @@ -35,51 +37,58 @@ import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder; import com.google.gerrit.server.util.ManualRequestContext; import com.google.gerrit.server.util.OneOffRequestContext; -import com.google.inject.AbstractModule; 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; import java.util.stream.Stream; +import org.eclipse.jgit.lib.Config; /** 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 SubmitRequirementChangeQueryBuilder.Factory queryBuilderFactory; private final ProjectCache projectCache; private final PluginSetContext<SubmitRequirement> globalSubmitRequirements; - + private final Config config; + private final boolean requireOperatorForUpdate; + private final boolean requireOperatorForEvaluation; // Use a request context to execute predicates as an internal user with expanded visibility. // This is so that the evaluation does not depend on who is running the current request (e.g. // a "ownerin" predicate with group that is not visible to the person making this request). private final OneOffRequestContext requestContext; public static Module module() { - return new AbstractModule() { + return new FactoryModule() { @Override protected void configure() { bind(SubmitRequirementsEvaluator.class) .to(SubmitRequirementsEvaluatorImpl.class) .in(Scopes.SINGLETON); + + factory(SubmitRequirementChangeQueryBuilder.Factory.class); } }; } @Inject private SubmitRequirementsEvaluatorImpl( - Provider<SubmitRequirementChangeQueryBuilder> queryBuilder, + SubmitRequirementChangeQueryBuilder.Factory queryBuilderFactory, ProjectCache projectCache, PluginSetContext<SubmitRequirement> globalSubmitRequirements, + @GerritServerConfig Config config, OneOffRequestContext requestContext) { - this.queryBuilder = queryBuilder; + this.queryBuilderFactory = queryBuilderFactory; this.projectCache = projectCache; this.globalSubmitRequirements = globalSubmitRequirements; + this.config = config; this.requestContext = requestContext; + this.requireOperatorForUpdate = requireOperatorForUpdate(); + this.requireOperatorForEvaluation = requireOperatorForEvaluation(); } @Override @@ -87,10 +96,23 @@ throws QueryParseException { try (ManualRequestContext ignored = requestContext.open()) { @SuppressWarnings("unused") - var unused = queryBuilder.get().parse(expression.expressionString()); + var unused = + queryBuilderFactory.create(requireOperatorForUpdate).parse(expression.expressionString()); } } + private boolean requireOperatorForUpdate() { + return requiredOperator("requireOperatorForUpdate"); + } + + private boolean requireOperatorForEvaluation() { + return requiredOperator("requireOperatorForEvaluation"); + } + + private boolean requiredOperator(String configName) { + return config.getBoolean("submitRequirement", null, configName, false); + } + @Override public ImmutableMap<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements( ChangeData cd) { @@ -111,7 +133,10 @@ public SubmitRequirementExpressionResult evaluateExpression( SubmitRequirementExpression expression, ChangeData changeData) { try { - Predicate<ChangeData> predicate = queryBuilder.get().parse(expression.expressionString()); + Predicate<ChangeData> predicate = + queryBuilderFactory + .create(requireOperatorForEvaluation) + .parse(expression.expressionString()); PredicateResult predicateResult = changeData.evaluatePredicateTree(predicate); return SubmitRequirementExpressionResult.create(expression, predicateResult); } catch (QueryParseException | SubmitRequirementEvaluationException e) { @@ -193,7 +218,7 @@ private ImmutableMap<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) { try (TraceTimer timer = TraceContext.newTimer( - "Get submit requirements", + "Evaluate submit requirements", Metadata.builder().changeId(cd.change().getId().get()).build())) { ImmutableMap<String, SubmitRequirement> globalRequirements; Map<String, SubmitRequirement> projectConfigRequirements;
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java index a41ef27..c969baf 100644 --- a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java +++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -52,7 +52,6 @@ case MAX -> ctx.approvalValue() == ctx.labelType().getMaxPositive(); case POSITIVE -> ctx.approvalValue() > 0; case NEGATIVE -> ctx.approvalValue() < 0; - default -> throw new IllegalArgumentException("unrecognized label value: " + value); }; }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java index 07c3299..9dc5d4c 100644 --- a/java/com/google/gerrit/server/query/change/ChangeData.java +++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -15,6 +15,7 @@ package com.google.gerrit.server.query.change; import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.google.common.flogger.LazyArgs.lazy; import static com.google.gerrit.server.project.ProjectCache.illegalState; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.toList; @@ -84,12 +85,18 @@ import com.google.gerrit.server.config.TrackingFooters; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.git.MergeUtilFactory; +import com.google.gerrit.server.logging.Metadata; +import com.google.gerrit.server.logging.TraceContext; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; import com.google.gerrit.server.notedb.ChangeNotes; +import com.google.gerrit.server.patch.DiffNotAvailableException; +import com.google.gerrit.server.patch.DiffOperations; import com.google.gerrit.server.patch.DiffSummary; import com.google.gerrit.server.patch.DiffSummaryKey; import com.google.gerrit.server.patch.PatchListCache; import com.google.gerrit.server.patch.PatchListKey; import com.google.gerrit.server.patch.PatchListNotAvailableException; +import com.google.gerrit.server.patch.gitdiff.ModifiedFile; import com.google.gerrit.server.project.NoSuchChangeException; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.project.ProjectConfig; @@ -117,6 +124,7 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; @@ -380,6 +388,7 @@ null, null, null, + null, virtualIdAlgo, false, null, @@ -412,6 +421,7 @@ private final MergeUtilFactory mergeUtilFactory; private final MergeabilityCache mergeabilityCache; private final PatchListCache patchListCache; + private final DiffOperations diffOperations; private final PatchSetUtil psUtil; private final ProjectCache projectCache; private final TrackingFooters trackingFooters; @@ -438,8 +448,7 @@ private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY; private Change change; private ChangeNotes notes; - private String commitMessage; - private List<FooterLine> commitFooters; + private CommitData commitData; private PatchSet currentPatchSet; private Collection<PatchSet> patchSets; private ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals; @@ -482,12 +491,10 @@ private ReviewerSet pendingReviewers; private ReviewerByEmailSet pendingReviewersByEmail; private List<ReviewerStatusUpdate> reviewerUpdates; - private PersonIdent author; - private PersonIdent committer; private ImmutableSet<AttentionSetUpdate> attentionSet; - private Integer parentCount; private Integer unresolvedCommentCount; private Integer totalCommentCount; + private Integer reviewerCount; private LabelTypes labelTypes; private Optional<Instant> mergedOn; private ImmutableSetMultimap<Project.NameKey, RefState> refStates; @@ -510,6 +517,7 @@ MergeUtilFactory mergeUtilFactory, MergeabilityCache mergeabilityCache, PatchListCache patchListCache, + DiffOperations diffOperations, PatchSetUtil psUtil, ProjectCache projectCache, TrackingFooters trackingFooters, @@ -536,6 +544,7 @@ this.mergeUtilFactory = mergeUtilFactory; this.mergeabilityCache = mergeabilityCache; this.patchListCache = patchListCache; + this.diffOperations = diffOperations; this.psUtil = psUtil; this.projectCache = projectCache; this.starredChangesReader = starredChangesReader; @@ -610,8 +619,45 @@ if (!lazyload()) { return Collections.emptyList(); } - Optional<DiffSummary> p = getDiffSummary(); - currentFiles = p.map(DiffSummary::getPaths).orElse(Collections.emptyList()); + + // If the diff summary was already loaded get the path from it. + if (diffSummary != null) { + currentFiles = diffSummary.get().getPaths(); + } + + Change c = change(); + PatchSet ps = currentPatchSet(); + if (c == null || ps == null) { + return ImmutableList.of(); + } + + try { + // Getting the modified files from DiffOperations#getModifiedFiles is much cheaper than + // using #getDiffSummary().map(DiffSummary::getPaths).orElse(Collections.emptyList()) if the + // diff summary is not loaded yet. This is because loading the diff summary does not only + // compute the modified files, but also the file diff for each of the modified files. + // Disabling the rename computation is OK since we are only interested in the (old and new + // paths) that have been touched, but don't care which old path has been renamed to which + // new path. The difference when rename detection is off is that for renames we get 1 + // ModifiedFile for adding the new path and 1 ModifiedFile for removing the old path instead + // of 1 ModifiedFile for renaming the old path to the new path. + ImmutableList<ModifiedFile> modifiedFiles = + diffOperations.getModifiedFiles( + c.getProject(), + ps.commitId(), + /* parentNum= */ 0, + /* enableRenameDetection= */ false); + + currentFiles = + modifiedFiles.stream() + .flatMap(modifiedFile -> Stream.of(modifiedFile.newPath(), modifiedFile.oldPath())) + .filter(Optional::isPresent) + .map(Optional::get) + .distinct() + .collect(toImmutableList()); + } catch (DiffNotAvailableException e) { + throw new StorageException("Unable to load modified files of change " + legacyId, e); + } } return currentFiles; } @@ -628,7 +674,7 @@ return Optional.empty(); } - PatchListKey pk = PatchListKey.againstBase(ps.commitId(), parentCount); + PatchListKey pk = PatchListKey.againstBase(ps.commitId(), commitData.parentCount()); DiffSummaryKey key = DiffSummaryKey.fromPatchListKey(pk); try { diffSummary = Optional.of(patchListCache.getDiffSummary(key, c.getProject())); @@ -898,22 +944,18 @@ @Nullable public String commitMessage() { - if (commitMessage == null) { - if (!loadCommitData()) { - return null; - } + if (!loadCommitData()) { + return null; } - return commitMessage; + return commitData.commitMessage(); } /** Returns the list of commit footers (which may be empty). */ public List<FooterLine> commitFooters() { - if (commitFooters == null) { - if (!loadCommitData()) { - return ImmutableList.of(); - } + if (!loadCommitData()) { + return ImmutableList.of(); } - return commitFooters; + return commitData.commitFooters(); } public ListMultimap<String, String> trackingFooters() { @@ -922,43 +964,44 @@ @Nullable public PersonIdent getAuthor() { - if (author == null) { - if (!loadCommitData()) { - return null; - } + if (!loadCommitData()) { + return null; } - return author; + return commitData.author(); } @Nullable public PersonIdent getCommitter() { - if (committer == null) { - if (!loadCommitData()) { - return null; - } + if (!loadCommitData()) { + return null; } - return committer; + return commitData.committer(); } private boolean loadCommitData() { + if (commitData != null) { + // The commit data has already been loaded. + return true; + } + PatchSet ps = currentPatchSet(); if (ps == null) { return false; } - try (Repository repo = repoManager.openRepository(project()); - RevWalk walk = new RevWalk(repo)) { - RevCommit c = walk.parseCommit(ps.commitId()); - commitMessage = c.getFullMessage(); - commitFooters = c.getFooterLines(); - author = c.getAuthorIdent(); - committer = c.getCommitterIdent(); - parentCount = c.getParentCount(); - } catch (IOException e) { - throw new StorageException( - String.format( - "Loading commit %s for ps %d of change %d failed.", - ps.commitId(), ps.id().get(), ps.id().changeId().get()), - e); + try (TraceTimer timer = + TraceContext.newTimer( + "loadCommitData", Metadata.builder().changeId(ps.id().changeId().get()).build())) { + try (Repository repo = repoManager.openRepository(project()); + RevWalk walk = new RevWalk(repo)) { + RevCommit c = walk.parseCommit(ps.commitId()); + commitData = new CommitData(c); + } catch (IOException e) { + throw new StorageException( + String.format( + "Loading commit %s for ps %d of change %d failed.", + ps.commitId(), ps.id().get(), ps.id().changeId().get()), + e); + } } return true; } @@ -1063,7 +1106,11 @@ return allApprovalsWithCopied; } - /* @return legacy submit ('SUBM') approval label */ + /** + * Get legacy submit ('SUBM') approval label + * + * @return legacy submit ('SUBM') approval label + */ // TODO(mariasavtchouk): Deprecate legacy submit label, // see com.google.gerrit.entities.LabelId.LEGACY_SUBMIT_NAME public Optional<PatchSetApproval> getSubmitApproval() { @@ -1100,6 +1147,17 @@ return reviewersByEmail; } + public Integer getReviewerCount() { + if (reviewerCount == null) { + reviewerCount = reviewers().all().size(); + } + return reviewerCount; + } + + public void setReviewerCount(Integer count) { + this.reviewerCount = count; + } + public void setPendingReviewers(ReviewerSet pendingReviewers) { this.pendingReviewers = pendingReviewers; } @@ -1181,23 +1239,29 @@ List<Comment> comments = publishedComments().stream().collect(toList()); logger.atFine().log( "published comments: %s", - comments.stream() - .map( - c -> - String.format( - "%s -> {parentUuid = %s, unresolved = %s}", - c.key, - c.parentUuid, - c instanceof HumanComment ? ((HumanComment) c).unresolved : "n/a")) - .collect(toImmutableList())); + lazy( + () -> + comments.stream() + .map( + c -> + String.format( + "%s -> {parentUuid = %s, unresolved = %s}", + c.key, + c.parentUuid, + c instanceof HumanComment + ? ((HumanComment) c).unresolved + : "n/a")) + .collect(toImmutableList()))); ImmutableSet<CommentThread<Comment>> commentThreads = CommentThreads.forComments(comments).getThreads(); logger.atFine().log( "comment threads: %s", - commentThreads.stream() - .map(t -> t.comments().stream().map(c -> c.key).collect(toImmutableList())) - .collect(toImmutableList())); + lazy( + () -> + commentThreads.stream() + .map(t -> t.comments().stream().map(c -> c.key).collect(toImmutableList())) + .collect(toImmutableList()))); unresolvedCommentCount = (int) commentThreads.stream().filter(CommentThread::unresolved).count(); } @@ -1385,12 +1449,10 @@ @Nullable public Boolean isMerge() { - if (parentCount == null) { - if (!loadCommitData()) { - return null; - } + if (!loadCommitData()) { + return null; } - return parentCount > 1; + return commitData.parentCount() > 1; } public Set<Account.Id> editsByUser() { @@ -1691,6 +1753,22 @@ } } + record CommitData( + String commitMessage, + List<FooterLine> commitFooters, + PersonIdent author, + PersonIdent committer, + int parentCount) { + CommitData(RevCommit c) { + this( + c.getFullMessage(), + c.getFooterLines(), + c.getAuthorIdent(), + c.getCommitterIdent(), + c.getParentCount()); + } + } + @AutoValue abstract static class ReviewedByEvent { private static ReviewedByEvent create(ChangeMessage msg) {
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java index 3c535df..704f8ed 100644 --- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java +++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -157,7 +157,7 @@ static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10; // NOTE: As new search operations are added, please keep the suggestions in - // gr-search-bar.ts up to date. + // gr-search-autocomplete.ts up to date. public static final String FIELD_ADDED = "added"; public static final String FIELD_AGE = "age"; @@ -213,6 +213,7 @@ public static final String FIELD_PROJECTS = "projects"; public static final String FIELD_REF = "ref"; public static final String FIELD_REVIEWEDBY = "reviewedby"; + public static final String FIELD_REVIEWERCOUNT = "reviewercount"; public static final String FIELD_REVIEWERIN = "reviewerin"; public static final String FIELD_STAR = "star"; public static final String FIELD_STARBY = "starby"; @@ -526,7 +527,8 @@ private static final Splitter RULE_SPLITTER = Splitter.on("="); private static final Splitter PLUGIN_SPLITTER = Splitter.on("_"); - protected static final Splitter LABEL_SPLITTER = Splitter.on(","); + protected static final Splitter LABEL_SPLITTER_AND = Splitter.on("&"); + @Deprecated protected static final Splitter LABEL_SPLITTER = Splitter.on(","); @Inject protected ChangeQueryBuilder( @@ -1095,7 +1097,12 @@ // label:Code-Review+2,owner // label:Code-Review+2,user=owner // label:Code-Review+1,count=2 - List<String> splitReviewer = LABEL_SPLITTER.limit(2).splitToList(name); + List<String> splitReviewer; + if (name.contains("&")) { + splitReviewer = LABEL_SPLITTER_AND.limit(2).splitToList(name); + } else { + splitReviewer = LABEL_SPLITTER.limit(2).splitToList(name); + } name = splitReviewer.get(0); // remove all but the vote piece, e.g.'CodeReview=1' if (splitReviewer.size() == 2) { @@ -1454,6 +1461,11 @@ } @Operator + public Predicate<ChangeData> reviewerCount(String value) throws QueryParseException { + return new ReviewerCountPredicate(value); + } + + @Operator public Predicate<ChangeData> reviewerin(String group) throws QueryParseException { GroupReference g = parseGroup(group); return new ReviewerinPredicate(args.userFactory, g.getUUID());
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java index 8e1cccb..4476d6d 100644 --- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java +++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -276,6 +276,13 @@ return query(and(byBranchCommitPred(project, branch, hash), open())); } + public List<ChangeData> byBranchCommitNewOrAbandoned(String project, String branch, String hash) { + return query( + and( + byBranchCommitPred(project, branch, hash), + or(status(Change.Status.NEW), status(Change.Status.ABANDONED)))); + } + public static Predicate<ChangeData> byBranchCommitOpenPred( Project.NameKey project, String branch, String hash) { return and(byBranchCommitPred(project.get(), branch, hash), open());
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java index 6321ccb..68da77a 100644 --- a/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java +++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicates.java
@@ -181,9 +181,6 @@ case ANY -> matchAny(cd, labelType); case MIN -> matchNumeric(cd, magicLabelVote.label(), labelType.getMin().getValue()); case MAX -> matchNumeric(cd, magicLabelVote.label(), labelType.getMax().getValue()); - default -> - throw new IllegalStateException( - "Unsupported magic label value: " + magicLabelVote.value()); }; }
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java index 6f77fd7..0621a9c 100644 --- a/java/com/google/gerrit/server/query/change/PredicateArgs.java +++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -25,11 +25,11 @@ import java.util.regex.Pattern; /** - * This class is used to extract comma separated values in a predicate. + * This class is used to extract & and comma(deprecated) separated values in a predicate. * - * <p>If tags for the values are present (e.g. "branch=jb_2.3,vote=approved") then the args are + * <p>If tags for the values are present (e.g. "branch=jb_2.3&vote=approved") then the args are * placed in a map that maps tag to value (e.g., "branch" to "jb_2.3"). If no tag is present (e.g. - * "jb_2.3,approved") then the args are placed into a positional list. Args may be mixed so some may + * "jb_2.3&approved") then the args are placed into a positional list. Args may be mixed so some may * appear in the map and others in the positional list (e.g. "vote=approved,jb_2.3). */ public class PredicateArgs { @@ -75,7 +75,7 @@ positional = new ArrayList<>(); keyValue = new HashMap<>(); - for (String arg : Splitter.on(',').split(args)) { + for (String arg : Splitter.on(Pattern.compile("[,&]")).split(args)) { Matcher m = SPLIT_PATTERN.matcher(arg); if (!m.find()) {
diff --git a/java/com/google/gerrit/server/query/change/ReviewerCountPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerCountPredicate.java new file mode 100644 index 0000000..15c0392 --- /dev/null +++ b/java/com/google/gerrit/server/query/change/ReviewerCountPredicate.java
@@ -0,0 +1,29 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.index.query.QueryParseException; +import com.google.gerrit.server.index.change.ChangeField; + +public class ReviewerCountPredicate extends IntegerRangeChangePredicate { + public ReviewerCountPredicate(String value) throws QueryParseException { + super(ChangeField.REVIEWER_COUNT_SPEC, value); + } + + @Override + protected Integer getValueInt(ChangeData changeData) { + return ChangeField.REVIEWER_COUNT_SPEC.get(changeData); + } +}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java index 55d3505..26ccb2c 100644 --- a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java +++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -15,6 +15,7 @@ package com.google.gerrit.server.query.change; import com.google.common.base.Splitter; +import com.google.common.flogger.FluentLogger; import com.google.gerrit.entities.Account; import com.google.gerrit.index.SchemaFieldDefs.SchemaField; import com.google.gerrit.index.query.Predicate; @@ -29,7 +30,8 @@ import com.google.gerrit.server.submitrequirement.predicate.RegexCommitterEmailPredicate; import com.google.gerrit.server.submitrequirement.predicate.RegexUploaderEmailPredicateFactory; import com.google.gerrit.server.submitrequirement.predicate.SubmitRequirementLabelExtensionPredicate; -import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; import java.io.IOException; import java.util.List; import java.util.Locale; @@ -46,6 +48,11 @@ * <p>Operators defined in this class cannot be used in change queries. */ public class SubmitRequirementChangeQueryBuilder extends ChangeQueryBuilder { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public interface Factory { + SubmitRequirementChangeQueryBuilder create(boolean operatorRequiredDuringSearch); + } private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> def = new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class); @@ -70,8 +77,9 @@ private final FileEditsPredicate.Factory fileEditsPredicateFactory; private final RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory; + private final boolean operatorRequiredDuringSearch; - @Inject + @AssistedInject SubmitRequirementChangeQueryBuilder( Arguments args, DistinctVotersPredicate.Factory distinctVotersPredicateFactory, @@ -79,7 +87,8 @@ submitRequirementLabelExtensionPredicateFactory, FileEditsPredicate.Factory fileEditsPredicateFactory, HasSubmoduleUpdatePredicate.Factory hasSubmoduleUpdateFactory, - RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory) { + RegexUploaderEmailPredicateFactory regexUploaderEmailPredicateFactory, + @Assisted boolean operatorRequiredDuringSearch) { super(def, args); this.distinctVotersPredicateFactory = distinctVotersPredicateFactory; this.submitRequirementLabelExtensionPredicateFactory = @@ -87,6 +96,7 @@ this.fileEditsPredicateFactory = fileEditsPredicateFactory; this.hasSubmoduleUpdateFactory = hasSubmoduleUpdateFactory; this.regexUploaderEmailPredicateFactory = regexUploaderEmailPredicateFactory; + this.operatorRequiredDuringSearch = operatorRequiredDuringSearch; } @Override @@ -96,6 +106,18 @@ } @Override + protected Predicate<ChangeData> defaultField(String value) throws QueryParseException { + if (!operatorRequiredDuringSearch) { + return super.defaultField(value); + } + logger.atSevere().log("Operator is missing in submit requirement term: %s", value); + throw error( + "Operator is missing in submit requirement term: " + + value + + ". Please ensure each term of submit requirements are in format <operator>:<value>"); + } + + @Override public Predicate<ChangeData> is(String value) throws QueryParseException { if ("submittable".equalsIgnoreCase(value)) { throw new QueryParseException(
diff --git a/java/com/google/gerrit/server/quota/QuotaResponse.java b/java/com/google/gerrit/server/quota/QuotaResponse.java index 940f731..c828917 100644 --- a/java/com/google/gerrit/server/quota/QuotaResponse.java +++ b/java/com/google/gerrit/server/quota/QuotaResponse.java
@@ -60,6 +60,29 @@ return new AutoValue_QuotaResponse.Builder().status(Status.OK).availableTokens(tokens).build(); } + /** + * Creates a successful quota response indicating that {@code tokens} are available. + * + * <p>The returned response has status {@link Status#OK}: the caller has not exceeded the quota. + * + * <p>{@code exceededQuotaMessage} specifies the client-facing message that should be used if a + * future request attempts to consume more than the {@code tokens} reported here and must + * therefore be rejected as exceeding quota. This message is not an error for the current request; + * it is metadata supplied by the enforcer for potential future quota failures. + * + * @param tokens the number of quota tokens currently available for this request context + * @param exceededQuotaMessage the message to surface to clients if a later request would exceed + * the available {@code tokens} + * @return a {@code QuotaResponse} representing a successful quota check with the given metadata + */ + public static QuotaResponse ok(long tokens, String exceededQuotaMessage) { + return new AutoValue_QuotaResponse.Builder() + .status(Status.OK) + .availableTokens(tokens) + .message(exceededQuotaMessage) + .build(); + } + public static QuotaResponse noOp() { return new AutoValue_QuotaResponse.Builder().status(Status.NO_OP).build(); } @@ -116,6 +139,38 @@ return responses().stream().filter(r -> r.status().isError()).collect(toImmutableList()); } + /** + * Returns the quota-exceeded message provided by the response with the lowest number of + * available tokens. + * + * <p>This message is intended to be shown to clients when a request would exceed the aggregated + * available quota. It allows quota enforcers to supply custom, client-facing explanations for + * quota-rejection scenarios. + * + * <p>Only responses that report {@link #availableTokens() available tokens} are considered. + * Among these, the response with the smallest token count (i.e. the most restrictive enforcer) + * is selected. If multiple such responses exist, their messages are joined using a comma. + * + * @return an {@code Optional} containing the quota-exceeded message (or comma-joined messages) + * from the most restrictive available-tokens responses, or an empty {@code Optional} if + * none of the considered responses provides such a message. + */ + public Optional<String> mostRestrictiveQuotaExceededMessage() { + return availableTokens().stream() + .mapToObj( + minAvailableTokens -> + responses().stream() + .filter( + r -> + r.availableTokens().isPresent() + && r.availableTokens().get() == minAvailableTokens) + .map(QuotaResponse::message) + .flatMap(Optional::stream) + .collect(Collectors.joining(","))) + .filter(s -> !s.isEmpty()) + .findFirst(); + } + public String errorMessage() { return error().stream() .map(QuotaResponse::message)
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD index 70ba6c5..5979571 100644 --- a/java/com/google/gerrit/server/restapi/BUILD +++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -6,7 +6,10 @@ java_library( name = "restapi", - srcs = glob(["**/*.java"]), + srcs = glob( + ["**/*.java"], + exclude = ["project/ProjectAccessModifier.java"], + ), deps = [ "//antlr3:query_parser", "//java/com/google/gerrit/common:annotations", @@ -27,6 +30,7 @@ "//java/com/google/gerrit/server/flow", "//java/com/google/gerrit/server/ioutil", "//java/com/google/gerrit/server/logging", + "//java/com/google/gerrit/server/schema", "//java/com/google/gerrit/server/util/time", "//lib:args4j", "//lib:blame-cache", @@ -38,14 +42,29 @@ "//lib/auto:auto-factory", "//lib/auto:auto-value", "//lib/auto:auto-value-annotations", + "//lib/bouncycastle:bcprov-neverlink", "//lib/commons:codec", - "//lib/commons:compress", "//lib/commons:lang3", "//lib/errorprone:annotations", "//lib/flogger:api", "//lib/guice", "//lib/guice:guice-assistedinject", "//proto:entities_java_proto", - "@bcprov//jar", + ], +) + +java_library( + name = "project_access_modifier", + srcs = glob(["project/ProjectAccessModifier.java"]), + visibility = ["//visibility:public"], + deps = [ + ":restapi", + "//java/com/google/gerrit/entities", + "//java/com/google/gerrit/entities/converter:converters", + "//java/com/google/gerrit/exceptions", + "//java/com/google/gerrit/extensions:api", + "//java/com/google/gerrit/server", + "//lib:guava", + "//lib/guice", ], )
diff --git a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java index 36fb452..653646f 100644 --- a/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java +++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -65,6 +65,7 @@ get(EMAIL_KIND).to(GetEmail.class); put(EMAIL_KIND).to(PutEmail.class); put(EMAIL_KIND, "preferred").to(PutPreferred.class); + put(EMAIL_KIND, "avatar").to(PutAvatarEmail.class); get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class); post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
diff --git a/java/com/google/gerrit/server/restapi/account/GetEmails.java b/java/com/google/gerrit/server/restapi/account/GetEmails.java index a518532..3f12665 100644 --- a/java/com/google/gerrit/server/restapi/account/GetEmails.java +++ b/java/com/google/gerrit/server/restapi/account/GetEmails.java
@@ -66,6 +66,7 @@ EmailInfo e = new EmailInfo(); e.email = email; e.preferred(rsrc.getUser().getAccount().preferredEmail()); + e.avatar(rsrc.getUser().getAccount().effectiveAvatarEmail()); return e; } }
diff --git a/java/com/google/gerrit/server/restapi/account/PutAvatarEmail.java b/java/com/google/gerrit/server/restapi/account/PutAvatarEmail.java new file mode 100644 index 0000000..3415ef2 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/account/PutAvatarEmail.java
@@ -0,0 +1,132 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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 java.util.stream.Collectors.toSet; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.extensions.common.Input; +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.CurrentUser; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.ServerInitiated; +import com.google.gerrit.server.account.AccountResource; +import com.google.gerrit.server.account.AccountState; +import com.google.gerrit.server.account.AccountsUpdate; +import com.google.gerrit.server.account.externalids.ExternalId; +import com.google.gerrit.server.permissions.GlobalPermission; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.Singleton; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.eclipse.jgit.errors.ConfigInvalidException; + +/** + * REST endpoint to set an email address as avatar email address for an account. + * + * <p>This REST endpoint handles {@code PUT + * /accounts/<account-identifier>/emails/<email-identifier>/avatar} requests. + * + * <p>The avatar email is used for avatar lookup (e.g. Gravatar). Users can only set an email + * address as avatar email that is assigned to their account. + */ +@Singleton +public class PutAvatarEmail implements RestModifyView<AccountResource.Email, Input> { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final Provider<CurrentUser> self; + private final PermissionBackend permissionBackend; + private final Provider<AccountsUpdate> accountsUpdateProvider; + + @Inject + PutAvatarEmail( + Provider<CurrentUser> self, + PermissionBackend permissionBackend, + @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) { + this.self = self; + this.permissionBackend = permissionBackend; + this.accountsUpdateProvider = accountsUpdateProvider; + } + + @Override + public Response<String> apply(AccountResource.Email rsrc, Input input) + throws RestApiException, IOException, PermissionBackendException, ConfigInvalidException { + if (!self.get().hasSameAccountId(rsrc.getUser())) { + permissionBackend.currentUser().check(GlobalPermission.MODIFY_ACCOUNT); + } + return apply(rsrc.getUser(), rsrc.getEmail()); + } + + public Response<String> apply(IdentifiedUser user, String avatarEmail) + throws RestApiException, IOException, ConfigInvalidException { + AtomicReference<Optional<RestApiException>> exception = new AtomicReference<>(Optional.empty()); + AtomicBoolean alreadySet = new AtomicBoolean(false); + Optional<AccountState> updatedAccount = + accountsUpdateProvider + .get() + .update( + "Set Avatar Email via API", + user.getAccountId(), + (r, a, u) -> { + if (avatarEmail.equals(a.account().avatarEmail())) { + alreadySet.set(true); + } else { + // Check if the user has a matching email + String matchingEmail = null; + for (String email : + a.externalIds().stream() + .map(ExternalId::email) + .filter(Objects::nonNull) + .collect(toSet())) { + if (email.equals(avatarEmail)) { + // We have an email that matches exactly + matchingEmail = email; + break; + } else if (matchingEmail == null && email.equalsIgnoreCase(avatarEmail)) { + // We found an email that matches but has a different case + matchingEmail = email; + } + } + + if (matchingEmail == null) { + // User doesn't have this email address + logger.atWarning().log( + "Cannot set avatar email %s for account %s because it is not assigned to" + + " this account", + avatarEmail, user.getAccountId()); + exception.set(Optional.of(new ResourceNotFoundException(avatarEmail))); + return; + } + u.setAvatarEmail(matchingEmail); + } + }); + if (!updatedAccount.isPresent()) { + throw new ResourceNotFoundException("account not found"); + } + if (exception.get().isPresent()) { + throw exception.get().get(); + } + return alreadySet.get() ? Response.ok() : Response.created(); + } +}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java index 7ea3c5b..cdca729 100644 --- a/java/com/google/gerrit/server/restapi/change/ApplyPatch.java +++ b/java/com/google/gerrit/server/restapi/change/ApplyPatch.java
@@ -32,6 +32,7 @@ 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.PatchSetUtil; import com.google.gerrit.server.change.ChangeResource; import com.google.gerrit.server.git.CodeReviewCommit; import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk; @@ -67,6 +68,7 @@ private final ContributorAgreementsChecker contributorAgreements; private final GitRepositoryManager gitManager; private final Provider<InternalChangeQuery> queryProvider; + private final PatchSetUtil psUtil; private final ProjectCache projectCache; private final ChangeUtil changeUtil; private final PatchSetCreator patchSetCreator; @@ -76,12 +78,14 @@ ContributorAgreementsChecker contributorAgreements, GitRepositoryManager gitManager, Provider<InternalChangeQuery> queryProvider, + PatchSetUtil psUtil, ProjectCache projectCache, ChangeUtil changeUtil, PatchSetCreator patchSetCreator) { this.contributorAgreements = contributorAgreements; this.gitManager = gitManager; this.queryProvider = queryProvider; + this.psUtil = psUtil; this.projectCache = projectCache; this.changeUtil = changeUtil; this.patchSetCreator = patchSetCreator; @@ -126,12 +130,27 @@ RevCommit latestPatchset = revWalk.parseCommit(destChange.currentPatchSet().commitId()); RevCommit baseCommit; ImmutableList<RevCommit> parents; + ImmutableList<String> groups = null; if (!Strings.isNullOrEmpty(input.base)) { baseCommit = CommitUtil.getBaseCommit( project.get(), queryProvider.get(), revWalk, destRef, input.base); parents = ImmutableList.of(baseCommit); + + List<ChangeData> parentChanges = + queryProvider + .get() + .setLimit(1) + .byBranchCommitNewOrAbandoned( + project.get(), destBranch.branch(), baseCommit.name()); + if (!parentChanges.isEmpty()) { + ChangeData parentChange = parentChanges.getFirst(); + groups = psUtil.current(parentChange.notes()).groups(); + } } else { + // No need to set groups, if groups is unset PatchSetInserter copies the groups from the + // latest patch set. + if (latestPatchset.getParentCount() != 1) { throw new BadRequestException( String.format( @@ -178,6 +197,7 @@ revWalk, applyResult.getTreeId(), commitMessage, + groups, /* validationOptions= */ null); if (changeInfo.containsGitConflicts == null && applyResult.getErrors().stream().anyMatch(Error::isGitConflict)) {
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java index e254727..9b80512 100644 --- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java +++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -62,6 +62,7 @@ import com.google.gerrit.server.edit.ChangeEditJson; import com.google.gerrit.server.edit.ChangeEditModifier; import com.google.gerrit.server.edit.ChangeEditUtil; +import com.google.gerrit.server.edit.tree.BadContentLengthException; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.git.validators.CommitValidators; import com.google.gerrit.server.patch.PatchListNotAvailableException; @@ -387,6 +388,8 @@ octalAsDecimal(fileContentInput.fileMode)); } catch (InvalidChangeOperationException e) { throw new ResourceConflictException(e.getMessage()); + } catch (BadContentLengthException e) { + throw new BadRequestException(e.getMessage(), e); } return Response.none(); }
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java index 9bbaa3f3..58f56e9 100644 --- a/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java +++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -130,6 +130,7 @@ child(REVIEWER_KIND, "votes").to(Votes.class); child(CHANGE_KIND, "revisions").to(Revisions.class); + get(REVISION_KIND).to(GetRevision.class); get(REVISION_KIND, "actions").to(GetRevisionActions.class); get(REVISION_KIND, "archive").to(GetArchive.class); post(REVISION_KIND, "cherrypick").to(CherryPick.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java index d680931..795b86c 100644 --- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java +++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -15,6 +15,7 @@ package com.google.gerrit.server.restapi.change; import static com.google.common.base.MoreObjects.firstNonNull; +import static com.google.common.base.Preconditions.checkState; import static com.google.gerrit.server.project.ProjectCache.noSuchProject; import static com.google.gerrit.server.update.context.RefUpdateContext.RefUpdateType.CHANGE_MODIFICATION; @@ -182,6 +183,7 @@ patch.id(), change.getProject(), patch.commitId(), + null, input, dest, TimeUtil.now(), @@ -229,6 +231,7 @@ sourcePsId, project, sourceCommit, + null, input, dest, TimeUtil.now(), @@ -250,6 +253,11 @@ * sourceChange is null and expected to match the sourceCommit. * @param project Project name * @param sourceCommit Id of the commit to be cherry picked. + * @param baseChange change that matches the base provided in {@code input.base}. May be {@code + * null} if {@code input.base} is unset, if {@code input.base} doesn't match a change or if + * the change that matches {@code input.base} is unknown. If {@code input.base} is set but a + * {@code baseChange} is not provided an index query will be done to try finding the matching + * change. If known the base change should be provided to avoid this index lookup. * @param input Input object for different configurations of cherry pick. * @param dest Destination branch for the cherry pick. * @param timestamp the current timestamp. @@ -279,6 +287,7 @@ @Nullable PatchSet.Id sourcePsId, Project.NameKey project, ObjectId sourceCommit, + @Nullable Change baseChange, CherryPickInput input, BranchNameKey dest, Instant timestamp, @@ -293,6 +302,10 @@ RestApiException, ConfigInvalidException, NoSuchProjectException { + checkState( + (input.base == null && baseChange == null) || input.base != null, + "input.base must be set if baseChange is provided"); + IdentifiedUser identifiedUser = user.get(); try (Repository git = gitManager.openRepository(project); // This inserter and revwalk *must* be passed to any BatchUpdates @@ -449,6 +462,7 @@ sourceChange, sourcePsId, sourceCommit, + baseChange, input, revertedChange, idForNewChange, @@ -515,6 +529,7 @@ @Nullable Change sourceChange, @Nullable PatchSet.Id sourcePsId, @Nullable ObjectId sourceCommit, + @Nullable Change baseChange, CherryPickInput input, @Nullable Change.Id revertOf, @Nullable Change.Id idForNewChange, @@ -559,18 +574,22 @@ // groups. ins.setGroups(GroupCollector.getDefaultGroups(cherryPickCommit.getId())); if (input.base != null) { - List<ChangeData> changes = - queryProvider.get().setLimit(2).byBranchCommitOpen(project.get(), refName, input.base); - if (changes.size() > 1) { - throw new InvalidChangeOperationException( - "Several changes with key " - + input.base - + " reside on the same branch. " - + "Cannot cherry-pick on target branch."); - } - if (changes.size() == 1) { - Change change = changes.get(0).change(); - ins.setGroups(changeNotesFactory.createChecked(change).getCurrentPatchSet().groups()); + if (baseChange != null) { + ins.setGroups(changeNotesFactory.createChecked(baseChange).getCurrentPatchSet().groups()); + } else { + List<ChangeData> changes = + queryProvider.get().setLimit(2).byBranchCommitOpen(project.get(), refName, input.base); + if (changes.size() > 1) { + throw new InvalidChangeOperationException( + "Several changes with key " + + input.base + + " reside on the same branch. " + + "Cannot cherry-pick on target branch."); + } + if (changes.size() == 1) { + Change change = changes.get(0).change(); + ins.setGroups(changeNotesFactory.createChecked(change).getCurrentPatchSet().groups()); + } } } bu.insertChange(ins);
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevision.java b/java/com/google/gerrit/server/restapi/change/GetRevision.java new file mode 100644 index 0000000..8055eac --- /dev/null +++ b/java/com/google/gerrit/server/restapi/change/GetRevision.java
@@ -0,0 +1,53 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.extensions.client.ListChangesOption; +import com.google.gerrit.extensions.common.RevisionInfo; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestReadView; +import com.google.gerrit.server.GpgException; +import com.google.gerrit.server.change.RevisionJson; +import com.google.gerrit.server.change.RevisionResource; +import com.google.gerrit.server.patch.PatchListNotAvailableException; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import java.util.EnumSet; + +/** + * REST endpoint to get a revision / patch-set of a change. + * + * <p>This REST endpoint handles {@code GET + * /changes/<change-identifier>/revisions/<revision-identifier>} requests. + */ +@Singleton +public class GetRevision implements RestReadView<RevisionResource> { + private final RevisionJson.Factory json; + + @Inject + GetRevision(RevisionJson.Factory json) { + this.json = json; + } + + @Override + public Response<RevisionInfo> apply(RevisionResource rsrc) + throws PatchListNotAvailableException, GpgException, IOException, PermissionBackendException { + return Response.ok( + json.create(EnumSet.allOf(ListChangesOption.class)) + .getRevisionInfo(rsrc.getChangeResource().getChangeData(), rsrc.getPatchSet())); + } +}
diff --git a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java index fe12119..7b7ee13 100644 --- a/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java +++ b/java/com/google/gerrit/server/restapi/change/PatchSetCreator.java
@@ -94,6 +94,7 @@ CodeReviewRevWalk revWalk, ObjectId commitTree, String commitMessage, + @Nullable List<String> groups, @Nullable ImmutableListMultimap<String, String> validationOptions) throws IOException, RestApiException, UpdateException { requireNonNull(destChange); @@ -127,7 +128,13 @@ bu.setRepository(repo, revWalk, oi); resultChange = insertPatchSet( - bu, repo, patchSetInserterFactory, destChange.notes(), commit, validationOptions); + bu, + repo, + patchSetInserterFactory, + destChange.notes(), + commit, + groups, + validationOptions); } catch (NoSuchChangeException | RepositoryNotFoundException e) { throw new ResourceConflictException(e.getMessage()); } @@ -161,13 +168,17 @@ PatchSetInserter.Factory patchSetInserterFactory, ChangeNotes destNotes, CodeReviewCommit commit, - ImmutableListMultimap<String, String> validationOptions) + @Nullable List<String> groups, + @Nullable ImmutableListMultimap<String, String> validationOptions) 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)); + if (groups != null) { + inserter.setGroups(groups); + } if (validationOptions != null) { inserter.setValidationOptions(validationOptions); }
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java index 22a1759..d8e03d9 100644 --- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java +++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -21,15 +21,23 @@ import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.api.changes.ReviewerInput; import com.google.gerrit.extensions.api.changes.ReviewerResult; +import com.google.gerrit.extensions.restapi.AuthException; import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; import com.google.gerrit.extensions.restapi.Response; import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.extensions.restapi.RestCollectionModifyView; +import com.google.gerrit.server.CurrentUser; +import com.google.gerrit.server.IdentifiedUser; +import com.google.gerrit.server.account.AccountResolver; import com.google.gerrit.server.change.ChangeResource; import com.google.gerrit.server.change.NotifyResolver; import com.google.gerrit.server.change.ReviewerModifier; import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification; import com.google.gerrit.server.change.ReviewerResource; +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.query.change.ChangeData; import com.google.gerrit.server.update.BatchUpdate; @@ -48,17 +56,23 @@ private final ChangeData.Factory changeDataFactory; private final NotifyResolver notifyResolver; private final ReviewerModifier reviewerModifier; + private final AccountResolver accountResolver; + private final PermissionBackend permissionBackend; @Inject PostReviewers( BatchUpdate.Factory updateFactory, ChangeData.Factory changeDataFactory, NotifyResolver notifyResolver, - ReviewerModifier reviewerModifier) { + ReviewerModifier reviewerModifier, + AccountResolver accountResolver, + PermissionBackend permissionBackend) { this.updateFactory = updateFactory; this.changeDataFactory = changeDataFactory; this.notifyResolver = notifyResolver; this.reviewerModifier = reviewerModifier; + this.accountResolver = accountResolver; + this.permissionBackend = permissionBackend; } @Override @@ -68,14 +82,15 @@ UpdateException, PermissionBackendException, ConfigInvalidException { + final CurrentUser user = getActingUser(rsrc.getUser(), input, rsrc.getNotes()); + ReviewerModification modification = - reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), input, true); + reviewerModifier.prepare(rsrc.getNotes(), user, input, true); if (modification.op == null) { return Response.withStatusCode(SC_BAD_REQUEST, modification.result); } try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) { - try (BatchUpdate bu = - updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.now())) { + try (BatchUpdate bu = updateFactory.create(rsrc.getProject(), user, TimeUtil.now())) { bu.setNotify(resolveNotify(rsrc, input)); Change.Id id = rsrc.getChange().getId(); bu.addOp(id, modification.op); @@ -88,6 +103,30 @@ return Response.ok(modification.result); } + private CurrentUser getActingUser( + CurrentUser caller, ReviewerInput input, ChangeNotes changeNotes) + throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException { + if (input.onBehalfOf != null) { + return onBehalfOf(caller, input.onBehalfOf, changeNotes); + } + return caller; + } + + private IdentifiedUser onBehalfOf(CurrentUser caller, String onBehalfOf, ChangeNotes changeNotes) + throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException { + IdentifiedUser reviewer = + accountResolver + .resolve(onBehalfOf) + .asUniqueUserOnBehalfOf(caller, IdentifiedUser.ImpersonationPermissionMode.THIS_USER); + try { + permissionBackend.user(reviewer).change(changeNotes).check(ChangePermission.READ); + } catch (AuthException e) { + throw new ResourceConflictException( + String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()), e); + } + return reviewer; + } + private NotifyResolver.Result resolveNotify(ChangeResource rsrc, ReviewerInput input) throws BadRequestException, ConfigInvalidException, IOException { NotifyHandling notifyHandling = input.notify;
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java index b2cf76c..3c2ef66 100644 --- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java +++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -48,7 +48,6 @@ import com.google.gerrit.server.PatchSetUtil; import com.google.gerrit.server.Sequences; 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.NotifyResolver; import com.google.gerrit.server.change.RevisionResource; @@ -77,7 +76,6 @@ import com.google.inject.Inject; import com.google.inject.Provider; import java.io.IOException; -import java.text.MessageFormat; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; @@ -87,8 +85,6 @@ import java.util.Locale; import java.util.Optional; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang3.RandomStringUtils; import org.eclipse.jgit.errors.ConfigInvalidException; @@ -122,10 +118,8 @@ private final GetRelated getRelated; private CherryPickInput cherryPickInput; + private Change cherryPickBaseChange; private List<ChangeInfo> results; - private static final Pattern patternRevertSubject = Pattern.compile("Revert \"(.+)\""); - private static final Pattern patternRevertSubjectWithNum = - Pattern.compile("Revert\\^(\\d+) \"(.+)\""); @Inject RevertSubmission( @@ -249,7 +243,6 @@ cherryPickInput = createCherryPickInput(revertInput); Instant timestamp = TimeUtil.now(); - String initialMessage = revertInput.message; for (BranchNameKey projectAndBranch : changesPerProjectAndBranch.keySet()) { cherryPickInput.base = null; Project.NameKey project = projectAndBranch.project(); @@ -266,7 +259,6 @@ .collect(Collectors.toSet()); revertAllChangesInProjectAndBranch( - initialMessage, revertInput, project, sortedChangesInProjectAndBranch, @@ -279,9 +271,7 @@ return revertSubmissionInfo; } - // Warning: reuses and modifies revertInput.message. private void revertAllChangesInProjectAndBranch( - String initialMessage, RevertInput revertInput, Project.NameKey project, Iterator<PatchSetData> sortedChangesInProjectAndBranch, @@ -299,8 +289,6 @@ 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 // revert would be on top of the change being reverted. @@ -315,7 +303,8 @@ RevertInput revertInput, Project.NameKey project, ChangeNotes changeNotes, Instant timestamp) throws IOException, ConfigInvalidException, UpdateException, RestApiException { RevCommit revCommit = - commitUtil.createRevertCommit(revertInput.message, changeNotes, user.get(), timestamp); + commitUtil.createRevertCommit( + revertInput.message, changeNotes, user.get(), timestamp, true); // TODO (paiking): As a future change, the revert should just be done directly on the // target rather than just creating a commit and then cherry-picking it. cherryPickInput.message = revCommit.getFullMessage(); @@ -357,14 +346,12 @@ throws IOException, RestApiException, UpdateException, ConfigInvalidException { Change.Id revertId = - commitUtil.createRevertChange(changeNotes, user.get(), revertInput, timestamp); + commitUtil.createRevertChange(changeNotes, user.get(), revertInput, timestamp, true); results.add(json.noOptions().format(changeNotes.getProjectName(), revertId)); - cherryPickInput.base = - changeNotesFactory - .createChecked(changeNotes.getProjectName(), revertId) - .getCurrentPatchSet() - .commitId() - .getName(); + ChangeNotes revertChange = + changeNotesFactory.createChecked(changeNotes.getProjectName(), revertId); + cherryPickBaseChange = revertChange.getChange(); + cherryPickInput.base = revertChange.getCurrentPatchSet().commitId().getName(); } private CherryPickInput createCherryPickInput(RevertInput revertInput) { @@ -384,47 +371,6 @@ return cherryPickInput; } - private String getMessage(String initialMessage, ChangeNotes changeNotes) { - String subject = changeNotes.getChange().getSubject(); - if (subject.length() > 60) { - subject = subject.substring(0, 56) + "..."; - } - if (initialMessage == null) { - initialMessage = - MessageFormat.format( - ChangeMessages.revertSubmissionDefaultMessage, - changeNotes.getCurrentPatchSet().commitId().name()); - } - - // For performance purposes: Almost all cases will end here. - if (!subject.startsWith("Revert")) { - return MessageFormat.format( - ChangeMessages.revertSubmissionUserMessage, subject, initialMessage); - } - - Matcher matcher = patternRevertSubjectWithNum.matcher(subject); - - if (matcher.matches()) { - return MessageFormat.format( - ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage, - Integer.valueOf(matcher.group(1)) + 1, - matcher.group(2), - changeNotes.getCurrentPatchSet().commitId().name()); - } - - matcher = patternRevertSubject.matcher(subject); - if (matcher.matches()) { - return MessageFormat.format( - ChangeMessages.revertSubmissionOfRevertSubmissionUserMessage, - 2, - matcher.group(1), - changeNotes.getCurrentPatchSet().commitId().name()); - } - - return MessageFormat.format( - ChangeMessages.revertSubmissionUserMessage, subject, initialMessage); - } - /** * This function finds the base that the first revert in a project + branch should be based on. * @@ -593,6 +539,7 @@ change.currentPatchSetId(), change.getProject(), revCommitId, + cherryPickBaseChange, cherryPickInput, BranchNameKey.create( change.getProject(), RefNames.fullName(cherryPickInput.destination)), @@ -603,12 +550,10 @@ workInProgress, Optional.ofNullable(baseCommit)); // save the commit as base for next cherryPick of that branch - cherryPickInput.base = - changeNotesFactory - .createChecked(ctx.getProject(), cherryPickResult.changeId()) - .getCurrentPatchSet() - .commitId() - .getName(); + ChangeNotes cherryPickChange = + changeNotesFactory.createChecked(ctx.getProject(), cherryPickResult.changeId()); + cherryPickBaseChange = cherryPickChange.getChange(); + cherryPickInput.base = cherryPickChange.getCurrentPatchSet().commitId().getName(); results.add(json.noOptions().format(change.getProject(), cherryPickResult.changeId())); return true; }
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java index 94e4cf0..44abc4f 100644 --- a/java/com/google/gerrit/server/restapi/change/Submit.java +++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -248,7 +248,10 @@ @Nullable private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) { Optional<String> reason = - MergeOp.checkCommonSubmitProblems(cd.change(), cs, false, permissionBackend, user).stream() + mergeOpProvider + .get() + .checkCommonSubmitProblems(cd.change(), cs, false, permissionBackend, user) + .stream() .findFirst() .map(MergeOp.ChangeProblem::getProblem); if (reason.isPresent()) { @@ -294,7 +297,7 @@ ChangeData cd = resource.getChangeResource().getChangeData(); try { - MergeOp.checkSubmitRequirements(cd); + mergeOpProvider.get().checkSubmitRequirements(cd); } catch (ResourceConflictException e) { return null; // submit not visible }
diff --git a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java index ca0c796..6fd02db8 100644 --- a/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java +++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -52,6 +52,7 @@ child(CONFIG_KIND, "indexes").to(IndexCollection.class); post(INDEX_KIND, "snapshot").to(SnapshotIndex.class); + post(INDEX_KIND, "flush").to(FlushIndex.class); get(INDEX_KIND).to(GetIndex.class); get(CONFIG_KIND, "info").to(GetServerInfo.class);
diff --git a/java/com/google/gerrit/server/restapi/config/DeleteTask.java b/java/com/google/gerrit/server/restapi/config/DeleteTask.java index a08b036..5a06bb6 100644 --- a/java/com/google/gerrit/server/restapi/config/DeleteTask.java +++ b/java/com/google/gerrit/server/restapi/config/DeleteTask.java
@@ -16,7 +16,6 @@ import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK; import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER; -import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; import com.google.gerrit.extensions.annotations.RequiresAnyCapability; import com.google.gerrit.extensions.common.Input; @@ -34,8 +33,10 @@ public Response<?> apply(TaskResource rsrc, Input input) { Task<?> task = rsrc.getTask(); boolean taskDeleted = task.cancel(true); - return taskDeleted - ? Response.none() - : Response.withStatusCode(SC_INTERNAL_SERVER_ERROR, "Unable to kill task " + task); + if (taskDeleted) { + return Response.none(); + } + + throw new IllegalStateException("Unable to kill task " + task); } }
diff --git a/java/com/google/gerrit/server/restapi/config/FlushIndex.java b/java/com/google/gerrit/server/restapi/config/FlushIndex.java new file mode 100644 index 0000000..5f86da4 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/config/FlushIndex.java
@@ -0,0 +1,47 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.restapi.config; + +import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER; + +import com.google.gerrit.extensions.annotations.RequiresCapability; +import com.google.gerrit.extensions.common.Input; +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.index.Index; +import com.google.gerrit.index.IndexDefinition; +import com.google.gerrit.server.config.IndexResource; +import com.google.inject.Singleton; +import java.io.IOException; + +@RequiresCapability(MAINTAIN_SERVER) +@Singleton +public class FlushIndex implements RestModifyView<IndexResource, Input> { + + @Override + public Response<?> apply(IndexResource resource, Input input) + throws AuthException, BadRequestException, ResourceConflictException, IOException { + + IndexDefinition<?, ?, ?> def = resource.getIndexDefinition(); + for (Index<?, ?> index : def.getIndexCollection().getWriteIndexes()) { + index.flushAndCommit(); + } + + return Response.none(); + } +}
diff --git a/java/com/google/gerrit/server/restapi/config/PostCaches.java b/java/com/google/gerrit/server/restapi/config/PostCaches.java index 79fda18..a0728d21 100644 --- a/java/com/google/gerrit/server/restapi/config/PostCaches.java +++ b/java/com/google/gerrit/server/restapi/config/PostCaches.java
@@ -79,24 +79,23 @@ throw new BadRequestException("operation must be specified"); } - switch (input.operation) { + return switch (input.operation) { case FLUSH_ALL -> { if (input.caches != null) { throw new BadRequestException( "specifying caches is not allowed for operation 'FLUSH_ALL'"); } flushAll(); - return Response.ok(); + yield Response.ok(); } case FLUSH -> { if (input.caches == null || input.caches.isEmpty()) { throw new BadRequestException("caches must be specified for operation 'FLUSH'"); } flush(input.caches); - return Response.ok(); + yield Response.ok(); } - default -> throw new BadRequestException("unsupported operation: " + input.operation); - } + }; } private void flushAll() throws AuthException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/flow/CreateFlow.java b/java/com/google/gerrit/server/restapi/flow/CreateFlow.java index 94c893f..439725e 100644 --- a/java/com/google/gerrit/server/restapi/flow/CreateFlow.java +++ b/java/com/google/gerrit/server/restapi/flow/CreateFlow.java
@@ -14,7 +14,10 @@ package com.google.gerrit.server.restapi.flow; +import static com.google.common.collect.ImmutableList.toImmutableList; + import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; import com.google.gerrit.extensions.common.FlowInfo; import com.google.gerrit.extensions.common.FlowInput; import com.google.gerrit.extensions.restapi.AuthException; @@ -30,6 +33,7 @@ import com.google.gerrit.server.flow.FlowCreation; import com.google.gerrit.server.flow.FlowPermissionDeniedException; import com.google.gerrit.server.flow.FlowServiceUtil; +import com.google.gerrit.server.flow.FlowStageEvaluationStatus.State; import com.google.gerrit.server.flow.InvalidFlowException; import com.google.inject.Inject; import com.google.inject.Provider; @@ -45,6 +49,7 @@ public class CreateFlow implements RestCollectionModifyView<ChangeResource, FlowResource, FlowInput> { @VisibleForTesting public static final int DEFAULT_MAX_FLOWS_PER_CHANGE = 20; + @VisibleForTesting public static final int DEFAULT_MAX_PENDING_FLOWS_PER_CHANGE = 3; private final Provider<Config> cfgProvider; private final FlowServiceUtil flowServiceUtil; @@ -70,21 +75,21 @@ throw new AuthException("Authentication required"); } + if (!changeResource + .getChangeData() + .currentPatchSet() + .realUploader() + .equals(self.get().getAccountId())) { + throw new AuthException( + "Only latest uploader can create a flow, because actions are executed on behalf of" + + " uploader."); + } + if (flowInput == null) { flowInput = new FlowInput(); } - int maxFlowsPerChange = - cfgProvider.get().getInt("flows", "maxPerChange", DEFAULT_MAX_FLOWS_PER_CHANGE); - if (maxFlowsPerChange > 0 - && flowServiceUtil - .getFlowServiceOrThrow() - .listFlows(changeResource.getProject(), changeResource.getId()) - .size() - >= maxFlowsPerChange) { - throw new ResourceConflictException( - String.format("Too many flows (max %s flow allowed per change)", maxFlowsPerChange)); - } + checkLimits(changeResource); FlowCreation flowCreation = FlowJson.createFlowCreation( @@ -102,4 +107,37 @@ throw new BadRequestException(e.getMessage(), e); } } + + private void checkLimits(ChangeResource changeResource) + throws MethodNotAllowedException, ResourceConflictException { + ImmutableList<Flow> flows = + flowServiceUtil + .getFlowServiceOrThrow() + .listFlows(changeResource.getProject(), changeResource.getId()); + int maxFlowsPerChange = + cfgProvider.get().getInt("flows", "maxPerChange", DEFAULT_MAX_FLOWS_PER_CHANGE); + if (maxFlowsPerChange > 0 && flows.size() >= maxFlowsPerChange) { + throw new ResourceConflictException( + String.format("Too many flows (max %s flows allowed per change)", maxFlowsPerChange)); + } + + int maxPendingFlowsPerChange = + cfgProvider + .get() + .getInt("flows", "maxPendingPerChange", DEFAULT_MAX_PENDING_FLOWS_PER_CHANGE); + if (maxPendingFlowsPerChange > 0 + && flows.stream() + .filter( + flow -> + flow.stages().stream() + .anyMatch(stage -> State.PENDING.equals(stage.status().state()))) + .collect(toImmutableList()) + .size() + >= maxPendingFlowsPerChange) { + throw new ResourceConflictException( + String.format( + "Too many pending flows (max %s pending flows allowed per change)", + maxPendingFlowsPerChange)); + } + } }
diff --git a/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java b/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java index 442e6b1..9abf521 100644 --- a/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java +++ b/java/com/google/gerrit/server/restapi/flow/FlowRestApiModule.java
@@ -35,5 +35,6 @@ delete(FLOW_KIND).to(DeleteFlow.class); get(CHANGE_KIND, "is-flows-enabled").to(IsFlowsEnabled.class); + get(CHANGE_KIND, "flows-actions").to(ListActions.class); } }
diff --git a/java/com/google/gerrit/server/restapi/flow/ListActions.java b/java/com/google/gerrit/server/restapi/flow/ListActions.java new file mode 100644 index 0000000..f54ab4b --- /dev/null +++ b/java/com/google/gerrit/server/restapi/flow/ListActions.java
@@ -0,0 +1,59 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.flow; + +import com.google.gerrit.extensions.common.FlowActionTypeInfo; +import com.google.gerrit.extensions.restapi.MethodNotAllowedException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestReadView; +import com.google.gerrit.server.change.ChangeResource; +import com.google.gerrit.server.flow.FlowServiceUtil; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.util.List; +import java.util.stream.Collectors; + +/** + * REST endpoint to list actions of a change. + * + * <p>This REST endpoint handles {@code GET /change/<change-id>/actions} requests. + */ +@Singleton +public class ListActions implements RestReadView<ChangeResource> { + private final FlowServiceUtil flowServiceUtil; + + @Inject + ListActions(FlowServiceUtil flowServiceUtil) { + this.flowServiceUtil = flowServiceUtil; + } + + @Override + public Response<List<FlowActionTypeInfo>> apply(ChangeResource changeResource) + throws MethodNotAllowedException { + return Response.ok( + flowServiceUtil + .getFlowServiceOrThrow() + .listActions(changeResource.getProject(), changeResource.getId()) + .stream() + .map( + flowAction -> { + FlowActionTypeInfo flowActionTypeInfo = new FlowActionTypeInfo(); + flowActionTypeInfo.name = flowAction.name(); + flowActionTypeInfo.parametersPlaceholder = flowAction.parametersPlaceholder(); + return flowActionTypeInfo; + }) + .collect(Collectors.toList())); + } +}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java index 66ca854..4dda2ec 100644 --- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java +++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -123,13 +123,13 @@ String.format( "user %s lacks permission %s for %s in project %s", match, input.permission, input.ref, rsrc.getName()))); - } else { - // We say access is okay if there are no refs, but this warrants a warning, - // as access denied looks the same as no branches to the user. - try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) { - if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) { - message = "access is OK, but repository has no branches under refs/heads/"; - } + } + + // We say access is okay if there are no refs, but this warrants a warning, + // as access denied looks the same as no branches to the user. + try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) { + if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) { + message = "access is OK, but repository has no branches under refs/heads/"; } } return Response.ok(createInfo(HttpServletResponse.SC_OK, message));
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java index 25fe405..60eb46e 100644 --- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java +++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -140,12 +140,6 @@ "Error: garbage collection for project \"" + e.getProjectName() + "\" failed."; - default -> - "Error: garbage collection for project \"" - + e.getProjectName() - + "\" failed: " - + e.getType() - + "."; }; } }
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java new file mode 100644 index 0000000..44f7ba2 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelFunctionsToSubmitRequirement.java
@@ -0,0 +1,350 @@ +// 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.project; + +import static com.google.gerrit.server.project.ProjectConfig.RULES_PL_FILE; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.common.Nullable; +import com.google.gerrit.entities.LabelFunction; +import com.google.gerrit.entities.LabelType; +import com.google.gerrit.entities.LabelValue; +import com.google.gerrit.entities.Project; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.entities.SubmitRequirement; +import com.google.gerrit.entities.SubmitRequirementExpression; +import com.google.gerrit.extensions.restapi.MethodNotAllowedException; +import com.google.gerrit.server.git.GitRepositoryManager; +import com.google.gerrit.server.permissions.PermissionBackendException; +import com.google.gerrit.server.project.ProjectConfig; +import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigUpdater; +import com.google.gerrit.server.schema.UpdateUI; +import com.google.inject.Inject; +import java.io.IOException; +import java.util.Collection; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.eclipse.jgit.errors.ConfigInvalidException; +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; +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 RepoMetaDataUpdater repoMetaDataUpdater; + private final GitRepositoryManager repoManager; + + 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( + RepoMetaDataUpdater repoMetaDataUpdater, GitRepositoryManager repoManager) { + this.repoMetaDataUpdater = repoMetaDataUpdater; + this.repoManager = repoManager; + } + + /** + * 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, + MethodNotAllowedException, + PermissionBackendException { + try (ConfigUpdater updater = + repoMetaDataUpdater.configUpdaterWithoutPermissionsCheck(project, null, COMMIT_MSG)) { + Status result = updateConfig(project, updater.getConfig(), ui); + if (result == Status.MIGRATED) { + updater.commitConfigUpdate(); + } + return result; + } + } + + public Status updateConfig(Project.NameKey project, ProjectConfig projectConfig, UpdateUI ui) + throws IOException { + boolean updated = false; + if (hasPrologRules(project)) { + ui.message(String.format("Skipping project %s because it has prolog rules", project)); + return Status.HAS_PROLOG; + } + + if (hasMigrationAlreadyRun(project)) { + 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; + } + + Map<String, LabelType> labelSections = projectConfig.getLabelSections(); + SubmitRequirementMap existingSubmitRequirements = + new SubmitRequirementMap(projectConfig.getSubmitRequirementSections()); + + for (Map.Entry<String, LabelType> section : labelSections.entrySet()) { + String labelName = section.getKey(); + LabelType labelType = section.getValue(); + + if (labelType.getFunction() == LabelFunction.PATCH_SET_LOCK) { + // 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 (labelType.getFunction() != LabelFunction.NO_BLOCK) { + section.setValue(labelType.toBuilder().setNoBlockFunction().build()); + updated = true; + } + + Optional<SubmitRequirement> sr = createSrFromLabelDef(labelType); + 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)) { + SubmitRequirement existing = existingSubmitRequirements.get(labelName); + if (!sr.get().equals(existing)) { + 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)); + existingSubmitRequirements.put(sr.get()); + } + return updated ? Status.MIGRATED : Status.NO_CHANGE; + } + + private static Optional<SubmitRequirement> createSrFromLabelDef(LabelType lt) { + if (isLabelSkipped(lt)) { + return Optional.of(createNonApplicableSr(lt)); + } else if (isBlockingOrRequiredLabel(lt)) { + return Optional.of(createBlockingOrRequiredSr(lt)); + } + return Optional.empty(); + } + + private static SubmitRequirement createNonApplicableSr(LabelType lt) { + return SubmitRequirement.builder() + .setName(lt.getName()) + .setApplicabilityExpression(SubmitRequirementExpression.of("is:false")) + .setSubmittabilityExpression(SubmitRequirementExpression.create("is:true")) + .setAllowOverrideInChildProjects(lt.isCanOverride()) + .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(LabelType lt) { + SubmitRequirement.Builder builder = + SubmitRequirement.builder() + .setName(lt.getName()) + .setAllowOverrideInChildProjects(lt.isCanOverride()); + String maxPart = + String.format("label:%s=MAX", lt.getName()) + + (lt.isIgnoreSelfApproval() ? ",user=non_uploader" : ""); + switch (lt.getFunction()) { + case MAX_WITH_BLOCK -> + builder.setSubmittabilityExpression( + SubmitRequirementExpression.create( + String.format("%s AND -label:%s=MIN", maxPart, lt.getName()))); + case ANY_WITH_BLOCK -> + builder.setSubmittabilityExpression( + SubmitRequirementExpression.create(String.format("-label:%s=MIN", lt.getName()))); + case MAX_NO_BLOCK -> + builder.setSubmittabilityExpression(SubmitRequirementExpression.create(maxPart)); + case NO_BLOCK -> {} + case NO_OP -> {} + case PATCH_SET_LOCK -> {} + default -> {} + } + ImmutableList<String> refPatterns = lt.getRefPatterns(); + if (refPatterns != null && !refPatterns.isEmpty()) { + builder.setApplicabilityExpression( + SubmitRequirementExpression.of( + String.join( + " OR ", + lt.getRefPatterns().stream() + .map(b -> "branch:\\\"" + b + "\\\"") + .collect(Collectors.toList())))); + } + return builder.build(); + } + + private static boolean isBlockingOrRequiredLabel(LabelType lt) { + return switch (lt.getFunction()) { + case ANY_WITH_BLOCK, MAX_WITH_BLOCK, MAX_NO_BLOCK -> true; + case NO_BLOCK, NO_OP, PATCH_SET_LOCK -> false; + }; + } + + private static boolean isLabelSkipped(LabelType lt) { + ImmutableList<LabelValue> values = lt.getValues(); + return values.isEmpty() || (values.size() == 1 && values.get(0).getValue() == 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; + } + } + + private boolean hasMigrationAlreadyRun(Project.NameKey project) throws IOException { + try (Repository repo = repoManager.openRepository(project)) { + 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; + } + } + } + + /** + * Helper "Map" to of submit requirements with case-preserving keys and case-insensitive lookup + */ + private static class SubmitRequirementMap { + private final Map<String, SubmitRequirement> submitRequirements; + private final Map<String, String> lowerCaseToOriginalNames; + + SubmitRequirementMap(Map<String, SubmitRequirement> submitRequirements) { + this.submitRequirements = submitRequirements; + this.lowerCaseToOriginalNames = + submitRequirements.keySet().stream() + .collect(Collectors.toMap(k -> k.toLowerCase(Locale.ROOT), k -> k)); + } + + boolean containsKey(String name) { + return lowerCaseToOriginalNames.containsKey(name.toLowerCase(Locale.ROOT)); + } + + @Nullable + SubmitRequirement get(String name) { + String orig = lowerCaseToOriginalNames.get(name.toLowerCase(Locale.ROOT)); + return orig != null ? submitRequirements.get(orig) : null; + } + + void put(SubmitRequirement sr) { + String name = sr.name(); + submitRequirements.put(name, sr); + lowerCaseToOriginalNames.put(name.toLowerCase(Locale.ROOT), name); + } + } +}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabels.java b/java/com/google/gerrit/server/restapi/project/MigrateLabels.java new file mode 100644 index 0000000..5fce7f9 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/MigrateLabels.java
@@ -0,0 +1,83 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.restapi.project; + +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.server.permissions.PermissionBackend; +import com.google.gerrit.server.permissions.ProjectPermission; +import com.google.gerrit.server.project.ProjectResource; +import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement; +import com.google.gerrit.server.schema.UpdateUI; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.util.Set; + +@Singleton +public class MigrateLabels implements RestModifyView<ProjectResource, MigrateLabelsInput> { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + private final MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement; + private final PermissionBackend permissionBackend; + + @Inject + MigrateLabels( + MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement, + PermissionBackend permissionBackend) { + this.migrateLabelFunctionsToSubmitRequirement = migrateLabelFunctionsToSubmitRequirement; + this.permissionBackend = permissionBackend; + } + + @Override + public Response<MigrateLabelsInfo> apply(ProjectResource rsrc, MigrateLabelsInput input) + throws Exception { + Project.NameKey project = rsrc.getNameKey(); + permissionBackend.currentUser().project(project).check(ProjectPermission.WRITE_CONFIG); + MigrateLabelFunctionsToSubmitRequirement.Status status = + migrateLabelFunctionsToSubmitRequirement.executeMigration(project, new LoggingUpdateUI()); + + MigrateLabelsInfo info = new MigrateLabelsInfo(); + info.status = status; + return Response.ok(info); + } + + public static class LoggingUpdateUI implements UpdateUI { + + @Override + public void message(String message) { + logger.atInfo().log("%s", message); + } + + @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/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java new file mode 100644 index 0000000..b6a2920 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInfo.java
@@ -0,0 +1,21 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.restapi.project; + +import com.google.gerrit.server.schema.MigrateLabelFunctionsToSubmitRequirement; + +public class MigrateLabelsInfo { + public MigrateLabelFunctionsToSubmitRequirement.Status status; +}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java new file mode 100644 index 0000000..d010a9d --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsInput.java
@@ -0,0 +1,17 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.restapi.project; + +public class MigrateLabelsInput {}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java new file mode 100644 index 0000000..a0aa70a --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReview.java
@@ -0,0 +1,60 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.restapi.project; + +import static com.google.gerrit.server.restapi.project.MigrateLabelFunctionsToSubmitRequirement.Status.MIGRATED; + +import com.google.gerrit.extensions.restapi.AuthException; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.extensions.restapi.Response; +import com.google.gerrit.extensions.restapi.RestModifyView; +import com.google.gerrit.server.project.ProjectResource; +import com.google.gerrit.server.restapi.project.RepoMetaDataUpdater.ConfigChangeCreator; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +@Singleton +public class MigrateLabelsReview implements RestModifyView<ProjectResource, MigrateLabelsInput> { + + private final RepoMetaDataUpdater repoMetaDataUpdater; + private final MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement; + + @Inject + MigrateLabelsReview( + RepoMetaDataUpdater repoMetaDataUpdater, + MigrateLabelFunctionsToSubmitRequirement migrateLabelFunctionsToSubmitRequirement) { + this.repoMetaDataUpdater = repoMetaDataUpdater; + this.migrateLabelFunctionsToSubmitRequirement = migrateLabelFunctionsToSubmitRequirement; + } + + @Override + public Response<MigrateLabelsReviewInfo> apply(ProjectResource rsrc, MigrateLabelsInput input) + throws AuthException, BadRequestException, ResourceConflictException, Exception { + try (ConfigChangeCreator creator = + repoMetaDataUpdater.configChangeCreator( + rsrc.getNameKey(), null, MigrateLabelFunctionsToSubmitRequirement.COMMIT_MSG)) { + MigrateLabelFunctionsToSubmitRequirement.Status status = + migrateLabelFunctionsToSubmitRequirement.updateConfig( + rsrc.getProjectState().getNameKey(), + creator.getConfig(), + new MigrateLabels.LoggingUpdateUI()); + if (status == MIGRATED) { + return Response.ok(new MigrateLabelsReviewInfo(MIGRATED, creator.createChange().value())); + } + return Response.ok(new MigrateLabelsReviewInfo(status)); + } + } +}
diff --git a/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java new file mode 100644 index 0000000..dcf0fd0 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/MigrateLabelsReviewInfo.java
@@ -0,0 +1,32 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.restapi.project; + +import com.google.gerrit.extensions.common.ChangeInfo; + +public class MigrateLabelsReviewInfo { + public MigrateLabelFunctionsToSubmitRequirement.Status status; + public ChangeInfo change; + + public MigrateLabelsReviewInfo( + MigrateLabelFunctionsToSubmitRequirement.Status status, ChangeInfo change) { + this.status = status; + this.change = change; + } + + public MigrateLabelsReviewInfo(MigrateLabelFunctionsToSubmitRequirement.Status status) { + this(status, null); + } +}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectAccessModifier.java b/java/com/google/gerrit/server/restapi/project/ProjectAccessModifier.java new file mode 100644 index 0000000..f404ec5 --- /dev/null +++ b/java/com/google/gerrit/server/restapi/project/ProjectAccessModifier.java
@@ -0,0 +1,48 @@ +package com.google.gerrit.server.restapi.project; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.entities.AccessSection; +import com.google.gerrit.exceptions.InvalidNameException; +import com.google.gerrit.extensions.api.access.AccessSectionInfo; +import com.google.gerrit.extensions.restapi.BadRequestException; +import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.server.project.ProjectConfig; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.util.List; +import java.util.Map; + +/** + * Helper to modify the project config of a project. + * + * <p>This class processes a {@link ProjectAccessInput} to update the access sections of a project. + * It handles the validation of the input, resolution of access sections, and application of + * additions and removals of permissions. The changes are committed using the provided {@link + * RepoMetaDataUpdater}. + */ +@Singleton +public class ProjectAccessModifier { + private final SetAccessUtil setAccessUtil; + + @Inject + ProjectAccessModifier(SetAccessUtil setAccessUtil) { + this.setAccessUtil = setAccessUtil; + } + + public ImmutableList<AccessSection> getAccessSections( + Map<String, AccessSectionInfo> sectionInfos, boolean rejectNonResolvableGroups) + throws UnprocessableEntityException { + return setAccessUtil.getAccessSections(sectionInfos, rejectNonResolvableGroups); + } + + public void validateChanges( + ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) + throws BadRequestException, InvalidNameException { + setAccessUtil.validateChanges(config, removals, additions); + } + + public void applyChanges( + ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) { + setAccessUtil.applyChanges(config, removals, additions); + } +}
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java index f5647ec..adba60e 100644 --- a/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java +++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -87,6 +87,9 @@ put(PROJECT_KIND, "config").to(PutConfig.class); put(PROJECT_KIND, "config:review").to(PutConfigReview.class); + post(PROJECT_KIND, "migrate-labels").to(MigrateLabels.class); + post(PROJECT_KIND, "migrate-labels:review").to(MigrateLabelsReview.class); + post(PROJECT_KIND, "create.change").to(CreateChange.class); child(PROJECT_KIND, "dashboards").to(DashboardsCollection.class);
diff --git a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java index a34b7d8..62c88276 100644 --- a/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java +++ b/java/com/google/gerrit/server/restapi/project/RepoMetaDataUpdater.java
@@ -42,6 +42,7 @@ import com.google.gerrit.server.permissions.PermissionBackendException; import com.google.gerrit.server.permissions.ProjectPermission; import com.google.gerrit.server.permissions.RefPermission; +import com.google.gerrit.server.project.ContributorAgreementsChecker; import com.google.gerrit.server.project.ProjectCache; import com.google.gerrit.server.project.ProjectConfig; import com.google.gerrit.server.update.BatchUpdate; @@ -75,6 +76,7 @@ private final PermissionBackend permissionBackend; private final ChangeJson.Factory jsonFactory; + private final ContributorAgreementsChecker contributorAgreements; @Inject RepoMetaDataUpdater( @@ -86,7 +88,8 @@ Sequences seq, BatchUpdate.Factory updateFactory, PermissionBackend permissionBackend, - ChangeJson.Factory jsonFactory) { + ChangeJson.Factory jsonFactory, + ContributorAgreementsChecker contributorAgreements) { this.metaDataUpdateFactory = metaDataUpdateFactory; this.user = user; this.projectConfigFactory = projectConfigFactory; @@ -96,6 +99,7 @@ this.updateFactory = updateFactory; this.permissionBackend = permissionBackend; this.jsonFactory = jsonFactory; + this.contributorAgreements = contributorAgreements; } /** @@ -125,6 +129,8 @@ public ConfigChangeCreator configChangeCreator( Project.NameKey projectName, @Nullable String message, String defaultMessage) throws PermissionBackendException, AuthException, IOException, ConfigInvalidException { + contributorAgreements.check(projectName, user.get()); + message = validateMessage(message, defaultMessage); PermissionBackend.ForProject forProject = permissionBackend.user(user.get()).project(projectName);
diff --git a/java/com/google/gerrit/server/schema/BUILD b/java/com/google/gerrit/server/schema/BUILD index b344e6d..8d57ff0 100644 --- a/java/com/google/gerrit/server/schema/BUILD +++ b/java/com/google/gerrit/server/schema/BUILD
@@ -20,7 +20,6 @@ "//java/com/google/gerrit/server/logging", "//lib:guava", "//lib:jgit", - "//lib:jgit-archive", "//lib/auto:auto-value", "//lib/auto:auto-value-annotations", "//lib/commons:dbcp",
diff --git a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java index fcd50f6..06c2037 100644 --- a/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java +++ b/java/com/google/gerrit/server/schema/MigrateLabelFunctionsToSubmitRequirement.java
@@ -310,7 +310,7 @@ .setAllowOverrideInChildProjects(attributes.canOverride()); String maxPart = String.format("label:%s=MAX", labelName) - + (attributes.ignoreSelfApproval() ? ",user=non_uploader" : ""); + + (attributes.ignoreSelfApproval() ? "&user=non_uploader" : ""); switch (attributes.function()) { case "MaxWithBlock" -> builder.setSubmittabilityExpression(
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java index 4bd6f3d..25c7566 100644 --- a/java/com/google/gerrit/server/submit/EmailMerge.java +++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -24,6 +24,7 @@ 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.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.ChangeEmail; @@ -59,6 +60,7 @@ private final EmailFactories emailFactories; private final ThreadLocalRequestContext requestContext; private final MessageIdGenerator messageIdGenerator; + private final boolean sendEmailEnabled; private final Project.NameKey project; private final Change change; @@ -71,6 +73,7 @@ @Inject EmailMerge( @SendEmailExecutor ExecutorService executor, + @SendEmailEnabled Boolean sendEmailEnabled, EmailFactories emailFactories, ThreadLocalRequestContext requestContext, MessageIdGenerator messageIdGenerator, @@ -82,6 +85,7 @@ @Assisted String stickyApprovalDiff, @Assisted List<FileDiffOutput> modifiedFiles) { this.sendEmailsExecutor = executor; + this.sendEmailEnabled = sendEmailEnabled; this.emailFactories = emailFactories; this.requestContext = requestContext; this.messageIdGenerator = messageIdGenerator; @@ -95,6 +99,9 @@ } void sendAsync() { + if (!sendEmailEnabled) { + return; + } @SuppressWarnings("unused") Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this); }
diff --git a/java/com/google/gerrit/server/submit/MergeMetrics.java b/java/com/google/gerrit/server/submit/MergeMetrics.java index ed07f2f..ea9e678 100644 --- a/java/com/google/gerrit/server/submit/MergeMetrics.java +++ b/java/com/google/gerrit/server/submit/MergeMetrics.java
@@ -25,13 +25,13 @@ 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; + private final SubmitRequirementChangeQueryBuilder.Factory + submitRequirementChangequeryBuilderFactory; // 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 @@ -40,10 +40,9 @@ @Inject public MergeMetrics( - Provider<SubmitRequirementChangeQueryBuilder> submitRequirementChangequeryBuilder, + SubmitRequirementChangeQueryBuilder.Factory submitRequirementChangequeryBuilderFactory, MetricMaker metricMaker) { - this.submitRequirementChangequeryBuilder = submitRequirementChangequeryBuilder; - + this.submitRequirementChangequeryBuilderFactory = submitRequirementChangequeryBuilderFactory; this.countChangesThatWereSubmittedWithRebaserApproval = metricMaker.newCounter( "change/submitted_with_rebaser_approval", @@ -121,8 +120,8 @@ for (SubmitRequirement submitRequirement : cd.submitRequirements().keySet()) { try { Predicate<ChangeData> predicate = - submitRequirementChangequeryBuilder - .get() + submitRequirementChangequeryBuilderFactory + .create(false) .parse(submitRequirement.submittabilityExpression().expressionString()); boolean ignoresCodeReviewApprovalsOfUploader = ignoresCodeReviewApprovalsOfUploader(predicate);
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java index 82efa23..0a45852 100644 --- a/java/com/google/gerrit/server/submit/MergeOp.java +++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -36,6 +36,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.Iterables; import com.google.common.collect.ListMultimap; import com.google.common.collect.MultimapBuilder; import com.google.common.collect.SetMultimap; @@ -43,11 +44,13 @@ 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.Change.Status; import com.google.gerrit.entities.PatchSet; +import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.entities.Project; import com.google.gerrit.entities.SubmissionId; import com.google.gerrit.entities.SubmitRecord; @@ -73,16 +76,20 @@ import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.IdentifiedUser.ImpersonationPermissionMode; import com.google.gerrit.server.InternalUser; +import com.google.gerrit.server.account.AccountCache; import com.google.gerrit.server.change.NotifyResolver; import com.google.gerrit.server.config.ConfigUtil; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.experiments.ExperimentFeatures; +import com.google.gerrit.server.experiments.ExperimentFeaturesConstants; import com.google.gerrit.server.git.CodeReviewCommit; import com.google.gerrit.server.git.MergeTip; import com.google.gerrit.server.git.validators.MergeValidationException; import com.google.gerrit.server.git.validators.MergeValidators; +import com.google.gerrit.server.logging.Metadata; import com.google.gerrit.server.logging.RequestId; import com.google.gerrit.server.logging.TraceContext; +import com.google.gerrit.server.logging.TraceContext.TraceTimer; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp; import com.google.gerrit.server.permissions.ChangePermission; @@ -93,6 +100,7 @@ import com.google.gerrit.server.project.SubmitRuleOptions; import com.google.gerrit.server.query.change.ChangeData; import com.google.gerrit.server.query.change.InternalChangeQuery; +import com.google.gerrit.server.submit.MergeOp.CommitStatus; import com.google.gerrit.server.submit.MergeOpRepoManager.OpenBranch; import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo; import com.google.gerrit.server.update.BatchUpdate; @@ -313,6 +321,7 @@ private final Map<Change.Id, Change> updatedChanges; private final ExperimentFeatures experimentFeatures; + private final AccountCache accountCache; private final ProjectCache projectCache; private final long hasImplicitMergeTimeoutSeconds; @@ -352,6 +361,7 @@ MergeMetrics mergeMetrics, ProjectCache projectCache, ExperimentFeatures experimentFeatures, + AccountCache accountCache, @GerritServerConfig Config config, PermissionBackend permissionBackend) { this.cmUtil = cmUtil; @@ -375,6 +385,7 @@ this.mergeMetrics = mergeMetrics; this.projectCache = projectCache; this.experimentFeatures = experimentFeatures; + this.accountCache = accountCache; // Undocumented - experimental, can be removed. hasImplicitMergeTimeoutSeconds = ConfigUtil.getTimeUnit( @@ -396,46 +407,49 @@ * @param cd change that is being checked * @throws ResourceConflictException the exception that is thrown if the SR is not fulfilled */ - public static void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException { - PatchSet patchSet = cd.currentPatchSet(); - if (patchSet == null) { - throw new ResourceConflictException("missing current patch set for change " + cd.getId()); - } - Map<SubmitRequirement, SubmitRequirementResult> srResults = - cd.submitRequirementsIncludingLegacy(); - if (srResults.values().stream().allMatch(SubmitRequirementResult::fulfilled)) { - return; - } else if (srResults.isEmpty()) { - throw new IllegalStateException( - String.format( - "Submit requirement results for change '%s' and patchset '%s' " - + "are empty in project '%s'", - cd.getId(), patchSet.id(), cd.change().getProject().get())); - } - - for (SubmitRequirementResult srResult : srResults.values()) { - switch (srResult.status()) { - case SATISFIED, NOT_APPLICABLE, OVERRIDDEN, FORCED -> {} - case ERROR -> - throw new ResourceConflictException( - String.format( - "submit requirement '%s' has an error: %s", - srResult.submitRequirement().name(), srResult.errorMessage().orElse(""))); - case UNSATISFIED -> - throw new ResourceConflictException( - String.format( - "submit requirement '%s' is unsatisfied.", - srResult.submitRequirement().name())); - default -> - throw new IllegalStateException( - String.format( - "Unexpected submit requirement status %s for %s in %s", - srResult.status().name(), - patchSet.id().getId(), - cd.change().getProject().get())); + public void checkSubmitRequirements(ChangeData cd) throws ResourceConflictException { + try (TraceTimer timer = + TraceContext.newTimer( + "MergeOp#checkSubmitRequirements", + Metadata.builder().changeId(cd.getId().get()).build())) { + if (!experimentFeatures.isFeatureEnabled( + ExperimentFeaturesConstants.CONSIDER_VOTES_OF_DELETED_ACCOUNTS)) { + cd.setCurrentApprovals( + ImmutableList.copyOf(filterOutApprovalsOfDeletedAccounts(cd.currentApprovals()))); } + PatchSet patchSet = cd.currentPatchSet(); + if (patchSet == null) { + throw new ResourceConflictException("missing current patch set for change " + cd.getId()); + } + Map<SubmitRequirement, SubmitRequirementResult> srResults = + cd.submitRequirementsIncludingLegacy(); + if (srResults.values().stream().allMatch(SubmitRequirementResult::fulfilled)) { + return; + } else if (srResults.isEmpty()) { + throw new IllegalStateException( + String.format( + "Submit requirement results for change '%s' and patchset '%s' " + + "are empty in project '%s'", + cd.getId(), patchSet.id(), cd.change().getProject().get())); + } + + for (SubmitRequirementResult srResult : srResults.values()) { + switch (srResult.status()) { + case SATISFIED, NOT_APPLICABLE, OVERRIDDEN, FORCED -> {} + case ERROR -> + throw new ResourceConflictException( + String.format( + "submit requirement '%s' has an error: %s", + srResult.submitRequirement().name(), srResult.errorMessage().orElse(""))); + case UNSATISFIED -> + throw new ResourceConflictException( + String.format( + "submit requirement '%s' is unsatisfied.", + srResult.submitRequirement().name())); + } + } + throw new IllegalStateException(); } - throw new IllegalStateException(); } private static SubmitRuleOptions submitRuleOptions(boolean allowClosed) { @@ -458,7 +472,7 @@ } } - private static void addProblemForChange( + private void addProblemForChange( Change.Id triggeringChangeId, ChangeData cd, boolean allowMerged, @@ -470,9 +484,7 @@ permissionBackend .user(caller) .change(cd) - .test( - EnumSet.of( - ChangePermission.READ, ChangePermission.SUBMIT, ChangePermission.SUBMIT_AS)); + .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT)); String callerName = caller.getUserForPermission().getLoggableName(); if (!can.contains(ChangePermission.READ)) { // The READ permission should already be handled during generation of ChangeSet, however @@ -541,7 +553,13 @@ impersonatedName, cd.getId().get()))); return; } - if (!can.contains(ChangePermission.SUBMIT_AS)) { + // THIS_USER: Permissions are checked against the impersonated user. + // REAL_USER: Permissions are checked against the user who initiated the impersonation. + // Verify the user doing the impersonation has SUBMIT_AS permission + if (!permissionBackend + .user(caller, ImpersonationPermissionMode.REAL_USER) + .change(cd) + .test(ChangePermission.SUBMIT_AS)) { if (triggeringChangeId.get() != cd.getId().get()) { logger.atFine().log( "Change %d cannot be submitted by user %s on behalf of user %s because it depends" @@ -613,7 +631,7 @@ * case of impersonation {@code caller.getRealUser()} contains the user triggering the merge. * @return List of problems preventing merge */ - public static ImmutableList<ChangeProblem> checkCommonSubmitProblems( + public ImmutableList<ChangeProblem> checkCommonSubmitProblems( Change triggeringChange, ChangeSet cs, boolean allowMerged, @@ -641,12 +659,36 @@ private void checkSubmitRulesAndState(Change triggeringChange, ChangeSet cs, boolean allowMerged) throws ResourceConflictException { + if (!experimentFeatures.isFeatureEnabled( + ExperimentFeaturesConstants.CONSIDER_VOTES_OF_DELETED_ACCOUNTS)) { + for (ChangeData cd : cs.changes()) { + cd.setCurrentApprovals( + ImmutableList.copyOf(filterOutApprovalsOfDeletedAccounts(cd.currentApprovals()))); + } + } checkCommonSubmitProblems(triggeringChange, cs, allowMerged, permissionBackend, caller).stream() .forEach(cp -> commitStatus.problem(cp.getChangeId(), cp.getProblem())); commitStatus.maybeFailVerbose(); mergeMetrics.countChangesThatWereSubmittedWithRebaserApproval(cs); } + public Iterable<PatchSetApproval> filterOutApprovalsOfDeletedAccounts( + Iterable<PatchSetApproval> psas) { + if (experimentFeatures.isFeatureEnabled( + ExperimentFeaturesConstants.CONSIDER_VOTES_OF_DELETED_ACCOUNTS)) { + return psas; + } + + try (TraceTimer traceTimer = + TraceContext.newTimer("Filtering out approvals of deleted accounts", Metadata.empty())) { + return Iterables.filter(psas, psa -> !isDeletedAccount(psa.accountId())); + } + } + + private boolean isDeletedAccount(Account.Id accountId) { + return !accountCache.get(accountId).isPresent(); + } + private void bypassSubmitRulesAndRequirements(ChangeSet cs) { checkArgument( !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java index 906e519..f133c74 100644 --- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java +++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -138,7 +138,6 @@ } commitStatus.problem(id, CharMatcher.is('\n').collapseFrom(message, ' ')); } - default -> commitStatus.problem(id, "unspecified merge failure: " + s); } } commitStatus.maybeFailVerbose();
diff --git a/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java b/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java index 389c7f4..25c8087 100644 --- a/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java +++ b/java/com/google/gerrit/server/submitrequirement/predicate/SubmitRequirementLabelExtensionPredicate.java
@@ -55,16 +55,24 @@ SubmitRequirementLabelExtensionPredicate create(String value) throws QueryParseException; } + private static final Pattern PATTERN_AND = + Pattern.compile("(?<label>[^&]*)&users=human_reviewers$"); + private static final Pattern PATTERN_AND_LABEL = + Pattern.compile("(?<label>[^&<>=]*)(?<op>=|<=|>=|<|>)(?<value>[^&]*)"); + + @Deprecated private static final Pattern PATTERN = Pattern.compile("(?<label>[^,]*),users=human_reviewers$"); + + @Deprecated private static final Pattern PATTERN_LABEL = Pattern.compile("(?<label>[^,<>=]*)(?<op>=|<=|>=|<|>)(?<value>[^,]*)"); public static boolean matches(String value) { - return PATTERN.matcher(value).matches(); + return PATTERN.matcher(value).matches() || PATTERN_AND.matcher(value).matches(); } public static void validateIfNoMatch(String value) throws QueryParseException { - if (value.contains(",users=")) { + if (value.contains(",users=") || value.contains("&users=")) { throw new QueryParseException( "Cannot use the 'users' argument in conjunction with other arguments ('count', 'user'," + " group')"); @@ -83,7 +91,14 @@ this.args = args; this.serviceUserClassifier = serviceUserClassifier; - Matcher m = PATTERN.matcher(value); + Matcher m; + + if (value.contains("&")) { + m = PATTERN_AND.matcher(value); + } else { + m = PATTERN.matcher(value); + } + if (!m.matches()) { throw new QueryParseException( String.format("invalid value for '%s': %s", getOperator(), value)); @@ -163,7 +178,12 @@ } private boolean matchZeroVotes(String label) { - Matcher m = PATTERN_LABEL.matcher(label); + Matcher m; + if (label.contains("&")) { + m = PATTERN_AND_LABEL.matcher(label); + } else { + m = PATTERN_LABEL.matcher(label); + } if (!m.matches()) { return false; }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java index 9879d3d..76bf5ef 100644 --- a/java/com/google/gerrit/server/update/BatchUpdate.java +++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -55,6 +55,7 @@ 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.account.ServiceUserClassifier; import com.google.gerrit.server.change.NotifyResolver; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.extensions.events.AttentionSetObserver; @@ -293,6 +294,7 @@ private final ChangeIndexer indexer; private final GitReferenceUpdated gitRefUpdated; private final RefLogIdentityProvider refLogIdentityProvider; + private final ServiceUserClassifier serviceUserClassifier; private final Project.NameKey project; private final CurrentUser user; @@ -331,6 +333,7 @@ ChangeIndexer indexer, GitReferenceUpdated gitRefUpdated, RefLogIdentityProvider refLogIdentityProvider, + ServiceUserClassifier serviceUserClassifier, AttentionSetObserver attentionSetObserver, @GerritServerConfig Config gerritConfig, @Assisted Project.NameKey project, @@ -347,6 +350,7 @@ this.indexer = indexer; this.gitRefUpdated = gitRefUpdated; this.refLogIdentityProvider = refLogIdentityProvider; + this.serviceUserClassifier = serviceUserClassifier; this.attentionSetObserver = attentionSetObserver; this.project = project; this.user = user; @@ -560,8 +564,15 @@ // return false. @UsedAt(GOOGLE) private boolean indexAsync() { - return user.getAccessPath().equals(AccessPath.WEB_BROWSER) - && gerritConfig.getBoolean("index", "indexChangesAsync", false); + if (!gerritConfig.getBoolean("index", "indexChangesAsync", false)) { + return false; + } + + if (user.getAccessPath().equals(AccessPath.WEB_BROWSER)) { + return true; + } + + return user.isIdentifiedUser() && !serviceUserClassifier.isServiceUser(user.getAccountId()); } void fireRefChangeEvents() { @@ -636,7 +647,6 @@ case UPSERTED -> indexFutures.add(indexer.indexAsync(project, id)); case DELETED -> indexFutures.add(indexer.deleteAsync(project, id)); case SKIPPED -> {} - default -> throw new IllegalStateException("unexpected result: " + e.getValue()); } } if (indexAsync) { @@ -795,7 +805,7 @@ PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas); try (TraceContext.TraceTimer ignored = TraceContext.newTimer( - opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) { + opData.op().getClass().getSimpleName() + "#postUpdate", Metadata.empty())) { opData.op().postUpdate(ctx); } } @@ -804,7 +814,7 @@ PostUpdateContextImpl ctx = new PostUpdateContextImpl(opData.user(), changeDatas); try (TraceContext.TraceTimer ignored = TraceContext.newTimer( - opData.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) { + opData.op().getClass().getSimpleName() + "#postUpdate", Metadata.empty())) { opData.op().postUpdate(ctx); } }
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java index 95fc246..e6a5f3d 100644 --- a/java/com/google/gerrit/server/util/AttentionSetEmail.java +++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -17,12 +17,15 @@ import static com.google.gerrit.server.mail.EmailFactories.ATTENTION_SET_ADDED; import static com.google.gerrit.server.mail.EmailFactories.ATTENTION_SET_REMOVED; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; import com.google.common.flogger.FluentLogger; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.Change; import com.google.gerrit.entities.Project; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.change.NotifyResolver; +import com.google.gerrit.server.config.SendEmailEnabled; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.mail.EmailFactories; import com.google.gerrit.server.mail.send.AttentionSetChangeEmailDecorator; @@ -63,11 +66,13 @@ } private final ExecutorService sendEmailsExecutor; - private final AsyncSender asyncSender; + private final Supplier<AsyncSender> asyncSenderSupplier; + private final boolean sendEmailEnabled; @Inject AttentionSetEmail( @SendEmailExecutor ExecutorService executor, + @SendEmailEnabled Boolean sendEmailEnabled, ThreadLocalRequestContext requestContext, MessageIdGenerator messageIdGenerator, AccountTemplateUtil accountTemplateUtil, @@ -78,33 +83,40 @@ @Assisted String reason, @Assisted Account.Id attentionUserId) { this.sendEmailsExecutor = executor; + this.sendEmailEnabled = sendEmailEnabled; - MessageId messageId; - try { - messageId = - messageIdGenerator.fromChangeUpdateAndReason( - ctx.getRepoView(), change.currentPatchSetId(), "AttentionSetEmail"); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + this.asyncSenderSupplier = + Suppliers.memoize( + () -> { + MessageId messageId; + try { + messageId = + messageIdGenerator.fromChangeUpdateAndReason( + ctx.getRepoView(), change.currentPatchSetId(), "AttentionSetEmail"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } - this.asyncSender = - new AsyncSender( - requestContext, - emailFactories, - ctx.getUser(), - ctx.getProject(), - attentionSetChange, - messageId, - ctx.getNotify(change.getId()), - attentionUserId, - accountTemplateUtil.replaceTemplates(reason), - change.getId()); + return new AsyncSender( + requestContext, + emailFactories, + ctx.getUser(), + ctx.getProject(), + attentionSetChange, + messageId, + ctx.getNotify(change.getId()), + attentionUserId, + accountTemplateUtil.replaceTemplates(reason), + change.getId()); + }); } public void sendAsync() { + if (!sendEmailEnabled) { + return; + } @SuppressWarnings("unused") - Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSender); + Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(asyncSenderSupplier.get()); } /**
diff --git a/java/com/google/gerrit/server/version/BUILD b/java/com/google/gerrit/server/version/BUILD index 1176e33..a4bf6e3 100644 --- a/java/com/google/gerrit/server/version/BUILD +++ b/java/com/google/gerrit/server/version/BUILD
@@ -13,6 +13,6 @@ "//java/com/google/gerrit/server", "//java/com/google/gerrit/server/schema", "//lib/errorprone:annotations", - "@guice-library//jar", + "//lib/guice:guice-library", ], )
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD index fa04827..4693d54 100644 --- a/java/com/google/gerrit/sshd/BUILD +++ b/java/com/google/gerrit/sshd/BUILD
@@ -28,7 +28,6 @@ "//lib:gson", "//lib:guava", "//lib:jgit", - "//lib:jgit-archive", "//lib:jgit-ssh-apache", "//lib:servlet-api", "//lib/auto:auto-value",
diff --git a/java/com/google/gerrit/sshd/commands/ScpCommand.java b/java/com/google/gerrit/sshd/commands/ScpCommand.java index 3be98fd..5bbfbcc 100644 --- a/java/com/google/gerrit/sshd/commands/ScpCommand.java +++ b/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -15,7 +15,7 @@ * the License. */ -/* +/** * NB: This code was primarly ripped out of MINA SSHD. * * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java index e47aa6d..be4916a 100644 --- a/java/com/google/gerrit/sshd/commands/UploadArchive.java +++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -264,12 +264,12 @@ if (permissionBackend.user(user).project(projectName).test(ProjectPermission.READ)) { return true; - } else { - // Check reachability of the specific revision. - try (RevWalk rw = new RevWalk(repo)) { - RevCommit commit = rw.parseCommit(revId); - return commits.canRead(projectState, repo, commit); - } + } + + // Check reachability of the specific revision. + try (RevWalk rw = new RevWalk(repo)) { + RevCommit commit = rw.parseCommit(revId); + return commits.canRead(projectState, repo, commit); } } }
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java index 657e1f2..745f89a 100644 --- a/java/com/google/gerrit/testing/InMemoryModule.java +++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -87,6 +87,7 @@ import com.google.gerrit.server.config.GerritServerId; import com.google.gerrit.server.config.GerritServerIdProvider; import com.google.gerrit.server.config.GlobalPluginConfigProvider; +import com.google.gerrit.server.config.SendEmailEnabledModule; import com.google.gerrit.server.config.SendEmailExecutor; import com.google.gerrit.server.config.SitePath; import com.google.gerrit.server.config.TrackingFooters; @@ -267,6 +268,7 @@ install(NoSshKeyCache.module()); install(new GerritInstanceNameModule()); install(new GerritInstanceIdModule()); + install(new SendEmailEnabledModule()); install( new CanonicalWebUrlModule() { @Override
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD index b6d8325..b2e3e7b 100644 --- a/java/gerrit/BUILD +++ b/java/gerrit/BUILD
@@ -10,10 +10,10 @@ "//java/com/google/gerrit/extensions:api", "//java/com/google/gerrit/server", "//java/com/google/gerrit/server/rules/prolog", + "//lib:guava", "//lib:jgit", "//lib/errorprone:annotations", "//lib/flogger:api", "//lib/prolog:runtime", - "@guava//jar", ], )
diff --git a/javatests/com/google/gerrit/acceptance/GitRepositoryReferenceCountingManagerIT.java b/javatests/com/google/gerrit/acceptance/GitRepositoryReferenceCountingManagerIT.java index 3836a9e..8635c7c 100644 --- a/javatests/com/google/gerrit/acceptance/GitRepositoryReferenceCountingManagerIT.java +++ b/javatests/com/google/gerrit/acceptance/GitRepositoryReferenceCountingManagerIT.java
@@ -35,7 +35,7 @@ } } - @Test() + @Test @SuppressWarnings("resource") public void shouldFailTestWhenRepositoryIsLeftOpen() throws Exception { Repository unused = repoManager.openRepository(project);
diff --git a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java index a940644..3e0540b 100644 --- a/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java +++ b/javatests/com/google/gerrit/acceptance/TestMetricMakerTest.java
@@ -27,7 +27,7 @@ /** Tests for {@link TestMetricMaker}. */ public class TestMetricMakerTest { - private TestMetricMaker testMetricMaker = new TestMetricMaker(); + private final TestMetricMaker testMetricMaker = TestMetricMaker.getInstance(); @Before public void setUp() {
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java index eca7f5c..1db2414 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -322,7 +322,9 @@ AuthRequest who = authRequestFactory.createForUser(username); AccountException thrown = assertThrows(AccountException.class, () -> accountManager.authenticate(who)); - assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive"); + assertThat(thrown) + .hasMessageThat() + .contains("Authentication error, account %s inactive".formatted(accountId)); } @Test @@ -341,7 +343,9 @@ who.setAuthProvidesAccountActiveStatus(true); AccountException thrown = assertThrows(AccountException.class, () -> accountManager.authenticate(who)); - assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive"); + assertThat(thrown) + .hasMessageThat() + .contains("Authentication error, account %s inactive".formatted(accountId)); } @Test @@ -404,7 +408,9 @@ who.setAuthProvidesAccountActiveStatus(true); AccountException thrown = assertThrows(AccountException.class, () -> accountManager.authenticate(who)); - assertThat(thrown).hasMessageThat().isEqualTo("Authentication error, account inactive"); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Authentication error, account %s inactive".formatted(accountId)); Optional<AccountState> accountState = accounts.get(accountId); assertThat(accountState).isPresent();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java index 2803603..e7b548d 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -23,8 +23,10 @@ import static java.util.Comparator.comparing; 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.RestResponse; import com.google.gerrit.acceptance.UseClockStep; import com.google.gerrit.acceptance.config.GerritConfig; import com.google.gerrit.acceptance.testsuite.group.GroupOperations; @@ -37,6 +39,11 @@ import com.google.gerrit.entities.InternalGroup; import com.google.gerrit.entities.Permission; import com.google.gerrit.entities.PermissionRule; +import com.google.gerrit.entities.RefNames; +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.ProjectAccessInput; import com.google.gerrit.extensions.api.changes.CherryPickInput; import com.google.gerrit.extensions.api.changes.ReviewInput; import com.google.gerrit.extensions.api.changes.SubmitInput; @@ -52,7 +59,10 @@ import com.google.gerrit.extensions.restapi.BadRequestException; import com.google.gerrit.extensions.restapi.MethodNotAllowedException; import com.google.gerrit.extensions.restapi.UnprocessableEntityException; +import com.google.gerrit.server.group.SystemGroupBackend; import com.google.gerrit.testing.ConfigSuite; +import com.google.gson.Strictness; +import com.google.gson.stream.JsonReader; import com.google.inject.Inject; import java.util.List; import org.eclipse.jgit.lib.Config; @@ -362,6 +372,69 @@ } @Test + public void createAccessChangeRespectsCLA() throws Exception { + assume().that(isContributorAgreementsEnabled()).isTrue(); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS)) + .add(allow(Permission.PUSH).ref("refs/for/" + RefNames.REFS_CONFIG).group(REGISTERED_USERS)) + .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS)) + .add(allow(Permission.SUBMIT).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS)) + .update(); + + // Create an access change succeeds when agreement is not required + setUseContributorAgreements(InheritableBoolean.FALSE); + RestResponse resp = createPermissionsChange("agreement not required"); + resp.assertCreated(); + ChangeInfo got; + try (JsonReader jsonReader = new JsonReader(resp.getReader())) { + jsonReader.setStrictness(Strictness.LENIENT); + got = newGson().fromJson(jsonReader, ChangeInfo.class); + assertThat(got.subject).isEqualTo("agreement not required"); + } + + // Create an access change is not allowed when CLA is required but not signed + setUseContributorAgreements(InheritableBoolean.TRUE); + resp = createPermissionsChange("agreement required - fails"); + resp.assertForbidden(); + assertThat(resp.hasContent()).isTrue(); + assertThat(resp.getEntityContent()).startsWith("No Contributor Agreement on file "); + + // Sign the agreement + gApi.accounts().self().signAgreement(caAutoVerify.getName()); + + // Explicitly reset the user to force a new request context + requestScopeOperations.setApiUser(user.id()); + + // Create an access change succeeds after signing the agreement + resp = createPermissionsChange("agreement required - passes"); + resp.assertCreated(); + try (JsonReader jsonReader = new JsonReader(resp.getReader())) { + jsonReader.setStrictness(Strictness.LENIENT); + got = newGson().fromJson(jsonReader, ChangeInfo.class); + assertThat(got.subject).isEqualTo("agreement required - passes"); + } + } + + private RestResponse createPermissionsChange(String message) throws Exception { + ProjectAccessInput in = new ProjectAccessInput(); + in.message = message; + + PermissionInfo p = new PermissionInfo(null, null); + p.rules = + ImmutableMap.of( + SystemGroupBackend.REGISTERED_USERS.get(), + new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false)); + AccessSectionInfo a = new AccessSectionInfo(); + a.permissions = ImmutableMap.of("read", p); + in.add = ImmutableMap.of("refs/heads/*", a); + + return userRestSession.put("/projects/" + project.get() + "/access:review", in); + } + + @Test public void createExcludedProjectChangeIgnoresCLA() throws Exception { // Contributor agreements configured with excludeProjects = ExcludedProject // in AbstractDaemonTest.configureContributorAgreement(...)
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java index 20d42fb..dd2d0c3 100644 --- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -86,6 +86,7 @@ i.allowBrowserNotifications ^= false; i.allowSuggestCodeWhileCommenting ^= false; i.allowAutocompletingComments ^= false; + i.aiChatSelectedModel = "test-ai-model"; i.diffPageSidebar = "plugin-insight"; i.diffView = DiffView.UNIFIED_DIFF; i.my = new ArrayList<>(); @@ -101,6 +102,7 @@ assertThat(o.allowBrowserNotifications).isEqualTo(i.allowBrowserNotifications); assertThat(o.allowSuggestCodeWhileCommenting).isEqualTo(i.allowSuggestCodeWhileCommenting); assertThat(o.allowAutocompletingComments).isEqualTo(i.allowAutocompletingComments); + assertThat(o.aiChatSelectedModel).isEqualTo(i.aiChatSelectedModel); assertThat(o.diffPageSidebar).isEqualTo(i.diffPageSidebar); assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts); }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AiReviewPermissionIT.java b/javatests/com/google/gerrit/acceptance/api/change/AiReviewPermissionIT.java new file mode 100644 index 0000000..3d779db --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/api/change/AiReviewPermissionIT.java
@@ -0,0 +1,274 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.allow; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny; +import static com.google.gerrit.extensions.client.ListChangesOption.CHANGE_ACTIONS; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; +import com.google.gerrit.entities.Permission; +import com.google.gerrit.extensions.common.ChangeInfo; +import com.google.inject.Inject; +import org.junit.Test; + +// TODO(AI review experiment): When UiFeature__enable_ai_chat is removed, revise tests for +// standard default-deny model. DENY-only and BLOCK-only tests assume default-allow behavior and +// will need to be rewritten to include explicit ALLOW rules. +public class AiReviewPermissionIT extends AbstractDaemonTest { + + @Inject private RequestScopeOperations requestScopeOperations; + @Inject private ProjectOperations projectOperations; + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewTrueWhenNoRulesConfigured() throws Exception { + String changeId = createChange().getChangeId(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isTrue(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewTrueWhenUserInGrantedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isTrue(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenUserNotInGrantedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.AI_REVIEW).ref("refs/heads/*").group(adminGroupUuid())) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenUserInDeniedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewTrueWhenUserNotInDeniedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(adminGroupUuid())) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isTrue(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenUserInBlockedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(block(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewTrueWhenUserNotInBlockedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(block(Permission.AI_REVIEW).ref("refs/heads/*").group(adminGroupUuid())) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isTrue(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenDenySuppressesAllow() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenAllowForOtherGroupAndDenyForUserGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.AI_REVIEW).ref("refs/heads/*").group(adminGroupUuid())) + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewTrueWhenAllowForUserGroupAndDenyForOtherGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(adminGroupUuid())) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isTrue(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenAdminInDeniedGroup() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(project) + .forUpdate() + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(adminGroupUuid())) + .update(); + + requestScopeOperations.setApiUser(admin.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + values = {"UiFeature__enable_ai_chat"}) + public void canAiReviewNullWhenDenyInheritedFromAllProjects() throws Exception { + String changeId = createChange().getChangeId(); + + projectOperations + .project(allProjects) + .forUpdate() + .add(deny(Permission.AI_REVIEW).ref("refs/heads/*").group(REGISTERED_USERS)) + .update(); + + requestScopeOperations.setApiUser(user.id()); + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } + + @Test + public void canAiReviewNullWhenExperimentDisabled() throws Exception { + String changeId = createChange().getChangeId(); + + ChangeInfo info = gApi.changes().id(changeId).get(CHANGE_ACTIONS); + + assertThat(info.canAiReview).isNull(); + } +}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java index 8e5c0d9..8132d67 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/ApplyPatchIT.java
@@ -44,6 +44,7 @@ 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.RelatedChangeAndCommitInfo; import com.google.gerrit.extensions.api.changes.RevisionApi; import com.google.gerrit.extensions.client.ListChangesOption; import com.google.gerrit.extensions.common.ChangeInfo; @@ -59,6 +60,7 @@ import com.google.gerrit.extensions.restapi.RestApiException; import com.google.gerrit.server.patch.ApplyPatchUtil; import com.google.inject.Inject; +import java.util.List; import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; import org.eclipse.jgit.junit.TestRepository; import org.eclipse.jgit.revwalk.RevCommit; @@ -488,6 +490,69 @@ assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit) .isEqualTo(in.base); + + // No change relation since the change for the baseCommit is on another branch. + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(result.id).current().related().changes; + assertThat(relatedChanges).hasSize(0); + } + + @Test + public void applyPatchWithBaseCommitThatIsAnOpenChange_success() throws Exception { + PushOneCommit.Result baseChange = + createChange("base commit", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT); + baseChange.assertOkStatus(); + + PushOneCommit.Result ignoredCommit = + createChange("Ignored file modification", MODIFIED_FILE_NAME, "Ignored file modification"); + ignoredCommit.assertOkStatus(); + + ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF); + in.base = baseChange.getCommit().getName(); + in.responseFormatOptions = ImmutableList.of(ListChangesOption.CURRENT_REVISION); + ChangeInfo result = + gApi.changes() + .create(new ChangeInput(project.get(), "master", "Default commit message")) + .applyPatch(in); + + assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit) + .isEqualTo(in.base); + + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(result.id).current().related().changes; + assertThat(relatedChanges).hasSize(2); + assertThat(relatedChanges.get(0)._changeNumber).isEqualTo(result._number); + assertThat(relatedChanges.get(0)._revisionNumber).isEqualTo(2); + assertThat(relatedChanges.get(1)._changeNumber).isEqualTo(baseChange.getChange().getId().get()); + assertThat(relatedChanges.get(1)._revisionNumber).isEqualTo(1); + } + + @Test + public void applyPatchWithBaseCommitThatIsAMergedChange_success() throws Exception { + PushOneCommit.Result baseChange = + createChange("base commit", MODIFIED_FILE_NAME, MODIFIED_FILE_ORIGINAL_CONTENT); + baseChange.assertOkStatus(); + approve(baseChange.getChangeId()); + gApi.changes().id(baseChange.getChangeId()).current().submit(); + + PushOneCommit.Result ignoredCommit = + createChange("Ignored file modification", MODIFIED_FILE_NAME, "Ignored file modification"); + ignoredCommit.assertOkStatus(); + + ApplyPatchPatchSetInput in = buildInput(MODIFIED_FILE_DIFF); + in.base = baseChange.getCommit().getName(); + in.responseFormatOptions = ImmutableList.of(ListChangesOption.CURRENT_REVISION); + ChangeInfo result = + gApi.changes() + .create(new ChangeInput(project.get(), "master", "Default commit message")) + .applyPatch(in); + + assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit) + .isEqualTo(in.base); + + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(result.id).current().related().changes; + assertThat(relatedChanges).hasSize(0); } @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java index 0eff580..d716006 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -273,6 +273,7 @@ assertThat(changeInfo.id).isEqualTo(change.project() + "~" + change.numericChangeId()); assertThat(changeInfo.project).isEqualTo(change.project().get()); assertThat(changeInfo.branch).isEqualTo(change.dest().shortName()); + assertThat(changeInfo.fullBranch).isEqualTo(change.dest().branch()); assertThat(changeInfo.status).isEqualTo(ChangeStatus.NEW); assertThat(changeInfo.subject).isEqualTo(change.subject()); assertThat(changeInfo.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY); @@ -2799,7 +2800,8 @@ in.project = project.get(); ChangeInfo info = gApi.changes().create(in).get(); assertThat(info.project).isEqualTo(in.project); - assertThat(RefNames.fullName(info.branch)).isEqualTo(RefNames.fullName(in.branch)); + assertThat(info.branch).isEqualTo(Constants.MASTER); + assertThat(info.fullBranch).isEqualTo(RefNames.fullName(Constants.MASTER)); assertThat(info.subject).isEqualTo(in.subject); assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1."); } @@ -3535,7 +3537,7 @@ getAccount(admin.id()).id(), c.updated.toInstant(), serverIdent.get()); assertThat(commitPatchSetCreation.getAuthorIdent()).isEqualTo(expectedAuthor); assertThat(commitPatchSetCreation.getCommitterIdent()) - .isEqualTo(new PersonIdent(serverIdent.get(), c.updated)); + .isEqualTo(new PersonIdent(serverIdent.get(), c.updated.toInstant())); assertThat(commitPatchSetCreation.getParentCount()).isEqualTo(1); RevCommit commitChangeCreation = rw.parseCommit(commitPatchSetCreation.getParent(0)); @@ -3545,7 +3547,7 @@ getAccount(admin.id()).id(), c.created.toInstant(), serverIdent.get()); assertThat(commitChangeCreation.getAuthorIdent()).isEqualTo(expectedAuthor); assertThat(commitChangeCreation.getCommitterIdent()) - .isEqualTo(new PersonIdent(serverIdent.get(), c.created)); + .isEqualTo(new PersonIdent(serverIdent.get(), c.created.toInstant())); assertThat(commitChangeCreation.getParentCount()).isEqualTo(0); } } @@ -3559,7 +3561,8 @@ in.newBranch = true; ChangeInfo info = gApi.changes().create(in).get(); assertThat(info.project).isEqualTo(in.project); - assertThat(RefNames.fullName(info.branch)).isEqualTo(RefNames.fullName(in.branch)); + assertThat(info.branch).isEqualTo("foo"); + assertThat(info.fullBranch).isEqualTo(RefNames.fullName("foo")); assertThat(info.subject).isEqualTo(in.subject); assertThat(Iterables.getOnlyElement(info.messages).message).isEqualTo("Uploaded patch set 1."); }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java index 7d0d70e..c54d22f 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -23,7 +23,6 @@ import static com.google.gerrit.testing.GerritJUnit.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; @@ -76,12 +75,10 @@ import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.notedb.ChangeNotes; import com.google.gerrit.server.restapi.change.OnPostReview; -import com.google.gerrit.server.restapi.change.PostReview; import com.google.gerrit.server.update.CommentsRejectedException; import com.google.gerrit.testing.FakeEmailSender; 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; @@ -98,10 +95,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; -/** Tests for comment validation in {@link PostReview}. */ +/** Tests for comment validation in {@link com.google.gerrit.server.restapi.change.PostReview}. */ public class PostReviewIT extends AbstractDaemonTest { - @Inject private CommentValidator mockCommentValidator; @Inject private TestCommentHelper testCommentHelper; @Inject private RequestScopeOperations requestScopeOperations; @Inject private ExtensionRegistry extensionRegistry; @@ -127,9 +123,13 @@ COMMENT_TEXT, COMMENT_TEXT.length()); + private static CommentValidator mockCommentValidator; @Captor private ArgumentCaptor<ImmutableList<CommentForValidation>> captor; @Captor private ArgumentCaptor<CommentValidationContext> captorCtx; + @SuppressWarnings("unused") + private AutoCloseable mockCommentValidatorPlugin; + private static final Correspondence<CommentForValidation, CommentForValidation> COMMENT_CORRESPONDENCE = Correspondence.from( @@ -141,28 +141,23 @@ && left.getText().equals(right.getText()), "matches (ignoring size approximation)"); - @Override - public Module createModule() { - return new FactoryModule() { - @Override - public void configure() { - CommentValidator mockCommentValidator = mock(CommentValidator.class); - - // by default return no validation errors - when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of()); - - bind(CommentValidator.class) - .annotatedWith(Exports.named(mockCommentValidator.getClass())) - .toInstance(mockCommentValidator); - bind(CommentValidator.class).toInstance(mockCommentValidator); - } - }; + public static class MockCommentValidatorModule extends FactoryModule { + @Override + public void configure() { + // by default return no validation errors + bind(CommentValidator.class) + .annotatedWith(Exports.named(mockCommentValidator.getClass())) + .toInstance(mockCommentValidator); + bind(CommentValidator.class).toInstance(mockCommentValidator); + } } @Before - public void resetMock() { + public void resetMockCommentValidator() throws Exception { + mockCommentValidator = mock(CommentValidator.class); initMocks(this); - clearInvocations(mockCommentValidator); + mockCommentValidatorPlugin = super.installPlugin("validator", MockCommentValidatorModule.class); + when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of()); } @Test @@ -393,6 +388,45 @@ } @Test + @GerritConfig(name = "change.maxCommentsPerUser", value = "2") + public void restrictNumberOfCommentsPerUser() throws Exception { + when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of()); + + PushOneCommit.Result r = createChange(); + String filePath = r.getChange().currentFilePaths().get(0); + + CommentInput comment1 = new CommentInput(); + comment1.line = 1; + comment1.message = "first comment"; + comment1.path = filePath; + + CommentInput comment2 = new CommentInput(); + comment2.line = 2; + comment2.message = "second comment"; + comment2.path = filePath; + + CommentInput comment3 = new CommentInput(); + comment3.line = 3; + comment3.message = "third comment"; + comment3.path = filePath; + + ReviewInput reviewInput = new ReviewInput(); + reviewInput.comments = + ImmutableMap.of(filePath, ImmutableList.of(comment1, comment2, comment3)); + + BadRequestException exception = + assertThrows( + BadRequestException.class, + () -> gApi.changes().id(r.getChangeId()).current().review(reviewInput)); + + assertThat(exception) + .hasMessageThat() + .contains("Exceeding maximum comments per user: 1 (existing) + 4 (new) > 2"); + + assertThat(testCommentHelper.getPublishedComments(r.getChangeId())).hasSize(0); + } + + @Test @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "7k") public void validateCumulativeCommentSize() throws Exception { PushOneCommit.Result r = createChange();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java index 9d6331d..cf8c585 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -356,8 +356,8 @@ @Test public void testInvalidListChangeOption() throws Exception { PushOneCommit.Result r = createChange(); - RestResponse rep = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=ffffffff"); - rep.assertBadRequest(); + RestResponse resp = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=ffffffff"); + resp.assertBadRequest(); } /**
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java index 2669b31..b0c0a3f 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseChainOnBehalfOfUploaderIT.java
@@ -75,13 +75,13 @@ * RebaseOnBehalfOfUploaderIT}. */ public class RebaseChainOnBehalfOfUploaderIT extends AbstractDaemonTest { + private final TestMetricMaker testMetricMaker = TestMetricMaker.getInstance(); @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 { @@ -1411,9 +1411,13 @@ assertThat(changeMessage.message) .isEqualTo( String.format( - "Patch Set %d: Patch Set %d was rebased on behalf of %s", + "Patch Set %d: Patch Set %d was rebased on behalf of %s" + + "\n\n" + + "(Performed by %s on behalf of %s)", expectedPatchSetNum, expectedPatchSetNum - 1, + AccountTemplateUtil.getAccountTemplate(expectedUploader), + AccountTemplateUtil.getAccountTemplate(expectedRealUploader), AccountTemplateUtil.getAccountTemplate(expectedUploader))); assertThat(changeMessage.realAuthor._accountId).isEqualTo(expectedRealUploader.get()); } else {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java index 78e5060..671d1cb 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseIT.java
@@ -99,11 +99,11 @@ }) public class RebaseIT { public abstract static class Base extends AbstractDaemonTest { + protected final TestMetricMaker testMetricMaker = TestMetricMaker.getInstance(); @Inject protected ChangeOperations changeOperations; @Inject protected RequestScopeOperations requestScopeOperations; @Inject protected ProjectOperations projectOperations; @Inject protected ExtensionRegistry extensionRegistry; - @Inject protected TestMetricMaker testMetricMaker; @Inject protected AccountOperations accountOperations; @FunctionalInterface
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java index c07521f..00e3385 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/RebaseOnBehalfOfUploaderIT.java
@@ -36,6 +36,7 @@ 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.common.data.GlobalCapability; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.AccountGroup; @@ -78,13 +79,13 @@ * RebaseChainOnBehalfOfUploaderIT}. */ public class RebaseOnBehalfOfUploaderIT extends AbstractDaemonTest { + private final TestMetricMaker testMetricMaker = TestMetricMaker.getInstance(); @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 { @@ -339,17 +340,7 @@ 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()); + assertRebaseChangeMessage(changeToBeRebased, 3, uploader, rebaser); } @Test @@ -571,6 +562,7 @@ rebaseInput.onBehalfOfUploader = true; gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput); + assertRebaseChangeMessage(changeToBeRebased, 2, uploader, null); RevisionInfo currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision(); // The change had 1 patch set before the rebase, now it should be 2 @@ -670,6 +662,7 @@ rebaseInput.onBehalfOfUploader = true; gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput); + assertRebaseChangeMessage(changeToBeRebased, 3, uploader, rebaser); RevisionInfo currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision(); // The change had 2 patch set before the rebase, now it should be 3 @@ -773,6 +766,7 @@ rebaseInput.onBehalfOfUploader = true; gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput); + assertRebaseChangeMessage(changeToBeRebased, 2, uploader, rebaser); RevisionInfo currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision(); // The change had 1 patch set before the rebase, now it should be 2 @@ -867,6 +861,7 @@ rebaseInput.onBehalfOfUploader = true; gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput); + assertRebaseChangeMessage(changeToBeRebased, 2, uploader, rebaser); RevisionInfo currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision(); // The change had 1 patch set before the rebase, now it should be 2 @@ -920,6 +915,7 @@ rebaseInput.onBehalfOfUploader = true; gApi.changes().id(changeToBeRebased.get()).rebase(rebaseInput); + assertRebaseChangeMessage(changeToBeRebased, 2, uploader, rebaser); RevisionInfo currentRevisionInfo = gApi.changes().id(changeToBeRebased.get()).get().getCurrentRevision(); // The change had 1 patch set before the rebase, now it should be 2 @@ -1437,6 +1433,43 @@ .update(); } + private void assertRebaseChangeMessage( + 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" + + "\n\n" + + "(Performed by %s on behalf of %s)", + expectedPatchSetNum, + expectedPatchSetNum - 1, + AccountTemplateUtil.getAccountTemplate(expectedUploader), + AccountTemplateUtil.getAccountTemplate(expectedRealUploader), + 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(); + } + } + @FunctionalInterface private interface RebaseCall { void call(Change.Id changeId, RebaseInput rebaseInput) throws RestApiException;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java index b3b56db..8e2a3ba 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -15,7 +15,6 @@ package com.google.gerrit.acceptance.api.change; import static com.google.common.truth.Truth.assertThat; -import static com.google.gerrit.acceptance.TestExtensions.TestCommitValidationListener; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block; import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; @@ -25,7 +24,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.gerrit.acceptance.AbstractDaemonTest; -import com.google.gerrit.acceptance.AbstractDaemonTest.ProjectConfigUpdate; import com.google.gerrit.acceptance.ExtensionRegistry; import com.google.gerrit.acceptance.ExtensionRegistry.Registration; import com.google.gerrit.acceptance.PushOneCommit; @@ -350,6 +348,33 @@ } @Test + public void revertOfRevertWithSetMessage() throws Exception { + PushOneCommit.Result result = createChange(); + gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve()); + gApi.changes().id(result.getChangeId()).revision(result.getCommit().name()).submit(); + + // First revert + ChangeInfo revertChange = gApi.changes().id(result.getChangeId()).revert().get(); + gApi.changes().id(revertChange.id).current().review(ReviewInput.approve()); + gApi.changes().id(revertChange.id).current().submit(); + + // Revert of revert with custom message + RevertInput revertInput = new RevertInput(); + revertInput.message = "Custom message for double revert"; + ChangeInfo doubleRevertChange = gApi.changes().id(revertChange.id).revert(revertInput).get(); + + String actualSubject = doubleRevertChange.subject; + String commitMessage = gApi.changes().id(doubleRevertChange.id).current().commit(false).message; + + assertThat(actualSubject).isEqualTo("Custom message for double revert"); + assertThat(commitMessage) + .isEqualTo( + String.format( + "Custom message for double revert\n\nChange-Id: %s\n", + doubleRevertChange.changeId)); + } + + @Test public void revertWithSetMessageChangeIdIgnored() throws Exception { PushOneCommit.Result result = createChange(); gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve()); @@ -1070,6 +1095,68 @@ } @Test + public void revertSubmissionOfRevertSubmissionWithSetMessage() throws Exception { + String firstResult = createChange("first change", "a.txt", "message").getChangeId(); + String secondResult = createChange("second change", "b.txt", "message").getChangeId(); + approve(firstResult); + approve(secondResult); + gApi.changes().id(secondResult).current().submit(); + + // First revert + List<ChangeInfo> revertChanges = + gApi.changes().id(firstResult).revertSubmission().revertChanges; + ChangeInfo firstRevert = + revertChanges.stream() + .filter(c -> c.subject.equals("Revert \"first change\"")) + .findFirst() + .get(); + ChangeInfo secondRevert = + revertChanges.stream() + .filter(c -> c.subject.equals("Revert \"second change\"")) + .findFirst() + .get(); + + approve(firstRevert.id); + approve(secondRevert.id); + gApi.changes().id(firstRevert.id).current().submit(); + + // Revert of revert with custom message + RevertInput revertInput = new RevertInput(); + String commitMessage = "Custom message for double revert"; + revertInput.message = commitMessage; + List<ChangeInfo> doubleRevertChanges = + gApi.changes().id(firstRevert.id).revertSubmission(revertInput).revertChanges; + + ChangeInfo doubleRevertFirst = + doubleRevertChanges.stream() + .filter(c -> c.subject.equals("Revert^2 \"first change\"")) + .findFirst() + .get(); + ChangeInfo doubleRevertSecond = + doubleRevertChanges.stream() + .filter(c -> c.subject.equals("Revert^2 \"second change\"")) + .findFirst() + .get(); + + String firstRevertCommit = gApi.changes().id(firstRevert.id).current().commit(false).commit; + String commitMessage1 = gApi.changes().id(doubleRevertFirst.id).current().commit(false).message; + assertThat(commitMessage1) + .isEqualTo( + String.format( + "Revert^2 \"first change\"\n\nThis reverts commit %s.\n\n%s\n\nChange-Id: %s\n", + firstRevertCommit, commitMessage, doubleRevertFirst.changeId)); + + String secondRevertCommit = gApi.changes().id(secondRevert.id).current().commit(false).commit; + String commitMessage2 = + gApi.changes().id(doubleRevertSecond.id).current().commit(false).message; + assertThat(commitMessage2) + .isEqualTo( + String.format( + "Revert^2 \"second change\"\n\nThis reverts commit %s.\n\n%s\n\nChange-Id: %s\n", + secondRevertCommit, commitMessage, doubleRevertSecond.changeId)); + } + + @Test public void revertSubmissionWithSetMessageChangeIdIgnored() throws Exception { String firstResult = createChange("first change", "a.txt", "message").getChangeId(); String secondResult = createChange("second change", "b.txt", "message").getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java index 2c376fc..acefaf6 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementCustomRuleIT.java
@@ -67,7 +67,8 @@ rule.block(false); PushOneCommit.Result r = createChange(); - ChangeInfo result = gApi.changes().id(r.getChangeId()).get(); + ChangeInfo result = + gApi.changes().id(r.getChangeId()).get(ListChangesOption.SUBMIT_REQUIREMENTS); assertThat(result.requirements).isEmpty(); rule.block(true); @@ -81,20 +82,21 @@ PushOneCommit.Result r = createChange(); String query = "status:open project:" + project.get(); - List<ChangeInfo> result = gApi.changes().query(query).get(); + List<ChangeInfo> result = + gApi.changes().query(query).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get(); assertThat(result).hasSize(1); assertThat(result.get(0).requirements).isEmpty(); // Submit rule behavior is changed, but the query still returns // the previous result from the index rule.block(true); - result = gApi.changes().query(query).get(); + result = gApi.changes().query(query).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get(); assertThat(result).hasSize(1); assertThat(result.get(0).requirements).isEmpty(); // The submit rule result is updated after the change is reindexed gApi.changes().id(r.getChangeId()).index(); - result = gApi.changes().query(query).get(); + result = gApi.changes().query(query).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get(); assertThat(result).hasSize(1); assertThat(result.get(0).requirements).containsExactly(reqInfo); } @@ -183,7 +185,10 @@ var unused = gApi.changes() .id(changeId) - .get(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS); + .get( + ListChangesOption.ALL_REVISIONS, + ListChangesOption.CURRENT_ACTIONS, + ListChangesOption.SUBMIT_REQUIREMENTS); // Submit rules are computed freshly, but only once. assertThat(rule.numberOfEvaluations.get()).isEqualTo(1); @@ -200,7 +205,10 @@ var unused = gApi.changes() .query(changeId) - .withOptions(ListChangesOption.ALL_REVISIONS, ListChangesOption.CURRENT_ACTIONS) + .withOptions( + ListChangesOption.ALL_REVISIONS, + ListChangesOption.CURRENT_ACTIONS, + ListChangesOption.SUBMIT_REQUIREMENTS) .get(); // Submit rule evaluation results from the change index are reused
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java index 1fdece7..5bf1a40 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementIT.java
@@ -485,7 +485,7 @@ SubmitRequirement.builder() .setName("my-label") .setSubmittabilityExpression( - SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader")) + SubmitRequirementExpression.create("label:my-label=MAX&user=non_uploader")) .setAllowOverrideInChildProjects(false) .build()); @@ -562,7 +562,7 @@ .setName("my-label") .setSubmittabilityExpression( SubmitRequirementExpression.create( - "label:my-label=MAX,user=non_uploader -label:my-label=MIN")) + "label:my-label=MAX&user=non_uploader -label:my-label=MIN")) .setAllowOverrideInChildProjects(false) .build()); @@ -644,9 +644,9 @@ .setApplicabilityExpression( Optional.of( SubmitRequirementExpression.create( - "-label:Code-Review>=1,users=human_reviewers"))) + "-label:Code-Review>=1&users=human_reviewers"))) .setSubmittabilityExpression( - SubmitRequirementExpression.create("label:Code-Review>=1,users=human_reviewers")) + SubmitRequirementExpression.create("label:Code-Review>=1&users=human_reviewers")) .setAllowOverrideInChildProjects(false) .build()); @@ -718,7 +718,7 @@ /* isLegacy= */ false); // Want-Code-Review-From-All is not applicable since there are approval from all reviewers - // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX,users=human_reviewers" + // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX&users=human_reviewers" // satisfied. assertSubmitRequirementStatus( gApi.changes().id(changeId).get().submitRequirements, @@ -775,9 +775,9 @@ Optional.of( SubmitRequirementExpression.create( "footer:\"Want-Code-Review: all\"" - + " -label:Code-Review>=1,users=human_reviewers"))) + + " -label:Code-Review>=1&users=human_reviewers"))) .setSubmittabilityExpression( - SubmitRequirementExpression.create("label:Code-Review>=1,users=human_reviewers")) + SubmitRequirementExpression.create("label:Code-Review>=1&users=human_reviewers")) .setAllowOverrideInChildProjects(false) .build()); @@ -836,7 +836,7 @@ voteLabel(changeId, "Code-Review", 2); // Want-Code-Review-From-All is not applicable since there are approval from all reviewers - // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX,users=human_reviewers" + // (reviewer1 and reviewer2) which makes "label:Code-Review=MAX&users=human_reviewers" // satisfied. assertSubmitRequirementStatus( gApi.changes().id(changeId).get().submitRequirements, @@ -1397,7 +1397,7 @@ SubmitRequirement.builder() .setName("Code-Review") .setSubmittabilityExpression( - SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader")) + SubmitRequirementExpression.create("label:Code-Review=MAX&user=non_uploader")) .setAllowOverrideInChildProjects(true) .build()); @@ -1450,7 +1450,7 @@ SubmitRequirement.builder() .setName("Code-Review") .setSubmittabilityExpression( - SubmitRequirementExpression.create("label:Code-Review=MAX,user=non_uploader")) + SubmitRequirementExpression.create("label:Code-Review=MAX&user=non_uploader")) .setAllowOverrideInChildProjects(true) .build());
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java index 822807e..5046eee 100644 --- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java +++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRequirementPredicateIT.java
@@ -32,6 +32,7 @@ import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.Sandboxed; import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.TestProjectInput; import com.google.gerrit.acceptance.UseTimezone; import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes; import com.google.gerrit.acceptance.testsuite.account.AccountOperations; @@ -49,6 +50,7 @@ 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.ChangeIdentifier; import com.google.gerrit.extensions.api.changes.PublishChangeEditInput; import com.google.gerrit.extensions.api.changes.ReviewInput; import com.google.gerrit.extensions.common.ChangeInfo; @@ -1015,6 +1017,142 @@ assertMatching("-has:unresolved", r.getChange().getId()); } + @Test + public void matchFileAndFolderOfNewlyAddedFile() throws Exception { + TestChange change = + changeOperations.newChange().file("a/b/c").content("abc").project(project).createAndGet(); + + assertMatching("file:a/b/c", change.numericChangeId()); + assertMatching("dir:a", change.numericChangeId()); + assertMatching("dir:a/b", change.numericChangeId()); + + assertNotMatching("file:other", change.numericChangeId()); + assertNotMatching("dir:other", change.numericChangeId()); + } + + @Test + public void matchFileAndFolderOfDeletedFile() throws Exception { + ChangeIdentifier baseChangeIdentifier = + changeOperations.newChange().file("a/b/c").content("abc").project(project).create(); + + TestChange change = + changeOperations + .newChange() + .childOf() + .change(baseChangeIdentifier) + .file("a/b/c") + .delete() + .project(project) + .createAndGet(); + + assertMatching("file:a/b/c", change.numericChangeId()); + assertMatching("dir:a", change.numericChangeId()); + assertMatching("dir:a/b", change.numericChangeId()); + + assertNotMatching("file:other", change.numericChangeId()); + assertNotMatching("dir:other", change.numericChangeId()); + } + + @Test + public void matchFileAndFolderOfRenamedFile() throws Exception { + ChangeIdentifier baseChangeIdentifier = + changeOperations.newChange().file("a/b/c").content("abc").project(project).create(); + + TestChange change = + changeOperations + .newChange() + .childOf() + .change(baseChangeIdentifier) + .file("a/b/c") + .renameTo("x/y/z") + .project(project) + .createAndGet(); + + // old path matches + assertMatching("file:a/b/c", change.numericChangeId()); + assertMatching("dir:a", change.numericChangeId()); + assertMatching("dir:a/b", change.numericChangeId()); + + // new path matches + assertMatching("file:x/y/z", change.numericChangeId()); + assertMatching("dir:x", change.numericChangeId()); + assertMatching("dir:x/y", change.numericChangeId()); + + assertNotMatching("file:other", change.numericChangeId()); + assertNotMatching("dir:other", change.numericChangeId()); + } + + @Test + public void matchFileAndFolderInCleanMergeChange() throws Exception { + ChangeIdentifier parent1ChangeId = + changeOperations.newChange().project(project).file("a/b/c").content("abc").create(); + ChangeIdentifier parent2ChangeId = + changeOperations.newChange().project(project).file("x/y/z").content("xyz").create(); + + TestChange change = + changeOperations + .newChange() + .project(project) + .mergeOf() + .change(parent1ChangeId) + .and() + .change(parent2ChangeId) + .createAndGet(); + + // Since it was a clean merge the auto-merge commit is empty, hence no folder matches. + assertNotMatching("file:x/y/z", change.numericChangeId()); + assertNotMatching("dir:x", change.numericChangeId()); + assertNotMatching("dir:x/y", change.numericChangeId()); + assertNotMatching("file:a/b/c", change.numericChangeId()); + assertNotMatching("dir:a", change.numericChangeId()); + assertNotMatching("dir:a/b", change.numericChangeId()); + + assertNotMatching("file:other", change.numericChangeId()); + assertNotMatching("dir:other", change.numericChangeId()); + } + + @Test + public void matchFileAndFolderInMergeChangeThatResolvedConflicts() throws Exception { + ChangeIdentifier parent1ChangeId = + changeOperations.newChange().project(project).file("a/b/c").content("abc").create(); + ChangeIdentifier parent2ChangeId = + changeOperations.newChange().project(project).file("a/b/c").content("xyz").create(); + + TestChange change = + changeOperations + .newChange() + .project(project) + .mergeOfButBaseOnFirst() + .change(parent1ChangeId) + .and() + .change(parent2ChangeId) + .file("a/b/c") + .content("abc+xyz") + .createAndGet(); + + // The auto-merge commit contains the files that had conflicts. + assertMatching("file:a/b/c", change.numericChangeId()); + assertMatching("dir:a", change.numericChangeId()); + assertMatching("dir:a/b", change.numericChangeId()); + + assertNotMatching("file:other", change.numericChangeId()); + assertNotMatching("dir:other", change.numericChangeId()); + } + + @Test + @TestProjectInput(createEmptyCommit = false) + public void matchFileAndFolderInInitialChange() throws Exception { + PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, "subject", "a/b/c", "abc"); + PushOneCommit.Result r = push.to("refs/for/master"); + + assertMatching("file:a/b/c", r.getChange().getId()); + assertMatching("dir:a", r.getChange().getId()); + assertMatching("dir:a/b", r.getChange().getId()); + + assertNotMatching("file:other", r.getChange().getId()); + assertNotMatching("dir:other", r.getChange().getId()); + } + private void addReviewers(Project.NameKey project, Change.Id changeId, Account.Id... reviewers) throws Exception { for (Account.Id reviewer : reviewers) {
diff --git a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java index 60a8204..b2765a4 100644 --- a/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java +++ b/javatests/com/google/gerrit/acceptance/api/config/ListExperimentsIT.java
@@ -46,15 +46,14 @@ ImmutableMap<String, ExperimentInfo> experiments = gApi.config().server().listExperiments().get(); assertThat(experiments.keySet()) - .containsExactly( + .containsAtLeast( ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS, ExperimentFeaturesConstants .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE, - ExperimentFeaturesConstants.ASYNC_SUBMIT_REQUIREMENTS, - ExperimentFeaturesConstants.UI_FEATURE_GET_AI_PROMPT) + ExperimentFeaturesConstants.SKIP_SUBMIT_RECORDS_WITHOUT_SUBMIT_REQUIREMENTS) .inOrder(); // "GerritBackendFeature__check_implicit_merges_on_merge", @@ -83,8 +82,6 @@ assertThat( experiments.get(ExperimentFeaturesConstants.ALLOW_FIX_SUGGESTIONS_IN_COMMENTS).enabled) .isFalse(); - assertThat(experiments.get(ExperimentFeaturesConstants.UI_FEATURE_GET_AI_PROMPT).enabled) - .isTrue(); assertThat( experiments.get( ExperimentFeaturesConstants @@ -110,8 +107,7 @@ .GERRIT_BACKEND_FEATURE_ALWAYS_REJECT_IMPLICIT_MERGES_ON_MERGE, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_ATTACH_NONCE_TO_DOCUMENTATION, ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_CHECK_IMPLICIT_MERGES_ON_MERGE, - ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE, - ExperimentFeaturesConstants.UI_FEATURE_GET_AI_PROMPT) + ExperimentFeaturesConstants.GERRIT_BACKEND_FEATURE_REJECT_IMPLICIT_MERGES_ON_MERGE) .inOrder(); for (ExperimentInfo experimentInfo : experiments.values()) { assertThat(experimentInfo.enabled).isTrue();
diff --git a/javatests/com/google/gerrit/acceptance/api/flow/CreateFlowIT.java b/javatests/com/google/gerrit/acceptance/api/flow/CreateFlowIT.java index c135b8a..3346630 100644 --- a/javatests/com/google/gerrit/acceptance/api/flow/CreateFlowIT.java +++ b/javatests/com/google/gerrit/acceptance/api/flow/CreateFlowIT.java
@@ -43,9 +43,11 @@ import com.google.gerrit.extensions.restapi.ResourceConflictException; import com.google.gerrit.server.flow.FlowKey; import com.google.gerrit.server.flow.FlowService; +import com.google.gerrit.server.flow.FlowStageEvaluationStatus.State; import com.google.gerrit.server.restapi.flow.CreateFlow; import com.google.inject.Inject; import java.time.Instant; +import java.util.Optional; import org.junit.Test; /** @@ -255,6 +257,25 @@ } @Test + public void createFlow_callerMustBeUploader() throws Exception { + ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); + requestScopeOperations.setApiUser(accountCreator.user2().id()); + TestFlowService testFlowService = new TestExtensions.TestFlowService(); + testFlowService.rejectFlowCreation(); + try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { + FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, changeIdentifier); + AuthException exception = + assertThrows( + AuthException.class, () -> gApi.changes().id(changeIdentifier).createFlow(flowInput)); + assertThat(exception) + .hasMessageThat() + .isEqualTo( + "Only latest uploader can create a flow, because actions are executed on behalf of" + + " uploader."); + } + } + + @Test public void createFlow_permissionDenied() throws Exception { ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); TestFlowService testFlowService = new TestExtensions.TestFlowService(); @@ -268,11 +289,83 @@ @Test public void numberOfFlowsPerChangeIsLimitedByDefault() throws Exception { + TestChange change = changeOperations.newChange().createAndGet(); + TestFlowService testFlowService = new TestExtensions.TestFlowService(); + try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { + FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, change.id()); + for (int i = 1; i <= CreateFlow.DEFAULT_MAX_FLOWS_PER_CHANGE; i++) { + FlowInfo flowInfo = gApi.changes().id(change.id()).createFlow(flowInput); + + // Mark the flow as done so that we do not hit the lower limit for pending flows. + FlowKey flowKey = FlowKey.create(change.project(), change.numericChangeId(), flowInfo.uuid); + testFlowService.evaluate( + flowKey, ImmutableList.of(State.DONE), ImmutableList.of(Optional.empty())); + } + + ResourceConflictException exception = + assertThrows( + ResourceConflictException.class, + () -> gApi.changes().id(change.id()).createFlow(flowInput)); + assertThat(exception) + .hasMessageThat() + .isEqualTo( + String.format( + "Too many flows (max %s flows allowed per change)", + CreateFlow.DEFAULT_MAX_FLOWS_PER_CHANGE)); + } + } + + @Test + @GerritConfig(name = "flows.maxPerChange", value = "50") + public void numberOfFlowsPerChangeIsLimitedByConfiguration() throws Exception { + TestChange change = changeOperations.newChange().createAndGet(); + TestFlowService testFlowService = new TestExtensions.TestFlowService(); + try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { + FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, change.id()); + for (int i = 1; i <= 50; i++) { + FlowInfo flowInfo = gApi.changes().id(change.id()).createFlow(flowInput); + + // Mark the flow as done so that we do not hit the lower limit for pending flows. + FlowKey flowKey = FlowKey.create(change.project(), change.numericChangeId(), flowInfo.uuid); + testFlowService.evaluate( + flowKey, ImmutableList.of(State.DONE), ImmutableList.of(Optional.empty())); + } + + ResourceConflictException exception = + assertThrows( + ResourceConflictException.class, + () -> gApi.changes().id(change.id()).createFlow(flowInput)); + assertThat(exception) + .hasMessageThat() + .isEqualTo(String.format("Too many flows (max %s flows allowed per change)", 50)); + } + } + + @Test + @GerritConfig(name = "flows.maxPerChange", value = "0") + public void numberOfFlowsPerChangeIsUnlimited() throws Exception { + TestChange change = changeOperations.newChange().createAndGet(); + TestFlowService testFlowService = new TestExtensions.TestFlowService(); + try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { + FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, change.id()); + for (int i = 1; i <= CreateFlow.DEFAULT_MAX_FLOWS_PER_CHANGE + 10; i++) { + FlowInfo flowInfo = gApi.changes().id(change.id()).createFlow(flowInput); + + // Mark the flow as done so that we do not hit the lower limit for pending flows. + FlowKey flowKey = FlowKey.create(change.project(), change.numericChangeId(), flowInfo.uuid); + testFlowService.evaluate( + flowKey, ImmutableList.of(State.DONE), ImmutableList.of(Optional.empty())); + } + } + } + + @Test + public void numberOfPendingFlowsPerChangeIsLimitedByDefault() throws Exception { ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); TestFlowService testFlowService = new TestExtensions.TestFlowService(); try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, changeIdentifier); - for (int i = 1; i <= CreateFlow.DEFAULT_MAX_FLOWS_PER_CHANGE; i++) { + for (int i = 1; i <= CreateFlow.DEFAULT_MAX_PENDING_FLOWS_PER_CHANGE; i++) { gApi.changes().id(changeIdentifier).createFlow(flowInput); } @@ -284,19 +377,19 @@ .hasMessageThat() .isEqualTo( String.format( - "Too many flows (max %s flow allowed per change)", - CreateFlow.DEFAULT_MAX_FLOWS_PER_CHANGE)); + "Too many pending flows (max %s pending flows allowed per change)", + CreateFlow.DEFAULT_MAX_PENDING_FLOWS_PER_CHANGE)); } } @Test - @GerritConfig(name = "flows.maxPerChange", value = "50") - public void numberOfFlowsPerChangeIsLimitedByConfiguration() throws Exception { + @GerritConfig(name = "flows.maxPendingPerChange", value = "5") + public void numberOfPendingFlowsPerChangeIsLimitedByConfiguration() throws Exception { ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); TestFlowService testFlowService = new TestExtensions.TestFlowService(); try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, changeIdentifier); - for (int i = 1; i <= 50; i++) { + for (int i = 1; i <= 5; i++) { gApi.changes().id(changeIdentifier).createFlow(flowInput); } @@ -306,18 +399,19 @@ () -> gApi.changes().id(changeIdentifier).createFlow(flowInput)); assertThat(exception) .hasMessageThat() - .isEqualTo(String.format("Too many flows (max %s flow allowed per change)", 50)); + .isEqualTo( + String.format("Too many pending flows (max %s pending flows allowed per change)", 5)); } } @Test - @GerritConfig(name = "flows.maxPerChange", value = "0") - public void numberOfFlowsPerChangeIsUnlimited() throws Exception { + @GerritConfig(name = "flows.maxPendingPerChange", value = "0") + public void numberOfPendingFlowsPerChangeIsUnlimited() throws Exception { ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); TestFlowService testFlowService = new TestExtensions.TestFlowService(); try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { FlowInput flowInput = createTestFlowInputWithOneStage(accountCreator, changeIdentifier); - for (int i = 1; i <= CreateFlow.DEFAULT_MAX_FLOWS_PER_CHANGE + 10; i++) { + for (int i = 1; i <= CreateFlow.DEFAULT_MAX_PENDING_FLOWS_PER_CHANGE + 10; i++) { gApi.changes().id(changeIdentifier).createFlow(flowInput); } }
diff --git a/javatests/com/google/gerrit/acceptance/api/flow/ListActionsIT.java b/javatests/com/google/gerrit/acceptance/api/flow/ListActionsIT.java new file mode 100644 index 0000000..c69ee66 --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/api/flow/ListActionsIT.java
@@ -0,0 +1,84 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.flow; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.common.collect.ImmutableList; +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.ExtensionRegistry; +import com.google.gerrit.acceptance.ExtensionRegistry.Registration; +import com.google.gerrit.acceptance.TestExtensions; +import com.google.gerrit.acceptance.testsuite.change.ChangeOperations; +import com.google.gerrit.extensions.api.changes.ChangeIdentifier; +import com.google.gerrit.extensions.common.FlowActionTypeInfo; +import com.google.gerrit.extensions.restapi.MethodNotAllowedException; +import com.google.gerrit.server.flow.FlowActionType; +import com.google.gerrit.server.flow.FlowService; +import com.google.inject.Inject; +import java.util.List; +import org.junit.Test; + +/** + * Integration tests for the {@link com.google.gerrit.server.restapi.flow.ListActions} REST + * endpoint. + */ +public class ListActionsIT extends AbstractDaemonTest { + @Inject private ChangeOperations changeOperations; + @Inject private ExtensionRegistry extensionRegistry; + + @Test + public void listActionsIfNoFlowServiceIsBound_methodNotAllowed() throws Exception { + ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); + MethodNotAllowedException exception = + assertThrows( + MethodNotAllowedException.class, + () -> gApi.changes().id(changeIdentifier).flowsActions()); + assertThat(exception).hasMessageThat().isEqualTo("No FlowService bound."); + } + + @Test + public void listActions_noActionsExist() throws Exception { + ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); + FlowService flowService = new TestExtensions.TestFlowService(); + try (Registration registration = extensionRegistry.newRegistration().set(flowService)) { + List<FlowActionTypeInfo> actions = gApi.changes().id(changeIdentifier).flowsActions(); + assertThat(actions).isEmpty(); + } + } + + @Test + public void listActions() throws Exception { + ChangeIdentifier changeIdentifier = changeOperations.newChange().create(); + + TestExtensions.TestFlowService testFlowService = new TestExtensions.TestFlowService(); + testFlowService.setActions( + ImmutableList.of(action("action1", "param1"), action("action2", "param2"))); + + try (Registration registration = extensionRegistry.newRegistration().set(testFlowService)) { + List<FlowActionTypeInfo> actions = gApi.changes().id(changeIdentifier).flowsActions(); + assertThat(actions).hasSize(2); + assertThat(actions.get(0).name).isEqualTo("action1"); + assertThat(actions.get(0).parametersPlaceholder).isEqualTo("param1"); + assertThat(actions.get(1).name).isEqualTo("action2"); + assertThat(actions.get(1).parametersPlaceholder).isEqualTo("param2"); + } + } + + private static FlowActionType action(String name, String parametersPlaceholder) { + return FlowActionType.builder(name).parametersPlaceholder(parametersPlaceholder).build(); + } +}
diff --git a/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java b/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java index 5e9e81a..a66edcf 100644 --- a/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java +++ b/javatests/com/google/gerrit/acceptance/api/plugin/CorsForPluginsIT.java
@@ -60,7 +60,8 @@ @Test public void noCorsConfig_CorsNotAllowed() throws Exception { try (AutoCloseable ignored = - installPlugin("foo", null, FooPluginHttpModule.class, null, PluginContentScanner.EMPTY)) { + installPlugin( + "foo", null, null, FooPluginHttpModule.class, null, PluginContentScanner.EMPTY)) { RestResponse rsp = execute("/plugins/foo/Documentation/foo.html", "evil"); assertThat(rsp.getHeader(ACCESS_CONTROL_ALLOW_ORIGIN)).isNull(); @@ -74,7 +75,8 @@ @GerritConfig(name = "site.allowOriginRegex", value = "friend") public void configConfigured_onlyMatchingOriginAllowed() throws Exception { try (AutoCloseable ignored = - installPlugin("foo", null, FooPluginHttpModule.class, null, PluginContentScanner.EMPTY)) { + installPlugin( + "foo", null, null, FooPluginHttpModule.class, null, PluginContentScanner.EMPTY)) { RestResponse rsp;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java index c03a710..b8163a0 100644 --- a/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java +++ b/javatests/com/google/gerrit/acceptance/api/project/AccessReviewIT.java
@@ -60,9 +60,9 @@ a.permissions = ImmutableMap.of("read", p); in.add = ImmutableMap.of("refs/heads/*", a); - RestResponse rep = + RestResponse resp = adminRestSession.put("/projects/" + defaultMessageProject.get() + "/access:review", in); - rep.assertCreated(); + resp.assertCreated(); List<ChangeInfo> result = gApi.changes()
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java index 3c24985..59d6f81 100644 --- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java +++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -214,15 +214,15 @@ @Test public void httpGet() throws Exception { - RestResponse rep = + RestResponse resp = adminRestSession.get( "/projects/" + normalProject.get() + "/check.access" + "?ref=refs/heads/master&perm=viewPrivateChanges&account=" + user.email()); - rep.assertOK(); - assertThat(rep.getEntityContent()).contains("403"); + resp.assertOK(); + assertThat(resp.getEntityContent()).contains("403"); } @Test @@ -386,7 +386,57 @@ // name is set to the UUID + "' by rule 'group " + privilegedGroupUuid.get() - + "')"))); + + "')")), + + // Test 10 + TestCase.projectRef( + admin.email(), + normalProject.get(), + "refs/heads/master", + 200, + ImmutableList.of( + "'admin' can access project '" + + normalProject.get() + + "' because they have the 'Administrate Server' global capability", + "'admin' can perform 'read' on project '" + + normalProject.get() + + "' for ref 'refs/heads/master' because they have the 'Administrate" + + " Server' global capability")), + + // Test 11 + TestCase.projectRef( + admin.email(), + secretRefProject.get(), + "refs/heads/secret/master", + 200, + ImmutableList.of( + "'admin' can access project '" + + secretRefProject.get() + + "' because they have the 'Administrate Server' global capability", + "'admin' can perform 'read' on project '" + + secretRefProject.get() + + "' for ref 'refs/heads/secret/master' because they have the 'Administrate" + + " Server' global capability")), + + // Test 12 + TestCase.project( + admin.email(), + normalProject.get(), + 200, + ImmutableList.of( + "'admin' can access project '" + + normalProject.get() + + "' because they have the 'Administrate Server' global capability")), + + // Test 13 + TestCase.project( + admin.email(), + secretProject.get(), + 200, + ImmutableList.of( + "'admin' can access project '" + + secretProject.get() + + "' because they have the 'Administrate Server' global capability"))); for (TestCase tc : inputs) { String in = newGson().toJson(tc.input);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java index 56da33d..3564001 100644 --- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java +++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -3004,6 +3004,14 @@ } @Test + public void diffOfRootIsAnEmptyDiffResult() throws Exception { + addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n")); + + DiffInfo diffInfo = getDiffRequest(changeId, CURRENT, "/").withBase(initialPatchSetId).get(); + assertThat(diffInfo).content().isEmpty(); + } + + @Test public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception { addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n")); String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java index eebcbb8..2cfb109 100644 --- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java +++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -140,6 +140,19 @@ @Inject private ChangeOperations changeOperations; @Test + public void get() throws Exception { + PushOneCommit.Result r = createChange(); + RevisionInfo revisionInfo = + gApi.changes() + .id(r.getChange().project().get(), r.getChange().getId().get()) + .revision(r.getCommit().name()) + .get(); + assertThat(revisionInfo._number).isEqualTo(r.getPatchSetId().get()); + assertThat(revisionInfo.branch).isEqualTo(r.getChange().change().getDest().branch()); + assertThat(revisionInfo.commit.commit).isEqualTo(r.getCommit().name()); + } + + @Test public void reviewTriplet() throws Exception { PushOneCommit.Result r = createChange(); gApi.changes()
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java index bfa5130..4ccb80b 100644 --- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java +++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -214,7 +214,6 @@ switch (p) { case SSH -> adminSshSession.getUrl(); case HTTP -> admin.getHttpUrl(server); - default -> throw new IllegalArgumentException("unexpected protocol: " + p); }; testRepo = GitUtil.cloneProject(project, url + "/" + project.get()); }
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java index 206a9d5..8fed0f1 100644 --- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java +++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -242,32 +242,18 @@ pushSubmoduleConfig(repo, branch, config); } - protected void createRelativeSubmoduleSubscription( + protected void createSubmoduleSubscription( TestRepository<?> repo, String branch, - String subscribeToRepoPrefix, + String url, Project.NameKey subscribeToRepo, String subscribeToBranch) throws Exception { Config config = new Config(); - prepareRelativeSubmoduleConfigEntry( - config, subscribeToRepoPrefix, subscribeToRepo, subscribeToBranch); + prepareSubmoduleConfigEntry(config, url, subscribeToRepo, subscribeToBranch); pushSubmoduleConfig(repo, branch, config); } - protected void prepareRelativeSubmoduleConfigEntry( - Config config, - String subscribeToRepoPrefix, - Project.NameKey subscribeToRepo, - String subscribeToBranch) { - String url = subscribeToRepoPrefix + subscribeToRepo.get(); - config.setString("submodule", subscribeToRepo.get(), "path", subscribeToRepo.get()); - config.setString("submodule", subscribeToRepo.get(), "url", url); - if (subscribeToBranch != null) { - config.setString("submodule", subscribeToRepo.get(), "branch", subscribeToBranch); - } - } - protected void prepareSubmoduleConfigEntry( Config config, Project.NameKey subscribeToRepo, String subscribeToBranch) { // The submodule subscription module checks for gerrit.canonicalWebUrl to @@ -285,8 +271,16 @@ // detect if it's configured for automatic updates. It doesn't matter if // it serves from that URL. String url = cfg.getString("gerrit", null, "canonicalWebUrl") + "/" + subscribeToRepo; + prepareSubmoduleConfigEntry(config, url, subscribeToRepoPath, subscribeToBranch); + } + + protected void prepareSubmoduleConfigEntry( + Config config, + String subscribeToUrl, + Project.NameKey subscribeToRepoPath, + String subscribeToBranch) { config.setString("submodule", subscribeToRepoPath.get(), "path", subscribeToRepoPath.get()); - config.setString("submodule", subscribeToRepoPath.get(), "url", url); + config.setString("submodule", subscribeToRepoPath.get(), "url", subscribeToUrl); if (subscribeToBranch != null) { config.setString("submodule", subscribeToRepoPath.get(), "branch", subscribeToBranch); }
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java index a4bfd7e..a1ee467 100644 --- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java +++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -456,14 +456,14 @@ } @Test - public void subscriptionDeepRelative() throws Exception { + public void subscriptionRelative() throws Exception { Project.NameKey nest = createProjectForPush(getSubmitType()); TestRepository<?> subRepo = cloneProject(nest); // master is allowed to be subscribed to any superprojects branch: allowMatchingSubmoduleSubscription(nest, "refs/heads/master", superKey, null); pushChangeTo(subRepo, "master"); - createRelativeSubmoduleSubscription(superRepo, "master", "../", nest, "master"); + createSubmoduleSubscription(superRepo, "master", "../" + nest, nest, "master"); ObjectId subHEAD = pushChangeTo(subRepo, "master"); @@ -471,6 +471,60 @@ } @Test + public void subscriptionNonRootRelative() throws Exception { + // Check that relative projects names work, even if ../../ doesn't reach the + // root. + Project.NameKey superKey = + projectOperations.newProject().name("path/to/super").submitType(getSubmitType()).create(); + grantPush(superKey); + Project.NameKey subKey = + projectOperations.newProject().name("path/other/sub").submitType(getSubmitType()).create(); + grantPush(subKey); + + TestRepository<?> superRepo = cloneProject(superKey); + TestRepository<?> subRepo = cloneProject(subKey); + // master is allowed to be subscribed to any superprojects branch: + allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, null); + + pushChangeTo(subRepo, "master"); + createSubmoduleSubscription(superRepo, "master", "../../other/sub", subKey, "master"); + + ObjectId subHEAD = pushChangeTo(subRepo, "master"); + + expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD); + } + + @Test + public void subscriptionRelativeStartsWithParent() throws Exception { + // Check that relative projects names has to start with ../ to avoid + // confusion if the subRepo name is super.git/sub.git or super/sub.git. + Project.NameKey subKey = + projectOperations.newProject().name(superKey + "/sub").submitType(getSubmitType()).create(); + grantPush(subKey); + + TestRepository<?> subRepo = cloneProject(subKey); + // master is allowed to be subscribed to any superprojects branch: + allowMatchingSubmoduleSubscription(subKey, "refs/heads/master", superKey, null); + + // Create the submodule via an initial subscription. + createSubmoduleSubscription(superRepo, "master", "../" + superKey + "/sub", subKey, "master"); + ObjectId subHEAD = pushChangeTo(subRepo, "master"); + expectToHaveSubmoduleState(superRepo, "master", subKey, subHEAD); + + ObjectId subHEADbeforeSubscribing = pushChangeTo(subRepo, "master"); + + deleteAllSubscriptions(superRepo, "master"); + createSubmoduleSubscription(superRepo, "master", "./sub", subKey, "master"); + pushChangeTo(subRepo, "master"); + expectToHaveSubmoduleState(superRepo, "master", subKey, subHEADbeforeSubscribing); + + deleteAllSubscriptions(superRepo, "master"); + createSubmoduleSubscription(superRepo, "master", "sub", subKey, "master"); + pushChangeTo(subRepo, "master"); + expectToHaveSubmoduleState(superRepo, "master", subKey, subHEADbeforeSubscribing); + } + + @Test @GerritConfig(name = "submodule.verboseSuperprojectUpdate", value = "SUBJECT_ONLY") @GerritConfig(name = "submodule.maxCommitMessages", value = "1") public void submoduleSubjectCommitMessageCountLimit() throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/lucene/LuceneIndexMetricsIT.java b/javatests/com/google/gerrit/acceptance/lucene/LuceneIndexMetricsIT.java index 16992df..88db4b8 100644 --- a/javatests/com/google/gerrit/acceptance/lucene/LuceneIndexMetricsIT.java +++ b/javatests/com/google/gerrit/acceptance/lucene/LuceneIndexMetricsIT.java
@@ -20,12 +20,11 @@ import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.TestMetricMaker; import com.google.gerrit.index.IndexType; -import com.google.inject.Inject; import org.junit.Test; public class LuceneIndexMetricsIT extends AbstractDaemonTest { - @Inject protected TestMetricMaker testMetricMaker; + protected final TestMetricMaker testMetricMaker = TestMetricMaker.getInstance(); private boolean isLuceneIndex = IndexType.fromEnvironment().map(IndexType::isLucene).orElse(false);
diff --git a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java index 8e1b86e..5a6c5c5 100644 --- a/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java +++ b/javatests/com/google/gerrit/acceptance/pgm/MigrateLabelFunctionsToSubmitRequirementIT.java
@@ -111,7 +111,7 @@ assertExistentSr( /* srName */ "Foo", /* applicabilityExpression= */ null, - /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader AND -label:Foo=MIN", + /* submittabilityExpression= */ "label:Foo=MAX&user=non_uploader AND -label:Foo=MIN", /* canOverride= */ true); assertLabelFunction("Foo", "NoBlock"); } @@ -128,7 +128,7 @@ assertExistentSr( /* srName */ "Foo", /* applicabilityExpression= */ null, - /* submittabilityExpression= */ "label:Foo=MAX,user=non_uploader", + /* submittabilityExpression= */ "label:Foo=MAX&user=non_uploader", /* canOverride= */ true); assertLabelFunction("Foo", "NoBlock"); }
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java index f7fb142..c3b1672 100644 --- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -392,7 +392,7 @@ RestResponse response = adminRestSession.put("/projects/new10"); assertThat(response.getStatusCode()).isEqualTo(SC_CREATED); verify(testPerformanceLogger, timeout(5000).atLeastOnce()) - .logNanos(anyString(), anyLong(), any()); + .logNanos(anyString(), anyLong(), any(), any()); } }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java index 625d53b..7cbc2a2 100644 --- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -30,6 +30,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; +import com.google.common.truth.Expect; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.acceptance.PushOneCommit; @@ -50,10 +51,13 @@ import com.google.gerrit.entities.PatchSetApproval; import com.google.gerrit.entities.Permission; import com.google.gerrit.entities.Project; +import com.google.gerrit.extensions.api.changes.AttentionSetInput; import com.google.gerrit.extensions.api.changes.DraftInput; import com.google.gerrit.extensions.api.changes.ReviewInput; import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput; import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling; +import com.google.gerrit.extensions.api.changes.ReviewerInput; +import com.google.gerrit.extensions.api.changes.ReviewerResult; import com.google.gerrit.extensions.api.changes.RevisionApi; import com.google.gerrit.extensions.api.changes.SubmitInput; import com.google.gerrit.extensions.api.groups.GroupInput; @@ -64,10 +68,10 @@ import com.google.gerrit.extensions.common.ChangeMessageInfo; import com.google.gerrit.extensions.common.CommentInfo; import com.google.gerrit.extensions.common.GroupInfo; +import com.google.gerrit.extensions.common.ReviewerUpdateInfo; 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; @@ -75,7 +79,9 @@ import com.google.gerrit.server.approval.ApprovalsUtil; import com.google.gerrit.server.project.testing.TestLabels; import com.google.gerrit.server.query.change.ChangeData; +import com.google.gerrit.server.util.AccountTemplateUtil; import com.google.inject.Inject; +import java.util.Collection; import org.apache.http.Header; import org.apache.http.message.BasicHeader; import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; @@ -85,15 +91,19 @@ import org.eclipse.jgit.revwalk.RevCommit; import org.junit.After; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; public class ImpersonationIT extends AbstractDaemonTest { + @Rule public final Expect expect = Expect.create(); + @Inject private AccountControl.Factory accountControlFactory; @Inject private ApprovalsUtil approvalsUtil; @Inject private ChangeMessagesUtil cmUtil; @Inject private CommentsUtil commentsUtil; @Inject private ProjectOperations projectOperations; @Inject private RequestScopeOperations requestScopeOperations; + @Inject private AccountTemplateUtil accountTemplateUtil; private TestAccount admin2; private GroupInfo newGroup; @@ -625,6 +635,74 @@ } } + @CanIgnoreReturnValue + private ChangeData testSubmitWithRunAs( + Project.NameKey project, TestAccount realUser, TestAccount impersonatedUser) + throws Exception { + allowRunAs(); + // Grant submit permission to all registered users. This will cover the impersonated user. + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.SUBMIT).ref("refs/heads/*").group(REGISTERED_USERS)) + .add( + allowLabel(TestLabels.codeReview().getName()) + .ref("refs/heads/*") + .group(REGISTERED_USERS) + .range(-2, 2)) + .update(); + + PushOneCommit.Result r = createChange(cloneProject(project, admin)); + String changeId = r.getChangeId(); + + // The realUser approves the change + requestScopeOperations.setApiUser(realUser.id()); + gApi.changes().id(changeId).current().review(ReviewInput.approve()); + SubmitInput in = new SubmitInput(); + + 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))); + + // Currently, the real user must be the admin user. If use wish to change that, modify the + // below call to create a new session. + expect + .withMessage("testSubmitWithRunAs#realUser must be admin") + .that(realUser.id()) + .isEqualTo(admin.id()); + RestResponse res = + adminRestSession.postWithHeaders( + "/changes/" + changeId + "/revisions/current/submit", + in, + runAsHeader(impersonatedUser.id())); + res.assertOK(); + ChangeInfo info = newGson().fromJson(res.getEntityContent(), ChangeInfo.class); + assertThat(info.status).isEqualTo(com.google.gerrit.extensions.client.ChangeStatus.MERGED); + + ChangeData cd = r.getChange(); + + PatchSetApproval submitter = + approvalsUtil.getSubmitter(cd.notes(), cd.currentPatchSet().id()); + assertThat(submitter.accountId()).isEqualTo(impersonatedUser.id()); + assertThat(submitter.realAccountId()).isEqualTo(realUser.id()); + + RevCommit changeMetaCommit = projectOperations.project(project).getHead(changeMetaRef); + assertThat(changeMetaCommit.getCommitterIdent().getEmailAddress()) + .isEqualTo(serverIdent.get().getEmailAddress()); + assertThat(changeMetaCommit.getAuthorIdent().getEmailAddress()) + .isEqualTo(changeNoteUtil.getAccountIdAsEmailAddress(impersonatedUser.id())); + + ReflogEntry changeMetaRefLogEntry = + repo.getRefDatabase().getReflogReader(changeMetaRef).getLastEntry(); + assertThat(changeMetaRefLogEntry.getWho().getEmailAddress()) + .isEqualTo(impersonatedUser.email()); + + return cd; + } + } + @Test public void submitOnBehalfOfInvalidUser() throws Exception { allowSubmitOnBehalfOf(); @@ -710,6 +788,235 @@ } @Test + @UseLocalDisk + public void submitWithRunAs_mergeAlways() throws Exception { + TestAccount impersonatedUser = admin2; // Must not be `admin`. + + // 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(); + + testSubmitWithRunAs(project, admin, 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.getRefDatabase().getReflogReader("refs/heads/master").getLastEntry(); + assertThat(targetBranchRefLogEntry.getWho().getEmailAddress()) + .isEqualTo(impersonatedUser.email()); + } + } + + @Test + @UseLocalDisk + public void submitWithRunAs_rebaseAlways() throws Exception { + TestAccount originalAuthor = admin; // user that creates and authors the change that is rebased + TestAccount impersonatedUser = user; // Must not be `admin`. + + // 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 = testSubmitWithRunAs(project, admin, 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.getRefDatabase().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.getRefDatabase().getReflogReader("refs/heads/master").getLastEntry(); + assertThat(targetBranchRefLogEntry.getWho().getEmailAddress()) + .isEqualTo(impersonatedUser.email()); + } + } + + @Test + public void setWorkInProgressWithRunAs() throws Exception { + allowRunAs(); + TestAccount realUser = admin; + TestAccount impersonatedUser = user; + + // Create a change as the user that will be impersonated. + TestRepository<InMemoryRepository> impersonatedUserRepo = + cloneProject(project, impersonatedUser); + PushOneCommit.Result r = + pushFactory + .create( + impersonatedUser.newIdent(), impersonatedUserRepo, "subject", "a.txt", "content") + .to("refs/for/master"); + String changeId = r.getChangeId(); + + ChangeInfo info = gApi.changes().id(changeId).get(); + assertThat(info.workInProgress).isNull(); + + RestResponse res = + adminRestSession.postWithHeaders( + "/changes/" + changeId + "/wip", + /* content= */ null, + runAsHeader(impersonatedUser.id())); + res.assertOK(); + + info = gApi.changes().id(changeId).get(); + assertThat(info.workInProgress).isTrue(); + + assertLastChangeMessage(r.getChange(), "Set Work In Progress", impersonatedUser, realUser); + } + + @Test + public void setReadyForReviewWithRunAs() throws Exception { + allowRunAs(); + TestAccount realUser = admin; + TestAccount impersonatedUser = user; + + // Create a change as the user that will be impersonated and set it to WIP. + TestRepository<InMemoryRepository> impersonatedUserRepo = + cloneProject(project, impersonatedUser); + PushOneCommit.Result r = + pushFactory + .create( + impersonatedUser.newIdent(), impersonatedUserRepo, "subject", "a.txt", "content") + .to("refs/for/master%wip"); + String changeId = r.getChangeId(); + + ChangeInfo info = gApi.changes().id(changeId).get(); + assertThat(info.workInProgress).isTrue(); + + RestResponse res = + adminRestSession.postWithHeaders( + "/changes/" + changeId + "/ready", + /* content= */ null, + runAsHeader(impersonatedUser.id())); + res.assertOK(); + + info = gApi.changes().id(changeId).get(); + assertThat(info.workInProgress).isNull(); + + assertLastChangeMessage(r.getChange(), "Set Ready For Review", impersonatedUser, realUser); + } + + @Test + public void addToAttentionSetWithRunAs() throws Exception { + allowRunAs(); + TestAccount realUser = admin; + TestAccount impersonatedUser = user; + TestAccount userToAddToAttentionSet = admin2; + + TestRepository<InMemoryRepository> impersonatedUserRepo = + cloneProject(project, impersonatedUser); + PushOneCommit.Result r = + pushFactory + .create( + impersonatedUser.newIdent(), impersonatedUserRepo, "subject", "a.txt", "content") + .to("refs/for/master"); + String changeId = r.getChangeId(); + + ChangeInfo info = gApi.changes().id(changeId).get(); + assertThat(info.attentionSet).isNull(); + + // The user to be added to the attention set must be a reviewer. + // The change owner (the impersonated user) can add reviewers. + requestScopeOperations.setApiUser(impersonatedUser.id()); + gApi.changes().id(changeId).addReviewer(userToAddToAttentionSet.email()); + requestScopeOperations.setApiUser(realUser.id()); + + AttentionSetInput attentionSetInput = + new AttentionSetInput(userToAddToAttentionSet.email(), "test attention message"); + + RestResponse res = + adminRestSession.postWithHeaders( + "/changes/" + changeId + "/attention", + attentionSetInput, + runAsHeader(impersonatedUser.id())); + res.assertOK(); + + info = gApi.changes().id(changeId).get(); + assertThat(info.attentionSet).isNotNull(); + assertThat(info.attentionSet).hasSize(1); + assertThat(Iterables.getOnlyElement(info.attentionSet.values()).account._accountId) + .isEqualTo(userToAddToAttentionSet.id().get()); + + assertLastChangeMessage( + r.getChange(), + "", // No change message is expected to be created when adding to attention set. + impersonatedUser, + realUser); + } + + @Test + public void removeFromAttentionSetWithRunAs() throws Exception { + allowRunAs(); + TestAccount realUser = admin; + TestAccount impersonatedUser = user; + TestAccount userInAttentionSet = admin2; + + TestRepository<InMemoryRepository> impersonatedUserRepo = + cloneProject(project, impersonatedUser); + PushOneCommit.Result r = + pushFactory + .create( + impersonatedUser.newIdent(), impersonatedUserRepo, "subject", "a.txt", "content") + .to("refs/for/master"); + String changeId = r.getChangeId(); + + // Add user to attention set as change owner. + requestScopeOperations.setApiUser(impersonatedUser.id()); + gApi.changes().id(changeId).addReviewer(userInAttentionSet.email()); + gApi.changes() + .id(changeId) + .addToAttentionSet( + new AttentionSetInput(userInAttentionSet.email(), "test attention message")); + requestScopeOperations.setApiUser(realUser.id()); + + ChangeInfo info = gApi.changes().id(changeId).get(); + assertThat(info.attentionSet).hasSize(1); + + AttentionSetInput attentionSetInput = + new AttentionSetInput(null, "test removed attention message"); + + RestResponse res = + adminRestSession.postWithHeaders( + "/changes/" + changeId + "/attention/" + userInAttentionSet.id().get() + "/delete", + attentionSetInput, + runAsHeader(impersonatedUser.id())); + res.assertNoContent(); + + info = gApi.changes().id(changeId).get(); + assertThat(info.attentionSet).isEmpty(); + + assertLastChangeMessage( + r.getChange(), + "", // No change message is expected to be created when removing from attention set. + impersonatedUser, + realUser); + } + + @Test public void runAsValidUser() throws Exception { allowRunAs(); RestResponse res = adminRestSession.getWithHeaders("/accounts/self", runAsHeader(user.id())); @@ -796,32 +1103,33 @@ public void runAsWithOnBehalfOf() throws Exception { // - Has the same restrictions as on_behalf_of (e.g. requires labels). // - Takes the effective user from on_behalf_of (user). - // - Takes the real user from the real caller, not the intermediate - // X-Gerrit-RunAs user (user2). + // - Takes the real user from the real caller (admin), not the intermediate + // X-Gerrit-RunAs user (runAsHeaderUser). allowRunAs(); allowCodeReviewOnBehalfOf(); - TestAccount user2 = accountCreator.user2(); + TestAccount runAsHeaderUser = accountCreator.user2(); PushOneCommit.Result r = createChange(); ReviewInput in = new ReviewInput(); in.onBehalfOf = user.id().toString(); - in.message = "Message on behalf of"; + in.message = "test message"; String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review"; - RestResponse res = adminRestSession.postWithHeaders(endpoint, in, runAsHeader(user2.id())); + RestResponse res = + adminRestSession.postWithHeaders(endpoint, in, runAsHeader(runAsHeaderUser.id())); res.assertForbidden(); assertThat(res.getEntityContent()) .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"'); in.label("Code-Review", 1); - adminRestSession.postWithHeaders(endpoint, in, runAsHeader(user2.id())).assertOK(); + adminRestSession.postWithHeaders(endpoint, in, runAsHeader(runAsHeaderUser.id())).assertOK(); 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.value()).isEqualTo(1); - assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not user2 + assertThat(psa.realAccountId()).isEqualTo(admin.id()); // not runAsHeaderUser assertLastChangeMessage(r.getChange(), in.message, user, admin); } @@ -845,29 +1153,156 @@ assertLastChangeMessage(r.getChange(), in.message, user, accountCreator.user2()); } + @Test + public void voteOnBehalfOfWithDisplayName() throws Exception { + allowCodeReviewOnBehalfOf(); + TestAccount realUser = admin; + TestAccount impersonatedUser = + accountCreator.create("impersonated-display", null, null, "impersonated-display-name"); + gApi.accounts().id(impersonatedUser.id().get()).setName("Impersonated Full Name"); + + PushOneCommit.Result r = createChange(); + + ReviewInput in = ReviewInput.recommend(); + in.onBehalfOf = impersonatedUser.id().toString(); + in.message = "Input test message"; + gApi.changes().id(r.getChangeId()).current().review(in); + + assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser); + } + + @Test + public void voteOnBehalfOfWithFullName() throws Exception { + allowCodeReviewOnBehalfOf(); + TestAccount realUser = admin; + TestAccount impersonatedUser = + accountCreator.create("impersonated-fullname", null, "Impersonated Full Name", null); + // Ensure display name is not set + gApi.accounts().id(impersonatedUser.id().get()).setDisplayName(""); + + PushOneCommit.Result r = createChange(); + + ReviewInput in = ReviewInput.recommend(); + in.onBehalfOf = impersonatedUser.id().toString(); + in.message = "Input test message"; + gApi.changes().id(r.getChangeId()).current().review(in); + + assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser); + } + + @Test + public void voteOnBehalfOfWithNoName() throws Exception { + allowCodeReviewOnBehalfOf(); + TestAccount realUser = admin; + TestAccount impersonatedUser = accountCreator.create("impersonated-noname", null, null, null); + + PushOneCommit.Result r = createChange(); + + ReviewInput in = ReviewInput.recommend(); + in.onBehalfOf = impersonatedUser.id().toString(); + in.message = "Input test message"; + gApi.changes().id(r.getChangeId()).current().review(in); + + assertLastChangeMessage(r.getChange(), in.message, impersonatedUser, realUser); + } + + @Test + public void addReviewerOnBehalfOf() throws Exception { + allowRunAs(); + TestAccount realUser = admin; + TestAccount impersonatedUser = user; + TestAccount reviewer = admin2; + PushOneCommit.Result r = createChange(); + assertThat(gApi.changes().id(r.getChangeId()).get(MESSAGES).messages).hasSize(1); + + ReviewerInput in = new ReviewerInput(); + in.reviewer = reviewer.email(); + in.onBehalfOf = impersonatedUser.id().toString(); + ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(in); + + assertThat(result.reviewers).hasSize(1); + assertThat(result.reviewers.get(0)._accountId).isEqualTo(reviewer.id().get()); + + Collection<ReviewerUpdateInfo> reviewerUpdates = + gApi.changes().id(r.getChangeId()).get().reviewerUpdates; + assertThat(reviewerUpdates).hasSize(1); + ReviewerUpdateInfo reviewerUpdate = reviewerUpdates.iterator().next(); + assertThat(reviewerUpdate.updatedBy._accountId).isEqualTo(impersonatedUser.id().get()); + assertThat(reviewerUpdate.reviewer._accountId).isEqualTo(reviewer.id().get()); + assertThat(reviewerUpdate.realUpdatedBy._accountId).isEqualTo(realUser.id().get()); + } + + @Test + public void addReviewerWithRunAs() throws Exception { + allowRunAs(); + TestAccount realUser = admin; + TestAccount impersonatedUser = user; + TestAccount reviewer = admin2; + PushOneCommit.Result r = createChange(); + assertThat(gApi.changes().id(r.getChangeId()).get(MESSAGES).messages).hasSize(1); + + ReviewerInput in = new ReviewerInput(); + in.reviewer = reviewer.email(); + + RestResponse res = + adminRestSession.postWithHeaders( + "/changes/" + r.getChangeId() + "/reviewers", in, runAsHeader(impersonatedUser.id())); + res.assertOK(); + ReviewerResult result = newGson().fromJson(res.getEntityContent(), ReviewerResult.class); + + assertThat(result.reviewers).hasSize(1); + assertThat(result.reviewers.get(0)._accountId).isEqualTo(reviewer.id().get()); + + Collection<ReviewerUpdateInfo> reviewerUpdates = + gApi.changes().id(r.getChangeId()).get().reviewerUpdates; + assertThat(reviewerUpdates).hasSize(1); + ReviewerUpdateInfo reviewerUpdate = reviewerUpdates.iterator().next(); + assertThat(reviewerUpdate.updatedBy._accountId).isEqualTo(impersonatedUser.id().get()); + assertThat(reviewerUpdate.reviewer._accountId).isEqualTo(reviewer.id().get()); + assertThat(reviewerUpdate.realUpdatedBy._accountId).isEqualTo(realUser.id().get()); + } + private void assertLastChangeMessage( ChangeData changeData, String expectedMessage, TestAccount expectedAuthor, TestAccount expectedRealAuthor) - throws RestApiException { + throws Exception { 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); + Iterables.getLast(gApi.changes().id(changeData.getId().get()).get(MESSAGES).messages); assertThat(lastChangeMessageInfo.author._accountId).isEqualTo(expectedAuthor.id().get()); if (expectedAuthor.id().equals(expectedRealAuthor.id())) { + assertThat(lastChangeMessageInfo.message).endsWith(expectedMessage); + assertThat(m.getMessage()).endsWith(expectedMessage); assertThat(lastChangeMessageInfo.realAuthor).isNull(); } else { + String expectedTemplateFullMessage = + concatImpersonationClause(expectedMessage, expectedAuthor, expectedRealAuthor); + assertThat(m.getMessage()).endsWith(expectedTemplateFullMessage); + + assertThat(lastChangeMessageInfo.message).endsWith(expectedTemplateFullMessage); assertThat(lastChangeMessageInfo.realAuthor._accountId) .isEqualTo(expectedRealAuthor.id().get()); } } + private String concatImpersonationClause( + String message, TestAccount author, TestAccount realAuthor) { + String impersonationClause = + String.format( + "(Performed by %s on behalf of %s)", + AccountTemplateUtil.getAccountTemplate(realAuthor.id()), + AccountTemplateUtil.getAccountTemplate(author.id())); + if (message.isEmpty()) { + return impersonationClause; + } + return message + "\n\n" + impersonationClause; + } + private void allowCodeReviewOnBehalfOf() throws Exception { projectOperations .project(project)
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java index b007b54..219e4b4 100644 --- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -131,6 +131,7 @@ */ private static final ImmutableList<RestCall> REVISION_ENDPOINTS = ImmutableList.of( + RestCall.get("/changes/%s/revisions/%s"), RestCall.get("/changes/%s/revisions/%s/actions"), RestCall.get("/changes/%s/revisions/%s/archive"), RestCall.post("/changes/%s/revisions/%s/cherrypick"),
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java index f5953d2..eb76385 100644 --- a/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/binding/FlowRestApiBindingsIT.java
@@ -43,6 +43,7 @@ ImmutableList.of( RestCall.get("/changes/%s/flows"), RestCall.get("/changes/%s/is-flows-enabled"), + RestCall.get("/changes/%s/flows-actions"), RestCall.post("/changes/%s/flows")); private static final ImmutableList<RestCall> FLOW_ENDPOINTS =
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java index a4cf221..6d72a64 100644 --- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -46,7 +46,7 @@ /** * Tests for checking the bindings of the projects REST API. * - * <p>These tests only verify that the project REST endpoints are correctly bound, they do no test + * <p>These tests only verify that the project REST endpoints are correctly bound, they do not test * the functionality of the project REST endpoints. */ public class ProjectsRestApiBindingsIT extends AbstractDaemonTest {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java index a5e23e9..9140a71 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -151,14 +151,14 @@ return cfg; } + final TestMetricMaker testMetricMaker = TestMetricMaker.getInstance(); + @Inject private ApprovalsUtil approvalsUtil; @Inject private IdentifiedUser.GenericFactory userFactory; @Inject private ProjectOperations projectOperations; @Inject private RequestScopeOperations requestScopeOperations; @Inject private Submit submitHandler; @Inject private ExtensionRegistry extensionRegistry; - @Inject TestMetricMaker testMetricMaker; - @Inject private ChangeIndexer changeIndex; protected MergeabilityComputationBehavior mcb;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java index c64f96e..bf4cf13 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeNoLongerSubmittableIT.java
@@ -87,6 +87,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()) .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n"); assertThat(message.htmlBody()) @@ -131,6 +136,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()) .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n"); assertThat(message.htmlBody()) @@ -176,6 +186,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()) .contains("The change is no longer submittable: Code-Review is unsatisfied now.\n"); assertThat(message.htmlBody()) @@ -296,6 +311,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()) .contains( "The change is no longer submittable:" @@ -348,6 +368,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()).doesNotContain("The change is no longer submittable"); assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable"); } @@ -394,6 +419,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()).doesNotContain("The change is no longer submittable"); assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable"); } @@ -431,6 +461,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()).doesNotContain("The change is no longer submittable"); assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable"); } @@ -468,6 +503,11 @@ + "\n" + "%s has posted comments on this change by %s.", admin.fullName(), user.fullName(), approver.fullName(), admin.fullName())); + assertThat(message.htmlBody()) + .contains( + String.format( + "<p>%s has posted comments on this change by %s.</p>", + approver.fullName(), admin.fullName())); assertThat(message.body()).doesNotContain("The change is no longer submittable"); assertThat(message.htmlBody()).doesNotContain("The change is no longer submittable"); }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java index dada354..adcea96 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -799,6 +799,29 @@ } @Test + public void addReviewerWithUpdatedSubject() throws Exception { + PushOneCommit.Result r = createChange(); + String changeId = r.getChangeId(); + + // Update the subject + String newSubject = "Updated Subject"; + amendChange(changeId, newSubject, "file.txt", "content"); + + // Add reviewer + ReviewerInput in = new ReviewerInput(); + in.reviewer = user.email(); + gApi.changes().id(changeId).addReviewer(in); + + // Verify email subject + ImmutableList<Message> messages = sender.getMessages(); + assertThat(messages).hasSize(1); + Message m = messages.get(0); + assertThat(m.headers()).containsKey("Subject"); + assertThat(m.headers().get("Subject").toString()).contains(newSubject); + assertThat(m.body()).contains(newSubject); + } + + @Test public void notifyDetailsWorkOnPostReviewers() throws Exception { PushOneCommit.Result r = createChange(); TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java index 81e5593..55751cd 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -64,6 +64,7 @@ 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.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; @@ -619,24 +620,129 @@ } @Test - public void createChangeWithParentCommit() throws Exception { - ImmutableMap<String, PushOneCommit.Result> setup = - changeInTwoBranches("foo", "foo.txt", "bar", "bar.txt"); + public void createChangeWithParentCommitThatIsNotAChange() throws Exception { + // Create a commit by direct push (no change) + PushOneCommit.Result parentCommit = + pushFactory.create(user.newIdent(), testRepo).to("refs/heads/master"); + parentCommit.assertOkStatus(); + ChangeInput input = newChangeInput(ChangeStatus.NEW); - input.baseCommit = setup.get("master").getCommit().getId().name(); - ChangeInfo result = assertCreateSucceeds(input); - assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit) + input.baseCommit = parentCommit.getCommit().getId().name(); + ChangeInfo changeInfo = assertCreateSucceeds(input); + assertThat(gApi.changes().id(changeInfo.id).current().commit(false).parents.get(0).commit) .isEqualTo(input.baseCommit); + + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(changeInfo.id).current().related().changes; + assertThat(relatedChanges).hasSize(0); } @Test - public void createChangeWithParentChange() throws Exception { - Result change = createChange(); + public void createChangeWithParentCommitThatIsAnOpenChange() throws Exception { + PushOneCommit.Result parentChange = createChange(); + ChangeInput input = newChangeInput(ChangeStatus.NEW); - input.baseChange = change.getChangeId(); - ChangeInfo result = assertCreateSucceeds(input); - assertThat(gApi.changes().id(result.id).current().commit(false).parents.get(0).commit) - .isEqualTo(change.getCommit().getId().name()); + input.baseCommit = parentChange.getCommit().getId().name(); + assertCreateFails( + input, + BadRequestException.class, + String.format("Commit %s doesn't exist on ref refs/heads/master", input.baseCommit)); + } + + @Test + public void createChangeWithParentCommitThatIsAnAbandonedChange() throws Exception { + PushOneCommit.Result parentChange = createChange(); + gApi.changes().id(parentChange.getChangeId()).abandon(); + + ChangeInput input = newChangeInput(ChangeStatus.NEW); + input.baseCommit = parentChange.getCommit().getId().name(); + assertCreateFails( + input, + BadRequestException.class, + String.format("Commit %s doesn't exist on ref refs/heads/master", input.baseCommit)); + } + + @Test + public void createChangeWithParentCommitThatIsAMergedChange() throws Exception { + PushOneCommit.Result parentChange = createChange(); + approve(parentChange.getChangeId()); + gApi.changes().id(parentChange.getChangeId()).current().submit(); + + ChangeInput input = newChangeInput(ChangeStatus.NEW); + input.baseCommit = parentChange.getCommit().getId().name(); + ChangeInfo changeInfo = assertCreateSucceeds(input); + assertThat(gApi.changes().id(changeInfo.id).current().commit(false).parents.get(0).commit) + .isEqualTo(input.baseCommit); + + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(changeInfo.id).current().related().changes; + assertThat(relatedChanges).hasSize(0); + } + + @Test + public void createChangeWithOpenParentChange() throws Exception { + PushOneCommit.Result parentChange = createChange(); + ChangeInput input = newChangeInput(ChangeStatus.NEW); + input.baseChange = parentChange.getChangeId(); + ChangeInfo changeInfo = assertCreateSucceeds(input); + assertThat(gApi.changes().id(changeInfo.id).current().commit(false).parents.get(0).commit) + .isEqualTo(parentChange.getCommit().getId().name()); + + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(changeInfo.id).current().related().changes; + assertThat(relatedChanges).hasSize(2); + assertThat(relatedChanges.get(0)._changeNumber).isEqualTo(changeInfo._number); + assertThat(relatedChanges.get(0)._revisionNumber).isEqualTo(1); + assertThat(relatedChanges.get(1)._changeNumber) + .isEqualTo(parentChange.getChange().getId().get()); + assertThat(relatedChanges.get(1)._revisionNumber).isEqualTo(parentChange.getPatchSetId().get()); + } + + @Test + public void createChangeWithAbandonedParentChange() throws Exception { + PushOneCommit.Result parentChange = createChange(); + gApi.changes().id(parentChange.getChangeId()).abandon(); + + ChangeInput input = newChangeInput(ChangeStatus.NEW); + input.baseChange = parentChange.getChangeId(); + ChangeInfo changeInfo = assertCreateSucceeds(input); + assertThat(gApi.changes().id(changeInfo.id).current().commit(false).parents.get(0).commit) + .isEqualTo(parentChange.getCommit().getId().name()); + + // Not sure if it's expected that the new change should have a relation to the merged parent + // change. If this change was created by git push the changes would not be related. + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(changeInfo.id).current().related().changes; + assertThat(relatedChanges).hasSize(2); + assertThat(relatedChanges.get(0)._changeNumber).isEqualTo(changeInfo._number); + assertThat(relatedChanges.get(0)._revisionNumber).isEqualTo(1); + assertThat(relatedChanges.get(1)._changeNumber) + .isEqualTo(parentChange.getChange().getId().get()); + assertThat(relatedChanges.get(1)._revisionNumber).isEqualTo(parentChange.getPatchSetId().get()); + } + + @Test + public void createChangeWithMergedParentChange() throws Exception { + PushOneCommit.Result parentChange = createChange(); + approve(parentChange.getChangeId()); + gApi.changes().id(parentChange.getChangeId()).current().submit(); + + ChangeInput input = newChangeInput(ChangeStatus.NEW); + input.baseChange = parentChange.getChangeId(); + ChangeInfo changeInfo = assertCreateSucceeds(input); + assertThat(gApi.changes().id(changeInfo.id).current().commit(false).parents.get(0).commit) + .isEqualTo(parentChange.getCommit().getId().name()); + + // Not sure if it's expected that the new change should have a relation to the merged parent + // change. If this was change was created by git push the changes would not be related. + List<RelatedChangeAndCommitInfo> relatedChanges = + gApi.changes().id(changeInfo.id).current().related().changes; + assertThat(relatedChanges).hasSize(2); + assertThat(relatedChanges.get(0)._changeNumber).isEqualTo(changeInfo._number); + assertThat(relatedChanges.get(0)._revisionNumber).isEqualTo(1); + assertThat(relatedChanges.get(1)._changeNumber) + .isEqualTo(parentChange.getChange().getId().get()); + assertThat(relatedChanges.get(1)._revisionNumber).isEqualTo(parentChange.getPatchSetId().get()); } @Test @@ -725,7 +831,7 @@ assertThat(commit.getAuthorIdent()).isEqualTo(expectedAuthor); assertThat(commit.getCommitterIdent()) - .isEqualTo(new PersonIdent(serverIdent.get(), c.created)); + .isEqualTo(new PersonIdent(serverIdent.get(), c.created.toInstant())); assertThat(commit.getParentCount()).isEqualTo(0); } }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitAddChangeReviewFootersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitAddChangeReviewFootersIT.java new file mode 100644 index 0000000..2c1507c --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitAddChangeReviewFootersIT.java
@@ -0,0 +1,99 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.common.truth.Truth.assertWithMessage; +import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; + +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.TestProjectInput; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.common.FooterConstants; +import com.google.gerrit.entities.Permission; +import com.google.gerrit.extensions.api.changes.ReviewInput; +import com.google.gerrit.extensions.client.SubmitType; +import com.google.inject.Inject; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.Test; + +public class SubmitAddChangeReviewFootersIT extends AbstractDaemonTest { + @Inject private ProjectOperations projectOperations; + + @Test + @TestProjectInput(submitType = SubmitType.CHERRY_PICK) + @GerritConfig(name = "change.addChangeReviewFootersToCommitMessage", value = "false") + public void submitWithSkipApprovals() throws Throwable { + // Grant submit permission + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS)) + .update(); + + PushOneCommit.Result change = createChange(); + gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve()); + gApi.changes().id(change.getChangeId()).current().submit(); + + RevCommit head = projectOperations.project(project).getHead("master"); + testRepo.getRevWalk().parseBody(head); + + // Check footers + assertWithMessage(head.getFullMessage()) + .that(head.getFooterLines(FooterConstants.CHANGE_ID)) + .isNotEmpty(); + assertWithMessage(head.getFullMessage()) + .that(head.getFooterLines(FooterConstants.REVIEWED_ON)) + .isNotEmpty(); + assertThat(head.getFooterLines(FooterConstants.REVIEWED_BY)).isEmpty(); + assertThat(head.getFooterLines(FooterConstants.TESTED_BY)).isEmpty(); + + // Verify it was actually the change we submitted (just in case) + assertThat(head.getShortMessage()).isEqualTo("test commit"); + } + + @Test + @TestProjectInput(submitType = SubmitType.CHERRY_PICK) + @GerritConfig(name = "change.addChangeReviewFootersToCommitMessage", value = "true") + public void submitWithAddApprovals() throws Throwable { + // Grant submit permission + projectOperations + .project(project) + .forUpdate() + .add(allow(Permission.SUBMIT).ref("refs/heads/master").group(REGISTERED_USERS)) + .update(); + + PushOneCommit.Result change = createChange(); + gApi.changes().id(change.getChangeId()).current().review(ReviewInput.approve()); + gApi.changes().id(change.getChangeId()).current().submit(); + + RevCommit head = projectOperations.project(project).getHead("master"); + testRepo.getRevWalk().parseBody(head); + + // Check footers + assertWithMessage(head.getFullMessage()) + .that(head.getFooterLines(FooterConstants.CHANGE_ID)) + .isNotEmpty(); + assertWithMessage(head.getFullMessage()) + .that(head.getFooterLines(FooterConstants.REVIEWED_ON)) + .isNotEmpty(); + assertWithMessage(head.getFullMessage()) + .that(head.getFooterLines(FooterConstants.REVIEWED_BY)) + .isNotEmpty(); + } +}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitWithDeletedUserIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitWithDeletedUserIT.java new file mode 100644 index 0000000..c55e9ab --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitWithDeletedUserIT.java
@@ -0,0 +1,121 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.allowLabel; +import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; + +import com.google.gerrit.acceptance.PushOneCommit; +import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.config.GerritConfig; +import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; +import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; +import com.google.gerrit.entities.Account; +import com.google.gerrit.entities.RefNames; +import com.google.gerrit.extensions.client.ChangeStatus; +import com.google.gerrit.extensions.client.SubmitType; +import com.google.gerrit.extensions.restapi.ResourceConflictException; +import com.google.gerrit.server.experiments.ExperimentFeaturesConstants; +import com.google.gerrit.server.project.testing.TestLabels; +import com.google.gerrit.server.update.context.RefUpdateContext; +import com.google.inject.Inject; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.Repository; +import org.junit.Before; +import org.junit.Test; + +public class SubmitWithDeletedUserIT extends AbstractSubmit { + @Inject private RequestScopeOperations requestScopeOperations; + @Inject private ProjectOperations projectOperations; + + @Override + protected SubmitType getSubmitType() { + return SubmitType.MERGE_IF_NECESSARY; + } + + @Before + public void setupPermissions() { + projectOperations + .project(project) + .forUpdate() + .add( + allowLabel(TestLabels.codeReview().getName()) + .ref("refs/heads/*") + .group(REGISTERED_USERS) + .range(-2, 2)) + .update(); + } + + @Test + public void submitWithApprovalFromDeletedUserFails() throws Exception { + // Create a new user + TestAccount user2 = accountCreator.create("user2", "user2@example.com", "User 2", null); + + // Create a change and approve it with user2 + PushOneCommit.Result r = createChange(); + requestScopeOperations.setApiUser(user2.id()); + approve(r.getChangeId()); + + // "Delete" user2 by removing their account ref + deleteAccount(user2.id()); + + // Try to submit as admin. + // It should fail because user2's approval is now ignored, so the change is not ready. + requestScopeOperations.setApiUser(admin.id()); + ResourceConflictException thrown = + assertThrows( + ResourceConflictException.class, + () -> gApi.changes().id(r.getChangeId()).current().submit()); + assertThat(thrown).hasMessageThat().contains("is not ready"); + } + + @Test + @GerritConfig( + name = "experiments.enabled", + value = ExperimentFeaturesConstants.CONSIDER_VOTES_OF_DELETED_ACCOUNTS) + public void submitWithApprovalFromDeletedUserSucceedsIfExperimentEnabled() throws Throwable { + // Create a new user + TestAccount user2 = accountCreator.create("user2", "user2@example.com", "User 2", null); + + // Create a change and approve it with user2 + PushOneCommit.Result r = createChange(); + requestScopeOperations.setApiUser(user2.id()); + approve(r.getChangeId()); + + // "Delete" user2 + deleteAccount(user2.id()); + + // Try to submit as admin. + // It should succeed because the experiment IS enabled, so deleted user's votes are NOT + // ignored. + requestScopeOperations.setApiUser(admin.id()); + gApi.changes().id(r.getChangeId()).current().submit(); + assertThat(gApi.changes().id(r.getChangeId()).get().status).isEqualTo(ChangeStatus.MERGED); + } + + private void deleteAccount(Account.Id id) throws Exception { + try (RefUpdateContext ctx = + RefUpdateContext.open(RefUpdateContext.RefUpdateType.ACCOUNTS_UPDATE)) { + try (Repository repo = repoManager.openRepository(allUsers)) { + RefUpdate ru = repo.updateRef(RefNames.refsUsers(id)); + ru.setForceUpdate(true); + RefUpdate.Result result = ru.delete(); + assertThat(result).isEqualTo(RefUpdate.Result.FORCED); + } + } + } +}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java index e417e54..8141f54 100644 --- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -626,6 +626,19 @@ } @Test + public void suggestRemovedReviewers() throws Exception { + requestScopeOperations.setApiUser(user.id()); + String changeId = createChangeFromApi(); + + String name = name("foo"); + TestAccount foo = accountCreator.create(name); + + gApi.changes().id(changeId).addReviewer(foo.id().toString()); + gApi.changes().id(changeId).reviewer(foo.id().toString()).remove(); + assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo), ImmutableList.of()); + } + + @Test public void suggestCcAsReviewer() throws Exception { requestScopeOperations.setApiUser(user.id()); String changeId = createChangeFromApi();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java index 9a0f5f5..ba84db1 100644 --- a/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/config/FlushCacheIT.java
@@ -32,19 +32,20 @@ @Test public void flushCache() throws Exception { - // access the admin group once so that it is loaded into the group cache + // get the groups of the admin user once so that group memberships are loaded into the + // groups_bymember cache @SuppressWarnings("unused") - var unused = adminGroup(); + var unused = adminRestSession.get("/accounts/self/groups"); - RestResponse r = adminRestSession.get("/config/server/caches/groups_byname"); + RestResponse r = adminRestSession.get("/config/server/caches/groups_bymember"); CacheInfo result = newGson().fromJson(r.getReader(), CacheInfo.class); assertThat(result.entries.mem).isGreaterThan((long) 0); - r = adminRestSession.post("/config/server/caches/groups_byname/flush"); + r = adminRestSession.post("/config/server/caches/groups_bymember/flush"); r.assertOK(); r.consume(); - r = adminRestSession.get("/config/server/caches/groups_byname"); + r = adminRestSession.get("/config/server/caches/groups_bymember"); result = newGson().fromJson(r.getReader(), CacheInfo.class); assertThat(result.entries.mem).isNull(); }
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexFlushIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexFlushIT.java new file mode 100644 index 0000000..591fac7 --- /dev/null +++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexFlushIT.java
@@ -0,0 +1,109 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF 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.config; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.acceptance.RestResponse; +import com.google.gerrit.index.project.ProjectIndexCollection; +import com.google.gerrit.index.testing.AbstractFakeIndex; +import com.google.gerrit.server.index.account.AccountIndexCollection; +import com.google.gerrit.server.index.change.ChangeIndexCollection; +import com.google.gerrit.server.index.group.GroupIndexCollection; +import com.google.inject.Inject; +import org.junit.Test; + +public class IndexFlushIT extends AbstractDaemonTest { + + @Inject private AccountIndexCollection accountIndexCollection; + @Inject private ChangeIndexCollection changeIndexCollection; + @Inject private ProjectIndexCollection projectIndexCollection; + @Inject private GroupIndexCollection groupIndexCollection; + + @Test + public void flushAndCommitAccountsIndex() throws Exception { + int initialCommitCount = totalFlushAndCommitCount(accountIndexCollection.getWriteIndexes()); + + assertIndexFlushAndCommit("accounts"); + + int finalCommitCount = totalFlushAndCommitCount(accountIndexCollection.getWriteIndexes()); + + assertThat(finalCommitCount).isEqualTo(initialCommitCount + 1); + } + + @Test + public void flushAndCommitChangesIndex() throws Exception { + int initialCommitCount = totalFlushAndCommitCount(changeIndexCollection.getWriteIndexes()); + + assertIndexFlushAndCommit("changes"); + + int finalCommitCount = totalFlushAndCommitCount(changeIndexCollection.getWriteIndexes()); + + assertThat(finalCommitCount).isEqualTo(initialCommitCount + 1); + } + + @Test + public void flushAndCommitProjectsIndex() throws Exception { + int initialCommitCount = totalFlushAndCommitCount(projectIndexCollection.getWriteIndexes()); + + assertIndexFlushAndCommit("projects"); + + int finalCommitCount = totalFlushAndCommitCount(projectIndexCollection.getWriteIndexes()); + + assertThat(finalCommitCount).isEqualTo(initialCommitCount + 1); + } + + @Test + public void flushAndCommitGroupsIndex() throws Exception { + int initialCommitCount = totalFlushAndCommitCount(groupIndexCollection.getWriteIndexes()); + + assertIndexFlushAndCommit("groups"); + + int finalCommitCount = totalFlushAndCommitCount(groupIndexCollection.getWriteIndexes()); + + assertThat(finalCommitCount).isEqualTo(initialCommitCount + 1); + } + + @Test + public void flushAndCommitInvalidIndex() throws Exception { + RestResponse response = adminRestSession.post("/config/server/indexes/invalidIndex/flush"); + + response.assertNotFound(); + } + + @Test + public void flushAndCommitForbiddenForUnauthorisedUsers() throws Exception { + RestResponse response = userRestSession.post("/config/server/indexes/changes/flush"); + + response.assertForbidden(); + } + + private int totalFlushAndCommitCount(Iterable<?> indexes) { + int total = 0; + for (Object index : indexes) { + AbstractFakeIndex<?, ?, ?> fakeIndex = (AbstractFakeIndex<?, ?, ?>) index; + total += fakeIndex.getFlushAndCommitCount(); + } + return total; + } + + private void assertIndexFlushAndCommit(String indexName) throws Exception { + RestResponse response = + adminRestSession.post(String.format("/config/server/indexes/%s/flush", indexName)); + + response.assertNoContent(); + } +}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java index 5cb4474..dc709fb 100644 --- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java +++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -277,7 +277,6 @@ updateAnnotatedTag(testRepo, tagName, user.newIdent()); } } - default -> throw new IllegalStateException("unexpected tag type: " + tagType); } if (!newCommit) {
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java index 5bd0e25..38cc6c7 100644 --- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java +++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -43,7 +43,6 @@ @Before public void setUp() throws Exception { repo = GitUtil.newTestRepository(repoManager.openRepository(project)); - blockRead(); } @After @@ -60,7 +59,6 @@ @Test public void getMergedCommit_Found() throws Exception { - unblockRead(); RevCommit commit = repo.parseBody(repo.branch("master").commit().message("Create\n\nNew commit\n").create()); @@ -83,6 +81,7 @@ @Test public void getMergedCommit_NotFound() throws Exception { + blockRead("refs/heads/master"); RevCommit commit = repo.parseBody(repo.branch("master").commit().message("Create\n\nNew commit\n").create()); assertNotFound(commit); @@ -90,7 +89,6 @@ @Test public void getOpenChange_Found() throws Exception { - unblockRead(); PushOneCommit.Result r = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master"); r.assertOkStatus(); @@ -113,37 +111,18 @@ @Test public void getOpenChange_NotFound() throws Exception { - // Need to unblock read to allow the push operation to succeed if not, when retrieving the - // advertised refs during - // the push, the client won't be sent the initial commit and will send it again as part of the - // change. - unblockRead(); - PushOneCommit.Result r = pushFactory.create(admin.newIdent(), testRepo).to("refs/for/master"); r.assertOkStatus(); - // Re-blocking the read - blockRead(); + blockRead("refs/heads/master"); assertNotFound(r.getCommit()); } - private void unblockRead() throws Exception { - try (ProjectConfigUpdate u = updateProject(project)) { - u.getConfig() - .upsertAccessSection( - "refs/*", - as -> { - as.remove(Permission.builder(Permission.READ)); - }); - u.save(); - } - } - - private void blockRead() { + private void blockRead(String ref) throws Exception { projectOperations .project(project) .forUpdate() - .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS)) + .add(block(Permission.READ).ref(ref).group(REGISTERED_USERS)) .update(); }
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java index 51025b9..09bed03 100644 --- a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java +++ b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
@@ -61,18 +61,13 @@ String method = restCall.httpMethod().name(); String uri = restCall.uri(args); - RestResponse response; - switch (restCall.httpMethod()) { - case GET -> response = restSession.get(uri); - case PUT -> response = restSession.put(uri); - case POST -> response = restSession.post(uri); - case DELETE -> response = restSession.delete(uri); - default -> { - assertWithMessage(String.format("unsupported method: %s", restCall.httpMethod().name())) - .fail(); - throw new IllegalStateException(); - } - } + RestResponse response = + switch (restCall.httpMethod()) { + case GET -> response = restSession.get(uri); + case PUT -> response = restSession.put(uri); + case POST -> response = restSession.post(uri); + case DELETE -> response = restSession.delete(uri); + }; int status = response.getStatusCode(); String body = response.hasContent() ? response.getEntityContent() : "";
diff --git a/javatests/com/google/gerrit/acceptance/server/change/BUILD b/javatests/com/google/gerrit/acceptance/server/change/BUILD index 33dfe67..56c27a0 100644 --- a/javatests/com/google/gerrit/acceptance/server/change/BUILD +++ b/javatests/com/google/gerrit/acceptance/server/change/BUILD
@@ -20,6 +20,6 @@ "//java/com/google/gerrit/acceptance:lib", "//java/com/google/gerrit/entities", "//java/com/google/gerrit/extensions:api", - "@guava//jar", + "//lib:guava", ], )
diff --git a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java index 97eda4a..e172153 100644 --- a/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java +++ b/javatests/com/google/gerrit/acceptance/server/experiments/ExperimentFeaturesIT.java
@@ -74,30 +74,6 @@ public void configOverride_defaultFeatureDisabled() { assertThat(experimentFeatures.isFeatureEnabled("enabledFeature")).isTrue(); assertThat(experimentFeatures.isFeatureEnabled("UiFeature__patchset_comments")).isFalse(); - - ImmutableSet<String> expectedEnabledFeatures = - new ImmutableSet.Builder<String>() - .addAll(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) - .add("enabledFeature") - .build(); - - assertThat(experimentFeatures.getEnabledExperimentFeatures()) - .isEqualTo(expectedEnabledFeatures); - } - - @Test - @GerritConfig( - name = "experiments.disabled", - values = {"UiFeature__get_ai_prompt"}) - public void configOverride_getAiPromptDisabled() { - assertThat(experimentFeatures.isFeatureEnabled("UiFeature__get_ai_prompt")).isFalse(); - - ImmutableSet<String> expectedEnabledFeatures = - ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES.stream() - .filter(feature -> !feature.equals("UiFeature__get_ai_prompt")) - .collect(ImmutableSet.toImmutableSet()); - - assertThat(experimentFeatures.getEnabledExperimentFeatures()) - .isEqualTo(expectedEnabledFeatures); + assertThat(experimentFeatures.getEnabledExperimentFeatures()).containsExactly("enabledFeature"); } }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java index 2dfcb6b..0bed233 100644 --- a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java +++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -19,6 +19,7 @@ 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.value; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; import com.google.common.collect.ImmutableMap; import com.google.common.collect.MoreCollectors; @@ -28,6 +29,7 @@ import com.google.gerrit.acceptance.NoHttpd; import com.google.gerrit.acceptance.PushOneCommit; import com.google.gerrit.acceptance.TestAccount; +import com.google.gerrit.acceptance.config.GerritConfig; import com.google.gerrit.acceptance.testsuite.change.ChangeOperations; import com.google.gerrit.acceptance.testsuite.project.ProjectOperations; import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations; @@ -250,6 +252,41 @@ } @Test + @GerritConfig(name = "submitRequirement.requireOperatorForUpdate", value = "true") + public void submitRequirementThrowsException_whenOperatorIsMissingInExpression() { + SubmitRequirementExpression exp = SubmitRequirementExpression.create("Code-Review=+1"); + QueryParseException e = + assertThrows(QueryParseException.class, () -> evaluator.validateExpression(exp)); + assertThat(e).hasMessageThat().contains("Operator is missing in submit requirement term:"); + } + + @Test + @GerritConfig(name = "submitRequirement.requireOperatorForUpdate", value = "false") + public void submitRequirementDoesNotThrowsException_whenOperatorIsMissingInExpression() + throws QueryParseException { + SubmitRequirementExpression exp = SubmitRequirementExpression.create("Code-Review=+1"); + evaluator.validateExpression(exp); + } + + @Test + @GerritConfig(name = "submitRequirement.requireOperatorForEvaluation", value = "true") + public void submitRequirementReportsErrorAtChange_whenOperatorIsMissingInExpression() + throws QueryParseException { + SubmitRequirement sr = createSubmitRequirement(null, "Code-Review=+1", null); + SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData); + assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR); + } + + @Test + @GerritConfig(name = "submitRequirement.requireOperatorForEvaluation", value = "false") + public void submitRequirementReportsNoErrorAtChange_whenOperatorIsMissingInExpression() + throws QueryParseException { + SubmitRequirement sr = createSubmitRequirement(null, "Code-Review=+1", null); + SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData); + assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED); + } + + @Test public void submitRequirement_alwaysNotApplicable() { SubmitRequirement sr = createSubmitRequirement(
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java index 24767cb..b200226 100644 --- a/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java +++ b/javatests/com/google/gerrit/acceptance/server/quota/MultipleQuotaPluginsIT.java
@@ -32,6 +32,7 @@ import com.google.gerrit.server.quota.QuotaResponse; import com.google.inject.Inject; import com.google.inject.Module; +import java.util.Optional; import java.util.OptionalLong; import org.junit.Before; import org.junit.Test; @@ -131,6 +132,39 @@ } @Test + public void quotaExceededMessageIsShownForMostRestrictiveEnforcer() { + QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build(); + when(quotaEnforcerA.availableTokens("testGroup", ctx)) + .thenReturn(QuotaResponse.ok(20L, "Message1")); + when(quotaEnforcerB.availableTokens("testGroup", ctx)) + .thenReturn(QuotaResponse.ok(10L, "Message2")); + + Optional<String> quotaExceededMessage = + quotaBackend + .user(identifiedAdmin) + .availableTokens("testGroup") + .mostRestrictiveQuotaExceededMessage(); + assertThat(quotaExceededMessage).hasValue("Message2"); + } + + @Test + public void quotaExceededMessagesAreJoinedForEquallyRestrictiveEnforcers() { + QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build(); + when(quotaEnforcerA.availableTokens("testGroup", ctx)) + .thenReturn(QuotaResponse.ok(10L, "Message1")); + when(quotaEnforcerB.availableTokens("testGroup", ctx)) + .thenReturn(QuotaResponse.ok(10L, "Message2")); + + Optional<String> quotaExceededMessage = + quotaBackend + .user(identifiedAdmin) + .availableTokens("testGroup") + .mostRestrictiveQuotaExceededMessage(); + + assertThat(quotaExceededMessage).hasValue("Message1,Message2"); + } + + @Test public void ignoreNoOpForAvailableTokens() { QuotaRequestContext ctx = QuotaRequestContext.builder().user(identifiedAdmin).build(); when(quotaEnforcerA.availableTokens("testGroup", ctx)).thenReturn(QuotaResponse.noOp());
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java index c9c3bbb..11fad5b 100644 --- a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java +++ b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
@@ -15,6 +15,7 @@ package com.google.gerrit.acceptance.server.quota; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.server.git.receive.AsyncReceiveCommits.DEFAULT_EXCEEDED_SIZE_QUOTA_TEMPLATE; import static com.google.gerrit.server.quota.QuotaGroupDefinitions.REPOSITORY_SIZE_GROUP; import static com.google.gerrit.server.quota.QuotaResponse.ok; import static com.google.gerrit.testing.GerritJUnit.assertThrows; @@ -34,7 +35,6 @@ import com.google.gerrit.server.quota.QuotaResponse; import com.google.inject.Module; import java.util.Collections; -import org.eclipse.jgit.api.errors.TooLargePackException; import org.eclipse.jgit.api.errors.TransportException; import org.junit.Before; import org.junit.Test; @@ -96,10 +96,29 @@ when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP)) .thenReturn(singletonAggregation(ok(availableTokens))); when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource); - assertThat(assertThrows(TooLargePackException.class, () -> pushCommit()).getMessage()) - .contains( - String.format( - "Pack exceeds the limit of %d bytes, rejecting the pack", availableTokens)); + assertThat(assertThrows(TransportException.class, () -> pushCommit()).getMessage()) + .contains(String.format(DEFAULT_EXCEEDED_SIZE_QUOTA_TEMPLATE, availableTokens)); + } + + @Test + public void pushWithZeroTokens() throws Exception { + long availableTokens = 0L; + when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP)) + .thenReturn(singletonAggregation(ok(availableTokens))); + when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource); + assertThat(assertThrows(TransportException.class, this::pushCommit).getMessage()) + .contains(String.format(DEFAULT_EXCEEDED_SIZE_QUOTA_TEMPLATE, availableTokens)); + } + + @Test + public void pushWithNotSufficientTokensCustomMessage() throws Exception { + long availableTokens = 1L; + String exceededQuotaCustomMessage = "Too much!"; + when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP)) + .thenReturn(singletonAggregation(ok(availableTokens, exceededQuotaCustomMessage))); + when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource); + assertThat(assertThrows(TransportException.class, this::pushCommit).getMessage()) + .contains(exceededQuotaCustomMessage); } @Test
diff --git a/javatests/com/google/gerrit/acceptance/server/util/WorkQueueIT.java b/javatests/com/google/gerrit/acceptance/server/util/WorkQueueIT.java index 21a4d96..92aca78 100644 --- a/javatests/com/google/gerrit/acceptance/server/util/WorkQueueIT.java +++ b/javatests/com/google/gerrit/acceptance/server/util/WorkQueueIT.java
@@ -18,11 +18,16 @@ import com.google.gerrit.acceptance.AbstractDaemonTest; import com.google.gerrit.extensions.annotations.Exports; +import com.google.gerrit.server.config.ConfigResource; import com.google.gerrit.server.git.WorkQueue; +import com.google.gerrit.server.git.WorkQueue.Task.State; +import com.google.gerrit.server.restapi.config.ListTasks; import com.google.inject.AbstractModule; import com.google.inject.Inject; import com.google.inject.Module; +import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -45,12 +50,14 @@ } private static final Integer FIXED_RATE_SCHEDULE_INITIAL_DELAY = 0; - private static final Integer FIXED_RATE_SCHEDULE_INTERVAL_MILLI_SEC = 1000; + private static final Integer FIXED_RATE_SCHEDULE_INTERVAL_MILLI_SEC = 200; private static final Integer POOL_CORE_SIZE = 8; private static final String QUEUE_NAME = "test-Queue"; private static final Integer EXCEPT_RUN_TIMES = 2; + private static final Integer TIMEOUT_MILLIS = 500; private final CountDownLatch downLatch = new CountDownLatch(EXCEPT_RUN_TIMES); @Inject private WorkQueue workQueue; + @Inject private ListTasks listTasks; private TestListener testListener; @Override @@ -82,4 +89,50 @@ assertThat(ifRunMoreThanOnce).isTrue(); testExecutor.shutdownNow(); } + + @Test + public void testCanceledTaskStaysUntilFinished() throws Exception { + ScheduledExecutorService testExecutor = workQueue.createQueue(POOL_CORE_SIZE, QUEUE_NAME); + CountDownLatch latch = new CountDownLatch(1); + Future<?> taskFuture = + testExecutor.submit( + () -> { + try { + latch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }); + assertTasksInStateEventually(QUEUE_NAME, State.RUNNING, 1); + + taskFuture.cancel(false); + assertTasksInStateEventually(QUEUE_NAME, State.CANCELLED, 1); + + latch.countDown(); + // task is now removed after completion + assertEventually( + () -> + listTasks.apply(new ConfigResource()).value().stream() + .noneMatch(t -> t.queueName.equals(QUEUE_NAME))); + testExecutor.shutdownNow(); + } + + public void assertTasksInStateEventually(String queue, State expectedState, int expectedCount) + throws Exception { + assertEventually( + () -> + expectedCount + == listTasks.apply(new ConfigResource()).value().stream() + .filter(t -> t.queueName.equals(queue)) + .filter(t -> t.state.equals(expectedState)) + .count()); + } + + public void assertEventually(Callable<Boolean> r) throws Exception { + long ms = 0; + while (r.call() != true) { + assertThat(ms++).isLessThan(TIMEOUT_MILLIS); + TimeUnit.MILLISECONDS.sleep(1); + } + } }
diff --git a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java index b6b5dcc..9221999 100644 --- a/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java +++ b/javatests/com/google/gerrit/acceptance/ssh/StreamEventsIT.java
@@ -127,7 +127,7 @@ waitForEvent(() -> pollEventsContaining("ref-updated", "refs/draft-comments/").size() == 1); } - @Test() + @Test @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "true") @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "false") @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "false") @@ -157,7 +157,7 @@ () -> pollEventsContaining("batch-ref-updated", "refs/draft-comments/").size() == 1); } - @Test() + @Test @GerritConfig(name = "event.stream-events.enableBatchRefUpdatedEvents", value = "true") @GerritConfig(name = "event.stream-events.enableRefUpdatedEvents", value = "false") @GerritConfig(name = "event.stream-events.enableDraftCommentEvents", value = "false")
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java index 9e563fc..ef76e20 100644 --- a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java +++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -1748,9 +1748,19 @@ @Test public void newPatchsetKeepsFileContentsWithDifferentParent() throws Exception { Change.Id changeId = - changeOperations.newChange().file("file1").content("Actual change content").createV1(); + changeOperations + .newChange() + .project(project) + .file("file1") + .content("Actual change content") + .createV1(); Change.Id newParentChange = - changeOperations.newChange().file("file1").content("Parent content").createV1(); + changeOperations + .newChange() + .project(project) + .file("file1") + .content("Parent content") + .createV1(); changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
diff --git a/javatests/com/google/gerrit/auth/BUILD b/javatests/com/google/gerrit/auth/BUILD index 6a18174..517f942 100644 --- a/javatests/com/google/gerrit/auth/BUILD +++ b/javatests/com/google/gerrit/auth/BUILD
@@ -1,4 +1,3 @@ -load("@rules_java//java:defs.bzl", "java_library") load("//tools/bzl:junit.bzl", "junit_tests") junit_tests(
diff --git a/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java new file mode 100644 index 0000000..756a266 --- /dev/null +++ b/javatests/com/google/gerrit/entities/converter/AccountProtoConverterTest.java
@@ -0,0 +1,138 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.entities.converter; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass; + +import com.google.common.collect.ImmutableMap; +import com.google.gerrit.entities.Account; +import com.google.gerrit.proto.testing.SerializedClassSubject; +import java.lang.reflect.Type; +import java.time.Instant; +import org.junit.Test; + +/** + * Tests for {@link AccountProtoConverter}. + * + * <p>These tests cover the requirements that would be enforced by {@code SafeProtoConverterTest} if + * {@code AccountProtoConverter} implemented {@code SafeProtoConverter}. The converter cannot use + * {@code SafeProtoConverter} because proto3 cannot distinguish between unset and empty string + * fields, while Account treats null and empty string differently. + */ +public class AccountProtoConverterTest { + private final AccountProtoConverter converter = AccountProtoConverter.INSTANCE; + + @Test + public void allFieldsConvertedToProtoAndBack() { + Account account = + Account.builder(Account.id(123), Instant.ofEpochMilli(1234567890L)) + .setFullName("Test User") + .setDisplayName("Test") + .setPreferredEmail("test@example.com") + .setAvatarEmail("avatar@example.com") + .setInactive(false) + .setStatus("OOO") + .setMetaId("meta-123") + .setUniqueTag("unique-123") + .build(); + + Account roundTripped = converter.fromProto(converter.toProto(account)); + + assertThat(roundTripped).isEqualTo(account); + } + + @Test + public void nullFieldsPreservedThroughRoundTrip() { + Account account = + Account.builder(Account.id(789), Instant.ofEpochMilli(1111111111L)) + .setInactive(false) + .build(); + + Account roundTripped = converter.fromProto(converter.toProto(account)); + + assertThat(roundTripped.fullName()).isNull(); + assertThat(roundTripped.displayName()).isNull(); + assertThat(roundTripped.preferredEmail()).isNull(); + assertThat(roundTripped.avatarEmail()).isNull(); + assertThat(roundTripped.status()).isNull(); + } + + @Test + public void avatarEmailPreservedThroughRoundTrip() { + Account account = + Account.builder(Account.id(100), Instant.ofEpochMilli(2222222222L)) + .setPreferredEmail("preferred@example.com") + .setAvatarEmail("avatar@example.com") + .setInactive(false) + .build(); + + Account roundTripped = converter.fromProto(converter.toProto(account)); + + assertThat(roundTripped.avatarEmail()).isEqualTo("avatar@example.com"); + assertThat(roundTripped.preferredEmail()).isEqualTo("preferred@example.com"); + assertThat(roundTripped.effectiveAvatarEmail()).isEqualTo("avatar@example.com"); + } + + @Test + public void effectiveAvatarEmailFallsBackWhenAvatarEmailNull() { + Account account = + Account.builder(Account.id(101), Instant.ofEpochMilli(3333333333L)) + .setPreferredEmail("preferred@example.com") + .setInactive(false) + .build(); + + Account roundTripped = converter.fromProto(converter.toProto(account)); + + assertThat(roundTripped.avatarEmail()).isNull(); + assertThat(roundTripped.effectiveAvatarEmail()).isEqualTo("preferred@example.com"); + } + + @Test + public void inactiveFieldPreserved() { + Account active = + Account.builder(Account.id(1), Instant.ofEpochMilli(1L)).setInactive(false).build(); + Account inactive = + Account.builder(Account.id(2), Instant.ofEpochMilli(1L)).setInactive(true).build(); + + assertThat(converter.fromProto(converter.toProto(active)).inactive()).isFalse(); + assertThat(converter.fromProto(converter.toProto(inactive)).inactive()).isTrue(); + } + + /** + * If this test fails, it's likely that a field was added to or removed from {@link Account}. If a + * field was added, please update {@link AccountProtoConverter} and the {@code AccountProto} in + * {@code cache.proto} accordingly. + * + * @see SerializedClassSubject + */ + @Test + public void accountFieldsMatchExpected() { + assertThatSerializedClass(Account.class) + .hasFields( + ImmutableMap.<String, Type>builder() + .put("id", Account.Id.class) + .put("registeredOn", Instant.class) + .put("fullName", String.class) + .put("displayName", String.class) + .put("preferredEmail", String.class) + .put("avatarEmail", String.class) + .put("inactive", boolean.class) + .put("status", String.class) + .put("metaId", String.class) + .put("uniqueTag", String.class) + .build()); + } +}
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD index c1490fe..71c690a 100644 --- a/javatests/com/google/gerrit/entities/converter/BUILD +++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -18,10 +18,11 @@ "//lib:guava-testlib", "//lib:jgit", "//lib:protobuf", + "//lib/commons:lang3", "//lib/guice", "//lib/truth", "//lib/truth:truth-proto-extension", + "//proto:cache_java_proto", "//proto:entities_java_proto", - "@commons-lang3//jar", ], )
diff --git a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java index 828f6c1..5ca382a 100644 --- a/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java +++ b/javatests/com/google/gerrit/extensions/registration/DynamicItemTest.java
@@ -31,6 +31,7 @@ public class DynamicItemTest { private static final String PLUGIN_NAME = "plugin-name"; + private static final String ANOTHER_PLUGIN = "another-plugin"; private static final String UNEXPECTED_PLUGIN_NAME = "unexpected-plugin"; private static final String DYNAMIC_ITEM_1 = "item-1"; @@ -56,7 +57,43 @@ } @Test - public void shouldAssignDynamicItemTwice() { + public void shouldAssignDynamicItemTwice_GerritCoreThenPlugin() { + shouldAssignDynamicItemTwice(PluginName.GERRIT, ANOTHER_PLUGIN); + } + + @Test + public void shouldAssignDynamicItemTwice_PluginOverridesPlugin() { + shouldAssignDynamicItemTwice(PLUGIN_NAME, ANOTHER_PLUGIN); + } + + private void shouldAssignDynamicItemTwice(String initialPlugin, String overridingPlugin) { + ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings = + ImmutableMap.of(STRING_TYPE_LITERAL, DynamicItem.itemOf(String.class, null)); + + ImmutableList<RegistrationHandle> gerritRegistrations = + PrivateInternals_DynamicTypes.attachItems( + newInjector( + (binder) -> { + DynamicItem.itemOf(binder, String.class); + DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_1); + }), + initialPlugin, + bindings); + assertThat(gerritRegistrations).hasSize(1); + assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_1, initialPlugin); + + ImmutableList<RegistrationHandle> pluginRegistrations = + PrivateInternals_DynamicTypes.attachItems( + newInjector( + (binder) -> DynamicItem.bind(binder, String.class).toInstance(DYNAMIC_ITEM_2)), + overridingPlugin, + bindings); + assertThat(pluginRegistrations).hasSize(1); + assertDynamicItem(bindings.get(STRING_TYPE_LITERAL), DYNAMIC_ITEM_2, overridingPlugin); + } + + @Test + public void shouldAssignDynamicItemAlreadyAssigned() { ImmutableMap<TypeLiteral<?>, DynamicItem<?>> bindings = ImmutableMap.of(STRING_TYPE_LITERAL, DynamicItem.itemOf(String.class, null));
diff --git a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java index a106aa1..be71a79 100644 --- a/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java +++ b/javatests/com/google/gerrit/git/RefUpdateUtilRepoTest.java
@@ -24,7 +24,10 @@ import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; import org.eclipse.jgit.internal.storage.file.FileRepository; import org.eclipse.jgit.junit.TestRepository; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -116,4 +119,17 @@ RefUpdateUtil.deleteChecked(repo, "refs/heads/foo"); assertThat(repo.exactRef(ref)).isNull(); } + + @Test + public void deleteCheckedSetsNewObjectIdToZeroId() throws Exception { + String ref = "refs/heads/foo"; + + try (TestRepository<Repository> tr = new TestRepository<>(repo)) { + @SuppressWarnings("unused") + RevCommit ignored = tr.branch(ref).commit().create(); + } + + RefUpdate refUpdate = RefUpdateUtil.deleteChecked(repo, ref); + assertThat(refUpdate.getNewObjectId()).isEqualTo(ObjectId.zeroId()); + } }
diff --git a/javatests/com/google/gerrit/httpd/ProjectOAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectOAuthFilterTest.java index 3ce35de..7f8e2f1 100644 --- a/javatests/com/google/gerrit/httpd/ProjectOAuthFilterTest.java +++ b/javatests/com/google/gerrit/httpd/ProjectOAuthFilterTest.java
@@ -37,7 +37,9 @@ import com.google.gerrit.server.account.AuthRequest; import com.google.gerrit.server.account.AuthResult; import com.google.gerrit.server.account.externalids.ExternalId; +import com.google.gerrit.server.account.externalids.ExternalIdFactory; import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory; +import com.google.gerrit.server.account.externalids.storage.notedb.ExternalIdFactoryNoteDbImpl; import com.google.gerrit.server.config.AuthConfig; import com.google.gerrit.util.http.testutil.FakeHttpServletRequest; import com.google.gerrit.util.http.testutil.FakeHttpServletResponse; @@ -72,11 +74,7 @@ String.format("%s:%s", AUTH_USER, OAUTH_TOKEN).getBytes(StandardCharsets.UTF_8)); private static final OAuthUserInfo OAUTH_USER_INFO = new OAuthUserInfo( - String.format("oauth-test:%s", AUTH_USER), - AUTH_USER, - "johndoe@example.com", - "John Doe", - null); + String.format("test:%s", AUTH_USER), AUTH_USER, "johndoe@example.com", "John Doe", null); @Mock private DynamicItem<WebSession> webSessionItem; @@ -100,6 +98,7 @@ private FakeHttpServletRequest req; private HttpServletResponse res; private AuthResult authSuccessful; + private ExternalIdFactory extIdFactory; private ExternalIdKeyFactory extIdKeyFactory; private AuthRequest.Factory authRequestFactory; private Config gerritConfig = new Config(); @@ -110,6 +109,7 @@ res = new FakeHttpServletResponse(); extIdKeyFactory = new ExternalIdKeyFactory(new ExternalIdKeyFactory.ConfigImpl(authConfig)); + extIdFactory = new ExternalIdFactoryNoteDbImpl(extIdKeyFactory, authConfig); authRequestFactory = new AuthRequest.Factory(extIdKeyFactory); authSuccessful = @@ -288,14 +288,22 @@ oAuthFilter.init(null); oAuthFilter.doFilter(req, res, chain); - verify(accountManager).authenticate(any()); + AuthRequest expected = + authRequestFactory.create( + ExternalIdKeyFactory.parse(OAUTH_USER_INFO.getExternalId(), false)); + expected.setUserName(OAUTH_USER_INFO.getUserName()); + expected.setPassword(OAUTH_TOKEN); + expected.setAuthPlugin("oauth"); + expected.setAuthProvider("test"); + + verify(accountManager).authenticate(eq(expected)); verify(chain, never()).doFilter(any(), any()); assertThat(res.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED); } private void initAccount() throws Exception { - initAccount(ImmutableSet.of()); + initAccount(ImmutableSet.of(extIdFactory.create("test", AUTH_USER, AUTH_ACCOUNT_ID))); } private void initAccount(Collection<ExternalId> extIds) throws Exception {
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java index 976a883..925f9b2 100644 --- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java +++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -43,7 +43,13 @@ assertThat( staticTemplateData( "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain)) - .containsExactly("canonicalPath", "", "staticResourcePath", ordain("")); + .containsExactly( + "canonicalPath", + "", + "staticResourcePath", + ordain(""), + "manifestPath", + ordain("/manifest.webmanifest")); } @Test @@ -55,7 +61,13 @@ null, new HashMap<>(), IndexHtmlUtilTest::ordain)) - .containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit")); + .containsExactly( + "canonicalPath", + "/gerrit", + "staticResourcePath", + ordain("/gerrit"), + "manifestPath", + ordain("/gerrit/manifest.webmanifest")); } @Test @@ -68,7 +80,12 @@ new HashMap<>(), IndexHtmlUtilTest::ordain)) .containsExactly( - "canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/")); + "canonicalPath", + "", + "staticResourcePath", + ordain("http://my-cdn.com/foo/bar/"), + "manifestPath", + ordain("/manifest.webmanifest")); } @Test @@ -81,7 +98,12 @@ new HashMap<>(), IndexHtmlUtilTest::ordain)) .containsExactly( - "canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/")); + "canonicalPath", + "/gerrit", + "staticResourcePath", + ordain("http://my-cdn.com/foo/bar/"), + "manifestPath", + ordain("/gerrit/manifest.webmanifest")); } @Test @@ -92,7 +114,14 @@ staticTemplateData( "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain)) .containsExactly( - "canonicalPath", "", "staticResourcePath", ordain(""), "useGoogleFonts", "true"); + "canonicalPath", + "", + "staticResourcePath", + ordain(""), + "manifestPath", + ordain("/manifest.webmanifest"), + "useGoogleFonts", + "true"); } @Test @@ -117,9 +146,11 @@ String requestedPath = "/c/project/+/123/4..6"; assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(4); - assertThat(dynamicTemplateData(gerritApi, requestedPath, "", false)) + assertThat( + dynamicTemplateData( + gerritApi, requestedPath, "", serverApi.getInfo(), serverApi.getVersion())) .containsAtLeast( - "defaultChangeDetailHex", "9996394", + "defaultChangeDetailHex", "8896394", "changeRequestsPath", "changes/project~123"); } @@ -145,9 +176,11 @@ String requestedPath = "/c/project/+/123"; assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(0); - assertThat(dynamicTemplateData(gerritApi, requestedPath, "", false)) + assertThat( + dynamicTemplateData( + gerritApi, requestedPath, "", serverApi.getInfo(), serverApi.getVersion())) .containsAtLeast( - "defaultChangeDetailHex", "1996394", + "defaultChangeDetailHex", "896394", "changeRequestsPath", "changes/project~123"); } @@ -173,7 +206,9 @@ String requestedPath = "/c/project/+/123"; assertThat(IndexHtmlUtil.computeBasePatchNum(requestedPath)).isEqualTo(0); - assertThat(dynamicTemplateData(gerritApi, requestedPath, "", true)) + assertThat( + dynamicTemplateData( + gerritApi, requestedPath, "", serverApi.getInfo(), serverApi.getVersion())) .containsAtLeast( "defaultChangeDetailHex", "896394", "submitRequirementsHex", "1900000",
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java index 6865fe2..9becbd6 100644 --- a/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java +++ b/javatests/com/google/gerrit/httpd/raw/IndexServletTest.java
@@ -16,6 +16,8 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableList; @@ -60,8 +62,7 @@ String testCdnPath = "bar-cdn"; String testFaviconURL = "zaz-url"; - assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES) - .containsExactly(ExperimentFeaturesConstants.UI_FEATURE_GET_AI_PROMPT); + assertThat(ExperimentFeaturesConstants.DEFAULT_ENABLED_FEATURES).isEmpty(); org.eclipse.jgit.lib.Config serverConfig = new org.eclipse.jgit.lib.Config(); serverConfig.setStringList( @@ -69,9 +70,17 @@ serverConfig.setStringList( "experiments", null, "disabled", ImmutableList.of("DisabledFeature")); ExperimentFeatures experimentFeatures = new ConfigExperimentFeatures(serverConfig); + com.google.common.cache.Cache< + String, com.google.gerrit.server.config.ServerConfigCacheImpl.ServerConfigData> + serverConfigCache = com.google.common.cache.CacheBuilder.newBuilder().build(); IndexServlet servlet = new IndexServlet( - testCanonicalUrl, testCdnPath, testFaviconURL, gerritApi, experimentFeatures); + testCanonicalUrl, + testCdnPath, + testFaviconURL, + gerritApi, + experimentFeatures, + serverConfigCache); FakeHttpServletResponse response = new FakeHttpServletResponse(); @@ -106,4 +115,45 @@ + String.join("\\x22,\\x22", expectedEnabled) + "\\x22\\x5d');</script>"); } + + @Test + public void serverConfigIsCached() throws Exception { + Accounts accountsApi = mock(Accounts.class); + when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated")); + + Server serverApi = mock(Server.class); + when(serverApi.getVersion()).thenReturn("123"); + when(serverApi.topMenus()).thenReturn(ImmutableList.of(), ImmutableList.of()); + ServerInfo serverInfo = new ServerInfo(); + serverInfo.defaultTheme = "my-default-theme"; + when(serverApi.getInfo()).thenReturn(serverInfo); + + Config configApi = mock(Config.class); + when(configApi.server()).thenReturn(serverApi); + + GerritApi gerritApi = mock(GerritApi.class); + when(gerritApi.accounts()).thenReturn(accountsApi); + when(gerritApi.config()).thenReturn(configApi); + + com.google.common.cache.Cache< + String, com.google.gerrit.server.config.ServerConfigCacheImpl.ServerConfigData> + serverConfigCache = com.google.common.cache.CacheBuilder.newBuilder().build(); + + IndexServlet servlet = + new IndexServlet( + "foo-url", + "bar-cdn", + "zaz-url", + gerritApi, + new ConfigExperimentFeatures(new org.eclipse.jgit.lib.Config()), + serverConfigCache); + + servlet.doGet(new FakeHttpServletRequest(), new FakeHttpServletResponse()); + servlet.doGet(new FakeHttpServletRequest(), new FakeHttpServletResponse()); + + verify(serverApi, times(1)).getInfo(); + verify(serverApi, times(1)).getVersion(); + // topMenus is no longer cached, so it should be called dynamically per user request + verify(serverApi, times(2)).topMenus(); + } }
diff --git a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java index e973a26..f9b1044 100644 --- a/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java +++ b/javatests/com/google/gerrit/httpd/raw/StaticModuleTest.java
@@ -16,7 +16,15 @@ import static com.google.common.truth.Truth.assertThat; import static com.google.gerrit.httpd.raw.StaticModule.PolyGerritFilter.isPolyGerritIndex; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.google.gerrit.httpd.raw.StaticModule.ManifestServlet; +import java.io.PrintWriter; +import java.io.StringWriter; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import org.junit.Test; public class StaticModuleTest { @@ -44,4 +52,22 @@ assertThat(isPolyGerritIndex("/c/321/+/123456/comment/9ab75172_67d798e1")).isTrue(); assertThat(isPolyGerritIndex("/c/321/anyString")).isTrue(); } + + @Test + public void manifestServlet_servesManifest() throws Exception { + ManifestServlet servlet = new ManifestServlet("Gerrit", null); + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse resp = mock(HttpServletResponse.class); + StringWriter stringWriter = new StringWriter(); + PrintWriter printWriter = new PrintWriter(stringWriter); + when(resp.getWriter()).thenReturn(printWriter); + + servlet.doGet(req, resp); + + verify(resp).setContentType("application/manifest+json"); + verify(resp).setHeader("Cache-Control", "public, max-age=900"); + verify(resp).setStatus(HttpServletResponse.SC_OK); + assertThat(stringWriter.toString()).contains("\"start_url\":\"" + "." + "\""); + assertThat(stringWriter.toString()).contains("\"display\":\"" + "standalone" + "\""); + } }
diff --git a/javatests/com/google/gerrit/index/query/QueryParserTest.java b/javatests/com/google/gerrit/index/query/QueryParserTest.java index 268c388..35d0a85 100644 --- a/javatests/com/google/gerrit/index/query/QueryParserTest.java +++ b/javatests/com/google/gerrit/index/query/QueryParserTest.java
@@ -208,6 +208,17 @@ } @Test + public void fieldNameAndValueWithAmpersand() throws Exception { + Tree r = parse("label:foo&bar"); + assertThat(r).hasType(FIELD_NAME); + assertThat(r).hasText("label"); + assertThat(r).hasChildCount(1); + assertThat(r).child(0).hasType(SINGLE_WORD); + assertThat(r).child(0).hasText("foo&bar"); + assertThat(r).child(0).hasNoChildren(); + } + + @Test public void fieldNameWithEscapedDoubleQuotesInValue() throws Exception { // Actual String: A \"special\" word String search = "message:\"A \\\"special\\\" word\"";
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/BUILD b/javatests/com/google/gerrit/metrics/dropwizard/BUILD index 2dbe7f0..5821848 100644 --- a/javatests/com/google/gerrit/metrics/dropwizard/BUILD +++ b/javatests/com/google/gerrit/metrics/dropwizard/BUILD
@@ -9,8 +9,8 @@ "//java/com/google/gerrit/metrics", "//java/com/google/gerrit/metrics/dropwizard", "//java/com/google/gerrit/testing:gerrit-junit", + "//lib/dropwizard:dropwizard-core", "//lib/mockito", "//lib/truth", - "@dropwizard-core//jar", ], )
diff --git a/javatests/com/google/gerrit/pgm/BUILD b/javatests/com/google/gerrit/pgm/BUILD index 43b86cb..d30e7ee 100644 --- a/javatests/com/google/gerrit/pgm/BUILD +++ b/javatests/com/google/gerrit/pgm/BUILD
@@ -14,11 +14,11 @@ "//lib:jgit", "//lib:jgit-junit", "//lib:junit", + "//lib:servlet-api", "//lib/guice", + "//lib/jetty:server", "//lib/mockito", "//lib/truth", - "@jetty-server//jar", - "@servlet-api//jar", ], )
diff --git a/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java b/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java index dcbcc76..23c9724 100644 --- a/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java +++ b/javatests/com/google/gerrit/pgm/http/jetty/ProjectQoSFilterTest.java
@@ -15,6 +15,7 @@ package com.google.gerrit.pgm.http.jetty; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.mockito.Mockito.when; import com.google.gerrit.server.CurrentUser; @@ -65,6 +66,7 @@ try { listener.onComplete(asyncEvent); } catch (Exception e) { + assertWithMessage("should never happen").fail(); } } }.run(); @@ -89,6 +91,7 @@ try { listener.onTimeout(asyncEvent); } catch (Exception e) { + assertWithMessage("should never happen").fail(); } } }.run(); @@ -114,6 +117,7 @@ try { listener.onError(asyncEvent); } catch (Exception e) { + assertWithMessage("should never happen").fail(); } } }.run();
diff --git a/javatests/com/google/gerrit/server/cache/CacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/CacheFactoryIT.java new file mode 100644 index 0000000..593685e --- /dev/null +++ b/javatests/com/google/gerrit/server/cache/CacheFactoryIT.java
@@ -0,0 +1,58 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.cache; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.acceptance.AbstractDaemonTest; +import com.google.gerrit.extensions.registration.DynamicMap; +import com.google.gerrit.extensions.registration.Extension; +import com.google.inject.Inject; +import java.util.Optional; +import java.util.stream.StreamSupport; +import org.junit.Test; + +public class CacheFactoryIT extends AbstractDaemonTest { + private static final String CACHE_NAME = "dummy-cache"; + + @Inject private DynamicMap<CacheDef<?, ?>> cacheDefs; + + @Override + public com.google.inject.Module createModule() { + return new TestModule(); + } + + @Test + public void newCacheDefIsAvailableInDynamicMap() { + assertThat(cacheDefs).isNotNull(); + + Optional<? extends CacheDef<?, ?>> def = + StreamSupport.stream(cacheDefs.spliterator(), false) + .filter(e -> e.getExportName().equals(CACHE_NAME)) + .map(Extension::get) + .findFirst(); + assertThat(def).isPresent(); + assertThat(def.get().keyType().getRawType()).isEqualTo(String.class); + assertThat(def.get().valueType().getRawType()).isEqualTo(String.class); + } + + public static class TestModule extends CacheModule { + + @Override + protected void configure() { + cache(CACHE_NAME, String.class, String.class); + } + } +}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD index d68a5c1..d72e1ed 100644 --- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD +++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -9,6 +9,7 @@ "//java/com/google/gerrit/server", "//java/com/google/gerrit/server/cache/serialize/entities", "//java/com/google/gerrit/server/util/time", + "//lib:gson", "//lib:guava", "//lib:jgit", "//lib:protobuf", @@ -16,6 +17,5 @@ "//lib/truth:truth-proto-extension", "//proto:cache_java_proto", "//proto/testing:test_java_proto", - "@gson//jar", ], )
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java index 36f0058..fdbfffa 100644 --- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java +++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -236,11 +236,26 @@ } @Test - public void defaultPreferences_throwingForProto() throws Exception { + public void defaultPreferences_notThrowingForEmptyProto() throws Exception { CachedPreferences defaults = CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance()); CachedPreferences userPreferences = CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance()); + + assertThat(CachedPreferences.general(Optional.of(defaults), userPreferences)).isNotNull(); + assertThat(CachedPreferences.diff(Optional.of(defaults), userPreferences)).isNotNull(); + assertThat(CachedPreferences.edit(Optional.of(defaults), userPreferences)).isNotNull(); + } + + @Test + public void defaultPreferences_throwingForNonEmptyProto() throws Exception { + CachedPreferences defaults = + CachedPreferences.fromUserPreferencesProto( + UserPreferences.newBuilder() + .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.getDefaultInstance()) + .build()); + CachedPreferences userPreferences = + CachedPreferences.fromUserPreferencesProto(UserPreferences.getDefaultInstance()); assertThrows( StorageException.class, () -> CachedPreferences.general(Optional.of(defaults), userPreferences));
diff --git a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java index ffdd2a1..a4dade4 100644 --- a/javatests/com/google/gerrit/server/events/EventDeserializerTest.java +++ b/javatests/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -15,10 +15,13 @@ package com.google.gerrit.server.events; import static com.google.common.truth.Truth.assertThat; +import static com.google.gerrit.testing.GerritJUnit.assertThrows; import com.google.common.base.Supplier; import com.google.common.base.Suppliers; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.reflect.TypeToken; import com.google.gerrit.entities.Account; import com.google.gerrit.entities.BranchNameKey; import com.google.gerrit.entities.Change; @@ -30,11 +33,14 @@ import com.google.gerrit.server.data.RefUpdateAttribute; import com.google.gerrit.server.util.time.TimeUtil; import com.google.gson.Gson; +import com.google.gson.JsonParseException; import org.junit.Test; public class EventDeserializerTest { private final Gson gson = new EventGsonProvider().get(); + public record TestComplexType(int intField) {} + @Test public void refUpdatedEvent() { RefUpdatedEvent orig = new RefUpdatedEvent(); @@ -287,6 +293,55 @@ assertThat(gson.toJson(projectNameKey)).isEqualTo(String.format("\"%s\"", projectString)); } + @Test + public void shouldDeserializeToImmutableListOfStrings() { + ImmutableList<String> deserializedListOfString = + gson.fromJson( + "[\"string1\",\"string2\"]", new TypeToken<ImmutableList<String>>() {}.getType()); + assertThat(deserializedListOfString).containsExactly("string1", "string2"); + } + + @Test + public void shouldDeserializeToImmutableListOfAnything() { + ImmutableList<?> deserializedListOfAnything = + gson.fromJson("[1.0,2.0]", new TypeToken<ImmutableList<?>>() {}.getType()); + assertThat(deserializedListOfAnything).containsExactly(1.0, 2.0); + } + + @Test + @SuppressWarnings("rawtypes") + public void shouldDeserializeToRawImmutableList() { + ImmutableList deserializedRawList = gson.fromJson("[\"foo\",2.0]", ImmutableList.class); + assertThat(deserializedRawList).containsExactly("foo", 2.0); + } + + @Test + public void shouldDeserializeToImmutableListOfComplexType() { + ImmutableList<TestComplexType> deserializedListOfComplexType = + gson.fromJson( + "[{\"intField\": 1},{\"intField\": 2}]", + new TypeToken<ImmutableList<TestComplexType>>() {}.getType()); + assertThat(deserializedListOfComplexType) + .containsExactly(new TestComplexType(1), new TestComplexType(2)); + } + + @Test + public void shouldDeserializeToEmptyImmutableList() { + ImmutableList<?> deserializedListOfComplexType = + gson.fromJson("[]", new TypeToken<ImmutableList<?>>() {}.getType()); + assertThat(deserializedListOfComplexType).isEmpty(); + } + + @Test + public void shouldFailToDeserializeToImmutableListOfNulls() { + assertThrows(JsonParseException.class, () -> gson.fromJson("[null]", ImmutableList.class)); + } + + @Test + public void shouldFailToDeserializeNonArrayToImmutableList() { + assertThrows(JsonParseException.class, () -> gson.fromJson("{}", ImmutableList.class)); + } + private <T> Supplier<T> createSupplier(T value) { return Suppliers.memoize(() -> value); }
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java index e8eb015..2444165 100644 --- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java +++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -120,9 +120,12 @@ assertForceLogging(true); assertThat(LoggingContext.getInstance().isPerformanceLogging()).isTrue(); - // The performance log record that was added in the inner thread is available in addition to + // The performance log record that was added in the inner thread is available in addition + // to. + // The performance log record that LoggingContextAwareRunnable adds when running the + // runnable. // the performance log record that was created in the outer thread. - assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(2); + assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).hasSize(3); } }
diff --git a/javatests/com/google/gerrit/server/logging/MetadataTest.java b/javatests/com/google/gerrit/server/logging/MetadataTest.java index d7981f8..649c0bd 100644 --- a/javatests/com/google/gerrit/server/logging/MetadataTest.java +++ b/javatests/com/google/gerrit/server/logging/MetadataTest.java
@@ -18,7 +18,102 @@ import org.junit.Test; +/** Unit tests for {@link Metadata}. */ public class MetadataTest { + private final String OPERATION_NAME = "operation"; + + @Test + public void decorateOperationName() { + // operation is not decorated if metadata is empty or doesn't contain relevant fields + assertThat(Metadata.empty().decorateOperation(OPERATION_NAME)).isEqualTo(OPERATION_NAME); + assertThat(Metadata.builder().projectName("project").build().decorateOperation(OPERATION_NAME)) + .isEqualTo(OPERATION_NAME); + + // plugin name and class are included only if the operation name is "plugin/latency" + assertThat(Metadata.empty().decorateOperation("plugin/latency")).isEqualTo("plugin/latency"); + assertThat(Metadata.builder().pluginName("plugin").build().decorateOperation("plugin/latency")) + .isEqualTo("plugin/latency (plugin)"); + assertThat(Metadata.builder().className("class").build().decorateOperation("plugin/latency")) + .isEqualTo("plugin/latency (class)"); + assertThat( + Metadata.builder() + .pluginName("plugin") + .className("class") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("plugin/latency (plugin:class)"); + assertThat(Metadata.builder().pluginName("plugin").build().decorateOperation(OPERATION_NAME)) + .isEqualTo(OPERATION_NAME); + assertThat(Metadata.builder().className("class").build().decorateOperation(OPERATION_NAME)) + .isEqualTo(OPERATION_NAME); + assertThat( + Metadata.builder() + .pluginName("plugin") + .className("class") + .build() + .decorateOperation(OPERATION_NAME)) + .isEqualTo(OPERATION_NAME); + + // thread name is included if available + assertThat(Metadata.builder().thread("thread").build().decorateOperation(OPERATION_NAME)) + .isEqualTo("[thread] " + OPERATION_NAME); + assertThat(Metadata.builder().thread("thread").build().decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency"); + assertThat( + Metadata.builder() + .thread("thread") + .pluginName("plugin") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency (plugin)"); + assertThat( + Metadata.builder() + .thread("thread") + .className("class") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency (class)"); + assertThat( + Metadata.builder() + .thread("thread") + .pluginName("plugin") + .className("class") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency (plugin:class)"); + + // view name is included if available + assertThat(Metadata.builder().restViewName("MyView").build().decorateOperation(OPERATION_NAME)) + .isEqualTo(OPERATION_NAME + " (view: MyView)"); + assertThat( + Metadata.builder().restViewName("MyView").build().decorateOperation("plugin/latency")) + .isEqualTo("plugin/latency (view: MyView)"); + assertThat( + Metadata.builder() + .restViewName("MyView") + .thread("thread") + .pluginName("plugin") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency (plugin) (view: MyView)"); + assertThat( + Metadata.builder() + .restViewName("MyView") + .thread("thread") + .className("class") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency (class) (view: MyView)"); + assertThat( + Metadata.builder() + .restViewName("MyView") + .thread("thread") + .pluginName("plugin") + .className("class") + .build() + .decorateOperation("plugin/latency")) + .isEqualTo("[thread] plugin/latency (plugin:class) (view: MyView)"); + } @Test public void stringForLoggingOmitsEmptyOptionalValuesAndReformatsOptionalValuesThatArePresent() {
diff --git a/javatests/com/google/gerrit/server/logging/RunningOperationsTest.java b/javatests/com/google/gerrit/server/logging/RunningOperationsTest.java new file mode 100644 index 0000000..3647624 --- /dev/null +++ b/javatests/com/google/gerrit/server/logging/RunningOperationsTest.java
@@ -0,0 +1,108 @@ +// Copyright (C) 2025 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package com.google.gerrit.server.logging; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.gerrit.server.logging.RunningOperations.RegistrationHandle; +import org.junit.Test; + +/** Unit tests for {@link RunningOperations}. */ +public class RunningOperationsTest { + @Test + @SuppressWarnings("unused") + public void operationsAreOrdered() { + RunningOperations runningOperations = new RunningOperations(); + var unused = runningOperations.add("foo", Metadata.empty()); + var unused2 = runningOperations.add("bar", Metadata.empty()); + var unused3 = runningOperations.add("baz", Metadata.empty()); + assertThat(runningOperations.toOperationNames()).containsExactly("foo", "bar", "baz").inOrder(); + } + + @Test + public void removeOperationsInOrder() { + RunningOperations runningOperations = new RunningOperations(); + RegistrationHandle fooHandle = runningOperations.add("foo", Metadata.empty()); + RegistrationHandle barHandle = runningOperations.add("bar", Metadata.empty()); + RegistrationHandle bazHandle = runningOperations.add("baz", Metadata.empty()); + assertThat(runningOperations.toOperationNames()).containsExactly("foo", "bar", "baz").inOrder(); + + bazHandle.remove(); + assertThat(runningOperations.toOperationNames()).containsExactly("foo", "bar").inOrder(); + + barHandle.remove(); + assertThat(runningOperations.toOperationNames()).containsExactly("foo"); + + fooHandle.remove(); + assertThat(runningOperations.toOperationNames()).isEmpty(); + } + + @Test + public void removeOperationsOutOfOrder() { + RunningOperations runningOperations = new RunningOperations(); + RegistrationHandle fooHandle = runningOperations.add("foo", Metadata.empty()); + RegistrationHandle barHandle = runningOperations.add("bar", Metadata.empty()); + RegistrationHandle bazHandle = runningOperations.add("baz", Metadata.empty()); + assertThat(runningOperations.toOperationNames()).containsExactly("foo", "bar", "baz").inOrder(); + + barHandle.remove(); + assertThat(runningOperations.toOperationNames()).containsExactly("foo", "baz").inOrder(); + + fooHandle.remove(); + assertThat(runningOperations.toOperationNames()).containsExactly("baz"); + + bazHandle.remove(); + assertThat(runningOperations.toOperationNames()).isEmpty(); + } + + @Test + public void removeOperationTwice() { + RunningOperations runningOperations = new RunningOperations(); + RegistrationHandle fooHandle = runningOperations.add("foo", Metadata.empty()); + assertThat(runningOperations.toOperationNames()).containsExactly("foo"); + + fooHandle.remove(); + assertThat(runningOperations.toOperationNames()).isEmpty(); + + fooHandle.remove(); + assertThat(runningOperations.toOperationNames()).isEmpty(); + } + + @Test + @SuppressWarnings("unused") + public void operationsWithTheSameName() { + RunningOperations runningOperations = new RunningOperations(); + RegistrationHandle fooHandle = runningOperations.add("foo", Metadata.empty()); + var unused = runningOperations.add("bar", Metadata.empty()); + var unused2 = runningOperations.add("foo", Metadata.empty()); + assertThat(runningOperations.toOperationNames()).containsExactly("foo", "bar", "foo").inOrder(); + + fooHandle.remove(); + assertThat(runningOperations.toOperationNames()).containsExactly("bar", "foo").inOrder(); + } + + @Test + @SuppressWarnings("unused") + public void operationNamesAreDecorated() { + RunningOperations runningOperations = new RunningOperations(); + var unused = runningOperations.add("foo", Metadata.builder().thread("thread").build()); + var unused2 = + runningOperations.add( + "plugin/latency", Metadata.builder().pluginName("plugin").className("class").build()); + assertThat(runningOperations.toOperationNames()) + .containsExactly("[thread] foo", "plugin/latency (plugin:class)") + .inOrder(); + } +}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java index 0aa4fcb..f482287 100644 --- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java +++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -662,11 +662,55 @@ ReviewerStatusUpdate.createForReviewer( Instant.ofEpochMilli(1212L), Account.id(1000), + Account.id(1001), Account.id(2002), ReviewerStateInternal.CC), ReviewerStatusUpdate.createForReviewer( Instant.ofEpochMilli(3434L), Account.id(1000), + Account.id(1002), + Account.id(2001), + ReviewerStateInternal.REVIEWER))) + .build(), + ChangeNotesStateProto.newBuilder() + .setMetaId(SHA_BYTES) + .setChangeId(ID.get()) + .setColumns(colsProto) + .addReviewerUpdate( + ReviewerStatusUpdateProto.newBuilder() + .setTimestampMillis(1212L) + .setUpdatedBy(1000) + .setRealUpdatedBy(1001) + .setHasReviewer(true) + .setReviewer(2002) + .setState("CC")) + .addReviewerUpdate( + ReviewerStatusUpdateProto.newBuilder() + .setTimestampMillis(3434L) + .setUpdatedBy(1000) + .setRealUpdatedBy(1002) + .setHasReviewer(true) + .setReviewer(2001) + .setState("REVIEWER")) + .build()); + } + + @Test + public void serializeReviewerUpdatesWithoutRealUpdatedBy() throws Exception { + assertRoundTrip( + newBuilder() + .reviewerUpdates( + ImmutableList.of( + ReviewerStatusUpdate.createForReviewer( + Instant.ofEpochMilli(1212L), + Account.id(1000), + null, + Account.id(2002), + ReviewerStateInternal.CC), + ReviewerStatusUpdate.createForReviewer( + Instant.ofEpochMilli(3434L), + Account.id(1000), + null, Account.id(2001), ReviewerStateInternal.REVIEWER))) .build(), @@ -700,11 +744,13 @@ ReviewerStatusUpdate.createForReviewerByEmail( Instant.ofEpochMilli(1212L), Account.id(1000), + Account.id(1001), Address.parse("email1@example.com"), ReviewerStateInternal.CC), ReviewerStatusUpdate.createForReviewerByEmail( Instant.ofEpochMilli(3434L), Account.id(1000), + Account.id(1002), Address.parse("email2@example.com"), ReviewerStateInternal.REVIEWER))) .build(), @@ -716,6 +762,7 @@ ReviewerStatusUpdateProto.newBuilder() .setTimestampMillis(1212L) .setUpdatedBy(1000) + .setRealUpdatedBy(1001) .setHasReviewerByEmail(true) .setReviewerByEmail("email1@example.com") .setState("CC")) @@ -723,6 +770,7 @@ ReviewerStatusUpdateProto.newBuilder() .setTimestampMillis(3434L) .setUpdatedBy(1000) + .setRealUpdatedBy(1002) .setHasReviewerByEmail(true) .setReviewerByEmail("email2@example.com") .setState("REVIEWER")) @@ -1109,11 +1157,18 @@ assertThatSerializedClass(ReviewerStatusUpdate.class) .hasAutoValueMethods( ImmutableMap.of( - "date", Instant.class, - "updatedBy", Account.Id.class, - "reviewer", new TypeLiteral<Optional<Account.Id>>() {}.getType(), - "reviewerByEmail", new TypeLiteral<Optional<Address>>() {}.getType(), - "state", ReviewerStateInternal.class)); + "date", + Instant.class, + "updatedBy", + Account.Id.class, + "realUpdatedBy", + new TypeLiteral<Optional<Account.Id>>() {}.getType(), + "reviewer", + new TypeLiteral<Optional<Account.Id>>() {}.getType(), + "reviewerByEmail", + new TypeLiteral<Optional<Address>>() {}.getType(), + "state", + ReviewerStateInternal.class)); } @Test
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java index 71e60d2..6cf766a 100644 --- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java +++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -3564,7 +3564,14 @@ update.commit(); ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages()); - assertThat(msg.getMessage()).isEqualTo("Message on behalf of other user"); + assertThat(msg.getMessage()) + .isEqualTo( + "Message on behalf of other user" + + "\n\n" + + String.format( + "(Performed by %s on behalf of %s)", + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()), + AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId()))); assertThat(msg.getAuthor()).isEqualTo(otherUserId); assertThat(msg.getRealAuthor()).isEqualTo(changeOwner.getAccountId()); }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java index 90f858d..8d79fad 100644 --- a/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java +++ b/javatests/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -26,6 +26,7 @@ import com.google.gerrit.entities.SubmissionId; import com.google.gerrit.server.CurrentUser; import com.google.gerrit.server.IdentifiedUser.ImpersonationPermissionMode; +import com.google.gerrit.server.util.AccountTemplateUtil; import com.google.gerrit.server.util.time.TimeUtil; import com.google.gerrit.testing.TestChanges; import java.time.ZoneOffset; @@ -377,8 +378,14 @@ + "\n" + "Message on behalf of other user\n" + "\n" + + String.format( + "(Performed by %s on behalf of %s)", + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()), + AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId())) + + "\n" + + "\n" + "Patch-set: 1\n" - + "Real-user: Gerrit User 1 <1@gerrit>\n", + + "Real-user: Gerrit User 1 <1@gerrit>", commit); }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java index eb709aa..a386d5f 100644 --- a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java +++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -429,13 +429,27 @@ ChangeNotes notesAfterRewrite = newNotes(c); assertThat(changeMessages(notesBeforeRewrite)) - .containsExactly("Comment on behalf of user", "Other comment on behalf of"); + .containsExactly( + "Comment on behalf of user", + "Other comment on behalf of" + + "\n\n" + + String.format( + "(Performed by %s on behalf of %s)", + AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId()), + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()))); assertThat(notesBeforeRewrite.getChangeMessages().get(0).getAuthor()) .isEqualTo(changeOwner.getAccountId()); assertThat(notesBeforeRewrite.getChangeMessages().get(0).getRealAuthor()) .isEqualTo(otherUser.getAccountId()); assertThat(changeMessages(notesAfterRewrite)) - .containsExactly("Comment on behalf of user", "Other comment on behalf of"); + .containsExactly( + "Comment on behalf of user", + "Other comment on behalf of" + + "\n\n" + + String.format( + "(Performed by %s on behalf of %s)", + AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId()), + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()))); assertThat(notesBeforeRewrite.getChangeMessages().get(0).getAuthor()) .isEqualTo(changeOwner.getAccountId()); assertThat(notesBeforeRewrite.getChangeMessages().get(0).getRealAuthor()) @@ -511,10 +525,19 @@ ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates = ImmutableList.of( ReviewerStatusUpdate.createForReviewer( - updateTimestamp, changeOwner.getAccountId(), otherUserId, REVIEWER), - ReviewerStatusUpdate.createForReviewer(updateTimestamp, otherUserId, otherUserId, CC), + updateTimestamp, + changeOwner.getAccountId(), + changeOwner.getAccountId(), + otherUserId, + REVIEWER), ReviewerStatusUpdate.createForReviewer( - updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED)); + updateTimestamp, otherUserId, otherUserId, otherUserId, CC), + ReviewerStatusUpdate.createForReviewer( + updateTimestamp, + changeOwner.getAccountId(), + changeOwner.getAccountId(), + otherUserId, + REMOVED)); ChangeNotes notesAfterRewrite = newNotes(c); assertThat(notesBeforeRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates); @@ -595,13 +618,29 @@ ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates = ImmutableList.of( ReviewerStatusUpdate.createForReviewer( - addReviewerUpdate.when, changeOwner.getAccountId(), otherUserId, REVIEWER), + addReviewerUpdate.when, + changeOwner.getAccountId(), + changeOwner.getAccountId(), + otherUserId, + REVIEWER), ReviewerStatusUpdate.createForReviewer( - updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED), + updateTimestamp, + changeOwner.getAccountId(), + changeOwner.getAccountId(), + otherUserId, + REMOVED), ReviewerStatusUpdate.createForReviewer( - addCcUpdate.when, changeOwner.getAccountId(), otherUserId, CC), + addCcUpdate.when, + changeOwner.getAccountId(), + changeOwner.getAccountId(), + otherUserId, + CC), ReviewerStatusUpdate.createForReviewer( - updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED)); + updateTimestamp, + changeOwner.getAccountId(), + changeOwner.getAccountId(), + otherUserId, + REMOVED)); ChangeNotes notesAfterRewrite = newNotes(c); assertThat(notesBeforeRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java index 4a492d0..179e397 100644 --- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java +++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -1594,6 +1594,147 @@ } @Test + public void byLabelWithAndOperator() throws Exception { + Account.Id anotherUser = createAccount("anotheruser"); + Project.NameKey project = Project.nameKey("repo"); + repo = createAndOpenProject(project); + ChangeInserter ins = newChange(repo); + ChangeInserter ins2 = newChange(repo); + ChangeInserter ins4 = newChange(repo); + ChangeInserter ins5 = newChange(repo); + ChangeInserter ins6 = newChange(repo); + + Change reviewMinus2Change = insert(project, ins); + getChangeApi(reviewMinus2Change).current().review(ReviewInput.reject()); + + Change reviewMinus1Change = insert(project, ins2); + getChangeApi(reviewMinus1Change).current().review(ReviewInput.dislike()); + + Change reviewPlus1Change = insert(project, ins4); + getChangeApi(reviewPlus1Change).current().review(ReviewInput.recommend()); + + Change reviewTwoPlus1Change = insert(project, ins5); + getChangeApi(reviewTwoPlus1Change).current().review(ReviewInput.recommend()); + setRequestContextForUser(createAccount("user1")); + getChangeApi(reviewTwoPlus1Change).current().review(ReviewInput.recommend()); + setRequestContextForUser(userId); + + Change reviewPlus2Change = insert(project, ins6); + getChangeApi(reviewPlus2Change).current().review(ReviewInput.approve()); + + 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); + assertQuery("label:Code-Review=+1&user=owner", reviewTwoPlus1Change, reviewPlus1Change); + assertQuery("label:Code-Review=+1&owner", reviewTwoPlus1Change, reviewPlus1Change); + assertQuery("label:Code-Review=+2&owner", reviewPlus2Change); + assertQuery("label:Code-Review=-2&owner", reviewMinus2Change); + + // count=0 is not allowed + Exception thrown = + assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=+2&count=0")); + assertThat(thrown).hasMessageThat().isEqualTo("Argument count=0 is not allowed."); + assertQuery("label:Code-Review=1&count=1", reviewPlus1Change); + assertQuery("label:Code-Review=1&count=2", reviewTwoPlus1Change); + assertQuery("label:Code-Review=1&count>=2", reviewTwoPlus1Change); + assertQuery("label:Code-Review=1&count>1", reviewTwoPlus1Change); + assertQuery("label:Code-Review=1&count>=1", reviewTwoPlus1Change, reviewPlus1Change); + assertQuery("label:Code-Review=1&count=3"); + thrown = + assertThrows(BadRequestException.class, () -> assertQuery("label:Code-Review=1&count=7")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("count=7 is not allowed. Maximum allowed value for count is 5."); + + assertQuery("label:Code-Review=1&count<5", reviewTwoPlus1Change, reviewPlus1Change); + assertQuery("label:Code-Review=1&count<=5", reviewTwoPlus1Change, reviewPlus1Change); + assertQuery( + "label:Code-Review=1&count<=1", // reviewTwoPlus1Change is not matched since its count=2 + reviewPlus1Change); + assertQuery( + "label:Code-Review=1&count<5 label:Code-Review=1&count>=1", + reviewTwoPlus1Change, + reviewPlus1Change); + assertQuery( + "label:Code-Review=1&count<=5 label:Code-Review=1&count>=1", + reviewTwoPlus1Change, + reviewPlus1Change); + assertQuery("label:Code-Review=1&count<=1 label:Code-Review=1&count>=1", reviewPlus1Change); + + assertQuery("label:Code-Review=MAX&count=1", reviewPlus2Change); + assertQuery("label:Code-Review=MAX&count=2"); + assertQuery("label:Code-Review=MIN&count=1", reviewMinus2Change); + assertQuery("label:Code-Review=MIN&count>1"); + assertQuery("label:Code-Review=MAX&count<2", reviewPlus2Change); + assertQuery("label:Code-Review=MIN&count<1"); + assertQuery("label:Code-Review=MAX&count<2 label:Code-Review=MAX&count>=1", reviewPlus2Change); + assertQuery("label:Code-Review=MIN&count<1 label:Code-Review=MIN&count>=1"); + assertQuery("label:Code-Review>=+1&count=2", reviewTwoPlus1Change); + + // "count" and "user" args cannot be used simultaneously. + thrown = + assertThrows( + BadRequestException.class, + () -> assertQuery("label:Code-Review=+1&user=non_uploader&count=2")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Cannot use the 'count' argument in conjunction with the 'user' argument"); + + // "count" and "group" args cannot be used simultaneously. + thrown = + assertThrows( + BadRequestException.class, + () -> assertQuery("label:Code-Review=+1&group=gerrit&count=2")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Cannot use the 'count' argument in conjunction with the 'group' argument"); + + // "user" and "group" args cannot be used simultaneously. + thrown = + assertThrows( + BadRequestException.class, + () -> assertQuery("label:Code-Review=+1&user=non_uploader&group=gerrit")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Cannot use the 'user' argument in conjunction with the 'group' argument"); + + // "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"); + + // "non_auther" arg for the label operator is not allowed in change queries + thrown = + assertThrows( + BadRequestException.class, () -> assertQuery("label:Code-Review=+2&user=non_author")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("non_author arg is not allowed in change queries"); + + // "non_committer" arg for the label operator is not allowed in change queries + thrown = + assertThrows( + BadRequestException.class, + () -> assertQuery("label:Code-Review=+2&user=non_committer")); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("non_committer arg is not allowed in change queries"); + } + + @Test public void cannotUseUsersArgWithLabel() throws Exception { assertFailingQuery( "label:Code-Review=MAX,users=human_reviewers", "Cannot use the 'users' argument in search"); @@ -3373,15 +3514,8 @@ Change change3 = insert(project, newChange(repo)); insert(project, newChange(repo)); - ReviewerInput rin = new ReviewerInput(); - rin.reviewer = user1.toString(); - rin.state = ReviewerState.REVIEWER; - getChangeApi(change1).addReviewer(rin); - - rin = new ReviewerInput(); - rin.reviewer = user1.toString(); - rin.state = ReviewerState.CC; - getChangeApi(change2).addReviewer(rin); + addReviewer(user1, ReviewerState.REVIEWER, change1); + addReviewer(user1, ReviewerState.CC, change2); assertQuery("is:reviewer"); assertQuery("reviewer:self"); @@ -3397,6 +3531,33 @@ } @Test + public void byReviewerCount() throws Exception { + assume().that(getSchema().hasField(ChangeField.REVIEWER_COUNT_SPEC)).isTrue(); + + Project.NameKey project = Project.nameKey("repo"); + repo = createAndOpenProject(project); + + Change change1 = insert(project, newChange(repo)); + Change change2 = insert(project, newChange(repo)); + Change change3 = insert(project, newChange(repo)); + + Account.Id user1 = createAccount("user1"); + Account.Id user2 = createAccount("user2"); + + addReviewer(user1, ReviewerState.REVIEWER, change1); + addReviewer(user1, ReviewerState.REVIEWER, change2); + addReviewer(user2, ReviewerState.REVIEWER, change1); + + assertQuery("reviewercount:2", change1); + assertQuery("reviewercount:1", change2); + assertQuery("reviewercount:0", change3); + assertQuery("reviewercount:>0", change1, change2); + assertQuery("reviewercount:<2", change2, change3); + assertQuery("reviewercount:<5", change1, change2, change3); + assertQuery("reviewercount:>2"); + } + + @Test public void byReviewed() throws Exception { Project.NameKey project = Project.nameKey("repo"); repo = createAndOpenProject(project); @@ -3434,20 +3595,9 @@ Change change2 = insert(project, newChange(repo)); Change change3 = insert(project, newChange(repo)); - ReviewerInput rin = new ReviewerInput(); - rin.reviewer = user1.toString(); - rin.state = ReviewerState.REVIEWER; - getChangeApi(change1).addReviewer(rin); - - rin = new ReviewerInput(); - rin.reviewer = user2.toString(); - rin.state = ReviewerState.REVIEWER; - getChangeApi(change2).addReviewer(rin); - - rin = new ReviewerInput(); - rin.reviewer = user3.toString(); - rin.state = ReviewerState.CC; - getChangeApi(change3).addReviewer(rin); + addReviewer(user1, ReviewerState.REVIEWER, change1); + addReviewer(user2, ReviewerState.REVIEWER, change2); + addReviewer(user3, ReviewerState.CC, change3); String group = gApi.groups().create("foo").get().name; gApi.groups().id(group).addMembers(user2.toString(), user3.toString()); @@ -3484,15 +3634,8 @@ Change change2 = insert(project, newChange(repo)); insert(project, newChange(repo)); - ReviewerInput rin = new ReviewerInput(); - rin.reviewer = userByEmailWithName; - rin.state = ReviewerState.REVIEWER; - getChangeApi(change1).addReviewer(rin); - - rin = new ReviewerInput(); - rin.reviewer = userByEmailWithName; - rin.state = ReviewerState.CC; - getChangeApi(change2).addReviewer(rin); + addReviewer(userByEmailWithName, ReviewerState.REVIEWER, change1); + addReviewer(userByEmailWithName, ReviewerState.CC, change2); assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1); assertQuery("cc:\"" + userByEmailWithName + "\"", change2); @@ -3516,15 +3659,8 @@ Change change2 = insert(project, newChange(repo)); insert(project, newChange(repo)); - ReviewerInput rin = new ReviewerInput(); - rin.reviewer = userByEmail; - rin.state = ReviewerState.REVIEWER; - getChangeApi(change1).addReviewer(rin); - - rin = new ReviewerInput(); - rin.reviewer = userByEmail; - rin.state = ReviewerState.CC; - getChangeApi(change2).addReviewer(rin); + addReviewer(userByEmail, ReviewerState.REVIEWER, change1); + addReviewer(userByEmail, ReviewerState.CC, change2); assertQuery("reviewer:\"someone@example.com\""); assertQuery("cc:\"someone@example.com\""); @@ -3914,10 +4050,7 @@ cApi.addReviewer("" + reviewerId); } for (Account.Id reviewerId : cced) { - ReviewerInput in = new ReviewerInput(); - in.reviewer = reviewerId.toString(); - in.state = ReviewerState.CC; - cApi.addReviewer(in); + AbstractQueryChangesTest.this.addReviewer(reviewerId, ReviewerState.CC, change); } DraftInput in = new DraftInput(); in.path = Patch.COMMIT_MSG; @@ -4154,10 +4287,7 @@ // Add the second user as cc to ensure that user took part of the change and can be added to the // attention set. - ReviewerInput reviewerInput = new ReviewerInput(); - reviewerInput.reviewer = user2Id.toString(); - reviewerInput.state = ReviewerState.CC; - getChangeApi(change).addReviewer(reviewerInput); + addReviewer(user2Id, ReviewerState.CC, change); input = new AttentionSetInput(user2Id.toString(), "reason 2"); getChangeApi(change).addToAttentionSet(input); @@ -4878,6 +5008,19 @@ return getChangeApi(c).get().updated.getTime(); } + protected void addReviewer(String user, ReviewerState state, Change change) + throws RestApiException { + ReviewerInput rin = new ReviewerInput(); + rin.reviewer = user; + rin.state = state; + getChangeApi(change).addReviewer(rin); + } + + protected void addReviewer(Account.Id user, ReviewerState state, Change change) + throws RestApiException { + addReviewer(user.toString(), state, change); + } + protected void approve(Change change) throws Exception { getChangeApi(change).current().review(ReviewInput.approve()); } @@ -4924,9 +5067,9 @@ if (gApi.groups().query(emptyGroupName).get().isEmpty()) { createGroup(emptyGroupName, "Administrators"); } - String queryPattern = - "(status:new OR status:merged OR status:abandoned) AND (reviewerin:\"%s\" OR %s)"; - return String.format(queryPattern, emptyGroupName, searchTerm); + return String.format( + "(status:new OR status:merged OR status:abandoned) AND (reviewerin:\"%s\" OR %s)", + emptyGroupName, searchTerm); } private void addComment(Change change, String message, Boolean unresolved) throws Exception {
diff --git a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java index 55ec604..f2b481f 100644 --- a/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java +++ b/javatests/com/google/gerrit/server/update/context/RefUpdateContextTest.java
@@ -197,6 +197,7 @@ } private static interface Callback { + @SuppressWarnings("unused") void callback(); } }
diff --git a/javatests/com/google/gerrit/util/concurrent/BUILD b/javatests/com/google/gerrit/util/concurrent/BUILD index 744093a..2bbc5d4 100644 --- a/javatests/com/google/gerrit/util/concurrent/BUILD +++ b/javatests/com/google/gerrit/util/concurrent/BUILD
@@ -5,9 +5,9 @@ srcs = glob(["**/*.java"]), deps = [ "//java/com/google/gerrit/util/concurrent", + "//lib:guava", "//lib:junit", "//lib:servlet-api-without-neverlink", "//lib/truth", - "@guava//jar", ], )
diff --git a/lib/BUILD b/lib/BUILD index 7fabe96..7255a4d 100644 --- a/lib/BUILD +++ b/lib/BUILD
@@ -1,4 +1,4 @@ -load("@rules_java//java:defs.bzl", "java_import", "java_library") +load("@rules_java//java:defs.bzl", "java_library") exports_files(glob([ "LICENSE-*", @@ -18,21 +18,21 @@ data = ["//lib:LICENSE-Apache2.0"], neverlink = 1, visibility = ["//visibility:public"], - exports = ["@servlet-api//jar"], + exports = ["@external_deps//:javax_servlet_javax_servlet_api"], ) java_library( name = "servlet-api-without-neverlink", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@servlet-api//jar"], + exports = ["@external_deps//:javax_servlet_javax_servlet_api"], ) java_library( name = "gson", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@gson//jar"], + exports = ["@external_deps//:com_google_code_gson_gson"], ) java_library( @@ -61,7 +61,10 @@ data = ["//lib:LICENSE-jgit"], visibility = ["//visibility:public"], exports = ["@jgit//org.eclipse.jgit.archive:jgit-archive"], - runtime_deps = [":jgit"], + runtime_deps = [ + ":jgit", + "//lib/commons:compress", + ], ) java_library( @@ -85,28 +88,28 @@ name = "javaewah", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@javaewah//jar"], + exports = ["@external_deps//:com_googlecode_javaewah_JavaEWAH"], ) java_library( name = "protobuf", data = ["//lib:LICENSE-protobuf"], visibility = ["//visibility:public"], - exports = ["@protobuf-java//jar"], + exports = ["@external_deps//:com_google_protobuf_protobuf_java"], ) java_library( name = "guava-failureaccess", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@guava-failureaccess//jar"], + exports = ["@external_deps//:com_google_guava_failureaccess"], ) java_library( name = "j2objc", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@j2objc//jar"], + exports = ["@external_deps//:com_google_j2objc_j2objc_annotations"], ) java_library( @@ -116,7 +119,7 @@ exports = [ ":guava-failureaccess", ":j2objc", - "@guava//jar", + "@external_deps//:com_google_guava_guava", ], ) @@ -125,7 +128,7 @@ data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], exports = [ - "@guava-testlib//jar", + "@external_deps//:com_google_guava_guava_testlib", ], ) @@ -136,12 +139,7 @@ "//java/com/google/gerrit/acceptance:__pkg__", "//java/com/google/gerrit/server/cache/mem:__pkg__", ], - exports = ["@caffeine//jar"], -) - -java_import( - name = "caffeine-guava-renamed", - jars = ["@caffeine-guava-renamed//file"], + exports = ["@external_deps//:com_github_ben_manes_caffeine_caffeine"], ) java_library( @@ -151,56 +149,56 @@ "//java/com/google/gerrit/acceptance:__pkg__", "//java/com/google/gerrit/server/cache/mem:__pkg__", ], - exports = [":caffeine-guava-renamed"], + exports = ["@external_deps//:com_github_ben_manes_caffeine_guava"], ) java_library( name = "args4j", data = ["//lib:LICENSE-args4j"], visibility = ["//visibility:public"], - exports = ["@args4j//jar"], + exports = ["@external_deps//:args4j_args4j"], ) java_library( name = "automaton", data = ["//lib:LICENSE-automaton"], visibility = ["//visibility:public"], - exports = ["@automaton//jar"], + exports = ["@external_deps//:dk_brics_automaton"], ) java_library( name = "flexmark-all-lib", data = ["//lib:LICENSE-flexmark"], visibility = ["//visibility:public"], - exports = ["@flexmark-all-lib//jar"], + exports = ["@external_deps//:com_vladsch_flexmark_flexmark_all_lib"], ) java_library( name = "autolink", data = ["//lib:LICENSE-autolink"], visibility = ["//visibility:public"], - exports = ["@autolink//jar"], + exports = ["@external_deps//:org_nibor_autolink_autolink"], ) java_library( name = "tukaani-xz", data = ["//lib:LICENSE-xz"], visibility = ["//visibility:public"], - exports = ["@tukaani-xz//jar"], + exports = ["@external_deps//:org_tukaani_xz"], ) java_library( name = "mime-util", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@mime-util//jar"], + exports = ["@external_deps//:eu_medsea_mimeutil_mime_util"], ) java_library( name = "guava-retrying", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@guava-retrying//jar"], + exports = ["@external_deps//:com_github_rholder_guava_retrying"], runtime_deps = [":jsr305"], ) @@ -208,28 +206,28 @@ name = "jsr305", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@jsr305//jar"], + exports = ["@external_deps//:com_google_code_findbugs_jsr305"], ) java_library( name = "blame-cache", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@blame-cache//jar"], + exports = ["@external_deps//:com_google_gitiles_blame_cache"], ) java_library( name = "h2", data = ["//lib:LICENSE-h2"], visibility = ["//visibility:public"], - exports = ["@h2//jar"], + exports = ["@external_deps//:com_h2database_h2"], ) java_library( name = "jimfs", data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:public"], - exports = ["@jimfs//jar"], + exports = ["@external_deps//:com_google_jimfs_jimfs"], runtime_deps = [":guava"], ) @@ -239,7 +237,7 @@ visibility = ["//visibility:public"], exports = [ ":hamcrest", - "@junit//jar", + "@external_deps//:junit_junit", ], runtime_deps = [":hamcrest"], ) @@ -248,14 +246,14 @@ name = "hamcrest", data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:public"], - exports = ["@hamcrest//jar"], + exports = ["@external_deps//:org_hamcrest_hamcrest"], ) java_library( name = "soy", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@soy//jar"], + exports = ["@external_deps//:com_google_template_soy"], runtime_deps = [ ":args4j", ":gson", @@ -279,14 +277,14 @@ name = "html-types", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@html-types//jar"], + exports = ["@external_deps//:com_google_common_html_types_types"], ) java_library( name = "icu4j", data = ["//lib:LICENSE-icu4j"], visibility = ["//visibility:public"], - exports = ["@icu4j//jar"], + exports = ["@external_deps//:com_ibm_icu_icu4j"], ) java_library( @@ -294,13 +292,13 @@ data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], exports = [ - "@roaringbitmap-shims//jar", - "@roaringbitmap//jar", + "@external_deps//:org_roaringbitmap_RoaringBitmap", + "@external_deps//:org_roaringbitmap_shims", ], ) sh_test( name = "nongoogle_test", srcs = ["nongoogle_test.sh"], - data = ["//tools:nongoogle.bzl"], + data = ["//tools:nongoogle.toml"], )
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD index 076aea9..f9b19c7 100644 --- a/lib/antlr/BUILD +++ b/lib/antlr/BUILD
@@ -2,20 +2,23 @@ package(default_visibility = ["//java/com/google/gerrit/index:__pkg__"]) -[java_library( - name = n, +java_library( + name = "antlr27", data = ["//lib:LICENSE-antlr"], - exports = ["@%s//jar" % n], -) for n in [ - "antlr27", - "stringtemplate", -]] + exports = ["@external_deps//:antlr_antlr_2_7_7"], +) + +java_library( + name = "stringtemplate", + data = ["//lib:LICENSE-antlr"], + exports = ["@external_deps//:org_antlr_stringtemplate"], +) java_library( name = "java-runtime", data = ["//lib:LICENSE-antlr"], visibility = ["//visibility:public"], - exports = ["@java-runtime//jar"], + exports = ["@external_deps//:org_antlr_antlr_runtime"], ) # See https://github.com/bazelbuild/bazel/issues/3542 @@ -31,7 +34,7 @@ java_library( name = "tool", data = ["//lib:LICENSE-antlr"], - exports = ["@org-antlr//jar"], + exports = ["@external_deps//:org_antlr_antlr"], runtime_deps = [ ":antlr27", ":java-runtime",
diff --git a/lib/asciidoctor/BUILD b/lib/asciidoctor/BUILD index b46c08d..c4ca4da 100644 --- a/lib/asciidoctor/BUILD +++ b/lib/asciidoctor/BUILD
@@ -4,12 +4,12 @@ name = "asciidoctor", data = ["//lib:LICENSE-asciidoctor"], visibility = ["//java/com/google/gerrit/asciidoctor:__pkg__"], - exports = ["@asciidoctor//jar"], + exports = ["@external_deps//:org_asciidoctor_asciidoctorj"], runtime_deps = [":jruby"], ) java_library( name = "jruby", data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], - exports = ["@jruby//jar"], + exports = ["@external_deps//:org_jruby_jruby_complete"], )
diff --git a/lib/auto/BUILD b/lib/auto/BUILD index 13a6665..302b5cd 100644 --- a/lib/auto/BUILD +++ b/lib/auto/BUILD
@@ -3,9 +3,10 @@ java_plugin( name = "auto-annotation-plugin", processor_class = "com.google.auto.value.processor.AutoAnnotationProcessor", + visibility = ["//tools/eclipse:__pkg__"], deps = [ - "@auto-value-annotations//jar", - "@auto-value//jar", + "@external_deps//:com_google_auto_value_auto_value", + "@external_deps//:com_google_auto_value_auto_value_annotations", ], ) @@ -13,64 +14,68 @@ name = "auto-factory-plugin", generates_api = 1, processor_class = "com.google.auto.factory.processor.AutoFactoryProcessor", - visibility = ["//visibility:private"], + visibility = ["//tools/eclipse:__pkg__"], deps = [ - "@auto-common//jar", - "@auto-factory//jar", - "@auto-service-annotations//jar", - "@auto-value-annotations//jar", - "@auto-value//jar", - "@guava//jar", - "@javapoet//jar", - "@javax_inject//jar", + "@external_deps//:com_google_auto_auto_common", + "@external_deps//:com_google_auto_factory_auto_factory", + "@external_deps//:com_google_auto_service_auto_service_annotations", + "@external_deps//:com_google_auto_value_auto_value", + "@external_deps//:com_google_auto_value_auto_value_annotations", + "@external_deps//:com_google_guava_guava", + "@external_deps//:com_squareup_javapoet", + "@external_deps//:javax_inject_javax_inject", ], ) java_plugin( name = "auto-builder-plugin", processor_class = "com.google.auto.value.processor.AutoBuilderProcessor", + visibility = ["//tools/eclipse:__pkg__"], deps = [ - "@auto-common//jar", - "@auto-factory//jar", - "@auto-service-annotations//jar", - "@auto-value-annotations//jar", - "@auto-value//jar", - "@guava//jar", - "@javapoet//jar", - "@javax_inject//jar", + "@external_deps//:com_google_auto_auto_common", + "@external_deps//:com_google_auto_factory_auto_factory", + "@external_deps//:com_google_auto_service_auto_service_annotations", + "@external_deps//:com_google_auto_value_auto_value", + "@external_deps//:com_google_auto_value_auto_value_annotations", + "@external_deps//:com_google_guava_guava", + "@external_deps//:com_squareup_javapoet", + "@external_deps//:javax_inject_javax_inject", ], ) java_plugin( name = "auto-value-plugin", processor_class = "com.google.auto.value.processor.AutoValueProcessor", + visibility = ["//tools/eclipse:__pkg__"], deps = [ - "@auto-value-annotations//jar", - "@auto-value//jar", + "@external_deps//:com_google_auto_value_auto_value", + "@external_deps//:com_google_auto_value_auto_value_annotations", ], ) java_plugin( name = "auto-oneof-plugin", processor_class = "com.google.auto.value.processor.AutoOneOfProcessor", + visibility = ["//tools/eclipse:__pkg__"], deps = [ - "@auto-value-annotations//jar", - "@auto-value//jar", + "@external_deps//:com_google_auto_value_auto_value", + "@external_deps//:com_google_auto_value_auto_value_annotations", ], ) java_plugin( name = "auto-value-gson-plugin", processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor", + visibility = ["//tools/eclipse:__pkg__"], deps = [ - "@auto-value-annotations//jar", - "@auto-value-gson-extension//jar", - "@auto-value-gson-factory//jar", - "@auto-value-gson-runtime//jar", - "@auto-value//jar", - "@autotransient//jar", - "@gson//jar", - "@javapoet//jar", + "@external_deps//:com_google_auto_value_auto_value", + "@external_deps//:com_google_auto_value_auto_value_annotations", + "@external_deps//:com_google_code_gson_gson", + "@external_deps//:com_ryanharter_auto_value_auto_value_gson_extension", + "@external_deps//:com_ryanharter_auto_value_auto_value_gson_factory", + "@external_deps//:com_ryanharter_auto_value_auto_value_gson_runtime", + "@external_deps//:com_squareup_javapoet", + "@external_deps//:io_sweers_autotransient_autotransient", ], ) @@ -81,7 +86,7 @@ ":auto-factory-plugin", ], visibility = ["//visibility:public"], - exports = ["@auto-factory//jar"], + exports = ["@external_deps//:com_google_auto_factory_auto_factory"], ) java_library( @@ -94,7 +99,7 @@ ":auto-oneof-plugin", ], visibility = ["//visibility:public"], - exports = ["@auto-value//jar"], + exports = ["@external_deps//:com_google_auto_value_auto_value"], ) java_library( @@ -107,7 +112,7 @@ ":auto-oneof-plugin", ], visibility = ["//visibility:public"], - exports = ["@auto-value-annotations//jar"], + exports = ["@external_deps//:com_google_auto_value_auto_value_annotations"], ) java_library( @@ -118,6 +123,6 @@ ], visibility = ["//visibility:public"], exports = [ - "@auto-value-gson-runtime//jar", + "@external_deps//:com_ryanharter_auto_value_auto_value_gson_runtime", ], )
diff --git a/lib/bouncycastle/BUILD b/lib/bouncycastle/BUILD index 6a87d73..fbfc223 100644 --- a/lib/bouncycastle/BUILD +++ b/lib/bouncycastle/BUILD
@@ -4,28 +4,28 @@ name = "bcprov", data = ["//lib:LICENSE-bouncycastle"], visibility = ["//visibility:public"], - exports = ["@bcprov//jar"], + exports = ["@external_deps//:org_bouncycastle_bcprov_jdk18on"], ) java_library( name = "bcpg", data = ["//lib:LICENSE-bouncycastle"], visibility = ["//visibility:public"], - exports = ["@bcpg//jar"], + exports = ["@external_deps//:org_bouncycastle_bcpg_jdk18on"], ) java_library( name = "bcpkix", data = ["//lib:LICENSE-bouncycastle"], visibility = ["//visibility:public"], - exports = ["@bcpkix//jar"], + exports = ["@external_deps//:org_bouncycastle_bcpkix_jdk18on"], ) java_library( name = "bcutil", data = ["//lib:LICENSE-bouncycastle"], visibility = ["//visibility:public"], - exports = ["@bcutil//jar"], + exports = ["@external_deps//:org_bouncycastle_bcutil_jdk18on"], ) java_library( @@ -33,7 +33,7 @@ data = ["//lib:LICENSE-bouncycastle"], neverlink = 1, visibility = ["//visibility:public"], - exports = ["@bcprov//jar"], + exports = ["@external_deps//:org_bouncycastle_bcprov_jdk18on"], ) java_library( @@ -41,7 +41,7 @@ data = ["//lib:LICENSE-bouncycastle"], neverlink = 1, visibility = ["//visibility:public"], - exports = ["@bcpg//jar"], + exports = ["@external_deps//:org_bouncycastle_bcpg_jdk18on"], ) java_library( @@ -49,7 +49,7 @@ data = ["//lib:LICENSE-bouncycastle"], neverlink = 1, visibility = ["//visibility:public"], - exports = ["@bcpkix//jar"], + exports = ["@external_deps//:org_bouncycastle_bcpkix_jdk18on"], ) java_library( @@ -57,5 +57,5 @@ data = ["//lib:LICENSE-bouncycastle"], neverlink = 1, visibility = ["//visibility:public"], - exports = ["@bcutil//jar"], + exports = ["@external_deps//:org_bouncycastle_bcutil_jdk18on"], )
diff --git a/lib/commons/BUILD b/lib/commons/BUILD index 091ea07..3e8cf74 100644 --- a/lib/commons/BUILD +++ b/lib/commons/BUILD
@@ -5,55 +5,56 @@ java_library( name = "codec", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-codec//jar"], + exports = ["@external_deps//:commons_codec_commons_codec"], ) java_library( name = "compress", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-compress//jar"], + exports = ["@external_deps//:org_apache_commons_commons_compress"], + runtime_deps = [":io"], ) java_library( name = "lang3", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-lang3//jar"], + exports = ["@external_deps//:org_apache_commons_commons_lang3"], ) java_library( name = "net", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-net//jar"], + exports = ["@external_deps//:commons_net_commons_net"], ) java_library( name = "dbcp", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-dbcp//jar"], + exports = ["@external_deps//:commons_dbcp_commons_dbcp"], runtime_deps = [":pool"], ) java_library( name = "pool", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-pool//jar"], + exports = ["@external_deps//:commons_pool_commons_pool"], ) java_library( name = "text", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@commons-text//jar"], + exports = ["@external_deps//:org_apache_commons_commons_text"], ) java_library( name = "validator", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-validator//jar"], + exports = ["@external_deps//:commons_validator_commons_validator"], ) java_library( name = "io", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@commons-io//jar"], + exports = ["@external_deps//:commons_io_commons_io"], )
diff --git a/lib/dropwizard/BUILD b/lib/dropwizard/BUILD index 174b7ad..fd78cb2 100644 --- a/lib/dropwizard/BUILD +++ b/lib/dropwizard/BUILD
@@ -4,5 +4,5 @@ name = "dropwizard-core", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@dropwizard-core//jar"], + exports = ["@external_deps//:io_dropwizard_metrics_metrics_core"], )
diff --git a/lib/emojis/emojis.js b/lib/emojis/emojis.js index 1e004ab..e1836a6 100644 --- a/lib/emojis/emojis.js +++ b/lib/emojis/emojis.js
@@ -1,2 +1,2 @@ // Generated by generateEmojiMapping.js — do not edit manually. -export default [{"value":"😀","match":"smile :D"},{"value":"😃","match":"smile-with-big-eyes :-D"},{"value":"😄","match":"grin ^_^"},{"value":"😁","match":"grinning *^_^*"},{"value":"😆","match":"laughing X-D"},{"value":"😅","match":"grin-sweat ^_^;"},{"value":"😂","match":"joy >w<"},{"value":"🤣","match":"rofl *>w<*"},{"value":"😭","match":"loudly-crying ;_;"},{"value":"😉","match":"wink ;)"},{"value":"😗","match":"kissing :*"},{"value":"😙","match":"kissing-smiling-eyes ^3^"},{"value":"😚","match":"kissing-closed-eyes :**"},{"value":"😘","match":"kissing-heart ;*"},{"value":"🥰","match":"heart-face <3:)"},{"value":"🥰","match":"3-hearts <3:)"},{"value":"😍","match":"heart-eyes ♥_♥"},{"value":"🤩","match":"star-struck *_*"},{"value":"🥳","match":"partying-face (ノ◕ヮ◕)♬♪"},{"value":"🫠","match":"melting"},{"value":"🙃","match":"upside-down-face (:"},{"value":"🙂","match":"slightly-happy :) :-)"},{"value":"🥲","match":"happy-cry :,)"},{"value":"🥹","match":"holding-back-tears (;人;)"},{"value":"😊","match":"blush"},{"value":"☺️","match":"warm-smile"},{"value":"😌","match":"relieved"},{"value":"🙂↕️","match":"head-nod"},{"value":"🙂↔️","match":"head-shake"},{"value":"😏","match":"smirk >~>"},{"value":"🤤","match":"drool (¯﹃¯)"},{"value":"😋","match":"yum"},{"value":"😛","match":"stuck-out-tongue :P :p :-P :-p"},{"value":"😝","match":"squinting-tongue >q<"},{"value":"😜","match":"winky-tongue ;p"},{"value":"🤪","match":"zany-face"},{"value":"🥴","match":"woozy >﹏☉"},{"value":"😔","match":"pensive ._."},{"value":"🥺","match":"pleading ◕﹏◕"},{"value":"😬","match":"grimacing :-|"},{"value":"😑","match":"expressionless -_-"},{"value":"😐","match":"neutral-face :|"},{"value":"😶","match":"mouth-none"},{"value":"😶🌫️","match":"face-in-clouds"},{"value":"😶🌫️","match":"lost"},{"value":"🫥","match":"dotted-line-face"},{"value":"🫥","match":"invisible"},{"value":"🤐","match":"zipper-face :#"},{"value":"🫡","match":"salute (・д・ゝ)"},{"value":"🤔","match":"thinking-face =L"},{"value":"🤫","match":"shushing-face ( ̄b ̄)"},{"value":"🫢","match":"hand-over-mouth"},{"value":"🤭","match":"smiling-eyes-with-hand-over-mouth"},{"value":"🤭","match":"chuckling"},{"value":"🥱","match":"yawn ~O~"},{"value":"🤗","match":"hug-face \\(^o^)/"},{"value":"🫣","match":"peeking (*/。\)"},{"value":"😱","match":"screaming @0@"},{"value":"🤨","match":"raised-eyebrow ( ͝סּ ͜ʖ͡סּ)"},{"value":"🧐","match":"monocle o~O"},{"value":"😒","match":"unamused >->"},{"value":"🙄","match":"rolling-eyes"},{"value":"😮💨","match":"exhale"},{"value":"😤","match":"triumph (((╬◣﹏◢)))"},{"value":"😠","match":"angry X-("},{"value":"😡","match":"rage >:O"},{"value":"🤬","match":"cursing #$@!"},{"value":"😞","match":"sad"},{"value":"😓","match":"sweat (0へ0)"},{"value":"😓","match":"downcast (0へ0)"},{"value":"😟","match":"worried :S"},{"value":"😥","match":"concerned •_•'"},{"value":"😢","match":"cry :'("},{"value":"☹️","match":"big-frown :-("},{"value":"🙁","match":"frown :("},{"value":"🫤","match":"diagonal-mouth :/"},{"value":"😕","match":"slightly-frowning :-/"},{"value":"😰","match":"anxious-with-sweat D-':"},{"value":"😨","match":"scared D-:"},{"value":"😧","match":"anguished"},{"value":"😦","match":"gasp D="},{"value":"😮","match":"mouth-open :O"},{"value":"😯","match":"surprised :o"},{"value":"😯","match":"hushed :o"},{"value":"😲","match":"astonished"},{"value":"😳","match":"flushed 8‑0"},{"value":"🤯","match":"mind-blown"},{"value":"🤯","match":"exploding-head"},{"value":"😖","match":"scrunched-mouth >:["},{"value":"😖","match":"confounded >:["},{"value":"😖","match":"zigzag-mouth >:["},{"value":"😣","match":"scrunched-eyes >:("},{"value":"😣","match":"persevering >:("},{"value":"😩","match":"weary D:"},{"value":"😫","match":"distraught D-X"},{"value":"😵","match":"x-eyes X_o"},{"value":"😵💫","match":"dizzy-face"},{"value":"🫨","match":"shaking-face"},{"value":"🥶","match":"cold-face"},{"value":"🥵","match":"hot-face"},{"value":"🥵","match":"sweat-face"},{"value":"🤢","match":"sick :-###"},{"value":"🤢","match":"nauseated :-###"},{"value":"🤮","match":"vomit :-O##"},{"value":"","match":"tired"},{"value":"","match":"bags-under-eyes"},{"value":"😴","match":"sleep Z_Z"},{"value":"😪","match":"sleepy (-.-)zzZZ"},{"value":"🤧","match":"sneeze (*´台`*)"},{"value":"🤒","match":"thermometer-face"},{"value":"🤕","match":"bandage-face"},{"value":"😷","match":"mask"},{"value":"🤥","match":"liar"},{"value":"😇","match":"halo O:)"},{"value":"😇","match":"innocent O:)"},{"value":"🤠","match":"cowboy <):)"},{"value":"🤑","match":"money-face $_$"},{"value":"🤓","match":"nerd-face :-B"},{"value":"😎","match":"sunglasses-face B-)"},{"value":"🥸","match":"disguise"},{"value":"🤡","match":"clown :o)"},{"value":"💩","match":"poop ༼^-^༽"},{"value":"😈","match":"imp-smile 3:)"},{"value":"👿","match":"imp-frown 3:("},{"value":"👻","match":"ghost ⊂(´・◡・⊂)∘˚˳°"},{"value":"💀","match":"skull"},{"value":"☠️","match":"skull-and-crossbones"},{"value":"👹","match":"ogre"},{"value":"👺","match":"goblin"},{"value":"☃️","match":"snowman-with-snow"},{"value":"⛄","match":"snowman"},{"value":"🎃","match":"jack-o-lantern"},{"value":"🤖","match":"robot"},{"value":"👽","match":"alien (<>..<>)"},{"value":"👾","match":"alien-monster"},{"value":"🌚","match":"moon-face-new >_>"},{"value":"🌝","match":"moon-face-full <_<"},{"value":"🌞","match":"sun-with-face"},{"value":"🌛","match":"moon-face-first-quarter"},{"value":"🌜","match":"moon-face-last-quarter"},{"value":"😺","match":"smiley-cat :3"},{"value":"😸","match":"smile-cat"},{"value":"😹","match":"joy-cat"},{"value":"😻","match":"heart-eyes-cat"},{"value":"😼","match":"smirk-cat"},{"value":"😽","match":"kissing-cat"},{"value":"🙀","match":"scream-cat"},{"value":"😿","match":"crying-cat-face"},{"value":"😾","match":"pouting-cat"},{"value":"🙈","match":"see-no-evil-monkey"},{"value":"🙉","match":"hear-no-evil-monkey"},{"value":"🙊","match":"speak-no-evil-monkey"},{"value":"💫","match":"dizzy"},{"value":"⭐","match":"star"},{"value":"🌟","match":"glowing-star"},{"value":"✨","match":"sparkles"},{"value":"⚡","match":"electricity"},{"value":"⚡","match":"zap"},{"value":"⚡","match":"lightning"},{"value":"💥","match":"collision"},{"value":"💢","match":"anger"},{"value":"💨","match":"dash"},{"value":"💨","match":"poof"},{"value":"💦","match":"sweat-droplets"},{"value":"💤","match":"zzz"},{"value":"🕳️","match":"hole"},{"value":"🔥","match":"fire"},{"value":"🔥","match":"burn"},{"value":"🔥","match":"lit"},{"value":"💯","match":"100"},{"value":"💯","match":"one-hundred"},{"value":"💯","match":"hundred"},{"value":"💯","match":"points"},{"value":"🎉","match":"party-popper"},{"value":"🎊","match":"confetti-ball"},{"value":"❤️","match":"red-heart <3"},{"value":"🧡","match":"orange-heart"},{"value":"💛","match":"yellow-heart"},{"value":"💚","match":"green-heart"},{"value":"🩵","match":"light-blue-heart"},{"value":"💙","match":"blue-heart"},{"value":"💜","match":"purple-heart"},{"value":"🤎","match":"brown-heart"},{"value":"🖤","match":"black-heart"},{"value":"🩶","match":"grey-heart"},{"value":"🤍","match":"white-heart"},{"value":"🩷","match":"pink-heart"},{"value":"💘","match":"cupid"},{"value":"💝","match":"gift-heart"},{"value":"💖","match":"sparkling-heart"},{"value":"💗","match":"heart-grow"},{"value":"💓","match":"beating-heart"},{"value":"💞","match":"revolving-hearts"},{"value":"💕","match":"two-hearts <3<3"},{"value":"💌","match":"love-letter"},{"value":"💟","match":"heart-box"},{"value":"♥️","match":"heart"},{"value":"❣️","match":"heart-exclamation-point <3!"},{"value":"❤️🩹","match":"bandaged-heart"},{"value":"💔","match":"broken-heart </3"},{"value":"❤️🔥","match":"fire-heart"},{"value":"💋","match":"kiss"},{"value":"🫂","match":"hugging"},{"value":"👥","match":"busts-in-silhouette"},{"value":"👤","match":"bust-in-silhouette"},{"value":"🗣️","match":"speaking-head"},{"value":"👣","match":"footprints"},{"value":"","match":"fingerprint"},{"value":"🧠","match":"brain"},{"value":"🫀","match":"anatomical-heart"},{"value":"🫁","match":"lungs"},{"value":"🩸","match":"blood"},{"value":"🦠","match":"microbe"},{"value":"🦠","match":"virus"},{"value":"🦷","match":"tooth"},{"value":"🦴","match":"bone"},{"value":"👀","match":"eyes"},{"value":"👁️","match":"eye"},{"value":"👄","match":"mouth"},{"value":"🫦","match":"biting-lip"},{"value":"👅","match":"tongue"},{"value":"👃","match":"nose"},{"value":"👂","match":"ear"},{"value":"🦻","match":"hearing-aid"},{"value":"🦶","match":"foot"},{"value":"🦵","match":"leg"},{"value":"🦿","match":"leg-mechanical"},{"value":"🦾","match":"arm-mechanical"},{"value":"💪","match":"muscle"},{"value":"💪","match":"flex"},{"value":"💪","match":"bicep"},{"value":"💪","match":"strong"},{"value":"👏","match":"clap"},{"value":"👍","match":"thumbs-up"},{"value":"👍","match":"+1"},{"value":"👎","match":"thumbs-down"},{"value":"🫶","match":"heart-hands"},{"value":"🙌","match":"raising-hands"},{"value":"🙌","match":"hooray"},{"value":"👐","match":"open-hands"},{"value":"🤲","match":"palms-up"},{"value":"🤜","match":"fist-rightwards"},{"value":"🤛","match":"fist-leftwards"},{"value":"✊","match":"raised-fist"},{"value":"👊","match":"fist"},{"value":"👊","match":"bump"},{"value":"🫳","match":"palm-down"},{"value":"🫳","match":"drop"},{"value":"🫴","match":"palm-up"},{"value":"🫴","match":"throw"},{"value":"🫱","match":"rightwards-hand"},{"value":"🫲","match":"leftwards-hand"},{"value":"🫸","match":"push-rightwards"},{"value":"🫷","match":"push-leftwards"},{"value":"👋","match":"wave"},{"value":"🤚","match":"back-hand"},{"value":"🖐️","match":"palm"},{"value":"✋","match":"raised-hand"},{"value":"🖖","match":"vulcan"},{"value":"🖖","match":"prosper"},{"value":"🖖","match":"spock"},{"value":"🤟","match":"love-you-gesture"},{"value":"🤘","match":"metal"},{"value":"🤘","match":"horns"},{"value":"✌️","match":"v"},{"value":"✌️","match":"peace-hand"},{"value":"✌️","match":"victory"},{"value":"🤞","match":"crossed-fingers"},{"value":"🫰","match":"hand-with-index-finger-and-thumb-crossed"},{"value":"🫰","match":"snap"},{"value":"🫰","match":"finger-heart"},{"value":"🤙","match":"call-me-hand"},{"value":"🤌","match":"pinched-fingers"},{"value":"🤏","match":"pinch"},{"value":"👌","match":"ok"},{"value":"🫵","match":"pointing"},{"value":"👉","match":"point-right"},{"value":"👈","match":"point-left"},{"value":"☝️","match":"index-finger"},{"value":"👆","match":"point-up"},{"value":"👇","match":"point-down"},{"value":"🖕","match":"middle-finger"},{"value":"✍️","match":"writing-hand"},{"value":"🤳","match":"selfie"},{"value":"🙏","match":"folded-hands"},{"value":"🙏","match":"please"},{"value":"🙏","match":"pray"},{"value":"🙏","match":"hope"},{"value":"🙏","match":"wish"},{"value":"🙏","match":"thank-you"},{"value":"🙏","match":"high-five"},{"value":"💅","match":"nail-care"},{"value":"🤝","match":"handshake"},{"value":"🙇","match":"bow"},{"value":"🙋","match":"raising-hand"},{"value":"💁","match":"tipping-hand"},{"value":"🙆","match":"gesture-ok"},{"value":"🙅","match":"no-gesture"},{"value":"🙅","match":"no-good"},{"value":"🙅","match":"denied"},{"value":"🙅","match":"halt"},{"value":"🤷","match":"shrug"},{"value":"🤦","match":"facepalm"},{"value":"🙍","match":"frowning"},{"value":"🙎","match":"pouting"},{"value":"🧏","match":"deaf"},{"value":"💆","match":"massage"},{"value":"💇","match":"haircut"},{"value":"🧖","match":"sauna"},{"value":"🧖","match":"steamy"},{"value":"🛀","match":"bathe"},{"value":"🛌","match":"in-bed"},{"value":"🧘","match":"yoga"},{"value":"🧘","match":"meditation"},{"value":"🧘","match":"lotus-position"},{"value":"🧍","match":"standing"},{"value":"🤸","match":"cartwheel"},{"value":"🧎","match":"kneeling"},{"value":"🧑🦼","match":"person-in-motorized-wheelchair"},{"value":"🧑🦽","match":"person-in-manual-wheelchair"},{"value":"🧑🦯","match":"walking-with-cane"},{"value":"🧑🦯","match":"blind"},{"value":"🚶","match":"walking"},{"value":"🏃","match":"running"},{"value":"⛹️","match":"bouncing-ball"},{"value":"🤾","match":"handball"},{"value":"🚴","match":"biking"},{"value":"🚵","match":"mountain-biking"},{"value":"🧗","match":"climbing"},{"value":"🏋️","match":"lifting-weights"},{"value":"🤼","match":"wrestling"},{"value":"🤹","match":"juggling"},{"value":"🏌️","match":"golfing"},{"value":"🏇","match":"horse-racing"},{"value":"🤺","match":"fencing"},{"value":"⛷️","match":"skier"},{"value":"🏂","match":"snowboarder"},{"value":"🪂","match":"parachute"},{"value":"🏄","match":"surfing"},{"value":"🚣","match":"rowing-boat"},{"value":"🏊","match":"swimming"},{"value":"🤽","match":"water-polo"},{"value":"🧜","match":"merperson"},{"value":"🧚","match":"fairy"},{"value":"🧞","match":"genie"},{"value":"🧝","match":"elf"},{"value":"🧙","match":"mage"},{"value":"🧛","match":"vampire"},{"value":"🧟","match":"zombie"},{"value":"🧌","match":"troll"},{"value":"🦸","match":"superhero"},{"value":"🦹","match":"supervillain"},{"value":"🥷","match":"ninja"},{"value":"🧑🎄","match":"mx-claus"},{"value":"👼","match":"angel"},{"value":"💂","match":"guard"},{"value":"🫅","match":"royalty"},{"value":"🤵","match":"tuxedo"},{"value":"👰","match":"veil"},{"value":"🧑🚀","match":"astronaut"},{"value":"👷","match":"construction-worker"},{"value":"👮","match":"police"},{"value":"🕵️","match":"detective"},{"value":"🧑✈️","match":"pilot"},{"value":"🧑🔬","match":"scientist"},{"value":"🧑⚕️","match":"health-worker"},{"value":"🧑⚕️","match":"doctor"},{"value":"🧑⚕️","match":"nurse"},{"value":"🧑🔧","match":"mechanic"},{"value":"🧑🏭","match":"factory-worker"},{"value":"🧑🚒","match":"firefighter"},{"value":"🧑🌾","match":"farmer"},{"value":"🧑🏫","match":"teacher"},{"value":"🧑🎓","match":"student"},{"value":"🧑💼","match":"office-worker"},{"value":"🧑💼","match":"business-person"},{"value":"🧑⚖️","match":"judge"},{"value":"🧑💻","match":"technologist"},{"value":"🧑💻","match":"person-at-computer"},{"value":"🧑🎤","match":"singer"},{"value":"🧑🎨","match":"artist"},{"value":"🧑🍳","match":"cook"},{"value":"👳","match":"turban"},{"value":"🧕","match":"headscarf"},{"value":"👲","match":"gua-pi-mao"},{"value":"👶","match":"baby"},{"value":"🧒","match":"child"},{"value":"🧑","match":"adult"},{"value":"🧓","match":"elder"},{"value":"🧑🦳","match":"white-hair"},{"value":"🧑🦰","match":"red-hair"},{"value":"👱","match":"blond-hair"},{"value":"🧑🦱","match":"curly-hair"},{"value":"🧑🦲","match":"bald"},{"value":"🧔","match":"beard"},{"value":"🕴️","match":"levitating-suit"},{"value":"💃","match":"dancer-woman ♪┏(・o・)┛♪"},{"value":"🕺","match":"dancer-man ♪┗(・o・)┓♪"},{"value":"👯","match":"bunny-ears"},{"value":"🧑🤝🧑","match":"holding-hands"},{"value":"👭","match":"holding-hands-women"},{"value":"👬","match":"holding-hands-men"},{"value":"👫","match":"holding-hands-woman-and-man"},{"value":"💏","match":"kiss-people (-}{-)"},{"value":"👩❤️💋👨","match":"kiss-woman-and-man"},{"value":"👨❤️💋👨","match":"kiss-man-and-man"},{"value":"👩❤️💋👩","match":"kiss-woman-and-woman"},{"value":"💑","match":"people-with-heart"},{"value":"👩❤️👨","match":"heart-with-woman-and-man"},{"value":"👨❤️👨","match":"heart-with-man-and-man"},{"value":"👩❤️👩","match":"heart-with-woman-and-woman"},{"value":"🫄","match":"pregnant"},{"value":"🤱","match":"breast-feeding"},{"value":"🧑🍼","match":"person-feeding-baby"},{"value":"💐","match":"bouquet"},{"value":"💐","match":"flowers"},{"value":"🌹","match":"rose @-,-'-,-"},{"value":"🥀","match":"wilted-flower"},{"value":"🌺","match":"hibiscus"},{"value":"🌷","match":"tulip"},{"value":"🪷","match":"lotus"},{"value":"🌸","match":"cherry-blossom"},{"value":"💮","match":"white-flower"},{"value":"🏵️","match":"rosette"},{"value":"🪻","match":"hyacinth"},{"value":"🌻","match":"sunflower"},{"value":"🌼","match":"blossom"},{"value":"🍂","match":"fallen-leaf"},{"value":"🍁","match":"maple-leaf"},{"value":"🍄","match":"mushroom"},{"value":"🌾","match":"ear-of-rice"},{"value":"🌱","match":"plant"},{"value":"🌱","match":"seed"},{"value":"🌿","match":"herb"},{"value":"🍃","match":"leaves"},{"value":"☘️","match":"shamrock"},{"value":"🍀","match":"luck"},{"value":"🍀","match":"four-leaf-clover"},{"value":"🪴","match":"potted-plant"},{"value":"🌵","match":"cactus"},{"value":"🌴","match":"palm-tree"},{"value":"","match":"leafless-tree"},{"value":"🌳","match":"deciduous-tree"},{"value":"🌲","match":"evergreen-tree"},{"value":"🪵","match":"wood"},{"value":"🪹","match":"nest"},{"value":"🪺","match":"nest-with-eggs"},{"value":"🪨","match":"rock"},{"value":"⛰️","match":"mountain"},{"value":"🏔️","match":"snow-mountain"},{"value":"❄️","match":"snowflake"},{"value":"❄️","match":"winter"},{"value":"❄️","match":"cold"},{"value":"☃️","match":"snowman-with-snow"},{"value":"⛄","match":"snowman"},{"value":"🌡️","match":"thermometer"},{"value":"🔥","match":"fire"},{"value":"🔥","match":"burn"},{"value":"🔥","match":"lit"},{"value":"🌋","match":"volcano"},{"value":"🏜️","match":"desert"},{"value":"🏞️","match":"national-park"},{"value":"🌅","match":"sunrise"},{"value":"🌄","match":"sunrise-over-mountains"},{"value":"🏝️","match":"desert-island"},{"value":"🏖️","match":"beach"},{"value":"🌈","match":"rainbow"},{"value":"🫧","match":"bubbles"},{"value":"🌊","match":"ocean"},{"value":"🌬️","match":"wind-face"},{"value":"🌀","match":"cyclone"},{"value":"🌪️","match":"tornado"},{"value":"⚡","match":"electricity"},{"value":"⚡","match":"zap"},{"value":"⚡","match":"lightning"},{"value":"☔","match":"umbrella-in-rain"},{"value":"💧","match":"droplet"},{"value":"☁️","match":"cloud"},{"value":"🌨️","match":"cloud-with-snow"},{"value":"🌧️","match":"rain-cloud"},{"value":"🌩️","match":"cloud-with-lightning"},{"value":"⛈️","match":"cloud-with-lightning-and-rain"},{"value":"🌦️","match":"sun-behind-rain-cloud"},{"value":"🌥️","match":"sun-behind-large-cloud"},{"value":"⛅","match":"partly-sunny"},{"value":"🌤️","match":"sun-behind-small-cloud"},{"value":"☀️","match":"sunny"},{"value":"🌞","match":"sun-with-face"},{"value":"🌝","match":"moon-face-full <_<"},{"value":"🌚","match":"moon-face-new >_>"},{"value":"🌜","match":"moon-face-last-quarter"},{"value":"🌛","match":"moon-face-first-quarter"},{"value":"🌙","match":"crescent-moon"},{"value":"⭐","match":"star"},{"value":"🌟","match":"glowing-star"},{"value":"✨","match":"sparkles"},{"value":"🕳️","match":"hole"},{"value":"🪐","match":"ringed-planet"},{"value":"🌍","match":"globe-showing-europe-africa"},{"value":"🌎","match":"globe-showing-americas"},{"value":"🌏","match":"globe-showing-asia-australia"},{"value":"🌫️","match":"fog"},{"value":"🌠","match":"shooting-star"},{"value":"🌌","match":"milky-way"},{"value":"☄️","match":"comet"},{"value":"🌑","match":"new-moon"},{"value":"🌒","match":"waxing-crescent-moon"},{"value":"🌓","match":"first-quarter-moon"},{"value":"🌔","match":"waxing-gibbous-moon"},{"value":"🌕","match":"full-moon"},{"value":"🌖","match":"waning-gibbous-moon"},{"value":"🌗","match":"last-quarter-moon"},{"value":"🌘","match":"waning-crescent-moon"},{"value":"🙈","match":"see-no-evil-monkey"},{"value":"🙉","match":"hear-no-evil-monkey"},{"value":"🙊","match":"speak-no-evil-monkey"},{"value":"🐵","match":"monkey-face"},{"value":"🦁","match":"lion-face"},{"value":"🐯","match":"tiger-face"},{"value":"🐱","match":"cat-face =^.^="},{"value":"🐶","match":"dog-face ▼・ᴥ・▼"},{"value":"🐺","match":"wolf"},{"value":"🐻","match":"bear-face ʕ·ᴥ·ʔ"},{"value":"🐻❄️","match":"polar-bear"},{"value":"🐨","match":"koala"},{"value":"🐼","match":"panda"},{"value":"🐹","match":"hamster"},{"value":"🐭","match":"mouse-face"},{"value":"🐰","match":"rabbit-face"},{"value":"🦊","match":"fox-face"},{"value":"🦝","match":"raccoon"},{"value":"🐮","match":"cow-face 3:O"},{"value":"🐷","match":"pig-face"},{"value":"🐽","match":"snout"},{"value":"🐗","match":"boar"},{"value":"🦓","match":"zebra"},{"value":"🦄","match":"unicorn"},{"value":"🐴","match":"horse-face"},{"value":"🫎","match":"moose"},{"value":"🐲","match":"dragon-face"},{"value":"🦎","match":"lizard"},{"value":"🐉","match":"dragon"},{"value":"🦖","match":"t-rex"},{"value":"🦕","match":"dinosaur"},{"value":"🐢","match":"turtle"},{"value":"🐊","match":"crocodile"},{"value":"🐍","match":"snake ~>゜)~~~~"},{"value":"🐸","match":"frog"},{"value":"🐇","match":"rabbit"},{"value":"🐁","match":"mouse <:3)~"},{"value":"🐀","match":"rat"},{"value":"🐈","match":"cat"},{"value":"🐈⬛","match":"black-cat"},{"value":"🐩","match":"poodle"},{"value":"🐕","match":"dog"},{"value":"🦮","match":"guide-dog"},{"value":"🐕🦺","match":"service-dog"},{"value":"🐖","match":"pig"},{"value":"🐎","match":"racehorse"},{"value":"🫏","match":"donkey"},{"value":"🐄","match":"cow"},{"value":"🐂","match":"ox"},{"value":"🐃","match":"water-buffalo"},{"value":"🦬","match":"bison"},{"value":"🐏","match":"ram"},{"value":"🐑","match":"sheep"},{"value":"🐑","match":"ewe"},{"value":"🐐","match":"goat"},{"value":"🦌","match":"deer"},{"value":"🦙","match":"llama"},{"value":"🦥","match":"sloth"},{"value":"🦘","match":"kangaroo"},{"value":"🐘","match":"elephant"},{"value":"🦣","match":"mammoth"},{"value":"🦏","match":"rhino"},{"value":"🦏","match":"rhinoceros"},{"value":"🦛","match":"hippo"},{"value":"🦒","match":"giraffe"},{"value":"🐆","match":"leopard"},{"value":"🐅","match":"tiger"},{"value":"🐒","match":"monkey"},{"value":"🦍","match":"gorilla"},{"value":"🦧","match":"orangutan"},{"value":"🐪","match":"camel"},{"value":"🐫","match":"bactrian-camel"},{"value":"🐿️","match":"chipmunk"},{"value":"🦫","match":"beaver"},{"value":"🦨","match":"skunk"},{"value":"🦡","match":"badger"},{"value":"🦔","match":"hedgehog"},{"value":"🦦","match":"otter (:3ꇤ⁐ꃳ"},{"value":"🦇","match":"bat ⎛⎝(•ⱅ•)⎠⎞"},{"value":"🪽","match":"wing"},{"value":"🪶","match":"feather"},{"value":"🐦","match":"bird"},{"value":"🐦⬛","match":"black-bird"},{"value":"🐓","match":"rooster"},{"value":"🐔","match":"chicken"},{"value":"🐣","match":"hatching-chick"},{"value":"🐤","match":"baby-chick"},{"value":"🐥","match":"hatched-chick"},{"value":"🦅","match":"eagle"},{"value":"🦉","match":"owl"},{"value":"🦜","match":"parrot"},{"value":"🕊️","match":"peace"},{"value":"🕊️","match":"dove"},{"value":"🦤","match":"dodo"},{"value":"🦢","match":"swan"},{"value":"🦆","match":"duck"},{"value":"🪿","match":"goose"},{"value":"🦩","match":"flamingo"},{"value":"🦚","match":"peacock"},{"value":"🐦🔥","match":"phoenix"},{"value":"🦃","match":"turkey"},{"value":"🐧","match":"penguin <(\")"},{"value":"🦭","match":"seal"},{"value":"🦈","match":"shark"},{"value":"🐬","match":"dolphin"},{"value":"🐋","match":"humpback-whale"},{"value":"🐳","match":"whale"},{"value":"🐟","match":"fish <><"},{"value":"🐠","match":"tropical-fish"},{"value":"🐡","match":"blowfish"},{"value":"🦐","match":"shrimp"},{"value":"🦞","match":"lobster"},{"value":"🦀","match":"crab"},{"value":"🦑","match":"squid くコ:彡"},{"value":"🐙","match":"octopus"},{"value":"🪼","match":"jellyfish"},{"value":"🦪","match":"oyster"},{"value":"🪸","match":"coral"},{"value":"🦂","match":"scorpion"},{"value":"🕷️","match":"spider"},{"value":"🕸️","match":"spider-web"},{"value":"🐚","match":"shell"},{"value":"🐌","match":"snail"},{"value":"🐜","match":"ant"},{"value":"🦗","match":"cricket"},{"value":"🪲","match":"beetle"},{"value":"🦟","match":"mosquito"},{"value":"🪳","match":"cockroach"},{"value":"🪰","match":"fly"},{"value":"🐝","match":"bee"},{"value":"🐞","match":"lady-bug"},{"value":"🦋","match":"butterfly εїз"},{"value":"🐛","match":"bug"},{"value":"🪱","match":"worm"},{"value":"🦠","match":"microbe"},{"value":"🐾","match":"paw-prints"},{"value":"🍓","match":"strawberry"},{"value":"🍒","match":"cherries"},{"value":"🍎","match":"red-apple"},{"value":"🍅","match":"tomato"},{"value":"🌶️","match":"hot-pepper"},{"value":"🍉","match":"watermelon"},{"value":"🍑","match":"peach"},{"value":"🍊","match":"tangerine"},{"value":"🍊","match":"orange"},{"value":"🍊","match":"mandarin"},{"value":"🥕","match":"carrot"},{"value":"🥭","match":"mango"},{"value":"🍍","match":"pineapple"},{"value":"🍌","match":"banana"},{"value":"🌽","match":"ear-of-corn"},{"value":"🍋","match":"lemon"},{"value":"🍋🟩","match":"lime"},{"value":"🍈","match":"melon"},{"value":"🍐","match":"pear"},{"value":"🫛","match":"pea-pod"},{"value":"🥬","match":"leafy-green"},{"value":"🫑","match":"bell-pepper"},{"value":"🍏","match":"green-apple"},{"value":"🥝","match":"kiwi-fruit"},{"value":"🥑","match":"avocado"},{"value":"🫒","match":"olive"},{"value":"🥦","match":"broccoli"},{"value":"🥒","match":"cucumber"},{"value":"🫐","match":"blueberries"},{"value":"🍇","match":"grapes"},{"value":"🍆","match":"eggplant"},{"value":"🍠","match":"roasted-sweet-potato"},{"value":"","match":"root-vegetable"},{"value":"","match":"beet"},{"value":"","match":"turnip"},{"value":"🥥","match":"coconut"},{"value":"🥔","match":"potato"},{"value":"🍄🟫","match":"brown-mushroom"},{"value":"🧅","match":"onion"},{"value":"🫚","match":"ginger"},{"value":"🧄","match":"garlic"},{"value":"🫘","match":"beans"},{"value":"🌰","match":"chestnut"},{"value":"🥜","match":"peanuts"},{"value":"🍞","match":"bread"},{"value":"🫓","match":"flatbread"},{"value":"🥐","match":"croissant"},{"value":"🥖","match":"baguette-bread"},{"value":"🥯","match":"bagel"},{"value":"🧇","match":"waffle"},{"value":"🥞","match":"pancakes"},{"value":"🍳","match":"cooking"},{"value":"🥚","match":"egg"},{"value":"🧀","match":"cheese-wedge"},{"value":"🥓","match":"bacon"},{"value":"🥩","match":"cut-of-meat"},{"value":"🍗","match":"poultry-leg"},{"value":"🍖","match":"meat-on-bone"},{"value":"🍔","match":"hamburger"},{"value":"🌭","match":"hot-dog"},{"value":"🥪","match":"sandwich"},{"value":"🥨","match":"pretzel"},{"value":"🍟","match":"french-fries"},{"value":"🍕","match":"pizza"},{"value":"🫔","match":"tamale"},{"value":"🌮","match":"taco"},{"value":"🌯","match":"burrito"},{"value":"🥙","match":"stuffed-flatbread"},{"value":"🧆","match":"falafel"},{"value":"🥘","match":"shallow-pan-of-food"},{"value":"🍝","match":"spaghetti"},{"value":"🥫","match":"canned-food"},{"value":"🫕","match":"fondue"},{"value":"🥣","match":"bowl-with-spoon"},{"value":"🥗","match":"green-salad"},{"value":"🍲","match":"pot-of-food"},{"value":"🍛","match":"curry-rice"},{"value":"🍜","match":"steaming-bowl"},{"value":"🦪","match":"oyster"},{"value":"🦞","match":"lobster"},{"value":"🍣","match":"sushi"},{"value":"🍤","match":"fried-shrimp"},{"value":"🥡","match":"takeout-box"},{"value":"🍚","match":"cooked-rice"},{"value":"🍱","match":"bento-box"},{"value":"🥟","match":"dumpling"},{"value":"🍢","match":"oden"},{"value":"🍙","match":"rice-ball"},{"value":"🍘","match":"rice-cracker"},{"value":"🍥","match":"fish-cake-with-swirl"},{"value":"🍡","match":"dango"},{"value":"🥠","match":"fortune-cookie"},{"value":"🥮","match":"moon-cake"},{"value":"🍧","match":"shaved-ice"},{"value":"🍨","match":"ice-cream"},{"value":"🍦","match":"soft-ice-cream"},{"value":"🥧","match":"pie"},{"value":"🍰","match":"shortcake"},{"value":"🍮","match":"custard"},{"value":"🎂","match":"birthday-cake"},{"value":"🧁","match":"cupcake"},{"value":"🍭","match":"lollipop"},{"value":"🍬","match":"candy"},{"value":"🍫","match":"chocolate-bar"},{"value":"🍩","match":"doughnut"},{"value":"🍪","match":"cookie"},{"value":"🍯","match":"honey-pot"},{"value":"🧂","match":"salt"},{"value":"🧈","match":"butter"},{"value":"🍿","match":"popcorn"},{"value":"🧊","match":"ice-cube"},{"value":"🫙","match":"jar"},{"value":"🥤","match":"cup-with-straw"},{"value":"🧋","match":"bubble-tea"},{"value":"🧋","match":"milk-tea"},{"value":"🧃","match":"beverage-box"},{"value":"🥛","match":"glass-of-milk"},{"value":"🍼","match":"baby-bottle"},{"value":"🍵","match":"teacup-without-handle"},{"value":"☕","match":"hot-beverage"},{"value":"🫖","match":"teapot"},{"value":"🧉","match":"mate"},{"value":"🍺","match":"beer-mug"},{"value":"🍻","match":"clinking-beer-mugs"},{"value":"🥂","match":"clinking-glasses"},{"value":"🍾","match":"bottle-with-popping-cork"},{"value":"🍷","match":"wine-glass"},{"value":"🥃","match":"tumbler-glass"},{"value":"🫗","match":"pour"},{"value":"🍸","match":"cocktail-glass"},{"value":"🍹","match":"tropical-drink"},{"value":"🍶","match":"sake"},{"value":"🥢","match":"chopsticks"},{"value":"🍴","match":"fork-and-knife"},{"value":"🥄","match":"spoon"},{"value":"🔪","match":"kitchen-knife"},{"value":"🍽️","match":"fork-and-knife-with-plate"},{"value":"🛑","match":"stop-sign"},{"value":"🚧","match":"construction"},{"value":"🚨","match":"police-car-light"},{"value":"⛽","match":"fuel-pump"},{"value":"🛢️","match":"oil-drum"},{"value":"🧭","match":"compass"},{"value":"🛞","match":"wheel"},{"value":"🛟","match":"ring-buoy"},{"value":"⚓","match":"anchor"},{"value":"🚏","match":"bus-stop"},{"value":"🚇","match":"metro"},{"value":"🚥","match":"horizontal-traffic-light"},{"value":"🚦","match":"vertical-traffic-light"},{"value":"🛴","match":"kick-scooter"},{"value":"🦽","match":"manual-wheelchair"},{"value":"🦼","match":"motorized-wheelchair"},{"value":"🩼","match":"crutch"},{"value":"🚲","match":"bicycle"},{"value":"🛵","match":"motor-scooter"},{"value":"🏍️","match":"motorcycle"},{"value":"🚙","match":"sport-utility-vehicle"},{"value":"🚗","match":"automobile"},{"value":"🛻","match":"pickup-truck"},{"value":"🚐","match":"minibus"},{"value":"🚚","match":"delivery-truck"},{"value":"🚛","match":"articulated-lorry"},{"value":"🚜","match":"tractor"},{"value":"🏎️","match":"racing-car"},{"value":"🚒","match":"fire-engine"},{"value":"🚑","match":"ambulance"},{"value":"🚓","match":"police-car"},{"value":"🚕","match":"taxi"},{"value":"🛺","match":"auto-rickshaw"},{"value":"🚌","match":"bus"},{"value":"🚈","match":"light-rail"},{"value":"🚝","match":"monorail"},{"value":"🚅","match":"bullet-train"},{"value":"🚄","match":"high-speed-train"},{"value":"🚂","match":"locomotive"},{"value":"🚃","match":"railway-car"},{"value":"🚋","match":"tram-car"},{"value":"🚎","match":"trolleybus"},{"value":"🚞","match":"mountain-railway"},{"value":"🚊","match":"tram"},{"value":"🚉","match":"station"},{"value":"🚍","match":"bus-front"},{"value":"🚔","match":"police-car-front"},{"value":"🚘","match":"automobile-front"},{"value":"🚖","match":"taxi-front"},{"value":"🚆","match":"train"},{"value":"🚢","match":"ship"},{"value":"🛳️","match":"passenger-ship"},{"value":"🛥️","match":"motor-boat"},{"value":"🚤","match":"speedboat"},{"value":"⛴️","match":"ferry"},{"value":"⛵","match":"sailboat"},{"value":"🛶","match":"canoe"},{"value":"🚟","match":"suspension-railway"},{"value":"🚠","match":"mountain-cableway"},{"value":"🚡","match":"aerial-tramway"},{"value":"🚁","match":"helicopter"},{"value":"🛸","match":"flying-saucer"},{"value":"🚀","match":"rocket"},{"value":"✈️","match":"airplane"},{"value":"🛫","match":"airplane-departure"},{"value":"🛬","match":"airplane-arrival"},{"value":"🛩️","match":"small-airplane"},{"value":"🛝","match":"slide"},{"value":"🛝","match":"playground"},{"value":"🎢","match":"roller-coaster"},{"value":"🎡","match":"ferris-wheel"},{"value":"🎠","match":"carousel-horse"},{"value":"🎪","match":"circus-tent"},{"value":"🗼","match":"tokyo-tower"},{"value":"🗽","match":"statue-of-liberty"},{"value":"🗿","match":"moai"},{"value":"🗻","match":"mount-fuji"},{"value":"🏛️","match":"classical-building"},{"value":"💈","match":"barber-pole"},{"value":"⛲","match":"fountain"},{"value":"⛩️","match":"shinto-shrine"},{"value":"🕍","match":"synagogue"},{"value":"🕌","match":"mosque"},{"value":"🕋","match":"kaaba"},{"value":"🛕","match":"hindu-temple"},{"value":"⛪","match":"church"},{"value":"💒","match":"wedding"},{"value":"🏩","match":"love-hotel"},{"value":"🏯","match":"japanese-castle"},{"value":"🏰","match":"castle"},{"value":"🏗️","match":"construction-building"},{"value":"🏢","match":"office-building"},{"value":"🏭","match":"factory"},{"value":"🏬","match":"department-store"},{"value":"🏪","match":"convenience-store"},{"value":"🏟️","match":"stadium"},{"value":"🏦","match":"bank"},{"value":"🏫","match":"school"},{"value":"🏨","match":"hotel"},{"value":"🏣","match":"japanese-post-office"},{"value":"🏤","match":"post-office"},{"value":"🏥","match":"hospital"},{"value":"🏚️","match":"derelict-house"},{"value":"🏠","match":"house"},{"value":"🏡","match":"house-with-garden"},{"value":"🏘️","match":"houses"},{"value":"🛖","match":"hut"},{"value":"⛺","match":"tent"},{"value":"🏕️","match":"camping"},{"value":"⛱️","match":"umbrella-on-ground"},{"value":"🏙️","match":"cityscape"},{"value":"🌆","match":"sunset-cityscape"},{"value":"🌇","match":"sunset"},{"value":"🌃","match":"night-with-stars"},{"value":"🌉","match":"bridge-at-night"},{"value":"🌁","match":"foggy"},{"value":"🛤️","match":"railway-track"},{"value":"🛣️","match":"motorway"},{"value":"🗾","match":"map-of-japan"},{"value":"🗺️","match":"world-map"},{"value":"🌐","match":"globe-with-meridians"},{"value":"💺","match":"seat"},{"value":"🧳","match":"luggage"},{"value":"🎉","match":"party-popper"},{"value":"🎊","match":"confetti-ball"},{"value":"🎈","match":"balloon"},{"value":"🎂","match":"birthday-cake"},{"value":"🎀","match":"ribbon"},{"value":"🎁","match":"wrapped-gift"},{"value":"🎇","match":"sparkler"},{"value":"🎆","match":"fireworks"},{"value":"🧨","match":"firecracker"},{"value":"🧧","match":"red-envelope"},{"value":"🪔","match":"diya-lamp"},{"value":"🪅","match":"piñata"},{"value":"🪩","match":"mirror-ball"},{"value":"🪩","match":"disco-ball"},{"value":"🎐","match":"wind-chime"},{"value":"🎏","match":"carp-streamer"},{"value":"🎎","match":"japanese-dolls"},{"value":"🎑","match":"moon-viewing-ceremony"},{"value":"🎍","match":"pine-decoration"},{"value":"🎋","match":"tanabata-tree"},{"value":"🎄","match":"christmas-tree"},{"value":"🎃","match":"jack-o-lantern"},{"value":"🎗️","match":"reminder-ribbon"},{"value":"🥇","match":"gold-medal"},{"value":"🥇","match":"1st-place-medal"},{"value":"🥈","match":"silver-medal"},{"value":"🥈","match":"2nd-place-medal"},{"value":"🥉","match":"bronze-medal"},{"value":"🥉","match":"3rd-place-medal"},{"value":"🏅","match":"medal"},{"value":"🎖️","match":"military-medal"},{"value":"🏆","match":"trophy"},{"value":"📢","match":"loudspeaker"},{"value":"⚽","match":"soccer-ball"},{"value":"⚾","match":"baseball"},{"value":"🥎","match":"softball"},{"value":"🏀","match":"basketball"},{"value":"🏐","match":"volleyball"},{"value":"🏈","match":"american-football"},{"value":"🏉","match":"rugby-football"},{"value":"🥅","match":"goal-net"},{"value":"🎾","match":"tennis"},{"value":"🏸","match":"badminton"},{"value":"🥍","match":"lacrosse"},{"value":"🏏","match":"cricket-game"},{"value":"🏑","match":"field-hockey"},{"value":"🏒","match":"ice-hockey"},{"value":"🥌","match":"curling-stone"},{"value":"🛷","match":"sled"},{"value":"🎿","match":"skis"},{"value":"⛸️","match":"ice-skate"},{"value":"🛼","match":"roller-skates"},{"value":"🩰","match":"ballet-shoes"},{"value":"🛹","match":"skateboard"},{"value":"⛳","match":"flag-in-hole"},{"value":"🎯","match":"direct-hit"},{"value":"🎯","match":"target"},{"value":"🏹","match":"bow-and-arrow"},{"value":"🥏","match":"flying-disc"},{"value":"🪃","match":"boomerang"},{"value":"🪁","match":"kite"},{"value":"🎣","match":"fishing-pole"},{"value":"🤿","match":"diving-mask"},{"value":"🩱","match":"one-piece-swimsuit"},{"value":"🎽","match":"running-shirt"},{"value":"🥋","match":"martial-arts-uniform"},{"value":"🥊","match":"boxing-glove"},{"value":"🎱","match":"8-ball"},{"value":"🏓","match":"ping-pong"},{"value":"🎳","match":"bowling"},{"value":"♟️","match":"chess-pawn"},{"value":"🪀","match":"yo-yo"},{"value":"🧩","match":"jigsaw"},{"value":"🎮","match":"video-game"},{"value":"🕹️","match":"joystick"},{"value":"👾","match":"alien-monster"},{"value":"🔫","match":"pistol"},{"value":"🎲","match":"die"},{"value":"🎰","match":"slot-machine"},{"value":"🎴","match":"flower-playing-cards"},{"value":"🀄","match":"mahjong-red-dragon"},{"value":"🃏","match":"joker"},{"value":"🪄","match":"wand"},{"value":"🎩","match":"game-die"},{"value":"📷","match":"camera"},{"value":"📸","match":"camera-flash"},{"value":"🖼️","match":"framed-picture"},{"value":"🎨","match":"artist-palette"},{"value":"","match":"splatter"},{"value":"🖌️","match":"paintbrush"},{"value":"🖍️","match":"crayon"},{"value":"🪡","match":"needle"},{"value":"🧵","match":"thread"},{"value":"🧶","match":"yarn"},{"value":"🎹","match":"piano"},{"value":"🎹","match":"musical-keyboard"},{"value":"🎷","match":"saxophone"},{"value":"🎺","match":"trumpet"},{"value":"🎸","match":"guitar"},{"value":"🪕","match":"banjo"},{"value":"🎻","match":"violin"},{"value":"","match":"harp"},{"value":"🪘","match":"long-drum"},{"value":"🥁","match":"drum"},{"value":"🪇","match":"maracas"},{"value":"🪈","match":"flute"},{"value":"🪗","match":"accordion"},{"value":"🎤","match":"microphone"},{"value":"🎧","match":"headphone"},{"value":"🎚️","match":"level-slider"},{"value":"🎛️","match":"control-knobs"},{"value":"🎙️","match":"studio-microphone"},{"value":"📻","match":"radio"},{"value":"📺","match":"television"},{"value":"📼","match":"videocassette"},{"value":"📹","match":"video-camera"},{"value":"📽️","match":"film-projector"},{"value":"🎥","match":"movie-camera"},{"value":"🎞️","match":"film"},{"value":"🎬","match":"clapper"},{"value":"🎭","match":"performing-arts"},{"value":"🎫","match":"ticket"},{"value":"🎟️","match":"admission-tickets"},{"value":"📱","match":"mobile-phone"},{"value":"☎️","match":"telephone"},{"value":"📞","match":"telephone-receiver"},{"value":"📟","match":"pager"},{"value":"📠","match":"fax-machine"},{"value":"🔌","match":"electric-plug"},{"value":"🔋","match":"battery-full"},{"value":"🪫","match":"battery-low"},{"value":"🖲️","match":"trackball"},{"value":"💽","match":"computer-disk"},{"value":"💾","match":"floppy-disk"},{"value":"💿","match":"optical-disk"},{"value":"📀","match":"dvd"},{"value":"🖥️","match":"desktop-computer"},{"value":"💻","match":"laptop-computer"},{"value":"⌨️","match":"keyboard"},{"value":"🖨️","match":"printer"},{"value":"🖱️","match":"computer-mouse"},{"value":"🪙","match":"coin"},{"value":"💸","match":"money-with-wings"},{"value":"💵","match":"dollar"},{"value":"💴","match":"yen"},{"value":"💶","match":"euro"},{"value":"💷","match":"pound"},{"value":"💳","match":"credit-card"},{"value":"💰","match":"money-bag"},{"value":"💎","match":"gem-stone"},{"value":"🧾","match":"receipt"},{"value":"🧮","match":"abacus"},{"value":"⚖️","match":"balance-scale"},{"value":"🛒","match":"shopping-cart"},{"value":"🛍️","match":"shopping-bags"},{"value":"🕯️","match":"candle"},{"value":"💡","match":"light-bulb"},{"value":"🔦","match":"flashlight"},{"value":"🏮","match":"red-paper-lantern"},{"value":"🧱","match":"bricks"},{"value":"🪟","match":"window"},{"value":"🪞","match":"mirror"},{"value":"🚪","match":"door"},{"value":"🪑","match":"chair"},{"value":"🛏️","match":"bed"},{"value":"🛋️","match":"couch-and-lamp"},{"value":"🚿","match":"shower"},{"value":"🛁","match":"bathtub"},{"value":"🚽","match":"toilet"},{"value":"🧻","match":"roll-of-paper"},{"value":"🪠","match":"plunger"},{"value":"🧸","match":"teddy-bear"},{"value":"🪆","match":"nesting-doll"},{"value":"🧷","match":"safety-pin"},{"value":"🪢","match":"knot"},{"value":"🧹","match":"broom"},{"value":"🧴","match":"lotion-bottle"},{"value":"🧽","match":"sponge"},{"value":"🧼","match":"soap"},{"value":"🪥","match":"toothbrush"},{"value":"🪒","match":"razor"},{"value":"🪮","match":"hair-pick"},{"value":"🧺","match":"basket"},{"value":"🧦","match":"socks"},{"value":"🧤","match":"gloves"},{"value":"🧣","match":"scarf"},{"value":"👖","match":"jeans"},{"value":"👕","match":"t-shirt"},{"value":"🎽","match":"running-shirt"},{"value":"👚","match":"woman’s-clothes"},{"value":"👔","match":"necktie"},{"value":"👗","match":"dress"},{"value":"👘","match":"kimono"},{"value":"🥻","match":"sari"},{"value":"🩱","match":"one-piece-swimsuit"},{"value":"👙","match":"bikini"},{"value":"🩳","match":"shorts"},{"value":"🩲","match":"swim-brief"},{"value":"🧥","match":"coat"},{"value":"🥼","match":"lab-coat"},{"value":"🦺","match":"safety-vest"},{"value":"⛑️","match":"rescue-worker’s-helmet"},{"value":"🪖","match":"military-helmet"},{"value":"🎓","match":"graduation-cap"},{"value":"🎩","match":"top-hat"},{"value":"👒","match":"woman’s-hat"},{"value":"🧢","match":"billed-cap"},{"value":"👑","match":"crown"},{"value":"💍","match":"ring"},{"value":"💄","match":"lipstick"},{"value":"🪭","match":"fan"},{"value":"🎒","match":"school-backpack"},{"value":"👝","match":"clutch-bag"},{"value":"👛","match":"purse"},{"value":"👜","match":"handbag"},{"value":"💼","match":"briefcase"},{"value":"🧳","match":"luggage"},{"value":"☂️","match":"umbrella"},{"value":"🌂","match":"closed-umbrella"},{"value":"🥾","match":"hiking-boot"},{"value":"👢","match":"boot"},{"value":"🩴","match":"flip-flop"},{"value":"🩴","match":"thong-sandal"},{"value":"👠","match":"high-heeled-shoe"},{"value":"👟","match":"running-shoe"},{"value":"👞","match":"man’s-shoe"},{"value":"🥿","match":"flat-shoe"},{"value":"👡","match":"sandal"},{"value":"🦯","match":"probing-cane"},{"value":"🕶️","match":"sunglasses"},{"value":"👓","match":"glasses"},{"value":"🥽","match":"goggles"},{"value":"⚗️","match":"alembic"},{"value":"🧫","match":"petri-dish"},{"value":"🧪","match":"test-tube"},{"value":"🌡️","match":"thermometer"},{"value":"💉","match":"syringe"},{"value":"💊","match":"pill"},{"value":"🩹","match":"adhesive-bandage"},{"value":"🩺","match":"stethoscope"},{"value":"🩻","match":"x-ray"},{"value":"🧬","match":"dna"},{"value":"🔭","match":"telescope"},{"value":"🔬","match":"microscope"},{"value":"📡","match":"satellite-antenna"},{"value":"🛰️","match":"satellite"},{"value":"🧯","match":"fire-extinguisher"},{"value":"🪓","match":"axe"},{"value":"🪜","match":"ladder"},{"value":"🪣","match":"bucket"},{"value":"🪝","match":"hook"},{"value":"🧲","match":"magnet"},{"value":"🧰","match":"toolbox"},{"value":"🗜️","match":"clamp"},{"value":"🔩","match":"nut-and-bolt"},{"value":"🪛","match":"screwdriver"},{"value":"🪚","match":"saw"},{"value":"🔧","match":"wrench"},{"value":"🔨","match":"hammer"},{"value":"🛠️","match":"hammer-and-wrench"},{"value":"⚒️","match":"hammer-and-pick"},{"value":"⛏️","match":"pick"},{"value":"","match":"shovel"},{"value":"","match":"dig"},{"value":"⚙️","match":"gear"},{"value":"⛓️💥","match":"broken-chain"},{"value":"🔗","match":"link"},{"value":"⛓️","match":"chains"},{"value":"📎","match":"paperclip"},{"value":"🖇️","match":"linked-paperclips"},{"value":"✂️","match":"scissors"},{"value":"📏","match":"straight-ruler"},{"value":"📐","match":"triangular-ruler"},{"value":"🖌️","match":"paintbrush"},{"value":"🖍️","match":"crayon"},{"value":"🖊️","match":"pen"},{"value":"🖋️","match":"fountain-pen"},{"value":"✒️","match":"black-nib"},{"value":"✏️","match":"pencil"},{"value":"📝","match":"memo"},{"value":"🗒️","match":"spiral-notepad"},{"value":"📄","match":"page-facing-up"},{"value":"📃","match":"page-with-curl"},{"value":"📑","match":"bookmark-tabs"},{"value":"📋","match":"clipboard"},{"value":"🗃️","match":"card-file-box"},{"value":"🗄️","match":"file-cabinet"},{"value":"📒","match":"ledger"},{"value":"📔","match":"notebook-with-decorative-cover"},{"value":"📕","match":"closed-book"},{"value":"📓","match":"notebook"},{"value":"📗","match":"green-book"},{"value":"📘","match":"blue-book"},{"value":"📙","match":"orange-book"},{"value":"📚","match":"books"},{"value":"📖","match":"open-book"},{"value":"🔖","match":"bookmark"},{"value":"📂","match":"open-file-folder"},{"value":"📁","match":"file-folder"},{"value":"🗂️","match":"card-index-dividers"},{"value":"📊","match":"bar-chart"},{"value":"📈","match":"chart-increasing"},{"value":"📉","match":"chart-decreasing"},{"value":"📇","match":"card-index"},{"value":"🪪","match":"id"},{"value":"📌","match":"pushpin"},{"value":"📍","match":"round-pushpin"},{"value":"🗑️","match":"wastebasket"},{"value":"📰","match":"newspaper"},{"value":"🗞️","match":"rolled-up-newspaper"},{"value":"🏷️","match":"label"},{"value":"📦","match":"package"},{"value":"📤","match":"outbox-tray"},{"value":"📥","match":"inbox-tray"},{"value":"📩","match":"envelope-with-arrow"},{"value":"📨","match":"incoming-envelope"},{"value":"✉️","match":"envelope"},{"value":"💌","match":"love-letter"},{"value":"📧","match":"e-mail"},{"value":"📫","match":"closed-mailbox-with-raised"},{"value":"📪","match":"closed-mailbox-with-lowered"},{"value":"📬","match":"open-mailbox-with-raised"},{"value":"📭","match":"open-mailbox-with-lowered"},{"value":"📮","match":"postbox"},{"value":"🗳️","match":"ballot-box"},{"value":"⌚","match":"watch"},{"value":"🕰️","match":"mantelpiece-clock"},{"value":"⌛","match":"hourglass-done"},{"value":"⏳","match":"hourglass-not-done"},{"value":"⏲️","match":"timer-clock"},{"value":"⏰","match":"alarm-clock"},{"value":"⏱️","match":"stopwatch"},{"value":"🕛","match":"twelve-o-clock"},{"value":"🕧","match":"twelve-thirty"},{"value":"🕐","match":"one-o-clock"},{"value":"🕜","match":"one-thirty"},{"value":"🕑","match":"two-o-clock"},{"value":"🕝","match":"two-thirty"},{"value":"🕒","match":"three-o-clock"},{"value":"🕞","match":"three-thirty"},{"value":"🕓","match":"four-o-clock"},{"value":"🕟","match":"four-thirty"},{"value":"🕔","match":"five-o-clock"},{"value":"🕠","match":"five-thirty"},{"value":"🕕","match":"six-o-clock"},{"value":"🕡","match":"six-thirty"},{"value":"🕖","match":"seven-o-clock"},{"value":"🕢","match":"seven-thirty"},{"value":"🕗","match":"eight-o-clock"},{"value":"🕣","match":"eight-thirty"},{"value":"🕘","match":"nine-o-clock"},{"value":"🕤","match":"nine-thirty"},{"value":"🕙","match":"ten-o-clock"},{"value":"🕥","match":"ten-thirty"},{"value":"🕚","match":"eleven-o-clock"},{"value":"🕦","match":"eleven-thirty"},{"value":"📅","match":"calendar"},{"value":"📆","match":"tear-off-calendar"},{"value":"🗓️","match":"spiral-calendar"},{"value":"🪧","match":"placard"},{"value":"🛎️","match":"bellhop-bell"},{"value":"🔔","match":"bell"},{"value":"📯","match":"postal-horn"},{"value":"📢","match":"loudspeaker"},{"value":"📣","match":"megaphone"},{"value":"🔈","match":"low-volume"},{"value":"🔈","match":"speaker-low-volume"},{"value":"🔉","match":"medium-volume"},{"value":"🔉","match":"speaker-medium-volume"},{"value":"🔊","match":"high-volume"},{"value":"🔊","match":"speaker-high-volume"},{"value":"🔍","match":"magnifying-glass-tilted-left"},{"value":"🔎","match":"magnifying-glass-tilted-right"},{"value":"🔮","match":"crystal-ball"},{"value":"🧿","match":"evil-eye"},{"value":"🧿","match":"nazar-amulet"},{"value":"🪬","match":"hamsa"},{"value":"📿","match":"prayer-beads"},{"value":"🏺","match":"amphora"},{"value":"⚱️","match":"urn"},{"value":"⚰️","match":"coffin"},{"value":"🪦","match":"headstone"},{"value":"🚬","match":"cigarette"},{"value":"💣","match":"bomb"},{"value":"🪤","match":"mouse-trap"},{"value":"📜","match":"scroll"},{"value":"⚔️","match":"crossed-swords"},{"value":"🗡️","match":"dagger"},{"value":"🛡️","match":"shield"},{"value":"🗝️","match":"old-key"},{"value":"🔑","match":"key"},{"value":"🔐","match":"lock-with-key"},{"value":"🔏","match":"lock-with-pen"},{"value":"🔒","match":"locked"},{"value":"🔓","match":"unlocked"},{"value":"🔴","match":"red-circle"},{"value":"🟠","match":"orange-circle"},{"value":"🟡","match":"yellow-circle"},{"value":"🟢","match":"green-circle"},{"value":"🔵","match":"blue-circle"},{"value":"🟣","match":"purple-circle"},{"value":"🟤","match":"brown-circle"},{"value":"⚫","match":"black-circle"},{"value":"⚪","match":"white-circle"},{"value":"🟥","match":"red-square"},{"value":"🟧","match":"orange-square"},{"value":"🟨","match":"yellow-square"},{"value":"🟩","match":"green-square"},{"value":"🟦","match":"blue-square"},{"value":"🟪","match":"purple-square"},{"value":"🟫","match":"brown-square"},{"value":"⬛","match":"black-square"},{"value":"⬜","match":"white-square"},{"value":"❤️","match":"red-heart"},{"value":"🧡","match":"orange-heart"},{"value":"💛","match":"yellow-heart"},{"value":"💚","match":"green-heart"},{"value":"💙","match":"blue-heart"},{"value":"💜","match":"purple-heart"},{"value":"🤎","match":"brown-heart"},{"value":"🖤","match":"black-heart"},{"value":"🤍","match":"white-heart"},{"value":"🩷","match":"pink-heart"},{"value":"🩵","match":"light-blue-heart"},{"value":"🩶","match":"gray-heart"},{"value":"♥️","match":"heart"},{"value":"♦️","match":"diamond"},{"value":"♣️","match":"club"},{"value":"♠️","match":"spade"},{"value":"♈","match":"aries"},{"value":"♉","match":"taurus"},{"value":"♊","match":"gemini"},{"value":"♋","match":"cancer"},{"value":"♌","match":"leo"},{"value":"♍","match":"virgo"},{"value":"♎","match":"libra"},{"value":"♏","match":"scorpio"},{"value":"♐","match":"sagittarius"},{"value":"♑","match":"capricorn"},{"value":"♒","match":"aquarius"},{"value":"♓","match":"pisces"},{"value":"⛎","match":"ophiuchus"},{"value":"♀️","match":"female-sign"},{"value":"♂️","match":"male-sign"},{"value":"⚧️","match":"trans-sign"},{"value":"💭","match":"thought-bubble"},{"value":"💭","match":"thought-balloon"},{"value":"🗯️","match":"anger-bubble"},{"value":"💬","match":"speech-bubble"},{"value":"🗨️","match":"speech-bubble-leftwards"},{"value":"❕","match":"exclamation-mark-white"},{"value":"❔","match":"question-mark-white"},{"value":"❗","match":"exclamation"},{"value":"❗","match":"exclamation-mark"},{"value":"❓","match":"question"},{"value":"❓","match":"question-mark"},{"value":"❓","match":"?"},{"value":"⁉️","match":"exclamation-question-mark"},{"value":"⁉️","match":"!?"},{"value":"‼️","match":"exclamation-double"},{"value":"‼️","match":"!!"},{"value":"⭕","match":"large-circle"},{"value":"❌","match":"x"},{"value":"❌","match":"cross-mark"},{"value":"🚫","match":"prohibited"},{"value":"🚳","match":"no-bicycles"},{"value":"🚭","match":"no-smoking"},{"value":"🚯","match":"no-littering"},{"value":"🚱","match":"non-potable-water"},{"value":"🚷","match":"no-pedestrians"},{"value":"📵","match":"no-mobile-phones"},{"value":"🔞","match":"no-under-eighteen"},{"value":"🔕","match":"no-sound"},{"value":"🔕","match":"no-bell"},{"value":"🔇","match":"mute"},{"value":"🅰️","match":"a-button"},{"value":"🅰️","match":"blood-type-a"},{"value":"🆎","match":"ab-button"},{"value":"🆎","match":"blood-type-ab"},{"value":"🅱️","match":"b-button"},{"value":"🅱️","match":"blood-type-b"},{"value":"🅾️","match":"o-button"},{"value":"🅾️","match":"blood-type-o"},{"value":"🆑","match":"cl-button"},{"value":"🆘","match":"sos"},{"value":"🛑","match":"stop"},{"value":"⛔","match":"no-entry"},{"value":"📛","match":"name-badge"},{"value":"♨️","match":"hot-springs"},{"value":"🔻","match":"triangle-pointed-down"},{"value":"🔺","match":"triangle-pointed-up"},{"value":"🉐","match":"bargain"},{"value":"㊙️","match":"secret"},{"value":"㊗️","match":"congratulations"},{"value":"🈴","match":"passing-grade"},{"value":"🈵","match":"no-vacancy"},{"value":"🈹","match":"discount"},{"value":"🈲","match":"prohibited-button"},{"value":"🉑","match":"accept"},{"value":"🈶","match":"not-free-of-charge"},{"value":"🈚","match":"free-of-charge"},{"value":"🈸","match":"application"},{"value":"🈺","match":"open-for-business"},{"value":"🈷️","match":"monthly-amount"},{"value":"✴️","match":"eight-pointed-star"},{"value":"🔶","match":"diamond-orange-large"},{"value":"🔸","match":"diamond-orange-small"},{"value":"🔆","match":"bright"},{"value":"🔆","match":"brightness"},{"value":"🔅","match":"dim"},{"value":"🔅","match":"dimness"},{"value":"🆚","match":"vs"},{"value":"🎦","match":"cinema"},{"value":"📶","match":"signal-strength"},{"value":"🔁","match":"repeat"},{"value":"🔂","match":"repeat-one"},{"value":"🔀","match":"shuffle"},{"value":"🔀","match":"twisted-rightwards-arrows"},{"value":"▶️","match":"arrow-forward"},{"value":"▶️","match":"play-button"},{"value":"⏩","match":"fast-forward"},{"value":"⏭️","match":"next-track"},{"value":"⏭️","match":"play-next"},{"value":"⏭️","match":"next"},{"value":"⏭️","match":"right-pointing-double-triangle-with-vertical-bar"},{"value":"⏯️","match":"play-or-pause"},{"value":"⏯️","match":"right-pointing-triangle-with-double-vertical-bar"},{"value":"◀️","match":"reverse"},{"value":"◀️","match":"leftwards-triangle"},{"value":"◀️","match":"arrow-backward"},{"value":"⏪","match":"rewind"},{"value":"⏪","match":"leftwards-double-triangles"},{"value":"⏮️","match":"previous"},{"value":"⏮️","match":"left-pointing-double-triangle-with-vertical-bar"},{"value":"🔼","match":"upwards"},{"value":"🔼","match":"arrow-up"},{"value":"🔼","match":"triangle-up"},{"value":"⏫","match":"fast-up"},{"value":"⏫","match":"double-triangle-up"},{"value":"🔽","match":"downwards"},{"value":"🔽","match":"arrow-down"},{"value":"🔽","match":"triangle-down"},{"value":"⏬","match":"fast-down"},{"value":"⏬","match":"double-triangle-down"},{"value":"⏸️","match":"pause"},{"value":"⏸️","match":"double-vertical-bar"},{"value":"⏹️","match":"stop-button"},{"value":"⏹️","match":"square-button"},{"value":"⏺️","match":"record"},{"value":"⏏️","match":"eject"},{"value":"⏏️","match":"triangle-up-with-horizontal-bar"},{"value":"📴","match":"phone-off"},{"value":"🛜","match":"wireless"},{"value":"📳","match":"vibration"},{"value":"📳","match":"vibration-mode"},{"value":"📲","match":"phone-with-arrow"},{"value":"☢️","match":"radioactive"},{"value":"☣️","match":"biohazard"},{"value":"⚠️","match":"warning"},{"value":"🚸","match":"children-crossing"},{"value":"⚜️","match":"fleur-de-lis"},{"value":"🔱","match":"trident-emblem"},{"value":"〽️","match":"part-alternation-mark"},{"value":"🔰","match":"japanese-symbol-for-beginner"},{"value":"🔰","match":"beginner"},{"value":"✳️","match":"eight-spoked-asterisk"},{"value":"❇️","match":"sparkle"},{"value":"♻️","match":"recycling-symbol"},{"value":"💱","match":"currency-exchange"},{"value":"💲","match":"dollar-sign"},{"value":"💹","match":"chart-increasing-with-yen"},{"value":"🈯","match":"reserved"},{"value":"❎","match":"x-mark"},{"value":"❎","match":"cross-mark-button"},{"value":"❎","match":"no-mark"},{"value":"✅","match":"check-mark"},{"value":"✅","match":"check-mark-green"},{"value":"✔️","match":"check-mark-black"},{"value":"☑️","match":"check-mark-button"},{"value":"☑️","match":"vote"},{"value":"⬆️","match":"up-arrow"},{"value":"↗️","match":"up-right-arrow"},{"value":"➡️","match":"right-arrow"},{"value":"↘️","match":"down-right-arrow"},{"value":"⬇️","match":"down-arrow"},{"value":"↙️","match":"down-left-arrow"},{"value":"⬅️","match":"left-arrow"},{"value":"↖️","match":"up-left-arrow"},{"value":"↕️","match":"up-down-arrow"},{"value":"↔️","match":"left-right-arrow"},{"value":"↩️","match":"right-arrow-curving-left"},{"value":"↪️","match":"left-arrow-curving-right"},{"value":"⤴️","match":"right-arrow-curving-up"},{"value":"⤵️","match":"right-arrow-curving-down"},{"value":"🔃","match":"clockwise-arrows"},{"value":"🔄","match":"counterclockwise-arrows"},{"value":"🔙","match":"back"},{"value":"🔙","match":"arrow-back"},{"value":"🔛","match":"on"},{"value":"🔛","match":"arrow-on"},{"value":"🔝","match":"top"},{"value":"🔝","match":"arrow-top"},{"value":"🔚","match":"end"},{"value":"🔚","match":"arrow-end"},{"value":"🔜","match":"soon"},{"value":"🔜","match":"arrow-soon"},{"value":"🆕","match":"new"},{"value":"🆓","match":"free"},{"value":"🆙","match":"up!"},{"value":"🆗","match":"ok-button"},{"value":"🆒","match":"cool"},{"value":"🆖","match":"ng"},{"value":"ℹ️","match":"information"},{"value":"🅿️","match":"parking"},{"value":"🈁","match":"here"},{"value":"🈂️","match":"service-charge"},{"value":"🈳","match":"vacancy"},{"value":"🔣","match":"symbols"},{"value":"🔤","match":"letters"},{"value":"🔤","match":"abc"},{"value":"🔠","match":"uppercase-letters"},{"value":"🔡","match":"lowercase-letters"},{"value":"🔢","match":"numbers"},{"value":"#️⃣","match":"#"},{"value":"#️⃣","match":"number-sign"},{"value":"*️⃣","match":"asterisk"},{"value":"*️⃣","match":"keycap-asterisk"},{"value":"0️⃣","match":"zero"},{"value":"0️⃣","match":"keycap-zero"},{"value":"1️⃣","match":"one"},{"value":"1️⃣","match":"keycap-one"},{"value":"2️⃣","match":"two"},{"value":"2️⃣","match":"keycap-two"},{"value":"3️⃣","match":"three"},{"value":"3️⃣","match":"keycap-three"},{"value":"4️⃣","match":"four"},{"value":"4️⃣","match":"keycap-four"},{"value":"5️⃣","match":"five"},{"value":"5️⃣","match":"keycap-five"},{"value":"6️⃣","match":"six"},{"value":"6️⃣","match":"keycap-six"},{"value":"7️⃣","match":"seven"},{"value":"7️⃣","match":"keycap-seven"},{"value":"8️⃣","match":"eight"},{"value":"8️⃣","match":"keycap-eight"},{"value":"9️⃣","match":"nine"},{"value":"9️⃣","match":"keycap-nine"},{"value":"🔟","match":"ten"},{"value":"🔟","match":"keycap-ten"},{"value":"🌐","match":"globe"},{"value":"💠","match":"diamond-jewel"},{"value":"🔷","match":"blue-diamond-large"},{"value":"🔹","match":"blue-diamond-small"},{"value":"🏧","match":"atm"},{"value":"Ⓜ️","match":"metro-sign"},{"value":"Ⓜ️","match":"circled-m"},{"value":"🚾","match":"water-closet"},{"value":"🚻","match":"restroom"},{"value":"🚹","match":"mens-room"},{"value":"🚺","match":"womens-room"},{"value":"♿","match":"wheelchair-symbol"},{"value":"🚼","match":"baby-symbol"},{"value":"🛗","match":"elevator"},{"value":"🚮","match":"litter"},{"value":"🚰","match":"water-faucet"},{"value":"🛂","match":"passport-control"},{"value":"🛃","match":"customs"},{"value":"🛄","match":"baggage-claim"},{"value":"🛅","match":"left-luggage"},{"value":"💟","match":"heart-box"},{"value":"⚛️","match":"atom-symbol"},{"value":"🛐","match":"place-of-worship"},{"value":"🕉️","match":"om"},{"value":"☸️","match":"wheel-of-dharma"},{"value":"☮️","match":"peace-symbol"},{"value":"☯️","match":"yin-yang"},{"value":"☪️","match":"star-and-crescent"},{"value":"🪯","match":"khanda"},{"value":"✝️","match":"latin-cross"},{"value":"☦️","match":"orthodox-cross"},{"value":"✡️","match":"star-of-david"},{"value":"🔯","match":"star-of-david-with-dot"},{"value":"🕎","match":"menorah"},{"value":"♾️","match":"infinity"},{"value":"🆔","match":"id-button"},{"value":"🧑🧑🧒","match":"family"},{"value":"🧑🧑🧒🧒","match":"family-4"},{"value":"🧑🧒","match":"family-2"},{"value":"🧑🧒🧒","match":"family-3"},{"value":"⚕️","match":"medical-symbol"},{"value":"🎼","match":"musical-score"},{"value":"🎼","match":"treble-clef"},{"value":"🎵","match":"musical-note"},{"value":"🎶","match":"musical-notes"},{"value":"✖️","match":"multiplication-x"},{"value":"➕","match":"plus-sign"},{"value":"➕","match":"+"},{"value":"➖","match":"minus-sign"},{"value":"➖","match":"-"},{"value":"➗","match":"division-sign"},{"value":"🟰","match":"equals-sign"},{"value":"🟰","match":"="},{"value":"➰","match":"curly-loop"},{"value":"➿","match":"curly-loop-double"},{"value":"〰️","match":"wavy-dash"},{"value":"©️","match":"copyright"},{"value":"®️","match":"registered"},{"value":"™️","match":"trade-mark"},{"value":"🔘","match":"radio-button"},{"value":"🔳","match":"white-square-button"},{"value":"◼️","match":"black-square-medium"},{"value":"◾","match":"black-square-medium-small"},{"value":"▪️","match":"black-square-small"},{"value":"🔲","match":"button-black-square"},{"value":"◻️","match":"white-square-medium"},{"value":"◽","match":"white-square-medium-small"},{"value":"▫️","match":"white-square-small"},{"value":"👁️🗨️","match":"eye-bubble"},{"value":"🏁","match":"chequered-flag"},{"value":"🚩","match":"triangular-flag"},{"value":"🎌","match":"crossed-flags"},{"value":"🏴","match":"black-flag"},{"value":"🏳️","match":"white-flag"},{"value":"🏳️🌈","match":"rainbow-flag"},{"value":"🏳️⚧️","match":"trans-flag"},{"value":"🏴☠️","match":"pirate-flag"},{"value":"🇦🇨","match":"ascension-island-flag"},{"value":"🇦🇩","match":"andorra-flag"},{"value":"🇦🇪","match":"united-arab-emirates-flag"},{"value":"🇦🇫","match":"afghanistan-flag"},{"value":"🇦🇬","match":"antigua-barbuda-flag"},{"value":"🇦🇮","match":"anguilla-flag"},{"value":"🇦🇱","match":"albania-flag"},{"value":"🇦🇲","match":"armenia-flag"},{"value":"🇦🇴","match":"angola-flag"},{"value":"🇦🇶","match":"antarctica-flag"},{"value":"🇦🇷","match":"argentina-flag"},{"value":"🇦🇸","match":"american-samoa-flag"},{"value":"🇦🇹","match":"austria-flag"},{"value":"🇦🇺","match":"australia-flag"},{"value":"🇦🇼","match":"aruba-flag"},{"value":"🇦🇽","match":"åland-islands-flag"},{"value":"🇦🇿","match":"azerbaijan-flag"},{"value":"🇧🇦","match":"bosnia-herzegovina-flag"},{"value":"🇧🇧","match":"barbados-flag"},{"value":"🇧🇩","match":"bangladesh-flag"},{"value":"🇧🇪","match":"belgium-flag"},{"value":"🇧🇫","match":"burkina-faso-flag"},{"value":"🇧🇬","match":"bulgaria-flag"},{"value":"🇧🇭","match":"bahrain-flag"},{"value":"🇧🇮","match":"burundi-flag"},{"value":"🇧🇯","match":"benin-flag"},{"value":"🇧🇱","match":"st-barthélemy-flag"},{"value":"🇧🇲","match":"bermuda-flag"},{"value":"🇧🇳","match":"brunei-flag"},{"value":"🇧🇴","match":"bolivia-flag"},{"value":"🇧🇶","match":"caribbean-netherlands-flag"},{"value":"🇧🇷","match":"brazil-flag"},{"value":"🇧🇸","match":"bahamas-flag"},{"value":"🇧🇹","match":"bhutan-flag"},{"value":"🇧🇻","match":"bouvet-island-flag"},{"value":"🇧🇼","match":"botswana-flag"},{"value":"🇧🇾","match":"belarus-flag"},{"value":"🇧🇿","match":"belize-flag"},{"value":"🇨🇦","match":"canada-flag"},{"value":"🇨🇨","match":"cocos-islands-flag"},{"value":"🇨🇩","match":"congo-kinshasa-flag"},{"value":"🇨🇫","match":"central-african-republic-flag"},{"value":"🇨🇬","match":"congo-brazzaville-flag"},{"value":"🇨🇭","match":"switzerland-flag"},{"value":"🇨🇮","match":"côte-d’ivoire-flag"},{"value":"🇨🇰","match":"cook-islands-flag"},{"value":"🇨🇱","match":"chile-flag"},{"value":"🇨🇲","match":"cameroon-flag"},{"value":"🇨🇳","match":"china-flag"},{"value":"🇨🇴","match":"colombia-flag"},{"value":"🇨🇵","match":"clipperton-island-flag"},{"value":"🇨🇶","match":"sark-flag"},{"value":"🇨🇷","match":"costa-rica-flag"},{"value":"🇨🇺","match":"cuba-flag"},{"value":"🇨🇻","match":"cape-verde-flag"},{"value":"🇨🇼","match":"curaçao-flag"},{"value":"🇨🇽","match":"christmas-island-flag"},{"value":"🇨🇾","match":"cyprus-flag"},{"value":"🇨🇿","match":"czechia-flag"},{"value":"🇩🇪","match":"germany-flag"},{"value":"🇩🇬","match":"diego-garcia-flag"},{"value":"🇩🇯","match":"djibouti-flag"},{"value":"🇩🇰","match":"denmark-flag"},{"value":"🇩🇲","match":"dominica-flag"},{"value":"🇩🇴","match":"dominican-republic-flag"},{"value":"🇩🇿","match":"algeria-flag"},{"value":"🇪🇦","match":"ceuta-melilla-flag"},{"value":"🇪🇨","match":"ecuador-flag"},{"value":"🇪🇪","match":"estonia-flag"},{"value":"🇪🇬","match":"egypt-flag"},{"value":"🇪🇭","match":"western-sahara-flag"},{"value":"🇪🇷","match":"eritrea-flag"},{"value":"🇪🇸","match":"spain-flag"},{"value":"🇪🇹","match":"ethiopia-flag"},{"value":"🇪🇺","match":"european-union-flag"},{"value":"🇫🇮","match":"finland-flag"},{"value":"🇫🇯","match":"fiji-flag"},{"value":"🇫🇰","match":"falkland-islands-flag"},{"value":"🇫🇲","match":"micronesia-flag"},{"value":"🇫🇴","match":"faroe-islands-flag"},{"value":"🇫🇷","match":"france-flag"},{"value":"🇬🇦","match":"gabon-flag"},{"value":"🇬🇧","match":"united-kingdom-flag"},{"value":"🇬🇩","match":"grenada-flag"},{"value":"🇬🇪","match":"georgia-flag"},{"value":"🇬🇫","match":"french-guiana-flag"},{"value":"🇬🇬","match":"guernsey-flag"},{"value":"🇬🇭","match":"ghana-flag"},{"value":"🇬🇮","match":"gibraltar-flag"},{"value":"🇬🇱","match":"greenland-flag"},{"value":"🇬🇲","match":"gambia-flag"},{"value":"🇬🇳","match":"guinea-flag"},{"value":"🇬🇵","match":"guadeloupe-flag"},{"value":"🇬🇶","match":"equatorial-guinea-flag"},{"value":"🇬🇷","match":"greece-flag"},{"value":"🇬🇸","match":"south-georgia-south-flag"},{"value":"🇬🇹","match":"guatemala-flag"},{"value":"🇬🇺","match":"guam-flag"},{"value":"🇬🇼","match":"guinea-bissau-flag"},{"value":"🇬🇾","match":"guyana-flag"},{"value":"🇭🇰","match":"hong-kong-sar-china-flag"},{"value":"🇭🇲","match":"heard-mcdonald-islands-flag"},{"value":"🇭🇳","match":"honduras-flag"},{"value":"🇭🇷","match":"croatia-flag"},{"value":"🇭🇹","match":"haiti-flag"},{"value":"🇭🇺","match":"hungary-flag"},{"value":"🇮🇨","match":"canary-islands-flag"},{"value":"🇮🇩","match":"indonesia-flag"},{"value":"🇮🇪","match":"ireland-flag"},{"value":"🇮🇱","match":"israel-flag"},{"value":"🇮🇲","match":"isle-of-man-flag"},{"value":"🇮🇳","match":"india-flag"},{"value":"🇮🇴","match":"british-indian-ocean-territory-flag"},{"value":"🇮🇶","match":"iraq-flag"},{"value":"🇮🇷","match":"iran-flag"},{"value":"🇮🇸","match":"iceland-flag"},{"value":"🇮🇹","match":"italy-flag"},{"value":"🇯🇪","match":"jersey-flag"},{"value":"🇯🇲","match":"jamaica-flag"},{"value":"🇯🇴","match":"jordan-flag"},{"value":"🇯🇵","match":"japan-flag"},{"value":"🇰🇪","match":"kenya-flag"},{"value":"🇰🇬","match":"kyrgyzstan-flag"},{"value":"🇰🇭","match":"cambodia-flag"},{"value":"🇰🇮","match":"kiribati-flag"},{"value":"🇰🇲","match":"comoros-flag"},{"value":"🇰🇳","match":"st.-kitts-&-nevis-flag"},{"value":"🇰🇵","match":"north-korea-flag"},{"value":"🇰🇷","match":"south-korea-flag"},{"value":"🇰🇼","match":"kuwait-flag"},{"value":"🇰🇾","match":"cayman-islands-flag"},{"value":"🇰🇿","match":"kazakhstan-flag"},{"value":"🇱🇦","match":"laos-flag"},{"value":"🇱🇧","match":"lebanon-flag"},{"value":"🇱🇨","match":"st.-lucia-flag"},{"value":"🇱🇮","match":"liechtenstein-flag"},{"value":"🇱🇰","match":"sri-lanka-flag"},{"value":"🇱🇷","match":"liberia-flag"},{"value":"🇱🇸","match":"lesotho-flag"},{"value":"🇱🇹","match":"lithuania-flag"},{"value":"🇱🇺","match":"luxembourg-flag"},{"value":"🇱🇻","match":"latvia-flag"},{"value":"🇱🇾","match":"libya-flag"},{"value":"🇲🇦","match":"morocco-flag"},{"value":"🇲🇨","match":"monaco-flag"},{"value":"🇲🇩","match":"moldova-flag"},{"value":"🇲🇪","match":"montenegro-flag"},{"value":"🇲🇫","match":"st-martin-flag"},{"value":"🇲🇬","match":"madagascar-flag"},{"value":"🇲🇭","match":"marshall-islands-flag"},{"value":"🇲🇰","match":"macedonia-flag"},{"value":"🇲🇱","match":"mali-flag"},{"value":"🇲🇲","match":"myanmar-flag"},{"value":"🇲🇳","match":"mongolia-flag"},{"value":"🇲🇴","match":"macau-sar-china-flag"},{"value":"🇲🇵","match":"northern-mariana-islands-flag"},{"value":"🇲🇶","match":"martinique-flag"},{"value":"🇲🇷","match":"mauritania-flag"},{"value":"🇲🇸","match":"montserrat-flag"},{"value":"🇲🇹","match":"malta-flag"},{"value":"🇲🇺","match":"mauritius-flag"},{"value":"🇲🇻","match":"maldives-flag"},{"value":"🇲🇼","match":"malawi-flag"},{"value":"🇲🇽","match":"mexico-flag"},{"value":"🇲🇾","match":"malaysia-flag"},{"value":"🇲🇿","match":"mozambique-flag"},{"value":"🇳🇦","match":"namibia-flag"},{"value":"🇳🇨","match":"new-caledonia-flag"},{"value":"🇳🇪","match":"niger-flag"},{"value":"🇳🇫","match":"norfolk-island-flag"},{"value":"🇳🇬","match":"nigeria-flag"},{"value":"🇳🇮","match":"nicaragua-flag"},{"value":"🇳🇱","match":"netherlands-flag"},{"value":"🇳🇴","match":"norway-flag"},{"value":"🇳🇵","match":"nepal-flag"},{"value":"🇳🇷","match":"nauru-flag"},{"value":"🇳🇺","match":"niue-flag"},{"value":"🇳🇿","match":"new-zealand-flag"},{"value":"🇴🇲","match":"oman-flag"},{"value":"🇵🇦","match":"panama-flag"},{"value":"🇵🇪","match":"peru-flag"},{"value":"🇵🇫","match":"french-polynesia-flag"},{"value":"🇵🇬","match":"papua-new-guinea-flag"},{"value":"🇵🇭","match":"philippines-flag"},{"value":"🇵🇰","match":"pakistan-flag"},{"value":"🇵🇱","match":"poland-flag"},{"value":"🇵🇲","match":"st-pierre-miquelon-flag"},{"value":"🇵🇳","match":"pitcairn-islands-flag"},{"value":"🇵🇷","match":"puerto-rico-flag"},{"value":"🇵🇸","match":"palestinian-territories-flag"},{"value":"🇵🇹","match":"portugal-flag"},{"value":"🇵🇼","match":"palau-flag"},{"value":"🇵🇾","match":"paraguay-flag"},{"value":"🇶🇦","match":"qatar-flag"},{"value":"🇷🇪","match":"réunion-flag"},{"value":"🇷🇴","match":"romania-flag"},{"value":"🇷🇸","match":"serbia-flag"},{"value":"🇷🇺","match":"russia-flag"},{"value":"🇷🇼","match":"rwanda-flag"},{"value":"🇸🇦","match":"saudi-arabia-flag"},{"value":"🇸🇧","match":"solomon-islands-flag"},{"value":"🇸🇨","match":"seychelles-flag"},{"value":"🇸🇩","match":"sudan-flag"},{"value":"🇸🇪","match":"sweden-flag"},{"value":"🇸🇬","match":"singapore-flag"},{"value":"🇸🇭","match":"st-helena-flag"},{"value":"🇸🇮","match":"slovenia-flag"},{"value":"🇸🇯","match":"svalbard-jan-mayen-flag"},{"value":"🇸🇰","match":"slovakia-flag"},{"value":"🇸🇱","match":"sierra-leone-flag"},{"value":"🇸🇲","match":"san-marino-flag"},{"value":"🇸🇳","match":"senegal-flag"},{"value":"🇸🇴","match":"somalia-flag"},{"value":"🇸🇷","match":"suriname-flag"},{"value":"🇸🇸","match":"south-sudan-flag"},{"value":"🇸🇹","match":"são-tomé-príncipe-flag"},{"value":"🇸🇻","match":"el-salvador-flag"},{"value":"🇸🇽","match":"sint-maarten-flag"},{"value":"🇸🇾","match":"syria-flag"},{"value":"🇸🇿","match":"swaziland-flag"},{"value":"🇹🇦","match":"tristan-da-cunha-flag"},{"value":"🇹🇨","match":"turks-caicos-islands-flag"},{"value":"🇹🇩","match":"chad-flag"},{"value":"🇹🇫","match":"french-southern-territories-flag"},{"value":"🇹🇬","match":"togo-flag"},{"value":"🇹🇭","match":"thailand-flag"},{"value":"🇹🇯","match":"tajikistan-flag"},{"value":"🇹🇰","match":"tokelau-flag"},{"value":"🇹🇱","match":"timor-leste-flag"},{"value":"🇹🇲","match":"turkmenistan-flag"},{"value":"🇹🇳","match":"tunisia-flag"},{"value":"🇹🇴","match":"tonga-flag"},{"value":"🇹🇷","match":"turkey-flag"},{"value":"🇹🇹","match":"trinidad-tobago-flag"},{"value":"🇹🇻","match":"tuvalu-flag"},{"value":"🇹🇼","match":"taiwan-flag"},{"value":"🇹🇿","match":"tanzania-flag"},{"value":"🇺🇦","match":"ukraine-flag"},{"value":"🇺🇬","match":"uganda-flag"},{"value":"🇺🇲","match":"us-outlying-islands-flag"},{"value":"🇺🇳","match":"united-nations-flag"},{"value":"🇺🇸","match":"united-states-flag"},{"value":"🇺🇾","match":"uruguay-flag"},{"value":"🇺🇿","match":"uzbekistan-flag"},{"value":"🇻🇦","match":"vatican-city-flag"},{"value":"🇻🇨","match":"st-vincent-grenadines-flag"},{"value":"🇻🇪","match":"venezuela-flag"},{"value":"🇻🇬","match":"british-virgin-islands-flag"},{"value":"🇻🇮","match":"us-virgin-islands-flag"},{"value":"🇻🇳","match":"vietnam-flag"},{"value":"🇻🇺","match":"vanuatu-flag"},{"value":"🇼🇫","match":"wallis-futuna-flag"},{"value":"🇼🇸","match":"samoa-flag"},{"value":"🇽🇰","match":"kosovo-flag"},{"value":"🇾🇪","match":"yemen-flag"},{"value":"🇾🇹","match":"mayotte-flag"},{"value":"🇿🇦","match":"south-africa-flag"},{"value":"🇿🇲","match":"zambia-flag"},{"value":"🇿🇼","match":"zimbabwe-flag"},{"value":"🏴","match":"england-flag"},{"value":"🏴","match":"scotland-flag"},{"value":"🏴","match":"wales-flag"}]; +export default [{"value":"😀","match":"smile :D"},{"value":"😃","match":"smile-with-big-eyes :-D"},{"value":"😄","match":"grin ^_^"},{"value":"😁","match":"grinning *^_^*"},{"value":"😆","match":"laughing X-D"},{"value":"😅","match":"grin-sweat ^_^;"},{"value":"😂","match":"joy >w<"},{"value":"🤣","match":"rofl *>w<*"},{"value":"😭","match":"loudly-crying ;_;"},{"value":"😉","match":"wink ;)"},{"value":"😗","match":"kissing :*"},{"value":"😙","match":"kissing-smiling-eyes ^3^"},{"value":"😚","match":"kissing-closed-eyes :**"},{"value":"😘","match":"kissing-heart ;*"},{"value":"🥰","match":"heart-face <3:)"},{"value":"🥰","match":"3-hearts <3:)"},{"value":"😍","match":"heart-eyes ♥_♥"},{"value":"🤩","match":"star-struck *_*"},{"value":"🥳","match":"partying-face (ノ◕ヮ◕)♬♪"},{"value":"🫠","match":"melting"},{"value":"🙃","match":"upside-down-face (:"},{"value":"🙂","match":"slightly-happy :) :-)"},{"value":"🥲","match":"happy-cry :,)"},{"value":"🥹","match":"holding-back-tears (;人;)"},{"value":"😊","match":"blush"},{"value":"☺️","match":"warm-smile"},{"value":"😌","match":"relieved"},{"value":"🙂↕️","match":"head-nod"},{"value":"🙂↔️","match":"head-shake"},{"value":"😏","match":"smirk >~>"},{"value":"🤤","match":"drool (¯﹃¯)"},{"value":"😋","match":"yum"},{"value":"😛","match":"stuck-out-tongue :P :p :-P :-p"},{"value":"😝","match":"squinting-tongue >q<"},{"value":"😜","match":"winky-tongue ;p"},{"value":"🤪","match":"zany-face"},{"value":"","match":"distorted-face"},{"value":"🥴","match":"woozy >﹏☉"},{"value":"😔","match":"pensive ._."},{"value":"🥺","match":"pleading ◕﹏◕"},{"value":"😬","match":"grimacing :-|"},{"value":"😑","match":"expressionless -_-"},{"value":"😐","match":"neutral-face :|"},{"value":"😶","match":"mouth-none"},{"value":"😶🌫️","match":"face-in-clouds"},{"value":"😶🌫️","match":"lost"},{"value":"🫥","match":"dotted-line-face"},{"value":"🫥","match":"invisible"},{"value":"🤐","match":"zipper-face :#"},{"value":"🫡","match":"salute (・д・ゝ)"},{"value":"🤔","match":"thinking-face =L"},{"value":"🤫","match":"shushing-face ( ̄b ̄)"},{"value":"🫢","match":"hand-over-mouth"},{"value":"🤭","match":"smiling-eyes-with-hand-over-mouth"},{"value":"🤭","match":"chuckling"},{"value":"🥱","match":"yawn ~O~"},{"value":"🤗","match":"hug-face \\(^o^)/"},{"value":"🫣","match":"peeking (*/。\)"},{"value":"😱","match":"screaming @0@"},{"value":"🤨","match":"raised-eyebrow ( ͝סּ ͜ʖ͡סּ)"},{"value":"🧐","match":"monocle o~O"},{"value":"😒","match":"unamused >->"},{"value":"🙄","match":"rolling-eyes"},{"value":"😮💨","match":"exhale"},{"value":"😤","match":"triumph (((╬◣﹏◢)))"},{"value":"😠","match":"angry X-("},{"value":"😡","match":"rage >:O"},{"value":"🤬","match":"cursing #$@!"},{"value":"😞","match":"sad"},{"value":"😓","match":"sweat (0へ0)"},{"value":"😓","match":"downcast (0へ0)"},{"value":"😟","match":"worried :S"},{"value":"😥","match":"concerned •_•'"},{"value":"😢","match":"cry :'("},{"value":"☹️","match":"big-frown :-("},{"value":"🙁","match":"frown :("},{"value":"🫤","match":"diagonal-mouth :/"},{"value":"😕","match":"slightly-frowning :-/"},{"value":"😰","match":"anxious-with-sweat D-':"},{"value":"😨","match":"scared D-:"},{"value":"😧","match":"anguished"},{"value":"😦","match":"gasp D="},{"value":"😮","match":"mouth-open :O"},{"value":"😯","match":"surprised :o"},{"value":"😯","match":"hushed :o"},{"value":"😲","match":"astonished"},{"value":"😳","match":"flushed 8‑0"},{"value":"🤯","match":"mind-blown"},{"value":"🤯","match":"exploding-head"},{"value":"😖","match":"scrunched-mouth >:["},{"value":"😖","match":"confounded >:["},{"value":"😖","match":"zigzag-mouth >:["},{"value":"😣","match":"scrunched-eyes >:("},{"value":"😣","match":"persevering >:("},{"value":"😩","match":"weary D:"},{"value":"😫","match":"distraught D-X"},{"value":"😵","match":"x-eyes X_o"},{"value":"😵💫","match":"dizzy-face"},{"value":"🫨","match":"shaking-face"},{"value":"🥴","match":"woozy >﹏☉"},{"value":"🥵","match":"hot-face"},{"value":"🥵","match":"sweat-face"},{"value":"🥶","match":"cold-face"},{"value":"🤢","match":"sick :-###"},{"value":"🤢","match":"nauseated :-###"},{"value":"🤮","match":"vomit :-O##"},{"value":"","match":"tired"},{"value":"","match":"bags-under-eyes"},{"value":"😴","match":"sleep Z_Z"},{"value":"😪","match":"sleepy (-.-)zzZZ"},{"value":"🤧","match":"sneeze (*´台`*)"},{"value":"🤒","match":"thermometer-face"},{"value":"🤕","match":"bandage-face"},{"value":"😷","match":"mask"},{"value":"🤥","match":"liar"},{"value":"😇","match":"halo O:)"},{"value":"😇","match":"innocent O:)"},{"value":"🤠","match":"cowboy <):)"},{"value":"🤑","match":"money-face $_$"},{"value":"🤓","match":"nerd-face :-B"},{"value":"😎","match":"sunglasses-face B-)"},{"value":"🥸","match":"disguise"},{"value":"🤡","match":"clown :o)"},{"value":"💩","match":"poop ༼^-^༽"},{"value":"😈","match":"imp-smile 3:)"},{"value":"👿","match":"imp-frown 3:("},{"value":"👻","match":"ghost ⊂(´・◡・⊂)∘˚˳°"},{"value":"💀","match":"skull"},{"value":"☠️","match":"skull-and-crossbones"},{"value":"🤖","match":"robot"},{"value":"👹","match":"ogre"},{"value":"👺","match":"goblin"},{"value":"☃️","match":"snowman-with-snow"},{"value":"⛄","match":"snowman"},{"value":"👽","match":"alien (<>..<>)"},{"value":"👾","match":"alien-monster"},{"value":"🌚","match":"moon-face-new >_>"},{"value":"🌝","match":"moon-face-full <_<"},{"value":"🌞","match":"sun-with-face"},{"value":"🌛","match":"moon-face-first-quarter"},{"value":"🌜","match":"moon-face-last-quarter"},{"value":"😺","match":"smiley-cat :3"},{"value":"😸","match":"smile-cat"},{"value":"😹","match":"joy-cat"},{"value":"😻","match":"heart-eyes-cat"},{"value":"😼","match":"smirk-cat"},{"value":"😽","match":"kissing-cat"},{"value":"🙀","match":"scream-cat"},{"value":"😿","match":"crying-cat-face"},{"value":"😾","match":"pouting-cat"},{"value":"🙈","match":"see-no-evil-monkey"},{"value":"🙉","match":"hear-no-evil-monkey"},{"value":"🙊","match":"speak-no-evil-monkey"},{"value":"💫","match":"dizzy"},{"value":"⭐","match":"star"},{"value":"🌟","match":"glowing-star"},{"value":"✨","match":"sparkles"},{"value":"⚡","match":"electricity"},{"value":"⚡","match":"zap"},{"value":"⚡","match":"lightning"},{"value":"💥","match":"collision"},{"value":"","match":"fight"},{"value":"💢","match":"anger"},{"value":"💨","match":"dash"},{"value":"💨","match":"poof"},{"value":"💤","match":"zzz"},{"value":"🕳️","match":"hole"},{"value":"🔥","match":"fire"},{"value":"🔥","match":"burn"},{"value":"🔥","match":"lit"},{"value":"💯","match":"100"},{"value":"💯","match":"one-hundred"},{"value":"💯","match":"hundred"},{"value":"💯","match":"points"},{"value":"🎉","match":"party-popper"},{"value":"🎊","match":"confetti-ball"},{"value":"❤️","match":"red-heart <3"},{"value":"🧡","match":"orange-heart"},{"value":"💛","match":"yellow-heart"},{"value":"💚","match":"green-heart"},{"value":"🩵","match":"light-blue-heart"},{"value":"💙","match":"blue-heart"},{"value":"💜","match":"purple-heart"},{"value":"🤎","match":"brown-heart"},{"value":"🖤","match":"black-heart"},{"value":"🩶","match":"grey-heart"},{"value":"🤍","match":"white-heart"},{"value":"🩷","match":"pink-heart"},{"value":"💘","match":"cupid"},{"value":"💝","match":"gift-heart"},{"value":"💖","match":"sparkling-heart"},{"value":"💗","match":"heart-grow"},{"value":"💓","match":"beating-heart"},{"value":"💞","match":"revolving-hearts"},{"value":"💕","match":"two-hearts <3<3"},{"value":"💌","match":"love-letter"},{"value":"💟","match":"heart-box"},{"value":"♥️","match":"heart"},{"value":"❣️","match":"heart-exclamation-point <3!"},{"value":"❤️🩹","match":"bandaged-heart"},{"value":"💔","match":"broken-heart </3"},{"value":"❤️🔥","match":"fire-heart"},{"value":"💋","match":"kiss"},{"value":"🫂","match":"hugging"},{"value":"👥","match":"busts-in-silhouette"},{"value":"👤","match":"bust-in-silhouette"},{"value":"🗣️","match":"speaking-head"},{"value":"👣","match":"footprints"},{"value":"","match":"fingerprint"},{"value":"💦","match":"sweat-droplets"},{"value":"🧠","match":"brain"},{"value":"🫀","match":"anatomical-heart"},{"value":"🫁","match":"lungs"},{"value":"🩸","match":"blood"},{"value":"🦠","match":"microbe"},{"value":"🦠","match":"virus"},{"value":"🦷","match":"tooth"},{"value":"🦴","match":"bone"},{"value":"👀","match":"eyes"},{"value":"👁️","match":"eye"},{"value":"👄","match":"mouth"},{"value":"🫦","match":"biting-lip"},{"value":"👅","match":"tongue"},{"value":"👃","match":"nose"},{"value":"👂","match":"ear"},{"value":"🦻","match":"hearing-aid"},{"value":"🦶","match":"foot"},{"value":"🦵","match":"leg"},{"value":"🦿","match":"leg-mechanical"},{"value":"🦾","match":"arm-mechanical"},{"value":"💪","match":"muscle"},{"value":"💪","match":"flex"},{"value":"💪","match":"bicep"},{"value":"💪","match":"strong"},{"value":"👏","match":"clap"},{"value":"👍","match":"thumbs-up"},{"value":"👍","match":"+1"},{"value":"👎","match":"thumbs-down"},{"value":"🫶","match":"heart-hands"},{"value":"🙌","match":"raising-hands"},{"value":"🙌","match":"hooray"},{"value":"👐","match":"open-hands"},{"value":"🤲","match":"palms-up"},{"value":"🤜","match":"fist-rightwards"},{"value":"🤛","match":"fist-leftwards"},{"value":"✊","match":"raised-fist"},{"value":"👊","match":"fist"},{"value":"👊","match":"bump"},{"value":"🫳","match":"palm-down"},{"value":"🫳","match":"drop"},{"value":"🫴","match":"palm-up"},{"value":"🫴","match":"throw"},{"value":"🫱","match":"rightwards-hand"},{"value":"🫲","match":"leftwards-hand"},{"value":"🫸","match":"push-rightwards"},{"value":"🫷","match":"push-leftwards"},{"value":"👋","match":"wave"},{"value":"🤚","match":"back-hand"},{"value":"🖐️","match":"palm"},{"value":"✋","match":"raised-hand"},{"value":"🖖","match":"vulcan"},{"value":"🖖","match":"prosper"},{"value":"🖖","match":"spock"},{"value":"🤟","match":"love-you-gesture"},{"value":"🤘","match":"metal"},{"value":"🤘","match":"horns"},{"value":"✌️","match":"v"},{"value":"✌️","match":"peace-hand"},{"value":"✌️","match":"victory"},{"value":"🤞","match":"crossed-fingers"},{"value":"🫰","match":"hand-with-index-finger-and-thumb-crossed"},{"value":"🫰","match":"snap"},{"value":"🫰","match":"finger-heart"},{"value":"🤙","match":"call-me-hand"},{"value":"🤌","match":"pinched-fingers"},{"value":"🤏","match":"pinch"},{"value":"👌","match":"ok"},{"value":"🫵","match":"pointing"},{"value":"👉","match":"point-right"},{"value":"👈","match":"point-left"},{"value":"☝️","match":"index-finger"},{"value":"👆","match":"point-up"},{"value":"👇","match":"point-down"},{"value":"🖕","match":"middle-finger"},{"value":"✍️","match":"writing-hand"},{"value":"🤳","match":"selfie"},{"value":"🙏","match":"folded-hands"},{"value":"🙏","match":"please"},{"value":"🙏","match":"pray"},{"value":"🙏","match":"hope"},{"value":"🙏","match":"wish"},{"value":"🙏","match":"thank-you"},{"value":"🙏","match":"high-five"},{"value":"💅","match":"nail-care"},{"value":"🤝","match":"handshake"},{"value":"🙇","match":"bow"},{"value":"🙋","match":"raising-hand"},{"value":"💁","match":"tipping-hand"},{"value":"🙆","match":"gesture-ok"},{"value":"🙅","match":"no-gesture"},{"value":"🙅","match":"no-good"},{"value":"🙅","match":"denied"},{"value":"🙅","match":"halt"},{"value":"🤷","match":"shrug"},{"value":"🤦","match":"facepalm"},{"value":"🙍","match":"frowning"},{"value":"🙎","match":"pouting"},{"value":"🧏","match":"deaf"},{"value":"💆","match":"massage"},{"value":"💇","match":"haircut"},{"value":"🧖","match":"sauna"},{"value":"🧖","match":"steamy"},{"value":"🛀","match":"bathe"},{"value":"🛌","match":"in-bed"},{"value":"🧘","match":"yoga"},{"value":"🧘","match":"meditation"},{"value":"🧘","match":"lotus-position"},{"value":"🧍","match":"standing"},{"value":"🤸","match":"cartwheel"},{"value":"🧎","match":"kneeling"},{"value":"🧑🦼","match":"person-in-motorized-wheelchair"},{"value":"🧑🦽","match":"person-in-manual-wheelchair"},{"value":"🧑🦯","match":"walking-with-cane"},{"value":"🧑🦯","match":"blind"},{"value":"🚶","match":"walking"},{"value":"🏃","match":"running"},{"value":"⛹️","match":"bouncing-ball"},{"value":"🤾","match":"handball"},{"value":"🚴","match":"biking"},{"value":"🚵","match":"mountain-biking"},{"value":"🧗","match":"climbing"},{"value":"🏋️","match":"lifting-weights"},{"value":"🤼","match":"wrestling"},{"value":"🤼♂️","match":"wrestling-men"},{"value":"🤼♀️","match":"wrestling-women"},{"value":"🤹","match":"juggling"},{"value":"🏌️","match":"golfing"},{"value":"🏇","match":"horse-racing"},{"value":"🤺","match":"fencing"},{"value":"⛷️","match":"skier"},{"value":"🏂","match":"snowboarder"},{"value":"🪂","match":"parachute"},{"value":"🏄","match":"surfing"},{"value":"🚣","match":"rowing-boat"},{"value":"🏊","match":"swimming"},{"value":"🤽","match":"water-polo"},{"value":"🧜","match":"merperson"},{"value":"🧚","match":"fairy"},{"value":"🧞","match":"genie"},{"value":"🧝","match":"elf"},{"value":"🧙","match":"mage"},{"value":"🧛","match":"vampire"},{"value":"🧟","match":"zombie"},{"value":"🧌","match":"troll"},{"value":"","match":"hairy-creature"},{"value":"🦸","match":"superhero"},{"value":"🦹","match":"supervillain"},{"value":"🧑🎄","match":"mx-claus"},{"value":"🥷","match":"ninja"},{"value":"💂","match":"guard"},{"value":"🫅","match":"royalty"},{"value":"🤵","match":"tuxedo"},{"value":"👰","match":"veil"},{"value":"🧑🚀","match":"astronaut"},{"value":"👷","match":"construction-worker"},{"value":"👮","match":"police"},{"value":"🕵️","match":"detective"},{"value":"🧑✈️","match":"pilot"},{"value":"🧑🔬","match":"scientist"},{"value":"🧑⚕️","match":"health-worker"},{"value":"🧑⚕️","match":"doctor"},{"value":"🧑⚕️","match":"nurse"},{"value":"🧑🔧","match":"mechanic"},{"value":"🧑🏭","match":"factory-worker"},{"value":"🧑🚒","match":"firefighter"},{"value":"🧑🌾","match":"farmer"},{"value":"🧑🏫","match":"teacher"},{"value":"🧑🎓","match":"student"},{"value":"🧑💼","match":"office-worker"},{"value":"🧑💼","match":"business-person"},{"value":"🧑⚖️","match":"judge"},{"value":"🧑💻","match":"technologist"},{"value":"🧑💻","match":"person-at-computer"},{"value":"🧑🎤","match":"singer"},{"value":"🧑🎨","match":"artist"},{"value":"🧑🍳","match":"cook"},{"value":"👳","match":"turban"},{"value":"🧕","match":"headscarf"},{"value":"👲","match":"gua-pi-mao"},{"value":"👼","match":"angel"},{"value":"👶","match":"baby"},{"value":"🧒","match":"child"},{"value":"🧑","match":"adult"},{"value":"🧓","match":"elder"},{"value":"🧑🦳","match":"white-hair"},{"value":"🧑🦰","match":"red-hair"},{"value":"👱","match":"blond-hair"},{"value":"🧑🦱","match":"curly-hair"},{"value":"🧑🦲","match":"bald"},{"value":"🧔","match":"beard"},{"value":"🕴️","match":"levitating-suit"},{"value":"🧑🩰","match":"dancer-person"},{"value":"🧑🩰","match":"ballet-dancer"},{"value":"💃","match":"dancer-woman ♪┏(・o・)┛♪"},{"value":"🕺","match":"dancer-man ♪┗(・o・)┓♪"},{"value":"👯","match":"bunny-ears"},{"value":"👯♂️","match":"bunny-ears-men"},{"value":"👯♀️","match":"bunny-ears-women"},{"value":"🧑🤝🧑","match":"holding-hands"},{"value":"👭","match":"holding-hands-women"},{"value":"👬","match":"holding-hands-men"},{"value":"👫","match":"holding-hands-woman-and-man"},{"value":"💏","match":"kiss-people (-}{-)"},{"value":"👩❤️💋👨","match":"kiss-woman-and-man"},{"value":"👨❤️💋👨","match":"kiss-man-and-man"},{"value":"👩❤️💋👩","match":"kiss-woman-and-woman"},{"value":"💑","match":"people-with-heart"},{"value":"👩❤️👨","match":"heart-with-woman-and-man"},{"value":"👨❤️👨","match":"heart-with-man-and-man"},{"value":"👩❤️👩","match":"heart-with-woman-and-woman"},{"value":"🫄","match":"pregnant"},{"value":"🤱","match":"breast-feeding"},{"value":"🧑🍼","match":"person-feeding-baby"},{"value":"💐","match":"bouquet"},{"value":"💐","match":"flowers"},{"value":"🌹","match":"rose @-,-'-,-"},{"value":"🥀","match":"wilted-flower"},{"value":"🌺","match":"hibiscus"},{"value":"🌷","match":"tulip"},{"value":"🪷","match":"lotus"},{"value":"🌸","match":"cherry-blossom"},{"value":"💮","match":"white-flower"},{"value":"🏵️","match":"rosette"},{"value":"🪻","match":"hyacinth"},{"value":"🌻","match":"sunflower"},{"value":"🌼","match":"blossom"},{"value":"🍂","match":"fallen-leaf"},{"value":"🍁","match":"maple-leaf"},{"value":"🍄","match":"mushroom"},{"value":"🌾","match":"ear-of-rice"},{"value":"🌿","match":"herb"},{"value":"🌱","match":"plant"},{"value":"🌱","match":"seed"},{"value":"🍃","match":"leaves"},{"value":"☘️","match":"shamrock"},{"value":"🍀","match":"luck"},{"value":"🍀","match":"four-leaf-clover"},{"value":"🪴","match":"potted-plant"},{"value":"🌵","match":"cactus"},{"value":"🌴","match":"palm-tree"},{"value":"","match":"leafless-tree"},{"value":"🌳","match":"deciduous-tree"},{"value":"🌲","match":"evergreen-tree"},{"value":"🪵","match":"wood"},{"value":"🪹","match":"nest"},{"value":"🪺","match":"nest-with-eggs"},{"value":"🪨","match":"rock"},{"value":"","match":"debris"},{"value":"","match":"landslide"},{"value":"⛰️","match":"mountain"},{"value":"🏔️","match":"snow-mountain"},{"value":"☃️","match":"snowman-with-snow"},{"value":"⛄","match":"snowman"},{"value":"🌡️","match":"thermometer"},{"value":"🔥","match":"fire"},{"value":"🔥","match":"burn"},{"value":"🔥","match":"lit"},{"value":"🌋","match":"volcano"},{"value":"🏜️","match":"desert"},{"value":"🏞️","match":"national-park"},{"value":"🌅","match":"sunrise"},{"value":"🌄","match":"sunrise-over-mountains"},{"value":"🏝️","match":"desert-island"},{"value":"🏖️","match":"beach"},{"value":"🌊","match":"ocean"},{"value":"🌬️","match":"wind-face"},{"value":"❄️","match":"snowflake"},{"value":"❄️","match":"winter"},{"value":"❄️","match":"cold"},{"value":"🌀","match":"cyclone"},{"value":"🌪️","match":"tornado"},{"value":"⚡","match":"electricity"},{"value":"⚡","match":"zap"},{"value":"⚡","match":"lightning"},{"value":"☔","match":"umbrella-in-rain"},{"value":"🌈","match":"rainbow"},{"value":"💧","match":"droplet"},{"value":"☁️","match":"cloud"},{"value":"🌨️","match":"cloud-with-snow"},{"value":"🌧️","match":"rain-cloud"},{"value":"🌩️","match":"cloud-with-lightning"},{"value":"⛈️","match":"cloud-with-lightning-and-rain"},{"value":"🌦️","match":"sun-behind-rain-cloud"},{"value":"🌥️","match":"sun-behind-large-cloud"},{"value":"⛅","match":"partly-sunny"},{"value":"🌤️","match":"sun-behind-small-cloud"},{"value":"☀️","match":"sunny"},{"value":"🌞","match":"sun-with-face"},{"value":"🌝","match":"moon-face-full <_<"},{"value":"🌚","match":"moon-face-new >_>"},{"value":"🌜","match":"moon-face-last-quarter"},{"value":"🌛","match":"moon-face-first-quarter"},{"value":"🌙","match":"crescent-moon"},{"value":"⭐","match":"star"},{"value":"🌟","match":"glowing-star"},{"value":"✨","match":"sparkles"},{"value":"🕳️","match":"hole"},{"value":"🪐","match":"ringed-planet"},{"value":"🌍","match":"globe-showing-europe-africa"},{"value":"🌎","match":"globe-showing-americas"},{"value":"🌏","match":"globe-showing-asia-australia"},{"value":"🌫️","match":"fog"},{"value":"🌠","match":"shooting-star"},{"value":"🌌","match":"milky-way"},{"value":"☄️","match":"comet"},{"value":"🌑","match":"new-moon"},{"value":"🌒","match":"waxing-crescent-moon"},{"value":"🌓","match":"first-quarter-moon"},{"value":"🌔","match":"waxing-gibbous-moon"},{"value":"🌕","match":"full-moon"},{"value":"🌖","match":"waning-gibbous-moon"},{"value":"🌗","match":"last-quarter-moon"},{"value":"🌘","match":"waning-crescent-moon"},{"value":"🙈","match":"see-no-evil-monkey"},{"value":"🙉","match":"hear-no-evil-monkey"},{"value":"🙊","match":"speak-no-evil-monkey"},{"value":"🐵","match":"monkey-face"},{"value":"🦁","match":"lion-face"},{"value":"🐯","match":"tiger-face"},{"value":"🐱","match":"cat-face =^.^="},{"value":"🐶","match":"dog-face ▼・ᴥ・▼"},{"value":"🐺","match":"wolf"},{"value":"🐻","match":"bear-face ʕ·ᴥ·ʔ"},{"value":"🐻❄️","match":"polar-bear"},{"value":"🐨","match":"koala"},{"value":"🐼","match":"panda"},{"value":"🐹","match":"hamster"},{"value":"🐭","match":"mouse-face"},{"value":"🐰","match":"rabbit-face"},{"value":"🦊","match":"fox-face"},{"value":"🐮","match":"cow-face 3:O"},{"value":"🐷","match":"pig-face"},{"value":"🐽","match":"snout"},{"value":"🐗","match":"boar"},{"value":"🦄","match":"unicorn"},{"value":"🐴","match":"horse-face"},{"value":"🫎","match":"moose"},{"value":"🐲","match":"dragon-face"},{"value":"🦎","match":"lizard"},{"value":"🐉","match":"dragon"},{"value":"🦖","match":"t-rex"},{"value":"🦕","match":"dinosaur"},{"value":"🐢","match":"turtle"},{"value":"🐊","match":"crocodile"},{"value":"🐍","match":"snake ~>゜)~~~~"},{"value":"🐸","match":"frog"},{"value":"🐇","match":"rabbit"},{"value":"🐁","match":"mouse <:3)~"},{"value":"🐀","match":"rat"},{"value":"🐈","match":"cat"},{"value":"🐈⬛","match":"black-cat"},{"value":"🐩","match":"poodle"},{"value":"🐕","match":"dog"},{"value":"🦮","match":"guide-dog"},{"value":"🐕🦺","match":"service-dog"},{"value":"🐖","match":"pig"},{"value":"🐎","match":"racehorse"},{"value":"🫏","match":"donkey"},{"value":"🐄","match":"cow"},{"value":"🐂","match":"ox"},{"value":"🐃","match":"water-buffalo"},{"value":"🦬","match":"bison"},{"value":"🐏","match":"ram"},{"value":"🐑","match":"sheep"},{"value":"🐑","match":"ewe"},{"value":"🐐","match":"goat"},{"value":"🦌","match":"deer"},{"value":"🦙","match":"llama"},{"value":"🦥","match":"sloth"},{"value":"🦘","match":"kangaroo"},{"value":"🦓","match":"zebra"},{"value":"🐘","match":"elephant"},{"value":"🦣","match":"mammoth"},{"value":"🦏","match":"rhino"},{"value":"🦏","match":"rhinoceros"},{"value":"🦛","match":"hippo"},{"value":"🦒","match":"giraffe"},{"value":"🐆","match":"leopard"},{"value":"🐅","match":"tiger"},{"value":"🐒","match":"monkey"},{"value":"🦍","match":"gorilla"},{"value":"🦧","match":"orangutan"},{"value":"🐪","match":"camel"},{"value":"🐫","match":"bactrian-camel"},{"value":"🐿️","match":"chipmunk"},{"value":"🦫","match":"beaver"},{"value":"🦝","match":"raccoon"},{"value":"🦨","match":"skunk"},{"value":"🦡","match":"badger"},{"value":"🦔","match":"hedgehog"},{"value":"🦦","match":"otter (:3ꇤ⁐ꃳ"},{"value":"🦇","match":"bat ⎛⎝(•ⱅ•)⎠⎞"},{"value":"🪽","match":"wing"},{"value":"🪶","match":"feather"},{"value":"🐦","match":"bird"},{"value":"🐦⬛","match":"black-bird"},{"value":"🐓","match":"rooster"},{"value":"🐔","match":"chicken"},{"value":"🐣","match":"hatching-chick"},{"value":"🐤","match":"baby-chick"},{"value":"🐥","match":"hatched-chick"},{"value":"🦅","match":"eagle"},{"value":"🦉","match":"owl"},{"value":"🦜","match":"parrot"},{"value":"🕊️","match":"peace"},{"value":"🕊️","match":"dove"},{"value":"🦤","match":"dodo"},{"value":"🦢","match":"swan"},{"value":"🦆","match":"duck"},{"value":"🪿","match":"goose"},{"value":"🦩","match":"flamingo"},{"value":"🦚","match":"peacock"},{"value":"🐦🔥","match":"phoenix"},{"value":"🦃","match":"turkey"},{"value":"🐧","match":"penguin <(\")"},{"value":"🦭","match":"seal"},{"value":"🦈","match":"shark"},{"value":"","match":"orca"},{"value":"🐬","match":"dolphin"},{"value":"🐋","match":"humpback-whale"},{"value":"🐳","match":"whale"},{"value":"🐟","match":"fish <><"},{"value":"🐠","match":"tropical-fish"},{"value":"🐡","match":"blowfish"},{"value":"🦐","match":"shrimp"},{"value":"🦞","match":"lobster"},{"value":"🦀","match":"crab"},{"value":"🦑","match":"squid くコ:彡"},{"value":"🐙","match":"octopus"},{"value":"🪼","match":"jellyfish"},{"value":"🦪","match":"oyster"},{"value":"🪸","match":"coral"},{"value":"🫧","match":"bubbles"},{"value":"🦂","match":"scorpion"},{"value":"🕷️","match":"spider"},{"value":"🕸️","match":"spider-web"},{"value":"🐚","match":"shell"},{"value":"🐌","match":"snail"},{"value":"🐜","match":"ant"},{"value":"🦗","match":"cricket"},{"value":"🪲","match":"beetle"},{"value":"🦟","match":"mosquito"},{"value":"🪳","match":"cockroach"},{"value":"🪰","match":"fly"},{"value":"🐝","match":"bee"},{"value":"🐞","match":"lady-bug"},{"value":"🦋","match":"butterfly εїз"},{"value":"🐛","match":"bug"},{"value":"🪱","match":"worm"},{"value":"🦠","match":"microbe"},{"value":"🐾","match":"paw-prints"},{"value":"🍓","match":"strawberry"},{"value":"🍒","match":"cherries"},{"value":"🍎","match":"red-apple"},{"value":"🍅","match":"tomato"},{"value":"🌶️","match":"hot-pepper"},{"value":"🍉","match":"watermelon"},{"value":"🍑","match":"peach"},{"value":"🍊","match":"tangerine"},{"value":"🍊","match":"orange"},{"value":"🍊","match":"mandarin"},{"value":"🥕","match":"carrot"},{"value":"🥭","match":"mango"},{"value":"🍍","match":"pineapple"},{"value":"🍌","match":"banana"},{"value":"🌽","match":"ear-of-corn"},{"value":"🍋","match":"lemon"},{"value":"🍋🟩","match":"lime"},{"value":"🍈","match":"melon"},{"value":"🍐","match":"pear"},{"value":"🫛","match":"pea-pod"},{"value":"🥬","match":"leafy-green"},{"value":"🫑","match":"bell-pepper"},{"value":"🍏","match":"green-apple"},{"value":"🥝","match":"kiwi-fruit"},{"value":"🥑","match":"avocado"},{"value":"🫒","match":"olive"},{"value":"🥦","match":"broccoli"},{"value":"🥒","match":"cucumber"},{"value":"🫐","match":"blueberries"},{"value":"🍇","match":"grapes"},{"value":"🍆","match":"eggplant"},{"value":"🍠","match":"roasted-sweet-potato"},{"value":"","match":"root-vegetable"},{"value":"","match":"beet"},{"value":"","match":"turnip"},{"value":"🥥","match":"coconut"},{"value":"🥔","match":"potato"},{"value":"🍄🟫","match":"brown-mushroom"},{"value":"🧅","match":"onion"},{"value":"🫚","match":"ginger"},{"value":"🧄","match":"garlic"},{"value":"🫘","match":"beans"},{"value":"🌰","match":"chestnut"},{"value":"🥜","match":"peanuts"},{"value":"🍞","match":"bread"},{"value":"🫓","match":"flatbread"},{"value":"🥐","match":"croissant"},{"value":"🥖","match":"baguette-bread"},{"value":"🥯","match":"bagel"},{"value":"🧇","match":"waffle"},{"value":"🥞","match":"pancakes"},{"value":"🍳","match":"cooking"},{"value":"🥚","match":"egg"},{"value":"🧀","match":"cheese-wedge"},{"value":"🥓","match":"bacon"},{"value":"🥩","match":"cut-of-meat"},{"value":"🍗","match":"poultry-leg"},{"value":"🍖","match":"meat-on-bone"},{"value":"🍔","match":"hamburger"},{"value":"🌭","match":"hot-dog"},{"value":"🥪","match":"sandwich"},{"value":"🥨","match":"pretzel"},{"value":"🍟","match":"french-fries"},{"value":"🍕","match":"pizza"},{"value":"🫔","match":"tamale"},{"value":"🌮","match":"taco"},{"value":"🌯","match":"burrito"},{"value":"🥙","match":"stuffed-flatbread"},{"value":"🧆","match":"falafel"},{"value":"🥘","match":"shallow-pan-of-food"},{"value":"🍝","match":"spaghetti"},{"value":"🥫","match":"canned-food"},{"value":"🫕","match":"fondue"},{"value":"🥣","match":"bowl-with-spoon"},{"value":"🥗","match":"green-salad"},{"value":"🍲","match":"pot-of-food"},{"value":"🍛","match":"curry-rice"},{"value":"🍜","match":"steaming-bowl"},{"value":"🦪","match":"oyster"},{"value":"🦞","match":"lobster"},{"value":"🍣","match":"sushi"},{"value":"🍤","match":"fried-shrimp"},{"value":"🥡","match":"takeout-box"},{"value":"🍚","match":"cooked-rice"},{"value":"🍱","match":"bento-box"},{"value":"🥟","match":"dumpling"},{"value":"🍢","match":"oden"},{"value":"🍙","match":"rice-ball"},{"value":"🍘","match":"rice-cracker"},{"value":"🍥","match":"fish-cake-with-swirl"},{"value":"🍡","match":"dango"},{"value":"🥠","match":"fortune-cookie"},{"value":"🥮","match":"moon-cake"},{"value":"🍧","match":"shaved-ice"},{"value":"🍨","match":"ice-cream"},{"value":"🍦","match":"soft-ice-cream"},{"value":"🥧","match":"pie"},{"value":"🍰","match":"shortcake"},{"value":"🍮","match":"custard"},{"value":"🎂","match":"birthday-cake"},{"value":"🧁","match":"cupcake"},{"value":"🍭","match":"lollipop"},{"value":"🍬","match":"candy"},{"value":"🍫","match":"chocolate-bar"},{"value":"🍩","match":"doughnut"},{"value":"🍪","match":"cookie"},{"value":"🍯","match":"honey-pot"},{"value":"🧂","match":"salt"},{"value":"🧈","match":"butter"},{"value":"🍿","match":"popcorn"},{"value":"🧊","match":"ice-cube"},{"value":"🫙","match":"jar"},{"value":"🥤","match":"cup-with-straw"},{"value":"🧋","match":"bubble-tea"},{"value":"🧋","match":"milk-tea"},{"value":"🧃","match":"beverage-box"},{"value":"🥛","match":"glass-of-milk"},{"value":"🍼","match":"baby-bottle"},{"value":"🍵","match":"teacup-without-handle"},{"value":"☕","match":"hot-beverage"},{"value":"🫖","match":"teapot"},{"value":"🧉","match":"mate"},{"value":"🍺","match":"beer-mug"},{"value":"🍻","match":"clinking-beer-mugs"},{"value":"🥂","match":"clinking-glasses"},{"value":"🍾","match":"bottle-with-popping-cork"},{"value":"🍷","match":"wine-glass"},{"value":"🥃","match":"tumbler-glass"},{"value":"🫗","match":"pour"},{"value":"🍸","match":"cocktail-glass"},{"value":"🍹","match":"tropical-drink"},{"value":"🍶","match":"sake"},{"value":"🥢","match":"chopsticks"},{"value":"🍴","match":"fork-and-knife"},{"value":"🥄","match":"spoon"},{"value":"🔪","match":"kitchen-knife"},{"value":"🍽️","match":"fork-and-knife-with-plate"},{"value":"🛑","match":"stop-sign"},{"value":"🚧","match":"construction"},{"value":"🚨","match":"police-car-light"},{"value":"⛽","match":"fuel-pump"},{"value":"🛢️","match":"oil-drum"},{"value":"🧭","match":"compass"},{"value":"🛞","match":"wheel"},{"value":"🛟","match":"ring-buoy"},{"value":"⚓","match":"anchor"},{"value":"🚏","match":"bus-stop"},{"value":"🚇","match":"metro"},{"value":"🚥","match":"horizontal-traffic-light"},{"value":"🚦","match":"vertical-traffic-light"},{"value":"🛴","match":"kick-scooter"},{"value":"🦽","match":"manual-wheelchair"},{"value":"🦼","match":"motorized-wheelchair"},{"value":"🩼","match":"crutch"},{"value":"🚲","match":"bicycle"},{"value":"🛵","match":"motor-scooter"},{"value":"🏍️","match":"motorcycle"},{"value":"🚙","match":"sport-utility-vehicle"},{"value":"🚗","match":"automobile"},{"value":"🛻","match":"pickup-truck"},{"value":"🚐","match":"minibus"},{"value":"🚚","match":"delivery-truck"},{"value":"🚛","match":"articulated-lorry"},{"value":"🚜","match":"tractor"},{"value":"🏎️","match":"racing-car"},{"value":"🚒","match":"fire-engine"},{"value":"🚑","match":"ambulance"},{"value":"🚓","match":"police-car"},{"value":"🚕","match":"taxi"},{"value":"🛺","match":"auto-rickshaw"},{"value":"🚌","match":"bus"},{"value":"🚈","match":"light-rail"},{"value":"🚝","match":"monorail"},{"value":"🚅","match":"bullet-train"},{"value":"🚄","match":"high-speed-train"},{"value":"🚂","match":"locomotive"},{"value":"🚃","match":"railway-car"},{"value":"🚋","match":"tram-car"},{"value":"🚎","match":"trolleybus"},{"value":"🚞","match":"mountain-railway"},{"value":"🚊","match":"tram"},{"value":"🚉","match":"station"},{"value":"🚍","match":"bus-front"},{"value":"🚔","match":"police-car-front"},{"value":"🚘","match":"automobile-front"},{"value":"🚖","match":"taxi-front"},{"value":"🚆","match":"train"},{"value":"🚢","match":"ship"},{"value":"🛳️","match":"passenger-ship"},{"value":"🛥️","match":"motor-boat"},{"value":"🚤","match":"speedboat"},{"value":"⛴️","match":"ferry"},{"value":"⛵","match":"sailboat"},{"value":"🛶","match":"canoe"},{"value":"🚟","match":"suspension-railway"},{"value":"🚠","match":"mountain-cableway"},{"value":"🚡","match":"aerial-tramway"},{"value":"🚁","match":"helicopter"},{"value":"🛸","match":"flying-saucer"},{"value":"🚀","match":"rocket"},{"value":"✈️","match":"airplane"},{"value":"🛫","match":"airplane-departure"},{"value":"🛬","match":"airplane-arrival"},{"value":"🛩️","match":"small-airplane"},{"value":"🛝","match":"slide"},{"value":"🛝","match":"playground"},{"value":"🎢","match":"roller-coaster"},{"value":"🎡","match":"ferris-wheel"},{"value":"🎠","match":"carousel-horse"},{"value":"🎪","match":"circus-tent"},{"value":"🗼","match":"tokyo-tower"},{"value":"🗽","match":"statue-of-liberty"},{"value":"🗿","match":"moai"},{"value":"🗻","match":"mount-fuji"},{"value":"🏛️","match":"classical-building"},{"value":"💈","match":"barber-pole"},{"value":"⛲","match":"fountain"},{"value":"⛩️","match":"shinto-shrine"},{"value":"🕍","match":"synagogue"},{"value":"🕌","match":"mosque"},{"value":"🕋","match":"kaaba"},{"value":"🛕","match":"hindu-temple"},{"value":"⛪","match":"church"},{"value":"💒","match":"wedding"},{"value":"🏩","match":"love-hotel"},{"value":"🏯","match":"japanese-castle"},{"value":"🏰","match":"castle"},{"value":"🏗️","match":"construction-building"},{"value":"🏢","match":"office-building"},{"value":"🏭","match":"factory"},{"value":"🏬","match":"department-store"},{"value":"🏪","match":"convenience-store"},{"value":"🏟️","match":"stadium"},{"value":"🏦","match":"bank"},{"value":"🏫","match":"school"},{"value":"🏨","match":"hotel"},{"value":"🏣","match":"japanese-post-office"},{"value":"🏤","match":"post-office"},{"value":"🏥","match":"hospital"},{"value":"🏚️","match":"derelict-house"},{"value":"🏠","match":"house"},{"value":"🏡","match":"house-with-garden"},{"value":"🏘️","match":"houses"},{"value":"🛖","match":"hut"},{"value":"⛺","match":"tent"},{"value":"🏕️","match":"camping"},{"value":"⛱️","match":"umbrella-on-ground"},{"value":"🏙️","match":"cityscape"},{"value":"🌆","match":"sunset-cityscape"},{"value":"🌇","match":"sunset"},{"value":"🌃","match":"night-with-stars"},{"value":"🌉","match":"bridge-at-night"},{"value":"🌁","match":"foggy"},{"value":"🛤️","match":"railway-track"},{"value":"🛣️","match":"motorway"},{"value":"🗾","match":"map-of-japan"},{"value":"🗺️","match":"world-map"},{"value":"🌐","match":"globe-with-meridians"},{"value":"💺","match":"seat"},{"value":"🧳","match":"luggage"},{"value":"🎉","match":"party-popper"},{"value":"🎊","match":"confetti-ball"},{"value":"🎈","match":"balloon"},{"value":"🎂","match":"birthday-cake"},{"value":"🎀","match":"ribbon"},{"value":"🎁","match":"wrapped-gift"},{"value":"🎇","match":"sparkler"},{"value":"🎆","match":"fireworks"},{"value":"🧨","match":"firecracker"},{"value":"🧧","match":"red-envelope"},{"value":"🪔","match":"diya-lamp"},{"value":"🪅","match":"piñata"},{"value":"🪩","match":"mirror-ball"},{"value":"🪩","match":"disco-ball"},{"value":"🎐","match":"wind-chime"},{"value":"🎏","match":"carp-streamer"},{"value":"🎎","match":"japanese-dolls"},{"value":"🎑","match":"moon-viewing-ceremony"},{"value":"🎍","match":"pine-decoration"},{"value":"🎋","match":"tanabata-tree"},{"value":"🎄","match":"christmas-tree"},{"value":"🎃","match":"jack-o-lantern"},{"value":"🎗️","match":"reminder-ribbon"},{"value":"🥇","match":"gold-medal"},{"value":"🥇","match":"1st-place-medal"},{"value":"🥈","match":"silver-medal"},{"value":"🥈","match":"2nd-place-medal"},{"value":"🥉","match":"bronze-medal"},{"value":"🥉","match":"3rd-place-medal"},{"value":"🏅","match":"medal"},{"value":"🎖️","match":"military-medal"},{"value":"🏆","match":"trophy"},{"value":"📢","match":"loudspeaker"},{"value":"⚽","match":"soccer-ball"},{"value":"⚾","match":"baseball"},{"value":"🥎","match":"softball"},{"value":"🏀","match":"basketball"},{"value":"🏐","match":"volleyball"},{"value":"🏈","match":"american-football"},{"value":"🏉","match":"rugby-football"},{"value":"🥅","match":"goal-net"},{"value":"🎾","match":"tennis"},{"value":"🏸","match":"badminton"},{"value":"🥍","match":"lacrosse"},{"value":"🏏","match":"cricket-game"},{"value":"🏑","match":"field-hockey"},{"value":"🏒","match":"ice-hockey"},{"value":"🥌","match":"curling-stone"},{"value":"🛷","match":"sled"},{"value":"🎿","match":"skis"},{"value":"⛸️","match":"ice-skate"},{"value":"🛼","match":"roller-skates"},{"value":"🩰","match":"ballet-shoes"},{"value":"🛹","match":"skateboard"},{"value":"⛳","match":"flag-in-hole"},{"value":"🎯","match":"direct-hit"},{"value":"🎯","match":"target"},{"value":"🏹","match":"bow-and-arrow"},{"value":"🥏","match":"flying-disc"},{"value":"🪃","match":"boomerang"},{"value":"🪁","match":"kite"},{"value":"🎣","match":"fishing-pole"},{"value":"🤿","match":"diving-mask"},{"value":"🩱","match":"one-piece-swimsuit"},{"value":"🎽","match":"running-shirt"},{"value":"🥋","match":"martial-arts-uniform"},{"value":"🥊","match":"boxing-glove"},{"value":"🎱","match":"8-ball"},{"value":"🏓","match":"ping-pong"},{"value":"🎳","match":"bowling"},{"value":"♟️","match":"chess-pawn"},{"value":"🪀","match":"yo-yo"},{"value":"🧩","match":"jigsaw"},{"value":"🎮","match":"video-game"},{"value":"🕹️","match":"joystick"},{"value":"👾","match":"alien-monster"},{"value":"🔫","match":"pistol"},{"value":"🎲","match":"die"},{"value":"🎰","match":"slot-machine"},{"value":"🎴","match":"flower-playing-cards"},{"value":"🀄","match":"mahjong-red-dragon"},{"value":"🃏","match":"joker"},{"value":"🪄","match":"wand"},{"value":"🎩","match":"game-die"},{"value":"📷","match":"camera"},{"value":"📸","match":"camera-flash"},{"value":"🖼️","match":"framed-picture"},{"value":"🎨","match":"artist-palette"},{"value":"","match":"splatter"},{"value":"🖌️","match":"paintbrush"},{"value":"🖍️","match":"crayon"},{"value":"🪡","match":"needle"},{"value":"🧵","match":"thread"},{"value":"🧶","match":"yarn"},{"value":"🎹","match":"piano"},{"value":"🎹","match":"musical-keyboard"},{"value":"🎷","match":"saxophone"},{"value":"🎺","match":"trumpet"},{"value":"","match":"trombone"},{"value":"🎸","match":"guitar"},{"value":"🪕","match":"banjo"},{"value":"🎻","match":"violin"},{"value":"","match":"harp"},{"value":"🪘","match":"long-drum"},{"value":"🥁","match":"drum"},{"value":"🪇","match":"maracas"},{"value":"🪈","match":"flute"},{"value":"🪗","match":"accordion"},{"value":"🎤","match":"microphone"},{"value":"🎧","match":"headphone"},{"value":"🎚️","match":"level-slider"},{"value":"🎛️","match":"control-knobs"},{"value":"🎙️","match":"studio-microphone"},{"value":"📼","match":"videocassette"},{"value":"📻","match":"radio"},{"value":"📺","match":"television"},{"value":"📹","match":"video-camera"},{"value":"📽️","match":"film-projector"},{"value":"🎥","match":"movie-camera"},{"value":"🎞️","match":"film"},{"value":"🎬","match":"clapper"},{"value":"🎭","match":"performing-arts"},{"value":"🎫","match":"ticket"},{"value":"🎟️","match":"admission-tickets"},{"value":"📱","match":"mobile-phone"},{"value":"☎️","match":"telephone"},{"value":"📞","match":"telephone-receiver"},{"value":"📟","match":"pager"},{"value":"📠","match":"fax-machine"},{"value":"🔌","match":"electric-plug"},{"value":"🔋","match":"battery-full"},{"value":"🪫","match":"battery-low"},{"value":"🖲️","match":"trackball"},{"value":"💽","match":"computer-disk"},{"value":"💾","match":"floppy-disk"},{"value":"💿","match":"optical-disk"},{"value":"📀","match":"dvd"},{"value":"🖥️","match":"desktop-computer"},{"value":"💻","match":"laptop-computer"},{"value":"⌨️","match":"keyboard"},{"value":"🖨️","match":"printer"},{"value":"🖱️","match":"computer-mouse"},{"value":"","match":"treasure"},{"value":"🪙","match":"coin"},{"value":"💎","match":"gem-stone"},{"value":"💸","match":"money-with-wings"},{"value":"💵","match":"dollar"},{"value":"💴","match":"yen"},{"value":"💶","match":"euro"},{"value":"💷","match":"pound"},{"value":"💳","match":"credit-card"},{"value":"💰","match":"money-bag"},{"value":"🧾","match":"receipt"},{"value":"🧮","match":"abacus"},{"value":"⚖️","match":"balance-scale"},{"value":"🛒","match":"shopping-cart"},{"value":"🛍️","match":"shopping-bags"},{"value":"🕯️","match":"candle"},{"value":"💡","match":"light-bulb"},{"value":"🔦","match":"flashlight"},{"value":"🏮","match":"red-paper-lantern"},{"value":"🧱","match":"bricks"},{"value":"🪟","match":"window"},{"value":"🪞","match":"mirror"},{"value":"🚪","match":"door"},{"value":"🪑","match":"chair"},{"value":"🛏️","match":"bed"},{"value":"🛋️","match":"couch-and-lamp"},{"value":"🚿","match":"shower"},{"value":"🛁","match":"bathtub"},{"value":"🚽","match":"toilet"},{"value":"🧻","match":"roll-of-paper"},{"value":"🪠","match":"plunger"},{"value":"🧸","match":"teddy-bear"},{"value":"🪆","match":"nesting-doll"},{"value":"🧷","match":"safety-pin"},{"value":"🪢","match":"knot"},{"value":"🧹","match":"broom"},{"value":"🧴","match":"lotion-bottle"},{"value":"🧽","match":"sponge"},{"value":"🧼","match":"soap"},{"value":"🪥","match":"toothbrush"},{"value":"🪒","match":"razor"},{"value":"🪮","match":"hair-pick"},{"value":"🧺","match":"basket"},{"value":"🧦","match":"socks"},{"value":"🧤","match":"gloves"},{"value":"🧣","match":"scarf"},{"value":"👖","match":"jeans"},{"value":"👕","match":"t-shirt"},{"value":"🎽","match":"running-shirt"},{"value":"👚","match":"woman’s-clothes"},{"value":"👔","match":"necktie"},{"value":"👗","match":"dress"},{"value":"👘","match":"kimono"},{"value":"🥻","match":"sari"},{"value":"🩱","match":"one-piece-swimsuit"},{"value":"👙","match":"bikini"},{"value":"🩳","match":"shorts"},{"value":"🩲","match":"swim-brief"},{"value":"🧥","match":"coat"},{"value":"🥼","match":"lab-coat"},{"value":"🦺","match":"safety-vest"},{"value":"⛑️","match":"rescue-worker’s-helmet"},{"value":"🪖","match":"military-helmet"},{"value":"🎓","match":"graduation-cap"},{"value":"🎩","match":"top-hat"},{"value":"👒","match":"woman’s-hat"},{"value":"🧢","match":"billed-cap"},{"value":"👑","match":"crown"},{"value":"💍","match":"ring"},{"value":"💄","match":"lipstick"},{"value":"🪭","match":"fan"},{"value":"🎒","match":"school-backpack"},{"value":"👝","match":"clutch-bag"},{"value":"👛","match":"purse"},{"value":"👜","match":"handbag"},{"value":"💼","match":"briefcase"},{"value":"🧳","match":"luggage"},{"value":"☂️","match":"umbrella"},{"value":"🌂","match":"closed-umbrella"},{"value":"🥾","match":"hiking-boot"},{"value":"👢","match":"boot"},{"value":"👠","match":"high-heeled-shoe"},{"value":"🩴","match":"flip-flop"},{"value":"🩴","match":"thong-sandal"},{"value":"👟","match":"running-shoe"},{"value":"👞","match":"man’s-shoe"},{"value":"🥿","match":"flat-shoe"},{"value":"👡","match":"sandal"},{"value":"🦯","match":"probing-cane"},{"value":"🕶️","match":"sunglasses"},{"value":"👓","match":"glasses"},{"value":"🥽","match":"goggles"},{"value":"⚗️","match":"alembic"},{"value":"🧫","match":"petri-dish"},{"value":"🧪","match":"test-tube"},{"value":"🌡️","match":"thermometer"},{"value":"💉","match":"syringe"},{"value":"💊","match":"pill"},{"value":"🩹","match":"adhesive-bandage"},{"value":"🩺","match":"stethoscope"},{"value":"🩻","match":"x-ray"},{"value":"🧬","match":"dna"},{"value":"🔭","match":"telescope"},{"value":"🔬","match":"microscope"},{"value":"📡","match":"satellite-antenna"},{"value":"🛰️","match":"satellite"},{"value":"🧯","match":"fire-extinguisher"},{"value":"🪓","match":"axe"},{"value":"🪜","match":"ladder"},{"value":"🪣","match":"bucket"},{"value":"🪝","match":"hook"},{"value":"🧲","match":"magnet"},{"value":"🧰","match":"toolbox"},{"value":"🗜️","match":"clamp"},{"value":"🔩","match":"nut-and-bolt"},{"value":"🪛","match":"screwdriver"},{"value":"🪚","match":"saw"},{"value":"🔧","match":"wrench"},{"value":"🔨","match":"hammer"},{"value":"🛠️","match":"hammer-and-wrench"},{"value":"⚒️","match":"hammer-and-pick"},{"value":"⛏️","match":"pick"},{"value":"","match":"shovel"},{"value":"","match":"dig"},{"value":"⚙️","match":"gear"},{"value":"⛓️💥","match":"broken-chain"},{"value":"🔗","match":"link"},{"value":"⛓️","match":"chains"},{"value":"📎","match":"paperclip"},{"value":"🖇️","match":"linked-paperclips"},{"value":"✂️","match":"scissors"},{"value":"📏","match":"straight-ruler"},{"value":"📐","match":"triangular-ruler"},{"value":"🖌️","match":"paintbrush"},{"value":"🖍️","match":"crayon"},{"value":"🖊️","match":"pen"},{"value":"🖋️","match":"fountain-pen"},{"value":"✒️","match":"black-nib"},{"value":"✏️","match":"pencil"},{"value":"📝","match":"memo"},{"value":"🗒️","match":"spiral-notepad"},{"value":"📄","match":"page-facing-up"},{"value":"📃","match":"page-with-curl"},{"value":"📑","match":"bookmark-tabs"},{"value":"📋","match":"clipboard"},{"value":"🗃️","match":"card-file-box"},{"value":"🗄️","match":"file-cabinet"},{"value":"📒","match":"ledger"},{"value":"📔","match":"notebook-with-decorative-cover"},{"value":"📕","match":"closed-book"},{"value":"📓","match":"notebook"},{"value":"📗","match":"green-book"},{"value":"📘","match":"blue-book"},{"value":"📙","match":"orange-book"},{"value":"📚","match":"books"},{"value":"📖","match":"open-book"},{"value":"🔖","match":"bookmark"},{"value":"📂","match":"open-file-folder"},{"value":"📁","match":"file-folder"},{"value":"🗂️","match":"card-index-dividers"},{"value":"📊","match":"bar-chart"},{"value":"📈","match":"chart-increasing"},{"value":"📉","match":"chart-decreasing"},{"value":"📇","match":"card-index"},{"value":"🪪","match":"id"},{"value":"📌","match":"pushpin"},{"value":"📍","match":"round-pushpin"},{"value":"🗑️","match":"wastebasket"},{"value":"📰","match":"newspaper"},{"value":"🗞️","match":"rolled-up-newspaper"},{"value":"🏷️","match":"label"},{"value":"📤","match":"outbox-tray"},{"value":"📥","match":"inbox-tray"},{"value":"📩","match":"envelope-with-arrow"},{"value":"📨","match":"incoming-envelope"},{"value":"✉️","match":"envelope"},{"value":"💌","match":"love-letter"},{"value":"📧","match":"e-mail"},{"value":"📫","match":"closed-mailbox-with-raised"},{"value":"📪","match":"closed-mailbox-with-lowered"},{"value":"📬","match":"open-mailbox-with-raised"},{"value":"📭","match":"open-mailbox-with-lowered"},{"value":"📦","match":"package"},{"value":"📯","match":"postal-horn"},{"value":"📮","match":"postbox"},{"value":"🗳️","match":"ballot-box"},{"value":"📅","match":"calendar"},{"value":"📆","match":"tear-off-calendar"},{"value":"🗓️","match":"spiral-calendar"},{"value":"🪧","match":"placard"},{"value":"⏰","match":"alarm-clock"},{"value":"🕰️","match":"mantelpiece-clock"},{"value":"⌛","match":"hourglass-done"},{"value":"⏳","match":"hourglass-not-done"},{"value":"⏲️","match":"timer-clock"},{"value":"⌚","match":"watch"},{"value":"⏱️","match":"stopwatch"},{"value":"🕛","match":"twelve-o-clock"},{"value":"🕧","match":"twelve-thirty"},{"value":"🕐","match":"one-o-clock"},{"value":"🕜","match":"one-thirty"},{"value":"🕑","match":"two-o-clock"},{"value":"🕝","match":"two-thirty"},{"value":"🕒","match":"three-o-clock"},{"value":"🕞","match":"three-thirty"},{"value":"🕓","match":"four-o-clock"},{"value":"🕟","match":"four-thirty"},{"value":"🕔","match":"five-o-clock"},{"value":"🕠","match":"five-thirty"},{"value":"🕕","match":"six-o-clock"},{"value":"🕡","match":"six-thirty"},{"value":"🕖","match":"seven-o-clock"},{"value":"🕢","match":"seven-thirty"},{"value":"🕗","match":"eight-o-clock"},{"value":"🕣","match":"eight-thirty"},{"value":"🕘","match":"nine-o-clock"},{"value":"🕤","match":"nine-thirty"},{"value":"🕙","match":"ten-o-clock"},{"value":"🕥","match":"ten-thirty"},{"value":"🕚","match":"eleven-o-clock"},{"value":"🕦","match":"eleven-thirty"},{"value":"🛎️","match":"bellhop-bell"},{"value":"🔔","match":"bell"},{"value":"🔈","match":"low-volume"},{"value":"🔈","match":"speaker-low-volume"},{"value":"🔉","match":"medium-volume"},{"value":"🔉","match":"speaker-medium-volume"},{"value":"🔊","match":"high-volume"},{"value":"🔊","match":"speaker-high-volume"},{"value":"📢","match":"loudspeaker"},{"value":"📣","match":"megaphone"},{"value":"🔍","match":"magnifying-glass-tilted-left"},{"value":"🔎","match":"magnifying-glass-tilted-right"},{"value":"🔮","match":"crystal-ball"},{"value":"🧿","match":"evil-eye"},{"value":"🧿","match":"nazar-amulet"},{"value":"🪬","match":"hamsa"},{"value":"📿","match":"prayer-beads"},{"value":"🏺","match":"amphora"},{"value":"⚱️","match":"urn"},{"value":"⚰️","match":"coffin"},{"value":"🪦","match":"headstone"},{"value":"🚬","match":"cigarette"},{"value":"💣","match":"bomb"},{"value":"🪤","match":"mouse-trap"},{"value":"📜","match":"scroll"},{"value":"⚔️","match":"crossed-swords"},{"value":"🗡️","match":"dagger"},{"value":"🛡️","match":"shield"},{"value":"🗝️","match":"old-key"},{"value":"🔑","match":"key"},{"value":"🔐","match":"lock-with-key"},{"value":"🔏","match":"lock-with-pen"},{"value":"🔒","match":"locked"},{"value":"🔓","match":"unlocked"},{"value":"🔴","match":"red-circle"},{"value":"🟠","match":"orange-circle"},{"value":"🟡","match":"yellow-circle"},{"value":"🟢","match":"green-circle"},{"value":"🔵","match":"blue-circle"},{"value":"🟣","match":"purple-circle"},{"value":"🟤","match":"brown-circle"},{"value":"⚫","match":"black-circle"},{"value":"⚪","match":"white-circle"},{"value":"🟥","match":"red-square"},{"value":"🟧","match":"orange-square"},{"value":"🟨","match":"yellow-square"},{"value":"🟩","match":"green-square"},{"value":"🟦","match":"blue-square"},{"value":"🟪","match":"purple-square"},{"value":"🟫","match":"brown-square"},{"value":"⬛","match":"black-square"},{"value":"⬜","match":"white-square"},{"value":"❤️","match":"red-heart"},{"value":"🧡","match":"orange-heart"},{"value":"💛","match":"yellow-heart"},{"value":"💚","match":"green-heart"},{"value":"💙","match":"blue-heart"},{"value":"💜","match":"purple-heart"},{"value":"🤎","match":"brown-heart"},{"value":"🖤","match":"black-heart"},{"value":"🤍","match":"white-heart"},{"value":"🩷","match":"pink-heart"},{"value":"🩵","match":"light-blue-heart"},{"value":"🩶","match":"gray-heart"},{"value":"♥️","match":"heart"},{"value":"♦️","match":"diamond"},{"value":"♣️","match":"club"},{"value":"♠️","match":"spade"},{"value":"♈","match":"aries"},{"value":"♉","match":"taurus"},{"value":"♊","match":"gemini"},{"value":"♋","match":"cancer"},{"value":"♌","match":"leo"},{"value":"♍","match":"virgo"},{"value":"♎","match":"libra"},{"value":"♏","match":"scorpio"},{"value":"♐","match":"sagittarius"},{"value":"♑","match":"capricorn"},{"value":"♒","match":"aquarius"},{"value":"♓","match":"pisces"},{"value":"⛎","match":"ophiuchus"},{"value":"♀️","match":"female-sign"},{"value":"♂️","match":"male-sign"},{"value":"⚧️","match":"trans-sign"},{"value":"💭","match":"thought-bubble"},{"value":"💭","match":"thought-balloon"},{"value":"🗯️","match":"anger-bubble"},{"value":"💬","match":"speech-bubble"},{"value":"🗨️","match":"speech-bubble-leftwards"},{"value":"❕","match":"exclamation-mark-white"},{"value":"❔","match":"question-mark-white"},{"value":"❗","match":"exclamation"},{"value":"❗","match":"exclamation-mark"},{"value":"❓","match":"question"},{"value":"❓","match":"question-mark"},{"value":"❓","match":"?"},{"value":"⁉️","match":"exclamation-question-mark"},{"value":"⁉️","match":"!?"},{"value":"‼️","match":"exclamation-double"},{"value":"‼️","match":"!!"},{"value":"⭕","match":"large-circle"},{"value":"❌","match":"x"},{"value":"❌","match":"cross-mark"},{"value":"🚫","match":"prohibited"},{"value":"🚳","match":"no-bicycles"},{"value":"🚭","match":"no-smoking"},{"value":"🚯","match":"no-littering"},{"value":"🚱","match":"non-potable-water"},{"value":"🚷","match":"no-pedestrians"},{"value":"📵","match":"no-mobile-phones"},{"value":"🔞","match":"no-under-eighteen"},{"value":"🔕","match":"no-sound"},{"value":"🔕","match":"no-bell"},{"value":"🔇","match":"mute"},{"value":"🅰️","match":"a-button"},{"value":"🅰️","match":"blood-type-a"},{"value":"🆎","match":"ab-button"},{"value":"🆎","match":"blood-type-ab"},{"value":"🅱️","match":"b-button"},{"value":"🅱️","match":"blood-type-b"},{"value":"🅾️","match":"o-button"},{"value":"🅾️","match":"blood-type-o"},{"value":"🆑","match":"cl-button"},{"value":"🆘","match":"sos"},{"value":"🛑","match":"stop"},{"value":"⛔","match":"no-entry"},{"value":"📛","match":"name-badge"},{"value":"♨️","match":"hot-springs"},{"value":"🔻","match":"triangle-pointed-down"},{"value":"🔺","match":"triangle-pointed-up"},{"value":"🉐","match":"bargain"},{"value":"㊙️","match":"secret"},{"value":"㊗️","match":"congratulations"},{"value":"🈴","match":"passing-grade"},{"value":"🈵","match":"no-vacancy"},{"value":"🈹","match":"discount"},{"value":"🈲","match":"prohibited-button"},{"value":"🉑","match":"accept"},{"value":"🈶","match":"not-free-of-charge"},{"value":"🈚","match":"free-of-charge"},{"value":"🈸","match":"application"},{"value":"🈺","match":"open-for-business"},{"value":"🈷️","match":"monthly-amount"},{"value":"✴️","match":"eight-pointed-star"},{"value":"🔶","match":"diamond-orange-large"},{"value":"🔸","match":"diamond-orange-small"},{"value":"🔆","match":"bright"},{"value":"🔆","match":"brightness"},{"value":"🔅","match":"dim"},{"value":"🔅","match":"dimness"},{"value":"🆚","match":"vs"},{"value":"🎦","match":"cinema"},{"value":"📶","match":"signal-strength"},{"value":"🔁","match":"repeat"},{"value":"🔂","match":"repeat-one"},{"value":"🔀","match":"shuffle"},{"value":"🔀","match":"twisted-rightwards-arrows"},{"value":"▶️","match":"arrow-forward"},{"value":"▶️","match":"play-button"},{"value":"⏩","match":"fast-forward"},{"value":"⏭️","match":"next-track"},{"value":"⏭️","match":"play-next"},{"value":"⏭️","match":"next"},{"value":"⏭️","match":"right-pointing-double-triangle-with-vertical-bar"},{"value":"⏯️","match":"play-or-pause"},{"value":"⏯️","match":"right-pointing-triangle-with-double-vertical-bar"},{"value":"◀️","match":"reverse"},{"value":"◀️","match":"leftwards-triangle"},{"value":"◀️","match":"arrow-backward"},{"value":"⏪","match":"rewind"},{"value":"⏪","match":"leftwards-double-triangles"},{"value":"⏮️","match":"previous"},{"value":"⏮️","match":"left-pointing-double-triangle-with-vertical-bar"},{"value":"🔼","match":"upwards"},{"value":"🔼","match":"arrow-up"},{"value":"🔼","match":"triangle-up"},{"value":"⏫","match":"fast-up"},{"value":"⏫","match":"double-triangle-up"},{"value":"🔽","match":"downwards"},{"value":"🔽","match":"arrow-down"},{"value":"🔽","match":"triangle-down"},{"value":"⏬","match":"fast-down"},{"value":"⏬","match":"double-triangle-down"},{"value":"⏸️","match":"pause"},{"value":"⏸️","match":"double-vertical-bar"},{"value":"⏹️","match":"stop-button"},{"value":"⏹️","match":"square-button"},{"value":"⏺️","match":"record"},{"value":"⏏️","match":"eject"},{"value":"⏏️","match":"triangle-up-with-horizontal-bar"},{"value":"📴","match":"phone-off"},{"value":"🛜","match":"wireless"},{"value":"📳","match":"vibration"},{"value":"📳","match":"vibration-mode"},{"value":"📲","match":"phone-with-arrow"},{"value":"☢️","match":"radioactive"},{"value":"☣️","match":"biohazard"},{"value":"⚠️","match":"warning"},{"value":"🚸","match":"children-crossing"},{"value":"⚜️","match":"fleur-de-lis"},{"value":"🔱","match":"trident-emblem"},{"value":"〽️","match":"part-alternation-mark"},{"value":"🔰","match":"japanese-symbol-for-beginner"},{"value":"🔰","match":"beginner"},{"value":"✳️","match":"eight-spoked-asterisk"},{"value":"❇️","match":"sparkle"},{"value":"♻️","match":"recycling-symbol"},{"value":"💱","match":"currency-exchange"},{"value":"💲","match":"dollar-sign"},{"value":"💹","match":"chart-increasing-with-yen"},{"value":"🈯","match":"reserved"},{"value":"❎","match":"x-mark"},{"value":"❎","match":"cross-mark-button"},{"value":"❎","match":"no-mark"},{"value":"✅","match":"check-mark"},{"value":"✅","match":"check-mark-green"},{"value":"✔️","match":"check-mark-black"},{"value":"☑️","match":"check-mark-button"},{"value":"☑️","match":"vote"},{"value":"⬆️","match":"up-arrow"},{"value":"↗️","match":"up-right-arrow"},{"value":"➡️","match":"right-arrow"},{"value":"↘️","match":"down-right-arrow"},{"value":"⬇️","match":"down-arrow"},{"value":"↙️","match":"down-left-arrow"},{"value":"⬅️","match":"left-arrow"},{"value":"↖️","match":"up-left-arrow"},{"value":"↕️","match":"up-down-arrow"},{"value":"↔️","match":"left-right-arrow"},{"value":"↩️","match":"right-arrow-curving-left"},{"value":"↪️","match":"left-arrow-curving-right"},{"value":"⤴️","match":"right-arrow-curving-up"},{"value":"⤵️","match":"right-arrow-curving-down"},{"value":"🔃","match":"clockwise-arrows"},{"value":"🔄","match":"counterclockwise-arrows"},{"value":"🔙","match":"back"},{"value":"🔙","match":"arrow-back"},{"value":"🔛","match":"on"},{"value":"🔛","match":"arrow-on"},{"value":"🔝","match":"top"},{"value":"🔝","match":"arrow-top"},{"value":"🔚","match":"end"},{"value":"🔚","match":"arrow-end"},{"value":"🔜","match":"soon"},{"value":"🔜","match":"arrow-soon"},{"value":"🆕","match":"new"},{"value":"🆓","match":"free"},{"value":"🆙","match":"up!"},{"value":"🆗","match":"ok-button"},{"value":"🆒","match":"cool"},{"value":"🆖","match":"ng"},{"value":"ℹ️","match":"information"},{"value":"🅿️","match":"parking"},{"value":"🈁","match":"here"},{"value":"🈂️","match":"service-charge"},{"value":"🈳","match":"vacancy"},{"value":"🔣","match":"symbols"},{"value":"🔤","match":"letters"},{"value":"🔤","match":"abc"},{"value":"🔠","match":"uppercase-letters"},{"value":"🔡","match":"lowercase-letters"},{"value":"🔢","match":"numbers"},{"value":"#️⃣","match":"#"},{"value":"#️⃣","match":"number-sign"},{"value":"*️⃣","match":"asterisk"},{"value":"*️⃣","match":"keycap-asterisk"},{"value":"0️⃣","match":"zero"},{"value":"0️⃣","match":"keycap-zero"},{"value":"1️⃣","match":"one"},{"value":"1️⃣","match":"keycap-one"},{"value":"2️⃣","match":"two"},{"value":"2️⃣","match":"keycap-two"},{"value":"3️⃣","match":"three"},{"value":"3️⃣","match":"keycap-three"},{"value":"4️⃣","match":"four"},{"value":"4️⃣","match":"keycap-four"},{"value":"5️⃣","match":"five"},{"value":"5️⃣","match":"keycap-five"},{"value":"6️⃣","match":"six"},{"value":"6️⃣","match":"keycap-six"},{"value":"7️⃣","match":"seven"},{"value":"7️⃣","match":"keycap-seven"},{"value":"8️⃣","match":"eight"},{"value":"8️⃣","match":"keycap-eight"},{"value":"9️⃣","match":"nine"},{"value":"9️⃣","match":"keycap-nine"},{"value":"🔟","match":"ten"},{"value":"🔟","match":"keycap-ten"},{"value":"🌐","match":"globe"},{"value":"💠","match":"diamond-jewel"},{"value":"🔷","match":"blue-diamond-large"},{"value":"🔹","match":"blue-diamond-small"},{"value":"🏧","match":"atm"},{"value":"Ⓜ️","match":"metro-sign"},{"value":"Ⓜ️","match":"circled-m"},{"value":"🚾","match":"water-closet"},{"value":"🚻","match":"restroom"},{"value":"🚹","match":"mens-room"},{"value":"🚺","match":"womens-room"},{"value":"♿","match":"wheelchair-symbol"},{"value":"🚼","match":"baby-symbol"},{"value":"🛗","match":"elevator"},{"value":"🚮","match":"litter"},{"value":"🚰","match":"water-faucet"},{"value":"🛂","match":"passport-control"},{"value":"🛃","match":"customs"},{"value":"🛄","match":"baggage-claim"},{"value":"🛅","match":"left-luggage"},{"value":"⚕️","match":"medical-symbol"},{"value":"💟","match":"heart-box"},{"value":"⚛️","match":"atom-symbol"},{"value":"🛐","match":"place-of-worship"},{"value":"🕉️","match":"om"},{"value":"☸️","match":"wheel-of-dharma"},{"value":"☮️","match":"peace-symbol"},{"value":"☯️","match":"yin-yang"},{"value":"☪️","match":"star-and-crescent"},{"value":"🪯","match":"khanda"},{"value":"✝️","match":"latin-cross"},{"value":"☦️","match":"orthodox-cross"},{"value":"✡️","match":"star-of-david"},{"value":"🔯","match":"star-of-david-with-dot"},{"value":"🕎","match":"menorah"},{"value":"♾️","match":"infinity"},{"value":"🆔","match":"id-button"},{"value":"👁️🗨️","match":"eye-bubble"},{"value":"🧑🧑🧒","match":"family"},{"value":"🧑🧑🧒🧒","match":"family-4"},{"value":"🧑🧒","match":"family-2"},{"value":"🧑🧒🧒","match":"family-3"},{"value":"🎼","match":"musical-score"},{"value":"🎼","match":"treble-clef"},{"value":"🎵","match":"musical-note"},{"value":"🎶","match":"musical-notes"},{"value":"✖️","match":"multiplication-x"},{"value":"➕","match":"plus-sign"},{"value":"➕","match":"+"},{"value":"➖","match":"minus-sign"},{"value":"➖","match":"-"},{"value":"➗","match":"division-sign"},{"value":"🟰","match":"equals-sign"},{"value":"🟰","match":"="},{"value":"➰","match":"curly-loop"},{"value":"➿","match":"curly-loop-double"},{"value":"〰️","match":"wavy-dash"},{"value":"©️","match":"copyright"},{"value":"®️","match":"registered"},{"value":"™️","match":"trade-mark"},{"value":"🔘","match":"radio-button"},{"value":"🔳","match":"white-square-button"},{"value":"◼️","match":"black-square-medium"},{"value":"◾","match":"black-square-medium-small"},{"value":"▪️","match":"black-square-small"},{"value":"🔲","match":"button-black-square"},{"value":"◻️","match":"white-square-medium"},{"value":"◽","match":"white-square-medium-small"},{"value":"▫️","match":"white-square-small"},{"value":"🏁","match":"chequered-flag"},{"value":"🚩","match":"triangular-flag"},{"value":"🎌","match":"crossed-flags"},{"value":"🏴","match":"black-flag"},{"value":"🏳️","match":"white-flag"},{"value":"🏳️🌈","match":"rainbow-flag"},{"value":"🏳️⚧️","match":"trans-flag"},{"value":"🏴☠️","match":"pirate-flag"},{"value":"🇦🇨","match":"ascension-island-flag"},{"value":"🇦🇩","match":"andorra-flag"},{"value":"🇦🇪","match":"united-arab-emirates-flag"},{"value":"🇦🇫","match":"afghanistan-flag"},{"value":"🇦🇬","match":"antigua-barbuda-flag"},{"value":"🇦🇮","match":"anguilla-flag"},{"value":"🇦🇱","match":"albania-flag"},{"value":"🇦🇲","match":"armenia-flag"},{"value":"🇦🇴","match":"angola-flag"},{"value":"🇦🇶","match":"antarctica-flag"},{"value":"🇦🇷","match":"argentina-flag"},{"value":"🇦🇸","match":"american-samoa-flag"},{"value":"🇦🇹","match":"austria-flag"},{"value":"🇦🇺","match":"australia-flag"},{"value":"🇦🇼","match":"aruba-flag"},{"value":"🇦🇽","match":"åland-islands-flag"},{"value":"🇦🇿","match":"azerbaijan-flag"},{"value":"🇧🇦","match":"bosnia-herzegovina-flag"},{"value":"🇧🇧","match":"barbados-flag"},{"value":"🇧🇩","match":"bangladesh-flag"},{"value":"🇧🇪","match":"belgium-flag"},{"value":"🇧🇫","match":"burkina-faso-flag"},{"value":"🇧🇬","match":"bulgaria-flag"},{"value":"🇧🇭","match":"bahrain-flag"},{"value":"🇧🇮","match":"burundi-flag"},{"value":"🇧🇯","match":"benin-flag"},{"value":"🇧🇱","match":"st-barthélemy-flag"},{"value":"🇧🇲","match":"bermuda-flag"},{"value":"🇧🇳","match":"brunei-flag"},{"value":"🇧🇴","match":"bolivia-flag"},{"value":"🇧🇶","match":"caribbean-netherlands-flag"},{"value":"🇧🇷","match":"brazil-flag"},{"value":"🇧🇸","match":"bahamas-flag"},{"value":"🇧🇹","match":"bhutan-flag"},{"value":"🇧🇻","match":"bouvet-island-flag"},{"value":"🇧🇼","match":"botswana-flag"},{"value":"🇧🇾","match":"belarus-flag"},{"value":"🇧🇿","match":"belize-flag"},{"value":"🇨🇦","match":"canada-flag"},{"value":"🇨🇨","match":"cocos-islands-flag"},{"value":"🇨🇩","match":"congo-kinshasa-flag"},{"value":"🇨🇫","match":"central-african-republic-flag"},{"value":"🇨🇬","match":"congo-brazzaville-flag"},{"value":"🇨🇭","match":"switzerland-flag"},{"value":"🇨🇮","match":"côte-d’ivoire-flag"},{"value":"🇨🇰","match":"cook-islands-flag"},{"value":"🇨🇱","match":"chile-flag"},{"value":"🇨🇲","match":"cameroon-flag"},{"value":"🇨🇳","match":"china-flag"},{"value":"🇨🇴","match":"colombia-flag"},{"value":"🇨🇵","match":"clipperton-island-flag"},{"value":"🇨🇶","match":"sark-flag"},{"value":"🇨🇷","match":"costa-rica-flag"},{"value":"🇨🇺","match":"cuba-flag"},{"value":"🇨🇻","match":"cape-verde-flag"},{"value":"🇨🇼","match":"curaçao-flag"},{"value":"🇨🇽","match":"christmas-island-flag"},{"value":"🇨🇾","match":"cyprus-flag"},{"value":"🇨🇿","match":"czechia-flag"},{"value":"🇩🇪","match":"germany-flag"},{"value":"🇩🇬","match":"diego-garcia-flag"},{"value":"🇩🇯","match":"djibouti-flag"},{"value":"🇩🇰","match":"denmark-flag"},{"value":"🇩🇲","match":"dominica-flag"},{"value":"🇩🇴","match":"dominican-republic-flag"},{"value":"🇩🇿","match":"algeria-flag"},{"value":"🇪🇦","match":"ceuta-melilla-flag"},{"value":"🇪🇨","match":"ecuador-flag"},{"value":"🇪🇪","match":"estonia-flag"},{"value":"🇪🇬","match":"egypt-flag"},{"value":"🇪🇭","match":"western-sahara-flag"},{"value":"🇪🇷","match":"eritrea-flag"},{"value":"🇪🇸","match":"spain-flag"},{"value":"🇪🇹","match":"ethiopia-flag"},{"value":"🇪🇺","match":"european-union-flag"},{"value":"🇫🇮","match":"finland-flag"},{"value":"🇫🇯","match":"fiji-flag"},{"value":"🇫🇰","match":"falkland-islands-flag"},{"value":"🇫🇲","match":"micronesia-flag"},{"value":"🇫🇴","match":"faroe-islands-flag"},{"value":"🇫🇷","match":"france-flag"},{"value":"🇬🇦","match":"gabon-flag"},{"value":"🇬🇧","match":"united-kingdom-flag"},{"value":"🇬🇩","match":"grenada-flag"},{"value":"🇬🇪","match":"georgia-flag"},{"value":"🇬🇫","match":"french-guiana-flag"},{"value":"🇬🇬","match":"guernsey-flag"},{"value":"🇬🇭","match":"ghana-flag"},{"value":"🇬🇮","match":"gibraltar-flag"},{"value":"🇬🇱","match":"greenland-flag"},{"value":"🇬🇲","match":"gambia-flag"},{"value":"🇬🇳","match":"guinea-flag"},{"value":"🇬🇵","match":"guadeloupe-flag"},{"value":"🇬🇶","match":"equatorial-guinea-flag"},{"value":"🇬🇷","match":"greece-flag"},{"value":"🇬🇸","match":"south-georgia-south-flag"},{"value":"🇬🇹","match":"guatemala-flag"},{"value":"🇬🇺","match":"guam-flag"},{"value":"🇬🇼","match":"guinea-bissau-flag"},{"value":"🇬🇾","match":"guyana-flag"},{"value":"🇭🇰","match":"hong-kong-sar-china-flag"},{"value":"🇭🇲","match":"heard-mcdonald-islands-flag"},{"value":"🇭🇳","match":"honduras-flag"},{"value":"🇭🇷","match":"croatia-flag"},{"value":"🇭🇹","match":"haiti-flag"},{"value":"🇭🇺","match":"hungary-flag"},{"value":"🇮🇨","match":"canary-islands-flag"},{"value":"🇮🇩","match":"indonesia-flag"},{"value":"🇮🇪","match":"ireland-flag"},{"value":"🇮🇱","match":"israel-flag"},{"value":"🇮🇲","match":"isle-of-man-flag"},{"value":"🇮🇳","match":"india-flag"},{"value":"🇮🇴","match":"british-indian-ocean-territory-flag"},{"value":"🇮🇶","match":"iraq-flag"},{"value":"🇮🇷","match":"iran-flag"},{"value":"🇮🇸","match":"iceland-flag"},{"value":"🇮🇹","match":"italy-flag"},{"value":"🇯🇪","match":"jersey-flag"},{"value":"🇯🇲","match":"jamaica-flag"},{"value":"🇯🇴","match":"jordan-flag"},{"value":"🇯🇵","match":"japan-flag"},{"value":"🇰🇪","match":"kenya-flag"},{"value":"🇰🇬","match":"kyrgyzstan-flag"},{"value":"🇰🇭","match":"cambodia-flag"},{"value":"🇰🇮","match":"kiribati-flag"},{"value":"🇰🇲","match":"comoros-flag"},{"value":"🇰🇳","match":"st.-kitts-&-nevis-flag"},{"value":"🇰🇵","match":"north-korea-flag"},{"value":"🇰🇷","match":"south-korea-flag"},{"value":"🇰🇼","match":"kuwait-flag"},{"value":"🇰🇾","match":"cayman-islands-flag"},{"value":"🇰🇿","match":"kazakhstan-flag"},{"value":"🇱🇦","match":"laos-flag"},{"value":"🇱🇧","match":"lebanon-flag"},{"value":"🇱🇨","match":"st.-lucia-flag"},{"value":"🇱🇮","match":"liechtenstein-flag"},{"value":"🇱🇰","match":"sri-lanka-flag"},{"value":"🇱🇷","match":"liberia-flag"},{"value":"🇱🇸","match":"lesotho-flag"},{"value":"🇱🇹","match":"lithuania-flag"},{"value":"🇱🇺","match":"luxembourg-flag"},{"value":"🇱🇻","match":"latvia-flag"},{"value":"🇱🇾","match":"libya-flag"},{"value":"🇲🇦","match":"morocco-flag"},{"value":"🇲🇨","match":"monaco-flag"},{"value":"🇲🇩","match":"moldova-flag"},{"value":"🇲🇪","match":"montenegro-flag"},{"value":"🇲🇫","match":"st-martin-flag"},{"value":"🇲🇬","match":"madagascar-flag"},{"value":"🇲🇭","match":"marshall-islands-flag"},{"value":"🇲🇰","match":"macedonia-flag"},{"value":"🇲🇱","match":"mali-flag"},{"value":"🇲🇲","match":"myanmar-flag"},{"value":"🇲🇳","match":"mongolia-flag"},{"value":"🇲🇴","match":"macau-sar-china-flag"},{"value":"🇲🇵","match":"northern-mariana-islands-flag"},{"value":"🇲🇶","match":"martinique-flag"},{"value":"🇲🇷","match":"mauritania-flag"},{"value":"🇲🇸","match":"montserrat-flag"},{"value":"🇲🇹","match":"malta-flag"},{"value":"🇲🇺","match":"mauritius-flag"},{"value":"🇲🇻","match":"maldives-flag"},{"value":"🇲🇼","match":"malawi-flag"},{"value":"🇲🇽","match":"mexico-flag"},{"value":"🇲🇾","match":"malaysia-flag"},{"value":"🇲🇿","match":"mozambique-flag"},{"value":"🇳🇦","match":"namibia-flag"},{"value":"🇳🇨","match":"new-caledonia-flag"},{"value":"🇳🇪","match":"niger-flag"},{"value":"🇳🇫","match":"norfolk-island-flag"},{"value":"🇳🇬","match":"nigeria-flag"},{"value":"🇳🇮","match":"nicaragua-flag"},{"value":"🇳🇱","match":"netherlands-flag"},{"value":"🇳🇴","match":"norway-flag"},{"value":"🇳🇵","match":"nepal-flag"},{"value":"🇳🇷","match":"nauru-flag"},{"value":"🇳🇺","match":"niue-flag"},{"value":"🇳🇿","match":"new-zealand-flag"},{"value":"🇴🇲","match":"oman-flag"},{"value":"🇵🇦","match":"panama-flag"},{"value":"🇵🇪","match":"peru-flag"},{"value":"🇵🇫","match":"french-polynesia-flag"},{"value":"🇵🇬","match":"papua-new-guinea-flag"},{"value":"🇵🇭","match":"philippines-flag"},{"value":"🇵🇰","match":"pakistan-flag"},{"value":"🇵🇱","match":"poland-flag"},{"value":"🇵🇲","match":"st-pierre-miquelon-flag"},{"value":"🇵🇳","match":"pitcairn-islands-flag"},{"value":"🇵🇷","match":"puerto-rico-flag"},{"value":"🇵🇸","match":"palestinian-territories-flag"},{"value":"🇵🇹","match":"portugal-flag"},{"value":"🇵🇼","match":"palau-flag"},{"value":"🇵🇾","match":"paraguay-flag"},{"value":"🇶🇦","match":"qatar-flag"},{"value":"🇷🇪","match":"réunion-flag"},{"value":"🇷🇴","match":"romania-flag"},{"value":"🇷🇸","match":"serbia-flag"},{"value":"🇷🇺","match":"russia-flag"},{"value":"🇷🇼","match":"rwanda-flag"},{"value":"🇸🇦","match":"saudi-arabia-flag"},{"value":"🇸🇧","match":"solomon-islands-flag"},{"value":"🇸🇨","match":"seychelles-flag"},{"value":"🇸🇩","match":"sudan-flag"},{"value":"🇸🇪","match":"sweden-flag"},{"value":"🇸🇬","match":"singapore-flag"},{"value":"🇸🇭","match":"st-helena-flag"},{"value":"🇸🇮","match":"slovenia-flag"},{"value":"🇸🇯","match":"svalbard-jan-mayen-flag"},{"value":"🇸🇰","match":"slovakia-flag"},{"value":"🇸🇱","match":"sierra-leone-flag"},{"value":"🇸🇲","match":"san-marino-flag"},{"value":"🇸🇳","match":"senegal-flag"},{"value":"🇸🇴","match":"somalia-flag"},{"value":"🇸🇷","match":"suriname-flag"},{"value":"🇸🇸","match":"south-sudan-flag"},{"value":"🇸🇹","match":"são-tomé-príncipe-flag"},{"value":"🇸🇻","match":"el-salvador-flag"},{"value":"🇸🇽","match":"sint-maarten-flag"},{"value":"🇸🇾","match":"syria-flag"},{"value":"🇸🇿","match":"swaziland-flag"},{"value":"🇹🇦","match":"tristan-da-cunha-flag"},{"value":"🇹🇨","match":"turks-caicos-islands-flag"},{"value":"🇹🇩","match":"chad-flag"},{"value":"🇹🇫","match":"french-southern-territories-flag"},{"value":"🇹🇬","match":"togo-flag"},{"value":"🇹🇭","match":"thailand-flag"},{"value":"🇹🇯","match":"tajikistan-flag"},{"value":"🇹🇰","match":"tokelau-flag"},{"value":"🇹🇱","match":"timor-leste-flag"},{"value":"🇹🇲","match":"turkmenistan-flag"},{"value":"🇹🇳","match":"tunisia-flag"},{"value":"🇹🇴","match":"tonga-flag"},{"value":"🇹🇷","match":"turkey-flag"},{"value":"🇹🇹","match":"trinidad-tobago-flag"},{"value":"🇹🇻","match":"tuvalu-flag"},{"value":"🇹🇼","match":"taiwan-flag"},{"value":"🇹🇿","match":"tanzania-flag"},{"value":"🇺🇦","match":"ukraine-flag"},{"value":"🇺🇬","match":"uganda-flag"},{"value":"🇺🇲","match":"us-outlying-islands-flag"},{"value":"🇺🇳","match":"united-nations-flag"},{"value":"🇺🇸","match":"united-states-flag"},{"value":"🇺🇾","match":"uruguay-flag"},{"value":"🇺🇿","match":"uzbekistan-flag"},{"value":"🇻🇦","match":"vatican-city-flag"},{"value":"🇻🇨","match":"st-vincent-grenadines-flag"},{"value":"🇻🇪","match":"venezuela-flag"},{"value":"🇻🇬","match":"british-virgin-islands-flag"},{"value":"🇻🇮","match":"us-virgin-islands-flag"},{"value":"🇻🇳","match":"vietnam-flag"},{"value":"🇻🇺","match":"vanuatu-flag"},{"value":"🇼🇫","match":"wallis-futuna-flag"},{"value":"🇼🇸","match":"samoa-flag"},{"value":"🇽🇰","match":"kosovo-flag"},{"value":"🇾🇪","match":"yemen-flag"},{"value":"🇾🇹","match":"mayotte-flag"},{"value":"🇿🇦","match":"south-africa-flag"},{"value":"🇿🇲","match":"zambia-flag"},{"value":"🇿🇼","match":"zimbabwe-flag"},{"value":"🏴","match":"england-flag"},{"value":"🏴","match":"scotland-flag"},{"value":"🏴","match":"wales-flag"}];
diff --git a/lib/emojis/generateEmojiMapping.js b/lib/emojis/generateEmojiMapping.mjs similarity index 96% rename from lib/emojis/generateEmojiMapping.js rename to lib/emojis/generateEmojiMapping.mjs index c909a05..0a86a50 100644 --- a/lib/emojis/generateEmojiMapping.js +++ b/lib/emojis/generateEmojiMapping.mjs
@@ -42,4 +42,4 @@ const content = `// Generated by generateEmojiMapping.js — do not edit manually.\n` + `export default ${JSON.stringify(mapping)};\n`; -await writeFile('emoji.js', content, 'utf8'); +await writeFile('emojis.js', content, 'utf8');
diff --git a/lib/errorprone/BUILD b/lib/errorprone/BUILD index f95a430..9bc08b1 100644 --- a/lib/errorprone/BUILD +++ b/lib/errorprone/BUILD
@@ -5,5 +5,5 @@ data = ["//lib:LICENSE-Apache2.0"], neverlink = 1, visibility = ["//visibility:public"], - exports = ["@error-prone-annotations//jar"], + exports = ["@external_deps//:com_google_errorprone_error_prone_annotations"], )
diff --git a/lib/flogger/BUILD b/lib/flogger/BUILD index a335586..e0fada6 100644 --- a/lib/flogger/BUILD +++ b/lib/flogger/BUILD
@@ -5,9 +5,9 @@ data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], exports = [ - "@flogger-google-extensions//jar", - "@flogger-log4j-backend//jar", - "@flogger-system-backend//jar", - "@flogger//jar", + "@external_deps//:com_google_flogger_flogger", + "@external_deps//:com_google_flogger_flogger_log4j_backend", + "@external_deps//:com_google_flogger_flogger_system_backend", + "@external_deps//:com_google_flogger_google_extensions", ], )
diff --git a/lib/fonts/material-icons.woff2 b/lib/fonts/material-icons.woff2 index 4fd4a47..c9f0b2f 100644 --- a/lib/fonts/material-icons.woff2 +++ b/lib/fonts/material-icons.woff2 Binary files differ
diff --git a/lib/gitiles/BUILD b/lib/gitiles/BUILD index b91cc1f..3457828 100644 --- a/lib/gitiles/BUILD +++ b/lib/gitiles/BUILD
@@ -19,35 +19,35 @@ name = "cm-autolink", data = ["//lib:LICENSE-commonmark"], visibility = ["//visibility:public"], - exports = ["@cm-autolink//jar"], + exports = ["@external_deps//:org_commonmark_commonmark_ext_autolink"], ) java_library( name = "commonmark", data = ["//lib:LICENSE-commonmark"], visibility = ["//visibility:public"], - exports = ["@commonmark//jar"], + exports = ["@external_deps//:org_commonmark_commonmark"], ) java_library( name = "gfm-strikethrough", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@gfm-strikethrough//jar"], + exports = ["@external_deps//:org_commonmark_commonmark_ext_gfm_strikethrough"], ) java_library( name = "gfm-tables", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@gfm-tables//jar"], + exports = ["@external_deps//:org_commonmark_commonmark_ext_gfm_tables"], ) java_library( name = "gitiles-servlet", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@gitiles-servlet//jar"], + exports = ["@external_deps//:com_google_gitiles_gitiles_servlet"], ) java_library(
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD index 3a400a4..007f257 100644 --- a/lib/greenmail/BUILD +++ b/lib/greenmail/BUILD
@@ -6,13 +6,13 @@ name = "javax-activation", testonly = True, data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], - exports = ["@javax-activation//jar"], + exports = ["@external_deps//:javax_activation_activation"], ) java_library( name = "greenmail", testonly = True, data = ["//lib:LICENSE-Apache2.0"], - exports = ["@greenmail//jar"], + exports = ["@external_deps//:com_icegreen_greenmail"], runtime_deps = [":javax-activation"], )
diff --git a/lib/guice/BUILD b/lib/guice/BUILD index 091dcad..8e4272b 100644 --- a/lib/guice/BUILD +++ b/lib/guice/BUILD
@@ -15,7 +15,7 @@ name = "guice-library", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@guice-library//jar"], + exports = ["@external_deps//:com_google_inject_guice"], runtime_deps = ["aopalliance"], ) @@ -23,7 +23,7 @@ name = "guice-assistedinject", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@guice-assistedinject//jar"], + exports = ["@external_deps//:com_google_inject_extensions_guice_assistedinject"], runtime_deps = [":guice"], ) @@ -31,26 +31,26 @@ name = "guice-servlet", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@guice-servlet//jar"], + exports = ["@external_deps//:com_google_inject_extensions_guice_servlet"], runtime_deps = [":guice"], ) java_library( name = "aopalliance", data = ["//lib:LICENSE-PublicDomain"], - exports = ["@aopalliance//jar"], + exports = ["@external_deps//:aopalliance_aopalliance"], ) java_library( name = "jakarta-inject", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@jakarta-inject-api//jar"], + exports = ["@external_deps//:jakarta_inject_jakarta_inject_api"], ) java_library( name = "javax_inject", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@javax_inject//jar"], + exports = ["@external_deps//:javax_inject_javax_inject"], )
diff --git a/lib/highlightjs/BUILD b/lib/highlightjs/BUILD index 9503f20..aa15509 100644 --- a/lib/highlightjs/BUILD +++ b/lib/highlightjs/BUILD
@@ -21,6 +21,7 @@ "@ui_npm//highlightjs-closure-templates", "@ui_npm//highlightjs-epp", "@ui_npm//highlightjs-structured-text", + "@ui_npm//highlightjs-ttcn3", "@ui_npm//highlightjs-vue", ], args = [
diff --git a/lib/highlightjs/index.js b/lib/highlightjs/index.js index 7b9088a7..d0152e6 100644 --- a/lib/highlightjs/index.js +++ b/lib/highlightjs/index.js
@@ -19,12 +19,14 @@ import soy from 'highlightjs-closure-templates'; import epp from 'highlightjs-epp'; import iecst from 'highlightjs-structured-text'; +import ttcn3 from 'highlightjs-ttcn3'; import vue from 'highlightjs-vue'; import gn from './gn'; hljs.registerLanguage('soy', soy); hljs.registerLanguage('epp', epp); hljs.registerLanguage('iecst', iecst); +hljs.registerLanguage('ttcn3', ttcn3); hljs.registerLanguage('vue', vue); hljs.registerLanguage('gn', gn);
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD index 25e09c9..8ead589 100644 --- a/lib/httpcomponents/BUILD +++ b/lib/httpcomponents/BUILD
@@ -5,14 +5,14 @@ java_library( name = "fluent-hc", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@fluent-hc//jar"], + exports = ["@external_deps//:org_apache_httpcomponents_fluent_hc"], runtime_deps = [":httpclient"], ) java_library( name = "httpclient", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@httpclient//jar"], + exports = ["@external_deps//:org_apache_httpcomponents_httpclient"], runtime_deps = [ ":httpcore", "//lib/commons:codec", @@ -23,5 +23,5 @@ java_library( name = "httpcore", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@httpcore//jar"], + exports = ["@external_deps//:org_apache_httpcomponents_httpcore"], )
diff --git a/lib/jetty/BUILD b/lib/jetty/BUILD index 4619ac4..fc029ed 100644 --- a/lib/jetty/BUILD +++ b/lib/jetty/BUILD
@@ -6,7 +6,7 @@ visibility = ["//visibility:public"], exports = [ ":util-ajax", - "@jetty-servlet//jar", + "@external_deps//:org_eclipse_jetty_jetty_servlet", ], runtime_deps = [":security"], ) @@ -15,7 +15,7 @@ name = "security", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@jetty-security//jar"], + exports = ["@external_deps//:org_eclipse_jetty_jetty_security"], runtime_deps = [":server"], ) @@ -25,7 +25,7 @@ visibility = ["//visibility:public"], exports = [ ":http", - "@jetty-server//jar", + "@external_deps//:org_eclipse_jetty_jetty_server", ], ) @@ -35,7 +35,7 @@ visibility = ["//visibility:public"], exports = [ ":http", - "@jetty-jmx//jar", + "@external_deps//:org_eclipse_jetty_jetty_jmx", ], ) @@ -45,7 +45,7 @@ visibility = ["//visibility:public"], exports = [ ":io", - "@jetty-http//jar", + "@external_deps//:org_eclipse_jetty_jetty_http", ], ) @@ -54,18 +54,18 @@ data = ["//lib:LICENSE-Apache2.0"], exports = [ ":util", - "@jetty-io//jar", + "@external_deps//:org_eclipse_jetty_jetty_io", ], ) java_library( name = "util", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@jetty-util//jar"], + exports = ["@external_deps//:org_eclipse_jetty_jetty_util"], ) java_library( name = "util-ajax", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@jetty-util-ajax//jar"], + exports = ["@external_deps//:org_eclipse_jetty_jetty_util_ajax"], )
diff --git a/lib/jsoup/BUILD b/lib/jsoup/BUILD index 7171901..57a16f6 100644 --- a/lib/jsoup/BUILD +++ b/lib/jsoup/BUILD
@@ -4,5 +4,5 @@ name = "jsoup", data = ["//lib:LICENSE-jsoup"], visibility = ["//visibility:public"], - exports = ["@jsoup//jar"], + exports = ["@external_deps//:org_jsoup_jsoup"], )
diff --git a/lib/log/BUILD b/lib/log/BUILD index 21c4d47..9dbd55c 100644 --- a/lib/log/BUILD +++ b/lib/log/BUILD
@@ -7,21 +7,21 @@ "//lib:__pkg__", "//plugins:__pkg__", ], - exports = ["@log-api//jar"], + exports = ["@external_deps//:org_slf4j_slf4j_api"], ) java_library( name = "ext", data = ["//lib:LICENSE-slf4j"], visibility = ["//visibility:public"], - exports = ["@log-ext//jar"], + exports = ["@external_deps//:org_slf4j_slf4j_ext"], ) java_library( name = "impl-log4j", data = ["//lib:LICENSE-slf4j"], visibility = ["//visibility:public"], - exports = ["@impl-log4j//jar"], + exports = ["@external_deps//:org_slf4j_slf4j_reload4j"], runtime_deps = [":log4j"], ) @@ -29,19 +29,19 @@ name = "jcl-over-slf4j", data = ["//lib:LICENSE-slf4j"], visibility = ["//visibility:public"], - exports = ["@jcl-over-slf4j//jar"], + exports = ["@external_deps//:org_slf4j_jcl_over_slf4j"], ) java_library( name = "log4j", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@log4j//jar"], + exports = ["@external_deps//:ch_qos_reload4j_reload4j"], ) java_library( name = "json-smart", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@json-smart//jar"], + exports = ["@external_deps//:net_minidev_json_smart"], )
diff --git a/lib/lucene/BUILD b/lib/lucene/BUILD index 6b14a78..d001d30 100644 --- a/lib/lucene/BUILD +++ b/lib/lucene/BUILD
@@ -1,37 +1,37 @@ -load("@rules_java//java:defs.bzl", "java_binary", "java_import", "java_library") +load("@rules_java//java:defs.bzl", "java_library") package(default_visibility = ["//visibility:public"]) java_library( name = "lucene-analyzers-common", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@lucene-analyzers-common//jar"], + exports = ["@external_deps//:org_apache_lucene_lucene_analysis_common"], runtime_deps = [":lucene-core"], ) java_library( name = "lucene-backward-codecs", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@lucene-backward-codecs//jar"], + exports = ["@external_deps//:org_apache_lucene_lucene_backward_codecs"], ) java_library( name = "lucene-core", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@lucene-core//jar"], + exports = ["@external_deps//:org_apache_lucene_lucene_core"], runtime_deps = [":lucene-backward-codecs"], ) java_library( name = "lucene-misc", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@lucene-misc//jar"], + exports = ["@external_deps//:org_apache_lucene_lucene_misc"], runtime_deps = [":lucene-core"], ) java_library( name = "lucene-queryparser", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@lucene-queryparser//jar"], + exports = ["@external_deps//:org_apache_lucene_lucene_queryparser"], runtime_deps = [":lucene-core"], )
diff --git a/lib/mail/BUILD b/lib/mail/BUILD index 489f544..c8f6dd4 100644 --- a/lib/mail/BUILD +++ b/lib/mail/BUILD
@@ -4,5 +4,5 @@ name = "mail", data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:public"], - exports = ["@mail//jar"], + exports = ["@external_deps//:com_sun_mail_javax_mail"], )
diff --git a/lib/mime4j/BUILD b/lib/mime4j/BUILD index 577661d..ea8e9d5 100644 --- a/lib/mime4j/BUILD +++ b/lib/mime4j/BUILD
@@ -4,12 +4,12 @@ name = "core", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@mime4j-core//jar"], + exports = ["@external_deps//:org_apache_james_apache_mime4j_core"], ) java_library( name = "dom", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@mime4j-dom//jar"], + exports = ["@external_deps//:org_apache_james_apache_mime4j_dom"], )
diff --git a/lib/mina/BUILD b/lib/mina/BUILD index 8ea39fe..00cc2e5 100644 --- a/lib/mina/BUILD +++ b/lib/mina/BUILD
@@ -5,8 +5,8 @@ data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], exports = [ - "@sshd-mina//jar", - "@sshd-osgi//jar", + "@external_deps//:org_apache_sshd_sshd_mina", + "@external_deps//:org_apache_sshd_sshd_osgi", ], runtime_deps = [":core"], ) @@ -15,12 +15,12 @@ name = "sshd-sftp", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@sshd-sftp//jar"], + exports = ["@external_deps//:org_apache_sshd_sshd_sftp"], ) java_library( name = "core", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@mina-core//jar"], + exports = ["@external_deps//:org_apache_mina_mina_core"], )
diff --git a/lib/mockito/BUILD b/lib/mockito/BUILD index 2aaf56d..215f5ff 100644 --- a/lib/mockito/BUILD +++ b/lib/mockito/BUILD
@@ -9,7 +9,7 @@ name = "mockito", data = ["//lib:LICENSE-mockito"], visibility = ["//visibility:public"], - exports = ["@mockito//jar"], + exports = ["@external_deps//:org_mockito_mockito_core"], runtime_deps = [ ":byte-buddy", ":byte-buddy-agent", @@ -20,17 +20,17 @@ java_library( name = "byte-buddy", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@bytebuddy//jar"], + exports = ["@external_deps//:net_bytebuddy_byte_buddy"], ) java_library( name = "byte-buddy-agent", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@bytebuddy-agent//jar"], + exports = ["@external_deps//:net_bytebuddy_byte_buddy_agent"], ) java_library( name = "objenesis", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@objenesis//jar"], + exports = ["@external_deps//:org_objenesis_objenesis"], )
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh index 231282a..16d2f12 100755 --- a/lib/nongoogle_test.sh +++ b/lib/nongoogle_test.sh
@@ -1,14 +1,14 @@ #!/bin/sh -# This test ensures that new dependencies in nongoogle.bzl go through LC review. +# This test ensures that new dependencies in nongoogle.toml go through LC review. set -eux -bzl=$(pwd)/tools/nongoogle.bzl +toml=$(pwd)/tools/nongoogle.toml TMP=$(mktemp -d || mktemp -d -t /tmp/tmp.XXXXXX) -grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names +grep 'module =' ${toml} | cut -d' ' -f1 | sort > $TMP/names cat << EOF > $TMP/want auto-common @@ -16,7 +16,6 @@ auto-service-annotations auto-value auto-value-annotations -cglib-3_2 commons-io dropwizard-core error-prone-annotations @@ -47,7 +46,6 @@ lucene-queryparser mina-core nekohtml -objenesis openid-consumer protobuf-java soy
diff --git a/lib/openid/BUILD b/lib/openid/BUILD index c27e8ab..dfab6a5 100644 --- a/lib/openid/BUILD +++ b/lib/openid/BUILD
@@ -4,7 +4,7 @@ name = "consumer", data = ["//lib:LICENSE-Apache2.0"], visibility = ["//visibility:public"], - exports = ["@openid-consumer//jar"], + exports = ["@external_deps//:org_openid4java_openid4java"], runtime_deps = [ ":nekohtml", ":xerces", @@ -17,12 +17,12 @@ java_library( name = "nekohtml", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@nekohtml//jar"], + exports = ["@external_deps//:net_sourceforge_nekohtml_nekohtml"], runtime_deps = [":xerces"], ) java_library( name = "xerces", data = ["//lib:LICENSE-Apache2.0"], - exports = ["@xerces//jar"], + exports = ["@external_deps//:xerces_xercesImpl"], )
diff --git a/lib/ow2/BUILD b/lib/ow2/BUILD index 7fe7e2d..529f090 100644 --- a/lib/ow2/BUILD +++ b/lib/ow2/BUILD
@@ -4,21 +4,21 @@ name = "ow2-asm", data = ["//lib:LICENSE-ow2"], visibility = ["//visibility:public"], - exports = ["@ow2-asm//jar"], + exports = ["@external_deps//:org_ow2_asm_asm"], ) java_library( name = "ow2-asm-analysis", data = ["//lib:LICENSE-ow2"], visibility = ["//visibility:public"], - exports = ["@ow2-asm-analysis//jar"], + exports = ["@external_deps//:org_ow2_asm_asm_analysis"], ) java_library( name = "ow2-asm-commons", data = ["//lib:LICENSE-ow2"], visibility = ["//visibility:public"], - exports = ["@ow2-asm-commons//jar"], + exports = ["@external_deps//:org_ow2_asm_asm_commons"], runtime_deps = [":ow2-asm-tree"], ) @@ -26,12 +26,12 @@ name = "ow2-asm-tree", data = ["//lib:LICENSE-ow2"], visibility = ["//visibility:public"], - exports = ["@ow2-asm-tree//jar"], + exports = ["@external_deps//:org_ow2_asm_asm_tree"], ) java_library( name = "ow2-asm-util", data = ["//lib:LICENSE-ow2"], visibility = ["//visibility:public"], - exports = ["@ow2-asm-util//jar"], + exports = ["@external_deps//:org_ow2_asm_asm_util"], )
diff --git a/lib/prolog/BUILD b/lib/prolog/BUILD index fa55682..6b99765 100644 --- a/lib/prolog/BUILD +++ b/lib/prolog/BUILD
@@ -4,21 +4,22 @@ name = "runtime", data = ["//lib:LICENSE-prologcafe"], visibility = ["//visibility:public"], - exports = ["@prolog-runtime//jar"], + exports = ["@external_deps//:com_googlecode_prolog_cafe_prolog_runtime"], ) java_library( name = "runtime-neverlink", data = ["//lib:LICENSE-prologcafe"], + neverlink = 1, visibility = ["//visibility:public"], - exports = ["@prolog-runtime//jar:neverlink"], + exports = ["@external_deps//:com_googlecode_prolog_cafe_prolog_runtime"], ) java_library( name = "compiler", data = ["//lib:LICENSE-prologcafe"], visibility = ["//visibility:public"], - exports = ["@prolog-compiler//jar"], + exports = ["@external_deps//:com_googlecode_prolog_cafe_prolog_compiler"], runtime_deps = [ ":io", ":runtime", @@ -28,14 +29,14 @@ java_library( name = "io", data = ["//lib:LICENSE-prologcafe"], - exports = ["@prolog-io//jar"], + exports = ["@external_deps//:com_googlecode_prolog_cafe_prolog_io"], ) java_library( name = "cafeteria", data = ["//lib:LICENSE-prologcafe"], visibility = ["//visibility:public"], - exports = ["@cafeteria//jar"], + exports = ["@external_deps//:com_googlecode_prolog_cafe_prolog_cafeteria"], runtime_deps = [ "io", "runtime",
diff --git a/lib/truth/BUILD b/lib/truth/BUILD index dc1d802..ce9922c 100644 --- a/lib/truth/BUILD +++ b/lib/truth/BUILD
@@ -5,7 +5,7 @@ testonly = True, data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:public"], - exports = ["@truth//jar"], + exports = ["@external_deps//:com_google_truth_truth"], runtime_deps = [ ":diffutils", "//lib:guava", @@ -18,7 +18,7 @@ testonly = True, data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:public"], - exports = ["@truth-java8-extension//jar"], + exports = ["@external_deps//:com_google_truth_extensions_truth_java8_extension"], runtime_deps = [ ":truth", "//lib:guava", @@ -30,7 +30,7 @@ testonly = True, data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:private"], - exports = ["@truth-liteproto-extension//jar"], + exports = ["@external_deps//:com_google_truth_extensions_truth_liteproto_extension"], runtime_deps = [ ":truth", "//lib:guava", @@ -43,7 +43,7 @@ testonly = True, data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"], visibility = ["//visibility:private"], - exports = ["@diffutils//jar"], + exports = ["@external_deps//:io_github_java_diff_utils_java_diff_utils"], ) java_library( @@ -53,7 +53,7 @@ visibility = ["//visibility:public"], exports = [ ":truth-liteproto-extension", - "@truth-proto-extension//jar", + "@external_deps//:com_google_truth_extensions_truth_proto_extension", ], runtime_deps = [ ":truth",
diff --git a/modules/jgit b/modules/jgit index bb30974..d95b54f 160000 --- a/modules/jgit +++ b/modules/jgit
@@ -1 +1 @@ -Subproject commit bb309748c7ead580f09dd685ba8d1374a539cb1c +Subproject commit d95b54f6080eb2aa9421e01188d25ab4a4a0c6bf
diff --git a/package.json b/package.json index 2723b81..f1e24c5 100644 --- a/package.json +++ b/package.json
@@ -43,12 +43,12 @@ "vscode-html-languageservice": "for lit-analyzer, see change 487301" }, "resolutions": { - "eslint": "^9.26.0", - "@typescript-eslint/eslint-plugin": "^8.32.0", - "@typescript-eslint/parser": "^8.32.0", + "eslint": "^9.39.1", + "@typescript-eslint/eslint-plugin": "^8.49.0", + "@typescript-eslint/parser": "^8.49.0", "typescript": "5.8.2", - "vscode-css-languageservice": "^6.3.6", - "vscode-html-languageservice": "^5.5.0" + "vscode-css-languageservice": "^6.3.9", + "vscode-html-languageservice": "^5.6.1" }, "scripts": { "setup": "yarn && yarn --cwd=polygerrit-ui && yarn --cwd=polygerrit-ui/app", @@ -81,4 +81,4 @@ }, "author": "", "license": "Apache-2.0" -} \ No newline at end of file +}
diff --git a/plugins/BUILD b/plugins/BUILD index e0400f8..c48ddf6 100644 --- a/plugins/BUILD +++ b/plugins/BUILD
@@ -6,7 +6,6 @@ "CORE_PLUGINS", "CUSTOM_PLUGINS", ) -load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test") package(default_visibility = ["//visibility:public"]) @@ -71,9 +70,12 @@ "//lib/antlr:java-runtime", "//lib/auto:auto-value-annotations", "//lib/auto:auto-value-gson", + "//lib/commons:codec", "//lib/commons:compress", "//lib/commons:dbcp", "//lib/commons:lang3", + "//lib/commons:io", + "//lib/commons:net", "//lib/dropwizard:dropwizard-core", "//lib/flogger:api", "//lib/guice:guice",
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor index a6d0469..1d9e98f 160000 --- a/plugins/codemirror-editor +++ b/plugins/codemirror-editor
@@ -1 +1 @@ -Subproject commit a6d0469b9cab54336c985ddc967cbca3407f2aeb +Subproject commit 1d9e98f5645bab18e15de8e9094b76038518fcda
diff --git a/plugins/delete-project b/plugins/delete-project index e8fb93f..7090927 160000 --- a/plugins/delete-project +++ b/plugins/delete-project
@@ -1 +1 @@ -Subproject commit e8fb93fdae664baea65a1eba3231dd437eaa9f01 +Subproject commit 7090927136857b5abaa22604e538813989f2559d
diff --git a/plugins/download-commands b/plugins/download-commands index bfb522f..15f2608 160000 --- a/plugins/download-commands +++ b/plugins/download-commands
@@ -1 +1 @@ -Subproject commit bfb522f79240f4baf3398952998bded95a804af2 +Subproject commit 15f26084cc2d1b2055e5fa5f9b0d5583883896a9
diff --git a/plugins/external_plugin_deps.MODULE.bazel b/plugins/external_plugin_deps.MODULE.bazel new file mode 100644 index 0000000..4c07678 --- /dev/null +++ b/plugins/external_plugin_deps.MODULE.bazel
@@ -0,0 +1,4 @@ +# Module fragment used to wire plugin Bazel modules into the Gerrit +# in-tree build. Plugins that declare external dependencies can expose +# their repositories (e.g. repositories created via rules_jvm_external) +# through this file.
diff --git a/plugins/external_plugin_deps.bzl b/plugins/external_plugin_deps.bzl index 1f7c020..c498979 100644 --- a/plugins/external_plugin_deps.bzl +++ b/plugins/external_plugin_deps.bzl
@@ -1,2 +1,5 @@ +# Deprecation notice: This file is deprecated. Please migrate dependencies to +# MODULE.bazel. + def external_plugin_deps(): pass
diff --git a/plugins/gitiles b/plugins/gitiles index 5ee7f57..c423243 160000 --- a/plugins/gitiles +++ b/plugins/gitiles
@@ -1 +1 @@ -Subproject commit 5ee7f57486a1c1b5121e9979b8496706ae2891e5 +Subproject commit c42324332aa1357bd9dfdf2de14593a36ffbe3e0
diff --git a/plugins/package.json b/plugins/package.json index 422892f..e7fdc0f 100644 --- a/plugins/package.json +++ b/plugins/package.json
@@ -3,41 +3,43 @@ "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these", "browser": true, "dependencies": { - "@codemirror/autocomplete": "^6.18.6", - "@codemirror/commands": "^6.8.1", + "@codemirror/autocomplete": "^6.20.1", + "@codemirror/commands": "^6.10.3", "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", - "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-html": "^6.4.11", "@codemirror/lang-java": "^6.0.2", - "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-jinja": "^6.0.0", "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-less": "^6.0.2", - "@codemirror/lang-markdown": "^6.3.4", + "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-php": "^6.0.2", "@codemirror/lang-python": "^6.2.1", "@codemirror/lang-rust": "^6.0.2", "@codemirror/lang-sass": "^6.0.2", - "@codemirror/lang-sql": "^6.9.1", + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-vue": "^0.1.3", "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/language": "^6.11.3", - "@codemirror/language-data": "^6.5.1", - "@codemirror/legacy-modes": "^6.5.1", - "@codemirror/lint": "^6.8.5", - "@codemirror/search": "^6.5.11", - "@codemirror/state": "^6.5.2", - "@codemirror/view": "^6.38.1", + "@codemirror/language": "^6.12.2", + "@codemirror/language-data": "^6.5.2", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/lint": "^6.9.5", + "@codemirror/search": "^6.6.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.40.0", + "@lezer/highlight": "^1.2.3", "@gerritcodereview/typescript-api": "3.13.0", - "@lezer/highlight": "^1.2.1", - "@material/web": "^2.4.0", + "@material/web": "^2.4.1", "@open-wc/testing": "^4.0.0", "@polymer/decorators": "^3.0.0", "@polymer/polymer": "3.5.1", "@web/dev-server-esbuild": "^1.0.4", "@web/test-runner": "^0.20.2", "lit": "^3.3.1", + "resemblejs": "^5.0.0", "rxjs": "^6.6.7", "sinon": "^20.0.0" }, @@ -50,4 +52,4 @@ }, "license": "Apache-2.0", "private": true -} \ No newline at end of file +}
diff --git a/plugins/plugin-manager b/plugins/plugin-manager index 578aa64..cc03ee8 160000 --- a/plugins/plugin-manager +++ b/plugins/plugin-manager
@@ -1 +1 @@ -Subproject commit 578aa64d05fa982f41c2db72253c9b43c3f0d03e +Subproject commit cc03ee89d361ff92af739ffb83056dfc89e00726
diff --git a/plugins/replication b/plugins/replication index b62454c..8dafd93 160000 --- a/plugins/replication +++ b/plugins/replication
@@ -1 +1 @@ -Subproject commit b62454c285f78f23996864c9f920473f8f3d0f6a +Subproject commit 8dafd933a883bdd15135a382753e1b9ea447daa0
diff --git a/plugins/webhooks b/plugins/webhooks index b00a2c2..13f28d1 160000 --- a/plugins/webhooks +++ b/plugins/webhooks
@@ -1 +1 @@ -Subproject commit b00a2c28eb1412312e03b541b6e2dbeefea0247a +Subproject commit 13f28d1927673b48e75b2eacd248f5d303bbb82f
diff --git a/plugins/yarn.lock b/plugins/yarn.lock index 0a67116..4c5b62b 100644 --- a/plugins/yarn.lock +++ b/plugins/yarn.lock
@@ -16,23 +16,23 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== -"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.18.6", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1": - version "6.18.6" - resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.18.6.tgz#de26e864a1ec8192a1b241eb86addbb612964ddb" - integrity sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg== +"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.20.1", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1": + version "6.20.1" + resolved "https://registry.yarnpkg.com/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz#4cfbc8b2e1e25f890ec34a081037e58b4e44143e" + integrity sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A== dependencies: "@codemirror/language" "^6.0.0" "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.17.0" "@lezer/common" "^1.0.0" -"@codemirror/commands@^6.8.1": - version "6.8.1" - resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.8.1.tgz#639f5559d2f33f2582a2429c58cb0c1b925c7a30" - integrity sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw== +"@codemirror/commands@^6.10.3": + version "6.10.3" + resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.3.tgz#01877060befdec352e8300dec1f185489c300635" + integrity sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q== dependencies: "@codemirror/language" "^6.0.0" - "@codemirror/state" "^6.4.0" + "@codemirror/state" "^6.6.0" "@codemirror/view" "^6.27.0" "@lezer/common" "^1.1.0" @@ -78,10 +78,10 @@ "@lezer/common" "^1.0.0" "@lezer/go" "^1.0.0" -"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.9": - version "6.4.9" - resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.9.tgz#d586f2cc9c341391ae07d1d7c545990dfa069727" - integrity sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q== +"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@^6.4.11": + version "6.4.11" + resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz#c46ba46ae642fd567cf05c4129005d2913ac248d" + integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw== dependencies: "@codemirror/autocomplete" "^6.0.0" "@codemirror/lang-css" "^6.0.0" @@ -91,7 +91,7 @@ "@codemirror/view" "^6.17.0" "@lezer/common" "^1.0.0" "@lezer/css" "^1.1.0" - "@lezer/html" "^1.3.0" + "@lezer/html" "^1.3.12" "@codemirror/lang-java@^6.0.0", "@codemirror/lang-java@^6.0.2": version "6.0.2" @@ -101,10 +101,10 @@ "@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.2.4": - version "6.2.4" - resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz#eef2227d1892aae762f3a0f212f72bec868a02c5" - integrity sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA== +"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@^6.1.2", "@codemirror/lang-javascript@^6.2.5": + version "6.2.5" + resolved "https://registry.yarnpkg.com/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz#b9ea6b2f0383ed6895fae7888c0322541538f10a" + integrity sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A== dependencies: "@codemirror/autocomplete" "^6.0.0" "@codemirror/language" "^6.6.0" @@ -114,6 +114,17 @@ "@lezer/common" "^1.0.0" "@lezer/javascript" "^1.0.0" +"@codemirror/lang-jinja@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@codemirror/lang-jinja/-/lang-jinja-6.0.0.tgz#cc02cd1e45d1fed1226e3c3b44615503f794c904" + integrity sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw== + dependencies: + "@codemirror/lang-html" "^6.0.0" + "@codemirror/language" "^6.0.0" + "@lezer/common" "^1.2.0" + "@lezer/highlight" "^1.2.0" + "@lezer/lr" "^1.4.0" + "@codemirror/lang-json@^6.0.0", "@codemirror/lang-json@^6.0.2": version "6.0.2" resolved "https://registry.yarnpkg.com/@codemirror/lang-json/-/lang-json-6.0.2.tgz#054b160671306667e25d80385286049841836179" @@ -134,9 +145,9 @@ "@lezer/lr" "^1.0.0" "@codemirror/lang-liquid@^6.0.0": - version "6.3.0" - resolved "https://registry.yarnpkg.com/@codemirror/lang-liquid/-/lang-liquid-6.3.0.tgz#f77fdc4b6c5062ba7ce5525f9839d1e9d7a0e356" - integrity sha512-fY1YsUExcieXRTsCiwX/bQ9+PbCTA/Fumv7C7mTUZHoFkibfESnaXwpr2aKH6zZVwysEunsHHkaIpM/pl3xETQ== + version "6.3.2" + resolved "https://registry.yarnpkg.com/@codemirror/lang-liquid/-/lang-liquid-6.3.2.tgz#623bf5776d9069ddae371ac9a1bd1914d70107b8" + integrity sha512-6PDVU3ZnfeYyz1at1E/ttorErZvZFXXt1OPhtfe1EZJ2V2iDFa0CwPqPgG5F7NXN0yONGoBogKmFAafKTqlwIw== dependencies: "@codemirror/autocomplete" "^6.0.0" "@codemirror/lang-html" "^6.0.0" @@ -147,10 +158,10 @@ "@lezer/highlight" "^1.0.0" "@lezer/lr" "^1.3.1" -"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.3.4": - version "6.3.4" - resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.3.4.tgz#c1c021ba65ba21c4e9b1f6d5add809b4c9e02c8d" - integrity sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug== +"@codemirror/lang-markdown@^6.0.0", "@codemirror/lang-markdown@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz#29df87310a555b007beba8e12893363956a26e8e" + integrity sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw== dependencies: "@codemirror/autocomplete" "^6.7.1" "@codemirror/lang-html" "^6.0.0" @@ -201,10 +212,10 @@ "@lezer/common" "^1.0.2" "@lezer/sass" "^1.0.0" -"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.9.1": - version "6.9.1" - resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.9.1.tgz#9eb9f7203afef6f0f344dbaf306c8a2c6279001d" - integrity sha512-ecSk3gm/mlINcURMcvkCZmXgdzPSq8r/yfCtTB4vgqGGIbBC2IJIAy7GqYTy5pgBEooTVmHP2GZK6Z7h63CDGg== +"@codemirror/lang-sql@^6.0.0", "@codemirror/lang-sql@^6.10.0": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz#49bfbf6cf31516a99e674da9a399f4426101a95a" + integrity sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w== dependencies: "@codemirror/autocomplete" "^6.0.0" "@codemirror/language" "^6.0.0" @@ -260,10 +271,10 @@ "@lezer/lr" "^1.0.0" "@lezer/yaml" "^1.0.0" -"@codemirror/language-data@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.5.1.tgz#5cb9413d5225ef27a577c23781bbc0b36c58bb67" - integrity sha512-0sWxeUSNlBr6OmkqybUTImADFUP0M3P0IiSde4nc24bz/6jIYzqYSgkOSLS+CBIoW1vU8Q9KUWXscBXeoMVC9w== +"@codemirror/language-data@^6.5.2": + version "6.5.2" + resolved "https://registry.yarnpkg.com/@codemirror/language-data/-/language-data-6.5.2.tgz#404dc3d50a80d0f76bc8f81f93cc1d82e322417c" + integrity sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg== dependencies: "@codemirror/lang-angular" "^0.1.0" "@codemirror/lang-cpp" "^6.0.0" @@ -272,6 +283,7 @@ "@codemirror/lang-html" "^6.0.0" "@codemirror/lang-java" "^6.0.0" "@codemirror/lang-javascript" "^6.0.0" + "@codemirror/lang-jinja" "^6.0.0" "@codemirror/lang-json" "^6.0.0" "@codemirror/lang-less" "^6.0.0" "@codemirror/lang-liquid" "^6.0.0" @@ -288,56 +300,56 @@ "@codemirror/language" "^6.0.0" "@codemirror/legacy-modes" "^6.4.0" -"@codemirror/language@^6.0.0", "@codemirror/language@^6.11.3", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0": - version "6.11.3" - resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.11.3.tgz#8e6632df566a7ed13a1bd307f9837765bb1abfdd" - integrity sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA== +"@codemirror/language@^6.0.0", "@codemirror/language@^6.12.2", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0": + version "6.12.2" + resolved "https://registry.yarnpkg.com/@codemirror/language/-/language-6.12.2.tgz#7db5a46757411cf251e8f450474c05710c27d42c" + integrity sha512-jEPmz2nGGDxhRTg3lTpzmIyGKxz3Gp3SJES4b0nAuE5SWQoKdT5GoQ69cwMmFd+wvFUhYirtDTr0/DRHpQAyWg== dependencies: "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.23.0" - "@lezer/common" "^1.1.0" + "@lezer/common" "^1.5.0" "@lezer/highlight" "^1.0.0" "@lezer/lr" "^1.0.0" style-mod "^4.0.0" -"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.1.tgz#6bd13fac94f67a825e5420017e0d2f3c35d09342" - integrity sha512-DJYQQ00N1/KdESpZV7jg9hafof/iBNp9h7TYo1SLMk86TWl9uDsVdho2dzd81K+v4retmK6mdC7WpuOQDytQqw== +"@codemirror/legacy-modes@^6.4.0", "@codemirror/legacy-modes@^6.5.2": + version "6.5.2" + resolved "https://registry.yarnpkg.com/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz#7e2976c79007cd3fa9ed8a1d690892184a7f5ecf" + integrity sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q== dependencies: "@codemirror/language" "^6.0.0" -"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.8.5": - version "6.8.5" - resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.8.5.tgz#9edaa808e764e28e07665b015951934c8ec3a418" - integrity sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA== +"@codemirror/lint@^6.0.0", "@codemirror/lint@^6.9.5": + version "6.9.5" + resolved "https://registry.yarnpkg.com/@codemirror/lint/-/lint-6.9.5.tgz#c7da006f3335a33014799a7375c82df558e89f90" + integrity sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA== dependencies: "@codemirror/state" "^6.0.0" "@codemirror/view" "^6.35.0" crelt "^1.0.5" -"@codemirror/search@^6.5.11": - version "6.5.11" - resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.5.11.tgz#a324ffee36e032b7f67aa31c4fb9f3e6f9f3ed63" - integrity sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA== +"@codemirror/search@^6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@codemirror/search/-/search-6.6.0.tgz#3b83a1e35391e1575a83a3b485e3f95263ddaa0b" + integrity sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw== dependencies: "@codemirror/state" "^6.0.0" - "@codemirror/view" "^6.0.0" + "@codemirror/view" "^6.37.0" crelt "^1.0.5" -"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0", "@codemirror/state@^6.5.2": - version "6.5.2" - resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.5.2.tgz#8eca3a64212a83367dc85475b7d78d5c9b7076c6" - integrity sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA== +"@codemirror/state@^6.0.0", "@codemirror/state@^6.6.0": + version "6.6.0" + resolved "https://registry.yarnpkg.com/@codemirror/state/-/state-6.6.0.tgz#b88dbdc14aea4ace3c6d67bb77fe28bb84e4394e" + integrity sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ== dependencies: "@marijn/find-cluster-break" "^1.0.0" -"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.38.1": - version "6.38.1" - resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.38.1.tgz#74214434351719ec0710431363a85f7a01e80a73" - integrity sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ== +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@^6.37.0", "@codemirror/view@^6.40.0": + version "6.40.0" + resolved "https://registry.yarnpkg.com/@codemirror/view/-/view-6.40.0.tgz#97198fd717ebf471ef594a5bd557a9f2d1d4d165" + integrity sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg== dependencies: - "@codemirror/state" "^6.5.0" + "@codemirror/state" "^6.6.0" crelt "^1.0.6" style-mod "^4.1.0" w3c-keyname "^2.2.4" @@ -507,24 +519,24 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.1": - version "1.2.3" - resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.2.3.tgz#138fcddab157d83da557554851017c6c1e5667fd" - integrity sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA== +"@lezer/common@^1.0.0", "@lezer/common@^1.0.2", "@lezer/common@^1.1.0", "@lezer/common@^1.2.0", "@lezer/common@^1.2.1", "@lezer/common@^1.3.0", "@lezer/common@^1.5.0": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@lezer/common/-/common-1.5.1.tgz#6e8c114ff5d36a41148e146a253734d3bb8807d3" + integrity sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw== "@lezer/cpp@^1.0.0": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.3.tgz#3029a542f4624fba0ed28f96511b34b8e7906352" - integrity sha512-ykYvuFQKGsRi6IcE+/hCSGUhb/I4WPjd3ELhEblm2wS2cOznDFzO+ubK2c+ioysOnlZ3EduV+MVQFCPzAIoY3w== + version "1.1.5" + resolved "https://registry.yarnpkg.com/@lezer/cpp/-/cpp-1.1.5.tgz#de5b0352b4e0825b5cb62334f6a69f8ddc6ec734" + integrity sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.0.0" "@lezer/lr" "^1.0.0" "@lezer/css@^1.1.0", "@lezer/css@^1.1.7": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.0.tgz#296f298814782c2fad42a936f3510042cdcd2034" - integrity sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw== + version "1.3.1" + resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.1.tgz#583e0119768021c58a731d38e56a91c700b57e14" + integrity sha512-PYAKeUVBo3HFThruRyp/iK91SwiZJnzXh8QzkQlwijB5y+N5iB28+iLk78o2zmKqqV0uolNhCwFqB8LA7b0Svg== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.0.0" @@ -539,17 +551,17 @@ "@lezer/highlight" "^1.0.0" "@lezer/lr" "^1.3.0" -"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3", "@lezer/highlight@^1.2.0", "@lezer/highlight@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.1.tgz#596fa8f9aeb58a608be0a563e960c373cbf23f8b" - integrity sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA== +"@lezer/highlight@^1.0.0", "@lezer/highlight@^1.1.3", "@lezer/highlight@^1.2.0", "@lezer/highlight@^1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@lezer/highlight/-/highlight-1.2.3.tgz#a20f324b71148a2ea9ba6ff42e58bbfaec702857" + integrity sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g== dependencies: - "@lezer/common" "^1.0.0" + "@lezer/common" "^1.3.0" -"@lezer/html@^1.3.0": - version "1.3.10" - resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.10.tgz#1be9a029a6fe835c823b20a98a449a630416b2af" - integrity sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w== +"@lezer/html@^1.3.12": + version "1.3.13" + resolved "https://registry.yarnpkg.com/@lezer/html/-/html-1.3.13.tgz#6a1305ae3bd2c9c01f877f8a8dc1e15ec652d01c" + integrity sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.0.0" @@ -565,9 +577,9 @@ "@lezer/lr" "^1.0.0" "@lezer/javascript@^1.0.0": - version "1.5.1" - resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.5.1.tgz#2a424a6ec29f1d4ef3c34cbccc5447e373618ad8" - integrity sha512-ATOImjeVJuvgm3JQ/bpo2Tmv55HSScE2MTPnKRMRIPx2cLhHGyX2VnqpHhtIV1tVzIjZDbcWQm+NCTF40ggZVw== + version "1.5.4" + resolved "https://registry.yarnpkg.com/@lezer/javascript/-/javascript-1.5.4.tgz#11746955f957d33c0933f17d7594db54a8b4beea" + integrity sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.1.3" @@ -583,24 +595,24 @@ "@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", "@lezer/lr@^1.3.3", "@lezer/lr@^1.4.0": - version "1.4.2" - resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.2.tgz#931ea3dea8e9de84e90781001dae30dea9ff1727" - integrity sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA== + version "1.4.8" + resolved "https://registry.yarnpkg.com/@lezer/lr/-/lr-1.4.8.tgz#333de9bc9346057323ff09beb4cda47ccc38a498" + integrity sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA== dependencies: "@lezer/common" "^1.0.0" "@lezer/markdown@^1.0.0": - version "1.4.3" - resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.4.3.tgz#a742ed5e782ac4913a621dfd1e6a8e409f4dd589" - integrity sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg== + version "1.6.3" + resolved "https://registry.yarnpkg.com/@lezer/markdown/-/markdown-1.6.3.tgz#04beb444f656c2319ddf23554b1e4b0edf536071" + integrity sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw== dependencies: - "@lezer/common" "^1.0.0" + "@lezer/common" "^1.5.0" "@lezer/highlight" "^1.0.0" "@lezer/php@^1.0.0": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@lezer/php/-/php-1.0.4.tgz#b759a4ac71ca318aa7855971bdb65082b9d69e65" - integrity sha512-D2dJ0t8Z28/G1guztRczMFvPDUqzeMLSQbdWQmaiHV7urc8NlEOnjYk9UrZ531OcLiRxD4Ihcbv7AsDpNKDRaQ== + version "1.0.5" + resolved "https://registry.yarnpkg.com/@lezer/php/-/php-1.0.5.tgz#4e6b79daa97b98f0ba300f592e6916661339e661" + integrity sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.0.0" @@ -643,9 +655,9 @@ "@lezer/lr" "^1.0.0" "@lezer/yaml@^1.0.0": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@lezer/yaml/-/yaml-1.0.3.tgz#b23770ab42b390056da6b187d861b998fd60b1ff" - integrity sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA== + version "1.0.4" + resolved "https://registry.yarnpkg.com/@lezer/yaml/-/yaml-1.0.4.tgz#66a622188f1984a71d34506759b5807699043589" + integrity sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.0.0" @@ -663,15 +675,30 @@ dependencies: "@lit-labs/ssr-dom-shim" "^1.4.0" +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" + integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@marijn/find-cluster-break@^1.0.0": version "1.0.2" resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== -"@material/web@^2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@material/web/-/web-2.4.0.tgz#4158df5afa2a36f55db5a5b2d77146cf6bdc84a6" - integrity sha512-2jYiPIYOuP2UcWXal4VdKRlkpBX02U3rgXMWp0yFQbJaOmK8MOIA3BsyuooM3VOYI+VCO70BeH1x43y5cBLvZQ== +"@material/web@^2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@material/web/-/web-2.4.1.tgz#afd629ba350cf9485c3e19a7bfeb0476f02a0ec1" + integrity sha512-0sk9t25acJ72Qv3r0n9r0lgDbPaAKnpm0p+QmEAAwYyZomHxuVbgrrAdtNXaRm7jFyGh+WsTr8bhtvCnpPRFjw== dependencies: lit "^2.8.0 || ^3.0.0" tslib "^2.4.0" @@ -1377,6 +1404,11 @@ resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.2.tgz#7539b0ad29598aa2eafee8b341059e20ac9e1006" integrity sha512-vRq+GniJAYSBmTRnhCYPAPq6THYqovJ/gzGThWbgEZUQaBccndGTi1hdiUP15HzEco0I6t4RCtXyX0rsSmwgPw== +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + accepts@^1.3.5: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -1385,6 +1417,13 @@ 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" + agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" @@ -1409,6 +1448,19 @@ dependencies: color-convert "^2.0.1" +"aproba@^1.0.3 || ^2.0.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.1.0.tgz#75500a190313d95c64e871e7e4284c6ac219f0b1" + integrity sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew== + +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + 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" @@ -1451,6 +1503,11 @@ resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.7.tgz#a99587d4ebbfbd5a6e3b21bdb5d5fa385767abe4" integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== +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== + bare-events@^2.2.0, bare-events@^2.5.4: version "2.6.1" resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.6.1.tgz#f793b28bdc3dcf147d7cf01f882a6f0b12ccc4a2" @@ -1489,6 +1546,14 @@ resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0" integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg== +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + braces@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" @@ -1535,6 +1600,15 @@ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== +canvas@2.11.2: + version "2.11.2" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860" + integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + nan "^2.17.0" + simple-get "^3.0.3" + chai-a11y-axe@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/chai-a11y-axe/-/chai-a11y-axe-1.5.0.tgz#aafa37f91f53baeafe98219768e5dee8776cf655" @@ -1564,6 +1638,11 @@ dependencies: readdirp "^4.0.1" +chownr@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" + integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + chrome-launcher@^0.15.0: version "0.15.2" resolved "https://registry.yarnpkg.com/chrome-launcher/-/chrome-launcher-0.15.2.tgz#4e6404e32200095fdce7f6a1e1004f9bd36fa5da" @@ -1631,6 +1710,11 @@ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-support@^1.1.2: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + 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" @@ -1651,6 +1735,16 @@ table-layout "^4.1.0" typical "^7.1.1" +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== + +console-control-strings@^1.0.0, console-control-strings@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== + content-disposition@~0.5.2: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -1721,6 +1815,13 @@ dependencies: ms "^2.1.1" +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -1777,6 +1878,11 @@ resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +detect-libc@^2.0.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + devtools-protocol@0.0.1475386: version "0.0.1475386" resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz#5378401a2c5698ab68c3482c9b7816ff62ec652b" @@ -2015,6 +2121,18 @@ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fs-minipass@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" + integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== + dependencies: + minipass "^3.0.0" + +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.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -2025,6 +2143,21 @@ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -2082,6 +2215,18 @@ 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" @@ -2116,6 +2261,11 @@ dependencies: has-symbols "^1.0.3" +has-unicode@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" + integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== + hasown@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" @@ -2176,6 +2326,14 @@ agent-base "^7.1.0" debug "^4.3.4" +https-proxy-agent@^5.0.0: + 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" + https-proxy-agent@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" @@ -2206,16 +2364,24 @@ resolved "https://registry.yarnpkg.com/inflation/-/inflation-2.1.0.tgz#9214db11a47e6f756d111c4f9df96971c60f886c" integrity sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ== +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, inherits@2.0.4, inherits@^2.0.3: + 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== -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== - internal-ip@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-6.2.0.tgz#d5541e79716e406b74ac6b07b856ef18dc1621c1" @@ -2487,6 +2653,13 @@ resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-8.0.5.tgz#983fe337f3e176667f8e567cfcce7cb064ea214e" integrity sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA== +make-dir@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" + integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + dependencies: + semver "^6.0.0" + make-dir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" @@ -2544,12 +2717,44 @@ resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + +minimatch@^3.1.1: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + dependencies: + brace-expansion "^1.1.7" + +minipass@^3.0.0: + version "3.3.6" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" + integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== + dependencies: + yallist "^4.0.0" + +minipass@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" + integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== + +minizlib@^2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" + integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== + dependencies: + minipass "^3.0.0" + yallist "^4.0.0" + mitt@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== -mkdirp@^1.0.4: +mkdirp@^1.0.3, mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== @@ -2564,6 +2769,11 @@ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +nan@^2.17.0: + version "2.26.2" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.26.2.tgz#2e5e25764224c737b9897790b57c3294d4dcee9c" + integrity sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw== + nanocolors@^0.2.1: version "0.2.13" resolved "https://registry.yarnpkg.com/nanocolors/-/nanocolors-0.2.13.tgz#dfd1ed0bfab05e9fe540eb6874525f0a1684099b" @@ -2584,6 +2794,20 @@ resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -2591,6 +2815,21 @@ dependencies: path-key "^3.0.0" +npmlog@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + object-inspect@^1.13.3: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" @@ -2603,7 +2842,7 @@ dependencies: ee-first "1.1.1" -once@^1.3.1, once@^1.4.0: +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== @@ -2682,7 +2921,7 @@ resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -path-is-absolute@1.0.1: +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== @@ -2801,6 +3040,15 @@ iconv-lite "0.4.24" unpipe "1.0.0" +readable-stream@^3.6.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" @@ -2811,6 +3059,13 @@ resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +resemblejs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resemblejs/-/resemblejs-5.0.0.tgz#f5a0c6aaa59dcfb9f5192e7ab8740616cbbbf220" + integrity sha512-+B0eP9k9VDP/YhBbH+ZdYmHiotdtuc6blVI+h8wwkY2cOow+uiIpSmgkBBBtrEAL0D31/gR/AJPwDeX5TcwmIA== + optionalDependencies: + canvas "2.11.2" + resolve-path@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/resolve-path/-/resolve-path-1.4.0.tgz#c4bda9f5efb2fce65247873ab36bb4d834fe16f7" @@ -2841,6 +3096,13 @@ resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== +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@^4.4.0: version "4.49.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.49.0.tgz#9751ad9d06a47a4496c3c5c238b27b1422c8b0eb" @@ -2884,7 +3146,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== @@ -2903,11 +3165,26 @@ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +semver@^6.0.0: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.3.5: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + semver@^7.5.3, semver@^7.7.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" @@ -2970,11 +3247,25 @@ side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.2, signal-exit@^3.0.3: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55" + integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + sinon@^20.0.0: version "20.0.0" resolved "https://registry.yarnpkg.com/sinon/-/sinon-20.0.0.tgz#4b653468735f7152ba694d05498c2b5d024ab006" @@ -3052,7 +3343,7 @@ optionalDependencies: bare-events "^2.2.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3061,6 +3352,13 @@ 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" @@ -3074,9 +3372,9 @@ integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== style-mod@^4.0.0, style-mod@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.2.tgz#ca238a1ad4786520f7515a8539d5a63691d7bf67" - integrity sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw== + version "4.1.3" + resolved "https://registry.yarnpkg.com/style-mod/-/style-mod-4.1.3.tgz#6e9012255bb799bdac37e288f7671b5d71bf9f73" + integrity sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ== supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" @@ -3118,6 +3416,18 @@ fast-fifo "^1.2.0" streamx "^2.15.0" +tar@^6.1.11: + version "6.2.1" + resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" + integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== + dependencies: + chownr "^2.0.0" + fs-minipass "^2.0.0" + minipass "^5.0.0" + minizlib "^2.1.1" + mkdirp "^1.0.3" + yallist "^4.0.0" + text-decoder@^1.1.0: version "1.2.3" resolved "https://registry.yarnpkg.com/text-decoder/-/text-decoder-1.2.3.tgz#b19da364d981b2326d5f43099c310cc80d770c65" @@ -3144,6 +3454,11 @@ dependencies: punycode "^2.3.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" @@ -3212,6 +3527,11 @@ 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@^9.0.1: version "9.3.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" @@ -3231,6 +3551,11 @@ resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== +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" @@ -3244,6 +3569,14 @@ tr46 "^5.1.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" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3251,6 +3584,13 @@ dependencies: isexe "^2.0.0" +wide-align@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + wordwrapjs@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-5.1.0.tgz#4c4d20446dcc670b14fa115ef4f8fd9947af2b3a" @@ -3294,6 +3634,11 @@ resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD index 3c4270b..a42e2dd 100644 --- a/polygerrit-ui/BUILD +++ b/polygerrit-ui/BUILD
@@ -1,5 +1,4 @@ load("//tools/bzl:genrule2.bzl", "genrule2") -load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test") load("//tools/bzl:js.bzl", "web_test_runner") package(default_visibility = ["//visibility:public"])
diff --git a/polygerrit-ui/FE_Style_Guide.md b/polygerrit-ui/FE_Style_Guide.md index d2b865b..8d3bcf3 100644 --- a/polygerrit-ui/FE_Style_Guide.md +++ b/polygerrit-ui/FE_Style_Guide.md
@@ -14,7 +14,6 @@ - [Use destructuring imports only](#destructuring-imports-only) - [Use classes and services for storing and manipulating global state](#services-for-global-state) - [Pass required services in the constructor for plain classes](#pass-dependencies-in-constructor) -- [Assign required services in a HTML/Polymer element constructor](#assign-dependencies-in-html-element-constructor) ## <a name="prefer-undefined"></a>Prefer `undefined` over `null` @@ -25,6 +24,7 @@ then try to convert return values and leak as few `nulls` as possible. ## <a name="destructuring-imports-only"></a>Use destructuring imports only + Always use destructuring import statement and specify all required names explicitly (e.g. `import {a,b,c} from '...'`) where possible. @@ -39,6 +39,7 @@ [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import). **Good:** + ```Javascript // Import from the module in the same project. import {getDisplayName, getAccount} from './user-utils.js' @@ -49,6 +50,7 @@ ``` **Bad:** + ```Javascript import * as userUtils from './user-utils.js' ``` @@ -61,25 +63,26 @@ It is not easy to define precise what can be a shared global state and what is not. Below are some examples of what can treated as a shared global state: -* Information about enabled experiments -* Information about current user -* Information about current change +- Information about enabled experiments +- Information about current user +- Information about current change **Note:** Service name must ends with a `Service` suffix. To share global state across modules in the project, do the following: + - put the state in a class - add a new service to the -[appContext](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context.js) + [appContext](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context.js) - add a service initialization code to the -[services/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context-init.js) file. + [services/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/services/app-context-init.js) file. - add a service or service-mock initialization code to the -[embed/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/embed/app-context-init.js) file. + [embed/app-context-init.js](https://gerrit.googlesource.com/gerrit/+/refs/heads/master/polygerrit-ui/app/embed/app-context-init.js) file. - recommended: add a separate service-mock for testing. Do not use the same mock for testing and for -the shared gr-diff (i.e. in the `services/app-context-init.js`). Even if the mocks are simple and looks -identically, keep them separate. It allows to change them independently in the future. + the shared gr-diff (i.e. in the `services/app-context-init.js`). Even if the mocks are simple and looks + identically, keep them separate. It allows to change them independently in the future. Also see the example below if a service depends on another services. @@ -91,6 +94,7 @@ review/update rules regarding the shared gr-diff element. **Good:** + ```Javascript export class CounterService { constructor() { @@ -106,6 +110,7 @@ ``` **Bad:** + ```Javascript // module counter.js // Incorrect: shared state declared at the top level of the counter.js module @@ -126,6 +131,7 @@ Do not use getAppContext() anywhere else in a class. **Good:** + ```Javascript export class UserService { constructor(restApiService) { @@ -138,6 +144,7 @@ ``` **Bad:** + ```Javascript import {getAppContext} from "./app-context";
diff --git a/polygerrit-ui/GEMINI.md b/polygerrit-ui/GEMINI.md index 6259e9f..7478a77 100644 --- a/polygerrit-ui/GEMINI.md +++ b/polygerrit-ui/GEMINI.md
@@ -14,13 +14,25 @@ - **Browser Testing**: `playwright` - **Package Manager**: `yarn` +## Linting + +The project uses `eslint` for linting. You can run the linter using the following commands from the root `gerrit` directory: + +- `npm run eslint`: Run linter +- `npm run eslintfix:modified`: Fix lint errors in modified files +- `npm run eslintfix`: Fix lint errors in all files + +**Note**: Imports should NOT have spaces around braces (e.g., `import {css} from 'lit';`, not `import { css } from 'lit';`). +**Note**: Do not use `_` prefix for private properties or variables. +**Note**: Use `// @ts-expect-error` instead of casting to `any` when suppressing TypeScript errors. + ## Key Commands The following commands should be run from the project root directory. ### Checking for TypeScript Errors -To check for TypeScript errors without running the full test suite, use the `compile` script: +To check for TypeScript errors without running the full test suite, use the `compile` script (must be run from the **project root**, not `polygerrit-ui`): ```bash yarn compile @@ -30,6 +42,7 @@ - `yarn test`: Run all tests. - `yarn test:screenshot`: Run visual regression tests. +- `yarn test:screenshot-update`: Run visual regression tests and update baseline images. ## Running Single Tests @@ -50,6 +63,21 @@ yarn test:single:nowatch "**/gr-user-suggestion_test.ts" ``` +## Common Test Utilities + +The `polygerrit-ui/app/test/test-utils.ts` file exports several useful utilities for testing. + +### Stubbing Feature Flags + +To stub feature flags in tests, use the `stubFlags` utility. + +```typescript +import {stubFlags} from '../../test/test-utils'; + +// ... inside your test ... +stubFlags('isEnabled').returns(true); +``` + ## UI Elements Here is a list of the UI elements found in `polygerrit-ui/app/elements` with a brief description of their purpose. @@ -172,7 +200,6 @@ - `gr-editor-view`: The main view for an editor. - `gr-app-element`: The main application element. - `gr-app`: The main application component. -- `gr-css-mixins`: CSS mixins. - `fit-controller`: A controller for fitting an element. - `incremental-repeat`: A directive for incrementally repeating an element. - `shortcut-controller`: A controller for shortcuts.
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md index ac51959..8924d67 100644 --- a/polygerrit-ui/README.md +++ b/polygerrit-ui/README.md
@@ -1,4 +1,4 @@ -# Gerrit Polymer Frontend +# Gerrit Lit Frontend Follow the [setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html) @@ -129,7 +129,7 @@ For running a locally built Gerrit war against your test instance use [this command](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#run_daemon). -If you want to serve the Polymer frontend directly from the sources in `polygerrit_ui/app/` instead of from the war: +If you want to serve the Lit frontend directly from the sources in `polygerrit_ui/app/` instead of from the war: 1. Start [Web Dev Server](#web-dev-server) 2. Add the `--dev-cdn` option: @@ -233,6 +233,12 @@ yarn eslint ``` +* To run ESLint and apply changes on the whole app: + +```sh +yarn eslintfix +``` + * To run ESLint on just the subdirectory you modified: ```sh @@ -364,14 +370,16 @@ variable: ``` // Before: -sinon.stub(GerritNav, 'getUrlForChange') +const navService = testResolver(navigationToken); +sinon.stub(navService, 'setUrl'); ... -assert.equal(GerritNav.getUrlForChange.lastCall.args[4], '#message-a12345'); +assert.equal(navService.setUrl.lastCall.firstArg, '/c/123'); // After: -const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange'); +const navService = testResolver(navigationToken); +const setUrlStub = sinon.stub(navService, 'setUrl'); ... -assert.equal(getUrlStub.lastCall.args[4], '#message-a12345'); +assert.equal(setUrlStub.lastCall.firstArg, '/c/123'); ``` If you need to define a type for such variable, you can use one of the following @@ -383,7 +391,7 @@ // Non static members, option 2 let updateHeightSpy_prototype: SinonSpyMember<typeof GrChangeView.prototype._updateRelatedChangeMaxHeight>; // Static members - let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>; + let setUrlStub: SinonStubbedMember<NavigationService['setUrl']>; // For interfaces let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>; }); @@ -447,21 +455,3 @@ If you are willing to join the queue and help the community review changes, you can create an issue through Monorail and request to join the queue! We will review your request and start from there. - -## Troubleshotting & Frequently asked questions - -1. Local host is blank page and console shows missing files from `polymer-bridges` - -Its likely you missed the `polymer-bridges` submodule when you clone the `gerrit` repo. - -To fix that, run: -``` -// fetch the submodule -git submodule update --init --recursive - -// reset the workspace (please save your local changes before running this command) -yarn clean - -// install all dependencies and start the server -npm start -```
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD index d94c10c..4d55ddc4 100644 --- a/polygerrit-ui/app/BUILD +++ b/polygerrit-ui/app/BUILD
@@ -1,7 +1,7 @@ -load(":rules.bzl", "polygerrit_bundle") -load("//tools/js:eslint.bzl", "eslint") load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_test") load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("//tools/js:eslint.bzl", "eslint") +load(":rules.bzl", "polygerrit_bundle") package(default_visibility = ["//visibility:public"]) @@ -12,7 +12,6 @@ "constants", "elements", "embed", - "gr-diff", "mixins", "models", "scripts", @@ -38,6 +37,7 @@ ".js", ".ts", ]], + allow_empty = True, exclude = [ "**/*_test.js", "**/*_test.ts", @@ -120,6 +120,7 @@ [ "**/*.html", ], + allow_empty = True, exclude = [ "node_modules/**", "node_modules_licenses/**",
diff --git a/polygerrit-ui/app/api/ai-code-review.ts b/polygerrit-ui/app/api/ai-code-review.ts new file mode 100644 index 0000000..eeac166 --- /dev/null +++ b/polygerrit-ui/app/api/ai-code-review.ts
@@ -0,0 +1,353 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {ChangeInfo, CommentInfo, FileInfoStatus} from './rest-api'; + +export declare interface AiCodeReviewPluginApi { + /** + * Must only be called once. You cannot register twice (throws an error). + * You cannot unregister. + */ + register(provider: AiCodeReviewProvider): void; +} + +/** + * Enum for AI chat Action types. + */ +export enum ActionEnum { + ACTION_UNSPECIFIED = 0, + ACTION_FREE_CHAT = 1, + ACTION_EXPLAIN_CODE = 2, + ACTION_IMPROVE_CODE = 3, + // 4 and 5 are deprecated. + + // ACTION_CUSTOM indicates that the user selected a custom action. In which + // case the action is determined by a `custom_action_id` that is set in the + // request. + ACTION_CUSTOM = 6, + ACTION_EXPLAIN_BUG = 7, + ACTION_SUMMARIZE = 8, + ACTION_ISSUE_FINDING = 9, + ACTION_HELP_REVIEW = 10, + ACTION_CL_GOAL = 11, +} + +/** + * The metadata source for a custom action. + */ +export declare interface MetadataSource { + cl_number?: number; +} + +/** + * The source of a custom action. + */ +export declare interface CustomActionSource { + custom_action_id: string; + metadata_source?: MetadataSource; +} + +export declare interface Action { + action_type?: ActionEnum; + id: string; + display_text: string; + hover_text?: string; + // The subtext for this action. This is displayed below the label. + subtext?: string; + icon?: string; + // Whether to show the splash page card for this action. + enable_splash_page_card?: boolean; + // Whether to send the request without user input. + enable_send_without_input?: boolean; + // The prompt that is fired by this action. + initial_user_prompt?: string; + // The links to the context items that are implicitly added. + context_item_links?: string[]; + matched_files?: string[]; + group_id?: string; + group_display_text?: string; + external_contexts?: ContextItem[]; + custom_action_source?: CustomActionSource; +} + +export declare interface ChatRequest { + /** The predefined action the user selected in the chat. */ + action: Action; + /** + * The prompt to be sent to the LLM. + */ + prompt: string; + /** + * UUID of the conversation. To start a new conversation, the caller should + * generate a new UUID. + * To continue an existing conversation, the caller should provide the UUID of + * the conversation. + */ + conversation_id: string; + /** + * Plugins can choose what context they want to derive from the change and + * send along to their backends. `changeInfo` contains broadly all the + * information about the change, and the plugin can also make additional + * requests to the REST API (e.g. getting patch content) by using properties + * from the change info. + */ + change: ChangeInfo; + /** + * The list of files in the change is vital information that is missing from + * `changeInfo`. So we are passing this along, as well. + */ + files: {path: string; status: FileInfoStatus}[]; + /** + * The 0-based turn index of the request. The caller should set it based on + * the history of the conversation. It should be one more than the last known + * turn. For new conversations, it should be 0. + */ + turn_index: number; + /** + * The 0-based index of the turn regeneration. 0 - original turn, and it is + * incremented by FE every time the user clicks on the regenerate button. + */ + regeneration_index: number; + /** + * A payload containing FE-specific data that is used to restore the chat + * history in the UI. The BE should not use the data, only store it and + * return it as part of GetConversationResponse. + * This is simply encoded/decoded by the chat-model using JSON.stringify() + * and JSON.parse(). + */ + client_data: string; + /** + * The name of the model to use. If not set, the default model will + * be used. If invalid, an error will be returned. + */ + model_name?: string; + /** + * The external contexts that should be used in the request. + */ + external_contexts: ContextItem[]; +} + +/** + * The chat response may come in as a stream, so instead of just one response + * object this listener will get multiple calls until the response is completed. + */ +export declare interface ChatResponseListener { + /** + * Emits one piece of a streaming response from the backend. All responses + * must be merged into one response object by the listener, but every + * intermediate state can be shown to the user. + */ + emitResponse(response: ChatResponse): void; + /** + * Emits an error message, indicating that the turn has failed. Will be + * immediately followed by a done() call. + */ + emitError(error: string): void; + /** + * The turn is completed. All response parts have been emitted. The listener + * can be discarded. + */ + done(): void; +} + +export declare interface ChatResponse { + response_parts: ChatResponsePart[]; + /** + * References that were used to generate the response. Corresponds to tool + * usage calls by the model. + */ + references: Reference[]; + /** The timestamp when the request was processed */ + timestamp_millis?: number; + /** + * The citations that were used to generate the response. Citations are + * passages that are "recited" from potentially copyrighted material. + */ + citations: string[]; +} + +export declare interface ChatResponsePart { + /** The unique ID of the response part within the turn */ + id: number; + /** A text part of the response, to be rendered as markdown */ + text?: string; + /** A suggested comment that can be shown to the user */ + create_comment_action?: CreateCommentAction; + /** A text that can be copied to the clipboard */ + copyable_text?: CopyableText; +} + +export declare interface CreateCommentAction extends Partial<CommentInfo> { + comment_text: string; + patchset: number; +} + +export declare interface CopyableText { + text?: string; + copyable_text?: string; +} + +/** A reference that was used by Gemini to generate a response. */ +export declare interface Reference { + /** May match the type id of ContextItemType. */ + type: string; + displayText: string; + secondaryText?: string; + externalUrl: string; + errorMsg?: string; + tooltip?: string; +} + +export declare interface Conversation { + /** UUID of the conversation */ + id: string; + /** Title of the conversation */ + title: string; + /** Timestamp of the last turn in the conversation */ + timestamp_millis: number; +} + +export declare interface ConversationTurn { + user_input: UserInput; + response?: ChatResponse; + regeneration_index?: number; + timestamp_millis?: number; + // TODO: Clean this up - when loadConversation is used we get chat_response instead of response + chat_response?: ChatResponse; +} + +/** + * The data sent by the client to the backend. It is stored in the database and + * returned in the response. It is used to restore the history of the + * conversation in the UI. The message should contain all data required to make + * a turn. + */ +export declare interface UserInput { + /** The text the user typed in the chat. Can be empty for some actions. */ + user_question?: string; + /** + * The data required by the UI to restore the history of the conversation. + * The server only stores the data and doesn't care about the content. + * This is simply encoded/decoded by the chat-model using JSON.stringify() + * and JSON.parse(). + */ + client_data?: string; +} + +export declare interface Models { + /** + * The models available to the user. Should be displayed in the UI in the + * order of appearance in this list. + */ + models: ModelInfo[]; + /** + * The default model to use when the user hasn't selected any model yet or + * when the selected model is not available anymore. + */ + default_model_id: string; + + custom_actions?: Action[]; + + documentation_url?: string; + + citation_url?: string; + + privacy_url?: string; +} + +export declare interface Actions { + /** + * The actions available to the user. Should be displayed in the UI in the + * order of appearance in this list. + */ + actions: Action[]; + /** + * The default action to use when the user hasn't made an explicit choice. + */ + default_action_id: string; +} + +export declare interface ModelInfo { + /** The model id which is used to identify the model. */ + model_id: string; + /** The short text to be displayed in the UI. */ + short_text: string; + /** The full text to be displayed in the UI. */ + full_display_text: string; +} + +export declare interface ContextItemType { + id: string; + name: string; + icon: string; + /** + * The regex to match the context item type. Will be applied to input + * strings, but can also be used to find context items in longer texts such as + * the user prompt. + */ + regex: RegExp; + /** + * The placeholder text to be displayed in input fields. Tells the user what + * kind of input is expected and can be parsed. + */ + placeholder: string; + /** Parses the input string into a context item of this type. */ + parse(input: string): ContextItem | undefined; +} + +export declare interface ContextItem { + /** + * The type of the context item, e.g. 'gerrit' or 'buganizer'. + * Corresponds to the 'type' of a GerritReference provided by the plugin. + */ + type_id: string; + link: string; + title: string; + identifier?: string; + tooltip?: string; + error_message?: string; +} + +export declare interface AiCodeReviewProvider { + /** + * If a AiCodeReviewProvider provider is registered that implements this + * method, then Gerrit will offer a side panel for the user to have an AI + * Chat conversation. Each chat() call is one turn of such a conversation. + */ + chat?(req: ChatRequest, listener: ChatResponseListener): void; + + /** + * List all chat conversations for the current user and the given change. + */ + listChatConversations?(change: ChangeInfo): Promise<Conversation[]>; + + /** + * Retrieve the details of a single conversation. + */ + getChatConversation?( + change: ChangeInfo, + conversation_id: string + ): Promise<ConversationTurn[]>; + + /** + * Get available models for the given change. + */ + getModels?(change: ChangeInfo): Promise<Models>; + + /** + * Get available actions for the given change. + */ + getActions?(change: ChangeInfo): Promise<Actions>; + + /** + * Get the list of context item types that the provider supports. + */ + getContextItemTypes?(): Promise<ContextItemType[]>; + + supports_add_context?: boolean; + supports_history?: boolean; + supports_more_menu?: boolean; + supports_this_change?: boolean; +}
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts deleted file mode 100644 index 56d25d4..0000000 --- a/polygerrit-ui/app/api/attribute-helper.ts +++ /dev/null
@@ -1,28 +0,0 @@ -/** - * @license - * Copyright 2020 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -export declare interface AttributeHelperPluginApi { - /** - * Binds callback to property updates. - * - * @param name Property name. - * @return Unbind function. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - bind(name: string, callback: (value: any) => void): () => any; - - /** - * Get value of the property from wrapped object. Waits for the property - * to be initialized if it isn't defined. - */ - get(name: string): Promise<unknown>; - - /** - * Sets value and dispatches event to force notify. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - set(name: string, value: any): void; -}
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts index 721df03..e8558c2 100644 --- a/polygerrit-ui/app/api/change-actions.ts +++ b/polygerrit-ui/app/api/change-actions.ts
@@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import {HttpMethod} from './rest'; +import {ChangeInfo} from './rest-api'; export declare interface ActionInfo { method?: HttpMethod; @@ -17,12 +18,26 @@ REVISION = 'revision', } +// This is used for sorting the actions, BUT: +// * For showing up as a dedicated button the action must not be hidden and not +// be an overflow action. See setActionOverflow() and setActionHidden(). +// * All primary actions are shown left of all secondary actions. By default +// the primary actions are: "Submit" and "Mark as active". +// +// Also note that a LOWER value means HIGHER priority! export enum ActionPriority { - CHANGE = 2, + CHANGE = 3, + // Only "Submit" and "Code-Review" buttons should show before "Chat". + CHAT = 1, DEFAULT = 0, - PRIMARY = 3, + // This is a bit confusing, because this is the LOWEST priority in the list. + // But it does not matter much, because the `primary` property is evaluated + // first, and then the `priority` does not matter anymore. + PRIMARY = 4, + // This means that the "Code-Review" voting button is the left most button, + // if there are no primary actions. REVIEW = -3, - REVISION = 1, + REVISION = 2, } export enum ChangeActions { @@ -101,4 +116,14 @@ setIcon(key: string, icon: string): void; getActionDetails(action: string): ActionInfo | undefined; + + /** Notify BEFORE_CHANGE_ACTION event handlers. + * + * If a plugin replaces any default change actions (e.g., the quick + * approve action), it should call this method so that any event + * handlers for that action still trigger. + * + * The returned value is true if the action should proceed. + */ + notifyBeforeChangeAction(key: string, change?: ChangeInfo): Promise<boolean>; }
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts index d90a302..41b881d 100644 --- a/polygerrit-ui/app/api/checks.ts +++ b/polygerrit-ui/app/api/checks.ts
@@ -254,8 +254,9 @@ tooltip?: string; /** * Primary actions will get a more prominent treatment in the UI. For example - * primary actions might be rendered as buttons versus just menu entries in - * an overflow menu. + * top-level primary actions may appear as buttons instead of menu items in + * an overflow menu and primary actions associated with a CheckRun may render + * next to the run name. */ primary?: boolean; /**
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts index e5d0af7..d881426 100644 --- a/polygerrit-ui/app/api/diff.ts +++ b/polygerrit-ui/app/api/diff.ts
@@ -122,6 +122,13 @@ | 'COPIED' | 'REWRITE'; +export declare type SkipObject = { + left: number; + right: number; +}; + +export declare type SkipInfo = number | SkipObject; + /** * The DiffContent entity contains information about the content differences in * a file. @@ -158,7 +165,7 @@ * Count of lines skipped on both sides when the file is too large to include * all common lines. */ - skip?: number; + skip?: SkipInfo; /** * Set to true if the region is common according to the requested * ignore-whitespace parameter, but a and b contain differing amounts of @@ -485,8 +492,14 @@ /** Data used by GrAnnotation to generate elements. */ export declare interface ElementSpec { - tagName: string; - attributes?: {[key: string]: unknown}; + tagName: 'a' | 'span'; + attributes?: { + href?: string; + target?: string; + rel?: string; + 'data-dc-diff-link-layer'?: string; + 'data-token-hovercard-selected'?: string; + }; } /** Used to annotate segments of an HTMLElement with a class string. */
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts index 37e68e3..7338120 100644 --- a/polygerrit-ui/app/api/embed.ts +++ b/polygerrit-ui/app/api/embed.ts
@@ -67,6 +67,8 @@ placeholderHint?: string; hint?: string; setRangeText: (replacement: string, start: number, end: number) => void; + setCursorPosition: (position: number) => void; + getCursorPosition: () => number; } /** <gr-autogrow-textarea> interface that external users can rely on */
diff --git a/polygerrit-ui/app/api/flows.ts b/polygerrit-ui/app/api/flows.ts new file mode 100644 index 0000000..09d4e4c --- /dev/null +++ b/polygerrit-ui/app/api/flows.ts
@@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ChangeInfo} from './rest-api'; + +/** + * Information about a custom condition that can be used in a flow. + * + * <p>Which custom conditions are supported depends on the flow service implementation. + */ +export declare interface FlowCustomConditionInfo { + /** + * The name of the custom condition. + * + * <p>Which custom conditions are supported depends on the flow service implementation. + */ + name: string; + + /** + * Optional prefix that should be appended to all created conditions. + * + * <p>Which prefix values are supported depends on the flow service implementation. + */ + prefix?: string; + + /** + * Optional documentation string that describes the custom condition. + * + * <p>Which documentation values are supported depends on the flow service implementation. + */ + documentation?: string; +} + +export declare interface FlowsProvider { + /** + * List all custom conditions for the current user and the given change. + */ + getCustomConditions(change: ChangeInfo): Promise<FlowCustomConditionInfo[]>; + + /** + * Returns a string containing a link to the feature documentation. + */ + getDocumentation(): string; +} + +export declare interface FlowsAutosubmitProvider { + /** + * Returns true if autosubmit is enabled . + */ + isAutosubmitEnabled(): boolean; + + /** + * Returns the submit condition for autosubmit. + */ + getSubmitCondition(): string | undefined; + + /** + * Returns the submit action for autosubmit. + */ + getSubmitAction(): {name: string} | undefined; +} + +export declare interface FlowsPluginApi { + /** + * Must only be called once. You cannot register twice (throws an error). + * You cannot unregister. + */ + register(provider: FlowsProvider): void; + + /** + * Must only be called once. You cannot register twice (throws an error). + * You cannot unregister. + */ + registerAutosubmitProvider(provider: FlowsAutosubmitProvider): void; +}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts index 7695b60..a248db6 100644 --- a/polygerrit-ui/app/api/plugin.ts +++ b/polygerrit-ui/app/api/plugin.ts
@@ -5,10 +5,10 @@ */ import {AdminPluginApi} from './admin'; import {AnnotationPluginApi} from './annotation'; -import {AttributeHelperPluginApi} from './attribute-helper'; import {ChangeReplyPluginApi} from './change-reply'; import {ChecksPluginApi} from './checks'; import {EventHelperPluginApi} from './event-helper'; +import {FlowsPluginApi} from './flows'; import {PluginElement} from './hook'; import {PopupPluginApi} from './popup'; import {ReportingPluginApi} from './reporting'; @@ -18,6 +18,7 @@ import {StylePluginApi} from './styles'; import {SuggestionsPluginApi} from './suggestions'; import {ChangeUpdatesPluginApi} from './change-updates'; +import {AiCodeReviewPluginApi} from './ai-code-review'; export enum TargetElement { CHANGE_ACTIONS = 'changeactions', @@ -43,7 +44,10 @@ SHOW_DIFF = 'showdiff', BEFORE_REPLY_SENT = 'before-reply-sent', REPLY_SENT = 'replysent', + BEFORE_PUBLISH_EDIT = 'before-publish-edit', PUBLISH_EDIT = 'publish-edit', + BEFORE_REBASE = 'before-rebase', + BEFORE_CHERRY_PICK = 'before-cherry-pick', } export declare interface PluginApi { @@ -64,12 +68,13 @@ */ url(): string; admin(): AdminPluginApi; + aiCodeReview(): AiCodeReviewPluginApi; annotationApi(): AnnotationPluginApi; - attributeHelper(element: Element): AttributeHelperPluginApi; changeActions(): ChangeActionsPluginApi; changeReply(): ChangeReplyPluginApi; changeUpdates(): ChangeUpdatesPluginApi; checks(): ChecksPluginApi; + flows(): FlowsPluginApi; suggestions(): SuggestionsPluginApi; eventHelper(element: Node): EventHelperPluginApi; getPluginName(): string;
diff --git a/polygerrit-ui/app/api/publish.sh b/polygerrit-ui/app/api/publish.sh index 1d6368c..f930fb3 100755 --- a/polygerrit-ui/app/api/publish.sh +++ b/polygerrit-ui/app/api/publish.sh
@@ -28,6 +28,11 @@ ${bazel_bin} build //${api_path}:js_plugin_api_npm_package +if [ "$1" == "--pack" ]; then + echo 'Creating npm package gerritcodereview-typescript-api-<version>.tgz' + ${bazel_bin} run //${api_path}:js_plugin_api_npm_package.pack +fi + if [ "$1" == "--upload" ]; then echo 'Uploading npm package @gerritcodereview/typescript-api' ${bazel_bin} run //${api_path}:js_plugin_api_npm_package.publish
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts index 3d716f7..262f308 100644 --- a/polygerrit-ui/app/api/rest-api.ts +++ b/polygerrit-ui/app/api/rest-api.ts
@@ -427,6 +427,7 @@ contains_git_conflicts?: boolean; submit_requirements?: SubmitRequirementResultInfo[]; submit_records?: SubmitRecordInfo[]; + can_ai_review?: boolean; } // The ID of the change in the format "'<project>~<branch>~<Change-Id>'" @@ -692,6 +693,7 @@ export declare interface EmailInfo { email: EmailAddress; preferred?: boolean; + avatar?: boolean; pending_confirmation?: boolean; } @@ -713,6 +715,7 @@ export declare interface FlowActionInfo { name: string; parameters?: string[]; + parameters_placeholder?: string; } /** @@ -1104,6 +1107,7 @@ export declare interface ReviewerUpdateInfo { updated: Timestamp; updated_by: AccountInfo; + real_updated_by?: AccountInfo; reviewer: AccountInfo; state: ReviewerState; }
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts index 1ea6ec2..6d7eced 100644 --- a/polygerrit-ui/app/constants/reporting.ts +++ b/polygerrit-ui/app/constants/reporting.ts
@@ -167,4 +167,28 @@ COMMENT_COMPLETION_SUGGESTION_ACCEPTED = 'comment-completion-suggestion-accepted', COMMENT_COMPLETION_SAVE_DRAFT = 'comment-completion-save-draft', COMMENT_COMPLETION_SUGGESTION_FETCHED = 'comment-completion-suggestion-fetched', + + COPY_AI_PROMPT = 'copy-ai-prompt', + + // AI agent suggests comments/fixes to user. + AI_AGENT_SUGGESTIONS_SHOWN = 'ai-agent-suggestions-shown', + // AI agent suggestions are promoted to a draft comment by user. + AI_AGENT_SUGGESTION_TO_COMMENT = 'ai-agent-suggestion-to-comment', + + FLOWS_TAB_RENDERED = 'flows-tab-rendered', + CREATE_FLOW_DIALOG_OPENED = 'create-flow-dialog-opened', + FLOW_CREATED = 'flow-created', } + +/** + * EventDetails to be passed to the reportInteraction method for AI agent + * interactions. + */ +export type AiAgentEventDetails = { + agentId: string; + conversationId: string; + // Each agent response in a conversation is a turn. + turnIndex: number; + // commentCount is 0 if agent ran but didn't suggest any comments/fixes. + commentCount?: number; +};
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 fc9b2f7..99646f1 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
@@ -7,6 +7,7 @@ import '../../shared/gr-icon/gr-icon'; import '../gr-permission/gr-permission'; import { + AccessPermissionId, AccessPermissions, PermissionArray, PermissionArrayItem, @@ -38,6 +39,8 @@ import '@material/web/select/outlined-select'; import '@material/web/select/select-option'; import {MdOutlinedSelect} from '@material/web/select/outlined-select'; +import {getAppContext} from '../../../services/app-context'; +import {KnownExperimentId} from '../../../services/flags/flags'; const GLOBAL_NAME = 'GLOBAL_CAPABILITIES'; @@ -47,6 +50,8 @@ @customElement('gr-access-section') export class GrAccessSection extends LitElement { + private readonly flagsService = getAppContext().flagsService; + @query('#permissionSelect') private permissionSelect?: MdOutlinedSelect; @property({type: String}) @@ -356,7 +361,10 @@ ); } return allPermissions.filter( - permission => !section.value.permissions[permission.id] + permission => + !section.value.permissions[permission.id] && + (permission.id !== AccessPermissionId.AI_REVIEW || + this.flagsService.isEnabled(KnownExperimentId.ENABLE_AI_CHAT)) ); }
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 a4760d8..92f9aa9 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
@@ -7,9 +7,11 @@ import '../../../test/common-test-setup'; import './gr-access-section'; import { + AccessPermissionId, AccessPermissions, toSortedPermissionsArray, } from '../../../utils/access-util'; +import {stubFlags} from '../../../test/test-utils'; import {GrAccessSection} from './gr-access-section'; import {GitRef} from '../../../types/common'; import {queryAndAssert} from '../../../utils/common-util'; @@ -362,12 +364,34 @@ assert.deepEqual(element.computePermissions(), expectedPermissions); // For everything else, include possible label values before filtering. + // AI Review is excluded because the experiment flag is disabled. element.section.id = 'refs/for/*' as GitRef; assert.deepEqual( element.computePermissions(), labelOptions .concat(toSortedPermissionsArray(AccessPermissions)) - .filter(permission => permission.id !== 'read') + .filter( + permission => + permission.id !== 'read' && + permission.id !== AccessPermissionId.AI_REVIEW + ) + ); + }); + + test('computePermissions includes AI Review when flag enabled', () => { + stubFlags('isEnabled').returns(true); + + element.section = { + id: 'refs/for/*' as GitRef, + value: { + permissions: {}, + }, + }; + element.labels = {'Code-Review': {values: {}, default_value: 0}}; + + const permissions = element.computePermissions(); + assert.isTrue( + permissions.some(p => p.id === AccessPermissionId.AI_REVIEW) ); });
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts index 17d2376..bd63113 100644 --- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts +++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -115,7 +115,7 @@ await element.updateComplete; const response = {...new Response(), status: 404}; - stubRestApi('getGroupAuditLog').callsFake((_group: any, errFn: any) => { + stubRestApi('getGroupAuditLog').callsFake((_group, errFn) => { if (errFn) errFn(response); return Promise.resolve(undefined); });
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 f7cab0e..61930d8 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
@@ -448,7 +448,7 @@ element.addEventListener('show-alert', alertStub); const errorResponse = {...new Response(), status: 404, ok: false}; stubRestApi('saveIncludedGroup').callsFake( - (_: any, _non: any, errFn: any) => { + (_groupId, _includedGroupId, errFn) => { if (errFn !== undefined) { errFn(errorResponse); } else { @@ -460,13 +460,13 @@ const groupMemberSearchInput = queryAndAssert<GrAutocomplete>( element, - '#groupMemberSearchInput' + '#includedGroupSearchInput' ); groupMemberSearchInput.text = memberName; groupMemberSearchInput.value = '1234'; await element.updateComplete; - element.handleSavingIncludedGroups().then(() => { + await element.handleSavingIncludedGroups().then(() => { assert.isTrue(alertStub.called); }); }); @@ -592,7 +592,7 @@ element.groupId = 'testId1' as GroupId; const response = {...new Response(), status: 404}; - stubRestApi('getGroupConfig').callsFake((_: any, errFn: any) => { + stubRestApi('getGroupConfig').callsFake((_group, errFn) => { if (errFn !== undefined) { errFn(response); }
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts index ddf1e2f..8b4192f 100644 --- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts +++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.ts
@@ -358,7 +358,7 @@ element.groupId = '1' as GroupId; const response = {...new Response(), status: 404}; - stubRestApi('getGroupConfig').callsFake((_: any, errFn: any) => { + stubRestApi('getGroupConfig').callsFake((_, errFn) => { if (errFn !== undefined) { errFn(response); } else {
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 8179f92..362ab22 100644 --- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts +++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -10,10 +10,15 @@ import {css, html, LitElement, PropertyValues} from 'lit'; import { AccessPermissionId, + getAccessDocsAnchor, PermissionArray, PermissionArrayItem, toSortedPermissionsArray, } from '../../../utils/access-util'; +import {resolve} from '../../../models/dependency'; +import {configModelToken} from '../../../models/config/config-model'; +import {subscribe} from '../../lit/subscription-controller'; +import {getDocUrl} from '../../../utils/url-util'; import {customElement, property, query, state} from 'lit/decorators.js'; import { GitRef, @@ -38,6 +43,7 @@ import {materialStyles} from '../../../styles/gr-material-styles'; import {grFormStyles} from '../../../styles/gr-form-styles'; import {menuPageStyles} from '../../../styles/gr-menu-page-styles'; +import {ifDefined} from 'lit/directives/if-defined.js'; import {when} from 'lit/directives/when.js'; import { AutocompleteCommitEvent, @@ -112,15 +118,26 @@ @state() originalExclusiveValue?: boolean; + // private but used in test + @state() + docsBaseUrl = ''; + @query('#groupAutocomplete') private groupAutocomplete!: GrAutocomplete; private readonly restApiService = getAppContext().restApiService; + private readonly getConfigModel = resolve(this, configModelToken); + constructor() { super(); this.query = () => this.getGroupSuggestions(); this.addEventListener('access-saved', () => this.handleAccessSaved()); + subscribe( + this, + () => this.getConfigModel().docsBaseUrl$, + docsBaseUrl => (this.docsBaseUrl = docsBaseUrl) + ); } override connectedCallback() { @@ -172,6 +189,12 @@ .title { margin-bottom: var(--spacing-s); } + .title a { + display: inline-block; + vertical-align: middle; + margin-left: var(--spacing-s); + text-decoration: none; + } #addRule, #removeBtn { display: none; @@ -210,6 +233,7 @@ if (!this.section || !this.permission) { return; } + const helpUrl = this.computeHelpUrl(); return html` <section id="permission" @@ -220,7 +244,21 @@ > <div id="mainContainer"> <div class="header"> - <span class="title">${this.name}</span> + <span class="title"> + ${this.name} + ${when( + helpUrl, + () => html` + <a + href=${ifDefined(helpUrl)} + target="_blank" + rel="noopener noreferrer" + > + <gr-icon icon="help" title="Help"></gr-icon> + </a> + ` + )} + </span> <div class="right"> ${when( !this.permissionIsOwnerOrGlobal( @@ -303,6 +341,21 @@ this.requestUpdate(); } + // private but used in test + computeHelpUrl(): string | undefined { + if (!this.permission || !this.permission.id || !this.docsBaseUrl) { + return undefined; + } + let anchor = getAccessDocsAnchor(this.permission.id as string); + if (!anchor && this.section === 'GLOBAL_CAPABILITIES') { + anchor = `capability_${this.permission.id}`; + } + if (!anchor) { + return undefined; + } + return getDocUrl(this.docsBaseUrl, `access-control.html#${anchor}`); + } + private handleAccessSaved() { // Set a new 'original' value to keep track of after the value has been // saved. @@ -506,12 +559,6 @@ // The group id is encoded, but have to decode in order for the access // API to work as expected. const groupId = decodeURIComponent(e.detail.value).replace(/\+/g, ' '); - // We cannot use "this.set(...)" here, because groupId may contain dots, - // and dots in property path names are totally unsupported by Polymer. - // Apparently Polymer picks up this change anyway, otherwise we should - // have looked at using MutableData: - // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data - // Actual value assigned below, after the flush this.permission.value.rules[groupId] = {} as EditablePermissionRuleInfo; // Purposely don't recompute sorted array so that the newly added rule
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 3092fa1..2f268e0 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
@@ -172,6 +172,27 @@ ); }); + test('computeHelpUrl', async () => { + element.docsBaseUrl = 'https://docs.com/'; + + element.permission = {id: 'abandon' as GitRef, value: {rules: {}}}; + assert.equal( + element.computeHelpUrl(), + 'https://docs.com/access-control.html#category_abandon' + ); + + element.permission = {id: 'priority' as GitRef, value: {rules: {}}}; + element.section = 'GLOBAL_CAPABILITIES' as GitRef; + assert.equal( + element.computeHelpUrl(), + 'https://docs.com/access-control.html#capability_priority' + ); + + element.permission = {id: 'unknown' as GitRef, value: {rules: {}}}; + element.section = 'refs/heads/*' as GitRef; + assert.isUndefined(element.computeHelpUrl()); + }); + test('computeGroupName', async () => { const groups = { abc123: {id: '1' as GroupId, name: 'test group' as GroupName}, @@ -326,7 +347,16 @@ <section class="gr-form-styles" id="permission"> <div id="mainContainer"> <div class="header"> - <span class="title"> Priority </span> + <span class="title"> + Priority + <a + href="/Documentation/access-control.html#category_review_labels" + rel="noopener noreferrer" + target="_blank" + > + <gr-icon icon="help" title="Help"></gr-icon> + </a> + </span> <div class="right"> <md-switch disabled="" id="exclusiveToggle"> </md-switch> Not Exclusive
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 74f9464..e989ede 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
@@ -390,7 +390,7 @@ test('fires page-error', async () => { const response = {status: 404} as Response; stubRestApi('getPlugins').callsFake( - (_filter: any, _pluginsPerPage: any, _offset: any, errFn: any) => { + (_filter, _pluginsPerPage, _offset, errFn) => { if (errFn !== undefined) { errFn(response); }
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 1bb5b1d..49463bd 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
@@ -362,14 +362,12 @@ test('fires page-error', async () => { const response = {status: 404} as Response; - stubRestApi('getRepoAccessRights').callsFake( - (_repoName: any, errFn: any) => { - if (errFn !== undefined) { - errFn(response); - } - return Promise.resolve(undefined); + stubRestApi('getRepoAccessRights').callsFake((_repoName, errFn) => { + if (errFn !== undefined) { + errFn(response); } - ); + return Promise.resolve(undefined); + }); const promise = mockPromise(); addListenerForTest(document, 'page-error', e => { @@ -402,7 +400,7 @@ ); assert.equal( queryAndAssert<GrButton>(element, '#editBtn').innerText, - 'EDIT' + 'Edit' ); assert.equal( getComputedStyle( @@ -428,7 +426,7 @@ // disabled. assert.equal( queryAndAssert<GrButton>(element, '#editBtn').innerText, - 'CANCEL' + 'Cancel' ); if (shouldShowSaveReview) { assert.notEqual( @@ -462,6 +460,7 @@ bubbles: true, }) ); + await element.updateComplete; if (shouldShowSaveReview) { assert.isFalse( queryAndAssert<GrButton>(element, '#saveReviewBtn').disabled @@ -481,6 +480,8 @@ element.groups = JSON.parse(JSON.stringify(accessRes.groups)); element.capabilities = JSON.parse(JSON.stringify(capabilitiesRes)); element.labels = JSON.parse(JSON.stringify(repoRes.labels)); + element.disableSaveWithoutReview = false; + element.modified = false; await element.updateComplete; }); @@ -516,20 +517,20 @@ test('button visibility for non ref owner with upload privilege', async () => { element.canUpload = true; await element.updateComplete; - testEditSaveCancelBtns(false, true); + await testEditSaveCancelBtns(false, true); }); test('button visibility for ref owner', async () => { element.ownerOf = ['refs/for/*'] as GitRef[]; await element.updateComplete; - testEditSaveCancelBtns(true, false); + await testEditSaveCancelBtns(true, false); }); test('button visibility for ref owner and upload', async () => { element.ownerOf = ['refs/for/*'] as GitRef[]; element.canUpload = true; await element.updateComplete; - testEditSaveCancelBtns(true, false); + await testEditSaveCancelBtns(true, false); }); test('_handleAccessModified called with event fired', async () => { @@ -1565,6 +1566,7 @@ sinon.stub(element, 'computeAddAndRemove').returns(repoAccessInput); element.modified = true; + await element.updateComplete; queryAndAssert<GrButton>(element, '#saveReviewBtn').click(); await element.updateComplete; assert.equal(
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 e92d286..b305fa3 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
@@ -202,7 +202,7 @@ element.repo = 'test' as RepoName; const response = {status: 404} as Response; - stubRestApi('getProjectConfig').callsFake((_repo: any, errFn: any) => { + stubRestApi('getProjectConfig').callsFake((_repo, errFn) => { if (errFn !== undefined) { errFn(response); }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts index b0acf64..fe4f6f8 100644 --- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts +++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -126,7 +126,7 @@ suite('404', () => { test('fires page-error', async () => { const response = {status: 404} as Response; - stubRestApi('getRepoDashboards').callsFake((_repo: any, errFn: any) => { + stubRestApi('getRepoDashboards').callsFake((_repo, errFn) => { errFn!(response); return Promise.resolve([]); });
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 3308dab..38f76de 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
@@ -383,8 +383,6 @@ return Promise.reject(new Error('undefined repo')); } - // paramsChanged is called before gr-admin-view can set _showRepoDetailList - // to false and polymer removes this component, hence check for params if ( !( this.params?.detail === RepoDetailView.BRANCHES ||
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 e757566..2def741 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
@@ -2235,10 +2235,15 @@ const response = {status: 404} as Response; stubRestApi('getRepoBranches').callsFake( ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any _filter: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _repo: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _reposBranchesPerPage: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _offset: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any errFn: any ) => { if (errFn !== undefined) { @@ -2438,10 +2443,15 @@ const response = {status: 404} as Response; stubRestApi('getRepoTags').callsFake( ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any _filter: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _repo: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _reposTagsPerPage: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _offset: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any errFn: any ) => { if (errFn !== undefined) {
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 bb9c1ab..2f1bf8b 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
@@ -1160,7 +1160,7 @@ const pageErrorFired = mockPromise(); const response = {...new Response(), status: 404}; - stubRestApi('getProjectConfig').callsFake((_: any, errFn: any) => { + stubRestApi('getProjectConfig').callsFake((_repo, errFn) => { if (errFn !== undefined) { errFn(response); }
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 b8ddfa2..9523d69 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
@@ -209,7 +209,7 @@ state => state === LoadingState.LOADED ); stubRestApi('saveChangeReview').callsFake( - (_changeNum: any, _patchNum: any, _review: any, errFn: any) => + (_changeNum, _patchNum, _review, errFn) => Promise.resolve(undefined).then(res => { errFn && errFn(); return res; @@ -446,7 +446,7 @@ getChangesStub.returns(Promise.resolve(changes)); stubRestApi('saveChangeReview').callsFake( - (_changeNum: any, _patchNum: any, _review: any, errFn: any) => + (_changeNum, _patchNum, _review, errFn) => Promise.resolve({}).then(res => { errFn && errFn(); return res; @@ -504,7 +504,7 @@ getChangesStub.returns(Promise.resolve(changes)); stubRestApi('saveChangeReview').callsFake( - (_changeNum: any, _patchNum: any, _review: any, errFn: any) => + (_changeNum, _patchNum, _review, errFn) => Promise.resolve(undefined).then(res => { errFn && errFn(); return res;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_test.ts index 9a14afa..3f6f3b7 100644 --- a/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_test.ts +++ b/polygerrit-ui/app/elements/change-list/gr-change-list-copy-link-flow/gr-change-list-copy-link-flow_test.ts
@@ -102,6 +102,7 @@ button?.click(); await element.updateComplete; await waitUntil(() => element.shadowRoot?.querySelector('gr-copy-links')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.isTrue((copyLinks as any).isDropdownOpen); }); });
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 2d8022b..9b7209f 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
@@ -28,6 +28,7 @@ import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; import '@material/web/menu/menu'; import {MdMenu} from '@material/web/menu/menu'; +import {materialStyles} from '../../../styles/gr-material-styles'; @customElement('gr-change-list-hashtag-flow') export class GrChangeListHashtagFlow extends LitElement { @@ -58,6 +59,7 @@ static override get styles() { return [ + materialStyles, spinnerStyles, css` md-menu {
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 7c6b559..61c06df 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
@@ -283,10 +283,7 @@ </div> `, { - // iron-dropdown sizing seems to vary between local & CI - ignoreAttributes: [ - {tags: ['iron-dropdown'], attributes: ['style', 'focused']}, - ], + ignoreAttributes: [{tags: [], attributes: ['style', 'focused']}], } ); });
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 2a5d5fe..b3d7a35 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
@@ -29,6 +29,7 @@ import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; import '@material/web/menu/menu'; import {MdMenu} from '@material/web/menu/menu'; +import {materialStyles} from '../../../styles/gr-material-styles'; @customElement('gr-change-list-topic-flow') export class GrChangeListTopicFlow extends LitElement { @@ -59,6 +60,7 @@ static override get styles() { return [ + materialStyles, spinnerStyles, css` md-menu {
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 23af59c2..46aca13 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
@@ -169,7 +169,9 @@ async function rejectPromises() { setChangeTopicPromises[0].reject(new Error('error')); + setChangeTopicPromises[0].catch(() => {}); setChangeTopicPromises[1].reject(new Error('error')); + setChangeTopicPromises[1].catch(() => {}); await element.updateComplete; } @@ -276,10 +278,7 @@ </div> `, { - // iron-dropdown sizing seems to vary between local & CI - ignoreAttributes: [ - {tags: ['iron-dropdown'], attributes: ['style', 'focused']}, - ], + ignoreAttributes: [{tags: [], attributes: ['style', 'focused']}], } ); }); @@ -498,7 +497,9 @@ async function rejectPromises() { setChangeTopicPromises[0].reject(new Error('error')); + setChangeTopicPromises[0].catch(() => {}); setChangeTopicPromises[1].reject(new Error('error')); + setChangeTopicPromises[1].catch(() => {}); await element.updateComplete; } @@ -587,10 +588,7 @@ </div> `, { - // iron-dropdown sizing seems to vary between local & CI - ignoreAttributes: [ - {tags: ['iron-dropdown'], attributes: ['style', 'focused']}, - ], + ignoreAttributes: [{tags: [], attributes: ['style', 'focused']}], } ); });
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 cb7f42f..8a41a2d 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
@@ -35,7 +35,6 @@ firePageError, fireTitleChange, } from '../../../utils/event-util'; -import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants'; import {ChangeListSection} from '../gr-change-list/gr-change-list'; import {a11yStyles} from '../../../styles/gr-a11y-styles'; import {sharedStyles} from '../../../styles/shared-styles'; @@ -113,8 +112,6 @@ 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. @@ -153,34 +150,6 @@ this.shortcuts.addAbstract(Shortcut.UP_TO_DASHBOARD, () => this.reload()); } - private readonly visibilityChangeListener = () => { - if (document.visibilityState === 'visible') { - if ( - Date.now() - this.lastVisibleTimestampMs > - RELOAD_DASHBOARD_INTERVAL_MS - ) - this.reload(); - } else { - this.lastVisibleTimestampMs = Date.now(); - } - }; - - override connectedCallback() { - super.connectedCallback(); - document.addEventListener( - 'visibilitychange', - this.visibilityChangeListener - ); - } - - override disconnectedCallback() { - document.removeEventListener( - 'visibilitychange', - this.visibilityChangeListener - ); - super.disconnectedCallback(); - } - static override get styles() { return [ a11yStyles, @@ -398,7 +367,6 @@ } this.firstTimeLoad = false; - this.loading = true; const {project, type, dashboard, title, user, sections} = this.viewState; const dashboardPromise: Promise<UserDashboard | undefined> = project
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 d93bae5..9a027a0 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
@@ -615,7 +615,7 @@ test('404 page', async () => { const response = {...new Response(), status: 404}; stubRestApi('getDashboard').callsFake( - async (_project: any, _dashboard: any, errFn: any) => { + async (_project, _dashboard, errFn) => { if (errFn !== undefined) { errFn(response); }
diff --git a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts index a2c2e6d..e4597e6 100644 --- a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts +++ b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog.ts
@@ -16,11 +16,15 @@ import {subscribe} from '../../lit/subscription-controller'; import {resolve} from '../../../models/dependency'; import {changeModelToken} from '../../../models/change/change-model'; +import {commentsModelToken} from '../../../models/comments/comments-model'; +import {CommentThread} from '../../../types/common'; +import {isUnresolved} from '../../../utils/comment-util'; import {ParsedChangeInfo} from '../../../types/types'; import {PatchSetNum} from '../../../types/common'; import {HELP_ME_REVIEW_PROMPT, IMPROVE_COMMIT_MESSAGE} from './prompts'; import {when} from 'lit/directives/when.js'; import {copyToClipboard} from '../../../utils/common-util'; +import {Interaction} from '../../../constants/reporting'; import '@material/web/select/outlined-select'; import '@material/web/select/select-option'; import {materialStyles} from '../../../styles/gr-material-styles'; @@ -42,6 +46,11 @@ label: 'Just patch content', prompt: '{{patch}}', }, + RESOLVE_COMMENTS: { + id: 'resolve_comments', + label: 'Unresolved Comments', + prompt: '{{comments}}', + }, }; const CONTEXT_OPTIONS = [ @@ -76,12 +85,21 @@ @state() private context = 3; + // private but used in tests + @state() threads: CommentThread[] = []; + @state() private promptContent = ''; + @state() private promptSize = ''; + private readonly getChangeModel = resolve(this, changeModelToken); + private readonly getCommentsModel = resolve(this, commentsModelToken); + private readonly restApiService = getAppContext().restApiService; + private readonly reporting = getAppContext().reportingService; + constructor() { super(); subscribe( @@ -94,6 +112,11 @@ () => this.getChangeModel().patchNum$, x => (this.patchNum = x) ); + subscribe( + this, + () => this.getCommentsModel().threads$, + x => (this.threads = x) + ); } static override get styles() { @@ -139,8 +162,6 @@ align-items: flex-start; margin-bottom: var(--spacing-m); } - .template-selector { - } .template-options { display: flex; flex-direction: column; @@ -170,6 +191,14 @@ justify-content: space-between; align-items: center; } + .actions { + display: flex; + align-items: center; + gap: var(--spacing-l); + } + .size { + color: var(--deactivated-text-color); + } .context-selector { display: flex; align-items: center; @@ -246,10 +275,13 @@ You can also use it for an AI Agent as context (a reference to a git change). </div> - <gr-button @click=${this.handleCopyPatch}> - <gr-icon icon="content_copy" small></gr-icon> - Copy Prompt - </gr-button> + <div class="actions"> + <div class="size">${this.promptSize}</div> + <gr-button @click=${this.handleCopyPatch}> + <gr-icon icon="content_copy" small></gr-icon> + Copy Prompt + </gr-button> + </div> </div>`, () => html` <div class="info-text"> @@ -284,7 +316,8 @@ override willUpdate(changedProperties: PropertyValues) { if ( changedProperties.has('patchContent') || - changedProperties.has('selectedTemplate') + changedProperties.has('selectedTemplate') || + changedProperties.has('threads') ) { this.updatePromptContent(); } @@ -300,8 +333,15 @@ } private getNumParents() { - return this.change?.revisions[this.change.current_revision].commit?.parents - .length; + if ( + !this.change || + !this.change.current_revision || + !this.change.revisions + ) { + return 0; + } + const revision = this.change.revisions[this.change.current_revision]; + return revision?.commit?.parents.length ?? 0; } private async loadPatchContent() { @@ -310,32 +350,88 @@ const content = await this.restApiService.getPatchContent( this.change._number, this.patchNum, - this.context + this.context, + () => fireError(this, 'Failed to get patch content') ); this.loading = false; - if (!content) { - fireError(this, 'Failed to get patch content'); - return; - } + if (!content) return; this.patchContent = content; } + private getUnresolvedCommentsFormatted(): string { + const unresolvedThreads = this.threads.filter(isUnresolved); + if (unresolvedThreads.length === 0) return 'No unresolved comments.'; + + return unresolvedThreads + .map(thread => { + const comments = thread.comments.map( + c => `${c.author?.name ?? 'Unknown'}:\n${c.message}` + ); + let loc = ''; + if (thread.line) { + loc = `Line ${thread.line}`; + } else if (thread.range) { + loc = `Lines ${thread.range.start_line}-${thread.range.end_line}`; + } else { + loc = 'File level'; + } + return `* File: ${thread.path} (${loc}) +${comments.join('\n\n')}`; + }) + .join('\n\n'); + } + private updatePromptContent() { if (!this.patchContent) { this.promptContent = ''; + this.promptSize = ''; return; } const template = PROMPT_TEMPLATES[this.selectedTemplate]; + + // Security compliance: Removing 2nd line of patchContent as it contains personal data. + const lines = this.patchContent.split('\n'); + if (lines.length > 1 && lines[1].toLowerCase().startsWith('from:')) { + lines.splice(1, 1); + } + const sanitizedPatchContent = lines.join('\n'); + this.promptContent = template.prompt.replace( '{{patch}}', - this.patchContent + sanitizedPatchContent ); + if (this.selectedTemplate === 'RESOLVE_COMMENTS') { + this.promptContent = this.promptContent.replace( + '{{comments}}', + this.getUnresolvedCommentsFormatted() + ); + } + // Inserts a space before each capital letter to handle CamelCase + const textWithSpaces = this.promptContent.replace(/([A-Z])/g, ' $1'); + + // Splits by whitespace, symbols, and now slashes + const size = textWithSpaces + .split(/["\s.(){}[\]\\/-]+/) + .filter(Boolean).length; + if (size === 0) { + this.promptSize = ''; + } else if (size < 1000) { + this.promptSize = `${size} words`; + } else { + this.promptSize = `${(size / 1000).toFixed(1)}k words`; + } } private async handleCopyPatch(e: Event) { e.preventDefault(); e.stopPropagation(); if (!this.promptContent) return; + + this.reporting.reportInteraction(Interaction.COPY_AI_PROMPT, { + template: this.selectedTemplate, + context: this.context, + }); + await copyToClipboard(this.promptContent, 'AI Prompt'); }
diff --git a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts index c72d0c3..7c5268a 100644 --- a/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts +++ b/polygerrit-ui/app/elements/change/gr-ai-prompt-dialog/gr-ai-prompt-dialog_test.ts
@@ -9,12 +9,23 @@ import {GrAiPromptDialog} from './gr-ai-prompt-dialog'; import {createParsedChange} from '../../../test/test-data-generators'; import {CommitId, PatchSetNum} from '../../../api/rest-api'; -import {stubRestApi} from '../../../test/test-utils'; +import {stubRestApi, waitUntil} from '../../../test/test-utils'; +import {testResolver} from '../../../test/common-test-setup'; +import {commentsModelToken} from '../../../models/comments/comments-model'; +import {of} from 'rxjs'; suite('gr-ai-prompt-dialog test', () => { let element: GrAiPromptDialog; + let getPatchContentStub: sinon.SinonStub; setup(async () => { - stubRestApi('getPatchContent').returns(Promise.resolve('<patch>')); + getPatchContentStub = stubRestApi('getPatchContent'); + getPatchContentStub.resolves('test code'); + const commentsModel = testResolver(commentsModelToken); + Object.defineProperty(commentsModel, 'threads$', { + value: of([]), + writable: true, + }); + element = await fixture(html`<gr-ai-prompt-dialog></gr-ai-prompt-dialog>`); element.change = createParsedChange(); element.change.revisions['abc'].commit!.parents = [ @@ -24,6 +35,8 @@ }, ]; element.patchNum = 1 as PatchSetNum; + element.patchContent = 'test code'; + element.selectedTemplate = 'PATCH_ONLY'; await element.updateComplete; }); @@ -42,9 +55,8 @@ <div class="template-options"> <label class="template-option"> <md-radio - checked="" name="template" - tabindex="0" + tabindex="-1" > </md-radio> Help me with review @@ -59,11 +71,20 @@ </label> <label class="template-option"> <md-radio + checked="" + name="template" + tabindex="0" + > + </md-radio> + Just patch content + </label> + <label class="template-option"> + <md-radio name="template" tabindex="-1" > </md-radio> - Just patch content + Unresolved Comments </label> </div> </div> @@ -111,28 +132,98 @@ code can be shared with AI. We recommend a thinking model. You can also use it for an AI Agent as context (a reference to a git change). - </div> - <gr-button> - <gr-icon - icon="content_copy" - small="" - > - </gr-icon> - Copy Prompt - </gr-button> - </div> - </div> - </section> - <section class="footer"> - <span class="closeButtonContainer"> - <gr-button - id="closeButton" - link="" - > - Close - </gr-button> - </span> - </section>` + </div> + <div class="actions"> + <div class="size"> + 2 words + </div> + <gr-button> + <gr-icon + icon="content_copy" + small="" + > + </gr-icon> + Copy Prompt + </gr-button> + </div> + </div> + </div> + </section> + <section class="footer"> + <span class="closeButtonContainer"> + <gr-button + id="closeButton" + link="" + > + Close + </gr-button> + </span> + </section>` ); }); + + test('handles failed patch content fetch', async () => { + getPatchContentStub.callsFake((_c, _p, _ctx, errFn) => { + if (errFn) errFn(); + return Promise.resolve(undefined); + }); + const fireStub = sinon.stub(element, 'dispatchEvent'); + + element.open(); + + await waitUntil(() => fireStub.called); + + assert.isTrue(fireStub.called); + const events = fireStub.args.map(arg => arg[0]); + assert.isTrue( + events.some( + event => + event.type === 'show-error' && + (event as CustomEvent).detail.message === + 'Failed to get patch content' + ) + ); + }); + test('renders help review prompt', async () => { + element.selectedTemplate = 'HELP_REVIEW'; + await element.updateComplete; + assert.include( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (element as any).promptContent, + 'You are a highly experienced code reviewer' + ); + }); + + test('renders resolve comments prompt', async () => { + element.selectedTemplate = 'RESOLVE_COMMENTS'; + await element.updateComplete; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.include((element as any).promptContent, 'No unresolved comments.'); + }); + + test('renders resolve comments prompt with comments', async () => { + element.threads = [ + { + comments: [ + { + message: 'test comment', + author: {name: 'Tester'}, + updated: '2025-01-01 10:00:00.000000000', + unresolved: true, + }, + ], + path: 'test.txt', + line: 1, + rootId: '1', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any[]; + element.selectedTemplate = 'RESOLVE_COMMENTS'; + await element.updateComplete; + const expected = `* File: test.txt (Line 1) +Tester: +test comment`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.include((element as any).promptContent, expected); + }); });
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 2a2a132..5b96c09 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
@@ -35,6 +35,7 @@ } from '../../../constants/constants'; import {TargetElement} from '../../../api/plugin'; import { + AccountId, AccountInfo, ActionInfo, ActionNameToActionInfoMap, @@ -113,12 +114,14 @@ import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; import {modalStyles} from '../../../styles/gr-modal-styles'; import {subscribe} from '../../lit/subscription-controller'; +import {chatModelToken} from '../../../models/chat/chat-model'; import {userModelToken} from '../../../models/user/user-model'; import {ParsedChangeInfo} from '../../../types/types'; import {readJSONResponsePayload} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; import {commentsModelToken} from '../../../models/comments/comments-model'; import {when} from 'lit/directives/when.js'; import {ValidationOptionInfo} from '../../../api/rest-api'; +import {KnownExperimentId} from '../../../services/flags/flags'; const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.'; const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.'; @@ -178,6 +181,13 @@ method: HttpMethod.POST, }; +const AI_CHAT_ACTION: UIActionInfo = { + __key: 'chat', + __type: ActionType.CHANGE, + enabled: true, + label: 'Review Agent', +}; + function isQuickApproveAction( action: UIActionInfo ): action is QuickApproveUIActionInfo { @@ -266,6 +276,7 @@ [ChangeActions.REVERT, {icon: 'undo'}], [ChangeActions.STOP_EDIT, {icon: 'stop', filled: true}], [QUICK_APPROVE_ACTION.key, {icon: 'check'}], + [AI_CHAT_ACTION.__key, {icon: 'ai'}], [RevisionActions.SUBMIT, {icon: 'done_all'}], ]); @@ -279,6 +290,8 @@ const AWAIT_CHANGE_ATTEMPTS = 5; const AWAIT_CHANGE_TIMEOUT_MS = 1000; +export const REMOVE_DELTE_ACCOUNTS_MESSAGE = + 'The following accounts are deleted and need to be removed from reviewers/CC before submitting:'; const SKIP_ACTION_KEYS: string[] = [ // REVIEWED/UNREVIEWED is made obsolete by AttentionSet. Once the @@ -371,6 +384,9 @@ @query('#confirmPublishEditDialog') confirmPublishEditDialog?: GrDialog; + @query('#confirmDeleteReviewerDialog') + confirmDeleteReviewerDialog?: GrDialog; + @query('#moreActions') moreActions?: GrDropdown; @query('#secondaryActions') secondaryActions?: HTMLElement; @@ -483,14 +499,26 @@ @state() pluginsLoaded = false; + @state() aiPluginsRegistered = false; + @state() threadsWithUnappliedSuggestions?: CommentThread[]; + @state() chatCapabilitiesLoaded = false; + + @state() deletedReviewers: AccountInfo[] = []; + + private aiChatLoadingCleanup?: () => void; + private readonly restApiService = getAppContext().restApiService; private readonly reporting = getAppContext().reportingService; + private readonly flagService = getAppContext().flagsService; + private readonly getPluginLoader = resolve(this, pluginLoaderToken); + private readonly getChatModel = resolve(this, chatModelToken); + private readonly getUserModel = resolve(this, userModelToken); private readonly getChangeModel = resolve(this, changeModelToken); @@ -505,6 +533,11 @@ super(); subscribe( this, + () => this.getChatModel().capabilitiesLoaded$, + x => (this.chatCapabilitiesLoaded = x) + ); + subscribe( + this, () => this.getChangeModel().latestPatchNum$, x => (this.latestPatchNum = x) ); @@ -570,6 +603,11 @@ ); subscribe( this, + () => this.getPluginLoader().pluginsModel.aiCodeReviewPlugins$, + plugins => (this.aiPluginsRegistered = (plugins.length ?? 0) > 0) + ); + subscribe( + this, () => this.getCommentsModel().threadsWithUnappliedSuggestions$, x => (this.threadsWithUnappliedSuggestions = x) ); @@ -818,6 +856,26 @@ Do you really want to publish the edit? </div> </gr-dialog> + <gr-dialog + id="confirmDeleteReviewerDialog" + class="confirmDialog" + confirm-label="OK" + @confirm=${this.handleConfirmDialogCancel} + @cancel=${this.handleConfirmDialogCancel} + > + <div class="header" slot="header">Invalid reviewers</div> + <div class="main" slot="main"> + ${REMOVE_DELTE_ACCOUNTS_MESSAGE} + <ul> + ${this.deletedReviewers.map( + acc => + html`<li> + ${acc.name ?? acc.email ?? acc.username ?? acc._account_id} + </li>` + )} + </ul> + </div> + </gr-dialog> </dialog> `; } @@ -882,6 +940,18 @@ this.menuActions = this.computeMenuActions(); } + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + if (this.chatCapabilitiesLoaded && this.aiChatLoadingCleanup) { + this.aiChatLoadingCleanup(); + this.aiChatLoadingCleanup = undefined; + // When capabilities are loaded, we also want to automatically open the Review + // Agent chat panel if the user had clicked the button while it was loading. + fire(this, 'ai-chat', {}); + } + } + reload() { if (!this.changeNum || !this.latestPatchNum || !this.change) { return Promise.resolve(); @@ -1207,6 +1277,20 @@ this._hideQuickApproveAction = true; } + private getAiChatAction(): UIActionInfo | null { + if (!this.flagService.isEnabled(KnownExperimentId.ENABLE_AI_CHAT)) { + return null; + } + // When undefined, assume AI chat is allowed. + if (this.change?.can_ai_review === false) { + return null; + } + if (!this.aiPluginsRegistered) { + return null; + } + return AI_CHAT_ACTION; + } + private getQuickApproveAction(): QuickApproveUIActionInfo | null { if (this._hideQuickApproveAction) { return null; @@ -1334,6 +1418,25 @@ // private but used in test canSubmitChange() { if (!this.change) return false; + const deletedVoters = new Map<AccountId, AccountInfo>(); + for (const label of Object.values(this.change.labels ?? {})) { + if (!isDetailedLabelInfo(label) || !label.all) continue; + for (const approval of label.all) { + if (approval.deleted && approval._account_id !== undefined) { + deletedVoters.set(approval._account_id, approval); + } + } + } + + if (deletedVoters.size > 0) { + this.deletedReviewers = [...deletedVoters.values()]; + assertIsDefined( + this.confirmDeleteReviewerDialog, + 'confirmDeleteReviewerDialog' + ); + this.showActionDialog(this.confirmDeleteReviewerDialog); + return false; + } const change = this.change as ChangeInfo; const revision = this.getRevision(change, this.latestPatchNum); return this.getPluginLoader().jsApiService.canSubmitChange( @@ -1363,26 +1466,18 @@ } async showRevertDialog() { - const change = this.change; - if (!change) return; - const query = `submissionid: "${change.submission_id}"`; - /* A chromium plugin expects that the modifyRevertMsg hook will only - be called after the revert button is pressed, hence we populate the - revert dialog after revert button is pressed. */ - const [changes, validationOptions] = await Promise.all([ - this.restApiService.getChanges(0, query), - this.restApiService.getValidationOptions(this.change!._number), - ]); - if (!changes) { + if (!this.change) return; + assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog'); + if ( + !(await this.confirmRevertDialog.populate( + this.change, + this.commitMessage + )) + ) { + // This indicates error in REST response that will show error dialog, no + // need to open revert dialog. return; } - assertIsDefined(this.confirmRevertDialog, 'confirmRevertDialog'); - this.confirmRevertDialog.populate( - change, - validationOptions, - this.commitMessage, - changes.length - ); this.showActionDialog(this.confirmRevertDialog); } @@ -1464,7 +1559,7 @@ if ( !(await this.getPluginLoader().jsApiService.handleBeforeChangeAction( key, - this.change + this.change as ChangeInfo )) ) return; @@ -1484,6 +1579,19 @@ this.fireAction(this.prependSlash(key), action, true, action.payload); break; } + case AI_CHAT_ACTION.__key: { + if (!this.chatCapabilitiesLoaded) { + try { + this.aiChatLoadingCleanup = + this.setLoadingOnButtonWithKey(AI_CHAT_ACTION); + } catch (e) { + // This is expected if the button is not found in the DOM. + } + return; + } + fire(this, 'ai-chat', {}); + break; + } case ChangeActions.EDIT: this.handleEditTap(); break; @@ -1506,7 +1614,7 @@ this.handleMoveTap(); break; case ChangeActions.PUBLISH_EDIT: - this.handlePublishEditTap(); + await this.handlePublishEditTap(); break; case ChangeActions.REBASE_EDIT: this.handleRebaseEditTap(); @@ -1578,9 +1686,19 @@ } // private but used in test - handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) { + async handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) { assertIsDefined(this.confirmRebase, 'confirmRebase'); assertIsDefined(this.actionsModal, 'actionsModal'); + + if ( + !(await this.getPluginLoader().jsApiService.handleBeforeRebase( + this.change as ChangeInfo + )) + ) { + // Exit early and abort rebase if a plugin hook requests it. + return; + } + const payload = { base: e.detail.base, allow_conflicts: e.detail.allowConflicts, @@ -1604,18 +1722,27 @@ } // private but used in test - handleCherrypickConfirm() { - this.handleCherryPickRestApi(false); + async handleCherrypickConfirm() { + await this.handleCherryPickRestApi(false); } // private but used in test - handleCherrypickConflictConfirm() { - this.handleCherryPickRestApi(true); + async handleCherrypickConflictConfirm() { + await this.handleCherryPickRestApi(true); } - private handleCherryPickRestApi(conflicts: boolean) { + private async handleCherryPickRestApi(conflicts: boolean) { assertIsDefined(this.confirmCherrypick, 'confirmCherrypick'); assertIsDefined(this.actionsModal, 'actionsModal'); + + if ( + !(await this.getPluginLoader().jsApiService.handleBeforeCherryPick( + this.change as ChangeInfo + )) + ) { + return; + } + const el = this.confirmCherrypick; if (!el.branch) { fireAlert(this, ERR_BRANCH_EMPTY); @@ -1766,7 +1893,7 @@ ); } - private handlePublishEditConfirm() { + private async handlePublishEditConfirm() { this.hideAllDialogs(); if (!this.actions.publishEdit) return; @@ -1775,6 +1902,15 @@ // edit are deleted. this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum); + if ( + !(await this.getPluginLoader().jsApiService.handleBeforePublishEdit( + this.change as ChangeInfo + )) + ) { + // Exit early and abort publish if a plugin hook requests it. + return; + } + this.fireAction( '/edit:publish', assertUIActionInfo(this.actions.publishEdit), @@ -2138,7 +2274,7 @@ this.fireAction('/wip', assertUIActionInfo(this.actions.wip), false); } - private handlePublishEditTap() { + private async handlePublishEditTap() { if (this.numberOfThreadsWithUnappliedSuggestions() > 0) { assertIsDefined( this.confirmPublishEditDialog, @@ -2147,7 +2283,7 @@ this.showActionDialog(this.confirmPublishEditDialog); } else { // Skip confirmation dialog and publish immediately. - this.handlePublishEditConfirm(); + await this.handlePublishEditConfirm(); } } @@ -2187,7 +2323,12 @@ if (quickApprove) { changeActionValues.unshift(quickApprove); } - + if (this.loggedIn) { + const aiChat = this.getAiChatAction(); + if (aiChat) { + changeActionValues.unshift(aiChat); + } + } return revisionActionValues .concat(changeActionValues) .sort((a, b) => this.actionComparator(a, b)) @@ -2210,8 +2351,12 @@ return overrideAction.priority; } } - if (action.__key === 'review') { + if (action.__key === AI_CHAT_ACTION.__key) { + return ActionPriority.CHAT; + } else if (action.__key === QUICK_APPROVE_ACTION.__key) { return ActionPriority.REVIEW; + } else if (action.__key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX)) { + return ActionPriority.DEFAULT; } else if (action.__primary) { return ActionPriority.PRIMARY; } else if (action.__type === ActionType.CHANGE) { @@ -2314,6 +2459,7 @@ declare global { interface HTMLElementEventMap { + 'ai-chat': CustomEvent<{}>; 'download-tap': CustomEvent<{}>; 'edit-tap': CustomEvent<{}>; 'included-tap': CustomEvent<{}>;
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 ed4c9c5..5544e15 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
@@ -23,11 +23,16 @@ query, queryAll, queryAndAssert, + stubFlags, stubReporting, stubRestApi, waitUntil, } from '../../../test/test-utils'; -import {assertUIActionInfo, GrChangeActions} from './gr-change-actions'; +import { + assertUIActionInfo, + GrChangeActions, + REMOVE_DELTE_ACCOUNTS_MESSAGE, +} from './gr-change-actions'; import { AccountId, ActionInfo, @@ -55,10 +60,7 @@ 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, - RevertType, -} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog'; +import {GrConfirmRevertDialog} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog'; 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'; @@ -66,7 +68,7 @@ ChangeModel, changeModelToken, } from '../../../models/change/change-model'; -import {assertIsDefined} from '../../../utils/common-util'; +import {ChatModel, chatModelToken} from '../../../models/chat/chat-model'; import {GrAutogrowTextarea} from '../../shared/gr-autogrow-textarea/gr-autogrow-textarea'; // TODO(dhruvsri): remove use of _populateRevertMessage as it's private @@ -75,6 +77,7 @@ let navigateResetStub: SinonStubbedMember< ChangeModel['navigateToChangeResetReload'] >; + let chatModel: ChatModel; suite('basic tests', () => { setup(async () => { @@ -143,6 +146,7 @@ testResolver(changeModelToken), 'navigateToChangeResetReload' ); + chatModel = testResolver(chatModelToken); await element.updateComplete; await element.reload(); @@ -303,6 +307,18 @@ Do you really want to publish the edit? </div> </gr-dialog> + <gr-dialog + class="confirmDialog" + confirm-label="OK" + id="confirmDeleteReviewerDialog" + role="dialog" + > + <div class="header" slot="header">Invalid reviewers</div> + <div class="main" slot="main"> + ${REMOVE_DELTE_ACCOUNTS_MESSAGE} + <ul></ul> + </div> + </gr-dialog> </dialog> ` ); @@ -348,6 +364,37 @@ element.revisionActions = {}; assert.isFalse(await isLoading()); }); + + test('chatCapabilitiesLoaded', async () => { + stubFlags('isEnabled').returns(true); + element.aiPluginsRegistered = true; + element.change = {...element.change!, can_ai_review: true}; + chatModel.updateState({ + models: undefined, + actions: undefined, + modelsLoadingError: undefined, + actionsLoadingError: undefined, + }); + element.requestUpdate(); + await element.updateComplete; + const chatButton = queryAndAssert( + element, + 'gr-button[data-action-key="chat"]' + ); + assert.isFalse(chatButton.hasAttribute('loading')); + + (chatButton as HTMLElement).click(); + await element.updateComplete; + assert.isTrue(chatButton.hasAttribute('loading')); + + chatModel.updateState({ + models: {models: [], default_model_id: 'foo'}, + actions: {actions: [], default_action_id: 'bar'}, + }); + element.requestUpdate(); + await element.updateComplete; + assert.isFalse(chatButton.hasAttribute('loading')); + }); }); test('show-revision-actions event should fire', async () => { @@ -539,6 +586,50 @@ assert.deepEqual(result, actions); }); + test('action priority order with plugin action', async () => { + // Create 'reland' via addActionButton to mimic plugin behavior + const relandKey = element.addActionButton(ActionType.CHANGE, 'Reland'); + + // Mock AI Chat action - set can_ai_review before actions to avoid + // change setter overwriting element.actions + stubFlags('isEnabled').returns(true); + element.aiPluginsRegistered = true; + element.change = { + ...element.change!, + can_ai_review: true, + actions: { + abandon: { + method: HttpMethod.POST, + label: 'Abandon', + title: 'Abandon this change', + enabled: true, + }, + }, + }; + element.revisionActions = { + rebase: { + method: HttpMethod.POST, + label: 'Rebase', + title: 'Rebase this change', + enabled: true, + }, + }; + + await element.updateComplete; + await element.reload(); + + const actions = element.topLevelSecondaryActions!; + assert.isOk(actions); + + const relevantKeys = [relandKey, 'chat', 'rebase', 'abandon']; + const relevantActions = actions.filter(a => + relevantKeys.includes(a.__key) + ); + const keys = relevantActions.map(a => a.__key); + + assert.deepEqual(keys, [relandKey, 'chat', 'rebase', 'abandon']); + }); + test('submit change', async () => { const showSpy = sinon.spy(element, 'showActionDialog'); stubRestApi('getRepoName').returns(Promise.resolve('test' as RepoName)); @@ -663,7 +754,7 @@ title: 'Rebase onto tip of branch or parent change', }; assert.isTrue(fetchChangesStub.called); - element.handleRebaseConfirm( + await element.handleRebaseConfirm( new CustomEvent('', { detail: { base: '1234', @@ -716,7 +807,7 @@ title: 'Rebase onto tip of branch or parent change', }; assert.isTrue(fetchChangesStub.called); - element.handleRebaseConfirm( + await element.handleRebaseConfirm( new CustomEvent('', { detail: { base: '1234', @@ -1055,14 +1146,14 @@ title: 'Cherry pick change to a different branch', }; - element.handleCherrypickConfirm(); + await element.handleCherrypickConfirm(); assert.equal(fireActionStub.callCount, 0); queryAndAssert<GrConfirmCherrypickDialog>( element, '#confirmCherrypick' ).branch = 'master' as BranchName; - element.handleCherrypickConfirm(); + await element.handleCherrypickConfirm(); assert.equal(fireActionStub.callCount, 0); // Still needs a message. // Add attributes that are used to determine the message. @@ -1080,7 +1171,7 @@ ).commitNum = '123' as CommitId; await element.updateComplete; - element.handleCherrypickConfirm(); + await element.handleCherrypickConfirm(); await element.updateComplete; const autogrowEl = queryAndAssert<GrAutogrowTextarea>( @@ -1138,7 +1229,7 @@ ).commitNum = '123' as CommitId; await element.updateComplete; - element.handleCherrypickConflictConfirm(); + await element.handleCherrypickConflictConfirm(); await element.updateComplete; assert.deepEqual(fireActionStub.lastCall.args, [ @@ -1155,6 +1246,30 @@ ]); }); + test('handleBeforeCherryPick blocks action', async () => { + const handleBeforeCherryPickStub = sinon + .stub( + testResolver(pluginLoaderToken).jsApiService, + 'handleBeforeCherryPick' + ) + .returns(Promise.resolve(false)); + + element.handleCherrypickTap(); + queryAndAssert<GrConfirmCherrypickDialog>( + element, + '#confirmCherrypick' + ).branch = 'master' as BranchName; + queryAndAssert<GrConfirmCherrypickDialog>( + element, + '#confirmCherrypick' + ).commitMessage = 'foo message'; + await element.updateComplete; + + await element.handleCherrypickConfirm(); + assert.equal(fireActionStub.callCount, 0); + assert.isTrue(handleBeforeCherryPickStub.called); + }); + test('branch name cleared when re-open cherrypick', () => { const emptyBranchName = ''; queryAndAssert<GrConfirmCherrypickDialog>( @@ -2619,45 +2734,6 @@ }); }); - test('sends validation options when opening revert dialog', async () => { - sinon.stub(element, 'handleAction'); - const fireActionStub = sinon.stub(element, 'fireAction'); - element.actions = { - revert: { - method: HttpMethod.POST, - label: 'Revert', - title: 'Revert the change', - enabled: true, - }, - }; - const confirmRevertDialog = query<GrConfirmRevertDialog>( - element, - '#confirmRevertDialog' - ); - assertIsDefined(confirmRevertDialog, 'confirmDialog'); - const populateRevertDialogStub = sinon.stub( - confirmRevertDialog, - 'populate' - ); - await element.showRevertDialog(); - await waitUntil(() => !!populateRevertDialogStub.called); - assert.deepEqual(populateRevertDialogStub.lastCall.args[1], { - validation_options: [{name: 'o1', description: 'option 1'}], - }); - - element.handleRevertDialogConfirm( - new CustomEvent('confirm', { - detail: { - revertType: RevertType.REVERT_SINGLE_CHANGE, - message: 'revert this change', - }, - }) - ); - await waitUntil(() => !!fireActionStub.called); - - assert.deepEqual(fireActionStub.lastCall.args[0], '/revert'); - }); - suite('multiple changes revert', () => { let showActionDialogStub: sinon.SinonStub; let setUrlStub: sinon.SinonStub; @@ -2770,11 +2846,17 @@ 'executeChangeAction' ).callsFake( ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any _num: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _method: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _patchNum: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _endpoint: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any _payload: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any onErr: any ) => { onErr!(); @@ -2803,6 +2885,7 @@ test('revert single change change not reachable', async () => { let getChangeCall = 0; let errorFired = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any stubRestApi('getChange').callsFake((_: any, errFn: any) => { ++getChangeCall; if (getChangeCall === 1) { @@ -2893,7 +2976,7 @@ stubRestApi('getChangeRevisionActions').returns( Promise.resolve(changeRevisionActions) ); - stubRestApi('send').returns(Promise.reject(new Error('error'))); + stubRestApi('send').returns(Promise.resolve(new Response())); sinon .stub(testResolver(pluginLoaderToken), 'awaitPluginsLoaded') @@ -2928,4 +3011,85 @@ ); }); }); + + suite('canSubmitChange', () => { + let element: GrChangeActions; + + setup(async () => { + element = await fixture<GrChangeActions>(html` + <gr-change-actions></gr-change-actions> + `); + element.change = createChangeViewChange(); + element.changeNum = 42 as NumericChangeId; + element.latestPatchNum = 2 as PatchSetNumber; + sinon + .stub(testResolver(pluginLoaderToken).jsApiService, 'canSubmitChange') + .returns(true); + await element.updateComplete; + }); + + test('show error if a reviewer is deleted', async () => { + element.change = { + ...createChangeViewChange(), + labels: { + 'Code-Review': { + all: [ + { + _account_id: 1 as AccountId, + name: 'name 1', + deleted: true, + }, + ], + values: { + '-1': 'No', + ' 0': 'No score', + '+1': 'Yes', + }, + }, + }, + }; + await element.updateComplete; + const showDialogSpy = sinon.spy(element, 'showActionDialog'); + + const canSubmit = element.canSubmitChange(); + + assert.isFalse(canSubmit); + assert.isTrue(showDialogSpy.called); + assert.deepEqual(element.deletedReviewers, [ + { + _account_id: 1 as AccountId, + name: 'name 1', + deleted: true, + }, + ]); + }); + + test('return true if no reviewer is deleted', async () => { + element.change = { + ...createChangeViewChange(), + labels: { + 'Code-Review': { + all: [ + { + _account_id: 1 as AccountId, + name: 'name 1', + }, + ], + values: { + '-1': 'No', + ' 0': 'No score', + '+1': 'Yes', + }, + }, + }, + }; + await element.updateComplete; + const showDialogSpy = sinon.spy(element, 'showActionDialog'); + + const canSubmit = element.canSubmitChange(); + + assert.isTrue(canSubmit); + assert.isFalse(showDialogSpy.called); + }); + }); });
diff --git a/polygerrit-ui/app/elements/change/gr-change-autocomplete/gr-change-autocomplete.ts b/polygerrit-ui/app/elements/change/gr-change-autocomplete/gr-change-autocomplete.ts index b1e5e82..9bef8a5 100644 --- a/polygerrit-ui/app/elements/change/gr-change-autocomplete/gr-change-autocomplete.ts +++ b/polygerrit-ui/app/elements/change/gr-change-autocomplete/gr-change-autocomplete.ts
@@ -82,7 +82,7 @@ /* changeNumber=*/ 450, `is:open -age:90d ${this.projectQuery}`, /* offset=*/ undefined, - /* options=*/ undefined, + /* options=*/ '0', throwingErrorCallback ); if (!res) {
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 0da17d0..ae9e411 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
@@ -625,7 +625,7 @@ ...createParsedChange(), current_revision: '456' as CommitId, revisions: {456: revision('111' as CommitId)}, - owner: {}, + owner: createAccountWithId(), }; element.revision = revision('222' as CommitId); await element.updateComplete;
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 62ac816..4bbd8be 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
@@ -46,7 +46,6 @@ import {assertIsDefined} from '../../../utils/common-util'; import {GrAiPromptDialog} from '../gr-ai-prompt-dialog/gr-ai-prompt-dialog'; import {flowsModelToken} from '../../../models/flows/flows-model'; -import {KnownExperimentId} from '../../../services/flags/flags'; function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) { if (modifierPressed(e)) return; @@ -105,6 +104,9 @@ @state() flows: FlowInfo[] = []; + @state() + canAiReview = false; + @query('#aiPromptModal') aiPromptModal?: HTMLDialogElement; @@ -123,8 +125,6 @@ private readonly getFlowsModel = resolve(this, flowsModelToken); - private readonly flagsService = getAppContext().flagsService; - private readonly reporting = getAppContext().reportingService; constructor() { @@ -205,6 +205,13 @@ () => this.getFlowsModel().flows$, x => (this.flows = x) ); + subscribe( + this, + () => this.getChangeModel().change$, + change => { + this.canAiReview = change?.can_ai_review !== false; + } + ); } static override get styles() { @@ -318,6 +325,14 @@ line-height: var(--line-height-normal); color: var(--primary-text-color); } + /* + * https://github.com/angular/components/issues/31207 + * It's not possible to assign a custom touch target to md-buttons. + * This results in the buttons overflowing into the checks-chip. + */ + gr-checks-chip { + z-index: 2; + } `, ]; } @@ -594,28 +609,25 @@ showCommentCategoryName clickableChips ></gr-comments-summary> - ${when( - this.flagsService.isEnabled(KnownExperimentId.GET_AI_PROMPT), - () => - html`<gr-button link @click=${this.handleOpenAiPromptDialog} + ${this.canAiReview + ? html`<gr-button link @click=${this.handleOpenAiPromptDialog} >Create AI Review Prompt</gr-button >` - )} + : nothing} </div> </td> </tr> ${this.renderChecksSummary()} ${this.renderFlowsSummary()} </table> </div> - ${when( - this.flagsService.isEnabled(KnownExperimentId.GET_AI_PROMPT), - () => html` <dialog id="aiPromptModal" tabindex="-1"> - <gr-ai-prompt-dialog - id="aiPromptDialog" - @close=${this.handleAiPromptDialogClose} - ></gr-ai-prompt-dialog> - </dialog>` - )} + ${this.canAiReview + ? html`<dialog id="aiPromptModal" tabindex="-1"> + <gr-ai-prompt-dialog + id="aiPromptDialog" + @close=${this.handleAiPromptDialogClose} + ></gr-ai-prompt-dialog> + </dialog>` + : nothing} `; }
diff --git a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts index 318187d..ed3e0d0 100644 --- a/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts +++ b/polygerrit-ui/app/elements/change/gr-change-summary/gr-change-summary_screenshot_test.ts
@@ -14,6 +14,7 @@ createCheckResult, createComment, createDraft, + createParsedChange, createRun, } from '../../../test/test-data-generators'; import {testResolver} from '../../../test/common-test-setup'; @@ -22,6 +23,7 @@ import {Category, RunStatus} from '../../../api/checks'; import {CheckRun} from '../../../models/checks/checks-model'; import {visualDiffDarkTheme} from '../../../test/test-utils'; +import {changeModelToken} from '../../../models/change/change-model'; suite('gr-change-summary screenshot tests', () => { let element: GrChangeSummary; @@ -78,4 +80,21 @@ await visualDiff(element, 'gr-change-summary-with-chips'); await visualDiffDarkTheme(element, 'gr-change-summary-with-chips'); }); + + test('screenshot with AI Review Prompt', async () => { + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: { + ...createParsedChange(), + can_ai_review: undefined as unknown as boolean, + }, + }); + + await element.updateComplete; + await visualDiff(element, 'gr-change-summary-with-ai-review-prompt'); + await visualDiffDarkTheme( + element, + 'gr-change-summary-with-ai-review-prompt' + ); + }); });
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 7538656..fbfaf97 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
@@ -7,7 +7,7 @@ import {assert, fixture, html} from '@open-wc/testing'; import {GrChangeSummary} from './gr-change-summary'; import {queryAll, queryAndAssert} from '../../../utils/common-util'; -import {fakeRun0} from '../../../models/checks/checks-fakes'; +import {checkRun0} from '../../../test/test-data-generators'; import { createAccountWithEmail, createCheckResult, @@ -27,8 +27,6 @@ import {CheckRun} from '../../../models/checks/checks-model'; import {Category, RunStatus} from '../../../api/checks'; import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model'; -import {getAppContext} from '../../../services/app-context'; -import {KnownExperimentId} from '../../../services/flags/flags'; function createFlow(partial: Partial<FlowInfo> = {}): FlowInfo { return { @@ -59,116 +57,106 @@ }); test('renders', async () => { - const flagsService = getAppContext().flagsService; - const isEnabledStub = sinon.stub(flagsService, 'isEnabled'); - isEnabledStub.returns(false); - isEnabledStub.withArgs(KnownExperimentId.GET_AI_PROMPT).returns(true); - - try { - commentsModel.setState({ - drafts: { - a: [createDraft(), createDraft(), createDraft()], - }, - discardedDrafts: [], - }); - element.commentsLoading = false; - element.commentThreads = [ - createCommentThread([createComment()]), - createCommentThread([{...createComment(), unresolved: true}]), - ]; - await element.updateComplete; - assert.shadowDom.equal( - element, - /* HTML */ ` - <div> - <table class="info"> - <tbody> - <tr> - <td class="key">Comments</td> - <td class="value"> - <div class="value-content"> - <gr-comments-summary - clickablechips="" - showcommentcategoryname="" - ></gr-comments-summary> - <gr-button - aria-disabled="false" - link="" - role="button" - tabindex="0" - > - Create AI Review Prompt - </gr-button> - </div> - </td> - </tr> - </tbody> - </table> - </div> - <dialog id="aiPromptModal" tabindex="-1"> - <gr-ai-prompt-dialog id="aiPromptDialog" role="dialog"> - </gr-ai-prompt-dialog> - </dialog> - ` - ); - } finally { - isEnabledStub.restore(); - } + commentsModel.setState({ + drafts: { + a: [createDraft(), createDraft(), createDraft()], + }, + discardedDrafts: [], + }); + element.commentsLoading = false; + element.commentThreads = [ + createCommentThread([createComment()]), + createCommentThread([{...createComment(), unresolved: true}]), + ]; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div> + <table class="info"> + <tbody> + <tr> + <td class="key">Comments</td> + <td class="value"> + <div class="value-content"> + <gr-comments-summary + clickablechips="" + showcommentcategoryname="" + ></gr-comments-summary> + <gr-button + aria-disabled="false" + link="" + role="button" + tabindex="0" + > + Create AI Review Prompt + </gr-button> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <dialog id="aiPromptModal" tabindex="-1"> + <gr-ai-prompt-dialog id="aiPromptDialog" role="dialog"> + </gr-ai-prompt-dialog> + </dialog> + ` + ); }); - test('does not render AI review prompt when experiment is disabled', async () => { - const flagsService = getAppContext().flagsService; - const isEnabledStub = sinon.stub(flagsService, 'isEnabled'); - isEnabledStub.returns(true); - isEnabledStub.withArgs(KnownExperimentId.GET_AI_PROMPT).returns(false); - - try { - const disabledElement = await fixture<GrChangeSummary>( - html`<gr-change-summary></gr-change-summary>` - ); - - commentsModel.setState({ - drafts: { - a: [createDraft(), createDraft(), createDraft()], - }, - discardedDrafts: [], - }); - disabledElement.commentsLoading = false; - disabledElement.commentThreads = [ - createCommentThread([createComment()]), - createCommentThread([{...createComment(), unresolved: true}]), - ]; - - await disabledElement.updateComplete; - assert.shadowDom.equal( - disabledElement, - /* HTML */ ` - <div> - <table class="info"> - <tbody> - <tr> - <td class="key">Comments</td> - <td class="value"> - <div class="value-content"> - <gr-comments-summary - clickablechips="" - showcommentcategoryname="" - ></gr-comments-summary> - </div> - </td> - </tr> - </tbody> - </table> - </div> - ` - ); - } finally { - isEnabledStub.restore(); - } + test('renders AI review button when canAiReview is true', async () => { + commentsModel.setState({ + drafts: { + a: [createDraft(), createDraft(), createDraft()], + }, + discardedDrafts: [], + }); + element.commentsLoading = false; + element.commentThreads = [ + createCommentThread([createComment()]), + createCommentThread([{...createComment(), unresolved: true}]), + ]; + element.canAiReview = true; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div> + <table class="info"> + <tbody> + <tr> + <td class="key">Comments</td> + <td class="value"> + <div class="value-content"> + <gr-comments-summary + clickablechips="" + showcommentcategoryname="" + ></gr-comments-summary> + <gr-button + aria-disabled="false" + link="" + role="button" + tabindex="0" + > + Create AI Review Prompt + </gr-button> + </div> + </td> + </tr> + </tbody> + </table> + </div> + <dialog id="aiPromptModal" tabindex="-1"> + <gr-ai-prompt-dialog id="aiPromptDialog" role="dialog"> + </gr-ai-prompt-dialog> + </dialog> + ` + ); }); test('renders checks summary message', async () => { - element.runs = [fakeRun0]; + element.runs = [checkRun0]; element.messages = ['a message']; element.showChecksSummary = true; await element.updateComplete; @@ -290,6 +278,9 @@ }), ], loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], }); await element.updateComplete; const flowsSummary = queryAndAssert(element, '.flowsSummary');
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 6b8e026..31a107a 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
@@ -4,10 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ import {BehaviorSubject} from 'rxjs'; -import '../gr-copy-links/gr-copy-links'; import '../../../styles/gr-a11y-styles'; import '../../../styles/gr-material-styles'; import '../../../styles/shared-styles'; +import '../../chat-panel/chat-panel'; +import '../../checks/gr-checks-tab'; +import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog'; import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator'; import '../../plugins/gr-endpoint-param/gr-endpoint-param'; import '../../shared/gr-button/gr-button'; @@ -16,6 +18,8 @@ import '../../shared/gr-editable-content/gr-editable-content'; import '../../shared/gr-formatted-text/gr-formatted-text'; import '../../shared/gr-tooltip-content/gr-tooltip-content'; +import '../../shared/gr-content-with-sidebar/gr-content-with-sidebar'; +import '../gr-copy-links/gr-copy-links'; import '../gr-change-actions/gr-change-actions'; import '../gr-change-summary/gr-change-summary'; import '../gr-change-metadata/gr-change-metadata'; @@ -27,10 +31,8 @@ import '../gr-included-in-dialog/gr-included-in-dialog'; import '../gr-messages-list/gr-messages-list'; import '../gr-related-changes-list/gr-related-changes-list'; -import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog'; import '../gr-reply-dialog/gr-reply-dialog'; import '../gr-thread-list/gr-thread-list'; -import '../../checks/gr-checks-tab'; import '../gr-flows/gr-flows'; import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star'; import {GrEditConstants} from '../../edit/gr-edit-constants'; @@ -88,12 +90,14 @@ import { EditableContentSaveEvent, FileActionTapEvent, + OpenDiffInChangeViewEvent, OpenFixPreviewEvent, ShowReplyDialogEvent, SwitchTabEvent, TabState, ValueChangedEvent, } from '../../../types/events'; +import {Side} from '../../../api/diff'; import {GrButton} from '../../shared/gr-button/gr-button'; import {GrMessagesList} from '../gr-messages-list/gr-messages-list'; import {GrThreadList} from '../gr-thread-list/gr-thread-list'; @@ -125,6 +129,7 @@ import {materialStyles} from '../../../styles/gr-material-styles'; import {sharedStyles} from '../../../styles/shared-styles'; import {ifDefined} from 'lit/directives/if-defined.js'; +import {ref} from 'lit/directives/ref.js'; import {when} from 'lit/directives/when.js'; import {ShortcutController} from '../../lit/shortcut-controller'; import {FilesExpandedState} from '../gr-file-list-constants'; @@ -143,7 +148,7 @@ 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'; -import {KnownExperimentId} from '../../../services/flags/flags'; +import {flowsModelToken} from '../../../models/flows/flows-model'; import {assign} from '../../../utils/location-util'; import '@material/web/tabs/secondary-tab'; import '@material/web/tabs/tabs'; @@ -160,6 +165,7 @@ const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm; const PREFIX = '#message-'; + @customElement('gr-change-view') export class GrChangeView extends LitElement { /** @@ -350,6 +356,9 @@ @state() activeTab: Tab | string = Tab.FILES; + @state() + private showSidebarChat = false; + @property({type: Boolean}) unresolvedOnly = true; @@ -370,8 +379,9 @@ @state() private revertingChange?: ChangeInfo; + // Private but used in tests. @state() - isFlowsEnabled = false; + flowsTabEnabled = false; // Private but used in tests. @state() @@ -406,6 +416,8 @@ private readonly getViewModel = resolve(this, changeViewModelToken); + private readonly getFlowsModel = resolve(this, flowsModelToken); + private readonly getRelatedChangesModel = resolve( this, relatedChangesModelToken @@ -441,6 +453,16 @@ private readonly getNavigation = resolve(this, navigationToken); + private headerResizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + const height = entry.borderBoxSize[0].blockSize; + document.documentElement.style.setProperty( + '--change-header-height', + `${height}px` + ); + } + }); + constructor() { super(); this.setupListeners(); @@ -458,7 +480,14 @@ this.handleCommitMessageCancel() ); this.addEventListener('open-fix-preview', e => this.onOpenFixPreview(e)); + this.addEventListener('open-diff-in-change-view', e => + this.onOpenDiffInChangeView(e) + ); this.addEventListener('show-tab', e => this.setActiveTab(e)); + this.addEventListener( + 'close-chat-panel', + () => (this.showSidebarChat = false) + ); } private setupShortcuts() { @@ -625,15 +654,17 @@ ); subscribe( this, + () => this.getFlowsModel().enabled$, + enabled => (this.flowsTabEnabled = enabled) + ); + subscribe( + this, () => 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; - this.restApiService.getIfFlowsIsEnabled(this.changeNum).then(res => { - this.isFlowsEnabled = res?.enabled ?? false; - }); } } ); @@ -790,6 +821,12 @@ sharedStyles, modalStyles, css` + :host { + --sidebar-top: calc( + var(--main-header-height) + var(--change-header-height, 38px) + ); + --sidebar-bottom-overflow: var(--main-footer-height); + } .tabs { display: flex; } @@ -805,7 +842,11 @@ background-color: var(--background-color-primary); border-bottom: 2px solid var(--border-color); display: flex; + flex-wrap: wrap; padding: var(--spacing-s) var(--spacing-l); + position: sticky; + top: var(--main-header-height); + z-index: 110; } .header.active { border-color: var(--status-active); @@ -835,16 +876,11 @@ .header .download { margin-right: var(--spacing-l); } - gr-change-status { - margin-left: var(--spacing-s); - } - gr-change-status:first-child { - margin-left: 0; - } .headerTitle { align-items: center; display: flex; flex: 1; + min-width: 500px; } .headerSubject { font-family: var(--header-font-family); @@ -853,7 +889,7 @@ line-height: var(--line-height-h3); margin-left: var(--spacing-l); line-break: anywhere; - whitespace: no-wrap; + white-space: nowrap; overflow: auto; } .changeNumberColon { @@ -887,8 +923,6 @@ .changeStatus { text-transform: capitalize; } - /* Strong specificity here is needed due to - https://github.com/Polymer/polymer/issues/2531 */ .container .changeInfo { display: flex; background-color: var(--background-color-secondary); @@ -926,6 +960,7 @@ } .changeStatuses { flex-wrap: wrap; + gap: var(--spacing-s); } .mainChangeInfo { display: flex; @@ -1041,7 +1076,8 @@ align-items: flex-start; flex-direction: column; flex: 1; - padding: var(--spacing-s) var(--spacing-l); + padding: var(--spacing-l) var(--spacing-l); + height: unset; } .headerTitle { flex-wrap: wrap; @@ -1049,6 +1085,7 @@ font-size: var(--font-size-h3); font-weight: var(--font-weight-h3); line-height: var(--line-height-h3); + min-width: 0; } .desktop { display: none; @@ -1100,6 +1137,9 @@ .tabContent gr-thread-list::part(threads) { padding: var(--spacing-l); } + .sidebar { + height: var(--sidebar-height); + } `, ]; } @@ -1117,12 +1157,18 @@ private renderMainContent() { return html` - <div id="mainContent" class="container" ?hidden=${!!this.loading}> - ${this.renderChangeInfoSection()} - <h2 class="assistive-tech-only">Files and Comments tabs</h2> - ${this.renderTabHeaders()} ${this.renderTabContent()} - ${this.renderChangeLog()} - </div> + ${this.renderHeader()} + <gr-content-with-sidebar .hideSide=${!this.showSidebarChat}> + <div slot="main"> + <div id="mainContent" class="container" ?hidden=${!!this.loading}> + ${this.renderChangeInfoSection()} + <h2 class="assistive-tech-only">Files and Comments tabs</h2> + ${this.renderTabHeaders()} ${this.renderTabContent()} + ${this.renderChangeLog()} + </div> + </div> + <div class="sidebar" slot="side">${this.renderSidebar()}</div> + </gr-content-with-sidebar> <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog> <dialog id="downloadModal" tabindex="-1"> <gr-download-dialog @@ -1154,17 +1200,38 @@ `; } - private renderChangeInfoSection() { - return html`<section class="changeInfoSection"> - <div class=${this.computeHeaderClass()}> + private onHeaderCreated(el?: Element) { + if (el) this.headerResizeObserver.observe(el); + } + + private renderHeader() { + if (this.loading) return; + return html` + <div class=${this.computeHeaderClass()} ${ref(this.onHeaderCreated)}> <h1 class="assistive-tech-only"> Change ${this.change?._number}: ${this.change?.subject} </h1> ${this.renderHeaderTitle()} ${this.renderCommitActions()} </div> - <h2 class="assistive-tech-only">Change metadata</h2> - ${this.renderChangeInfo()} - </section>`; + `; + } + + private toggleChat() { + this.showSidebarChat = !this.showSidebarChat; + } + + private renderSidebar() { + if (!this.showSidebarChat) return; + return html`<chat-panel></chat-panel>`; + } + + private renderChangeInfoSection() { + return html` + <section class="changeInfoSection"> + <h2 class="assistive-tech-only">Change metadata</h2> + ${this.renderChangeInfo()} + </section> + `; } private renderHeaderTitle() { @@ -1278,6 +1345,7 @@ <div class="commitActions"> <gr-change-actions id="actions" + @ai-chat=${() => this.toggleChat()} @edit-tap=${() => this.handleEditTap()} @stop-edit-tap=${() => this.handleStopEditTap()} @download-tap=${() => this.handleOpenDownloadDialog()} @@ -1392,8 +1460,7 @@ ` )} ${when( - this.flagService.isEnabled(KnownExperimentId.SHOW_FLOWS_TAB) && - this.isFlowsEnabled, + this.flowsTabEnabled, () => html` <md-secondary-tab data-name=${Tab.FLOWS} @@ -1609,7 +1676,7 @@ /** * Currently there is a bug in this code where this.unresolvedOnly is only - * assigned the correct value when onPaperTabClick is triggered which is + * assigned the correct value when onMdSecondaryTabClick is triggered which is * only triggered when user explicitly clicks on the tab however the comments * tab can also be opened via the url in which case the correct value to * unresolvedOnly is never assigned. @@ -1617,7 +1684,7 @@ private onMdSecondaryTabClick(e: MouseEvent) { let target = e.target as HTMLElement | null; let tabName: string | undefined; - // target can be slot child of papertab, so we search for tabName in parents + // target can be slot child of tab, so we search for tabName in parents do { tabName = target?.dataset?.['name']; if (tabName) break; @@ -1833,6 +1900,47 @@ this.fileList.collapseAllDiffs(); } + private async onOpenDiffInChangeView(e: OpenDiffInChangeViewEvent) { + if (!this.fileList) return; + const {path, lineNum, side} = e.detail; + + if (this.activeTab !== Tab.FILES) { + this.setActiveTab( + new CustomEvent('show-tab', {detail: {tab: Tab.FILES}}) + ); + } + + const fileIndex = this.fileList.files.findIndex(f => f.__path === path); + if (fileIndex !== -1) { + this.fileList.fileCursor.setCursorAtIndex(fileIndex, true); + const isExpanded = this.fileList.expandedFiles.some(f => f.path === path); + if (!isExpanded) { + this.fileList.toggleFileExpandedByIndex(fileIndex); + await this.fileList.updateComplete; + await this.fileList.filesExpandedPromise; + } + if (lineNum !== undefined) { + await waitUntil(() => { + const diffHost = this.fileList!.diffs.find(d => d.path === path); + return !!diffHost; + }); + const diffHost = this.fileList.diffs.find(d => d.path === path); + if (diffHost) { + if (isExpanded) { + await diffHost.waitForReloadToRender(); + } + // Wait another event loop tick for the dom to settle. + await new Promise(resolve => setTimeout(resolve)); + this.fileList.diffCursor?.moveToLineNumber( + lineNum, + side ?? Side.RIGHT, + path + ); + } + } + } + } + /** * ChangeView is never re-used for different changes. It is safer and simpler * to just re-create another change view when the user switches to a new
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts new file mode 100644 index 0000000..713a1ea --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_screenshot_test.ts
@@ -0,0 +1,390 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import './gr-change-view'; +import {fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import {setViewport} from '@web/test-runner-commands'; +import {GrChangeView} from './gr-change-view'; +import { + chatProvider, + createAccountDetailWithId, + createChangeViewChange, + createRevisions, + createServerInfo, + createUserConfig, + TEST_NUMERIC_CHANGE_ID, +} from '../../../test/test-data-generators'; +import { + stubRestApi, + visualDiffDarkTheme, + waitUntil, +} from '../../../test/test-utils'; +import {testResolver} from '../../../test/common-test-setup'; +import {changeModelToken} from '../../../models/change/change-model'; +import {commentsModelToken} from '../../../models/comments/comments-model'; +import { + ChangeChildView, + changeViewModelToken, +} from '../../../models/views/change'; +import {GerritView} from '../../../services/router/router-model'; +import { + AccountId, + ActionInfo, + EmailAddress, + NumericChangeId, + RepoName, + RevisionPatchSetNum, + Timestamp, +} from '../../../types/common'; +import {HttpMethod} from '../../../api/rest-api'; +import {navigationToken} from '../../core/gr-navigation/gr-navigation'; +import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; +import {ParsedChangeInfo} from '../../../types/types'; +import {ChangeStatus} from '../../../constants/constants'; +import {NormalizedFileInfo} from '../../../models/change/files-model'; +import * as sinon from 'sinon'; + +suite('gr-change-view screenshot tests', () => { + let element: GrChangeView; + + function createMockFiles(): NormalizedFileInfo[] { + return [ + { + __path: '/COMMIT_MSG', + lines_inserted: 10, + lines_deleted: 0, + size_delta: 350, + size: 350, + }, + { + __path: 'src/main/java/com/google/gerrit/server/ChangeUtil.java', + lines_inserted: 45, + lines_deleted: 12, + size_delta: 1250, + size: 8500, + }, + { + __path: + 'src/main/java/com/google/gerrit/server/project/ProjectState.java', + lines_inserted: 8, + lines_deleted: 3, + size_delta: 200, + size: 4200, + }, + { + __path: 'src/test/java/com/google/gerrit/server/ChangeUtilTest.java', + lines_inserted: 120, + lines_deleted: 0, + size_delta: 3500, + size: 3500, + }, + { + __path: + 'polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts', + lines_inserted: 25, + lines_deleted: 10, + size_delta: 450, + size: 15000, + }, + { + __path: 'polygerrit-ui/app/elements/shared/gr-button/gr-button.ts', + lines_inserted: 5, + lines_deleted: 2, + size_delta: 100, + size: 2500, + }, + { + __path: 'Documentation/rest-api-changes.txt', + lines_inserted: 30, + lines_deleted: 5, + size_delta: 800, + size: 45000, + }, + ]; + } + + function createActions(): {[key: string]: ActionInfo} { + return { + abandon: { + method: HttpMethod.POST, + label: 'Abandon', + title: 'Abandon the change', + enabled: true, + }, + rebase: { + method: HttpMethod.POST, + label: 'Rebase', + title: 'Rebase the change', + enabled: true, + }, + submit: { + method: HttpMethod.POST, + label: 'Submit', + title: 'Submit the change', + enabled: false, + }, + }; + } + + setup(async () => { + sinon.stub(testResolver(navigationToken), 'setUrl'); + sinon + .stub(testResolver(changeViewModelToken), 'editUrl') + .returns('fakeEditUrl'); + sinon + .stub(testResolver(changeViewModelToken), 'diffUrl') + .returns('fakeDiffUrl'); + + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + stubRestApi('getConfig').returns( + Promise.resolve({ + ...createServerInfo(), + user: { + ...createUserConfig(), + anonymous_coward_name: 'test coward name', + }, + }) + ); + stubRestApi('getAccount').returns( + Promise.resolve(createAccountDetailWithId(5)) + ); + stubRestApi('getIfFlowsIsEnabled').returns( + Promise.resolve({enabled: false}) + ); + stubRestApi('getDiffComments').returns(Promise.resolve({})); + stubRestApi('getDiffDrafts').returns(Promise.resolve({})); + + const change: ParsedChangeInfo = { + ...createChangeViewChange(), + _number: TEST_NUMERIC_CHANGE_ID, + subject: 'Implement new feature for code review improvements', + status: ChangeStatus.NEW, + mergeable: true, + owner: { + _account_id: 1000 as AccountId, + name: 'John Developer', + email: 'john@example.com' as EmailAddress, + }, + revisions: createRevisions(3), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + current_revision: '3' as any, + actions: createActions(), + labels: { + 'Code-Review': { + all: [ + { + value: 2, + _account_id: 1001 as AccountId, + name: 'Reviewer One', + }, + ], + values: { + '-2': 'This shall not be merged', + '-1': 'I would prefer this is not merged as is', + ' 0': 'No score', + '+1': 'Looks good to me, but someone else must approve', + '+2': 'Looks good to me, approved', + }, + default_value: 0, + }, + Verified: { + all: [ + { + value: 1, + _account_id: 1002 as AccountId, + name: 'CI Bot', + }, + ], + values: { + '-1': 'Fails', + ' 0': 'No score', + '+1': 'Verified', + }, + default_value: 0, + }, + }, + reviewers: { + REVIEWER: [ + { + _account_id: 1001 as AccountId, + name: 'Reviewer One', + email: 'reviewer1@example.com' as EmailAddress, + }, + { + _account_id: 1003 as AccountId, + name: 'Reviewer Two', + email: 'reviewer2@example.com' as EmailAddress, + }, + ], + CC: [ + { + _account_id: 1004 as AccountId, + name: 'Watcher', + email: 'watcher@example.com' as EmailAddress, + }, + ], + }, + insertions: 243, + deletions: 32, + updated: '2025-01-09 10:30:00.000000000' as Timestamp, + created: '2025-01-08 14:20:00.000000000' as Timestamp, + }; + + const changeModel = testResolver(changeModelToken); + changeModel.updateStateChange(change); + + // Set comments model to a settled state (not loading) + const commentsModel = testResolver(commentsModelToken); + commentsModel.setState({ + comments: {}, + drafts: {}, + portedComments: {}, + portedDrafts: {}, + discardedDrafts: [], + }); + + element = await fixture<GrChangeView>( + html`<gr-change-view></gr-change-view>` + ); + element.viewState = { + view: GerritView.CHANGE, + childView: ChangeChildView.OVERVIEW, + changeNum: TEST_NUMERIC_CHANGE_ID, + repo: 'gerrit' as RepoName, + edit: true, // Enable edit mode to show Edit button + }; + element.patchNum = 3 as RevisionPatchSetNum; + + await element.updateComplete; + + // Wait for the change to load + await waitUntil(() => element.change !== undefined); + await element.updateComplete; + + // Set files on the file list + if (element.fileList) { + element.fileList.files = createMockFiles(); + await element.fileList.updateComplete; + } + + await element.updateComplete; + }); + + test('full page at 801px width', async () => { + // Set viewport to ensure media queries respond correctly + await setViewport({width: 801, height: 900}); + + const container = document.createElement('div'); + container.style.width = '801px'; + container.style.height = '900px'; + container.style.overflow = 'hidden'; + container.style.display = 'block'; + container.style.backgroundColor = 'var(--view-background-color, #fff)'; + container.appendChild(element); + document.body.appendChild(container); + + try { + // Wait for all nested components to render + await waitUntil(() => !!element.fileList); + await element.updateComplete; + if (element.fileList) { + await element.fileList.updateComplete; + } + // Additional wait for any remaining async rendering + await waitUntil( + () => !!element.shadowRoot!.querySelector('gr-file-list') + ); + + await visualDiff(container, 'gr-change-view-801px'); + await visualDiffDarkTheme(container, 'gr-change-view-801px'); + } finally { + document.body.removeChild(container); + } + }); + + test('wrapped statuses 801px', async () => { + // Set viewport to ensure media queries respond correctly + await setViewport({width: 801, height: 900}); + + const changeModel = testResolver(changeModelToken); + changeModel.updateStateChange({ + ...element.change!, + revert_of: 12345 as NumericChangeId, + submittable: true, + subject: 'Reland "Add initial jj support to `gclient sync`."', + }); + await element.updateComplete; + + const container = document.createElement('div'); + container.style.width = '801px'; + container.style.height = '900px'; + container.style.overflow = 'hidden'; + container.style.display = 'block'; + container.style.backgroundColor = 'var(--view-background-color, #fff)'; + container.appendChild(element); + document.body.appendChild(container); + + try { + // Wait for all nested components to render + await waitUntil(() => !!element.fileList); + await element.updateComplete; + if (element.fileList) { + await element.fileList.updateComplete; + } + // Additional wait for any remaining async rendering + await waitUntil( + () => !!element.shadowRoot!.querySelector('gr-file-list') + ); + + await visualDiff(container, 'gr-change-view-wrapped-statuses-801px'); + } finally { + document.body.removeChild(container); + } + }); + + test('full page 1280px with chat panel', async () => { + // Set viewport to ensure media queries respond correctly + await setViewport({width: 1280, height: 800}); + // Force open the chat panel + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (element as any).showSidebarChat = true; + + const container = document.createElement('div'); + container.style.width = '1280px'; + container.style.height = '800px'; + container.style.overflow = 'hidden'; + container.style.display = 'block'; + container.style.backgroundColor = 'var(--view-background-color, #fff)'; + container.appendChild(element); + document.body.appendChild(container); + + try { + // Wait for all nested components to render + await waitUntil(() => !!element.fileList); + await element.updateComplete; + if (element.fileList) { + await element.fileList.updateComplete; + } + // Additional wait for any remaining async rendering + await waitUntil( + () => !!element.shadowRoot!.querySelector('gr-file-list') + ); + + await visualDiff(container, 'gr-change-view-1280px-chat-open'); + await visualDiffDarkTheme(container, 'gr-change-view-1280px-chat-open'); + } finally { + document.body.removeChild(container); + } + }); +});
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 0409f80..83fff44 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
@@ -293,10 +293,6 @@ changeNum: TEST_NUMERIC_CHANGE_ID, 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); }); @@ -310,147 +306,127 @@ element, /* HTML */ ` <div class="container loading">Loading...</div> - <div class="container" hidden="" id="mainContent"> - <section class="changeInfoSection"> - <div class="header"> - <h1 class="assistive-tech-only">Change :</h1> - <div class="headerTitle"> - <div class="changeStatuses"></div> - <div class="changeStarContainer"> - <gr-button - aria-disabled="false" - class="showCopyLinkDialogButton" - down-arrow="" - flatten="" - id="copyLinkDialogButton" - role="button" + <gr-content-with-sidebar> + <div slot="main"> + <div class="container" hidden="" id="mainContent"> + <section class="changeInfoSection"> + <h2 class="assistive-tech-only">Change metadata</h2> + <div class="changeInfo"> + <div class="changeInfo-column changeMetadata"> + <gr-change-metadata id="metadata"> </gr-change-metadata> + </div> + <div + class="changeInfo-column mainChangeInfo" + id="mainChangeInfo" + > + <div id="commitAndRelated"> + <div class="commitContainer"> + <h3 class="assistive-tech-only">Commit Message</h3> + <div> + <gr-button + aria-disabled="false" + class="reply" + id="replyBtn" + primary="" + role="button" + tabindex="0" + title="Open reply dialog to publish comments and add reviewers (shortcut: a)" + > + Reply + </gr-button> + </div> + <div class="commitMessage" id="commitMessage"> + <gr-editable-content + id="commitMessageEditor" + remove-zero-width-space="" + > + <gr-formatted-text> </gr-formatted-text> + </gr-editable-content> + </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"> </gr-endpoint-param> + <gr-endpoint-param name="revision"> + </gr-endpoint-param> + </gr-endpoint-decorator> + </div> + <div class="relatedChanges"> + <gr-related-changes-list> </gr-related-changes-list> + </div> + <div class="emptySpace"></div> + </div> + </div> + </div> + </section> + <h2 class="assistive-tech-only">Files and Comments tabs</h2> + <div class="tabs"> + <md-tabs id="tabs"> + <md-secondary-tab + active="" + data-name="files" + md-tab="" tabindex="0" > - <gr-change-star id="changeStar"> </gr-change-star> - <a aria-label="Change undefined" class="changeNumber"> </a> - </gr-button> - </div> - <div class="headerSubject"></div> - <gr-copy-clipboard - class="changeCopyClipboard" - hideinput="" - text="undefined: undefined | http://localhost:9876undefined" - > - </gr-copy-clipboard> - </div> - <div class="commitActions"> - <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"> - <gr-change-metadata id="metadata"> </gr-change-metadata> - </div> - <div class="changeInfo-column mainChangeInfo" id="mainChangeInfo"> - <div id="commitAndRelated"> - <div class="commitContainer"> - <h3 class="assistive-tech-only">Commit Message</h3> - <div> - <gr-button - aria-disabled="false" - class="reply" - id="replyBtn" - primary="" - role="button" - tabindex="0" - title="Open reply dialog to publish comments and add reviewers (shortcut: a)" - > - Reply - </gr-button> - </div> - <div class="commitMessage" id="commitMessage"> - <gr-editable-content - id="commitMessageEditor" - remove-zero-width-space="" - > - <gr-formatted-text> </gr-formatted-text> - </gr-editable-content> - </div> - <h3 class="assistive-tech-only"> - Comments and Checks Summary - </h3> - <gr-change-summary> </gr-change-summary> - <gr-endpoint-decorator name="commit-container"> + <span> Files </span> + </md-secondary-tab> + <md-secondary-tab + class="commentThreads" + data-name="comments" + md-tab="" + tabindex="-1" + > + <gr-tooltip-content has-tooltip="" title=""> + <span> Comments </span> + </gr-tooltip-content> + </md-secondary-tab> + <md-secondary-tab + data-name="change-view-tab-header-url" + md-tab="" + tabindex="0" + > + <gr-endpoint-decorator name="change-view-tab-header-url"> <gr-endpoint-param name="change"> </gr-endpoint-param> <gr-endpoint-param name="revision"> </gr-endpoint-param> </gr-endpoint-decorator> - </div> - <div class="relatedChanges"> - <gr-related-changes-list> </gr-related-changes-list> - </div> - <div class="emptySpace"></div> - </div> + </md-secondary-tab> + </md-tabs> </div> + <section class="tabContent"> + <div> + <gr-file-list-header id="fileListHeader"> + </gr-file-list-header> + <gr-revision-parents> </gr-revision-parents> + <gr-file-list id="fileList"> </gr-file-list> + </div> + </section> + <gr-endpoint-decorator name="change-view-integration"> + <gr-endpoint-param name="change"> </gr-endpoint-param> + <gr-endpoint-param name="revision"> </gr-endpoint-param> + </gr-endpoint-decorator> + <div class="tabs"> + <md-tabs> + <md-secondary-tab + active="" + class="changeLog" + data-name="_changeLog" + md-tab="" + tabindex="0" + > + Change Log + </md-secondary-tab> + </md-tabs> + </div> + <section class="changeLog"> + <h2 class="assistive-tech-only">Change Log</h2> + <gr-messages-list> </gr-messages-list> + </section> </div> - </section> - <h2 class="assistive-tech-only">Files and Comments tabs</h2> - <div class="tabs"> - <md-tabs id="tabs"> - <md-secondary-tab - active="" - data-name="files" - md-tab="" - tabindex="0" - > - <span> Files </span> - </md-secondary-tab> - <md-secondary-tab - class="commentThreads" - data-name="comments" - md-tab="" - tabindex="-1" - > - <gr-tooltip-content has-tooltip="" title=""> - <span> Comments </span> - </gr-tooltip-content> - </md-secondary-tab> - <md-secondary-tab - data-name="change-view-tab-header-url" - md-tab="" - tabindex="0" - > - <gr-endpoint-decorator name="change-view-tab-header-url"> - <gr-endpoint-param name="change"> </gr-endpoint-param> - <gr-endpoint-param name="revision"> </gr-endpoint-param> - </gr-endpoint-decorator> - </md-secondary-tab> - </md-tabs> </div> - <section class="tabContent"> - <div> - <gr-file-list-header id="fileListHeader"> </gr-file-list-header> - <gr-revision-parents> </gr-revision-parents> - <gr-file-list id="fileList"> </gr-file-list> - </div> - </section> - <gr-endpoint-decorator name="change-view-integration"> - <gr-endpoint-param name="change"> </gr-endpoint-param> - <gr-endpoint-param name="revision"> </gr-endpoint-param> - </gr-endpoint-decorator> - <div class="tabs"> - <md-tabs> - <md-secondary-tab - active="" - class="changeLog" - data-name="_changeLog" - md-tab="" - tabindex="0" - > - Change Log - </md-secondary-tab> - </md-tabs> - </div> - <section class="changeLog"> - <h2 class="assistive-tech-only">Change Log</h2> - <gr-messages-list> </gr-messages-list> - </section> - </div> + <div class="sidebar" slot="side"></div> + </gr-content-with-sidebar> <gr-apply-fix-dialog id="applyFixDialog"> </gr-apply-fix-dialog> <dialog id="downloadModal" tabindex="-1"> <gr-download-dialog id="downloadDialog" role="dialog"> @@ -478,11 +454,11 @@ }); test('renders flows tab if experiment is enabled', async () => { - element.isFlowsEnabled = true; + element.flowsTabEnabled = true; stubFlags('isEnabled').returns(true); element.requestUpdate(); await element.updateComplete; - await waitUntil(() => !!element.isFlowsEnabled); + await waitUntil(() => !!element.flowsTabEnabled); queryAndAssert(element, '[data-name="flows"]'); }); @@ -647,8 +623,12 @@ suite('keyboard shortcuts', () => { let clock: SinonFakeTimers; - setup(() => { - clock = sinon.useFakeTimers(); + setup(async () => { + element.change = createChangeViewChange(); + element.loading = false; + await element.updateComplete; + + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); }); teardown(() => { @@ -706,7 +686,9 @@ assert.isTrue(loggedInErrorSpy.called); }); - test('shift A does not open reply overlay', async () => { + test('shift A does not open reply overlay, if not logged in', async () => { + element.loggedIn = false; + await element.updateComplete; pressKey(element, 'a', Modifier.SHIFT_KEY); await element.updateComplete; assertIsDefined(element.replyModal); @@ -727,7 +709,7 @@ change.labels = {}; element.change = change; - changeModel.setState({ + changeModel.updateState({ loadingStatus: LoadingStatus.LOADED, change, }); @@ -919,7 +901,11 @@ assert.equal(element.replyBtn.textContent, 'Sign in'); }); - test('download tap calls handleOpenDownloadDialog', () => { + test('download tap calls handleOpenDownloadDialog', async () => { + element.change = createChangeViewChange(); + element.loading = false; + await element.updateComplete; + assertIsDefined(element.actions); const openDialogStub = sinon.stub(element, 'handleOpenDownloadDialog'); element.actions.dispatchEvent( @@ -1126,6 +1112,8 @@ labels: {}, actions: {}, }; + element.loading = false; + await element.updateComplete; sinon.stub(element, '_getUrlParameter').callsFake(param => { assert.equal(param, 'revert'); @@ -1285,6 +1273,7 @@ const newChange = {...element.change}; newChange.revisions.rev2 = createRevision(EDIT); element.change = newChange; + element.loading = false; await element.updateComplete; fireEdit(); @@ -1298,6 +1287,7 @@ newChange.revisions.rev2 = createRevision(2); element.change = newChange; element.viewModelPatchNum = 1 as RevisionPatchSetNum; + element.loading = false; await element.updateComplete; fireEdit(); @@ -1314,6 +1304,7 @@ newChange.revisions.rev2 = createRevision(2); element.change = newChange; element.patchNum = 2 as RevisionPatchSetNum; + element.loading = false; await element.updateComplete; fireEdit(); @@ -1329,6 +1320,7 @@ element.change = { ...createChangeViewChange(), }; + element.loading = false; await element.updateComplete; assertIsDefined(element.metadata); assertIsDefined(element.actions); @@ -1361,8 +1353,11 @@ const hookEl = await plugin .hook('change-view-integration') .getLastAttached(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.strictEqual((hookEl as any).plugin, plugin); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.strictEqual((hookEl as any).change, element.change); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.strictEqual((hookEl as any).revision, element.revision); }); }); @@ -1374,6 +1369,7 @@ starred: false, }; element.loggedIn = true; + element.loading = false; await element.updateComplete; const stub = sinon.stub(element, 'handleToggleStar'); @@ -1424,7 +1420,7 @@ changeNum: TEST_NUMERIC_CHANGE_ID, repo: TEST_PROJECT_NAME, }; - changeModel.setState({ + changeModel.updateState({ loadingStatus: LoadingStatus.LOADED, change: { ...createChangeViewChange(), @@ -1458,6 +1454,7 @@ status: ChangeStatus.MERGED, current_revision: sha, }; + element.loading = false; await element.updateComplete; const copyLinksDialog = queryAndAssert<GrCopyLinks>( @@ -1471,6 +1468,7 @@ test('copy links without a base URL', async () => { element.change = createChangeViewChange(); + element.loading = false; await element.updateComplete; const copyLinksDialog = queryAndAssert<GrCopyLinks>( @@ -1487,6 +1485,7 @@ test('copy links with a base URL having a path', async () => { stubBaseUrl('/review'); element.change = createChangeViewChange(); + element.loading = false; await element.updateComplete; const copyLinksDialog = queryAndAssert<GrCopyLinks>(
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 5124f58..262f300 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
@@ -32,7 +32,7 @@ ProgressStatus, } from '../../../constants/constants'; import {subscribe} from '../../lit/subscription-controller'; -import {fire, fireNoBubble} from '../../../utils/event-util'; +import {fireNoBubble} from '../../../utils/event-util'; import {trimWithEllipsis} from '../../../utils/string-util'; import {css, html, LitElement, PropertyValues} from 'lit'; import {sharedStyles} from '../../../styles/shared-styles'; @@ -590,12 +590,10 @@ private handlecherryPickSingleChangeClicked() { this.cherryPickType = CherryPickType.SINGLE_CHANGE; - fire(this, 'iron-resize', {}); } private handlecherryPickTopicClicked() { this.cherryPickType = CherryPickType.TOPIC; - fire(this, 'iron-resize', {}); } private computeMessage() {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts index 27ac9bc..34a0fb4 100644 --- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts +++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.ts
@@ -221,8 +221,11 @@ assert.equal(args[0], 1); assert.equal(args[1], 'POST' as HttpMethod); assert.equal(args[2], '/cherrypick'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.equal((args[4] as any).destination, 'master'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.isTrue((args[4] as any).allow_conflicts); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.isTrue((args[4] as any).allow_empty); });
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 30cf475..eac869d 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
@@ -131,7 +131,7 @@ element.branch = 'test' as BranchName; await element.updateComplete; changeModel = testResolver(changeModelToken); - changeModel.setState({ + changeModel.updateState({ loadingStatus: LoadingStatus.LOADED, change, });
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 52ae1c2..26aefd5 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
@@ -26,6 +26,7 @@ import {GrAutogrowTextarea} from '../../shared/gr-autogrow-textarea/gr-autogrow-textarea'; import '@material/web/radio/radio'; import {materialStyles} from '../../../styles/gr-material-styles'; +import {getAppContext} from '../../../services/app-context'; const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.'; const SPECIFY_REASON_STRING = '<MUST SPECIFY REASON HERE>'; @@ -83,6 +84,8 @@ private readonly getPluginLoader = resolve(this, pluginLoaderToken); + private readonly restApiService = getAppContext().restApiService; + static override get styles() { return [ formStyles, @@ -222,13 +225,31 @@ ); } - populate( + /** + * Fetches required information and populates the dialog message. + * + * Returns `false` if the dialog shouldn't be shown. + */ + async populate( change: ParsedChangeInfo, - validationOptions: ValidationOptionsInfo | undefined, - commitMessage: string, - changesCount: number - ) { - this.changesCount = changesCount; + commitMessage: string + ): Promise<boolean> { + const query = `submissionid: "${change.submission_id}"`; + /* A chromium plugin expects that the modifyRevertMsg hook will only + be called after the revert button is pressed, hence we populate the + revert dialog after revert button is pressed. */ + const [changes, validationOptions] = await Promise.all([ + // Specify options 0 to explicitly not request any additional information, + // as opposed to using default, since we only care about number of + // changes. + this.restApiService.getChanges(0, query, undefined, /* options=*/ '0'), + this.restApiService.getValidationOptions(change._number), + ]); + if (!changes) { + return false; + } + + this.changesCount = changes.length; this.validationOptions = validationOptions; // The option to revert a single change is always available this.populateRevertSingleChangeMessage( @@ -237,6 +258,7 @@ change.current_revision ); this.populateRevertSubmissionMessage(change, commitMessage); + return true; } populateRevertSingleChangeMessage(
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 7077099..acdb84b 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,10 +6,21 @@ import * as sinon from 'sinon'; import {assert, fixture, html} from '@open-wc/testing'; import '../../../test/common-test-setup'; -import {createParsedChange} from '../../../test/test-data-generators'; -import {ChangeSubmissionId, CommitId} from '../../../types/common'; +import { + createChange, + createParsedChange, +} from '../../../test/test-data-generators'; +import { + ChangeId, + ChangeSubmissionId, + CommitId, + TopicName, + ValidationOptionsInfo, +} from '../../../types/common'; import './gr-confirm-revert-dialog'; import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog'; +import {stubRestApi} from '../../../test/test-utils'; +import {ParsedChangeInfo} from '../../../types/types'; suite('gr-confirm-revert-dialog tests', () => { let element: GrConfirmRevertDialog; @@ -129,4 +140,44 @@ 'Reverted changes: /q/submissionid:5545\n'; assert.equal(element.message, expected); }); + + suite('populate tests', () => { + let change: ParsedChangeInfo; + + setup(async () => { + change = { + ...createParsedChange(), + submission_id: '5545' as ChangeSubmissionId, + current_revision: 'abcd123' as CommitId, + }; + stubRestApi('getChanges').returns( + Promise.resolve([ + { + ...createChange(), + change_id: '12345678901234' as ChangeId, + topic: 'T' as TopicName, + subject: 'random', + }, + { + ...createChange(), + change_id: '23456' as ChangeId, + topic: 'T' as TopicName, + subject: 'a'.repeat(100), + }, + ]) + ); + stubRestApi('getValidationOptions').returns( + Promise.resolve({ + validation_options: [{name: 'o1', description: 'option 1'}], + } as ValidationOptionsInfo) + ); + }); + + test('validation options are fetched when populating revert dialog', async () => { + await element.populate(change, 'commit message'); + assert.deepEqual(element.validationOptions, { + validation_options: [{name: 'o1', description: 'option 1'}], + }); + }); + }); });
diff --git a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts index c9c30d5..72050dc 100644 --- a/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts +++ b/polygerrit-ui/app/elements/change/gr-copy-links/gr-copy-links.ts
@@ -15,6 +15,7 @@ import '@material/web/menu/menu'; import {MdMenu} from '@material/web/menu/menu'; import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field'; +import {materialStyles} from '../../../styles/gr-material-styles'; export interface CopyLink { label: string; @@ -49,6 +50,7 @@ static override get styles() { return [ + materialStyles, formStyles, css` md-menu { @@ -188,8 +190,7 @@ /** * NOTE: (milutin) Slightly hacky way to listen to the overlay actually - * opening. It's from gr-editable-label. It will be removed when we - * migrate out of iron-* components. + * opening. It's from gr-editable-label. */ private awaitOpen(fn: () => void) { let iters = 0;
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 5bccb07..95df2fac 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
@@ -280,12 +280,11 @@ if (!this.change || !this.patchNum) return; const patchContent = await this.restApiService.getPatchContent( this.change._number, - this.patchNum + this.patchNum, + undefined, + () => fireError(this, 'Failed to get patch content') ); - if (!patchContent) { - fireError(this, 'Failed to get patch content'); - return; - } + if (!patchContent) return; await copyToClipboard(patchContent, 'patch file content'); this.handleCloseTap(e); }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts index d71c2af..c107c20 100644 --- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts +++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -350,7 +350,10 @@ }); test('handles failed patch content fetch', async () => { - getPatchContentStub.resolves(undefined); + getPatchContentStub.callsFake((_c, _p, _ctx, errFn) => { + if (errFn) errFn(); + return Promise.resolve(undefined); + }); const copyButton = queryAndAssert<GrButton>( element, @@ -362,20 +365,5 @@ assert.isFalse(copyToClipboardStub.called); assert.isTrue(fireStub.called); }); - - test('handles error during patch content fetch', async () => { - const error = new Error('Network error'); - getPatchContentStub.rejects(error); - - const copyButton = queryAndAssert<GrButton>( - element, - '#copy-clipboard-button' - ); - copyButton.click(); - - await waitUntil(() => getPatchContentStub.called); - assert.isFalse(copyToClipboardStub.called); - assert.isFalse(fireStub.called); - }); }); });
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 bbcf97e..ee3e307 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
@@ -216,6 +216,9 @@ @property({type: Boolean}) editMode = false; + // A promise that resolves when all file expansions are finished rendering. + filesExpandedPromise?: Promise<void>; + private _filesExpanded = FilesExpandedState.NONE; get filesExpanded() { @@ -342,6 +345,7 @@ css` :host { display: block; + container-type: inline-size; } .row { align-items: center; @@ -407,8 +411,10 @@ border-bottom: 1px solid var(--border-color); position: -webkit-sticky; position: sticky; - top: 0; - /* Has to visible above the diff view, and by default has a lower + /* -1px for the top border to scroll out of view */ + top: calc( + var(--main-header-height) + var(--change-header-height, 38px) - 1px + ); /* Has to visible above the diff view, and by default has a lower z-index. setting to 1 places it directly above. */ z-index: 1; } @@ -637,19 +643,19 @@ color: var(--deemphasized-text-color); } - @media screen and (max-width: 1200px) { + @container (max-width: 1200px) { gr-endpoint-decorator.extra-col { display: none; } } - @media screen and (max-width: 1000px) { + @container (max-width: 1000px) { .reviewed { display: none; } } - @media screen and (max-width: 800px) { + @container (max-width: 800px) { .desktop { display: none; } @@ -934,7 +940,9 @@ fire(this, 'files-shown-changed', {length: this.numFilesShown}); } if (changedProperties.has('expandedFiles')) { - this.expandedFilesChanged(changedProperties.get('expandedFiles')); + this.filesExpandedPromise = this.expandedFilesChanged( + changedProperties.get('expandedFiles') + ); } if (changedProperties.has('numFilesShown')) { fire(this, 'files-shown-changed', {length: this.numFilesShown}); @@ -1953,7 +1961,7 @@ ].scrollIntoView({block: 'nearest'}); } - private toggleFileExpandedByIndex(index: number) { + toggleFileExpandedByIndex(index: number) { this.toggleFileExpanded(this.computePatchSetFile(this.files[index])); }
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 8b8b0d3..684df6e 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
@@ -1109,6 +1109,7 @@ openSelectedStub.reset(); expandStub.reset(); element.handleOpenFile(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = {} as any; if (openCursorStub.called) { result.opened_cursor = true; @@ -1518,31 +1519,31 @@ return Promise.resolve(); }, }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any; - element.renderInOrder([{path: 'p2'}, {path: 'p1'}, {path: 'p0'}], diffs); + await element.renderInOrder( + [{path: 'p2'}, {path: 'p1'}, {path: 'p0'}], + diffs + ); await element.updateComplete; assert.isFalse(reviewStub.called); }); test('renderInOrder logged in', async () => { const reviewStub = sinon.stub(element, 'reviewFile'); - let callCount = 0; - // Have to type as any because the type is 'GrDiffHost' - // which would require stubbing so many different - // methods / properties that it isn't worth it. const diffs = [ { path: 'p2', style: {}, prefetchDiff() {}, reload() { - assert.equal(reviewStub.callCount, 0); - assert.equal(callCount++, 0); + assert.equal(reviewStub.callCount, 1); return Promise.resolve(); }, }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any; - element.renderInOrder([{path: 'p2'}], diffs); + await element.renderInOrder([{path: 'p2'}], diffs); await element.updateComplete; assert.equal(reviewStub.callCount, 1); }); @@ -1575,13 +1576,14 @@ return Promise.resolve(); }, }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any; - element.renderInOrder([{path: 'p'}], diffs); + await element.renderInOrder([{path: 'p'}], diffs); await element.updateComplete; assert.isFalse(reviewStub.called); delete element.diffPrefs.manual_review; - element.renderInOrder([{path: 'p'}], diffs); + await element.renderInOrder([{path: 'p'}], diffs); await element.updateComplete; // Wait for renderInOrder to finish await waitEventLoop(); @@ -1731,7 +1733,9 @@ }); test('should not show separator if no unmodified files', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (element as any).modifiedFiles = createFiles(2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (element as any).unmodifiedFiles = []; await element.updateComplete; @@ -1740,7 +1744,9 @@ }); test('should not show separator if no modified files', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (element as any).modifiedFiles = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (element as any).unmodifiedFiles = createFiles(2); await element.updateComplete; @@ -1749,10 +1755,14 @@ }); test('should show separator if modified and unmodified files', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (element as any).modifiedFiles = createFiles(2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (element as any).unmodifiedFiles = createFiles(3); element.files = [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(element as any).modifiedFiles, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ...(element as any).unmodifiedFiles, ]; await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts index f152806..ae1b582 100644 --- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow.ts
@@ -3,24 +3,37 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {customElement, property, state} from 'lit/decorators.js'; -import {css, html, LitElement} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators.js'; +import {css, html, LitElement, PropertyValues} from 'lit'; import {sharedStyles} from '../../../styles/shared-styles'; +import {materialStyles} from '../../../styles/gr-material-styles'; import {grFormStyles} from '../../../styles/gr-form-styles'; -import {FlowInput} from '../../../api/rest-api'; +import { + ChangeInfo, + FlowActionInfo, + FlowInput, + LabelDefinitionInfo, +} from '../../../api/rest-api'; import {getAppContext} from '../../../services/app-context'; import {NumericChangeId, ServerInfo} from '../../../types/common'; import '../../shared/gr-button/gr-button'; +import '../../shared/gr-dialog/gr-dialog'; +import '../../shared/gr-icon/gr-icon'; import '../../core/gr-search-autocomplete/gr-search-autocomplete'; import '@material/web/select/outlined-select.js'; import '@material/web/select/select-option.js'; import '@material/web/textfield/outlined-text-field.js'; -import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field'; +import '../../shared/gr-copy-clipboard/gr-copy-clipboard'; import {resolve} from '../../../models/dependency'; import {configModelToken} from '../../../models/config/config-model'; -import {flowsModelToken} from '../../../models/flows/flows-model'; +import { + flowsModelToken, + getChangePrefix, +} from '../../../models/flows/flows-model'; +import './gr-flow-rule'; import {subscribe} from '../../lit/subscription-controller'; import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; +import {modalStyles} from '../../../styles/gr-modal-styles'; import { AutocompleteSuggestion, fetchAccountSuggestions, @@ -28,6 +41,22 @@ import {ValueChangedEvent} from '../../../types/events'; import {SuggestionProvider} from '../../core/gr-search-autocomplete/gr-search-autocomplete'; import {when} from 'lit/directives/when.js'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field.js'; +import { + computeFlowString, + Stage, + STAGE_SEPARATOR, +} from '../../../utils/flows-util'; +import {FlowCustomConditionInfo} from '../../../api/flows'; +import {changeModelToken} from '../../../models/change/change-model'; +import {combineLatest} from 'rxjs'; +import {getUserName} from '../../../utils/display-name-util'; +import {LabelSuggestionsProvider} from '../../../services/label-suggestions-provider'; +import {queryAndAssert, unique} from '../../../utils/common-util'; +import {fireAlert} from '../../../utils/event-util'; +import {MdOutlinedSelect} from '@material/web/select/outlined-select.js'; +import {Interaction} from '../../../constants/reporting'; const MAX_AUTOCOMPLETE_RESULTS = 10; @@ -38,31 +67,68 @@ // Property so that we can mock it in tests @property({type: String}) hostUrl?: string; + @query('#createModal') + private createModal?: HTMLDialogElement; + @state() - private stages: { - condition: string; - action: string; - parameterStr: string; - }[] = []; + // private but used in tests + stages: Stage[] = []; - @state() private currentCondition = ''; + @state() + // private but used in tests + currentCondition = ''; - @state() private currentAction = ''; + @state() + // private but used in tests + currentAction = ''; - @state() private currentParameter = ''; + @state() + // private but used in tests + currentParameter = ''; + + @state() + // private but used in tests + repoLabels?: (Pick<LabelDefinitionInfo, 'name'> & + Pick<LabelDefinitionInfo, 'values'>)[]; + + @state() + private selectedLabelForVote?: string; + + @state() + private selectedValueForVote?: string; @state() private currentConditionPrefix = 'Gerrit'; + @state() private guidedBuilderExpanded = true; + + @state() copyPasteExpanded = false; + @state() private loading = false; @state() private serverConfig?: ServerInfo; + @state() flowString = ''; + + @state() + // private but used in tests + flowActions: FlowActionInfo[] = []; + + @state() documentationLink?: string; + private readonly restApiService = getAppContext().restApiService; + private readonly reportingService = getAppContext().reportingService; + private readonly getConfigModel = resolve(this, configModelToken); private readonly getFlowsModel = resolve(this, flowsModelToken); + private readonly getChangeModel = resolve(this, changeModelToken); + + private readonly labelSuggestionsProvider = new LabelSuggestionsProvider( + this.restApiService + ); + private readonly projectSuggestions: SuggestionProvider = ( predicate, expression @@ -73,6 +139,13 @@ expression ) => this.fetchGroups(predicate, expression); + private readonly labelSuggestions: SuggestionProvider = ( + predicate, + expression + ) => this.labelSuggestionsProvider.getSuggestions(predicate, expression); + + private customConditions: FlowCustomConditionInfo[] = []; + private readonly accountSuggestions: SuggestionProvider = ( predicate, expression @@ -93,6 +166,35 @@ ); }; + private readonly reviewerSuggestions: SuggestionProvider = expression => { + const accountFetcher = (expr: string) => + this.restApiService.queryAccounts( + expr, + MAX_AUTOCOMPLETE_RESULTS, + undefined, + undefined, + throwingErrorCallback + ); + const emails = expression.split(','); + const emailToAutocomplete = emails.pop() ?? ''; + return accountFetcher(emailToAutocomplete.trim()).then(accounts => { + if (!accounts) { + return []; + } + return accounts + .filter(account => !!account.email) + .map(account => { + const userName = getUserName(this.serverConfig, account); + return { + label: `${userName}`, + text: account.email, + value: account.email, // value that will be emitted by the autocomplete + name: account.email, + }; + }); + }); + }; + constructor() { super(); subscribe( @@ -100,142 +202,538 @@ () => this.getConfigModel().serverConfig$, config => (this.serverConfig = config) ); + subscribe( + this, + () => this.getChangeModel().change$, + change => { + if (change) { + this.labelSuggestionsProvider.setRepoName(change.project); + const permittedLabels = change.permitted_labels ?? {}; + this.repoLabels = Object.entries(permittedLabels) + .sort(([nameA], [nameB]) => nameA.localeCompare(nameB)) + .map(([name, values]) => { + return { + name, + values: Object.fromEntries(values.map(v => [v, ''])), + }; + }); + } else { + this.repoLabels = undefined; + } + } + ); + subscribe( + this, + () => + combineLatest([ + this.getChangeModel().change$, + this.getFlowsModel().providers$, + ]), + async ([change, providers]) => { + this.documentationLink = providers + .map(p => p.getDocumentation()) + .find(doc => !!doc); + if (!change || providers.length === 0) { + this.customConditions = []; + return; + } + const conditionsPromises = providers.map(provider => + provider.getCustomConditions(change as ChangeInfo) + ); + const allConditions = await Promise.all(conditionsPromises); + this.customConditions = allConditions.flat(); + } + ); + + this.hostUrl = getChangePrefix(); } static override get styles() { return [ + materialStyles, sharedStyles, grFormStyles, + modalStyles, css` - .add-stage-row { + .create-flow-header { + display: flex; + align-items: center; + } + md-outlined-text-field, + gr-search-autocomplete, + md-outlined-select { + --md-outlined-field-top-space: 10px; + --md-outlined-field-bottom-space: 10px; + } + .raw-flow-container { display: flex; align-items: center; gap: var(--spacing-s); } - .add-stage-row > md-outlined-select, - .add-stage-row > md-outlined-text-field, - .add-stage-row > gr-search-autocomplete { + .main { + width: 680px; /* 85ch equivalent to prevent screenshot flakiness */ + } + .section-header { + display: flex; + align-items: center; + gap: var(--spacing-s); + justify-content: center; + color: var(--link-color); + margin-top: var(--spacing-l); + margin-bottom: var(--spacing-m); + border: 1px solid var(--border-color); + border-radius: var(--border-radius, 4px); + padding: var(--spacing-m); + cursor: pointer; + user-select: none; + } + .add-stage-box { + display: flex; + flex-direction: column; + gap: var(--spacing-s); + background-color: var(--background-color-secondary); + border: 1px solid var(--border-color); + border-radius: var(--border-radius, 4px); + padding: var(--spacing-m); + margin-top: var(--spacing-m); + } + .add-stage-box md-outlined-text-field, + .add-stage-box gr-search-autocomplete, + .add-stage-box md-outlined-select { + background-color: var(--background-color-primary); + border-radius: var(--border-radius, 4px); + } + .stage-label { + color: var(--deemphasized-text-color); + font-size: var(--font-size-small); + } + .stage-row { + display: flex; + align-items: center; + gap: var(--spacing-s); + margin-bottom: var(--spacing-m); + } + .stage-row:last-child { + margin-bottom: 0; + } + .stage-row > md-outlined-select { width: 15em; } - table { - border-collapse: collapse; + .stage-row > .vote-parameter-input { + flex: 1; } - th, - td { - border: 1px solid var(--border-color); - padding: var(--spacing-s); - text-align: left; + .stage-row > md-outlined-text-field { + background-color: var(--background-color-primary); + border-radius: var(--border-radius, 4px); + } + .stage-row > gr-search-autocomplete { + background-color: var(--background-color-primary); + --gr-search-bar-border-radius: var(--border-radius, 4px); + --view-background-color: transparent; + --gr-autocomplete-height: 42px; + } + .stage-row > md-outlined-text-field, + .stage-row > gr-search-autocomplete, + .stage-row > gr-autocomplete { + flex: 1; + } + .stage-row > gr-autocomplete { + background-color: var(--background-color-primary); + --gr-autocomplete-border-radius: var(--border-radius, 4px); + --view-background-color: transparent; + --gr-autocomplete-height: 42px; + } + .stages-list { + display: flex; + flex-direction: column; + gap: var(--spacing-m); + } + .stage-list-item { + display: flex; + align-items: center; + gap: var(--spacing-m); + } + .stage-number { + font-weight: var(--font-weight-bold); + color: var(--deemphasized-text-color); + min-width: 1.5em; + } + .preview-label { + margin-top: var(--spacing-l); + } + .flow-rule { + flex: 1; + } + .full-width-text-field { + width: 100%; + margin-top: var(--spacing-s); + margin-bottom: var(--spacing-m); + } + md-icon-button { + --md-icon-button-icon-size: 20px; + } + .info { + padding: var(--spacing-m); + width: fit-content; + } + .info-text { + font-weight: 300; + padding-left: var(--spacing-s); + } + .info-title { + font-weight: var(--font-weight-bold); } `, ]; } - override firstUpdated() { - this.hostUrl = window.location.origin + window.location.pathname; + protected override firstUpdated() { + this.reportingService.reportInteraction(Interaction.FLOWS_TAB_RENDERED); } - private renderTable() { + override willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has('changeNum')) { + this.getFlowActions(); + } + if ( + changedProperties.has('stages') && + !changedProperties.has('flowString') + ) { + this.flowString = computeFlowString(this.stages); + } + } + + private async getFlowActions() { + if (!this.changeNum) return; + const actions = await this.restApiService.listFlowActions(this.changeNum); + this.flowActions = (actions ?? []).sort((a, b) => + a.name.localeCompare(b.name) + ); + } + + private renderStages() { return when( this.stages.length > 0, () => html` - <table> - <thead> - <tr> - <th>Stage</th> - <th>Condition</th> - <th>Action</th> - <th>Parameters</th> - <th></th> - </tr> - </thead> - <tbody> - ${this.stages.map( - (stage, index) => html` - <tr> - <td>${index + 1}</td> - <td>${stage.condition}</td> - <td>${stage.action}</td> - <td>${stage.parameterStr}</td> - <td> - <gr-button - link - @click=${() => this.handleRemoveStage(index)} - title="Delete stage" - > - <gr-icon icon="delete" filled></gr-icon> - </gr-button> - </td> - </tr> - ` - )} - </tbody> - </table> + <div class="stage-label preview-label">Preview</div> + <div class="stages-list"> + ${this.stages.map( + (stage, index) => html` + <div class="stage-list-item"> + <span class="stage-number">${index + 1}</span> + <gr-flow-rule + class="flow-rule" + .condition=${stage.condition} + .action=${stage.action} + .parameterStr=${stage.parameterStr} + ></gr-flow-rule> + <gr-button + link + @click=${() => this.handleRemoveStage(index)} + title="Delete stage" + > + <gr-icon icon="delete" filled></gr-icon> + </gr-button> + </div> + ` + )} + </div> ` ); } + private parseStagesFromRawFlow(rawFlow: string) { + if (!rawFlow) { + this.stages = []; + return; + } + const stageStrings = rawFlow.split(STAGE_SEPARATOR); + this.stages = stageStrings.map(stageStr => { + const stage = { + condition: '', + action: '', + parameterStr: '', + }; + if (stageStr.includes('->')) { + const [condition, actionStr] = stageStr.split('->').map(s => s.trim()); + stage.condition = condition; + const actionParts = actionStr.split(' ').filter(part => part); + stage.action = actionParts[0] ?? ''; + if (actionParts.length > 1) { + stage.parameterStr = actionParts.slice(1).join(' '); + } + } else { + stage.condition = stageStr.trim(); + } + return stage; + }); + } + override render() { return html` - <div>${this.renderTable()}</div> - <div class="add-stage-row"> - <md-outlined-select - value=${this.currentConditionPrefix} - @change=${(e: Event) => { - const select = e.target as HTMLSelectElement; - this.currentConditionPrefix = select.value; + <div class="info"> + <span class="info-title"> Flows: </span> + <span class="info-text"> + Automate your workflow such as adding reviewers, starting reviews, + submitting changes and more + </span> + </div> + + <div class="create-flow-header"> + <gr-button + aria-label="Create Flow" + @click=${() => { + this.reportingService.reportInteraction( + Interaction.CREATE_FLOW_DIALOG_OPENED + ); + this.createModal?.showModal(); }} > - <md-select-option value="Gerrit"> - <div slot="headline">Gerrit</div> - </md-select-option> - <md-select-option value="Other"> - <div slot="headline">Other</div> - </md-select-option> - </md-outlined-select> - ${this.currentConditionPrefix === 'Gerrit' - ? html`<gr-search-autocomplete - .placeholder=${'Create condition'} - .value=${this.currentCondition} - .projectSuggestions=${this.projectSuggestions} - .groupSuggestions=${this.groupSuggestions} - .accountSuggestions=${this.accountSuggestions} - @text-changed=${this.handleGerritConditionTextChanged} - ></gr-search-autocomplete>` - : html`<md-outlined-text-field - label="Condition" - .value=${this.currentCondition} - @input=${(e: InputEvent) => - (this.currentCondition = ( - e.target as MdOutlinedTextField - ).value)} - ></md-outlined-text-field>`} - <span> -> </span> - <md-outlined-text-field - label="Action" - .value=${this.currentAction} - @input=${(e: InputEvent) => - (this.currentAction = (e.target as MdOutlinedTextField).value)} - ></md-outlined-text-field> - <md-outlined-text-field - label="Parameters" - .value=${this.currentParameter} - @input=${(e: InputEvent) => - (this.currentParameter = (e.target as MdOutlinedTextField).value)} - ></md-outlined-text-field> - <gr-button aria-label="Add Stage" @click=${this.handleAddStage} - >Add Stage</gr-button - > + Create Flow + </gr-button> + ${this.renderDocumentationLink(this.documentationLink)} </div> - <gr-button - aria-label="Create Flow" - ?disabled=${this.loading} - @click=${this.handleCreateFlow} - > - Create Flow - </gr-button> + ${this.renderCreateFlowDialog()} `; } + private renderCustomConditions() { + return this.customConditions.map( + condition => html`<md-select-option value=${condition.name}> + <div slot="headline">${condition.name}</div> + </md-select-option>` + ); + } + + private renderConditions() { + return html`<md-select-option value="Gerrit"> + <div slot="headline">Gerrit</div> + </md-select-option> + ${this.renderCustomConditions()}`; + } + + private renderDocumentationLink(link?: string, slot?: string) { + if (!link) return; + return html` <a + class="help" + slot=${ifDefined(slot)} + href=${link} + target="_blank" + rel="noopener noreferrer" + tabindex="-1" + @click=${() => + this.reportingService.reportInteraction( + 'flows-documentation-link-clicked' + )} + > + <md-icon-button touch-target="none" type="button"> + <gr-icon icon="help" title="read documentation"></gr-icon> + </md-icon-button> + </a>`; + } + + private renderCreateFlowDialog() { + return html` + <dialog id="createModal" tabindex="-1"> + <gr-dialog + confirm-label="Create flow" + cancel-label="Close" + ?disabled=${this.loading} + @confirm=${this.handleCreateFlow} + @cancel=${() => { + this.createModal?.close(); + }} + > + <div slot="header">Create new flow</div> + <div class="main" slot="main"> + <div + class="section-header" + @click=${(e: Event) => this.toggleGuidedBuilder(e)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + this.toggleGuidedBuilder(e); + } + }} + role="button" + tabindex="0" + aria-expanded=${this.guidedBuilderExpanded ? 'true' : 'false'} + > + <span>Guided Builder</span> + <gr-icon + icon=${this.guidedBuilderExpanded + ? 'expand_less' + : 'expand_more'} + filled + ></gr-icon> + </div> + ${when( + this.guidedBuilderExpanded, + () => html` + <div class="add-stage-box"> + <div class="stage-label">Condition: IF</div> + <div class="stage-row"> + <md-outlined-select + value=${this.currentConditionPrefix} + @change=${(e: Event) => { + const select = e.target as HTMLSelectElement; + this.currentConditionPrefix = select.value; + }} + > + ${this.renderConditions()} + </md-outlined-select> + ${this.currentConditionPrefix === 'Gerrit' + ? html`<gr-search-autocomplete + .placeholder=${'Create condition'} + .value=${this.currentCondition} + .projectSuggestions=${this.projectSuggestions} + .groupSuggestions=${this.groupSuggestions} + .accountSuggestions=${this.accountSuggestions} + .labelSuggestions=${this.labelSuggestions} + @text-changed=${this.handleGerritConditionTextChanged} + ></gr-search-autocomplete>` + : html`<md-outlined-text-field + label="Condition" + .value=${this.currentCondition} + @input=${(e: InputEvent) => + (this.currentCondition = ( + e.target as MdOutlinedTextField + ).value)} + > + ${this.renderDocumentationLink( + this.customConditions.find( + c => c.name === this.currentConditionPrefix + )?.documentation, + 'trailing-icon' + )} + </md-outlined-text-field>`} + </div> + <div class="stage-label">Action: Then</div> + <div class="stage-row"> + <md-outlined-select + label="Action" + .value=${this.currentAction} + @change=${this.handleActionChanged} + > + ${this.flowActions.map( + action => html` + <md-select-option .value=${action.name}> + <div slot="headline">${action.name}</div> + </md-select-option> + ` + )} + </md-outlined-select> + ${this.renderParameterInputField()} + </div> + <div class="stage-row" style="margin-top: var(--spacing-m);"> + <gr-button + link + aria-label="Add Stage" + @click=${this.handleAddStage} + >Add Stage</gr-button + > + </div> + </div> + ${this.renderStages()} + ` + )} + <div + class="section-header" + @click=${(e: Event) => this.toggleCopyPaste(e)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + this.toggleCopyPaste(e); + } + }} + role="button" + tabindex="0" + aria-expanded=${this.copyPasteExpanded ? 'true' : 'false'} + > + <span>Copy and Paste existing Flows</span> + <gr-icon + icon=${this.copyPasteExpanded ? 'expand_less' : 'expand_more'} + filled + ></gr-icon> + </div> + ${when( + this.copyPasteExpanded, + () => html` + <div class="raw-flow-container"> + <md-outlined-text-field + class="full-width-text-field" + type="textarea" + rows="4" + label="Copy and Paste existing flows" + .value=${this.flowString} + @input=${(e: InputEvent) => { + this.flowString = (e.target as MdOutlinedTextField).value; + this.parseStagesFromRawFlow(this.flowString); + }} + ></md-outlined-text-field> + <gr-copy-clipboard + .text=${this.flowString} + buttonTitle="Copy raw flow to clipboard" + hideinput + ></gr-copy-clipboard> + </div> + ` + )} + </div> + </gr-dialog> + </dialog> + `; + } + + private toggleGuidedBuilder(e: Event) { + e.stopPropagation(); + e.preventDefault(); + this.guidedBuilderExpanded = !this.guidedBuilderExpanded; + } + + private toggleCopyPaste(e: Event) { + e.stopPropagation(); + e.preventDefault(); + this.copyPasteExpanded = !this.copyPasteExpanded; + } + + private setDefaultVoteLabelAndValue() { + const firstLabel = this.repoLabels?.[0]; + if (firstLabel) { + this.selectedLabelForVote = firstLabel.name; + const labelValues = Object.keys(firstLabel.values ?? {}); + if (labelValues.length > 0) { + this.selectedValueForVote = labelValues[0]; + } + } + } + + private handleActionChanged(e: Event) { + const select = e.target as HTMLSelectElement; + this.currentAction = select.value; + this.currentParameter = ''; + this.selectedLabelForVote = undefined; + this.selectedValueForVote = undefined; + + if (this.currentAction === 'vote') { + this.setDefaultVoteLabelAndValue(); + this.updateCurrentParameterForVote(); + } + } + + private updateCurrentParameterForVote() { + if (this.currentAction !== 'vote') return; + + if (this.selectedLabelForVote && this.selectedValueForVote) { + let value = this.selectedValueForVote; + // Returned value from labels[value] has an extra space + if (value.trim() === '0') { + value = '+0'; + } + this.currentParameter = `${this.selectedLabelForVote}${value}`; + } else { + this.currentParameter = ''; + } + } + private handleGerritConditionTextChanged(e: ValueChangedEvent) { this.currentCondition = e.detail.value ?? ''; } @@ -288,8 +786,10 @@ } private handleAddStage() { - if (this.currentCondition.trim() === '' && this.currentAction.trim() === '') + if (this.currentCondition.trim() === '') { + fireAlert(this, 'Condition string cannot be empty.'); return; + } const condition = this.currentConditionPrefix === 'Gerrit' ? `${this.hostUrl} is ${this.currentCondition}` @@ -304,9 +804,18 @@ ]; this.currentCondition = ''; this.currentAction = ''; + this.resetActionDropdown(); this.currentParameter = ''; } + private resetActionDropdown() { + const actionDropdown = queryAndAssert<MdOutlinedSelect>( + this, + 'md-outlined-select[label="Action"]' + ); + actionDropdown.reset(); + } + private handleRemoveStage(index: number) { this.stages = this.stages.filter((_, i) => i !== index); } @@ -315,10 +824,8 @@ if (!this.changeNum) return; const allStages = [...this.stages]; - if ( - this.currentCondition.trim() !== '' || - this.currentAction.trim() !== '' - ) { + + if (this.currentCondition.trim() !== '') { const condition = this.currentConditionPrefix === 'Gerrit' ? `${this.hostUrl} is ${this.currentCondition}` @@ -330,7 +837,10 @@ }); } - if (allStages.length === 0) return; // Or show an error + if (allStages.some(s => s.condition.trim() === '')) { + fireAlert(this, 'All stages must have a condition.'); + return; + } this.loading = true; const flowInput: FlowInput = { @@ -340,7 +850,9 @@ name: stage.action, }; if (stage.parameterStr.length > 0) { - action.parameters = stage.parameterStr.split(' '); + action.parameters = stage.parameterStr + .split(/[\s,]+/) + .filter(p => p.length > 0); } return { condition: stage.condition, @@ -351,11 +863,133 @@ }), }; await this.getFlowsModel().createFlow(flowInput); + this.reportingService.reportInteraction(Interaction.FLOW_CREATED); this.stages = []; this.currentCondition = ''; this.currentAction = ''; this.currentParameter = ''; this.loading = false; + this.createModal?.close(); + } + + // TODO: remove eventually when we fully migrated to fetching placeholders from the backend. + private getParametersPlaceholder(actionName: string) { + const action = this.flowActions.find(a => a.name === actionName); + if (action?.parameters_placeholder) return action.parameters_placeholder; + + if (actionName === 'add-reviewer') return 'user@example.com'; + if (actionName === 'vote') return '<Label>+/-<Value>'; + return 'Parameters'; + } + + private renderAddReviewerParameterInputField() { + return html`<gr-autocomplete + class="parameter-input autocomplete-input" + label="Parameters" + .placeholder=${this.getParametersPlaceholder(this.currentAction)} + .text=${this.currentParameter} + .query=${this.reviewerSuggestions} + ?multi=${true} + @text-changed=${(e: ValueChangedEvent) => { + this.currentParameter = e.detail.value ?? ''; + }} + ></gr-autocomplete>`; + } + + private renderVoteParameterInputField() { + const labelNames = (this.repoLabels ?? []).map(l => l.name).filter(unique); + if (!this.repoLabels || this.repoLabels.length === 0) { + // Fallback to text input if labels aren't loaded. + return this.renderDefaultParameterInputField(); + } + + const selectedLabelInfo = this.selectedLabelForVote + ? this.repoLabels?.find(l => l.name === this.selectedLabelForVote) + : undefined; + const labelValues = selectedLabelInfo + ? Object.keys(selectedLabelInfo.values) + : []; + + return html` + <md-outlined-select + class="vote-parameter-input" + label="Label" + value=${this.selectedLabelForVote ?? ''} + @change=${(e: Event) => { + // TODO: Remove the reading from attribute + // For some reason in the test, the value is only read from the attribute and not from the value property + this.selectedLabelForVote = + ((e.target as HTMLSelectElement).value || + (e.target as HTMLSelectElement).getAttribute('value')) ?? + ''; + const newSelectedLabelInfo = this.repoLabels?.find( + l => l.name === this.selectedLabelForVote + ); + const newLabelValues = newSelectedLabelInfo + ? Object.keys(newSelectedLabelInfo.values) + : []; + this.selectedValueForVote = newLabelValues[0] ?? undefined; + this.updateCurrentParameterForVote(); + }} + > + ${labelNames.map( + label => html` + <md-select-option value=${label}> + <div slot="headline">${label}</div> + </md-select-option> + ` + )} + </md-outlined-select> + <md-outlined-select + class="vote-parameter-input" + label="Value" + value=${this.selectedValueForVote ?? ''} + @change=${(e: Event) => { + // TODO: Remove the reading from attribute + // For some reason in the test, the value is only read from the attribute and not from the value property + this.selectedValueForVote = + ((e.target as HTMLSelectElement).value || + (e.target as HTMLSelectElement).getAttribute('value')) ?? + ''; + this.updateCurrentParameterForVote(); + }} + > + ${labelValues.map( + val => html` + <md-select-option value=${val}> + <div slot="headline">${val}</div> + </md-select-option> + ` + )} + </md-outlined-select> + `; + } + + private renderDefaultParameterInputField() { + return html`<md-outlined-text-field + class="parameter-input textfield-input" + label="Parameters" + .placeholder=${this.getParametersPlaceholder(this.currentAction)} + .value=${this.currentParameter} + ?disabled=${!this.currentAction} + @input=${(e: InputEvent) => + (this.currentParameter = (e.target as MdOutlinedTextField).value)} + ></md-outlined-text-field>`; + } + + private renderParameterInputField() { + if (this.currentAction === 'submit') return undefined; + if ( + this.currentAction === 'add-reviewer' || + this.currentAction === 'add-to-attention-set' || + this.currentAction === 'remove-from-attention-set' + ) { + return this.renderAddReviewerParameterInputField(); + } + if (this.currentAction === 'vote') { + return this.renderVoteParameterInputField(); + } + return this.renderDefaultParameterInputField(); } }
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts new file mode 100644 index 0000000..e2f58c9 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_screenshot_test.ts
@@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import './gr-create-flow'; +import {fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import {GrCreateFlow} from './gr-create-flow'; +import {visualDiffDarkTheme} from '../../../test/test-utils'; +import {NumericChangeId} from '../../../api/rest-api'; +import {GrButton} from '../../shared/gr-button/gr-button'; +import {queryAndAssert} from '../../../utils/common-util'; + +suite('gr-create-flow screenshot tests', () => { + let element: GrCreateFlow; + + setup(async () => { + element = await fixture<GrCreateFlow>( + html`<gr-create-flow></gr-create-flow>` + ); + element.changeNum = 123 as NumericChangeId; + element.hostUrl = + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321'; + + // Stub flows model fetching actions if necessary, or just set it + element.flowActions = [ + {name: 'review'}, + {name: 'submit'}, + {name: 'abandon'}, + ]; + + element.stages = [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is status:open', + action: 'review', + parameterStr: 'Code-Review+2', + }, + ]; + + element.documentationLink = 'http://link.to.documentation'; + + element.currentCondition = 'status:merged'; + element.currentAction = 'submit'; + element.currentParameter = ''; + + await element.updateComplete; + + // Open dialog + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + createButton.hidden = true; + await element.updateComplete; + }); + + test('dialog screenshot', async () => { + // Target the dialog element directly to avoid capturing empty space or the 'Create Flow' button. + const dialog = queryAndAssert(element, '#createModal'); + + await visualDiff(dialog, 'gr-create-flow-dialog'); + await visualDiffDarkTheme(dialog, 'gr-create-flow-dialog'); + }); + + test('create-flow header screenshot', async () => { + const header = queryAndAssert<HTMLDivElement>( + element, + '.create-flow-header' + ); + + await visualDiff(header, 'gr-create-flow-dialog-header'); + await visualDiffDarkTheme(header, 'gr-create-flow-dialog-header'); + }); +});
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts index ac71093..ba96d2e 100644 --- a/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-create-flow_test.ts
@@ -7,300 +7,1155 @@ import './gr-create-flow'; import {assert, fixture, html} from '@open-wc/testing'; import {GrCreateFlow} from './gr-create-flow'; -import {queryAll, queryAndAssert} from '../../../test/test-utils'; -import {NumericChangeId} from '../../../types/common'; +import {query, queryAll, queryAndAssert} from '../../../test/test-utils'; +import { + AccountId, + EmailAddress, + NumericChangeId, + RepoName, +} from '../../../types/common'; import {GrButton} from '../../shared/gr-button/gr-button'; -import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field'; +import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete'; import {GrSearchAutocomplete} from '../../core/gr-search-autocomplete/gr-search-autocomplete'; import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model'; +import {changeModelToken} from '../../../models/change/change-model'; +import { + createParsedChange, + createRevision, +} from '../../../test/test-data-generators'; import {testResolver} from '../../../test/common-test-setup'; +import {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field'; +import {getAppContext} from '../../../services/app-context'; +import {FlowActionInfo, RevisionPatchSetNum} from '../../../api/rest-api'; +import {MdOutlinedSelect} from '@material/web/select/outlined-select'; +import {GrDialog} from '../../shared/gr-dialog/gr-dialog'; suite('gr-create-flow tests', () => { let element: GrCreateFlow; let flowsModel: FlowsModel; setup(async () => { + const restApi = getAppContext().restApiService; + sinon + .stub(restApi, 'listFlowActions') + .resolves([ + {name: 'act-1'}, + {name: 'act-2'}, + {name: 'vote'}, + {name: 'add-reviewer'}, + {name: 'submit'}, + {name: 'vote'}, + ] as FlowActionInfo[]); + flowsModel = testResolver(flowsModelToken); - element = await fixture<GrCreateFlow>( - html`<gr-create-flow></gr-create-flow>` - ); - element.changeNum = 123 as NumericChangeId; - await element.updateComplete; - element.hostUrl = + const hostUrl = 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321'; + element = await fixture<GrCreateFlow>( + html`<gr-create-flow + .changeNum=${123 as NumericChangeId} + .hostUrl=${hostUrl} + ></gr-create-flow>` + ); + await element.updateComplete; }); - test('renders initially', () => { - assert.isDefined(queryAndAssert(element, 'gr-search-autocomplete')); - assert.isDefined( - queryAndAssert(element, 'md-outlined-text-field[label="Action"]') - ); - assert.isDefined( - queryAndAssert(element, 'md-outlined-text-field[label="Parameters"]') - ); - assert.isDefined( - queryAndAssert(element, 'gr-button[aria-label="Add Stage"]') - ); - assert.isDefined( - queryAndAssert(element, 'gr-button[aria-label="Create Flow"]') - ); + suite('default actions', () => { + test('renders initially', () => { + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + assert.isTrue(createModal.open); + + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + assert.isDefined(queryAndAssert(grDialog, 'gr-search-autocomplete')); + assert.isDefined( + queryAndAssert(grDialog, 'md-outlined-select[label="Action"]') + ); + assert.isDefined( + queryAndAssert(grDialog, 'md-outlined-text-field[label="Parameters"]') + ); + assert.isDefined( + queryAndAssert(grDialog, 'gr-button[aria-label="Add Stage"]') + ); + }); + + test('opens and closes dialog', async () => { + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + + createButton.click(); + await element.updateComplete; + assert.isTrue(createModal.open); + + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + const cancelButton = queryAndAssert<GrButton>(grDialog, '#cancel'); + cancelButton.click(); + await element.updateComplete; + assert.isFalse(createModal.open); + }); + + test('adds and removes stages', async () => { + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + + searchAutocomplete.value = 'cond 1'; + await element.updateComplete; + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + assert.deepEqual(element.stages, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', + action: 'act-1', + parameterStr: '', + }, + ]); + assert.equal(element['currentCondition'], ''); + assert.equal(element['currentAction'], ''); + + searchAutocomplete.value = 'cond 2'; + await element.updateComplete; + actionInput.value = 'act-2'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + assert.deepEqual(element.stages, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', + action: 'act-1', + parameterStr: '', + }, + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', + action: 'act-2', + parameterStr: '', + }, + ]); + + let removeButtons = queryAll<GrButton>( + grDialog, + '.stage-list-item gr-button' + ); + assert.lengthOf(removeButtons, 2); + + removeButtons[0].click(); + await element.updateComplete; + + assert.deepEqual(element.stages, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', + action: 'act-2', + parameterStr: '', + }, + ]); + removeButtons = queryAll<GrButton>( + grDialog, + '.stage-list-item gr-button' + ); + assert.lengthOf(removeButtons, 1); + }); + + test('creates a flow with one stage', async () => { + const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + searchAutocomplete.value = 'single condition'; + await element.updateComplete; + actionInput.value = 'add-reviewer'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm'); + confirmButton.click(); + await element.updateComplete; + + assert.isTrue(createFlowStub.calledOnce); + const flowInput = createFlowStub.lastCall.args[0]; + assert.deepEqual(flowInput.stage_expressions, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is single condition', + action: {name: 'add-reviewer'}, + }, + ]); + assert.isFalse(createModal.open); + }); + + test('creates a flow with parameters', async () => { + const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const parametersInput = queryAndAssert<MdOutlinedTextField>( + grDialog, + 'md-outlined-text-field[label="Parameters"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + searchAutocomplete.value = 'single condition'; + await element.updateComplete; + actionInput.value = 'add-reviewer'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + parametersInput.value = 'param1 param2'; + parametersInput.dispatchEvent(new Event('input')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm'); + confirmButton.click(); + await element.updateComplete; + + assert.isTrue(createFlowStub.calledOnce); + const flowInput = createFlowStub.lastCall.args[0]; + assert.deepEqual(flowInput.stage_expressions, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is single condition', + action: {name: 'add-reviewer', parameters: ['param1', 'param2']}, + }, + ]); + assert.isFalse(createModal.open); + }); + + test('creates a flow with multiple reviewers separated by commas', async () => { + const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const grDialog = queryAndAssert<GrDialog>( + element, + '#createModal gr-dialog' + ); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + searchAutocomplete.value = 'single condition'; + await element.updateComplete; + actionInput.value = 'add-reviewer'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + const parametersInput = queryAndAssert<GrAutocomplete>( + grDialog, + '.autocomplete-input' + ); + parametersInput.text = 'user1@example.com, user2@example.com'; + parametersInput.dispatchEvent( + new CustomEvent('text-changed', { + detail: {value: 'user1@example.com, user2@example.com'}, + }) + ); + await element.updateComplete; + + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + addButton.click(); + await element.updateComplete; + + const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm'); + confirmButton.click(); + await element.updateComplete; + + assert.isTrue(createFlowStub.calledOnce); + const flowInput = createFlowStub.lastCall.args[0]; + assert.deepEqual(flowInput.stage_expressions[0].action!.parameters, [ + 'user1@example.com', + 'user2@example.com', + ]); + }); + + test('creates a flow with multiple stages', async () => { + const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + + searchAutocomplete.value = 'cond 1'; + await element.updateComplete; + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + searchAutocomplete.value = 'cond 2'; + await element.updateComplete; + actionInput.value = 'act-2'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm'); + confirmButton.click(); + await element.updateComplete; + + assert.isTrue(createFlowStub.calledOnce); + const flowInput = createFlowStub.lastCall.args[0]; + assert.deepEqual(flowInput.stage_expressions, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', + action: {name: 'act-1'}, + }, + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', + action: {name: 'act-2'}, + }, + ]); + assert.isFalse(createModal.open); + }); + + test('create flow with added stages and current input', async () => { + const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + + searchAutocomplete.value = 'cond 1'; + await element.updateComplete; + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + searchAutocomplete.value = 'cond 2'; + await element.updateComplete; + actionInput.value = 'act-2'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm'); + confirmButton.click(); + await element.updateComplete; + + assert.isTrue(createFlowStub.calledOnce); + const flowInput = createFlowStub.lastCall.args[0]; + assert.deepEqual(flowInput.stage_expressions, [ + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', + action: {name: 'act-1'}, + }, + { + condition: + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', + action: {name: 'act-2'}, + }, + ]); + assert.isFalse(createModal.open); + }); + + test('raw flow textarea is updated', async () => { + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + element.copyPasteExpanded = true; + await element.updateComplete; + + const rawFlowTextarea = queryAndAssert<MdOutlinedTextField>( + grDialog, + 'md-outlined-text-field[label="Copy and Paste existing flows"]' + ); + assert.isDefined(rawFlowTextarea); + assert.equal(rawFlowTextarea.value, ''); + + const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( + grDialog, + 'gr-search-autocomplete' + ); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const paramsInput = queryAndAssert<MdOutlinedTextField>( + grDialog, + 'md-outlined-text-field[label="Parameters"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + + // Add first stage + searchAutocomplete.value = 'cond 1'; + await element.updateComplete; + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + assert.equal( + element.flowString, + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1 -> act-1' + ); + + // Add second stage with parameters + searchAutocomplete.value = 'cond 2'; + await element.updateComplete; + actionInput.value = 'act-2'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + paramsInput.value = 'param'; + paramsInput.dispatchEvent(new Event('input')); + await element.updateComplete; + addButton.click(); + await element.updateComplete; + + assert.equal( + element.flowString, + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1 -> act-1;https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2 -> act-2 param' + ); + + // Remove first stage + const removeButtons = queryAll<GrButton>( + grDialog, + '.stage-list-item gr-button' + ); + removeButtons[0].click(); + await element.updateComplete; + + assert.equal( + element.flowString, + 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2 -> act-2 param' + ); + }); + + test('typing -> does not get overwritten', async () => { + // Open Dialog + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); + + element.copyPasteExpanded = true; + await element.updateComplete; + + // Find textarea + const rawFlowTextarea = queryAndAssert<MdOutlinedTextField>( + grDialog, + 'md-outlined-text-field[label="Copy and Paste existing flows"]' + ); + + // Simulate user typing a condition and '-> ' + rawFlowTextarea.value = 'cond 1 -'; + rawFlowTextarea.dispatchEvent(new InputEvent('input')); + await element.updateComplete; + assert.equal(element.flowString, 'cond 1 -'); + + rawFlowTextarea.value = 'cond 1 -> '; + rawFlowTextarea.dispatchEvent(new InputEvent('input')); + await element.updateComplete; + // Expected to preserve '-> ' and not revert to 'cond 1' + assert.equal(element.flowString, 'cond 1 -> '); + }); + + test('adding stage with empty condition fails', async () => { + const alertStub = sinon.stub(); + element.addEventListener('show-alert', alertStub); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + + const grDialog = queryAndAssert<GrDialog>(element, 'gr-dialog'); + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + const addButton = queryAndAssert<GrButton>( + grDialog, + 'gr-button[aria-label="Add Stage"]' + ); + + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + addButton.click(); + await element.updateComplete; + + assert.isTrue(alertStub.calledOnce); + assert.equal( + alertStub.lastCall.args[0].detail.message, + 'Condition string cannot be empty.' + ); + assert.lengthOf(element.stages, 0); + }); + + test('creating flow with empty condition fails', async () => { + const alertStub = sinon.stub(); + element.addEventListener('show-alert', alertStub); + const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + + const grDialog = queryAndAssert<GrDialog>(element, 'gr-dialog'); + + element.copyPasteExpanded = true; + await element.updateComplete; + + // Add a stage with empty condition via raw flow textarea + const rawFlowTextarea = queryAndAssert<MdOutlinedTextField>( + grDialog, + 'md-outlined-text-field[label="Copy and Paste existing flows"]' + ); + rawFlowTextarea.value = '-> act-1'; + rawFlowTextarea.dispatchEvent(new InputEvent('input')); + await element.updateComplete; + + assert.lengthOf(element.stages, 1); + assert.equal(element.stages[0].condition, ''); + + const confirmButton = queryAndAssert<GrButton>(grDialog, '#confirm'); + confirmButton.click(); + await element.updateComplete; + + assert.isTrue(alertStub.calledOnce); + assert.equal( + alertStub.lastCall.args[0].detail.message, + 'All stages must have a condition.' + ); + assert.isFalse(createFlowStub.called); + }); }); - test('adds and removes stages', async () => { - const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( - element, - 'gr-search-autocomplete' - ); - const actionInput = queryAndAssert<MdOutlinedTextField>( - element, - 'md-outlined-text-field[label="Action"]' - ); - const addButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Add Stage"]' - ); + suite('parameter input field', () => { + test('is disabled when no action is selected', async () => { + element.currentAction = ''; + await element.updateComplete; - searchAutocomplete.value = 'cond 1'; - await element.updateComplete; - actionInput.value = 'act 1'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; - addButton.click(); - await element.updateComplete; + let textfield = queryAndAssert<MdOutlinedTextField>( + element, + '.textfield-input' + ); + assert.isTrue(textfield.disabled); - assert.deepEqual(element['stages'], [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', - action: 'act 1', - parameterStr: '', - }, - ]); - assert.equal(element['currentCondition'], ''); - assert.equal(element['currentAction'], ''); + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; - searchAutocomplete.value = 'cond 2'; - await element.updateComplete; - actionInput.value = 'act 2'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; - addButton.click(); - await element.updateComplete; + textfield = queryAndAssert<MdOutlinedTextField>( + element, + '.textfield-input' + ); + assert.isFalse(textfield.disabled); + }); - assert.deepEqual(element['stages'], [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', - action: 'act 1', - parameterStr: '', - }, - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', - action: 'act 2', - parameterStr: '', - }, - ]); + test('renders md-outlined-text-field for non-add-reviewer action', async () => { + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'act-1'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; - let removeButtons = queryAll<GrButton>(element, 'tr gr-button'); - assert.lengthOf(removeButtons, 2); + assert.isNotNull(query(element, '.textfield-input')); + assert.isUndefined(query(element, '.autocomplete-input')); + }); - removeButtons[0].click(); - await element.updateComplete; + test('renders gr-autocomplete for add-reviewer action', async () => { + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'add-reviewer'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; - assert.deepEqual(element['stages'], [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', - action: 'act 2', - parameterStr: '', - }, - ]); - removeButtons = queryAll<GrButton>(element, 'tr gr-button'); - assert.lengthOf(removeButtons, 1); + assert.isNotNull(query(element, '.autocomplete-input')); + assert.isUndefined(query(element, '.textfield-input')); + }); + + test('shows correct placeholder for add-reviewer', async () => { + element.currentAction = 'add-reviewer'; + await element.updateComplete; + const autocomplete = queryAndAssert<GrAutocomplete>( + element, + '.autocomplete-input' + ); + assert.equal(autocomplete.placeholder, 'user@example.com'); + }); + + test('shows correct placeholder for vote', async () => { + element.repoLabels = []; + element.currentAction = 'vote'; + await element.updateComplete; + const textfield = queryAndAssert<MdOutlinedTextField>( + element, + '.textfield-input' + ); + assert.equal(textfield.placeholder, '<Label>+/-<Value>'); + }); + + test('hides the parameter input for submit', async () => { + element.currentAction = 'submit'; + await element.updateComplete; + assert.isUndefined(query(element, '.textfield-input')); + assert.isUndefined(query(element, '.autocomplete-input')); + }); + + test('shows default placeholder for other actions', async () => { + element.currentAction = 'some-other-action'; + await element.updateComplete; + const textfield = queryAndAssert<MdOutlinedTextField>( + element, + '.textfield-input' + ); + assert.equal(textfield.placeholder, 'Parameters'); + }); }); - test('creates a flow with one stage', async () => { - const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + suite('reviewer suggestions', () => { + let queryAccountsStub: sinon.SinonStub; - const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( - element, - 'gr-search-autocomplete' - ); - const actionInput = queryAndAssert<MdOutlinedTextField>( - element, - 'md-outlined-text-field[label="Action"]' - ); - searchAutocomplete.value = 'single condition'; - await element.updateComplete; - actionInput.value = 'single action'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; + setup(() => { + const restApi = getAppContext().restApiService; + queryAccountsStub = sinon.stub(restApi, 'queryAccounts').resolves([ + { + _account_id: 1 as AccountId, + name: 'Test User 1', + email: 'test1@example.com' as EmailAddress, + }, + { + _account_id: 2 as AccountId, + name: 'Test User 2', + email: 'test2@example.com' as EmailAddress, + }, + ]); + queryAccountsStub.resetHistory(); + }); - const createButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Create Flow"]' - ); - createButton.click(); - await element.updateComplete; + test('simulates typing two reviewer suggestions', async () => { + // Open dialog + const createButton = queryAndAssert<GrButton>( + element, + 'gr-button[aria-label="Create Flow"]' + ); + createButton.click(); + await element.updateComplete; + const createModal = queryAndAssert<HTMLDialogElement>( + element, + '#createModal' + ); + const grDialog = queryAndAssert<GrDialog>(createModal, 'gr-dialog'); - assert.isTrue(createFlowStub.calledOnce); - const flowInput = createFlowStub.lastCall.args[0]; - assert.deepEqual(flowInput.stage_expressions, [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is single condition', - action: {name: 'single action'}, - }, - ]); + // Set action to add-reviewer + const actionInput = queryAndAssert<MdOutlinedSelect>( + grDialog, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'add-reviewer'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + const autocomplete = queryAndAssert<GrAutocomplete>( + grDialog, + '.autocomplete-input' + ); + + // Simulate typing 't' and selecting first suggestion + autocomplete.text = 't'; + await element.updateComplete; + await autocomplete.updateComplete; + autocomplete.value = 'test1@example.com'; + autocomplete.dispatchEvent( + new CustomEvent('text-changed', { + detail: {value: 'test1@example.com'}, + }) + ); + await element.updateComplete; + + assert.equal(element.currentParameter, 'test1@example.com'); + + // Simulate typing ',' + autocomplete.text = 'test1@example.com,'; + autocomplete.dispatchEvent(new InputEvent('input')); + await element.updateComplete; + + assert.equal(element.currentParameter, 'test1@example.com,'); + + // Simulate typing 'u' and selecting second suggestion + autocomplete.text = 'test1@example.com,u'; + autocomplete.value = 'test1@example.com,test2@example.com'; + autocomplete.dispatchEvent( + new CustomEvent('text-changed', { + detail: {value: 'test1@example.com,test2@example.com'}, + }) + ); + await element.updateComplete; + + assert.equal( + element.currentParameter, + 'test1@example.com,test2@example.com' + ); + }); }); - test('creates a flow with parameters', async () => { - const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + suite('vote action', () => { + setup(async () => { + element.repoLabels = [ + { + name: 'Code-Review', + values: { + '-2': 'Do not submit', + '-1': "I would prefer that you didn't submit this", + ' 0': 'No score', + '+1': 'Looks good to me, but someone else must approve', + '+2': 'Looks good to me, approved', + }, + }, + { + name: 'Verified', + values: { + '-1': 'Fails', + ' 0': 'No score', + '+1': 'Verified', + }, + }, + ]; + await element.updateComplete; + }); - const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( - element, - 'gr-search-autocomplete' - ); - const actionInput = queryAndAssert<MdOutlinedTextField>( - element, - 'md-outlined-text-field[label="Action"]' - ); - const parametersInput = queryAndAssert<MdOutlinedTextField>( - element, - 'md-outlined-text-field[label="Parameters"]' - ); - searchAutocomplete.value = 'single condition'; - await element.updateComplete; - actionInput.value = 'single action'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; - parametersInput.value = 'param1 param2'; - parametersInput.dispatchEvent(new Event('input')); - await element.updateComplete; + test('sets default label and value when action is changed to vote', async () => { + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'vote'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; - const createButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Create Flow"]' - ); - createButton.click(); - await element.updateComplete; + assert.equal(element['selectedLabelForVote'], 'Code-Review'); + assert.equal(element['selectedValueForVote'], '-2'); + assert.equal(element['currentParameter'], 'Code-Review-2'); + }); - assert.isTrue(createFlowStub.calledOnce); - const flowInput = createFlowStub.lastCall.args[0]; - assert.deepEqual(flowInput.stage_expressions, [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is single condition', - action: {name: 'single action', parameters: ['param1', 'param2']}, - }, - ]); + test('updates parameter when label is changed', async () => { + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'vote'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + const voteParamInputs = queryAll<MdOutlinedSelect>( + element, + '.vote-parameter-input' + ); + const labelSelect = voteParamInputs[0]; + + labelSelect.setAttribute('value', 'Verified'); + labelSelect.value = 'Verified'; + labelSelect.dispatchEvent(new Event('input', {bubbles: true})); + labelSelect.dispatchEvent(new Event('change', {bubbles: true})); + await element.updateComplete; + + assert.equal(element['selectedLabelForVote'], 'Verified'); + assert.equal(element['selectedValueForVote'], '-1'); + assert.equal(element['currentParameter'], 'Verified-1'); + }); + + test('updates parameter when value is changed', async () => { + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'vote'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + const voteParamInputs = queryAll<MdOutlinedSelect>( + element, + '.vote-parameter-input' + ); + const valueSelect = voteParamInputs[1]; + + // TODO: remove setting of attributes and fix reading from value + valueSelect.setAttribute('value', '+1'); + valueSelect.value = '+1'; + valueSelect.dispatchEvent( + new Event('input', {bubbles: true, composed: true}) + ); + valueSelect.dispatchEvent( + new Event('change', {bubbles: true, composed: true}) + ); + await element.updateComplete; + + assert.equal(element['selectedLabelForVote'], 'Code-Review'); + assert.equal(element['selectedValueForVote'], '+1'); + assert.equal(element['currentParameter'], 'Code-Review+1'); + }); + + test('updates parameter to +0 when value is 0', async () => { + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'vote'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + const voteParamInputs = queryAll<MdOutlinedSelect>( + element, + '.vote-parameter-input' + ); + const valueSelect = voteParamInputs[1]; + + // TODO: remove setting of attributes and fix reading from value + valueSelect.setAttribute('value', '0'); + valueSelect.value = '0'; + valueSelect.dispatchEvent( + new Event('input', {bubbles: true, composed: true}) + ); + valueSelect.dispatchEvent( + new Event('change', {bubbles: true, composed: true}) + ); + await element.updateComplete; + + assert.equal(element['selectedLabelForVote'], 'Code-Review'); + assert.equal(element['selectedValueForVote'], '0'); + assert.equal(element['currentParameter'], 'Code-Review+0'); + }); + + test('renders text input for vote when no labels are available', async () => { + element.repoLabels = []; + await element.updateComplete; + + const actionInput = queryAndAssert<MdOutlinedSelect>( + element, + 'md-outlined-select[label="Action"]' + ); + actionInput.value = 'vote'; + actionInput.dispatchEvent(new Event('change')); + await element.updateComplete; + + assert.isNotNull(query(element, '.textfield-input')); + assert.lengthOf(queryAll(element, '.vote-parameter-input'), 0); + }); }); - test('creates a flow with multiple stages', async () => { - const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + suite('parseStagesFromRawFlow tests', () => { + test('parses a single condition', async () => { + const rawFlow = 'cond 1'; + element['parseStagesFromRawFlow'](rawFlow); + await element.updateComplete; + assert.deepEqual(element.stages, [ + { + condition: 'cond 1', + action: '', + parameterStr: '', + }, + ]); + }); - const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( - element, - 'gr-search-autocomplete' - ); - const actionInput = queryAndAssert<MdOutlinedTextField>( - element, - 'md-outlined-text-field[label="Action"]' - ); - const addButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Add Stage"]' - ); + test('parses a single condition with action', async () => { + const rawFlow = 'cond 1 -> act-1'; + element['parseStagesFromRawFlow'](rawFlow); + await element.updateComplete; + assert.deepEqual(element.stages, [ + { + condition: 'cond 1', + action: 'act-1', + parameterStr: '', + }, + ]); + }); - searchAutocomplete.value = 'cond 1'; - await element.updateComplete; - actionInput.value = 'act 1'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; - addButton.click(); - await element.updateComplete; + test('parses a single condition with action and params', async () => { + const rawFlow = 'cond 1 -> act-1 param1 param2'; + element['parseStagesFromRawFlow'](rawFlow); + await element.updateComplete; + assert.deepEqual(element.stages, [ + { + condition: 'cond 1', + action: 'act-1', + parameterStr: 'param1 param2', + }, + ]); + }); - searchAutocomplete.value = 'cond 2'; - await element.updateComplete; - actionInput.value = 'act 2'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; - addButton.click(); - await element.updateComplete; + test('parses multiple stages', async () => { + const rawFlow = 'cond 1 -> act-1; cond 2 -> act-2 p2; cond 3'; + element['parseStagesFromRawFlow'](rawFlow); + await element.updateComplete; + assert.deepEqual(element.stages, [ + { + condition: 'cond 1', + action: 'act-1', + parameterStr: '', + }, + { + condition: 'cond 2', + action: 'act-2', + parameterStr: 'p2', + }, + { + condition: 'cond 3', + action: '', + parameterStr: '', + }, + ]); + }); - const createButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Create Flow"]' - ); - createButton.click(); - await element.updateComplete; + test('parses an empty string', async () => { + const rawFlow = ''; + element['parseStagesFromRawFlow'](rawFlow); + await element.updateComplete; + assert.deepEqual(element.stages, []); + }); - assert.isTrue(createFlowStub.calledOnce); - const flowInput = createFlowStub.lastCall.args[0]; - assert.deepEqual(flowInput.stage_expressions, [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', - action: {name: 'act 1'}, - }, - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', - action: {name: 'act 2'}, - }, - ]); + test('parses with extra spacing', async () => { + const rawFlow = ' cond 1 -> act-1 p1 ; cond 2 '; + element['parseStagesFromRawFlow'](rawFlow); + await element.updateComplete; + assert.deepEqual(element.stages, [ + { + condition: 'cond 1', + action: 'act-1', + parameterStr: 'p1', + }, + { + condition: 'cond 2', + action: '', + parameterStr: '', + }, + ]); + }); }); - test('create flow with added stages and current input', async () => { - const createFlowStub = sinon.stub(flowsModel, 'createFlow'); + suite('repoLabels calculation', () => { + test('repoLabels is calculated from change.permitted_labels', async () => { + const changeModel = testResolver(changeModelToken); + changeModel.updateStateChange({ + ...createParsedChange(), + project: 'test-project' as RepoName, + permitted_labels: { + 'Code-Review': ['-1', ' 0', '+1'], + Verified: ['-1', ' 0', '+1'], + }, + revisions: { + a: createRevision(1 as RevisionPatchSetNum), + }, + }); + await element.updateComplete; - const searchAutocomplete = queryAndAssert<GrSearchAutocomplete>( - element, - 'gr-search-autocomplete' - ); - const actionInput = queryAndAssert<MdOutlinedTextField>( - element, - 'md-outlined-text-field[label="Action"]' - ); - const addButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Add Stage"]' - ); + assert.isDefined(element['repoLabels']); + assert.lengthOf(element['repoLabels'], 2); + assert.equal(element['repoLabels'][0].name, 'Code-Review'); + assert.deepEqual(element['repoLabels'][0].values, { + '-1': '', + ' 0': '', + '+1': '', + }); + assert.equal(element['repoLabels'][1].name, 'Verified'); + assert.deepEqual(element['repoLabels'][1].values, { + '-1': '', + ' 0': '', + '+1': '', + }); + }); - searchAutocomplete.value = 'cond 1'; - await element.updateComplete; - actionInput.value = 'act 1'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; - addButton.click(); - await element.updateComplete; - searchAutocomplete.value = 'cond 2'; - await element.updateComplete; - actionInput.value = 'act 2'; - actionInput.dispatchEvent(new Event('input')); - await element.updateComplete; + test('repoLabels is sorted by name', async () => { + const changeModel = testResolver(changeModelToken); + changeModel.updateStateChange({ + ...createParsedChange(), + project: 'test-project' as RepoName, + permitted_labels: { + Verified: ['-1', ' 0', '+1'], + 'Code-Review': ['-1', ' 0', '+1'], + 'A-Label': [' 0'], + }, + revisions: { + a: createRevision(1 as RevisionPatchSetNum), + }, + }); + await element.updateComplete; - const createButton = queryAndAssert<GrButton>( - element, - 'gr-button[aria-label="Create Flow"]' - ); - createButton.click(); - await element.updateComplete; - - assert.isTrue(createFlowStub.calledOnce); - const flowInput = createFlowStub.lastCall.args[0]; - assert.deepEqual(flowInput.stage_expressions, [ - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 1', - action: {name: 'act 1'}, - }, - { - condition: - 'https://gerrit-review.googlesource.com/c/plugins/code-owners/+/441321 is cond 2', - action: {name: 'act 2'}, - }, - ]); + assert.isDefined(element['repoLabels']); + assert.lengthOf(element['repoLabels'], 3); + assert.equal(element['repoLabels'][0].name, 'A-Label'); + assert.equal(element['repoLabels'][1].name, 'Code-Review'); + assert.equal(element['repoLabels'][2].name, 'Verified'); + }); }); });
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts new file mode 100644 index 0000000..34433f5 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule.ts
@@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {customElement, property, state} from 'lit/decorators.js'; +import {css, html, LitElement, nothing, PropertyValues} from 'lit'; +import {sharedStyles} from '../../../styles/shared-styles'; +import {AccountDetailInfo, FlowStageState} from '../../../api/rest-api'; +import {formatActionName} from '../../../utils/flows-util'; +import '../../shared/gr-icon/gr-icon'; +import '../../shared/gr-tooltip-content/gr-tooltip-content'; +import {ifDefined} from 'lit/directives/if-defined.js'; +import {getAppContext} from '../../../services/app-context'; +import '../../shared/gr-avatar/gr-avatar'; +import '../../shared/gr-account-label/gr-account-label'; +import {UserId} from '../../../types/common'; + +@customElement('gr-flow-rule') +export class GrFlowRule extends LitElement { + @property({type: String}) + state?: FlowStageState; + + @property({type: String}) + message?: string; + + @property({type: String}) + condition = ''; + + @property({type: String}) + action?: string; + + @property({type: Array}) + parameters?: string[]; + + @property({type: String}) + parameterStr?: string; + + @state() + private accounts = new Map<string, AccountDetailInfo | null>(); + + private readonly restApiService = getAppContext().restApiService; + + static override get styles() { + return [ + sharedStyles, + css` + :host { + display: block; + } + .stage { + display: flex; + align-items: center; + gap: var(--spacing-m); + } + .stage-action { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--spacing-s); + } + .parameter { + background-color: var(--background-color-secondary); + padding: 2px 4px; + border-radius: var(--border-radius); + font-family: var(--monospace-font-family); + font-size: var(--font-size-small); + } + .account-parameter { + display: inline-flex; + align-items: center; + gap: var(--spacing-s); + background-color: var(--background-color-secondary); + padding: 2px 4px; + border-radius: var(--border-radius); + } + .arrow { + color: var(--deemphasized-text-color); + margin: 0 var(--spacing-xs); + font-size: 16px; + } + .condition { + color: var(--deemphasized-text-color); + } + b { + font-weight: bolder; + } + gr-icon { + font-size: var(--line-height-normal, 20px); + vertical-align: middle; + } + gr-icon.done { + color: var(--success-foreground); + } + gr-icon.pending { + color: var(--deemphasized-text-color); + } + gr-icon.failed { + color: var(--error-foreground); + } + .error { + color: var(--error-foreground); + } + `, + ]; + } + + private iconForFlowStageState(status: FlowStageState) { + switch (status) { + case FlowStageState.DONE: + return {icon: 'check_circle', filled: true, class: 'done'}; + case FlowStageState.PENDING: + return {icon: 'timelapse', filled: false, class: 'pending'}; + case FlowStageState.FAILED: + return {icon: 'error', filled: true, class: 'failed'}; + case FlowStageState.TERMINATED: + return {icon: 'error', filled: true, class: 'failed'}; + default: + return {icon: 'help', filled: false, class: 'other'}; + } + } + + override willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has('parameterStr')) { + this.parameters = this.parameterStr?.trim() + ? this.parameterStr + .trim() + .split(/[\s,]+/) + .filter(p => p.length > 0) + : []; + } + if (changedProperties.has('parameters')) { + this.updateAccounts(); + } + } + + private updateAccounts() { + if (!this.parameters) { + if (this.accounts.size > 0) this.accounts = new Map(); + return; + } + + const promises = this.parameters.map(async p => { + // Simple email regex check + if (/\S+@\S+\.\S+/.test(p)) { + try { + const account = await this.restApiService.getAccountDetails( + p as UserId + ); + return {key: p, value: account ?? null}; + } catch (e) { + console.error(`Failed to fetch account for ${p}`, e); + return {key: p, value: null}; + } + } + return {key: p, value: null}; + }); + + Promise.all(promises).then(results => { + const newAccounts = new Map<string, AccountDetailInfo | null>(); + for (const result of results) { + newAccounts.set(result.key, result.value); + } + this.accounts = newAccounts; + }); + } + + private renderParameters() { + if (!this.parameters || this.parameters.length === 0) return nothing; + return html` + ${this.parameters.map(p => { + const account = this.accounts.get(p); + if (account) { + return html` + <span class="account-parameter"> + <gr-avatar .account=${account} .imageSize=${16}></gr-avatar> + <gr-account-label .account=${account}></gr-account-label> + </span> + `; + } + return html`<span class="parameter"><code>${p}</code></span>`; + })} + `; + } + + private isFailingState() { + return ( + this.state === FlowStageState.FAILED || + this.state === FlowStageState.TERMINATED + ); + } + + override render() { + const actionText = formatActionName(this.action); + const icon = this.state + ? this.iconForFlowStageState(this.state) + : undefined; + + return html` + <div class="stage"> + ${icon + ? html`<gr-tooltip-content + ?has-tooltip=${!!this.message && !this.isFailingState()} + title=${ifDefined( + this.message && !this.isFailingState() + ? this.message + : undefined + )} + > + <gr-icon + class=${icon.class} + icon=${icon.icon} + ?filled=${icon.filled} + aria-label=${this.state?.toLowerCase() ?? ''} + role="img" + ></gr-icon> + </gr-tooltip-content>` + : nothing} + <span class="condition">${this.condition}</span> + ${this.action + ? html` <gr-icon icon="arrow_forward" class="arrow"></gr-icon> + <div class="stage-action"> + <b>${actionText}</b> + ${this.renderParameters()} + </div>` + : nothing} + ${this.message && this.isFailingState() + ? html`<span class="error">${this.message}</span>` + : nothing} + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gr-flow-rule': GrFlowRule; + } +}
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule_test.ts new file mode 100644 index 0000000..72fb4f6 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flow-rule_test.ts
@@ -0,0 +1,251 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import './gr-flow-rule'; +import {GrFlowRule} from './gr-flow-rule'; +import {assert, fixture, html} from '@open-wc/testing'; +import {FlowStageState} from '../../../api/rest-api'; +import {stubRestApi} from '../../../test/test-utils'; +import {createAccountDetailWithId} from '../../../test/test-data-generators'; +import {EmailAddress, UserId} from '../../../types/common'; + +suite('gr-flow-rule tests', () => { + let element: GrFlowRule; + setup(async () => { + element = await fixture<GrFlowRule>( + html`<gr-flow-rule .condition=${'label:Code-Review=+1'}></gr-flow-rule>` + ); + await element.updateComplete; + }); + + test('renders', () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <span class="condition"> label:Code-Review=+1 </span> + </div> + ` + ); + }); + + test('renders with state', async () => { + element.state = FlowStageState.DONE; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <gr-tooltip-content> + <gr-icon + aria-label="done" + class="done" + filled="" + icon="check_circle" + role="img" + > + </gr-icon> + </gr-tooltip-content> + <span class="condition"> label:Code-Review=+1 </span> + </div> + ` + ); + }); + + test('renders with action', async () => { + element.action = 'add_reviewer'; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <span class="condition"> label:Code-Review=+1 </span> + <gr-icon class="arrow" icon="arrow_forward"> </gr-icon> + <div class="stage-action"> + <b> Add Reviewer </b> + </div> + </div> + ` + ); + }); + + test('renders with action and parameters', async () => { + element.action = 'add_reviewer'; + element.parameters = ['user@example.com']; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <span class="condition"> label:Code-Review=+1 </span> + <gr-icon class="arrow" icon="arrow_forward"> </gr-icon> + <div class="stage-action"> + <b> Add Reviewer </b> + <span class="parameter"> + <code> user@example.com </code> + </span> + </div> + </div> + ` + ); + }); + + test('parses parameterStr with commas and spaces', async () => { + element.parameterStr = + 'user1@example.com, user2@example.com ,user3@example.com'; + await element.updateComplete; + assert.deepEqual(element.parameters, [ + 'user1@example.com', + 'user2@example.com', + 'user3@example.com', + ]); + }); + + test('renders email parameter as account chip when account exists', async () => { + const account = { + ...createAccountDetailWithId(1), + email: 'user@example.com' as EmailAddress, + }; + const getAccountDetailsStub = stubRestApi('getAccountDetails'); + getAccountDetailsStub + .withArgs('user@example.com' as UserId) + .resolves(account); + getAccountDetailsStub + .withArgs('not-found@example.com' as UserId) + .resolves(undefined); + + element.action = 'add_reviewer'; + element.parameters = [ + 'user@example.com', + 'not-an-email', + 'not-found@example.com', + ]; + await element.updateComplete; + + // wait for async account fetching and re-rendering + await new Promise(resolve => setTimeout(resolve, 0)); + await element.updateComplete; + + assert.isTrue( + getAccountDetailsStub.calledWith('user@example.com' as UserId) + ); + assert.isTrue( + getAccountDetailsStub.calledWith('not-found@example.com' as UserId) + ); + // getAccountDetails should not be called for 'not-an-email' + assert.equal(getAccountDetailsStub.callCount, 2); + + const params = element.shadowRoot?.querySelectorAll( + '.account-parameter, .parameter' + ); + assert.isOk(params); + assert.equal(params.length, 3); + + const accountParam = params[0]; + assert.isTrue(accountParam.classList.contains('account-parameter')); + const avatar = accountParam.querySelector('gr-avatar'); + assert.isOk(avatar); + assert.deepEqual(avatar.account, account); + const label = accountParam.querySelector('gr-account-label'); + assert.isOk(label); + assert.deepEqual(label.account, account); + + const notAnEmailParam = params[1]; + assert.isTrue(notAnEmailParam.classList.contains('parameter')); + assert.equal(notAnEmailParam.textContent?.trim(), 'not-an-email'); + + const notFoundParam = params[2]; + assert.isTrue(notFoundParam.classList.contains('parameter')); + assert.equal(notFoundParam.textContent?.trim(), 'not-found@example.com'); + }); + + test('renders error message directly', async () => { + element.state = FlowStageState.FAILED; + element.condition = 'label:Code-Review=+1'; + element.action = 'add_reviewer'; + element.message = 'An error occurred'; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <gr-tooltip-content> + <gr-icon + aria-label="failed" + class="failed" + icon="error" + role="img" + filled="" + > + </gr-icon> + </gr-tooltip-content> + <span class="condition"> label:Code-Review=+1 </span> + <gr-icon class="arrow" icon="arrow_forward"> </gr-icon> + <div class="stage-action"> + <b> Add Reviewer </b> + </div> + <span class="error"> An error occurred </span> + </div> + ` + ); + }); + + test('renders message in tooltip for successful state', async () => { + element.state = FlowStageState.DONE; + element.condition = 'label:Code-Review=+1'; + element.message = 'Conditions met'; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <gr-tooltip-content title="Conditions met" has-tooltip=""> + <gr-icon + aria-label="done" + class="done" + filled="" + icon="check_circle" + role="img" + > + </gr-icon> + </gr-tooltip-content> + <span class="condition"> label:Code-Review=+1 </span> + </div> + ` + ); + }); + + test('renders error message for terminated state directly', async () => { + element.state = FlowStageState.TERMINATED; + element.condition = 'label:Code-Review=+1'; + element.action = 'add_reviewer'; + element.message = 'An error occurred'; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="stage"> + <gr-tooltip-content> + <gr-icon + aria-label="terminated" + class="failed" + icon="error" + role="img" + filled="" + > + </gr-icon> + </gr-tooltip-content> + <span class="condition"> label:Code-Review=+1 </span> + <gr-icon class="arrow" icon="arrow_forward"> </gr-icon> + <div class="stage-action"> + <b> Add Reviewer </b> + </div> + <span class="error"> An error occurred </span> + </div> + ` + ); + }); +});
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts index e272eda..d7cd9e1 100644 --- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows.ts
@@ -10,7 +10,7 @@ import {resolve} from '../../../models/dependency'; import {changeModelToken} from '../../../models/change/change-model'; import {subscribe} from '../../lit/subscription-controller'; -import {FlowInfo, FlowStageState} from '../../../api/rest-api'; +import {FlowInfo, FlowStageInfo, FlowStageState} from '../../../api/rest-api'; import {flowsModelToken} from '../../../models/flows/flows-model'; import {NumericChangeId} from '../../../types/common'; import './gr-create-flow'; @@ -18,21 +18,13 @@ import '../../shared/gr-dialog/gr-dialog'; import '@material/web/select/filled-select'; import '@material/web/select/select-option'; - -const iconForFlowStageState = (status: FlowStageState) => { - switch (status) { - case FlowStageState.DONE: - return {icon: 'check_circle', filled: true, class: 'done'}; - case FlowStageState.PENDING: - return {icon: 'timelapse', filled: false, class: 'pending'}; - case FlowStageState.FAILED: - return {icon: 'error', filled: true, class: 'failed'}; - case FlowStageState.TERMINATED: - return {icon: 'error', filled: true, class: 'failed'}; - default: - return {icon: 'help', filled: false, class: 'other'}; - } -}; +import '../../shared/gr-account-label/gr-account-label'; +import '../../shared/gr-avatar/gr-avatar'; +import '../../shared/gr-date-formatter/gr-date-formatter'; +import {formatActionName} from '../../../utils/flows-util'; +import './gr-flow-rule'; +import {computeFlowStringFromFlowStageInfo} from '../../../utils/flows-util'; +import {materialStyles} from '../../../styles/gr-material-styles'; @customElement('gr-flows') export class GrFlows extends LitElement { @@ -43,87 +35,105 @@ @state() private changeNum?: NumericChangeId; + @state() isOwner = false; + @state() private loading = true; @state() private flowIdToDelete?: string; - @state() private statusFilter: FlowStageState | 'all' = 'all'; - private readonly getChangeModel = resolve(this, changeModelToken); private readonly getFlowsModel = resolve(this, flowsModelToken); static override get styles() { return [ + materialStyles, sharedStyles, grFormStyles, css` .container { padding: var(--spacing-l); } + b { + font-weight: bolder; + } hr { margin-top: var(--spacing-l); margin-bottom: var(--spacing-l); border: 0; border-top: 1px solid var(--border-color); } - .flow { - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - margin: var(--spacing-m) 0; - padding: var(--spacing-m); + .header-actions { + margin-bottom: var(--spacing-l); } - .flow-id { - font-weight: var(--font-weight-bold); - } - .flow-header { + .flows-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--spacing-s); + margin-bottom: var(--spacing-m); } .heading-with-button { display: flex; align-items: center; } - .hidden { - display: none; - } - table { - border-collapse: collapse; - } - th, - td { - border: 1px solid var(--border-color); - padding: var(--spacing-s); - text-align: left; - } .main-heading { font-size: var(--font-size-h2); font-weight: var(--font-weight-bold); - margin-bottom: var(--spacing-m); + margin-bottom: 0; } - gr-icon { - font-size: var(--line-height-normal, 20px); - vertical-align: middle; + .flow { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin-bottom: var(--spacing-l); + background: var(--background-color-primary); + width: fit-content; } - gr-icon.done { - color: var(--success-foreground); + .flow-header { + background-color: var(--background-color-secondary); + padding: 0 var(--spacing-l); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + border-radius: var(--border-radius) var(--border-radius) 0 0; } - gr-icon.pending { + .flow-title { + font-weight: var(--font-weight-bold); + font-family: var(--header-font-family); + } + .flow-actions { + display: flex; + align-items: center; + } + .flow-info { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--spacing-m); + padding: var(--spacing-m) var(--spacing-l); + font-size: var(--font-size-small); color: var(--deemphasized-text-color); } - gr-icon.failed { - color: var(--error-foreground); - } .owner-container { display: flex; align-items: center; gap: var(--spacing-s); } + .stages { + padding: var(--spacing-m) var(--spacing-l); + } + gr-flow-rule { + margin-bottom: var(--spacing-m); + } + gr-flow-rule:last-child { + margin-bottom: 0; + } .refresh { top: -4px; } + .no-flows-message { + padding-bottom: var(--spacing-l); + } `, ]; } @@ -139,6 +149,11 @@ ); subscribe( this, + () => this.getChangeModel().isOwner$, + x => (this.isOwner = x) + ); + subscribe( + this, () => this.getFlowsModel().flows$, flows => { this.flows = flows; @@ -172,9 +187,35 @@ override render() { return html` <div class="container"> - <h2 class="main-heading">Create new flow</h2> - <gr-create-flow .changeNum=${this.changeNum}></gr-create-flow> - <hr /> + <div class="header-actions"> + ${when( + this.isOwner, + () => + html`<gr-create-flow + .changeNum=${this.changeNum} + ></gr-create-flow>`, + () => + html`<b>Note:</b> New flows can only be added by change owner.` + )} + </div> + <div class="flows-header"> + <div class="heading-with-button"> + <h2 class="main-heading">Scheduled Flows</h2> + ${when( + this.flows.length > 0, + () => + html`<gr-button + link + @click=${() => this.getFlowsModel().reload()} + aria-label="Refresh flows" + title="Refresh flows" + class="refresh" + > + <gr-icon icon="refresh"></gr-icon> + </gr-button>` + )} + </div> + </div> ${this.renderFlowsList()} </div> ${this.renderDeleteFlowModal()} @@ -196,15 +237,33 @@ </dialog>`; } - private renderStatus(stage: FlowInfo['stages'][0]): TemplateResult { - const icon = iconForFlowStageState(stage.state); - return html`<gr-icon - class=${icon.class} - icon=${icon.icon} - ?filled=${icon.filled} - aria-label=${stage.state.toLowerCase()} - role="img" - ></gr-icon>`; + private getFlowTitle(flow: FlowInfo) { + const lastStage = flow.stages[flow.stages.length - 1]; + const name = lastStage?.expression?.action?.name; + if (!name) return 'Flow'; + return formatActionName(name); + } + + private renderStageRow(stage: FlowStageInfo): TemplateResult { + const action = stage.expression.action; + + return html` + <gr-flow-rule + .state=${stage.state} + .message=${stage.message} + .condition=${stage.expression.condition} + .action=${action?.name} + .parameters=${action?.parameters} + ></gr-flow-rule> + `; + } + + private isFlowSuccessful(flow: FlowInfo): boolean { + if (!flow.stages || flow.stages.length === 0) { + return false; + } + const lastStage = flow.stages[flow.stages.length - 1]; + return lastStage.state === FlowStageState.DONE; } private renderFlowsList() { @@ -212,102 +271,67 @@ return html`<p>Loading...</p>`; } if (this.flows.length === 0) { - return html`<p>No flows found for this change.</p>`; + return html`<div class="no-flows-message"> + <p>No flows found for this change.</p> + </div>`; } - const filteredFlows = this.flows.filter(flow => { - if (this.statusFilter === 'all') return true; - const lastStage = flow.stages[flow.stages.length - 1]; - return lastStage.state === this.statusFilter; - }); return html` <div> - <div class="heading-with-button"> - <h2 class="main-heading">Existing Flows</h2> - <gr-button - link - @click=${() => this.getFlowsModel().reload()} - aria-label="Refresh flows" - title="Refresh flows" - class="refresh" - > - <gr-icon icon="refresh"></gr-icon> - </gr-button> - </div> - <md-filled-select - label="Filter by status" - @request-selection=${(e: CustomEvent) => { - this.statusFilter = (e.target as HTMLSelectElement).value as - | FlowStageState - | 'all'; - }} - > - <md-select-option value="all"> - <div slot="headline">All</div> - </md-select-option> - ${Object.values(FlowStageState).map( - status => html` - <md-select-option value=${status}> - <div slot="headline">${status}</div> - </md-select-option> - ` - )} - </md-filled-select> - ${filteredFlows.map( + ${this.flows.map( (flow: FlowInfo) => html` <div class="flow"> <div class="flow-header"> - <gr-button - link - @click=${() => this.openConfirmDialog(flow.uuid)} - title="Delete flow" - > - <gr-icon icon="delete" filled></gr-icon> - </gr-button> + <div class="flow-title">${this.getFlowTitle(flow)}</div> + <div class="flow-actions"> + <gr-copy-clipboard + .text=${computeFlowStringFromFlowStageInfo(flow.stages)} + buttonTitle="Copy flow string to clipboard" + hideinput + .smallIcon=${false} + ></gr-copy-clipboard> + ${when( + this.isOwner, + () => html` + <gr-button + link + ?disabled=${this.isFlowSuccessful(flow)} + @click=${() => this.openConfirmDialog(flow.uuid)} + title="Delete flow" + > + <gr-icon icon="delete"></gr-icon> + </gr-button> + ` + )} + </div> </div> - <div class="flow-id hidden">Flow ${flow.uuid}</div> - <div> - Created: - <gr-date-formatter withTooltip .dateStr=${flow.created}> - </gr-date-formatter> + + <div class="flow-info"> + <div class="owner-container"> + Owner: + <gr-avatar + .account=${flow.owner} + .imageSize=${16} + ></gr-avatar> + <gr-account-label .account=${flow.owner}></gr-account-label> + </div> + ${when( + flow.last_evaluated, + () => html` + <div> + Last Evaluation: + <gr-date-formatter + withTooltip + .dateStr=${flow.last_evaluated} + ></gr-date-formatter> + </div> + ` + )} </div> - ${when( - flow.last_evaluated, - () => - html` <div> - Last Evaluated: - <gr-date-formatter - withTooltip - .dateStr=${flow.last_evaluated} - > - </gr-date-formatter> - </div>` - )} - <table> - <thead> - <tr> - <th>Status</th> - <th>Condition</th> - <th>Action</th> - <th>Parameters</th> - <th>Message</th> - </tr> - </thead> - <tbody> - ${flow.stages.map(stage => { - const action = stage.expression.action; - return html` - <tr> - <td>${this.renderStatus(stage)}</td> - <td>${stage.expression.condition}</td> - <td>${action ? action.name : ''}</td> - <td>${action ? action.parameters : ''}</td> - <td>${stage.message ?? ''}</td> - </tr> - `; - })} - </tbody> - </table> + + <div class="stages"> + ${flow.stages.map(stage => this.renderStageRow(stage))} + </div> </div> ` )}
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_screenshot_test.ts new file mode 100644 index 0000000..b71d56e --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_screenshot_test.ts
@@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import './gr-flows'; +import {fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import {GrFlows} from './gr-flows'; +import { + query, + stubRestApi, + visualDiffDarkTheme, + waitUntil, +} from '../../../test/test-utils'; +import { + AccountId, + CommitId, + FlowInfo, + FlowStageState, + NumericChangeId, + Timestamp, +} from '../../../api/rest-api'; +import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model'; +import { + ChangeModel, + changeModelToken, +} from '../../../models/change/change-model'; +import {UserModel, userModelToken} from '../../../models/user/user-model'; +import {testResolver} from '../../../test/common-test-setup'; +import { + createAccountDetailWithId, + createAccountWithId, + createParsedChange, + createRevision, +} from '../../../test/test-data-generators'; + +function setChangeWithUploader( + changeModel: ChangeModel, + uploaderId: AccountId +) { + changeModel.updateState({ + change: { + ...createParsedChange(), + owner: createAccountWithId(1), + _number: 123 as NumericChangeId, + revisions: { + rev1: { + ...createRevision(1), + uploader: createAccountDetailWithId(uploaderId), + }, + }, + current_revision: 'rev1' as CommitId, + }, + }); +} + +suite('gr-flows screenshot tests', () => { + let element: GrFlows; + let flowsModel: FlowsModel; + let changeModel: ChangeModel; + let userModel: UserModel; + + setup(async () => { + stubRestApi('getIfFlowsIsEnabled').returns( + Promise.resolve({enabled: true}) + ); + stubRestApi('listFlows').returns(Promise.resolve([])); + + flowsModel = testResolver(flowsModelToken); + changeModel = testResolver(changeModelToken); + userModel = testResolver(userModelToken); + + element = await fixture<GrFlows>(html`<gr-flows></gr-flows>`); + + setChangeWithUploader(changeModel, 1 as AccountId); + userModel.setState({ + account: createAccountDetailWithId(1 as AccountId), + accountLoaded: true, + }); + + // Wait for the initial loading state to resolve from API mocks + await waitUntil(() => !flowsModel.getState().loading); + + const flows: FlowInfo[] = [ + { + uuid: 'flow-12345678-90ab-cdef-1234-567890abcdef', + owner: createAccountDetailWithId(1 as AccountId), + created: '2025-02-09 10:00:00.000000000' as Timestamp, + last_evaluated: '2025-02-09 10:05:00.000000000' as Timestamp, + stages: [ + { + state: FlowStageState.DONE, + expression: { + condition: 'status:open', + action: {name: 'review', parameters: ['Code-Review+1']}, + }, + message: 'Condition met, added Code-Review+1.', + }, + { + state: FlowStageState.PENDING, + expression: { + condition: 'status:merged', + action: {name: 'submit'}, + }, + message: 'Waiting for merge status.', + }, + { + state: FlowStageState.FAILED, + expression: { + condition: 'status:abandoned', + action: {name: 'review', parameters: ['Code-Review-2']}, + }, + message: 'Condition failed.', + }, + { + state: FlowStageState.TERMINATED, + expression: { + condition: 'status:terminated', + action: {name: 'review', parameters: ['Code-Review-2']}, + }, + message: 'Condition terminated.', + }, + ], + }, + ]; + + flowsModel.setState({ + flows, + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); + await element.updateComplete; + await waitUntil( + () => element.shadowRoot!.querySelectorAll('.flow').length === 1 + ); + await waitUntil(() => !!element.isOwner); + }); + + test('flows list', async () => { + await visualDiff(element, 'gr-flows'); + await visualDiffDarkTheme(element, 'gr-flows'); + }); + + test('flows empty state', async () => { + flowsModel.setState({ + ...flowsModel.getState(), + flows: [], + providers: [], + }); + await element.updateComplete; + await waitUntil( + () => + !!element + .shadowRoot!.querySelector('p') + ?.textContent?.includes('No flows found') + ); + await visualDiff(element, 'gr-flows-empty'); + await visualDiffDarkTheme(element, 'gr-flows-empty'); + }); + + test('flows loading state', async () => { + flowsModel.setState({ + ...flowsModel.getState(), + flows: [], + loading: true, + providers: [], + }); + await element.updateComplete; + await waitUntil( + () => + !!element + .shadowRoot!.querySelector('p') + ?.textContent?.includes('Loading') + ); + await visualDiff(element, 'gr-flows-loading'); + await visualDiffDarkTheme(element, 'gr-flows-loading'); + }); + + test('cannot create flow (not uploader)', async () => { + userModel.setState({ + account: createAccountDetailWithId(2 as AccountId), + accountLoaded: true, + }); + await element.updateComplete; + await waitUntil( + () => + !!query(element, '.header-actions')!.textContent?.includes( + 'New flows can only be added by change owner.' + ) + ); + await visualDiff(element, 'gr-flows-not-uploader'); + await visualDiffDarkTheme(element, 'gr-flows-not-uploader'); + }); + + test('multiple flows', async () => { + const originalFlows = flowsModel.getState().flows; + flowsModel.setState({ + ...flowsModel.getState(), + flows: [ + ...originalFlows, + { + uuid: 'flow-87654321-cdef-90ab-5678-abcdef123456', + owner: createAccountDetailWithId(1 as AccountId), + created: '2025-02-10 10:00:00.000000000' as Timestamp, + last_evaluated: undefined, + stages: [ + { + state: FlowStageState.PENDING, + expression: { + condition: 'status:merged', + action: {name: 'submit'}, + }, + }, + ], + }, + ], + providers: [], + }); + await element.updateComplete; + await waitUntil( + () => element.shadowRoot!.querySelectorAll('.flow').length === 2 + ); + await visualDiff(element, 'gr-flows-multiple'); + await visualDiffDarkTheme(element, 'gr-flows-multiple'); + }); +});
diff --git a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts index b21b090..dd6085d 100644 --- a/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts +++ b/polygerrit-ui/app/elements/change/gr-flows/gr-flows_test.ts
@@ -7,30 +7,73 @@ import './gr-flows'; import {assert, fixture, html} from '@open-wc/testing'; import {GrFlows} from './gr-flows'; -import {FlowInfo, FlowStageState, Timestamp} from '../../../api/rest-api'; +import { + AccountId, + CommitId, + FlowInfo, + FlowStageState, + Timestamp, +} from '../../../api/rest-api'; import {queryAndAssert} from '../../../test/test-utils'; import {NumericChangeId} from '../../../types/common'; import sinon from 'sinon'; import {GrButton} from '../../shared/gr-button/gr-button'; import {GrDialog} from '../../shared/gr-dialog/gr-dialog'; import {FlowsModel, flowsModelToken} from '../../../models/flows/flows-model'; +import { + ChangeModel, + changeModelToken, +} from '../../../models/change/change-model'; +import {UserModel, userModelToken} from '../../../models/user/user-model'; import {testResolver} from '../../../test/common-test-setup'; +import { + createAccountDetailWithId, + createFlow, + createParsedChange, + createRevision, +} from '../../../test/test-data-generators'; + +function setChangeWithOwner(changeModel: ChangeModel, ownerId: AccountId) { + changeModel.updateState({ + change: { + ...createParsedChange(), + _number: 123 as NumericChangeId, + owner: createAccountDetailWithId(ownerId), + revisions: { + rev1: { + ...createRevision(1), + uploader: createAccountDetailWithId(ownerId), + }, + }, + current_revision: 'rev1' as CommitId, + }, + }); +} suite('gr-flows tests', () => { let element: GrFlows; let clock: sinon.SinonFakeTimers; let flowsModel: FlowsModel; + let changeModel: ChangeModel; + let userModel: UserModel; setup(async () => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); + changeModel = testResolver(changeModelToken); + userModel = testResolver(userModelToken); flowsModel = testResolver(flowsModelToken); // The model is created by the DI system. The test setup replaces the real // model with a mock. To prevent real API calls, we stub the reload method. sinon.stub(flowsModel, 'reload'); element = await fixture<GrFlows>(html`<gr-flows></gr-flows>`); - element['changeNum'] = 123 as NumericChangeId; + await element.updateComplete; + setChangeWithOwner(changeModel, 123 as AccountId); + userModel.setState({ + account: createAccountDetailWithId(123 as AccountId), + accountLoaded: true, + }); await element.updateComplete; }); @@ -39,47 +82,32 @@ }); test('renders create flow component and no flows', async () => { - flowsModel.setState({flows: [], loading: false}); + flowsModel.setState({ + flows: [], + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); await element.updateComplete; - assert.shadowDom.equal( - element, - /* HTML */ ` - <div class="container"> - <h2 class="main-heading">Create new flow</h2> - <gr-create-flow></gr-create-flow> - <hr /> - <p>No flows found for this change.</p> - </div> - <dialog id="deleteFlowModal"> - <gr-dialog confirm-label="Delete"> - <div class="header" slot="header">Delete Flow</div> - <div class="main" slot="main"> - Are you sure you want to delete this flow? - </div> - </gr-dialog> - </dialog> - `, - {ignoreAttributes: ['role']} - ); }); test('renders flows', async () => { const flows: FlowInfo[] = [ - { - uuid: 'flow1', - owner: {name: 'owner1'}, - created: '2025-01-01T10:00:00.000Z' as Timestamp, + createFlow({ last_evaluated: '2025-01-01T11:00:00.000Z' as Timestamp, stages: [ { - expression: {condition: 'label:Code-Review=+1'}, - state: FlowStageState.DONE, + expression: { + condition: 'label:Code-Review=+1', + }, + state: FlowStageState.PENDING, }, ], - }, - { + }), + createFlow({ uuid: 'flow2', - owner: {name: 'owner2'}, + owner: {name: 'owner2', _account_id: 2 as AccountId}, created: '2025-01-02T10:00:00.000Z' as Timestamp, stages: [ { @@ -90,169 +118,209 @@ state: FlowStageState.PENDING, }, ], - }, + }), ]; - flowsModel.setState({flows, loading: false}); + flowsModel.setState({ + flows, + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); await element.updateComplete; // prettier formats the spacing for "last evaluated" incorrectly assert.shadowDom.equal( element, - /* prettier-ignore */ /* HTML */ ` + /* HTML */ ` <div class="container"> - <h2 class="main-heading">Create new flow</h2> - <gr-create-flow></gr-create-flow> - <hr /> - <div> + <div class="header-actions"> + <gr-create-flow> </gr-create-flow> + </div> + <div class="flows-header"> <div class="heading-with-button"> - <h2 class="main-heading">Existing Flows</h2> + <h2 class="main-heading">Scheduled Flows</h2> <gr-button + aria-disabled="false" aria-label="Refresh flows" + class="refresh" link="" + role="button" + tabindex="0" title="Refresh flows" > - <gr-icon icon="refresh"></gr-icon> + <gr-icon icon="refresh"> </gr-icon> </gr-button> </div> - <md-filled-select label="Filter by status"> - <md-select-option value="all"> - <div slot="headline">All</div> - </md-select-option> - <md-select-option value="DONE"> - <div slot="headline">DONE</div> - </md-select-option> - <md-select-option value="FAILED"> - <div slot="headline">FAILED</div> - </md-select-option> - <md-select-option value="PENDING"> - <div slot="headline">PENDING</div> - </md-select-option> - <md-select-option value="TERMINATED"> - <div slot="headline">TERMINATED</div> - </md-select-option> - </md-filled-select> + </div> + <div> <div class="flow"> <div class="flow-header"> - <gr-button link title="Delete flow"> - <gr-icon icon="delete" filled></gr-icon> - </gr-button> + <div class="flow-title">Flow</div> + <div class="flow-actions"> + <gr-copy-clipboard + buttontitle="Copy flow string to clipboard" + hideinput="" + > + </gr-copy-clipboard> + <gr-button + aria-disabled="false" + link="" + role="button" + tabindex="0" + title="Delete flow" + > + <gr-icon icon="delete"> </gr-icon> + </gr-button> + </div> </div> - <div class="flow-id hidden">Flow flow1</div> - <div> - Created: - <gr-date-formatter withtooltip></gr-date-formatter> + <div class="flow-info"> + <div class="owner-container"> + Owner: + <gr-avatar hidden=""> </gr-avatar> + <gr-account-label deselected=""> </gr-account-label> + </div> + <div> + Last Evaluation: + <gr-date-formatter withtooltip=""> </gr-date-formatter> + </div> </div> - <div> - Last Evaluated: - <gr-date-formatter withtooltip></gr-date-formatter> + <div class="stages"> + <gr-flow-rule></gr-flow-rule> </div> - <table> - <thead> - <tr> - <th>Status</th> - <th>Condition</th> - <th>Action</th> - <th>Parameters</th> - <th>Message</th> - </tr> - </thead> - <tbody> - <tr> - <td> - <gr-icon - aria-label="done" - filled - icon="check_circle" - ></gr-icon> - </td> - <td>label:Code-Review=+1</td> - <td></td> - <td></td> - <td></td> - </tr> - </tbody> - </table> </div> <div class="flow"> <div class="flow-header"> - <gr-button link title="Delete flow"> - <gr-icon icon="delete" filled></gr-icon> - </button> + <div class="flow-title">Submit</div> + <div class="flow-actions"> + <gr-copy-clipboard + buttontitle="Copy flow string to clipboard" + hideinput="" + > + </gr-copy-clipboard> + <gr-button + aria-disabled="false" + link="" + role="button" + tabindex="0" + title="Delete flow" + > + <gr-icon icon="delete"> </gr-icon> + </gr-button> + </div> </div> - <div class="flow-id hidden">Flow flow2</div> - <div> - Created: - <gr-date-formatter withtooltip></gr-date-formatter> + <div class="flow-info"> + <div class="owner-container"> + Owner: + <gr-avatar hidden=""> </gr-avatar> + <gr-account-label deselected=""> </gr-account-label> + </div> </div> - <table> - <thead> - <tr> - <th>Status</th> - <th>Condition</th> - <th>Action</th> - <th>Parameters</th> - <th>Message</th> - </tr> - </thead> - <tbody> - <tr> - <td> - <gr-icon aria-label="pending" icon="timelapse"></gr-icon> - </td> - <td>label:Verified=+1</td> - <td>submit</td> - <td></td> - <td></td> - </tr> - </tbody> - </table> + <div class="stages"> + <gr-flow-rule></gr-flow-rule> + </div> </div> </div> </div> <dialog id="deleteFlowModal"> - <gr-dialog confirm-label="Delete"> + <gr-dialog confirm-label="Delete" role="dialog"> <div class="header" slot="header">Delete Flow</div> <div class="main" slot="main"> Are you sure you want to delete this flow? </div> </gr-dialog> </dialog> - `, - { - ignoreAttributes: [ - 'style', - 'class', - 'account', - 'changenum', - 'datestr', - 'aria-disabled', - 'role', - 'tabindex', - 'md-menu-item', - ], - } + ` ); }); + test('disables delete button for successful flows', async () => { + const flows: FlowInfo[] = [ + createFlow({ + stages: [ + { + expression: { + condition: 'label:Verified=+1', + action: {name: 'submit'}, + }, + state: FlowStageState.DONE, + }, + ], + }), + ]; + flowsModel.setState({ + flows, + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); + await element.updateComplete; + + const deleteButton = queryAndAssert<GrButton>( + element, + 'gr-button[title="Delete flow"]' + ); + assert.isTrue(deleteButton.disabled); + }); + + test('does not disable delete button for pending flows', async () => { + const flows: FlowInfo[] = [ + createFlow({ + stages: [ + { + expression: { + condition: 'label:Verified=+1', + action: {name: 'submit'}, + }, + state: FlowStageState.PENDING, + }, + ], + }), + ]; + flowsModel.setState({ + flows, + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); + await element.updateComplete; + + const deleteButton = queryAndAssert<GrButton>( + element, + '.flow .flow-actions gr-button[title="Delete flow"]' + ); + assert.isFalse(deleteButton.disabled); + }); + test('deletes a flow after confirmation', async () => { const flows: FlowInfo[] = [ - { - uuid: 'flow1', - owner: {name: 'owner1'}, - created: '2025-01-01T10:00:00.000Z' as Timestamp, + createFlow({ stages: [ { - expression: {condition: 'label:Code-Review=+1'}, - state: FlowStageState.DONE, + expression: { + condition: 'label:Code-Review=+1', + }, + state: FlowStageState.PENDING, }, ], - }, + }), ]; const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow'); - flowsModel.setState({flows, loading: false}); + flowsModel.setState({ + flows, + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); await element.updateComplete; - const deleteButton = queryAndAssert<GrButton>(element, '.flow gr-button'); + const deleteButton = queryAndAssert<GrButton>( + element, + 'gr-button[title="Delete flow"]' + ); deleteButton.click(); await element.updateComplete; @@ -272,23 +340,31 @@ test('cancel deleting a flow', async () => { const flows: FlowInfo[] = [ - { - uuid: 'flow1', - owner: {name: 'owner1'}, - created: '2025-01-01T10:00:00.000Z' as Timestamp, + createFlow({ stages: [ { - expression: {condition: 'label:Code-Review=+1'}, - state: FlowStageState.DONE, + expression: { + condition: 'label:Code-Review=+1', + }, + state: FlowStageState.PENDING, }, ], - }, + }), ]; const deleteFlowStub = sinon.stub(flowsModel, 'deleteFlow'); - flowsModel.setState({flows, loading: false}); + flowsModel.setState({ + flows, + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); await element.updateComplete; - const deleteButton = queryAndAssert<GrButton>(element, '.flow gr-button'); + const deleteButton = queryAndAssert<GrButton>( + element, + 'gr-button[title="Delete flow"]' + ); deleteButton.click(); await element.updateComplete; @@ -308,13 +384,23 @@ }); test('refreshes flows on button click', async () => { - const flow = { - uuid: 'flow1', - owner: {name: 'owner1'}, - created: '2025-01-01T10:00:00.000Z' as Timestamp, - stages: [], - } as FlowInfo; - flowsModel.setState({flows: [flow], loading: false}); + const flow = createFlow({ + stages: [ + { + expression: { + condition: 'label:Code-Review=+1', + }, + state: FlowStageState.PENDING, + }, + ], + }); + flowsModel.setState({ + flows: [flow], + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); await element.updateComplete; const reloadStub = flowsModel.reload as sinon.SinonStub; @@ -322,7 +408,7 @@ const refreshButton = queryAndAssert<GrButton>( element, - '.heading-with-button gr-button' + '.flows-header gr-button' ); refreshButton.click(); await element.updateComplete; @@ -330,108 +416,44 @@ assert.isTrue(reloadStub.calledOnce); }); - suite('filter', () => { - const flows: FlowInfo[] = [ - { - uuid: 'flow-done', - owner: {name: 'owner1'}, - created: '2025-01-01T10:00:00.000Z' as Timestamp, - stages: [ - {expression: {condition: 'cond-done'}, state: FlowStageState.DONE}, - ], - }, - { - uuid: 'flow-pending', - owner: {name: 'owner2'}, - created: '2025-01-02T10:00:00.000Z' as Timestamp, - stages: [ - { - expression: {condition: 'cond-pending'}, - state: FlowStageState.PENDING, - }, - ], - }, - { - uuid: 'flow-failed', - owner: {name: 'owner3'}, - created: '2025-01-03T10:00:00.000Z' as Timestamp, - stages: [ - { - expression: {condition: 'cond-failed'}, - state: FlowStageState.FAILED, - }, - ], - }, - { - uuid: 'flow-terminated', - owner: {name: 'owner4'}, - created: '2025-01-04T10:00:00.000Z' as Timestamp, - stages: [ - { - expression: {condition: 'cond-terminated'}, - state: FlowStageState.TERMINATED, - }, - ], - }, - ]; - + suite('create flow visibility', () => { setup(async () => { - flowsModel.setState({flows, loading: false}); + flowsModel.setState({ + flows: [], + loading: false, + isEnabled: true, + providers: [], + autosubmitProviders: [], + }); await element.updateComplete; }); - test('shows all flows by default', () => { - const flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 4); + test('shows gr-create-flow when current user is owner', async () => { + const ownerId = 123 as AccountId; + const currentUserId = 123 as AccountId; + setChangeWithOwner(changeModel, ownerId); + userModel.setState({ + account: createAccountDetailWithId(currentUserId), + accountLoaded: true, + }); + await element.updateComplete; + + const createFlow = element.shadowRoot!.querySelector('gr-create-flow'); + assert.isNotNull(createFlow); }); - test('filters by DONE', async () => { - element['statusFilter'] = FlowStageState.DONE; + test('hides gr-create-flow when current user is not owner', async () => { + const ownerId = 456 as AccountId; + const currentUserId = 123 as AccountId; + setChangeWithOwner(changeModel, ownerId); + userModel.setState({ + account: createAccountDetailWithId(currentUserId), + accountLoaded: true, + }); await element.updateComplete; - const flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 1); - assert.include(flowElements[0].textContent, 'cond-done'); - }); - - test('filters by PENDING', async () => { - element['statusFilter'] = FlowStageState.PENDING; - await element.updateComplete; - - const flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 1); - assert.include(flowElements[0].textContent, 'cond-pending'); - }); - - test('filters by FAILED', async () => { - element['statusFilter'] = FlowStageState.FAILED; - await element.updateComplete; - - const flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 1); - assert.include(flowElements[0].textContent, 'cond-failed'); - }); - - test('filters by TERMINATED', async () => { - element['statusFilter'] = FlowStageState.TERMINATED; - await element.updateComplete; - - const flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 1); - assert.include(flowElements[0].textContent, 'cond-terminated'); - }); - - test('shows all when filter is changed to all', async () => { - element['statusFilter'] = FlowStageState.DONE; - await element.updateComplete; - let flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 1); - - element['statusFilter'] = 'all'; - await element.updateComplete; - - flowElements = element.shadowRoot!.querySelectorAll('.flow'); - assert.equal(flowElements.length, 4); + const createFlow = element.shadowRoot!.querySelector('gr-create-flow'); + assert.isNull(createFlow); }); }); });
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_screenshot_test.ts index 39d681c..d8d1926 100644 --- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_screenshot_test.ts +++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_screenshot_test.ts
@@ -11,6 +11,7 @@ import {visualDiff} from '@web/test-runner-visual-regression'; import {GrLabelScoreRow} from './gr-label-score-row'; import {visualDiffDarkTheme} from '../../../test/test-utils'; +import {setViewport} from '@web/test-runner-commands'; suite('gr-label-score-row screenshot tests', () => { let element: GrLabelScoreRow; @@ -40,10 +41,12 @@ }); test('label score row screenshot', async () => { + await setViewport({width: 1200, height: 800}); + // Create a container with a fixed width to stabilize the component's dimensions. const container = document.createElement('div'); - container.style.width = '392px'; + container.style.width = '700px'; container.style.display = 'inline-block'; container.appendChild(element);
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts index f83e15c..134ecc0 100644 --- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts +++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_screenshot_test.ts
@@ -10,6 +10,7 @@ // @ts-ignore import {visualDiff} from '@web/test-runner-visual-regression'; import {GrLabelScores} from './gr-label-scores'; +import {visualDiffDarkTheme} from '../../../test/test-utils'; import {setViewport} from '@web/test-runner-commands'; import { createAccountWithId, @@ -82,6 +83,10 @@ document.body.appendChild(container); await visualDiff(container, 'gr-label-scores-long-trigger-vote-label'); + await visualDiffDarkTheme( + container, + 'gr-label-scores-long-trigger-vote-label' + ); document.body.removeChild(container); });
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 e55631a..5e23cbbd 100644 --- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts +++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -150,6 +150,14 @@ ); } + private getRealAuthor() { + if (!this.message) return undefined; + if (isFormattedReviewerUpdate(this.message)) { + return this.message.realAuthor; + } + return this.message.real_author; + } + static override get styles() { return [ css` @@ -331,7 +339,7 @@ this.computeShowOnBehalfOf(), () => html` <span> - <span class="name">${this.message?.real_author?.name}</span> + <span class="name">${this.getRealAuthor()?.name}</span> on behalf of </span> ` @@ -684,10 +692,11 @@ // private but used in tests computeShowOnBehalfOf() { if (!this.message) return false; + const realAuthor = this.getRealAuthor(); return !!( this.author && - this.message.real_author && - this.author._account_id !== this.message.real_author._account_id + realAuthor && + this.author._account_id !== realAuthor._account_id ); }
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 d5f4e52..66a1951 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
@@ -295,6 +295,7 @@ // Have to type as any otherwise fails with // Argument of type 'ChangeMessageInfo[]' is not assignable to // parameter of type 'ConcatArray<never>'. + // eslint-disable-next-line @typescript-eslint/no-explicit-any const messages = ([] as any).concat( randomMessage(), { @@ -599,6 +600,7 @@ test('_computeLabelExtremes', () => { // Have to type as any to be able to use null. + // eslint-disable-next-line @typescript-eslint/no-explicit-any element.labels = null as any; assert.deepEqual(element.computeLabelExtremes(), {});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change_test.ts new file mode 100644 index 0000000..57ac5df --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change_test.ts
@@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {assert, fixture, html} from '@open-wc/testing'; +import '../../../test/common-test-setup'; +import {createParsedChange} from '../../../test/test-data-generators'; +import './gr-related-change'; +import {GrRelatedChange} from './gr-related-change'; + +suite('gr-related-change', () => { + let element: GrRelatedChange; + + setup(async () => { + element = await fixture<GrRelatedChange>( + html`<gr-related-change + .change=${createParsedChange()} + href="/c/test-project/+/42" + label="Test subject" + ></gr-related-change>` + ); + }); + + test('render', async () => { + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="changeContainer"> + <a href="/c/test-project/+/42" aria-label="Test subject"> + <slot name="name"></slot> + </a> + <slot name="extra"></slot> + </div> + ` + ); + }); +});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts new file mode 100644 index 0000000..0e9e5c6 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse_test.ts
@@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {assert, fixture, html} from '@open-wc/testing'; +import '../../../test/common-test-setup'; +import './gr-related-collapse'; +import {GrRelatedCollapse} from './gr-related-collapse'; + +suite('gr-related-collapse', () => { + let element: GrRelatedCollapse; + + setup(async () => { + element = await fixture<GrRelatedCollapse>( + html`<gr-related-collapse></gr-related-collapse>` + ); + }); + + test('render', async () => { + element.name = 'Related Changes'; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="container"> + <h3 class="heading-3 title">Related Changes</h3> + </div> + <div> + <slot> </slot> + </div> + ` + ); + }); +});
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 04c8618..a10e118 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
@@ -103,6 +103,7 @@ import {LabelNameToValuesMap, PatchSetNumber} from '../../../api/rest-api'; import {css, html, LitElement, nothing, PropertyValues} from 'lit'; import {sharedStyles} from '../../../styles/shared-styles'; +import {flowsModelToken} from '../../../models/flows/flows-model'; import {when} from 'lit/directives/when.js'; import {classMap} from 'lit/directives/class-map.js'; import { @@ -138,9 +139,15 @@ readJSONResponsePayload, ResponsePayload, } from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; +import '../../shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox'; +import { + AutosubmitCheckedChangedEvent, + GrAutosubmitCheckbox, +} from '../../shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox'; export enum FocusTarget { ANY = 'any', + BODY = 'body', CCS = 'cc', REVIEWERS = 'reviewers', @@ -177,10 +184,14 @@ private readonly reporting = getAppContext().reportingService; - private readonly getChangeModel = resolve(this, changeModelToken); + // private but used in tests + readonly getChangeModel = resolve(this, changeModelToken); private readonly getCommentsModel = resolve(this, commentsModelToken); + // private but used in tests + readonly getFlowsModel = resolve(this, flowsModelToken); + // TODO: update type to only ParsedChangeInfo @property({type: Object}) change?: ParsedChangeInfo | ChangeInfo; @@ -300,6 +311,9 @@ @state() includeComments = true; + @state() + autosubmitChecked = false; + @state() reviewers: AccountInput[] = []; @state() @@ -410,6 +424,9 @@ /* We want the :hover highlight to extend to the border of the dialog. */ padding: var(--spacing-m) 0; } + section.autosubmitContainer { + padding-left: var(--spacing-l); + } .stickyBottom { background-color: var(--dialog-background-color); box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15); @@ -769,9 +786,6 @@ if (changedProperties.has('canBeStarted')) { this.computeMessagePlaceholder(); } - if (changedProperties.has('attentionExpanded')) { - this.onAttentionExpandedChange(); - } if ( changedProperties.has('account') || changedProperties.has('reviewers') || @@ -816,6 +830,11 @@ ${this.renderReplyText()} </section> ${this.renderDraftsSection()} + <section class="autosubmitContainer"> + <gr-autosubmit-checkbox + @autosubmit-checked-changed=${this.handleAutosubmitChanged} + ></gr-autosubmit-checkbox> + </section> <div class="stickyBottom newReplyDialog"> <gr-endpoint-decorator name="reply-bottom"> <gr-endpoint-param @@ -1336,6 +1355,11 @@ this.includeComments = e.target.checked; } + private handleAutosubmitChanged(e: AutosubmitCheckedChangedEvent) { + if (!(e.target instanceof GrAutosubmitCheckbox)) return; + this.autosubmitChecked = e.detail.checked; + } + setLabelValue(label: string, value: string): void { const selectorEl = this.getLabelScores().shadowRoot?.querySelector<GrLabelScoreRow>( @@ -1493,9 +1517,6 @@ // visible for testing async send(includeComments: boolean, startReview: boolean) { - // ChangeModel will be updated once the reply returns at which point the - // timer will be ended. - this.reporting.time(Timing.SEND_REPLY); const labels = this.getLabelScores().getLabelValues(); if (labels[StandardLabels.CODE_REVIEW] === 2) { this.reporting.reportInteraction(Interaction.CODE_REVIEW_APPROVAL); @@ -1589,6 +1610,9 @@ )) ) return; + // ChangeModel will be updated once the reply returns at which point the + // timer will be ended. + this.reporting.time(Timing.SEND_REPLY); this.getNavigation().blockNavigation('sending review'); return this.saveReview(reviewInput, errFn) .then(result => { @@ -1615,6 +1639,10 @@ ); if (reloadRequired) { fireReload(this); + } else { + // Only reload submittability if the full reload is not triggered, to + // avoid duplicate requests. + this.getChangeModel().reloadSubmittability(); } this.patchsetLevelDraftMessage = ''; @@ -1623,7 +1651,7 @@ fireIronAnnounce(this, 'Reply sent'); this.getPluginLoader().jsApiService.handleReplySent(); }) - .finally(() => { + .finally(async () => { this.getNavigation().releaseNavigation('sending review'); this.disabled = false; if (this.patchsetLevelGrComment) { @@ -1632,6 +1660,9 @@ // The request finished and reloads if necessary are asynchronously // scheduled. this.reporting.timeEnd(Timing.SEND_REPLY); + if (this.autosubmitChecked) { + await this.getFlowsModel().createAutosubmitFlow(); + } }); } @@ -1751,12 +1782,6 @@ this.attentionExpanded = !this.attentionExpanded; } - onAttentionExpandedChange() { - // If the attention-detail section is expanded without dispatching this - // event, then the dialog may expand beyond the screen's bottom border. - fire(this, 'iron-resize', {}); - } - handleAttentionClick(e: Event) { const targetAccount = (e.target as GrAccountChip)?.account; if (!targetAccount) return;
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_screenshot_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_screenshot_test.ts new file mode 100644 index 0000000..2441174 --- /dev/null +++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_screenshot_test.ts
@@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import './gr-reply-dialog'; +import {fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import {GrReplyDialog} from './gr-reply-dialog'; +import {visualDiffDarkTheme} from '../../../test/test-utils'; +import { + createAccountDetailWithId, + createChange, +} from '../../../test/test-data-generators'; +import {testResolver} from '../../../test/common-test-setup'; +import {commentsModelToken} from '../../../models/comments/comments-model'; +import {PatchSetNumber} from '../../../api/rest-api'; +import {userModelToken} from '../../../models/user/user-model'; +import {ParsedChangeInfo} from '../../../types/types'; + +suite('gr-reply-dialog screenshot tests', () => { + let element: GrReplyDialog; + + setup(async () => { + testResolver(commentsModelToken); + + element = await fixture<GrReplyDialog>( + html`<gr-reply-dialog></gr-reply-dialog>` + ); + element.change = createChange(); + element.latestPatchNum = 1 as PatchSetNumber; + const change = createChange(); + const userModel = testResolver(userModelToken); + userModel.setAccount(createAccountDetailWithId(change.owner._account_id)); + element.getChangeModel().updateState({ + change: change as ParsedChangeInfo, + }); + element.getFlowsModel().updateState({ + isEnabled: true, + autosubmitProviders: [ + { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => '', + getSubmitAction: () => undefined, + }, + ], + }); + await element.updateComplete; + await element.updateComplete; + }); + + test('autosubmit checkbox rendered', async () => { + element.autosubmitChecked = true; + await element.updateComplete; + await visualDiff(element, 'gr-reply-dialog-autosubmit'); + await visualDiffDarkTheme(element, 'gr-reply-dialog-autosubmit'); + }); +});
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 52a4bd7..a8f89d5 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
@@ -26,6 +26,7 @@ } from '../../../constants/constants'; import {StandardLabels} from '../../../utils/label-util'; import { + createAccountDetailWithId, createAccountWithEmail, createAccountWithId, createChange, @@ -64,7 +65,6 @@ import {assert, fixture, html, waitUntil} 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 {Key, Modifier} from '../../../utils/dom-util'; import {GrComment} from '../../shared/gr-comment/gr-comment'; import {testResolver} from '../../../test/common-test-setup'; @@ -77,6 +77,10 @@ import {Timing} from '../../../constants/reporting'; import {ParsedChangeInfo} from '../../../types/types'; import {changeModelToken} from '../../../models/change/change-model'; +import {GrAccountLabel} from '../../shared/gr-account-label/gr-account-label'; +import {userModelToken} from '../../../models/user/user-model'; +import {MdCheckbox} from '@material/web/checkbox/checkbox'; +import {GrAutosubmitCheckbox} from '../../shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox'; function cloneableResponse(status: number, text: string) { return { @@ -275,6 +279,9 @@ </gr-endpoint-decorator> </div> </section> + <section class="autosubmitContainer"> + <gr-autosubmit-checkbox> </gr-autosubmit-checkbox> + </section> <div class="newReplyDialog stickyBottom"> <gr-endpoint-decorator name="reply-bottom"> <gr-endpoint-param name="change"> </gr-endpoint-param> @@ -466,8 +473,6 @@ test('save review fires sendReply metric', async () => { const timeEndStub = stubReporting('timeEnd'); - // Async tick is needed because iron-selector content is distributed and - // distributed content requires an observer to be set up. await element.updateComplete; element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove'; element.draftCommentThreads = [createCommentThread([createComment()])]; @@ -486,8 +491,6 @@ }); 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. await element.updateComplete; element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove'; element.draftCommentThreads = [createCommentThread([createComment()])]; @@ -1351,8 +1354,6 @@ queryAndAssert<HTMLInputElement>(element, '#includeComments').click(); assert.equal(element.includeComments, false); - // Async tick is needed because iron-selector content is distributed and - // distributed content requires an observer to be set up. await element.updateComplete; element.patchsetLevelDraftMessage = 'I wholeheartedly disapprove'; @@ -2690,6 +2691,58 @@ }); }); + suite('createAutosubmitFlow', () => { + let createAutosubmitFlowStub: sinon.SinonStub; + + setup(async () => { + createAutosubmitFlowStub = sinon.stub( + element.getFlowsModel(), + 'createAutosubmitFlow' + ); + sinon.stub(element, 'saveReview').resolves({ + change_info: createChange(), + }); + const change = createChange(); + const userModel = testResolver(userModelToken); + userModel.setAccount(createAccountDetailWithId(change.owner._account_id)); + element.getChangeModel().updateState({ + change: change as ParsedChangeInfo, + }); + element.getFlowsModel().updateState({ + isEnabled: true, + autosubmitProviders: [ + { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => '', + getSubmitAction: () => undefined, + }, + ], + }); + await element.updateComplete; + }); + + test('createAutosubmitFlow is called when autosubmitChecked is true', async () => { + const checkbox = queryAndAssert<GrAutosubmitCheckbox>( + element, + 'gr-autosubmit-checkbox' + ); + queryAndAssert<MdCheckbox>(checkbox, 'md-checkbox').click(); + + await waitUntil(() => !!element.autosubmitChecked); + + await element.send(false, false); + await waitUntil(() => !!createAutosubmitFlowStub.calledOnce); + }); + + test('createAutosubmitFlow is not called when autosubmitChecked is false', async () => { + element.autosubmitChecked = false; + await element.updateComplete; + + await element.send(false, false); + assert.isFalse(createAutosubmitFlowStub.called); + }); + }); + test('manually added users are not lost when view updates.', async () => { assert.sameMembers([...element.newAttentionSet], []); @@ -2744,8 +2797,6 @@ }); test('reload change if patchset updated', async () => { - // Async tick is needed because iron-selector content is distributed and - // distributed content requires an observer to be set up. await element.updateComplete; const changeModel = testResolver(changeModelToken); const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange'); @@ -2790,8 +2841,6 @@ }); test('no reload if patchset is the same', async () => { - // Async tick is needed because iron-selector content is distributed and - // distributed content requires an observer to be set up. await element.updateComplete; const changeModel = testResolver(changeModelToken); const changeStateUpdateSpy = sinon.spy(changeModel, 'updateStateChange');
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 ce3c20e..a39917d 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
@@ -3,10 +3,12 @@ * Copyright 2021 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import {css, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {join} from 'lit/directives/join.js'; import '../../shared/gr-button/gr-button'; import '../../shared/gr-icon/gr-icon'; import '../../shared/gr-label-info/gr-label-info'; -import {customElement, property} from 'lit/decorators.js'; import { AccountInfo, ChangeStatus, @@ -22,7 +24,6 @@ iconForRequirement, } from '../../../utils/label-util'; import {ParsedChangeInfo} from '../../../types/types'; -import {css, html, LitElement} from 'lit'; import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin'; import {fontStyles} from '../../../styles/gr-font-styles'; import {DraftsAction} from '../../../constants/constants'; @@ -37,6 +38,19 @@ SubmitRequirementExpressionPart, } from '../../../utils/submit-requirement-util'; +function getRequirementErrorMessage(requirement: SubmitRequirementResultInfo) { + if (requirement.submittability_expression_result.error_message) { + return requirement.submittability_expression_result.error_message; + } + if (requirement.applicability_expression_result?.error_message) { + return requirement.applicability_expression_result?.error_message; + } + if (requirement.override_expression_result?.error_message) { + return requirement.override_expression_result?.error_message; + } + return undefined; +} + // This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error. const base = HovercardMixin(LitElement); @@ -120,6 +134,12 @@ .expression .passing.atom { border-bottom: 2px solid var(--success-foreground); } + .explanations .failing.atom { + border-bottom: 2px solid var(--error-foreground); + } + .explanations .passing.atom { + border-bottom: 2px solid var(--success-foreground); + } .button gr-icon { color: inherit; } @@ -177,11 +197,9 @@ private renderDescription() { let description = this.requirement?.description; if (this.requirement?.status === SubmitRequirementStatus.ERROR) { - const submitRecord = this.change?.submit_records?.filter( - record => record.rule_name === this.requirement?.name - ); - if (submitRecord?.length === 1 && submitRecord[0].error_message) { - description = submitRecord[0].error_message; + const error_message = getRequirementErrorMessage(this.requirement); + if (error_message) { + description = error_message; } } if (!description) return; @@ -370,10 +388,7 @@ } private getTitleFromPart(part: SubmitRequirementExpressionPart) { - let title = this.getTitleFromAtomStatus(part.atomStatus!); - if (part.atomExplanation) { - title += `: ${part.atomExplanation}`; - } + const title = this.getTitleFromAtomStatus(part.atomStatus!); return title; } @@ -395,12 +410,36 @@ expression?: SubmitRequirementExpressionInfo ) { if (!expression?.expression) return ''; + const atoms = atomizeExpression(expression); + let explanations = html``; + if (atoms.some(part => part.atomExplanation)) { + explanations = html` + <br /><br /> + Atom explanations:<br /> + <span class="explanations"> + ${join( + atoms + .filter(part => part.atomExplanation) + .map( + part => html` + <span + class=${this.getClassFromAtomStatus(part.atomStatus!)} + title=${this.getTitleFromPart(part)} + >${part.value}</span + >: <span class="explanation">${part.atomExplanation}</span> + ` + ), + html`<br />` + )} + </span> + `; + } return html` <div class="section condition"> <div class="sectionContent"> ${name}:<br /> <span class="expression"> - ${atomizeExpression(expression).map(part => + ${atoms.map(part => part.isAtom ? html`<span class=${this.getClassFromAtomStatus(part.atomStatus!)} @@ -410,6 +449,7 @@ : part.value )} </span> + ${explanations} </div> </div> `;
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts index 377a649..5972b41 100644 --- a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts +++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -13,7 +13,7 @@ import '../../checks/gr-checks-chip-for-label'; import {css, html, LitElement, nothing, TemplateResult} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; -import {ParsedChangeInfo} from '../../../types/types'; +import {LoadingStatus, ParsedChangeInfo} from '../../../types/types'; import {repeat} from 'lit/directives/repeat.js'; import { AccountInfo, @@ -49,6 +49,7 @@ import {subscribe} from '../../lit/subscription-controller'; import {when} from 'lit/directives/when.js'; import {spinnerStyles} from '../../../styles/gr-spinner-styles'; +import {changeModelToken} from '../../../models/change/change-model'; /** * @attr {Boolean} suppress-title - hide titles, currently for hovercard view @@ -73,6 +74,9 @@ @state() runs: CheckRun[] = []; + @state() + requirementsLoading?: boolean; + static override get styles() { return [ fontStyles, @@ -147,6 +151,8 @@ private readonly getChecksModel = resolve(this, checksModelToken); + private readonly getChangeModel = resolve(this, changeModelToken); + constructor() { super(); subscribe( @@ -154,6 +160,11 @@ () => this.getChecksModel().allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x) ); + subscribe( + this, + () => this.getChangeModel().submittabilityLoadingStatus$, + x => (this.requirementsLoading = x === LoadingStatus.LOADING) + ); } override render() { @@ -173,7 +184,7 @@ > Submit Requirements ${when( - submit_requirements.length === 0, + this.requirementsLoading, () => html`<span class="loadingSpin"
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 aee2017..8f55c29 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
@@ -72,6 +72,7 @@ submittable: false, }, loadingStatus: LoadingStatus.LOADED, + submittabilityLoadingStatus: LoadingStatus.LOADED, }); element = await fixture<GrSubmitRequirements>( html`<gr-submit-requirements @@ -138,6 +139,32 @@ ); }); + test('renders loading', async () => { + element.requirementsLoading = true; + element.change = { + ...element.change, + submit_requirements: undefined, + } as ParsedChangeInfo; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <h3 class="heading-3 metadata-title" id="submit-requirements-caption"> + Submit Requirements + <span + class="loadingSpin" + title="Submit Requirements status is being updated" + > + </span> + </h3> + <h3 class="heading-3 metadata-title">Label Votes</h3> + <section class="trigger-votes"> + <gr-trigger-vote> </gr-trigger-vote> + </section> + ` + ); + }); + suite('votes-cell', () => { setup(async () => { element.disableEndpoints = true;
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-header.ts b/polygerrit-ui/app/elements/chat-panel/chat-header.ts new file mode 100644 index 0000000..ad0fd4a --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-header.ts
@@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/iconbutton/icon-button.js'; +import '@material/web/button/text-button.js'; +import '@material/web/icon/icon.js'; +import '@material/web/menu/menu.js'; +import '@material/web/menu/menu-item.js'; +import '../shared/gr-icon/gr-icon'; + +import {MdMenu} from '@material/web/menu/menu'; +import {css, html, LitElement} from 'lit'; +import {customElement, query, state} from 'lit/decorators.js'; +import {styleMap} from 'lit/directives/style-map.js'; + +import {fire} from '../../utils/event-util'; +import {subscribe} from '../lit/subscription-controller'; +import {ModelInfo} from '../../api/ai-code-review'; +import {chatModelToken, ChatPanelMode} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {classMap} from 'lit/directives/class-map.js'; +import {materialStyles} from '../../styles/gr-material-styles'; + +@customElement('chat-header') +export class ChatHeader extends LitElement { + static override styles = [ + materialStyles, + css` + :host { + display: flex; + padding: 0 var(--spacing-xxl) 0 var(--spacing-xl); + align-items: center; + color: var(--primary-text-color); + } + .title { + color: var(--primary-text-color); + font-family: var(--header-font-family); + font-size: var(--font-size-h2); + font-weight: var(--font-weight-h2); + line-height: var(--line-height-h2); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + md-text-button.select-model-trigger { + height: auto; + min-width: 50px; + } + .title-group { + display: flex; + flex-direction: column; + align-items: start; + } + .subtitle { + font-size: 12px; + font-weight: 500; + color: var(--deemphasized-text-color); + display: flex; + flex-direction: row; + align-items: center; + max-width: 100%; + } + .subtitle-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .arrow-drop-down { + height: 16px; + width: 16px; + font-size: 18px; + margin-top: -2px; + } + .hidden { + visibility: hidden; + pointer-events: none; + } + :host > md-icon-button, + :host > gr-icon { + flex-shrink: 0; + } + md-icon-button { + height: 40px; + width: 40px; + font-size: 20px; + font-weight: 500; + } + md-icon-button.back-arrow { + height: 32px; + width: 32px; + padding-right: 0px; + } + md-icon { + vertical-align: middle; + } + md-icon-button:disabled md-icon { + color: var(--deemphasized-text-color); + } + .gemini-icon { + color: var(--deemphasized-text-color); + font-size: 24px; + margin-right: 3px; + } + .first-right-button { + margin-left: auto; + } + .more-actions-menu md-menu-item md-icon { + color: var(--deemphasized-text-color); + } + .select-model-menu { + max-width: 500px; + } + md-text-button.select-model-trigger > span { + min-width: 0; + } + md-icon-button { + color: var(--primary-text-color); + --md-icon-button-icon-color: var(--primary-text-color); + --md-icon-button-hover-icon-color: var(--primary-text-color); + } + md-icon-button md-icon { + color: var(--primary-text-color); + } + `, + ]; + + @state() availableModels: ModelInfo[] = []; + + @state() selectedModel?: ModelInfo; + + @state() documentationUrl?: string; + + @state() mode: ChatPanelMode = ChatPanelMode.CONVERSATION; + + @query('#selectModelMenu') private selectModelMenu?: MdMenu; + + @query('#moreActionsMenu') private moreActionsMenu?: MdMenu; + + @state() private supportsHistory = true; + + @state() private supportsMoreMenu = true; + + private readonly getChatModel = resolve(this, chatModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().availableModelsMap$, + x => (this.availableModels = [...x.values()]) + ); + subscribe( + this, + () => this.getChatModel().selectedModel$, + x => (this.selectedModel = x) + ); + subscribe( + this, + () => this.getChatModel().models$, + x => (this.documentationUrl = x?.documentation_url) + ); + subscribe( + this, + () => this.getChatModel().mode$, + x => (this.mode = x ?? ChatPanelMode.CONVERSATION) + ); + subscribe( + this, + () => this.getChatModel().provider$, + provider => { + this.supportsHistory = provider?.supports_history ?? true; + this.supportsMoreMenu = provider?.supports_more_menu ?? true; + } + ); + } + + override render() { + return html` + ${this.renderLeftSectionChat()} ${this.renderLeftSectionHistory()} + ${this.renderRightButtons()} ${this.renderMenus()} + `; + } + + private renderLeftSectionHistory() { + if (this.mode !== ChatPanelMode.HISTORY) return; + return html` + <md-icon-button + class="back-arrow" + aria-label="Back to chat" + title="Back to chat" + @click=${this.backToChat} + > + <md-icon>arrow_back_ios</md-icon> + </md-icon-button> + <span class="title">History</span> + `; + } + + private renderLeftSectionChat() { + if (this.mode !== ChatPanelMode.CONVERSATION) return; + return html` + <gr-icon class="gemini-icon" icon="ai"></gr-icon> + <md-text-button + id="selectModelTrigger" + class="select-model-trigger" + @click=${() => + this.selectModelMenu && (this.selectModelMenu.open = true)} + ?disabled=${!this.selectedModel} + > + <div class="title-group"> + <span class="title">Review Agent</span> + ${this.selectedModel + ? html` + <div class="subtitle"> + <span class="subtitle-text" + >${this.selectedModel?.short_text}</span + > + <md-icon class="arrow-drop-down">arrow_drop_down</md-icon> + </div> + ` + : ''} + </div> + </md-text-button> + `; + } + + private renderRightButtons() { + return html` + <md-icon-button + class=${classMap({ + 'history-button': true, + 'first-right-button': true, + hidden: !this.supportsHistory, + })} + aria-label="Show history" + title="Show history" + @click=${this.showHistory} + > + <md-icon>history</md-icon> + </md-icon-button> + + <md-icon-button + id="moreActionsTrigger" + class=${classMap({ + 'more-actions-trigger': true, + hidden: !this.supportsMoreMenu, + })} + aria-label="More actions" + title="More" + @click=${() => + this.moreActionsMenu && (this.moreActionsMenu.open = true)} + > + <md-icon>more_vert</md-icon> + </md-icon-button> + + <md-icon-button + class="clear-history-button" + @click=${this.startNewConversation} + title="Start a new conversation" + aria-label="Start a new conversation" + > + <md-icon>add</md-icon> + </md-icon-button> + + <md-icon-button + class="close-button" + @click=${this.closePanel} + title="Close Review Agent panel" + aria-label="Close Review Agent panel" + > + <md-icon>clear</md-icon> + </md-icon-button> + `; + } + + private renderMenus() { + return html` + <md-menu + id="selectModelMenu" + anchor="selectModelTrigger" + class="select-model-menu" + > + ${this.availableModels.map( + option => html` + <md-menu-item @click=${() => this.onSwitchModel(option)}> + <md-icon + slot="start" + style=${styleMap({ + visibility: + this.selectedModel?.model_id === option.model_id + ? 'visible' + : 'hidden', + })} + >done</md-icon + > + ${option.full_display_text} + </md-menu-item> + ` + )} + </md-menu> + ${this.renderDocumentationMenu()} + `; + } + + private renderDocumentationMenu() { + if (!this.documentationUrl) return; + return html` + <md-menu + id="moreActionsMenu" + anchor="moreActionsTrigger" + class="more-actions-menu" + menu-corner="start-end" + anchor-corner="end-end" + > + <a + href=${this.documentationUrl} + target="_blank" + rel="noopener noreferrer" + style="text-decoration: none;" + > + <md-menu-item> + <md-icon slot="start">help_outline</md-icon> + Documentation + </md-menu-item> + </a> + </md-menu> + `; + } + + private onSwitchModel(model: ModelInfo) { + this.getChatModel().selectModel(model.model_id); + } + + private closePanel() { + fire(this, 'close-chat-panel', {}); + } + + private startNewConversation() { + this.getChatModel().setMode(ChatPanelMode.CONVERSATION); + this.getChatModel().startEmptyNewChat(true); + } + + private showHistory() { + this.getChatModel().setMode(ChatPanelMode.HISTORY); + } + + private backToChat() { + this.getChatModel().setMode(ChatPanelMode.CONVERSATION); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-header': ChatHeader; + } + interface HTMLElementEventMap { + 'close-chat-panel': CustomEvent; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-header_test.ts b/polygerrit-ui/app/elements/chat-panel/chat-header_test.ts new file mode 100644 index 0000000..feb6876 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-header_test.ts
@@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './chat-header'; +import {ChatHeader} from './chat-header'; +import { + ChatModel, + chatModelToken, + ChatPanelMode, +} from '../../models/chat/chat-model'; +import sinon from 'sinon'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {changeModelToken} from '../../models/change/change-model'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('chat-header tests', () => { + let element: ChatHeader; + let chatModel: ChatModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + element = await fixture(html`<chat-header></chat-header>`); + chatModel = testResolver(chatModelToken); + await element.updateComplete; + }); + + test('renders', () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <gr-icon class="gemini-icon" custom="" icon="ai"></gr-icon> + <md-text-button + id="selectModelTrigger" + class="select-model-trigger" + value="" + > + <div class="title-group"> + <span class="title">Review Agent</span> + <div class="subtitle"> + <span class="subtitle-text">Gemini Pro</span> + <md-icon aria-hidden="true" class="arrow-drop-down" + >arrow_drop_down</md-icon + > + </div> + </div> + </md-text-button> + <md-icon-button + class="history-button first-right-button" + data-aria-label="Show history" + title="Show history" + value="" + > + <md-icon aria-hidden="true">history</md-icon> + </md-icon-button> + <md-icon-button + id="moreActionsTrigger" + class="more-actions-trigger" + data-aria-label="More actions" + title="More" + value="" + > + <md-icon aria-hidden="true">more_vert</md-icon> + </md-icon-button> + <md-icon-button + class="clear-history-button" + title="Start a new conversation" + data-aria-label="Start a new conversation" + value="" + > + <md-icon aria-hidden="true">add</md-icon> + </md-icon-button> + <md-icon-button + class="close-button" + title="Close Review Agent panel" + data-aria-label="Close Review Agent panel" + value="" + > + <md-icon aria-hidden="true">clear</md-icon> + </md-icon-button> + <md-menu + id="selectModelMenu" + anchor="selectModelTrigger" + class="select-model-menu" + aria-hidden="true" + > + <md-menu-item md-menu-item="" tabindex="0"> + <md-icon slot="start" style="visibility:visible;" aria-hidden="true" + >done</md-icon + > + Gemini Pro + </md-menu-item> + <md-menu-item md-menu-item="" tabindex="-1"> + <md-icon slot="start" style="visibility:hidden;" aria-hidden="true" + >done</md-icon + > + Gemini Ultra + </md-menu-item> + </md-menu> + <md-menu + id="moreActionsMenu" + anchor="moreActionsTrigger" + class="more-actions-menu" + menu-corner="start-end" + anchor-corner="end-end" + aria-hidden="true" + > + <a + href="http://doc.url" + target="_blank" + rel="noopener noreferrer" + style="text-decoration: none;" + > + <md-menu-item md-menu-item=""> + <md-icon slot="start" aria-hidden="true">help_outline</md-icon> + Documentation + </md-menu-item> + </a> + </md-menu> + ` + ); + }); + + test('renders history mode', async () => { + chatModel.setMode(ChatPanelMode.HISTORY); + await element.updateComplete; + + const backButton = element.shadowRoot?.querySelector('.back-arrow'); + assert.isOk(backButton); + const title = element.shadowRoot?.querySelector('.title'); + assert.equal(title?.textContent?.trim(), 'History'); + }); + + test('handles switching model', async () => { + const menuItems = element.shadowRoot?.querySelectorAll( + '#selectModelMenu md-menu-item' + ); + assert.equal(menuItems?.length, 2); + assert.equal(element.selectedModel?.model_id, 'gemini-pro'); + (menuItems![1] as HTMLElement).click(); + await element.updateComplete; + assert.equal(element.selectedModel?.model_id, 'gemini-ultra'); + }); + + test('handles show history', async () => { + assert.equal(element.mode, ChatPanelMode.CONVERSATION); + const historyButton = element.shadowRoot?.querySelector( + '.history-button' + ) as HTMLElement; + historyButton.click(); + await element.updateComplete; + assert.equal(element.mode, ChatPanelMode.HISTORY); + }); + + test('handles back to chat', async () => { + chatModel.setMode(ChatPanelMode.HISTORY); + await element.updateComplete; + assert.equal(element.mode, ChatPanelMode.HISTORY); + const backButton = element.shadowRoot?.querySelector( + '.back-arrow' + ) as HTMLElement; + backButton.click(); + await element.updateComplete; + assert.equal(element.mode, ChatPanelMode.CONVERSATION); + }); + + test('handles start new conversation', async () => { + const addButton = element.shadowRoot?.querySelector( + '.clear-history-button' + ) as HTMLElement; + addButton.click(); + await element.updateComplete; + + assert.equal(element.mode, ChatPanelMode.CONVERSATION); + assert.equal(chatModel.getState().turns.length, 0); + }); + + test('handles close panel', async () => { + const spy = sinon.spy(); + element.addEventListener('close-chat-panel', spy); + const closeButton = element.shadowRoot?.querySelector( + '.close-button' + ) as HTMLElement; + closeButton.click(); + assert.isTrue(spy.called); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-history.ts b/polygerrit-ui/app/elements/chat-panel/chat-history.ts new file mode 100644 index 0000000..d465f72 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-history.ts
@@ -0,0 +1,120 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '../shared/gr-date-formatter/gr-date-formatter'; +import '../shared/gr-icon/gr-icon'; + +import {css, html, LitElement} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; + +import {Conversation} from '../../api/ai-code-review'; +import {chatModelToken} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {dateToTimestamp} from '../../utils/date-util'; +import {subscribe} from '../lit/subscription-controller'; + +@customElement('chat-history') +export class ChatHistory extends LitElement { + private readonly getChatModel = resolve(this, chatModelToken); + + @state() conversations: readonly Conversation[] = []; + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().conversations$, + conversations => (this.conversations = conversations ?? []) + ); + } + + static override styles = css` + :host { + display: block; + } + .conversation-card { + width: 85%; + margin: 0 auto; + padding: var(--spacing-m); + padding-left: 0px; + display: flex; + flex-direction: row; + border-bottom: 1px solid var(--hairline); + cursor: pointer; + user-select: none; + } + .conversation-card * { + cursor: pointer; + } + .conversation-card:hover { + background-color: var(--background-color-secondary); + } + .conversation-icon { + margin-top: var(--spacing-s); + } + .conversation-content { + margin-left: var(--spacing-xxl); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + } + .conversation-content p { + margin: 0px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + } + .conversation-content p.ts { + margin-top: var(--spacing-m); + color: var(--deemphasized-text-color); + } + `; + + override render() { + if (this.conversations.length === 0) { + return html`<div>No conversations found.</div>`; + } + return html` + ${this.conversations.map( + conversation => html` + <div + class="conversation-card" + @click=${() => this.loadConversation(conversation)} + > + <div class="conversation-icon"> + <gr-icon icon="history"></gr-icon> + </div> + <div class="conversation-content"> + <p>${conversation.title}</p> + <p class="ts"> + <gr-date-formatter + withTooltip + .dateStr=${dateToTimestamp( + new Date(conversation.timestamp_millis) + )} + ></gr-date-formatter> + </p> + </div> + </div> + ` + )} + `; + } + + // visible for testing + loadConversation(conversation: Conversation) { + this.getChatModel().loadConversation(conversation.id); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-history': ChatHistory; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-history_test.ts b/polygerrit-ui/app/elements/chat-panel/chat-history_test.ts new file mode 100644 index 0000000..a9b3025 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-history_test.ts
@@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {GrDateFormatter} from '../../elements/shared/gr-date-formatter/gr-date-formatter'; +import '../../test/common-test-setup'; +import './chat-history'; +import {ChatHistory} from './chat-history'; +import sinon from 'sinon'; +import {assert, fixture, html} from '@open-wc/testing'; +import {Conversation} from '../../api/ai-code-review'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider} from '../../test/test-data-generators'; + +suite('chat-history tests', () => { + let element: ChatHistory; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + element = await fixture(html`<chat-history></chat-history>`); + element.conversations = []; + await element.updateComplete; + }); + + test('renders empty state', async () => { + element.conversations = []; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ '<div>No conversations found.</div>' + ); + }); + + test('renders conversations', async () => { + const date = new Date('2024-01-01T12:00:00Z'); + const conversations: Conversation[] = [ + { + id: '1', + title: 'Test Conversation 1', + timestamp_millis: date.getTime(), + }, + { + id: '2', + title: 'Test Conversation 2', + timestamp_millis: date.getTime(), + }, + ]; + element.conversations = conversations; + await element.updateComplete; + + const cards = element.shadowRoot?.querySelectorAll('.conversation-card'); + assert.equal(cards?.length, 2); + + const firstCard = cards![0]; + const title = firstCard.querySelector( + '.conversation-content p' + )?.textContent; + assert.equal(title, 'Test Conversation 1'); + + const timestamp = firstCard.querySelector( + '.conversation-content p.ts gr-date-formatter' + ) as GrDateFormatter; + assert.isOk(timestamp); + assert.include(timestamp.dateStr, '2024-01-01'); + }); + + test('clicking conversation calls loadConversation', async () => { + const loadConversationStub = sinon.stub(element, 'loadConversation'); + const conversations: Conversation[] = [ + { + id: '1', + title: 'Test Conversation 1', + timestamp_millis: Date.now(), + }, + ]; + element.conversations = conversations; + await element.updateComplete; + + const card = element.shadowRoot?.querySelector( + '.conversation-card' + ) as HTMLElement; + assert.isOk(card); + card.click(); + await element.updateComplete; + + assert.isTrue(loadConversationStub.calledOnce); + assert.isTrue(loadConversationStub.calledWith(conversations[0])); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-panel.ts b/polygerrit-ui/app/elements/chat-panel/chat-panel.ts new file mode 100644 index 0000000..62a8918 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-panel.ts
@@ -0,0 +1,332 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import './chat-header'; +import './chat-history'; +import './gemini-message'; +import './prompt-box'; +import {UserInputChangedEvent} from './prompt-box'; +import './splash-page'; +import './user-message'; + +import {css, html, LitElement} from 'lit'; +import {customElement, query, queryAll, state} from 'lit/decorators.js'; + +import { + chatModelToken, + ChatPanelMode, + Turn, +} from '../../models/chat/chat-model'; +import {changeModelToken} from '../../models/change/change-model'; +import {resolve} from '../../models/dependency'; +import {subscribe} from '../lit/subscription-controller'; + +enum Mode { + HISTORY, + SPLASH_PAGE, + CHAT, +} + +@customElement('chat-panel') +export class ChatPanel extends LitElement { + @query('#scrollableDiv') readonly scrollableDiv?: HTMLElement; + + @queryAll('user-message') private userMessages?: NodeListOf<HTMLElement>; + + @queryAll('gemini-message') private geminiMessages?: NodeListOf<HTMLElement>; + + @state() turns: readonly Turn[] = []; + + @state() conversationId?: string; + + @state() nextTurnIndex = 0; + + @state() chatPanelMode: ChatPanelMode = ChatPanelMode.CONVERSATION; + + @state() userInput = ''; + + @state() lastGeminiMessageMinHeight = 0; + + @state() privacyUrl?: string; + + @state() isChangePrivate = false; + + private readonly getChatModel = resolve(this, chatModelToken); + + private readonly getChangeModel = resolve(this, changeModelToken); + + static override styles = css` + :host { + display: flex; + flex-direction: column; + height: inherit; + background-color: var(--background-color-secondary); + } + .default-option.mat-mdc-outlined-button { + height: auto; + min-height: var(--mat-button-outlined-container-height, 40px); + } + chat-header { + flex: 0 0 auto; + } + .chat-panel-container { + display: flex; + flex-direction: column; + margin: 6px 2px 2px; + /* subtracting 10px for the margin-top and 6px for the border. */ + height: calc(100% - 6px - 10px); + background-color: var(--background-color-primary); + border: 1px solid var(--border-color); + border-radius: 16px; + } + splash-page, + chat-history { + scrollbar-width: thin; + overflow-y: auto; + flex-grow: 1; + min-height: 0; + } + .messages-container { + flex-grow: 1; + overflow: auto; + scrollbar-width: thin; + padding: var(--spacing-xl) var(--spacing-xl); + position: relative; + } + .prompt-section { + margin-top: auto; + padding: 16px var(--spacing-xl) 16px; + border: 1px solid var(--border-color); + border-radius: 16px; + } + .google-symbols { + font-variation-settings: 'FILL' 0, 'ROND' 50, 'wght' 400, 'GRAD' 0, + 'opsz' 24; + } + .default-options-container { + overflow: auto; + display: block; + } + .default-options-container .default-option { + margin-bottom: 8px; + margin-right: 8px; + border-radius: 32px; + height: 36px; + } + .ai-policy { + /* @include typography.text-title(); TODO: check if this is still needed*/ + font-weight: 500; + letter-spacing: 0.1px; + font-size: var(--font-size-small); + color: var(--deemphasized-text-color); + margin: 4px 0 0; + } + .ai-policy a { + color: var(--deemphasized-text-color); + } + gemini-message { + margin-bottom: var(--spacing-xl); + } + gemini-message.latest { + margin-bottom: 0; + } + `; + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().turns$, + x => (this.turns = x ?? []) + ); + subscribe( + this, + () => this.getChatModel().conversationId$, + x => (this.conversationId = x) + ); + subscribe( + this, + () => this.getChatModel().nextTurnIndex$, + x => (this.nextTurnIndex = x) + ); + subscribe( + this, + () => this.getChatModel().mode$, + x => (this.chatPanelMode = x) + ); + subscribe( + this, + () => this.getChatModel().userInput$, + x => (this.userInput = x) + ); + subscribe( + this, + () => this.getChatModel().models$, + x => (this.privacyUrl = x?.privacy_url) + ); + subscribe( + this, + () => this.getChangeModel().change$, + x => (this.isChangePrivate = x?.is_private ?? false) + ); + } + + override render() { + return html` + <div class="chat-panel-container"> + <chat-header></chat-header> + ${this.renderContent()} + </div> + `; + } + + private renderContent() { + switch (this.mode) { + case Mode.HISTORY: + return html`<chat-history></chat-history>`; + case Mode.SPLASH_PAGE: + return html` + <splash-page .isChangePrivate=${this.isChangePrivate}></splash-page> + ${this.renderPromptSection()} + `; + case Mode.CHAT: + return this.renderChatContent(); + } + } + + private renderChatContent() { + return html` + <div id="scrollableDiv" class="messages-container"> + ${this.turns.map( + (turn, index) => html` + <user-message .message=${turn.userMessage}></user-message> + <gemini-message + .turnIndex=${index} + .isLatest=${index === this.turns.length - 1} + class=${index === this.turns.length - 1 ? 'latest' : ''} + style="min-height: ${index === this.turns.length - 1 + ? this.lastGeminiMessageMinHeight + : 0}px" + ></gemini-message> + ` + )} + </div> + ${this.renderPromptSection()} + `; + } + + private renderPromptSection() { + return html` + <div class="prompt-section"> + <prompt-box + .userInput=${this.userInput} + .disabledMessage=${'Review Agent is disabled on private changes'} + .isDisabled=${this.isChangePrivate} + @user-input-change=${(e: UserInputChangedEvent) => + this.onUserInputChange(e)} + ></prompt-box> + ${this.renderPrivacySection()} + </div> + `; + } + + private renderPrivacySection() { + if (!this.privacyUrl) return; + return html` + <div class="ai-policy"> + Review agent may display inaccurate info. + <a href=${this.privacyUrl} target="_blank">AI privacy policy</a> + </div> + `; + } + + get mode() { + if (this.chatPanelMode === ChatPanelMode.HISTORY) { + return Mode.HISTORY; + } + if ( + this.turns.length === 0 || + (this.turns.length === 1 && this.turns[0].userMessage.isBackgroundRequest) + ) { + return Mode.SPLASH_PAGE; + } + return Mode.CHAT; + } + + override updated(changedProperties: Map<string, unknown>) { + if (changedProperties.has('turns') && this.scrollableDiv) { + const scrollableDivElement = this.scrollableDiv; + const lastUserMessageElement = + this.userMessages?.[this.userMessages.length - 1]; + const lastGeminiMessageElement = + this.geminiMessages?.[this.geminiMessages.length - 1]; + if (lastUserMessageElement) { + const scrollTop = computeScrollTop( + scrollableDivElement, + lastUserMessageElement + ); + scrollableDivElement.scrollTop = scrollTop; + } + if (lastUserMessageElement && lastGeminiMessageElement) { + const minHeight = computeGeminiMessageMinHeight( + scrollableDivElement, + lastUserMessageElement, + lastGeminiMessageElement + ); + this.lastGeminiMessageMinHeight = minHeight; + } + } + } + + private onUserInputChange(e: UserInputChangedEvent) { + this.getChatModel().updateUserInput(e.detail.value); + } +} + +function getPaddingBottom(element: HTMLElement) { + return Number(getComputedStyle(element).paddingBottom.replace('px', '')); +} + +function getPaddingTop(element: HTMLElement) { + return Number(getComputedStyle(element).paddingTop.replace('px', '')); +} + +function computeScrollTop( + scrollableDivElement: HTMLElement, + lastUserMessageElement: HTMLElement +) { + const scrollableDivTopPadding = getPaddingTop(scrollableDivElement); + return lastUserMessageElement.offsetTop - scrollableDivTopPadding; +} + +/** + * Computes the minimum height of the last gemini message such that the height + * of the last user message + the height of the last gemini message (plus + * padding) is equal to the height of the scrollable div. + */ +function computeGeminiMessageMinHeight( + scrollableDivElement: HTMLElement, + userMessageElement: HTMLElement, + geminiMessageElement: HTMLElement +) { + const scrollableDivHeight = + scrollableDivElement.offsetHeight - + getPaddingTop(scrollableDivElement) - + getPaddingBottom(scrollableDivElement); + const geminiMessagePaddingTop = getPaddingTop(geminiMessageElement); + const geminiMessagePaddingBottom = getPaddingBottom(geminiMessageElement); + return ( + scrollableDivHeight - + userMessageElement.offsetHeight - + geminiMessagePaddingTop - + geminiMessagePaddingBottom + ); +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-panel': ChatPanel; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-panel_screenshot_test.ts b/polygerrit-ui/app/elements/chat-panel/chat-panel_screenshot_test.ts new file mode 100644 index 0000000..62ba102 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-panel_screenshot_test.ts
@@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import './chat-panel'; +import {ChatPanel} from './chat-panel'; +import { + ChatModel, + chatModelToken, + ChatPanelMode, + ResponsePartType, + Turn, + UserType, +} from '../../models/chat/chat-model'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader'; +import {changeModelToken} from '../../models/change/change-model'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {ParsedChangeInfo} from '../../types/types'; +import {queryAndAssert, visualDiffDarkTheme} from '../../test/test-utils'; +import {PromptBox} from './prompt-box'; +import {ReferencesDropdown} from './references-dropdown'; + +suite('chat-panel screenshot tests', () => { + let element: ChatPanel; + let chatModel: ChatModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + chatModel = testResolver(chatModelToken); + chatModel.updateState({ + ...chatModel.getState(), + draftUserMessage: { + contextItems: [], + content: '', + userType: UserType.USER, + }, + }); + + element = await fixture(html`<chat-panel></chat-panel>`); + await element.updateComplete; + }); + + test('splash page', async () => { + await visualDiff(element, 'chat-panel-splash-page'); + await visualDiffDarkTheme(element, 'chat-panel-splash-page'); + }); + + test('splash page private change', async () => { + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: { + ...createChange(), + is_private: true, + } as ParsedChangeInfo, + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-splash-page-private'); + await visualDiffDarkTheme(element, 'chat-panel-splash-page-private'); + }); + + test('splash page with custom actions', async () => { + chatModel.updateState({ + ...chatModel.getState(), + actions: { + actions: [ + { + id: 'action3', + display_text: 'Standard Action 1', + enable_splash_page_card: true, + }, + ], + default_action_id: 'action3', + }, + customActions: [ + { + id: 'action1', + display_text: 'Custom Action 1', + enable_splash_page_card: true, + }, + { + id: 'action2', + display_text: 'Custom Action 2', + enable_splash_page_card: true, + }, + ], + models: { + default_model_id: 'gemini', + models: [ + { + model_id: 'gemini', + short_text: 'Gemini', + full_display_text: 'Gemini Model', + }, + ], + documentation_url: 'http://example.com/docs', + }, + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-splash-page-custom-actions'); + await visualDiffDarkTheme(element, 'chat-panel-splash-page-custom-actions'); + }); + + test('chat mode', async () => { + chatModel.updateState({ + ...chatModel.getState(), + turns: [ + { + userMessage: { + content: 'hello', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + responseParts: [ + {id: 0, type: ResponsePartType.TEXT, content: 'world'}, + ], + regenerationIndex: 0, + references: [], + citations: [], + userType: UserType.GEMINI, + responseComplete: true, + }, + }, + ] as Turn[], + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-chat-mode'); + await visualDiffDarkTheme(element, 'chat-panel-chat-mode'); + }); + + test('chat mode with comment', async () => { + chatModel.updateState({ + ...chatModel.getState(), + turns: [ + { + userMessage: { + content: 'Fix this issue', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + responseParts: [ + { + id: 0, + type: ResponsePartType.TEXT, + content: 'I have created a comment for you:', + }, + { + id: 1, + type: ResponsePartType.CREATE_COMMENT, + content: '', + commentCreationId: '123', + comment: { + path: 'polygerrit-ui/app/elements/chat-panel/chat-panel.ts', + line: 10, + message: 'Please fix this typo.', + }, + }, + { + id: 2, + type: ResponsePartType.CREATE_COMMENT, + content: '', + commentCreationId: '124', + comment: { + path: 'polygerrit-ui/app/elements/chat-panel/chat-panel.ts', + range: { + start_line: 10, + start_character: 0, + end_line: 12, + end_character: 10, + }, + message: 'Please fix this typo.', + }, + }, + ], + regenerationIndex: 0, + references: [], + citations: [], + userType: UserType.GEMINI, + responseComplete: true, + }, + }, + ] as Turn[], + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-chat-mode-with-comment'); + await visualDiffDarkTheme(element, 'chat-panel-chat-mode-with-comment'); + }); + + test('chat mode with error', async () => { + chatModel.updateState({ + ...chatModel.getState(), + turns: [ + { + userMessage: { + content: 'Do something', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + responseParts: [], + regenerationIndex: 0, + references: [], + citations: [], + userType: UserType.GEMINI, + errorMessage: 'Something went wrong', + }, + }, + ] as Turn[], + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-chat-mode-with-error'); + await visualDiffDarkTheme(element, 'chat-panel-chat-mode-with-error'); + }); + + test('chat mode with references', async () => { + chatModel.updateState({ + ...chatModel.getState(), + turns: [ + { + userMessage: { + content: 'What are the conventions?', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + responseParts: [ + { + id: 0, + type: ResponsePartType.TEXT, + content: 'Here are some references I found:', + }, + ], + regenerationIndex: 0, + references: [ + { + type: 'g3doc', + displayText: 'fe-conventions.md', + externalUrl: + 'https://source.corp.google.com///depot/company/teams/gstore/teams/gCMS/frontend/fe-conventions.md', + }, + { + type: 'yaqs', + displayText: 'YAQS 5734203896627200', + externalUrl: 'https://yaqs.corp.google.com/5734203896627200', + }, + { + type: 'g3doc', + displayText: 'style_guidelines.md', + externalUrl: + 'https://source.corp.google.com///depot/google3/video/youtube/src/web/polymer/music/g3doc/style_guidelines.md', + }, + ], + citations: [], + userType: UserType.GEMINI, + responseComplete: true, + }, + }, + ] as Turn[], + }); + await element.updateComplete; + await element.updateComplete; + const geminiMessage = queryAndAssert(element, 'gemini-message'); + const referencesDropdown = queryAndAssert<ReferencesDropdown>( + geminiMessage, + 'references-dropdown' + ); + const expandButton = queryAndAssert<HTMLButtonElement>( + referencesDropdown, + '.references-dropdown-button' + ); + expandButton.click(); + await element.updateComplete; + await visualDiff(element, 'chat-panel-chat-mode-with-references'); + await visualDiffDarkTheme(element, 'chat-panel-chat-mode-with-references'); + }); + + test('chat mode with citations', async () => { + chatModel.updateState({ + ...chatModel.getState(), + models: { + ...chatModel.getState().models!, + citation_url: 'https://www.google.com', + }, + turns: [ + { + userMessage: { + content: 'What are the conventions?', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + responseParts: [ + { + id: 0, + type: ResponsePartType.TEXT, + content: 'Here are some references I found:', + }, + ], + regenerationIndex: 0, + references: [], + citations: [ + 'http://example.com/citation1', + 'http://example.com/citation2', + ], + userType: UserType.GEMINI, + responseComplete: true, + }, + }, + ] as Turn[], + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-chat-mode-with-citations'); + await visualDiffDarkTheme(element, 'chat-panel-chat-mode-with-citations'); + }); + + test('chat mode history', async () => { + chatModel.updateState({ + ...chatModel.getState(), + mode: ChatPanelMode.HISTORY, + conversations: [ + { + id: '1', + title: 'Test Conversation 1', + timestamp_millis: 1704110400000, // 2024-01-01 12:00:00 UTC + }, + { + id: '2', + title: 'Test Conversation 2', + timestamp_millis: 1704024000000, // 2023-12-31 12:00:00 UTC + }, + ], + }); + await element.updateComplete; + await visualDiff(element, 'chat-panel-history'); + await visualDiffDarkTheme(element, 'chat-panel-history'); + }); + + test('chat mode with suggested context items', async () => { + const promptBox = queryAndAssert<PromptBox>(element, 'prompt-box'); + promptBox.commitMessageContextItems = [ + { + type_id: 'buganizer', + title: 'b/12345', + identifier: '12345', + link: 'http://b/12345', + }, + { + type_id: 'buganizer', + title: 'b/123456789', + identifier: '123456789', + link: 'http://b/123456789', + }, + ]; + await element.updateComplete; + + await visualDiff(element, 'chat-panel-prompt-box-suggested-items'); + await visualDiffDarkTheme(element, 'chat-panel-prompt-box-suggested-items'); + }); + + test('chat mode history scrolling', async () => { + const conversations = Array.from({length: 15}).map((_, i) => { + return { + id: `${i}`, + title: `Long Conversation Title Number ${i}`, + timestamp_millis: 1704110400000 - i * 1000000, + }; + }); + + chatModel.updateState({ + ...chatModel.getState(), + mode: ChatPanelMode.HISTORY, + conversations, + }); + element.style.display = 'block'; + element.style.width = '400px'; + element.style.height = '400px'; + await element.updateComplete; + await visualDiff(element, 'chat-panel-history-scrolling'); + await visualDiffDarkTheme(element, 'chat-panel-history-scrolling'); + }); + + test('chat mode with models menu open', async () => { + chatModel.updateState({ + ...chatModel.getState(), + models: { + default_model_id: 'gemini', + models: [ + { + model_id: 'gemini', + short_text: 'Gemini', + full_display_text: 'Gemini Model', + }, + { + model_id: 'gemini-ultra', + short_text: 'Gemini Ultra', + full_display_text: 'Gemini Ultra Model', + }, + { + model_id: 'gemini-pro', + short_text: 'Gemini Pro', + full_display_text: 'Gemini Pro Model', + }, + ], + documentation_url: 'http://example.com/docs', + }, + turns: [], + }); + element.style.display = 'block'; + element.style.height = '600px'; + await element.updateComplete; + const chatHeader = queryAndAssert(element, 'chat-header'); + const selectModelTrigger = queryAndAssert<HTMLElement>( + chatHeader, + '#selectModelTrigger' + ); + selectModelTrigger.click(); + await element.updateComplete; + // Wait for the menu animation + await new Promise(resolve => setTimeout(resolve, 500)); + await visualDiff(element, 'chat-panel-models-menu-open'); + await visualDiffDarkTheme(element, 'chat-panel-models-menu-open'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts b/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts new file mode 100644 index 0000000..84a2052 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/chat-panel_test.ts
@@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './chat-panel'; +import {ChatPanel} from './chat-panel'; +import { + ChatModel, + chatModelToken, + ChatPanelMode, + Turn, +} from '../../models/chat/chat-model'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../../elements/shared/gr-js-api-interface/gr-plugin-loader'; +import {changeModelToken} from '../../models/change/change-model'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('chat-panel tests', () => { + let element: ChatPanel; + let chatModel: ChatModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + chatModel = testResolver(chatModelToken); + + element = await fixture(html`<chat-panel></chat-panel>`); + await element.updateComplete; + }); + + test('renders', async () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="chat-panel-container"> + <chat-header></chat-header> + <splash-page></splash-page> + <div class="prompt-section"> + <prompt-box></prompt-box> + <div class="ai-policy"> + Review agent may display inaccurate info. + <a href="http://privacy.url" target="_blank"> + AI privacy policy + </a> + </div> + </div> + </div> + ` + ); + }); + + test('renders history mode', async () => { + chatModel.setMode(ChatPanelMode.HISTORY); + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="chat-panel-container"> + <chat-header></chat-header> + <chat-history></chat-history> + </div> + ` + ); + }); + + test('renders chat mode', async () => { + chatModel.updateState({ + ...chatModel.getState(), + turns: [ + { + userMessage: { + content: 'hello', + userType: 0, // UserType.USER + contextItems: [], + }, + geminiMessage: { + responseParts: [], + regenerationIndex: 0, + references: [], + citations: [], + userType: 1, // UserType.GEMINI + }, + }, + ] as Turn[], + }); + + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="chat-panel-container"> + <chat-header></chat-header> + <div class="messages-container" id="scrollableDiv"> + <user-message></user-message> + <gemini-message + class="latest" + style="min-height: 0px" + ></gemini-message> + </div> + <div class="prompt-section"> + <prompt-box></prompt-box> + <div class="ai-policy"> + Review agent may display inaccurate info. + <a href="http://privacy.url" target="_blank"> + AI privacy policy + </a> + </div> + </div> + </div> + ` + ); + }); + + test('renders privacy policy if url is present', async () => { + const policy = element.shadowRoot!.querySelector('.ai-policy'); + assert.isOk(policy); + assert.include( + policy.textContent!, + 'Review agent may display inaccurate info' + ); + const link = policy.querySelector('a'); + assert.isOk(link); + assert.equal(link.getAttribute('href'), 'http://privacy.url'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/citations-box.ts b/polygerrit-ui/app/elements/chat-panel/citations-box.ts new file mode 100644 index 0000000..0ee69c3 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/citations-box.ts
@@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; + +import {chatModelToken, Turn} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {subscribe} from '../lit/subscription-controller'; + +@customElement('citations-box') +export class CitationsBox extends LitElement { + private readonly getChatModel = resolve(this, chatModelToken); + + @property({type: Number}) turnIndex = 0; + + @state() turns: readonly Turn[] = []; + + @state() citation_url?: string; + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().turns$, + turns => (this.turns = turns ?? []) + ); + subscribe( + this, + () => this.getChatModel().models$, + models => (this.citation_url = models?.citation_url) + ); + } + + static override styles = [ + css` + :host { + .citations-display-box { + display: block; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl) + var(--spacing-xl); + margin-top: var(--spacing-xl); + margin-bottom: var(--spacing-xl); + background-color: var(--background-color-tertiary); + } + .citations-summary-message { + font-size: var(--font-size-small); + line-height: var(--line-height-small); + font-weight: var(--font-weight-medium); + letter-spacing: 0; + color: var(--primary-text-color); + margin-bottom: var(--spacing-m); + } + .citation-entry-list { + list-style-type: disc; + padding-left: 20px; + margin-top: 0; + margin-bottom: 0; + } + li { + font-size: var(--font-size-small); + line-height: var(--line-height-small); + font-weight: var(--font-weight-normal); + letter-spacing: 0; + color: var(--deemphasized-text-color); + overflow-wrap: break-word; /* Prevent long unbreakable strings from overflowing */ + margin-bottom: var(--spacing-xs); + } + li:last-child { + margin-bottom: 0; + } + a { + color: var(--link-color); + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + } + `, + ]; + + override render() { + if (!this.citation_url) return; + const citations = + this.turns[this.turnIndex]?.geminiMessage?.citations ?? []; + + if (citations.length === 0) return; + const count = citations.length; + + return html` + <div class="citations-display-box"> + <p class="citations-summary-message"> + Use + <a + href=${this.citation_url} + target="_blank" + rel="noopener noreferrer" + > + with caution</a + > + . The model answer includes ${count} citation${count > 1 ? 's' : ''} + from other sources: + </p> + <ul class="citation-entry-list"> + ${citations.map( + citationUrl => html` + <li class="citation-item"> + <a + .href=${citationUrl} + target="_blank" + rel="noopener noreferrer" + >${citationUrl}</a + > + </li> + ` + )} + </ul> + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'citations-box': CitationsBox; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts b/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts new file mode 100644 index 0000000..09b1192 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/citations-box_test.ts
@@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import { + ChatModel, + chatModelToken, + GeminiMessage, + Turn, + UserType, +} from '../../models/chat/chat-model'; +import './citations-box'; +import {CitationsBox} from './citations-box'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {changeModelToken} from '../../models/change/change-model'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('citations-box tests', () => { + let element: CitationsBox; + let chatModel: ChatModel; + + function createTurn(citations: string[]): Turn { + return { + userMessage: { + userType: UserType.USER, + content: 'test', + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex: 0, + references: [], + citations, + } as GeminiMessage, + }; + } + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + element = await fixture(html`<citations-box></citations-box>`); + chatModel = testResolver(chatModelToken); + await element.updateComplete; + }); + + test('renders nothing when no citations', async () => { + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn([])], + }); + await element.updateComplete; + + assert.shadowDom.equal(element, ''); + }); + + test('renders nothing when no citation_url', async () => { + chatModel.updateState({ + ...chatModel.getState(), + models: undefined, + turns: [createTurn(['http://example.com/1'])], + }); + await element.updateComplete; + + assert.shadowDom.equal(element, ''); + }); + + test('renders with one citation', async () => { + const citations = ['http://example.com/1']; + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn(citations)], + }); + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* prettier-ignore */ /* HTML */ ` + <div class="citations-display-box"> + <p class="citations-summary-message"> + Use + <a + href="http://citation.url" + target="_blank" + rel="noopener noreferrer" + > + with caution</a + > + . The model answer includes 1 citation + from other sources: + </p> + <ul class="citation-entry-list"> + <li class="citation-item"> + <a + href="http://example.com/1" + target="_blank" + rel="noopener noreferrer" + >http://example.com/1</a + > + </li> + </ul> + </div> + ` + ); + }); + + test('renders with multiple citations', async () => { + const citations = ['http://example.com/1', 'http://example.com/2']; + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn(citations)], + }); + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* prettier-ignore */ /* HTML */ ` + <div class="citations-display-box"> + <p class="citations-summary-message"> + Use + <a + href="http://citation.url" + target="_blank" + rel="noopener noreferrer" + > + with caution</a + > + . The model answer includes 2 citations + from other sources: + </p> + <ul class="citation-entry-list"> + <li class="citation-item"> + <a + href="http://example.com/1" + target="_blank" + rel="noopener noreferrer" + >http://example.com/1</a + > + </li> + <li class="citation-item"> + <a + href="http://example.com/2" + target="_blank" + rel="noopener noreferrer" + >http://example.com/2</a + > + </li> + </ul> + </div> + ` + ); + }); + + test('renders citations for the correct turnIndex', async () => { + const turn0 = createTurn(['http://example.com/0']); + const turn1 = createTurn(['http://example.com/1', 'http://example.com/2']); + chatModel.updateState({ + ...chatModel.getState(), + turns: [turn0, turn1], + }); + element.turnIndex = 1; + await element.updateComplete; + + const summary = element.shadowRoot?.querySelector( + '.citations-summary-message' + ); + assert.isOk(summary); + assert.include(summary.textContent!, '2 citations'); + + const items = element.shadowRoot?.querySelectorAll('.citation-item'); + assert.isOk(items); + assert.equal(items.length, 2); + assert.equal(items[0].querySelector('a')?.href, 'http://example.com/1'); + assert.equal(items[1].querySelector('a')?.href, 'http://example.com/2'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/context-chip.ts b/polygerrit-ui/app/elements/chat-panel/context-chip.ts new file mode 100644 index 0000000..1917fd2 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/context-chip.ts
@@ -0,0 +1,205 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/chips/filter-chip.js'; +import '@material/web/icon/icon.js'; +import '../shared/gr-icon/gr-icon'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; + +import {truncatePath} from '../../utils/path-list-util'; +import {ContextItem} from '../../api/ai-code-review'; +import {chatModelToken} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {fire} from '../../utils/event-util'; +import {subscribe} from '../lit/subscription-controller'; +import {classMap} from 'lit/directives/class-map.js'; +import {materialStyles} from '../../styles/gr-material-styles'; + +@customElement('context-chip') +export class ContextChip extends LitElement { + private readonly getChatModel = resolve(this, chatModelToken); + + @property({type: String}) text = ''; + + @property({type: Object}) contextItem?: ContextItem; + + @property({type: String}) subText?: string; + + @property({type: Boolean}) isSuggestion = false; + + @property({type: Boolean}) isCustomAction = false; + + @property({type: String}) tooltip?: string; + + @property({type: Boolean}) isRemovable = true; + + @state() private supportsThisChange = true; + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().provider$, + provider => { + this.supportsThisChange = provider?.supports_this_change ?? true; + } + ); + } + + static override styles = [ + materialStyles, + css` + :host { + overflow: hidden; + max-width: 300px; + } + md-filter-chip.suggested-chip { + opacity: 0.5; + border-style: dashed; + border-width: 1px; + border-color: var(--border-color); + --md-filter-chip-outline-color: transparent; + } + md-filter-chip.suggested-chip:hover { + opacity: 0.7; + } + md-filter-chip.custom-action-chip { + --md-sys-color-primary: var(--custom-action-context-chip-color); + --md-filter-chip-selected-container-color: transparent; + } + md-filter-chip { + --md-sys-color-primary: var(--primary-text-color); + --md-filter-chip-label-text-color: var(--primary-text-color); + --md-filter-chip-container-height: 20px; + --md-filter-chip-label-text-size: var(--font-size-small); + --md-filter-chip-label-text-weight: var(--font-weight-medium); + --md-filter-chip-unselected-container-color: transparent; + --md-filter-chip-outline-color: var(--border-color); + --md-filter-chip-hover-label-text-color: var(--primary-text-color); + --md-filter-chip-focus-label-text-color: var(--primary-text-color); + --md-filter-chip-pressed-label-text-color: var(--primary-text-color); + overflow: hidden; + margin: 0; + border-radius: 8px; + } + md-filter-chip.no-link { + --md-filter-chip-unselected-hover-state-layer-color: transparent; + --md-ripple-hover-color: transparent; + --md-ripple-pressed-color: transparent; + --md-ripple-focus-color: transparent; + } + + .context-chip-icon-base { + width: 12px; + height: 12px; + } + .custom-action-icon { + color: var(--custom-action-context-chip-color); + } + .external-context-text, + .custom-action-text { + margin-left: var(--spacing-s); + } + .custom-action-text { + color: var(--custom-action-context-chip-color); + } + .subtext { + color: var(--deemphasized-text-color); + } + .hidden { + visibility: hidden; + pointer-events: none; + } + .context-chip-container { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + } + .context-chip-title { + padding-top: 2px; + } + `, + ]; + + override render() { + const type = this.getChatModel().contextItemToType(this.contextItem); + const icon = type?.icon ?? ''; + return html` + <md-filter-chip + class=${classMap({ + 'context-chip': true, + 'suggested-chip': this.isSuggestion, + 'custom-action-chip': this.isCustomAction, + 'no-link': !this.contextItem?.link, + hidden: !this.supportsThisChange, + })} + .title=${this.contextItem?.tooltip ?? + this.tooltip ?? + this.contextItem?.title ?? + this.text} + @click=${this.handleChipClick} + ?removable=${this.isRemovable && !this.isSuggestion} + @remove=${this.onRemoveContextChip} + > + ${when( + icon, + () => html` <gr-icon + slot="icon" + class=${this.isCustomAction ? 'custom-action-icon' : ''} + .icon=${icon} + ></gr-icon>` + )} + <div class="context-chip-container"> + <span class="context-chip-title"> + ${truncatePath(this.contextItem?.title ?? this.text, 2)} + ${when( + this.subText, + () => html`<span class="subtext">: ${this.subText}</span>` + )} + </span> + ${when( + this.isSuggestion, + () => html` <gr-icon icon="add"></gr-icon> ` + )} + </div> + </md-filter-chip> + `; + } + + private onRemoveContextChip() { + fire(this, 'remove-context-chip', {}); + } + + protected handleChipClick(e: MouseEvent) { + // Always prevent the default filter chip behavior (selection/checkmark). + e.preventDefault(); + e.stopPropagation(); + + if (this.isSuggestion) { + fire(this, 'accept-context-item-suggestion', {}); + return; + } + + const link = this.contextItem?.link?.trim(); + if (link) { + const url = link.startsWith('http') ? link : `http://${link}`; + window.open(url, '_blank'); + } + } +} + +declare global { + interface HTMLElementEventMap { + 'remove-context-chip': CustomEvent<{}>; + 'accept-context-item-suggestion': CustomEvent<{}>; + } + interface HTMLElementTagNameMap { + 'context-chip': ContextChip; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/context-chip_test.ts b/polygerrit-ui/app/elements/chat-panel/context-chip_test.ts new file mode 100644 index 0000000..39a9f56 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/context-chip_test.ts
@@ -0,0 +1,216 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import {ContextItem} from '../../api/ai-code-review'; +import sinon from 'sinon'; +import './context-chip'; +import {ContextChip} from './context-chip'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; +import {MdFilterChip} from '@material/web/chips/filter-chip'; + +suite('context-chip tests', () => { + let element: ContextChip; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + element = await fixture(html`<context-chip></context-chip>`); + }); + + test('renders with default properties', () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <md-filter-chip class="context-chip no-link" removable="" title=""> + <div class="context-chip-container"> + <span class="context-chip-title"> </span> + </div> + </md-filter-chip> + ` + ); + }); + + test('renders with text', async () => { + element.text = 'test text'; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.equal(chip?.textContent?.trim(), 'test text'); + }); + + test('renders with subtext', async () => { + element.subText = 'sub text'; + await element.updateComplete; + const subtext = element.shadowRoot?.querySelector('.subtext'); + assert.isOk(subtext); + assert.dom.equal(subtext, '<span class="subtext">: sub text</span>'); + }); + + test('renders as suggestion', async () => { + element.isSuggestion = true; + await element.updateComplete; + const icon = element.shadowRoot?.querySelector('gr-icon'); + assert.isOk(icon); + assert.equal(icon.getAttribute('icon'), 'add'); + }); + + test('renders as custom action', async () => { + element.isCustomAction = true; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.isTrue(chip?.classList.contains('custom-action-chip')); + }); + + test('renders with tooltip', async () => { + element.tooltip = 'test tooltip'; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.equal(chip?.title, 'test tooltip'); + }); + + test('renders with gerrit change icon', async () => { + const contextItem: ContextItem = { + type_id: 'gerrit_change', + title: 'This Change', + link: '', + tooltip: 'File diffs (against base), commit message, and comments.', + }; + element.contextItem = contextItem; + await element.updateComplete; + + // Should use gr-icon element with commit + const icon = element.shadowRoot?.querySelector('gr-icon'); + + assert.isOk(icon, 'Expected gr-icon to be rendered'); + assert.equal(icon.getAttribute('icon'), 'commit'); + }); + + test('is removable', async () => { + element.isRemovable = true; + element.isSuggestion = false; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.isTrue(chip?.removable); + }); + + test('is not removable when suggestion', async () => { + element.isRemovable = true; + element.isSuggestion = true; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.isFalse(chip?.removable); + }); + + test('fires remove-context-chip event', async () => { + const spy = sinon.spy(); + element.addEventListener('remove-context-chip', spy); + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + chip?.dispatchEvent(new Event('remove')); + assert.isTrue(spy.called); + }); + + test('fires accept-context-item-suggestion event on chip click', async () => { + element.isSuggestion = true; + await element.updateComplete; + const spy = sinon.spy(); + element.addEventListener('accept-context-item-suggestion', spy); + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + chip?.click(); + assert.isTrue(spy.called); + }); + + test('navigates to url', async () => { + const openSpy = sinon.spy(window, 'open'); + const contextItem: ContextItem = { + type_id: 'file', + title: 'test.ts', + link: ' gerrit.test/test.ts ', + }; + element.contextItem = contextItem; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + chip?.click(); + assert.isTrue(openSpy.calledWith('http://gerrit.test/test.ts', '_blank')); + }); + + test('has no-link class when no link', async () => { + const contextItem: ContextItem = { + type_id: 'file', + title: 'test.ts', + link: '', + }; + element.contextItem = contextItem; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.isTrue(chip?.classList.contains('no-link')); + }); + + test('does not have no-link class when has link', async () => { + const contextItem: ContextItem = { + type_id: 'file', + title: 'test.ts', + link: ' http://gerrit.test/test.ts ', + }; + element.contextItem = contextItem; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + assert.isFalse(chip?.classList.contains('no-link')); + }); + + test('does not navigate on click when no link', async () => { + const openSpy = sinon.spy(window, 'open'); + const contextItem: ContextItem = { + type_id: 'file', + title: 'test.ts', + link: '', + }; + element.contextItem = contextItem; + await element.updateComplete; + const chip = element.shadowRoot?.querySelector('md-filter-chip'); + chip?.click(); + assert.isFalse(openSpy.called); + }); + + test('shortens long context item titles', async () => { + const contextItem: ContextItem = { + type_id: 'file', + title: 'very/long/path/to/some/deeply/nested/file.ts', + link: '...', + }; + element.contextItem = contextItem; + await element.updateComplete; + const chip = + element.shadowRoot?.querySelector<MdFilterChip>('md-filter-chip'); + assert.equal(chip?.textContent?.trim(), '\u2026/nested/file.ts'); + assert.equal(chip?.title, 'very/long/path/to/some/deeply/nested/file.ts'); + }); + + test('does not shorten short context item titles', async () => { + const contextItem: ContextItem = { + type_id: 'file', + title: 'short/file.ts', + link: '...', + }; + element.contextItem = contextItem; + await element.updateComplete; + const chip = + element.shadowRoot?.querySelector<MdFilterChip>('md-filter-chip'); + assert.equal(chip?.textContent?.trim(), 'short/file.ts'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/context-input-chip.ts b/polygerrit-ui/app/elements/chat-panel/context-input-chip.ts new file mode 100644 index 0000000..eec7918 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/context-input-chip.ts
@@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/chips/assist-chip.js'; +import '@material/web/icon/icon.js'; +import '@material/web/menu/menu.js'; +import '@material/web/menu/menu-item.js'; +import '@material/web/textfield/filled-text-field.js'; + +import {MdMenu} from '@material/web/menu/menu'; +import {css, html, LitElement} from 'lit'; +import {customElement, query, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; + +import {ContextItem, ContextItemType} from '../../api/ai-code-review'; +import {chatModelToken} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {assertIsDefined} from '../../utils/common-util'; +import {fire, fireAlert} from '../../utils/event-util'; +import {subscribe} from '../lit/subscription-controller'; +import {classMap} from 'lit/directives/class-map.js'; +import {materialStyles} from '../../styles/gr-material-styles'; + +@customElement('context-input-chip') +export class ContextInputChip extends LitElement { + @query('#contextMenu') private contextMenu?: MdMenu; + + @query('.add-link-input') private addLinkInput?: HTMLInputElement; + + @state() linkInputText = ''; + + @state() selectedContextMenuItem: ContextItemType | null = null; + + @state() addLinkDialogOpened = false; + + @state() contextMenuItems: readonly ContextItemType[] = []; + + @state() private supportsAddContext = true; + + private readonly getChatModel = resolve(this, chatModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().contextItemTypes$, + (contextItemTypes: readonly ContextItemType[]) => { + this.contextMenuItems = contextItemTypes; + } + ); + subscribe( + this, + () => this.getChatModel().provider$, + provider => { + this.supportsAddContext = provider?.supports_add_context ?? true; + } + ); + } + + static override styles = [ + materialStyles, + css` + .context-input-container { + position: relative; + } + /* .mat-mdc-standard-chip replaced by md-assist-chip */ + md-assist-chip { + --md-assist-chip-container-height: 22px; + --md-assist-chip-label-text-size: var(--font-size-small); + --md-assist-chip-label-text-weight: var(--font-weight-medium); + --md-assist-chip-label-text-color: var(--primary-text-color); + --md-assist-chip-outline-color: var(--border-color); + --md-assist-chip-hover-label-text-color: var(--primary-text-color); + --md-assist-chip-focus-label-text-color: var(--primary-text-color); + --md-assist-chip-pressed-label-text-color: var(--primary-text-color); + --md-assist-chip-hover-icon-color: var(--primary-text-color); + --md-assist-chip-focus-icon-color: var(--primary-text-color); + --md-assist-chip-pressed-icon-color: var(--primary-text-color); + overflow: hidden; + margin: 0; + border-color: var(--border-color); + background-color: transparent; + border-radius: 8px; + } + .add-icon { + color: var(--primary-text-color); + } + .add-link-container { + position: absolute; + text-align: center; + width: 200px; + left: 0; + bottom: 25px; + } + .add-link-input { + padding: var(--spacing-m); + margin-left: var(--spacing-m); + margin-right: var(--spacing-m); + border: 1px solid var(--border-color); + border-radius: 10px; + background-color: var(--background-color-primary); + font-family: var(--font-family); + font-size: var(--font-size-normal); + /* input color often defaults to browser/os default which might be dark even on dark mode if not set */ + color: var(--primary-text-color); + outline: none; + width: 100%; + height: 23px; + max-width: 100%; + } + .add-link-input::placeholder { + color: var(--chat-card-placeholder-text-color); + } + .add-link-input:focus { + background-color: var(--background-color-primary); + border: 1px solid var(--border-color); + } + .context-menu-icon { + width: 14px; + height: 14px; + margin-left: var(--spacing-m); + } + .hidden { + visibility: hidden; + pointer-events: none; + } + md-menu-item { + white-space: nowrap; + --md-menu-item-top-space: var(--spacing-s); + --md-menu-item-bottom-space: var(--spacing-s); + --md-menu-item-leading-space: var(--spacing-m); + --md-menu-item-trailing-space: var(--spacing-m); + --md-menu-item-one-line-container-height: 24px; + } + `, + ]; + + override render() { + return html` + <div class="context-input-container"> + <md-assist-chip + id="addContextChip" + class=${classMap({ + hidden: !this.supportsAddContext, + })} + .label=${'Add Context'} + title="Add context to your query" + aria-label="Add context to your query" + @click=${() => this.contextMenu && (this.contextMenu.open = true)} + > + <md-icon slot="icon" class="add-icon">add</md-icon> + </md-assist-chip> + <md-menu id="contextMenu" anchor="addContextChip" y-offset="4"> + ${this.contextMenuItems.map( + (item: ContextItemType) => html` + <md-menu-item @click=${() => this.showLinkDialogInput(item)}> + <md-icon slot="start">${item.icon}</md-icon> + <div slot="headline">${item.name}</div> + </md-menu-item> + ` + )} + </md-menu> + ${when( + this.addLinkDialogOpened, + () => html` + <div class="add-link-container"> + <input + class="add-link-input" + name="search" + role="searchbox" + tabindex="0" + autocomplete="off" + spellcheck="false" + .placeholder=${this.selectedContextMenuItem!.placeholder} + aria-label="Add external link" + .value=${this.linkInputText} + @input=${(e: Event) => + (this.linkInputText = (e.target as HTMLInputElement).value)} + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter') this.addLinkContext(); + if (e.key === 'Escape') this.closeMenu(); + }} + @blur=${() => this.closeMenu()} + /> + </div> + ` + )} + </div> + `; + } + + protected async showLinkDialogInput(contextMenuItem: ContextItemType) { + this.addLinkDialogOpened = true; + this.selectedContextMenuItem = contextMenuItem; + await this.updateComplete; + this.addLinkInput?.focus(); + } + + protected addLinkContext() { + assertIsDefined(this.selectedContextMenuItem, 'selected context menu item'); + const contextItem = this.selectedContextMenuItem.parse(this.linkInputText); + if (contextItem) { + fire(this, 'context-item-added', contextItem); + } else { + fireAlert(this, 'Could not parse the provided link.'); + } + this.closeMenu(); + this.linkInputText = ''; + } + + private closeMenu() { + this.addLinkDialogOpened = false; + if (this.contextMenu) this.contextMenu.open = false; + } +} + +export interface ContextItemAddedEvent extends CustomEvent<ContextItem> { + type: 'context-item-added'; +} + +declare global { + interface HTMLElementEventMap { + 'context-item-added': ContextItemAddedEvent; + } + interface HTMLElementTagNameMap { + 'context-input-chip': ContextInputChip; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/context-input-chip_test.ts b/polygerrit-ui/app/elements/chat-panel/context-input-chip_test.ts new file mode 100644 index 0000000..d3b7b37 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/context-input-chip_test.ts
@@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import './context-input-chip'; +import {assert, fixture, html} from '@open-wc/testing'; +import {ContextInputChip} from './context-input-chip'; +import sinon from 'sinon'; +import {ContextItem} from '../../api/ai-code-review'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('context-input-chip tests', () => { + let element: ContextInputChip; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + element = await fixture(html`<context-input-chip></context-input-chip>`); + await element.updateComplete; + }); + + async function openLinkDialogAndGetInput(): Promise<HTMLInputElement> { + const menuItem = element.shadowRoot?.querySelector('md-menu-item'); + menuItem?.click(); + await element.updateComplete; + assert.isTrue(element.addLinkDialogOpened); + return element.shadowRoot?.querySelector( + '.add-link-input' + ) as HTMLInputElement; + } + + test('renders the add context chip', () => { + const chip = element.shadowRoot?.querySelector('md-assist-chip'); + assert.isOk(chip); + assert.equal(chip?.label, 'Add Context'); + }); + + test('opens the menu when the chip is clicked', async () => { + const chip = element.shadowRoot?.querySelector('md-assist-chip'); + const menu = element.shadowRoot?.querySelector('md-menu'); + assert.isFalse(menu?.open); + chip?.click(); + await element.updateComplete; + assert.isTrue(menu?.open); + }); + + test('shows link dialog when menu item is clicked', async () => { + const input = await openLinkDialogAndGetInput(); + assert.isOk(input); + assert.equal(element.shadowRoot?.activeElement, input); + }); + + test('fires context-item-added event on enter', async () => { + const spy = sinon.spy(); + element.addEventListener('context-item-added', spy); + + const input = await openLinkDialogAndGetInput(); + const link = 'http://www.google.com'; + input.value = link; + input.dispatchEvent(new Event('input')); + await element.updateComplete; + + input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); + await element.updateComplete; + + assert.isTrue(spy.called); + const event = spy.args[0][0] as CustomEvent<ContextItem>; + assert.deepEqual(event.detail, { + type_id: 'google', + identifier: 'google-id', + link, + title: 'google-title', + }); + }); + + test('dismisses input on blur', async () => { + const input = await openLinkDialogAndGetInput(); + input.dispatchEvent(new Event('blur')); + await element.updateComplete; + assert.isFalse(element.addLinkDialogOpened); + }); + + test('dismisses input on Escape', async () => { + const input = await openLinkDialogAndGetInput(); + input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'})); + await element.updateComplete; + assert.isFalse(element.addLinkDialogOpened); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message.ts new file mode 100644 index 0000000..753c6d6 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/gemini-message.ts
@@ -0,0 +1,484 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/progress/circular-progress.js'; +import '@material/web/button/filled-button.js'; +import '@material/web/button/text-button.js'; +import '../shared/gr-icon/gr-icon'; +import '../shared/gr-button/gr-button'; +import '../shared/gr-formatted-text/gr-formatted-text'; +import './citations-box'; +import './references-dropdown'; +import './message-actions'; + +import {css, html, LitElement, PropertyValues} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; + +import {AiAgentEventDetails, Interaction} from '../../constants/reporting'; +import {changeModelToken} from '../../models/change/change-model'; +import { + filesModelToken, + NormalizedFileInfo, +} from '../../models/change/files-model'; +import { + chatModelToken, + CreateCommentPart, + GeminiMessage as GeminiMessageModel, + ResponsePartType, + Turn, +} from '../../models/chat/chat-model'; +import {commentsModelToken} from '../../models/comments/comments-model'; +import {resolve} from '../../models/dependency'; +import {getAppContext} from '../../services/app-context'; +import {NumericChangeId, PatchSetNumber} from '../../types/common'; +import { + compareComments, + computeDisplayLine, + createNew, +} from '../../utils/comment-util'; +import {assert} from '../../utils/common-util'; +import {fire} from '../../utils/event-util'; +import {subscribe} from '../lit/subscription-controller'; +import {materialStyles} from '../../styles/gr-material-styles'; + +@customElement('gemini-message') +export class GeminiMessage extends LitElement { + @property({type: Number}) turnIndex = 0; + + @property({type: Boolean}) isLatest = false; + + /** + * A background request is a request that is not part of an active ongoing + * chat conversation, but just kicked off from the splash page. + */ + @property({type: Boolean}) isBackgroundRequest = false; + + @state() turns: readonly Turn[] = []; + + @state() fileEntities: {[path: string]: NormalizedFileInfo} = {}; + + @state() currentClNumber?: NumericChangeId; + + @state() showErrorDetails = false; + + @state() latestPatchNum?: PatchSetNumber; + + @state() private conversationId?: string; + + private reportedSuggestionsShown = false; + + private readonly getChatModel = resolve(this, chatModelToken); + + private readonly getCommentsModel = resolve(this, commentsModelToken); + + private readonly getChangeModel = resolve(this, changeModelToken); + + private readonly getFilesModel = resolve(this, filesModelToken); + + private readonly reportingService = getAppContext().reportingService; + + static override styles = [ + materialStyles, + css` + :host { + display: block; + padding-top: var(--spacing-s); + padding-bottom: var(--spacing-s); + } + .material-icon { + vertical-align: middle; + } + .suggested-comment { + padding: 10px; + background-color: var(--background-color-tertiary); + border: 1px solid var(--border-color); + border-radius: 5px; + margin-bottom: 10px; + overflow-x: auto; + scrollbar-width: thin; + } + .thinking-indicator { + display: flex; + align-items: center; + } + .gemini-icon { + color: var(--link-color); + } + .thinking-spinner { + --md-circular-progress-size: 24px; + margin-left: 10px; + } + .server-error { + display: flex; + align-items: center; + gap: var(--spacing-s); + font-weight: 500; + color: var(--error-foreground); + margin-bottom: var(--spacing-s); + } + .error-icon { + color: var(--error-foreground); + } + .error-message { + margin-bottom: var(--spacing-m); + color: var(--deemphasized-text-color); + } + .error-details { + margin-top: var(--spacing-s); + margin-bottom: var(--spacing-s); + font-family: var(--monospace-font-family); + font-size: var(--font-size-small); + white-space: pre-wrap; + background-color: var(--background-color-tertiary); + padding: var(--spacing-s); + border-radius: var(--border-radius); + } + .error-actions { + display: flex; + gap: var(--spacing-m); + margin-top: var(--spacing-s); + } + .user-info { + margin-bottom: var(--spacing-m); + } + .text-response { + margin-top: var(--spacing-s); + margin-bottom: var(--spacing-xl); + } + references-dropdown { + margin-bottom: var(--spacing-l); + } + .text-content { + overflow-x: auto; + scrollbar-width: thin; + } + .comment-path, + .comment-line { + display: flex; + align-items: center; + gap: var(--spacing-s); + margin-bottom: var(--spacing-xs); + color: var(--link-color); + text-decoration: none; + } + .comment-path gr-icon, + .comment-line gr-icon { + font-size: 16px; + } + .link-button { + display: flex; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + color: var(--link-color); + padding: 0; + margin: 0; + border: none; + background: none; + cursor: pointer; + text-decoration: none; + text-align: left; + } + .link-button:hover { + text-decoration: underline; + } + .suggested-comment-message { + margin-top: var(--spacing-s); + margin-bottom: var(--spacing-m); + } + `, + ]; + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().turns$, + x => (this.turns = x ?? []) + ); + subscribe( + this, + () => this.getFilesModel().files$, + x => { + const fileEntities: {[path: string]: NormalizedFileInfo} = {}; + for (const file of x) { + fileEntities[file.__path] = file; + } + this.fileEntities = fileEntities; + } + ); + subscribe( + this, + () => this.getChangeModel().changeNum$, + x => (this.currentClNumber = x) + ); + subscribe( + this, + () => this.getChangeModel().latestPatchNum$, + x => (this.latestPatchNum = x) + ); + subscribe( + this, + () => this.getChatModel().conversationId$, + x => (this.conversationId = x) + ); + } + + private async onAddAsComment(part: CreateCommentPart) { + const draft = { + ...part.comment, + ...createNew(part.comment.message, true), + }; + if (!draft.patch_set) { + draft.patch_set = this.latestPatchNum; + } + // TODO(milutin): Remove this once Gemini or backend fixes the issue. + if (draft.range && draft.range.end_line < draft.range.start_line) { + draft.range.end_line = draft.range.start_line; + } + await this.getCommentsModel().saveDraft(draft); + this.getCommentsModel().reloadAllComments(); + this.reportSuggestionToComment(); + } + + private onRetry() { + this.getChatModel().regenerateMessage(this.turnId()); + } + + private toggleShowErrorDetails() { + this.showErrorDetails = !this.showErrorDetails; + } + + private handleFileClick(path: string, lineNum?: number) { + fire(this, 'open-diff-in-change-view', {path, lineNum}); + } + + override updated(changedProperties: PropertyValues) { + if (changedProperties.has('turns') && !this.reportedSuggestionsShown) { + if ( + this.turnIndex < this.turns.length && + this.message()?.responseComplete + ) { + this.reportSuggestionsShown(); + } + } + } + + override render() { + if (this.turnIndex >= this.turns.length) return; + const message = this.message(); + if (!message) return; + const responseParts = message.responseParts; + const textParts = responseParts.filter( + part => part.type === ResponsePartType.TEXT + ); + + return html` + ${when( + !this.isBackgroundRequest, + () => html` + <div class="user-info"> + <gr-icon + class="gemini-icon" + icon="ai" + .title=${message.timestamp + ? new Date(message.timestamp).toLocaleString() + : ''} + ></gr-icon> + </div> + ` + )} + ${when( + message.errorMessage, + () => html` + <div class="server-error text-content"> + <gr-icon icon="error" class="error-icon"></gr-icon> + Server error + </div> + <div class="error-message"> + We were unable to fulfill your request. + ${when( + this.showErrorDetails, + () => html`<p class="error-details">${message.errorMessage}</p>` + )} + <div class="error-actions"> + <gr-button @click=${() => this.onRetry()} link>Retry</gr-button> + <gr-button @click=${() => this.toggleShowErrorDetails()} link> + ${this.showErrorDetails ? 'Hide details' : 'Show details'} + </gr-button> + </div> + </div> + ` + )} + ${when(!message.errorMessage && responseParts.length === 0, () => + when( + message.responseComplete, + () => html`<p class="text-content"> + The server did not return any response. + </p>`, + () => html`<div class="thinking-indicator"> + <p class="text-content">Thinking ...</p> + ${when( + !this.isBackgroundRequest, + () => html` + <md-circular-progress + class="thinking-spinner" + indeterminate + size="small" + ></md-circular-progress> + ` + )} + </div>` + ) + )} + ${when( + !message.errorMessage && responseParts.length > 0, + () => html` + ${textParts.map( + responsePart => html` + <p class="text-content text-response"> + <gr-formatted-text + .markdown=${true} + .content=${responsePart.content} + ></gr-formatted-text> + </p> + ` + )} + ${when(!this.isBackgroundRequest, () => + this.sortedComments().map(comment => { + const displayLine = computeDisplayLine(comment.comment); + const lineNum = + typeof displayLine === 'string' && displayLine.startsWith('#') + ? Number(displayLine.substring(1)) + : typeof displayLine === 'number' + ? displayLine + : undefined; + return html` + ${when( + comment.comment.path, + () => html` + <button + class="comment-path link-button" + @click=${() => + this.handleFileClick( + comment.comment.path as string, + lineNum + )} + > + <gr-icon icon="description"></gr-icon> + ${comment.comment.path} + </button> + ` + )} + ${when( + displayLine, + () => html` + <button + class="comment-line link-button" + @click=${() => + this.handleFileClick( + comment.comment.path as string, + lineNum + )} + > + <gr-icon icon="code"></gr-icon> + ${displayLine} + </button> + ` + )} + <div class="suggested-comment"> + <p class="suggested-comment-message"> + <gr-formatted-text + .markdown=${true} + .content=${comment.comment.message} + ></gr-formatted-text> + </p> + <gr-button + primary + class="add-as-comment-button" + @click=${() => this.onAddAsComment(comment)} + >Add as Comment + </gr-button> + </div> + `; + }) + )} + ${when( + message.responseComplete && !this.isBackgroundRequest, + () => html` + <citations-box .turnIndex=${this.turnIndex}></citations-box> + <references-dropdown + .turnIndex=${this.turnIndex} + ></references-dropdown> + <message-actions + .turnId=${this.turnId()} + .isLatest=${this.isLatest} + ></message-actions> + ` + )} + ` + )} + `; + } + + private message(): GeminiMessageModel { + assert(this.turnIndex < this.turns.length, 'turnIndex out of bounds'); + return this.turns[this.turnIndex].geminiMessage; + } + + private sortedComments() { + const parts = this.message()?.responseParts ?? []; + return parts + .filter(part => part.type === ResponsePartType.CREATE_COMMENT) + .sort((p1, p2) => { + const c1 = {...createNew(p1.comment.message), ...p1.comment}; + const c2 = {...createNew(p2.comment.message), ...p2.comment}; + return compareComments(c1, c2); + }); + } + + private turnId() { + return { + turnIndex: this.turnIndex, + regenerationIndex: this.message()?.regenerationIndex ?? 0, + }; + } + + getAiAgentReportingDetails(): AiAgentEventDetails { + const agentId = this.turns[this.turnIndex]?.userMessage?.actionId ?? ''; + return { + agentId, + conversationId: this.conversationId ?? '', + turnIndex: this.turnIndex, + }; + } + + private reportSuggestionsShown() { + if (!this.conversationId) return; + this.reportedSuggestionsShown = true; + + this.reportingService.reportInteraction( + Interaction.AI_AGENT_SUGGESTIONS_SHOWN, + { + ...this.getAiAgentReportingDetails(), + commentCount: this.sortedComments().length, + } + ); + } + + private reportSuggestionToComment() { + this.reportingService.reportInteraction( + Interaction.AI_AGENT_SUGGESTION_TO_COMMENT, + this.getAiAgentReportingDetails() + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gemini-message': GeminiMessage; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts new file mode 100644 index 0000000..a23a199 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/gemini-message_test.ts
@@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import sinon from 'sinon'; +import { + ChatModel, + chatModelToken, + CreateCommentPart, + GeminiMessage as GeminiMessageModel, + ResponsePartType, + TextPart, + Turn, + UserType, +} from '../../models/chat/chat-model'; +import './gemini-message'; +import {Reference} from '../../api/ai-code-review'; +import {commentsModelToken} from '../../models/comments/comments-model'; +import {changeModelToken} from '../../models/change/change-model'; +import {GeminiMessage} from './gemini-message'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {ParsedChangeInfo} from '../../types/types'; +import {CommentsModel} from '../../models/comments/comments-model'; +import {AiAgentEventDetails, Interaction} from '../../constants/reporting'; +import {getAppContext} from '../../services/app-context'; + +suite('gemini-message tests', () => { + let element: GeminiMessage; + let chatModel: ChatModel; + let commentsModel: CommentsModel; + let saveDraftStub: sinon.SinonStub; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + commentsModel = testResolver(commentsModelToken); + saveDraftStub = sinon.stub(commentsModel, 'saveDraft'); + + element = await fixture<GeminiMessage>( + html`<gemini-message .turnIndex=${0}></gemini-message>` + ); + }); + + function createTurn(message: Partial<GeminiMessageModel>): Turn { + return { + userMessage: { + userType: UserType.USER, + content: 'test', + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex: 0, + references: [], + citations: [], + ...message, + }, + }; + } + + const RESPONSE_TEXT: TextPart = { + id: 0, + type: ResponsePartType.TEXT, + content: 'test message', + }; + const RESPONSE_CREATE_COMMENT: CreateCommentPart = { + id: 1, + type: ResponsePartType.CREATE_COMMENT, + content: 'test comment', + commentCreationId: 'test-id', + comment: { + message: 'test comment', + path: '/test/path', + }, + }; + + test('renders thinking', async () => { + const turn = createTurn({responseComplete: false}); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="user-info"> + <gr-icon class="gemini-icon" custom="" icon="ai" title=""></gr-icon> + </div> + <div class="thinking-indicator"> + <p class="text-content">Thinking ...</p> + <md-circular-progress + class="thinking-spinner" + indeterminate="" + size="small" + ></md-circular-progress> + </div> + ` + ); + }); + + test('renders empty response', async () => { + const turn = createTurn({responseComplete: true}); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="user-info"> + <gr-icon class="gemini-icon" custom="" icon="ai" title=""></gr-icon> + </div> + <p class="text-content">The server did not return any response.</p> + ` + ); + }); + + test('renders text response', async () => { + const turn = createTurn({ + responseComplete: true, + responseParts: [RESPONSE_TEXT], + }); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + await element.updateComplete; + + const formattedText = + element.shadowRoot?.querySelector('gr-formatted-text'); + assert.isOk(formattedText); + assert.equal(formattedText?.content, 'test message'); + }); + + test('renders error', async () => { + const turn = createTurn({errorMessage: 'test error'}); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + await element.updateComplete; + + const error = element.shadowRoot?.querySelector('.server-error'); + assert.isOk(error); + assert.include(error?.textContent, 'Server error'); + + const retryButton = element.shadowRoot?.querySelector( + 'gr-button' + ) as HTMLElement; + assert.isOk(retryButton); + assert.equal(retryButton.textContent, 'Retry'); + + const spy = sinon.spy(chatModel, 'regenerateMessage'); + retryButton.click(); + assert.isTrue(spy.calledOnce); + assert.deepEqual(spy.firstCall.args[0], { + turnIndex: 0, + regenerationIndex: 0, + }); + }); + + test('renders suggested comment', async () => { + const turn = createTurn({ + responseComplete: true, + responseParts: [RESPONSE_CREATE_COMMENT], + }); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + await element.updateComplete; + + const commentContainer = + element.shadowRoot?.querySelector('.suggested-comment'); + assert.isOk(commentContainer); + + const reloadStub = sinon.stub(commentsModel, 'reloadAllComments'); + saveDraftStub.resolves({}); + + const button = commentContainer?.querySelector('gr-button'); + assert.isOk(button); + (button as HTMLElement).click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.isTrue(saveDraftStub.called); + const draft = saveDraftStub.lastCall.args[0]; + assert.equal(draft.message, 'test comment'); + assert.isTrue(reloadStub.called); + }); + + test('renders citations', async () => { + const turn = createTurn({ + responseComplete: true, + responseParts: [RESPONSE_TEXT], + citations: ['http://example.com'], + }); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + element.isLatest = true; + await element.updateComplete; + + const citationsBox = element.shadowRoot?.querySelector('citations-box'); + assert.isOk(citationsBox); + }); + + test('renders references', async () => { + const references: Reference[] = [ + { + type: 'test', + displayText: 'test', + externalUrl: 'http://example.com', + }, + ]; + const turn = createTurn({ + responseComplete: true, + responseParts: [RESPONSE_TEXT], + references, + }); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + element.isLatest = true; + await element.updateComplete; + + const referencesDropdown = element.shadowRoot?.querySelector( + 'references-dropdown' + ); + assert.isOk(referencesDropdown); + }); + + test('reports AI_AGENT_SUGGESTIONS_SHOWN interaction', async () => { + chatModel.updateState({ + ...chatModel.getState(), + id: 'test-conversation-id', + selectedModelId: 'gemini-model-id', + }); + + const reportStub = sinon.stub( + getAppContext().reportingService, + 'reportInteraction' + ); + + const turn = createTurn({ + responseComplete: true, + responseParts: [RESPONSE_TEXT, RESPONSE_CREATE_COMMENT], + }); + const updatedTurn = { + ...turn, + userMessage: {...turn.userMessage, actionId: 'custom-agent-id'}, + }; + + chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]}); + await element.updateComplete; + + assert.isTrue(reportStub.calledOnce); + assert.equal( + reportStub.firstCall.args[0], + Interaction.AI_AGENT_SUGGESTIONS_SHOWN + ); + const details = reportStub.firstCall.args[1] as AiAgentEventDetails; + assert.equal(details.conversationId, 'test-conversation-id'); + assert.equal(details.agentId, 'custom-agent-id'); + assert.equal(details.commentCount, 1); + }); + + test('reports AI_AGENT_SUGGESTION_TO_COMMENT interaction', async () => { + chatModel.updateState({ + ...chatModel.getState(), + id: 'test-conversation-id', + selectedModelId: 'gemini-model-id', + }); + + const reportStub = sinon.stub( + getAppContext().reportingService, + 'reportInteraction' + ); + + const turn = createTurn({ + responseComplete: true, + responseParts: [RESPONSE_CREATE_COMMENT], + }); + const updatedTurn = { + ...turn, + userMessage: {...turn.userMessage, actionId: 'custom-agent-id'}, + }; + + chatModel.updateState({...chatModel.getState(), turns: [updatedTurn]}); + await element.updateComplete; + + const commentContainer = + element.shadowRoot?.querySelector('.suggested-comment'); + assert.isOk(commentContainer); + + sinon.stub(commentsModel, 'reloadAllComments'); + saveDraftStub.resolves({}); + + const button = commentContainer?.querySelector('gr-button'); + assert.isOk(button); + (button as HTMLElement).click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + const call = reportStub + .getCalls() + .find(c => c.args[0] === Interaction.AI_AGENT_SUGGESTION_TO_COMMENT); + assert.isOk(call, 'Expected AI_AGENT_SUGGESTION_TO_COMMENT to be reported'); + + const details = call.args[1] as AiAgentEventDetails; + assert.equal(details.conversationId, 'test-conversation-id'); + assert.equal(details.agentId, 'custom-agent-id'); + assert.isUndefined(details.commentCount); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/message-actions.ts b/polygerrit-ui/app/elements/chat-panel/message-actions.ts new file mode 100644 index 0000000..78f6b66 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/message-actions.ts
@@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/iconbutton/icon-button.js'; +import '@material/web/icon/icon.js'; +import '../shared/gr-copy-clipboard/gr-copy-clipboard'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; + +import { + chatModelToken, + ResponsePartType, + Turn, + UniqueTurnId, +} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {subscribe} from '../lit/subscription-controller'; +import {materialStyles} from '../../styles/gr-material-styles'; + +/** + * Component to display message actions for a Gemini message (e.g. thumbs up, + * down and retry). + */ +@customElement('message-actions') +export class MessageActions extends LitElement { + static override styles = [ + materialStyles, + css` + :host { + display: flex; + } + md-icon-button { + margin-left: var(--spacing-l); + --md-icon-button-icon-size: 24px; + --md-icon-size: 24px; + } + .copy-button { + --gr-icon-size: 24px; + margin: auto 0; + } + .feedback-button.thumbs-up-icon { + margin-left: auto; + } + md-icon-button { + color: var(--primary-text-color); + --md-icon-button-icon-color: var(--primary-text-color); + --md-icon-button-hover-icon-color: var(--primary-text-color); + } + md-icon-button md-icon { + color: var(--primary-text-color); + } + `, + ]; + + @property({type: Object}) turnId!: UniqueTurnId; + + @property({type: Boolean}) isLatest = false; + + @state() protected turns: readonly Turn[] = []; + + @state() protected conversationId?: string; + + private readonly getChatModel = resolve(this, chatModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().turns$, + x => (this.turns = x ?? []) + ); + subscribe( + this, + () => this.getChatModel().conversationId$, + x => (this.conversationId = x) + ); + } + + override render() { + return html` + <gr-copy-clipboard + class="copy-button" + ?hidden=${!this.isLatest} + .text=${this.getGeminiMessageText()} + hideInput + .smallIcon=${false} + ></gr-copy-clipboard> + + <md-icon-button + ?hidden=${!this.regenerationIsEnabled()} + class="regenerate-button" + @click=${this.onRegenerate} + aria-label="Regenerate response" + title="Regenerate response" + > + <md-icon>refresh</md-icon> + </md-icon-button> + `; + } + + protected onRegenerate() { + this.getChatModel().regenerateMessage(this.turnId); + } + + protected regenerationIsEnabled() { + return this.isLatest; + } + + private getGeminiMessageText() { + const turns = this.turns; + if (!turns || turns.length <= this.turnId.turnIndex) { + return ''; + } + const turn = turns[this.turnId.turnIndex]; + let text = ''; + turn.geminiMessage.responseParts.forEach(part => { + if (part.type === ResponsePartType.TEXT) { + text += part.content; + } + }); + return text; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'message-actions': MessageActions; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts b/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts new file mode 100644 index 0000000..123a76d --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/message-actions_test.ts
@@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import { + ChatModel, + chatModelToken, + GeminiMessage, + ResponsePartType, + Turn, + UniqueTurnId, + UserType, +} from '../../models/chat/chat-model'; +import './message-actions'; +import {MessageActions} from './message-actions'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('message-actions tests', () => { + let element: MessageActions; + let chatModel: ChatModel; + + const turnId: UniqueTurnId = {turnIndex: 0, regenerationIndex: 0}; + + function createTurn(text: string): Turn { + return { + userMessage: { + userType: UserType.USER, + content: 'test', + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [ + { + id: 0, + type: ResponsePartType.TEXT, + content: text, + }, + ], + regenerationIndex: 0, + references: [], + citations: [], + } as GeminiMessage, + }; + } + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + + element = await fixture<MessageActions>( + html`<message-actions + .turnId=${turnId} + .isLatest=${true} + ></message-actions>` + ); + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn('test message')], + }); + await element.updateComplete; + }); + + test('renders', () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <gr-copy-clipboard class="copy-button" hideinput=""> + </gr-copy-clipboard> + <md-icon-button + class="regenerate-button" + data-aria-label="Regenerate response" + title="Regenerate response" + value="" + > + <md-icon aria-hidden="true">refresh</md-icon> + </md-icon-button> + ` + ); + }); + + test('hides copy and regenerate when not latest', async () => { + element.isLatest = false; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <gr-copy-clipboard class="copy-button" hidden="" hideinput=""> + </gr-copy-clipboard> + <md-icon-button + class="regenerate-button" + data-aria-label="Regenerate response" + hidden="" + title="Regenerate response" + value="" + > + <md-icon aria-hidden="true">refresh</md-icon> + </md-icon-button> + ` + ); + }); + + test('regenerate button calls model', async () => { + const initialTurn = createTurn('test message'); + chatModel.updateState({...chatModel.getState(), turns: [initialTurn]}); + await element.updateComplete; + + const button = element.shadowRoot?.querySelector('.regenerate-button'); + assert.isOk(button); + (button as HTMLElement).click(); + + // Wait for the model update to propagate. + await element.updateComplete; + + const turns = chatModel.getState().turns; + assert.equal(turns.length, 1); + assert.equal(turns[0].geminiMessage?.regenerationIndex, 1); + }); + + test('copy clipboard has correct text', async () => { + const turn = createTurn('another message'); + chatModel.updateState({...chatModel.getState(), turns: [turn]}); + await element.updateComplete; + const copy = element.shadowRoot?.querySelector('gr-copy-clipboard'); + assert.isOk(copy); + assert.equal(copy?.text, 'another message'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/prompt-box.ts b/polygerrit-ui/app/elements/chat-panel/prompt-box.ts new file mode 100644 index 0000000..4290371 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/prompt-box.ts
@@ -0,0 +1,750 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/chips/assist-chip.js'; +import '@material/web/chips/chip-set.js'; +import './context-chip'; +import './context-input-chip'; + +import {css, html, LitElement, nothing} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; +import {repeat} from 'lit/directives/repeat.js'; + +import { + ContextItem, + ContextItemType, + ModelInfo, +} from '../../api/ai-code-review'; +import {chatModelToken, Turn} from '../../models/chat/chat-model'; +import {changeModelToken} from '../../models/change/change-model'; +import { + contextItemEquals, + searchForBugsInCommitMessage, + searchForContextLinks, +} from '../../models/chat/context-item-util'; +import {resolve} from '../../models/dependency'; +import {ParsedChangeInfo} from '../../types/types'; +import {debounce, DelayedTask} from '../../utils/async-util'; +import {fire} from '../../utils/event-util'; +import {subscribe} from '../lit/subscription-controller'; +import {materialStyles} from '../../styles/gr-material-styles'; + +const MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED = 3; +const MAX_VISIBLE_SUGGESTED_CONTEXT_ITEMS_COLLAPSED = 1; +const MAX_VISIBLE_INPUT_LINES = 5; + +interface PromptBoxContextItem extends ContextItem { + isProvisional?: boolean; +} + +@customElement('prompt-box') +export class PromptBox extends LitElement { + @query('#promptInput') promptInput?: HTMLTextAreaElement; + + @property({type: Boolean}) + isDisabled = false; + + @property({type: String}) + disabledMessage = 'Review Agent is disabled.'; + + @state() hasModelLoadingError = false; + + @state() selectedModel?: ModelInfo; + + @state() userInput = ''; + + @state() previousMessageIndex = -1; + + @state() turns: readonly Turn[] = []; + + @state() errorMessage?: string; + + @state() contextItems: readonly PromptBoxContextItem[] = []; + + @state() dynamicContextItemsSuggestions: PromptBoxContextItem[] = []; + + @state() commitMessageContextItems: readonly PromptBoxContextItem[] = []; + + @state() showAllContextItems = false; + + @state() contextItemTypes: readonly ContextItemType[] = []; + + @state() private change?: ParsedChangeInfo; + + // TODO(milutin): Find out if we need this. + // @ts-ignore + private turnBasisForUserInput?: number; + + private lineHeight = 0; + + private maxInputHeight = 0; + + private updateDynamicContextItemsTask?: DelayedTask; + + private readonly getChatModel = resolve(this, chatModelToken); + + private readonly getChangeModel = resolve(this, changeModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getChangeModel().change$, + x => (this.change = x) + ); + subscribe( + this, + () => this.getChatModel().modelsLoadingError$, + x => (this.hasModelLoadingError = !!x) + ); + subscribe( + this, + () => this.getChatModel().turns$, + x => { + const newLength = x?.length ?? 0; + if (this.turns.length !== newLength) { + this.previousMessageIndex = newLength; + } + this.turns = x ?? []; + } + ); + subscribe( + this, + () => this.getChatModel().errorMessage$, + x => (this.errorMessage = x) + ); + subscribe( + this, + () => this.getChatModel().selectedModel$, + x => (this.selectedModel = x) + ); + subscribe( + this, + () => this.getChatModel().contextItemTypes$, + x => (this.contextItemTypes = x) + ); + subscribe( + this, + () => this.getChatModel().userContextItems$, + x => (this.contextItems = x) + ); + } + + override disconnectedCallback() { + this.updateDynamicContextItemsTask?.cancel(); + super.disconnectedCallback(); + } + + protected get suggestedContextItems(): ContextItem[] { + const suggestions: ContextItem[] = [ + ...this.commitMessageContextItems, + ...this.dynamicContextItemsSuggestions, + ]; + return suggestions.filter(item => + this.contextItems.every( + existingItem => !contextItemEquals(item, existingItem) + ) + ); + } + + private get nextTurnIndex() { + return this.turns.length; + } + + private get lastTurn() { + if (this.turns.length === 0) { + return undefined; + } + return this.turns[this.turns.length - 1]; + } + + get chatInputDisabledText() { + if (this.hasModelLoadingError) { + return 'Failed to load models. Please reload the page.'; + } + if (!this.selectedModel) { + return 'Loading models...'; + } + const lastTurn = this.lastTurn; + const geminiMessage = lastTurn?.geminiMessage; + const isBackgroundRequest = lastTurn?.userMessage.isBackgroundRequest; + if ( + !!this.errorMessage || + (geminiMessage !== undefined && + !geminiMessage.responseComplete && + !isBackgroundRequest) + ) { + return 'Thinking ...'; + } + if (this.isDisabled) { + return this.disabledMessage; + } + return ''; + } + + get chatInputDisabled() { + return !!this.chatInputDisabledText; + } + + private get previousUserMessages() { + return this.turns.map(turn => turn.userMessage.content); + } + + private get promptHasInput() { + return !!this.userInput; + } + + private get selectedPreviousMessage() { + const previousUserMessages = this.previousUserMessages; + return this.previousMessageIndex >= 0 && + this.previousMessageIndex < previousUserMessages.length + ? previousUserMessages[this.previousMessageIndex] + : ''; + } + + /** + * Calculates the number of context items (both regular and suggested) + * that are not visible when the context chip set is collapsed. + */ + get numExcessContextItems() { + const regularContextItems = this.contextItems; + const suggestedContextItems = this.suggestedContextItems; + return ( + Math.max( + 0, + regularContextItems.length - MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED + ) + + Math.max( + 0, + suggestedContextItems.length - + MAX_VISIBLE_SUGGESTED_CONTEXT_ITEMS_COLLAPSED + ) + ); + } + + get shouldShowContextToggle() { + return this.numExcessContextItems > 0; + } + + get contextToggleTooltip() { + return this.showAllContextItems + ? 'Collapse' + : `${this.numExcessContextItems} additional items or suggestions`; + } + + get contextToggleText() { + return this.showAllContextItems ? '▲' : `+${this.numExcessContextItems}`; + } + + static override styles = [ + materialStyles, + css` + :host { + background-color: var(--background-color-tertiary); + border-radius: 8px; + display: flex; + flex-direction: column; + /* For high contrast mode. */ + outline: 1px solid transparent; + padding: var(--spacing-l) var(--spacing-l) var(--spacing-m) + var(--spacing-xl); + transition: box-shadow 280ms cubic-bezier(0.4, 0, 0.2, 1); + } + :host(:focus-within) { + background: var( + --search-box-focus-bg-color, + var(--background-color-primary) + ); + box-shadow: 0px 1px 2px 0px var(--elevation-color), + 0px 2px 6px 2px var(--elevation-color); + } + .tab-chip-set md-assist-chip.tab-chip { + --md-assist-chip-container-height: 16px; + --md-assist-chip-label-text-size: 10px; + --md-assist-chip-label-text-color: var(--primary-fg-color); + --md-assist-chip-label-line-height: 16px; + background-color: var(--tab-chip-bg-color); + border-radius: 4px; + margin: 0; + padding: 0; + } + gr-icon, + md-icon { + font-size: 20px; + line-height: 24px; + } + .prompt-box-inner-container { + display: flex; + height: auto; + min-height: 32px; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding-bottom: 8px; + } + .prompt-input-container { + flex-grow: 1; + height: inherit; + margin-right: var(--spacing-xs); + position: relative; + } + .prompt-input { + background: transparent; + color: var(--primary-text-color); + font: inherit; + font-size: 16px; /* $font-size-large */ + /* Explicitly set line-height to ensure consistent height in tests (e.g. 20px). */ + line-height: 20px; + border: none; + outline: none; + resize: none; + padding: 0; + margin: 0; + height: auto; + width: 100%; + max-width: 100%; + } + .prompt-input::placeholder { + color: var(--deemphasized-text-color); + } + .context-chip-set { + gap: 4px; + } + .context-chip-set md-assist-chip.context-toggle-chip { + --md-assist-chip-container-height: 20px; + --md-assist-chip-label-text-size: 12px; + --md-assist-chip-label-text-weight: 500; + --md-assist-chip-label-line-height: 16px; + --md-assist-chip-label-text-color: var(--primary-text-color); + margin: 0; + margin-left: auto; + border-color: var(--border-color); + background-color: var(--elevation-2); + border-radius: 8px; + display: flex; + padding: 0 4px; + } + `, + ]; + + override render() { + return html` + <div class="prompt-box-inner-container"> + <div class="prompt-input-container"> + <textarea + id="promptInput" + rows="1" + class="prompt-input" + name="search" + role="searchbox" + autocomplete="off" + spellcheck="false" + aria-label="Ask Gemini" + ?disabled=${this.chatInputDisabled} + .placeholder=${this.placeHolderText()} + .value=${this.userInput} + @keydown=${this.onKeyDown} + @input=${this.onInput} + @scroll=${this.onScroll} + ></textarea> + </div> + ${this.renderTabChip()} + </div> + ${this.renderAddContext()} + `; + } + + private renderTabChip() { + return html` ${when( + this.tabChipVisible(), + () => html` + <md-chip-set class="tab-chip-set"> + <md-assist-chip class="tab-chip" label="Tab"></md-assist-chip> + </md-chip-set> + ` + )}`; + } + + private renderThisChangeChip() { + // This Change is implicitly added to the context, so we don't need to add it. + // The chip makes it clear to the user that it is already in the context. + if (!this.change) return nothing; + const changeContextItem: ContextItem = { + type_id: 'gerrit_change', + // Don't include link in the chip since it's already on the change page. + link: '', + title: 'This Change', + identifier: this.change.id, + tooltip: 'File diffs (against base), commit message, and comments.', + }; + return html` + <context-chip + class="this-change-context" + .contextItem=${changeContextItem} + .isRemovable=${false} + ></context-chip> + `; + } + + private renderAddContext() { + return html` + <md-chip-set class="context-chip-set"> + <context-input-chip + @context-item-added=${(e: CustomEvent<ContextItem>) => + this.onContextItemAdded(e.detail)} + ></context-input-chip> + ${this.renderThisChangeChip()} + ${repeat( + this.showAllContextItems + ? this.contextItems + : this.contextItems.slice(0, MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED), + item => `${item.type_id}:${item.identifier}`, + contextItem => html` + <context-chip + class="external-context" + .text=${contextItem.title} + .contextItem=${contextItem} + .tooltip=${contextItem.tooltip} + @remove-context-chip=${() => this.removeContextItem(contextItem)} + ></context-chip> + ` + )} + ${repeat( + this.showAllContextItems + ? this.suggestedContextItems + : this.suggestedContextItems.slice( + 0, + MAX_VISIBLE_SUGGESTED_CONTEXT_ITEMS_COLLAPSED + ), + item => `${item.type_id}:${item.identifier}`, + contextItem => html` + <context-chip + class="suggestion-context" + .contextItem=${contextItem} + .text=${contextItem.title} + .isSuggestion=${true} + .tooltip=${`Add this ${contextItem.title} as context`} + @accept-context-item-suggestion=${() => + this.acceptContextItemSuggestion(contextItem)} + ></context-chip> + ` + )} + ${when( + this.shouldShowContextToggle, + () => html` + <md-assist-chip + class="context-toggle-chip" + @click=${this.toggleShowAllContext} + .title=${this.contextToggleTooltip} + .label=${this.contextToggleText} + > + </md-assist-chip> + ` + )} + </md-chip-set> + `; + } + + override firstUpdated() { + this.lineHeight = this.promptInput?.offsetHeight ?? 0; + this.maxInputHeight = this.lineHeight * MAX_VISIBLE_INPUT_LINES; + } + + override willUpdate( + changedProperties: Map<string | number | symbol, unknown> + ) { + if ( + changedProperties.has('change') || + changedProperties.has('contextItemTypes') + ) { + this.updateCommitMessageContextItems(); + } + } + + override updated(changedProperties: Map<string | number | symbol, unknown>) { + if (changedProperties.has('userInput')) { + if (!this.userInput) { + this.turnBasisForUserInput = undefined; + this.resetInputHeight(); + } else { + this.adjustInputHeight(); + } + } + if (changedProperties.has('chatInputDisabled')) { + if (!this.chatInputDisabled) { + this.refocusPromptInput(); + } + } + } + + private onScroll() { + this.scrollTop = this.promptInput?.scrollTop ?? 0; + this.adjustInputHeight(); + } + + private onInput(e: Event) { + this.userInput = (e.target as HTMLTextAreaElement).value; + this.adjustInputHeight(); + this.updateDynamicContextItemsDebounced(); + fire(this, 'user-input-change', {value: this.userInput}); + } + + private placeHolderText() { + if (this.chatInputDisabledText) { + return this.chatInputDisabledText; + } + const selectedPreviousMessage = this.selectedPreviousMessage; + if (selectedPreviousMessage) { + return selectedPreviousMessage; + } + if (this.isTextInputFocused()) { + return ''; + } + return 'Enter a prompt here...'; + } + + private onContextItemAdded(contextItem: ContextItem) { + this.addContextItem(contextItem); + } + + private addContextItem(contextItem: ContextItem) { + this.getChatModel().addContextItem(contextItem); + } + + private removeContextItem(contextItem: ContextItem) { + this.getChatModel().removeContextItem(contextItem); + } + + private acceptContextItemSuggestion(contextItem: ContextItem) { + this.addContextItem(contextItem); + this.updateDynamicContextItems(); + } + + private tabChipVisible() { + return ( + !this.chatInputDisabled && + !this.promptHasInput && + !!this.selectedPreviousMessage + ); + } + + private onKeyDown(event: KeyboardEvent) { + if ( + event.ctrlKey || + event.altKey || + event.metaKey || + (event.shiftKey && event.key === 'Enter') + ) { + return; + } + + switch (event.key) { + case 'Enter': + this.onEnter(event); + break; + case 'ArrowUp': + case 'ArrowDown': + this.handleArrowKey(event); + break; + case 'Tab': + this.onTab(event); + break; + default: + break; + } + } + + private handleArrowKey(event: KeyboardEvent) { + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + const previousMessageIndex = this.previousMessageIndex; + const previousUserMessages = this.previousUserMessages; + const index = + event.key === 'ArrowUp' + ? previousMessageIndex - 1 + : previousMessageIndex + 1; + if (index < 0 || index > previousUserMessages.length) return; + this.doKeyboardAction(event, () => { + this.previousMessageIndex = index; + }); + } + + private onTab(event: Event) { + const previousMessageIndex = this.previousMessageIndex; + const previousUserMessages = this.previousUserMessages; + if ( + previousMessageIndex < 0 || + previousMessageIndex >= previousUserMessages.length + ) { + return; + } + this.doKeyboardAction(event, () => { + this.userInput = previousUserMessages[previousMessageIndex]; + this.turnBasisForUserInput = previousMessageIndex; + }); + } + + /** Shared handler for up/down/tab keyboard actions. */ + private doKeyboardAction(event: Event, action: () => void) { + if (this.promptHasInput) return; + event.preventDefault(); + event.stopPropagation(); + action(); + this.adjustInputHeight(); + } + + private onEnter(event: Event) { + const userInput = this.userInput; + if (!userInput.trim()) return; + event.preventDefault(); + event.stopPropagation(); + this.dynamicContextItemsSuggestions = []; + const lastTurn = this.lastTurn; + const isLastTurnBackgroundRequest = + lastTurn?.userMessage.isBackgroundRequest; + const isLastTurnComplete = lastTurn?.geminiMessage.responseComplete; + if (isLastTurnBackgroundRequest && !isLastTurnComplete) { + // We don't want to block the user from typing and hitting enter in the + // prompt box if the current background request is not yet complete. + // Instead, we start a new conversation in this case. + this.getChatModel().startNewChatWithUserInput( + userInput, + undefined, + undefined, + true + ); + } else { + this.getChatModel().chat( + userInput, + lastTurn?.userMessage.actionId, + this.nextTurnIndex + // TODO(milutin): Find out if we need this. + // this.turnBasisForUserInput + ); + } + // Reset height after sending + this.resetInputHeight(); + } + + private toggleShowAllContext() { + this.showAllContextItems = !this.showAllContextItems; + } + + private isTextInputFocused() { + return document.activeElement === this.promptInput; + } + + private adjustInputHeight() { + if (!this.promptInput) { + return; + } + // Reset height to auto to correctly calculate scrollHeight for shrinking + this.promptInput.style.height = 'auto'; + const scrollHeight = this.promptInput.scrollHeight; + this.promptInput.style.height = `${Math.min( + scrollHeight, + this.maxInputHeight + )}px`; + } + + private resetInputHeight() { + if (this.promptInput) { + this.promptInput.style.height = `${this.lineHeight}px`; + } + } + + private refocusPromptInput() { + setTimeout(() => { + this.promptInput?.focus(); + }); + } + + private updateDynamicContextItemsDebounced() { + this.updateDynamicContextItemsTask = debounce( + this.updateDynamicContextItemsTask, + () => { + this.updateDynamicContextItems(); + }, + 200 + ); + } + + private updateDynamicContextItems() { + const suggestedContextItems = searchForContextLinks( + this.userInput, + this.contextItemTypes + ).map(item => { + return { + ...item, + isProvisional: true, + }; + }); + this.dynamicContextItemsSuggestions = suggestedContextItems; + // include all suggested context items as materialized context items + // by default + suggestedContextItems.forEach(contextItem => { + if ( + !this.contextItems.some(item => contextItemEquals(item, contextItem)) + ) { + this.addContextItem(contextItem); + } + }); + // remove materialized context items from the store if they previously came + // from a suggestion, if they no longer exist in the prompt input + this.contextItems.forEach(contextItem => { + if ( + !suggestedContextItems.some(item => + contextItemEquals(item, contextItem) + ) && + contextItem.isProvisional + ) { + this.removeContextItem(contextItem); + } + }); + } + + private updateCommitMessageContextItems() { + const currentRevisionSha = this.change?.current_revision; + const currentRevision = currentRevisionSha + ? this.change?.revisions?.[currentRevisionSha] + : undefined; + + const commitMessage = + currentRevision && 'commit' in currentRevision + ? currentRevision.commit?.message + : undefined; + if (!commitMessage) { + this.commitMessageContextItems = []; + return; + } + const contextLinks = searchForContextLinks( + commitMessage, + this.contextItemTypes + ); + const bugs = searchForBugsInCommitMessage( + commitMessage, + this.contextItemTypes + ); + this.commitMessageContextItems = [...contextLinks, ...bugs]; + } +} + +export interface ContextItemAddedEvent extends CustomEvent<ContextItem> { + type: 'context-item-added'; +} + +export interface UserInputChangedEvent extends CustomEvent<{value: string}> { + type: 'user-input-change'; +} + +declare global { + interface HTMLElementEventMap { + 'context-item-added': ContextItemAddedEvent; + 'user-input-change': UserInputChangedEvent; + } + interface HTMLElementTagNameMap { + 'prompt-box': PromptBox; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts b/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts new file mode 100644 index 0000000..63ffb13 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/prompt-box_test.ts
@@ -0,0 +1,323 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './prompt-box'; +import {PromptBox} from './prompt-box'; +import { + ChatModel, + chatModelToken, + ChatState, + UserType, +} from '../../models/chat/chat-model'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {ChangeModel, changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; +import sinon from 'sinon'; +import {ContextItem} from '../../api/ai-code-review'; + +suite('prompt-box tests', () => { + let element: PromptBox; + let chatModel: ChatModel; + let changeModel: ChangeModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + chatModel.updateState({ + turns: [], + draftUserMessage: { + contextItems: [], + content: '', + userType: UserType.USER, + }, + }); + + element = await fixture<PromptBox>(html`<prompt-box></prompt-box>`); + await element.updateComplete; + }); + + test('renders', () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="prompt-box-inner-container"> + <div class="prompt-input-container"> + <textarea + id="promptInput" + rows="1" + class="prompt-input" + name="search" + role="searchbox" + autocomplete="off" + spellcheck="false" + aria-label="Ask Gemini" + placeholder="Enter a prompt here..." + style="height: 20px;" + ></textarea> + </div> + </div> + <md-chip-set class="context-chip-set"> + <context-input-chip> </context-input-chip> + <context-chip class="this-change-context"> </context-chip> + </md-chip-set> + ` + ); + }); + + test('chatInputDisabledText when model loading error', async () => { + chatModel.updateState({ + ...chatModel.getState(), + modelsLoadingError: 'Error loading models', + }); + await element.updateComplete; + assert.equal( + element.chatInputDisabledText, + 'Failed to load models. Please reload the page.' + ); + }); + + test('updates userInput on input', async () => { + const promptInput = element.shadowRoot?.querySelector('#promptInput'); + assert.isOk(promptInput); + (promptInput as HTMLTextAreaElement).value = 'test input'; + promptInput?.dispatchEvent(new Event('input')); + await element.updateComplete; + assert.equal(element.userInput, 'test input'); + }); + + test('dispatches user-input-change event on input', async () => { + let eventFired = false; + let eventDetail = null; + element.addEventListener('user-input-change', (e: Event) => { + eventFired = true; + eventDetail = (e as CustomEvent).detail; + }); + + const promptInput = element.shadowRoot?.querySelector('#promptInput'); + assert.isOk(promptInput); + (promptInput as HTMLTextAreaElement).value = 'test input'; + promptInput?.dispatchEvent(new Event('input')); + await element.updateComplete; + + assert.isTrue(eventFired); + assert.deepEqual(eventDetail, {value: 'test input'}); + }); + + test('sends message on Enter', async () => { + const initialTurns = chatModel.getState().turns.length; + const promptInput = element.shadowRoot?.querySelector('#promptInput'); + assert.isOk(promptInput); + element.userInput = 'test input'; + await element.updateComplete; + + promptInput?.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter'})); + await element.updateComplete; + + const turns = chatModel.getState().turns; + assert.equal(turns.length, initialTurns + 1); + assert.equal(turns[turns.length - 1].userMessage.content, 'test input'); + }); + + test('renders context items', async () => { + chatModel.updateState({ + ...chatModel.getState(), + draftUserMessage: { + ...chatModel.getState().draftUserMessage, + contextItems: [ + {type_id: 'file', title: 'test.ts', link: 'link1'}, + {type_id: 'file', title: 'test2.ts', link: 'link2'}, + ], + }, + }); + await element.updateComplete; + const contextChips = element.shadowRoot?.querySelectorAll( + 'context-chip.external-context' + ); + assert.isOk(contextChips); + assert.equal(contextChips?.length, 2); + }); + + test('renders suggested context items', async () => { + element.dynamicContextItemsSuggestions = [ + {type_id: 'file', title: 'suggested.ts', link: 'link3'}, + ]; + await element.updateComplete; + const suggestedChips = element.shadowRoot?.querySelectorAll( + '.suggestion-context' + ); + assert.isOk(suggestedChips); + assert.equal(suggestedChips?.length, 1); + }); + + test('accepts suggested context item', async () => { + const addContextItemSpy = sinon.spy(chatModel, 'addContextItem'); + const suggestion: ContextItem = { + type_id: 'file', + title: 'suggested.ts', + link: 'link3', + identifier: 'id3', + }; + element.dynamicContextItemsSuggestions = [suggestion]; + await element.updateComplete; + + const suggestedChip = element.shadowRoot?.querySelector( + '.suggestion-context' + ); + assert.isOk(suggestedChip); + suggestedChip.dispatchEvent(new Event('accept-context-item-suggestion')); + await element.updateComplete; + + assert.isTrue(addContextItemSpy.calledOnceWith(suggestion)); + }); + + test('removes context item', async () => { + const removeContextItemSpy = sinon.spy(chatModel, 'removeContextItem'); + const item: ContextItem = { + type_id: 'file', + title: 'test.ts', + link: 'link1', + identifier: 'id1', + }; + chatModel.updateState({ + ...chatModel.getState(), + draftUserMessage: { + ...chatModel.getState().draftUserMessage, + contextItems: [item], + }, + }); + await element.updateComplete; + + const contextChip = element.shadowRoot?.querySelector( + 'context-chip.external-context' + ); + assert.isOk(contextChip); + contextChip.dispatchEvent(new Event('remove-context-chip')); + await element.updateComplete; + + assert.isTrue(removeContextItemSpy.calledOnceWith(item)); + }); + + test('shows context toggle when too many items', async () => { + chatModel.updateState({ + ...chatModel.getState(), + draftUserMessage: { + ...chatModel.getState().draftUserMessage, + contextItems: [ + {type_id: 'file', title: 'test.ts', link: 'link1'}, + {type_id: 'file', title: 'test2.ts', link: 'link2'}, + {type_id: 'file', title: 'test3.ts', link: 'link3'}, + {type_id: 'file', title: 'test4.ts', link: 'link4'}, + ], + }, + }); + await element.updateComplete; + const toggleChip = element.shadowRoot?.querySelector( + '.context-toggle-chip' + ); + assert.isOk(toggleChip); + }); + + test('toggles showAllContextItems', async () => { + chatModel.updateState({ + ...chatModel.getState(), + draftUserMessage: { + ...chatModel.getState().draftUserMessage, + contextItems: [ + {type_id: 'file', title: 'test.ts', link: 'link1'}, + {type_id: 'file', title: 'test2.ts', link: 'link2'}, + {type_id: 'file', title: 'test3.ts', link: 'link3'}, + {type_id: 'file', title: 'test4.ts', link: 'link4'}, + ], + }, + }); + await element.updateComplete; + assert.isFalse(element.showAllContextItems); + const toggleChip = element.shadowRoot?.querySelector( + '.context-toggle-chip' + ); + (toggleChip as HTMLElement).click(); + await element.updateComplete; + assert.isTrue(element.showAllContextItems); + }); + + test('chatInputDisabledText when message is processing', async () => { + chatModel.updateState({ + ...chatModel.getState(), + models: { + models: [ + { + model_id: 'gemini-pro', + short_text: 'Gemini Pro', + full_display_text: 'Gemini Pro', + }, + ], + default_model_id: 'gemini-pro', + }, + turns: [ + { + userMessage: { + content: 'test', + userType: 0, + contextItems: [], + }, + geminiMessage: { + responseComplete: false, + userType: 1, + responseParts: [], + regenerationIndex: 0, + references: [], + citations: [], + }, + }, + ], + } as Partial<ChatState>); + await element.updateComplete; + assert.equal(element.chatInputDisabledText, 'Thinking ...'); + }); + + test('grows on input', async () => { + const promptInput = element.shadowRoot?.querySelector('#promptInput'); + assert.isOk(promptInput); + const textarea = promptInput as HTMLTextAreaElement; + + // Mock scrollHeight to simulate content growth + Object.defineProperty(textarea, 'scrollHeight', { + value: 50, + configurable: true, + }); + + textarea.value = 'line 1\nline 2'; + textarea.dispatchEvent(new Event('input')); + await element.updateComplete; + + assert.equal(textarea.style.height, '50px'); + + // Mock scrollHeight to simulate further growth + Object.defineProperty(textarea, 'scrollHeight', { + value: 100, + configurable: true, + }); + + textarea.value = 'line 1\nline 2\nline 3\nline 4'; + textarea.dispatchEvent(new Event('input')); + await element.updateComplete; + + assert.equal(textarea.style.height, '100px'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/references-dropdown.ts b/polygerrit-ui/app/elements/chat-panel/references-dropdown.ts new file mode 100644 index 0000000..dc7f221 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/references-dropdown.ts
@@ -0,0 +1,336 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/button/text-button'; +import '@material/web/icon/icon'; +import '../shared/gr-icon/gr-icon'; + +import {css, html, LitElement, nothing} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; + +import {ContextItemType} from '../../api/ai-code-review'; +import {chatModelToken, Turn} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {subscribe} from '../lit/subscription-controller'; +import {materialStyles} from '../../styles/gr-material-styles'; + +/** + * A component to display a dropdown with references used by the model. + */ +@customElement('references-dropdown') +export class ReferencesDropdown extends LitElement { + private readonly getChatModel = resolve(this, chatModelToken); + + @property({type: Number}) turnIndex = 0; + + @state() private turns: readonly Turn[] = []; + + @state() private contextItemTypes: readonly ContextItemType[] = []; + + @state() private showReferences = false; + + @state() private listWarnings = false; + + static override styles = [ + materialStyles, + css` + :host { + display: block; + } + .references-dropdown-container { + display: flex; + flex-direction: row; + align-items: center; + /* Match the vertical line in the mockup */ + border-left: 1px solid var(--border-color); + padding-left: var(--spacing-m); + } + .references-dropdown-content { + display: flex; + flex-direction: column; + gap: var(--spacing-s); + padding: var(--spacing-s) 0 0 var(--spacing-m); + /* Match the vertical line in the mockup */ + border-left: 1px solid var(--border-color); + } + .references-dropdown-button { + --md-text-button-label-text-color: var(--link-color); + --md-text-button-icon-color: var(--link-color); + --md-text-button-label-text-font: var(--font-family); + --md-text-button-label-text-weight: 500; + --md-text-button-label-text-size: var(--font-size-normal); + --md-text-button-hover-label-text-color: var(--link-color); + --md-text-button-hover-icon-color: var(--link-color); + --md-text-button-focus-label-text-color: var(--link-color); + --md-text-button-focus-icon-color: var(--link-color); + --md-text-button-pressed-label-text-color: var(--link-color); + --md-text-button-pressed-icon-color: var(--link-color); + margin-left: calc(-1 * var(--spacing-m)); + } + .button-outer-wrapper { + display: inline-block; + max-width: 100%; + } + .reference-button { + height: 32px; + background-color: transparent; + border-radius: 8px; + max-width: inherit; + /* For <a> and <button> to look like buttons. */ + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + text-decoration: none; + border: none; + font: inherit; + cursor: pointer; + } + .reference-button.pill-link { + background-color: var(--background-color-tertiary); + padding: 0 12px; + border-radius: 8px; + height: 32px; + } + .reference-button.cl-references-button, + .reference-button.list-warnings-button, + .reference-button.selection-button { + background-color: transparent; + } + .reference-button[disabled] { + cursor: default; + } + .reference-wrapper { + display: flex; + align-items: center; + vertical-align: middle; + gap: var(--spacing-s); + color: var(--primary-text-color); + padding-top: 2px; + font-weight: var(--font-weight-medium); + font-size: var(--font-size-normal); + line-height: var(--line-height-normal); + } + .reference-wrapper .display-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + flex: 0 1 auto; + } + .reference-wrapper .additional-text { + flex-shrink: 0; + } + .reference-wrapper .reference-icon { + flex: 0 0 auto; + height: 20px; + width: 20px; + } + .reference-wrapper .reference-icon.selection-icon { + max-width: 100%; + max-height: 100%; + color: var(--file-chip-avatar-color); + margin-right: 3px; + margin-left: -3px; + margin-top: -5px; + transform: scale(0.85); + } + .reference-button .expand-icon { + transform: scale(0.5); + } + .cl-references, + .warnings-list { + margin: var(--spacing-s) 0; + padding-left: 20px; + list-style-type: disc; + } + .cl-references li, + .warnings-list li { + color: var(--primary-text-color); + overflow-wrap: break-word; + font-size: var(--font-size-normal); + line-height: var(--line-height-normal); + } + .warning-icon { + color: var(--warning-icon, purple); + } + .warning-icon.reference-icon { + transform: scale(0.8); + margin-left: -2px; + margin-top: -6px; + padding-right: 2px; + color: var(--warning-icon, purple); + } + `, + ]; + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().turns$, + x => (this.turns = x ?? []) + ); + subscribe( + this, + () => this.getChatModel().contextItemTypes$, + x => (this.contextItemTypes = x ?? []) + ); + } + + private get turn(): Turn | undefined { + if (!this.turns || this.turnIndex >= this.turns.length) { + return undefined; + } + return this.turns[this.turnIndex]; + } + + private get dynamicReferences() { + const references = this.turn?.geminiMessage.references ?? []; + const seenUrls = new Set<string>(); + return references.filter(reference => { + if (seenUrls.has(reference.externalUrl)) { + return false; + } + seenUrls.add(reference.externalUrl); + return true; + }); + } + + get validDynamicReferences() { + return this.dynamicReferences.filter(reference => !reference.errorMsg); + } + + get dynamicReferencesWithErrors() { + return this.dynamicReferences.filter(reference => !!reference.errorMsg); + } + + get totalReferencesCount() { + return this.validDynamicReferences.length; + } + + get hasErrors() { + return this.dynamicReferencesWithErrors.length !== 0; + } + + private toggleShowReferences() { + this.showReferences = !this.showReferences; + } + + private toggleListWarnings() { + this.listWarnings = !this.listWarnings; + } + + private renderReferenceIcon(type: string) { + const contextItemType = this.contextItemTypes.find( + contextItemType => contextItemType.id === type + ); + const icon = contextItemType?.icon ?? ''; + if (!icon) return nothing; + return html`<gr-icon class="reference-icon" .icon=${icon}></gr-icon>`; + } + + override render() { + if (this.totalReferencesCount === 0) return nothing; + + return html` + <div class="references-dropdown-container"> + <md-text-button + @click=${this.toggleShowReferences} + class="references-dropdown-button" + > + <md-icon slot="icon" + >${this.showReferences ? 'expand_less' : 'expand_more'}</md-icon + > + Context used (${this.totalReferencesCount}) + </md-text-button> + ${when( + this.hasErrors, + () => html` + <md-icon + class="warning-icon" + title="There were errors loading some references." + >warning_amber</md-icon + > + ` + )} + </div> + + ${when(this.showReferences, () => this.renderDropdownContent())} + `; + } + + private renderDropdownContent() { + return html` + <div class="references-dropdown-content"> + ${this.validDynamicReferences.map( + reference => + html`<div class="button-outer-wrapper"> + <a + class="reference-button pill-link" + .href=${reference.externalUrl} + target="_blank" + .title=${reference.tooltip ?? ''} + > + <div class="reference-wrapper"> + ${this.renderReferenceIcon(reference.type)} + <span class="display-text">${reference.displayText}</span> + ${when( + reference.secondaryText, + () => html`<span class="additional-text" + >- ${reference.secondaryText}</span + >` + )} + </div> + </a> + </div>` + )} + ${when( + this.hasErrors, + () => html` + <button + class="reference-button list-warnings-button" + @click=${this.toggleListWarnings} + > + <div class="reference-wrapper"> + <md-icon class="reference-icon warning-icon" + >warning_amber</md-icon + > + <span class="display-text">Warnings</span> + <md-icon class="expand-icon" + >${this.listWarnings ? 'expand_less' : 'expand_more'}</md-icon + > + </div> + </button> + ` + )} + ${when(this.hasErrors && this.listWarnings, () => + this.renderWarningsList() + )} + </div> + `; + } + + private renderWarningsList() { + return html` + <ul class="warnings-list"> + ${this.dynamicReferencesWithErrors.map( + reference => html` + <li> + Failed to load ${reference.displayText}: ${reference.errorMsg} + </li> + ` + )} + </ul> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'references-dropdown': ReferencesDropdown; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/references-dropdown_test.ts b/polygerrit-ui/app/elements/chat-panel/references-dropdown_test.ts new file mode 100644 index 0000000..6c363f0 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/references-dropdown_test.ts
@@ -0,0 +1,230 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './references-dropdown'; +import {ReferencesDropdown} from './references-dropdown'; +import { + ChatModel, + chatModelToken, + Turn, + UserType, +} from '../../models/chat/chat-model'; +import {Reference} from '../../api/ai-code-review'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('references-dropdown tests', () => { + let element: ReferencesDropdown; + let chatModel: ChatModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + + element = await fixture<ReferencesDropdown>( + html`<references-dropdown .turnIndex=${0}></references-dropdown>` + ); + }); + + function createTurn(references: Reference[]): Turn { + return { + userMessage: { + userType: UserType.USER, + content: 'test', + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + references, + regenerationIndex: 0, + citations: [], + }, + }; + } + + test('is hidden when there are no references', async () => { + chatModel.updateState({...chatModel.getState(), turns: [createTurn([])]}); + await element.updateComplete; + assert.shadowDom.equal(element, ''); + }); + + test('renders references', async () => { + const references: Reference[] = [ + { + type: 'FILE', + displayText: 'file1.txt', + externalUrl: 'http://example.com/file1', + }, + { + type: 'FILE', + displayText: 'file2.txt', + externalUrl: 'http://example.com/file2', + }, + ]; + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn(references)], + }); + await element.updateComplete; + + const button = element.shadowRoot?.querySelector( + '.references-dropdown-button' + ); + assert.isOk(button); + assert.dom.equal( + button, + ` + <md-text-button + class="references-dropdown-button" + value="" + > + <md-icon slot="icon" aria-hidden="true">expand_more</md-icon> + Context used (2) + </md-text-button> + ` + ); + (button as HTMLElement).click(); + await element.updateComplete; + + const referenceLinks = + element.shadowRoot?.querySelectorAll('.reference-button'); + assert.isOk(referenceLinks); + assert.equal(referenceLinks.length, 2); + assert.equal( + (referenceLinks[0] as HTMLAnchorElement).href, + 'http://example.com/file1' + ); + assert.equal( + (referenceLinks[1] as HTMLAnchorElement).href, + 'http://example.com/file2' + ); + }); + + test('renders warnings', async () => { + const references: Reference[] = [ + { + type: 'FILE', + displayText: 'file1.txt', + externalUrl: 'http://example.com/file1', + errorMsg: 'File not found', + }, + ]; + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn(references)], + }); + await element.updateComplete; + + assert.shadowDom.equal(element, ''); + + const referencesWithErrors: Reference[] = [ + { + type: 'FILE', + displayText: 'file1.txt', + externalUrl: 'http://example.com/file1', + }, + { + type: 'FILE', + displayText: 'file2.txt', + externalUrl: 'http://example.com/file2', + errorMsg: 'File not found', + }, + ]; + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn(referencesWithErrors)], + }); + await element.updateComplete; + + const button = element.shadowRoot?.querySelector( + '.references-dropdown-button' + ); + assert.isOk(button); + (button as HTMLElement).click(); + await element.updateComplete; + + const warningButton = element.shadowRoot?.querySelector( + '.list-warnings-button' + ); + assert.isOk(warningButton); + (warningButton as HTMLElement).click(); + await element.updateComplete; + + const warningsList = element.shadowRoot?.querySelector('.warnings-list'); + assert.isOk(warningsList); + const warningItems = warningsList?.querySelectorAll('li'); + assert.isOk(warningItems); + assert.equal(warningItems.length, 1); + assert.equal( + warningItems[0].textContent?.trim(), + 'Failed to load file2.txt: File not found' + ); + }); + + test('deduplicates by externalUrl but not by displayText', async () => { + const references: Reference[] = [ + { + type: 'FILE', + displayText: 'file1.txt', + externalUrl: 'http://example.com/file1', + }, + { + type: 'FILE', + displayText: 'file1.txt', + externalUrl: 'http://example.com/file1', + }, + { + type: 'FILE', + displayText: 'file1.txt', + externalUrl: 'http://anotherexample.com/file1', + }, + ]; + chatModel.updateState({ + ...chatModel.getState(), + turns: [createTurn(references)], + }); + await element.updateComplete; + + assert.equal(element.totalReferencesCount, 2); + + const button = element.shadowRoot?.querySelector( + '.references-dropdown-button' + ); + assert.isOk(button); + assert.include(button.textContent, 'Context used (2)'); + + (button as HTMLElement).click(); + await element.updateComplete; + + const referenceLinks = element.shadowRoot?.querySelectorAll( + '.reference-button.pill-link' + ); + assert.equal(referenceLinks?.length, 2); + assert.equal( + (referenceLinks![0] as HTMLAnchorElement).href, + 'http://example.com/file1' + ); + assert.equal( + (referenceLinks![1] as HTMLAnchorElement).href, + 'http://anotherexample.com/file1' + ); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts b/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts new file mode 100644 index 0000000..ca69b5e --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/splash-page-action.ts
@@ -0,0 +1,378 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/iconbutton/icon-button.js'; +import '@material/web/progress/circular-progress.js'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property, query, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {when} from 'lit/directives/when.js'; + +import '../shared/gr-icon/gr-icon'; +import '../shared/gr-button/gr-button'; +import '../shared/gr-tooltip-content/gr-tooltip-content'; + +import {Action, ContextItemType} from '../../api/ai-code-review'; +import {chatModelToken} from '../../models/chat/chat-model'; +import {parseLink} from '../../models/chat/context-item-util'; +import {resolve} from '../../models/dependency'; +import {isDefined} from '../../types/types'; +import {fireAlert} from '../../utils/event-util'; +import {subscribe} from '../lit/subscription-controller'; +import {materialStyles} from '../../styles/gr-material-styles'; +import {modalStyles} from '../../styles/gr-modal-styles'; + +/** + * A component that renders a single action as a clickable chip on the chat + * splash page. Clicking the chip initiates a chat or action based on the + * provided `Action` object. + */ +@customElement('splash-page-action') +export class SplashPageAction extends LitElement { + @property({type: Object}) action?: Action; + + @property({type: Boolean}) isFirst = false; + + @property({type: Boolean}) isLast = false; + + @state() contextItemTypes: readonly ContextItemType[] = []; + + @query('#detailsModal') private detailsModal?: HTMLDialogElement; + + private readonly getChatModel = resolve(this, chatModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().contextItemTypes$, + types => (this.contextItemTypes = types) + ); + } + + static override styles = [ + materialStyles, + modalStyles, + css` + :host { + display: flex; + justify-content: center; + flex-wrap: wrap; + width: 100%; + position: relative; + } + + .action-chip { + display: flex; + background-color: var(--background-color-tertiary); + color: var(--primary-default); + height: 60px; + align-items: center; + border-radius: 4px; + margin: 0; + width: 100%; + overflow: hidden; + cursor: pointer; + --md-assist-chip-outline-width: 0; + } + + .action-chip[disabled] { + opacity: 0.6; + cursor: default; + } + + .action-chip.first-action-chip { + border-top-left-radius: 16px; + border-top-right-radius: 16px; + } + + .action-chip.last-action-chip { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + } + + .action-chip.custom-action-chip { + background-color: var(--custom-action-chip-bg-color); + } + + .action-icon { + padding: 4px; + border-radius: 8px; + background-color: var(--background-color-primary); + flex-shrink: 0; + } + .action-text-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + .main-action-text-container { + margin-left: 20px; + font-weight: 400; + display: flex; + align-items: center; + } + .main-action-text-container.has-subtext { + margin-top: 12px; + margin-bottom: -2px; + } + .action-text { + font-family: var(--font-family); + font-size: var(--font-size-normal); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-normal); + color: var(--primary-text-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .action-subtext { + vertical-align: super; + margin-left: 5px; + padding: 0px 15px 8px; + font-size: 0.8em; + color: var(--chat-splash-page-question-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .action-subtext.is-passed { + color: var(--file-reviewed-color); + } + .action-subtext.is-actionable { + color: var(--tonal-red); + } + .info-button { + margin-left: 10px; + margin-right: 10px; + font-size: 24px; + --button-background-color: transparent; + } + .info-button gr-icon { + color: inherit; + } + .chip-content { + display: flex; + align-items: center; + } + .modalHeader { + padding: var(--spacing-l) var(--spacing-xl); + background-color: var(--dialog-background-color); + border-bottom: 1px solid var(--border-color); + font-weight: var(--font-weight-medium); + } + .modalActions { + padding: var(--spacing-l) var(--spacing-xl); + background-color: var(--dialog-background-color); + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + } + .detailsContent { + padding: var(--spacing-m) var(--spacing-xl); + background-color: var(--dialog-background-color); + flex: 1; + overflow: auto; + } + .info-button:hover { + background-color: var(--hover-background-color, rgba(0, 0, 0, 0.08)); + border-radius: 50%; + } + .container { + position: relative; + display: flex; + width: 100%; + align-items: center; + } + .action-chip { + width: 100%; + } + .info-button-container { + position: absolute; + right: var(--spacing-s); + top: 50%; + transform: translateY(-50%); + z-index: 1; + } + #detailsModal { + width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px); + max-width: 90vw; + height: 300px; + max-height: 90vh; + } + #detailsModal > div { + display: flex; + flex-direction: column; + height: 100%; + } + .description-section { + margin-bottom: var(--spacing-m); + } + .modal-row { + display: flex; + align-items: flex-start; + margin-top: var(--spacing-m); + gap: var(--spacing-s); + } + .modal-row gr-icon { + color: var(--deemphasized-text-color); + margin-top: 2px; + } + .modal-row-content { + display: flex; + flex-direction: column; + } + .modal-row-title { + color: var(--deemphasized-text-color); + font-weight: var(--font-weight-bold); + } + .modal-row-text { + color: var(--primary-text-color); + } + .link-row { + display: flex; + align-items: center; + gap: var(--spacing-xs); + color: var(--link-color); + cursor: pointer; + } + `, + ]; + + override render() { + if (!this.action) return; + + const chipClasses = { + 'action-chip': true, + 'first-action-chip': this.isFirst, + 'last-action-chip': this.isLast, + }; + + return html` + <div class="container"> + <md-assist-chip + class=${classMap(chipClasses)} + title=${this.action.hover_text ?? ''} + @click=${this.handleAction} + > + <div class="chip-content"> + <gr-icon + class="action-icon" + icon=${this.action.icon ?? 'ai'} + ></gr-icon> + <div class="action-text-container"> + <div + class=${classMap({ + 'main-action-text-container': true, + 'has-subtext': !!this.action.subtext, + })} + > + <span class="action-text">${this.action.display_text}</span> + </div> + ${when( + this.action.subtext, + () => html` <span + class=${classMap({ + 'action-subtext': true, + })} + >${this.action?.subtext}</span + >` + )} + </div> + </div> + </md-assist-chip> + <gr-tooltip-content + class="info-button-container" + has-tooltip + title="Capability details" + > + <gr-button + flatten + class="info-button" + @click=${this.displayDetailsCard} + > + <gr-icon icon="info"></gr-icon> + </gr-button> + </gr-tooltip-content> + </div> + <dialog id="detailsModal" tabindex="-1"> + <div role="dialog" aria-labelledby="detailsTitle"> + <h3 class="heading-3 modalHeader" id="detailsTitle"> + ${this.action?.display_text} + </h3> + <div class="detailsContent"> + ${when( + this.action?.initial_user_prompt, + () => html` + <div class="modal-row"> + <gr-icon icon="terminal"></gr-icon> + <div class="modal-row-content"> + <div class="modal-row-title">Instruction:</div> + <div class="modal-row-text"> + ${this.action?.initial_user_prompt} + </div> + </div> + </div> + ` + )} + </div> + <div class="modalActions"> + <gr-button + id="closeButton" + link="" + primary="" + @click=${() => this.detailsModal?.close()} + > + Close + </gr-button> + </div> + </div> + </dialog> + `; + } + + private handleAction() { + const action = this.action; + if (!action) return; + + const contextItems = (action.context_item_links ?? []).map(link => + parseLink(link, this.contextItemTypes) + ); + if (action.external_contexts) { + contextItems.push(...action.external_contexts); + } + + if (contextItems.some(item => !item)) { + fireAlert(this, 'Failed to parse one or more context item links.'); + } + if (action.enable_send_without_input) { + this.getChatModel().startNewChatWithPredefinedPrompt( + action.id, + contextItems.filter(isDefined) + ); + } else { + this.getChatModel().startNewChatWithUserInput( + action.initial_user_prompt ?? '', + action.id, + contextItems.filter(isDefined), + /* useCurrentContext */ false // we want to use the context from the action + ); + } + } + + private displayDetailsCard(event: MouseEvent) { + event.stopPropagation(); + this.detailsModal?.showModal(); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'splash-page-action': SplashPageAction; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts b/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts new file mode 100644 index 0000000..5dd2f4f --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/splash-page-action_screenshot_test.ts
@@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import './splash-page-action'; +import {SplashPageAction} from './splash-page-action'; +import {Action} from '../../api/ai-code-review'; +import {visualDiffDarkTheme} from '../../test/test-utils'; + +suite('splash-page-action screenshot tests', () => { + let element: SplashPageAction; + + setup(async () => { + element = await fixture(html`<splash-page-action></splash-page-action>`); + await element.updateComplete; + }); + + test('card rendering', async () => { + const action: Action = { + id: 'test-action', + display_text: 'Test Action', + initial_user_prompt: 'Test prompt', + }; + element.action = action; + await element.updateComplete; + + await visualDiff(element, 'splash-page-action-card'); + await visualDiffDarkTheme(element, 'splash-page-action-card'); + }); + + test('details modal rendering', async () => { + const action: Action = { + id: 'test-action', + display_text: 'Test Action', + initial_user_prompt: 'Test prompt', + }; + element.action = action; + await element.updateComplete; + + // Trigger the modal to open + const infoButton = element.shadowRoot?.querySelector( + '.info-button' + ) as HTMLElement; + assert.isOk(infoButton); + infoButton.click(); + await element.updateComplete; + + const modal = element.shadowRoot?.querySelector( + '#detailsModal' + ) as HTMLElement; + assert.isOk(modal); + + await visualDiff(modal, 'splash-page-action-details-modal'); + await visualDiffDarkTheme(modal, 'splash-page-action-details-modal'); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts b/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts new file mode 100644 index 0000000..803694a --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/splash-page-action_test.ts
@@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './splash-page-action'; +import {SplashPageAction} from './splash-page-action'; +import {ChatModel, chatModelToken} from '../../models/chat/chat-model'; +import {Action} from '../../api/ai-code-review'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import { + chatActions, + chatProvider, + createChange, +} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('splash-page-action tests', () => { + let element: SplashPageAction; + let chatModel: ChatModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + + element = await fixture<SplashPageAction>( + html`<splash-page-action></splash-page-action>` + ); + }); + + test('renders with action', async () => { + const action: Action = { + id: 'test-action', + display_text: 'Test Action', + hover_text: 'Test hover', + subtext: 'Test subtext', + icon: 'test-icon', + initial_user_prompt: 'Test prompt', + }; + element.action = action; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="container"> + <md-assist-chip class="action-chip" title="Test hover"> + <div class="chip-content"> + <gr-icon class="action-icon" icon="test-icon"></gr-icon> + <div class="action-text-container"> + <div class="main-action-text-container has-subtext"> + <span class="action-text">Test Action</span> + </div> + + <span class="action-subtext">Test subtext</span> + </div> + </div> + </md-assist-chip> + <gr-tooltip-content + class="info-button-container" + has-tooltip="" + title="Capability details" + > + <gr-button + aria-disabled="false" + class="info-button" + flatten="" + role="button" + tabindex="0" + > + <gr-icon icon="info"></gr-icon> + </gr-button> + </gr-tooltip-content> + </div> + <dialog id="detailsModal" tabindex="-1"> + <div role="dialog" aria-labelledby="detailsTitle"> + <h3 class="heading-3 modalHeader" id="detailsTitle">Test Action</h3> + <div class="detailsContent"> + <div class="modal-row"> + <gr-icon icon="terminal"></gr-icon> + <div class="modal-row-content"> + <div class="modal-row-title">Instruction:</div> + <div class="modal-row-text">Test prompt</div> + </div> + </div> + </div> + <div class="modalActions"> + <gr-button + aria-disabled="false" + id="closeButton" + link="" + primary="" + role="button" + tabindex="0" + > + Close + </gr-button> + </div> + </div> + </dialog> + ` + ); + }); + + test('handles click with predefined prompt', async () => { + // Summarize action + element.action = chatActions.actions[1]; + await element.updateComplete; + + const chip = element.shadowRoot?.querySelector('md-assist-chip'); + assert.isOk(chip); + chip.click(); + + const turns = chatModel.getState().turns; + assert.lengthOf(turns, 1); + assert.equal(turns[0].userMessage.content, 'Summarize the change'); + }); + + test('handles click with user input', async () => { + // Freeform action + element.action = chatActions.actions[0]; + await element.updateComplete; + + const chip = element.shadowRoot?.querySelector('md-assist-chip'); + assert.isOk(chip); + chip.click(); + + const turns = chatModel.getState().turns; + assert.lengthOf(turns, 0); + }); + + test('opens details dialog on info button click', async () => { + element.action = { + id: 'test-action', + display_text: 'Test Action', + }; + await element.updateComplete; + + const infoButton = element.shadowRoot?.querySelector('.info-button'); + assert.isOk(infoButton); + + const dialog = element.shadowRoot?.querySelector( + '#detailsModal' + ) as HTMLDialogElement; + assert.isOk(dialog); + assert.isFalse(dialog.open); + + (infoButton as HTMLElement).click(); + await element.updateComplete; + + assert.isTrue(dialog.open); + + const turns = chatModel.getState().turns; + assert.lengthOf(turns, 0); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page.ts b/polygerrit-ui/app/elements/chat-panel/splash-page.ts new file mode 100644 index 0000000..f44c838 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/splash-page.ts
@@ -0,0 +1,385 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '@material/web/button/filled-tonal-button.js'; +import '@material/web/chips/chip-set.js'; +import '@material/web/icon/icon.js'; +import '@material/web/iconbutton/icon-button.js'; +import '@material/web/progress/circular-progress.js'; +import './gemini-message'; +import './splash-page-action'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {classMap} from 'lit/directives/class-map.js'; +import {when} from 'lit/directives/when.js'; + +import {Action} from '../../api/ai-code-review'; +import {chatModelToken, Turn} from '../../models/chat/chat-model'; +import {resolve} from '../../models/dependency'; +import {userModelToken} from '../../models/user/user-model'; +import {AccountDetailInfo, ServerInfo} from '../../types/common'; +import {subscribe} from '../lit/subscription-controller'; +import {getDisplayName} from '../../utils/display-name-util'; +import {materialStyles} from '../../styles/gr-material-styles'; + +/** + * A component for displaying a splash page when there are no chat messages. + */ +@customElement('splash-page') +export class SplashPage extends LitElement { + @state() account?: AccountDetailInfo; + + @state() isBackgroundRequestExpanded = false; + + @state() turns: readonly Turn[] = []; + + @state() actions: readonly Action[] = []; + + @state() customActions: readonly Action[] = []; + + @state() documentationUrl?: string; + + @state() capabilitiesLoaded = false; + + @property({type: Boolean}) isChangePrivate = false; + + private readonly getChatModel = resolve(this, chatModelToken); + + private readonly getUserModel = resolve(this, userModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getChatModel().turns$, + x => (this.turns = x ?? []) + ); + subscribe( + this, + () => this.getChatModel().actions$, + x => + (this.actions = (x ?? []).filter( + action => !!action.enable_splash_page_card + )) + ); + subscribe( + this, + () => this.getChatModel().customActions$, + x => (this.customActions = x ?? []) + ); + subscribe( + this, + () => this.getChatModel().state$, + state => { + this.documentationUrl = state.models?.documentation_url; + } + ); + subscribe( + this, + () => this.getChatModel().capabilitiesLoaded$, + x => (this.capabilitiesLoaded = x) + ); + subscribe( + this, + () => this.getUserModel().account$, + x => (this.account = x) + ); + } + + private get currentTurn(): Turn | undefined { + if (this.turns.length === 0) return undefined; + const turn = this.turns[this.turns.length - 1]; + return turn; + } + + get backgroundRequest(): Turn | undefined { + const turn = this.currentTurn; + return turn?.userMessage.isBackgroundRequest ? turn : undefined; + } + + static override styles = [ + materialStyles, + css` + :host { + overflow: auto; + padding-left: 20px; + padding-right: 20px; + padding-top: 20px; + } + .splash-container { + display: flex; + justify-content: center; + flex-flow: column nowrap; + } + .splash-greeting { + background: linear-gradient( + 135deg, + #217bfe 0, + #078efb 33%, + #ac87eb 100% + ); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + -webkit-box-orient: vertical; + color: transparent; + display: -webkit-inline-box; + font-size: 24px; + font-weight: 400; + margin-block-end: var(--spacing-s); + } + .material-icon { + color: var(--deemphasized-text-color); + } + .splash-question { + color: var(--chat-splash-page-question-color); + margin-bottom: 16px; + margin-top: 0px; + font-family: var(--header-font-family); + font-size: var(--font-size-h1); + font-weight: var(--font-weight-h1); + line-height: var(--line-height-h1); + } + .background-request-container { + background-color: var(--chat-splash-page-info-panel-bg-color); + padding: 15px; + border-radius: 15px; + margin-bottom: 12px; + display: flex; + flex-direction: column; + } + .background-request-container-inner { + position: relative; + max-height: 10em; + min-height: 10em; + overflow: hidden; + } + .background-request-container-inner.expanded { + max-height: none; + overflow: auto; + } + .background-request-container-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + to top, + var(--chat-splash-page-info-panel-bg-color), + transparent 50% + ); + } + .user-background-question { + font-family: var(--header-font-family); + font-size: var(--font-size-normal); + font-weight: var(--font-weight-normal); + line-height: var(--line-height-normal); + } + .expansion-button-container { + display: flex; + justify-content: center; + align-items: center; + } + .info-panel-expansion-button { + top: 10px; + font-size: 1.5em; + border: none; + background-color: transparent; + color: var(--primary-default); + cursor: pointer; + } + .background-request-header { + display: flex; + align-items: center; + gap: 5px; + margin-bottom: 12px; + font-weight: bold; + } + .background-request-header .thinking-spinner { + margin-left: auto; + } + .action-container { + background-color: transparent; + margin-bottom: 20px; + display: flex; + justify-content: center; + flex-wrap: wrap; + width: 100%; + } + .action-container-title { + height: 28px; + display: flex; + align-items: center; + vertical-align: middle; + font-size: 0.9em; + font-weight: 500; + color: var(--chat-splash-page-action-set-title-color); + } + .action-container-title .autoreview-run-all-button { + margin-left: auto; + margin-right: 8px; + } + /* TODO: find small-icon styles equivalent for .small-icon */ + `, + ]; + + override render() { + const config = {user: {anonymous_coward_name: ''}} as ServerInfo; + const displayName = getDisplayName(config, this.account); + return html` + <div class="splash-container"> + <h1 class="splash-greeting">Hello, ${displayName}</h1> + <p class="splash-question">How can I help you today?</p> + + ${this.renderContent()} + </div> + `; + } + + private renderContent() { + if (!this.capabilitiesLoaded) return ''; + if (this.isChangePrivate) { + return html` + <div class="background-request-container"> + <div class="background-request-header"> + <md-icon class="material-icon">info</md-icon> + <span class="user-background-question" + >Review Agent is disabled on private changes.</span + > + </div> + </div> + `; + } + return html` + ${this.renderBackgroundRequest()} ${this.renderCustomActions()} + ${this.renderActions()} + `; + } + + private renderCustomActions() { + if (!this.customActions || this.customActions.length === 0) { + return ''; + } + return html` + <div class="action-container-title autoreview-actions-title"> + Review capabilities + ${this.documentationUrl + ? html` + <a + href=${this.documentationUrl} + target="_blank" + rel="noopener noreferrer" + > + <gr-icon icon="info" class="small-icon"></gr-icon> + </a> + ` + : ''} + </div> + ${this.renderActionChipSet(this.customActions)} + `; + } + + private renderActions() { + if (!this.actions || this.actions.length === 0) return ''; + const title = + this.customActions.length > 0 ? 'Other capabilities' : 'Capabilities'; + return html` + <div class="action-container-title suggested-actions-title"> + ${title} + ${this.customActions.length > 0 && this.documentationUrl + ? html` + <a + href=${this.documentationUrl} + target="_blank" + rel="noopener noreferrer" + > + <gr-icon icon="info" class="small-icon"></gr-icon> + </a> + ` + : ''} + </div> + + ${this.renderActionChipSet(this.actions)} + `; + } + + private renderBackgroundRequest() { + const request = this.backgroundRequest; + if (!request) return; + return html` + <div class="background-request-container"> + <div + class="background-request-container-inner ${classMap({ + expanded: this.isBackgroundRequestExpanded, + })}" + > + <div class="background-request-header"> + <md-icon class="material-icon">lightbulb_tips</md-icon> + <span class="user-background-question" + >${request.userMessage.content}</span + > + ${when( + !request.geminiMessage.responseComplete, + () => html`<md-circular-progress + class="thinking-spinner" + indeterminate + size="17" + ></md-circular-progress>` + )} + </div> + <gemini-message + .isBackgroundRequest=${true} + .isLatest=${true} + .turnIndex=${0} + ></gemini-message> + ${when( + !this.isBackgroundRequestExpanded, + () => html`<div class="background-request-container-overlay"></div>` + )} + </div> + <div class="expansion-button-container"> + <button + class="info-panel-expansion-button" + title=${this.isBackgroundRequestExpanded ? 'Hide' : 'Show more'} + aria-label=${this.isBackgroundRequestExpanded + ? 'Hide' + : 'Show more'} + @click=${this.toggleBackgroundRequestExpansion} + > + ${this.isBackgroundRequestExpanded ? '–' : '...'} + </button> + </div> + </div> + `; + } + + private renderActionChipSet(actions: readonly Action[]) { + return html` + <md-chip-set class="action-container"> + ${actions.map( + (action, index, array) => html` + <splash-page-action + .action=${action} + .isFirst=${index === 0} + .isLast=${index === array.length - 1} + ></splash-page-action> + ` + )} + </md-chip-set> + `; + } + + protected toggleBackgroundRequestExpansion() { + this.isBackgroundRequestExpanded = !this.isBackgroundRequestExpanded; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'splash-page': SplashPage; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts b/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts new file mode 100644 index 0000000..825eb01 --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/splash-page_test.ts
@@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './splash-page'; +import {SplashPage} from './splash-page'; +import { + ChatModel, + chatModelToken, + Turn, + UserType, +} from '../../models/chat/chat-model'; +import {Action} from '../../api/ai-code-review'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import {chatProvider, createChange} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; +import {userModelToken} from '../../models/user/user-model'; +import {AccountDetailInfo} from '../../types/common'; +import {UserModel} from '../../models/user/user-model'; + +suite('splash-page tests', () => { + let element: SplashPage; + let chatModel: ChatModel; + let userModel: UserModel; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + userModel = testResolver(userModelToken); + + element = await fixture<SplashPage>(html`<splash-page></splash-page>`); + }); + + test('render', () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="splash-container"> + <h1 class="splash-greeting">Hello,</h1> + <p class="splash-question">How can I help you today?</p> + <div class="action-container-title suggested-actions-title"> + Capabilities + </div> + <md-chip-set class="action-container"> + <splash-page-action></splash-page-action> + </md-chip-set> + </div> + ` + ); + }); + + test('displays user name', async () => { + userModel.updateState({ + account: {display_name: 'Test User'} as AccountDetailInfo, + }); + await element.updateComplete; + const greeting = element.shadowRoot!.querySelector('.splash-greeting'); + assert.dom.equal( + greeting, + '<h1 class="splash-greeting">Hello, Test User</h1>' + ); + }); + + test('renders actions', async () => { + const actions: Action[] = [ + {id: 'action1', display_text: 'Action 1', enable_splash_page_card: true}, + {id: 'action2', display_text: 'Action 2', enable_splash_page_card: true}, + ]; + chatModel.updateState({ + ...chatModel.getState(), + actions: {actions, default_action_id: 'action1'}, + }); + await element.updateComplete; + const actionElements = + element.shadowRoot!.querySelectorAll('splash-page-action'); + assert.lengthOf(actionElements, 2); + }); + + test('renders background request', async () => { + const turns: Turn[] = [ + { + userMessage: { + userType: UserType.USER, + content: 'Test background request', + isBackgroundRequest: true, + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex: 0, + responseComplete: false, + references: [], + citations: [], + }, + }, + ]; + chatModel.updateState({...chatModel.getState(), turns}); + await element.updateComplete; + const backgroundRequestContainer = element.shadowRoot!.querySelector( + '.background-request-container' + ); + assert.isOk(backgroundRequestContainer); + }); + + test('toggles background request expansion', async () => { + const turns: Turn[] = [ + { + userMessage: { + userType: UserType.USER, + content: 'Test background request', + isBackgroundRequest: true, + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex: 0, + responseComplete: false, + references: [], + citations: [], + }, + }, + ]; + chatModel.updateState({...chatModel.getState(), turns}); + await element.updateComplete; + + const expansionButton = element.shadowRoot!.querySelector( + '.info-panel-expansion-button' + ) as HTMLElement; + assert.isOk(expansionButton); + + const innerContainer = element.shadowRoot!.querySelector( + '.background-request-container-inner' + ); + assert.isFalse(innerContainer!.classList.contains('expanded')); + + expansionButton.click(); + await element.updateComplete; + assert.isTrue(innerContainer!.classList.contains('expanded')); + + expansionButton.click(); + await element.updateComplete; + assert.isFalse(innerContainer!.classList.contains('expanded')); + }); + + test('clicking action starts new chat', async () => { + const actionElement = + element.shadowRoot!.querySelector('splash-page-action')!; + await actionElement.updateComplete; + const chip = actionElement.shadowRoot!.querySelector('md-assist-chip')!; + assert.equal(chip.textContent?.trim(), 'Summarize'); + chip.click(); + + await element.updateComplete; + const turns = chatModel.getState().turns; + assert.lengthOf(turns, 1); + assert.equal(turns[0].userMessage.content, 'Summarize the change'); + assert.equal(turns[0].userMessage.userType, UserType.USER); + }); + test('renders private change message', async () => { + element.isChangePrivate = true; + await element.updateComplete; + assert.shadowDom.equal( + element, + ` + <div class="splash-container"> + <h1 class="splash-greeting">Hello, </h1> + <p class="splash-question">How can I help you today?</p> + <div class="background-request-container"> + <div class="background-request-header"> + <md-icon aria-hidden="true" class="material-icon">info</md-icon> + <span class="user-background-question" + >Review Agent is disabled on private changes.</span + > + </div> + </div> + </div> + ` + ); + }); +});
diff --git a/polygerrit-ui/app/elements/chat-panel/user-message.ts b/polygerrit-ui/app/elements/chat-panel/user-message.ts new file mode 100644 index 0000000..4dc7aef --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/user-message.ts
@@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import './context-chip'; +import '@material/web/chips/filter-chip.js'; + +import {css, html, LitElement} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {map} from 'lit/directives/map.js'; +import {when} from 'lit/directives/when.js'; + +import {ContextItem} from '../../api/ai-code-review'; +import {UserMessage as UserMessageState} from '../../models/chat/chat-model.js'; +import {resolve} from '../../models/dependency'; +import {userModelToken} from '../../models/user/user-model'; +import {AccountDetailInfo} from '../../types/common'; +import {subscribe} from '../lit/subscription-controller'; +import {materialStyles} from '../../styles/gr-material-styles'; + +const MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED = 3; + +/** + * A component to display a single message sent by the user in the chat + * conversation. This includes the user's textual input and any associated + * context items (e.g., code snippets, file paths, or suggestions) that were + * part of the message. + */ +@customElement('user-message') +export class UserMessage extends LitElement { + @property({type: Object}) message!: UserMessageState; + + @state() account?: AccountDetailInfo; + + @state() showAllContextItems = false; + + private readonly getUserModel = resolve(this, userModelToken); + + constructor() { + super(); + subscribe( + this, + () => this.getUserModel().account$, + x => (this.account = x) + ); + } + + private get content() { + return this.message.content; + } + + private get contextItems() { + return this.message.contextItems; + } + + private toggleShowAllContext() { + this.showAllContextItems = !this.showAllContextItems; + } + + private get regularContextItems() { + return this.contextItems.filter(item => !!item.title); + } + + private get numExcessContextItems() { + return Math.max( + 0, + this.regularContextItems.length - MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED + ); + } + + private get shouldShowContextToggle() { + return this.numExcessContextItems > 0; + } + + private get contextToggleTooltip() { + return this.showAllContextItems + ? 'Collapse' + : `${this.numExcessContextItems} additional items or suggestions`; + } + + private get contextToggleText() { + return this.showAllContextItems ? '▲' : `+${this.numExcessContextItems}`; + } + + static override styles = [ + materialStyles, + css` + :host { + display: flex; + flex-direction: column; + padding-bottom: var(--spacing-xl); + } + + .user-input-container { + padding-top: var(--spacing-m); + } + + .text-content { + white-space: pre-wrap; + margin: 0px; + } + + .context-chip-set { + margin-top: var(--spacing-m); + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + } + + gr-avatar { + display: block; + height: 24px; + width: 24px; + margin-right: 10px; + } + + md-filter-chip.context-toggle-chip { + margin: 0; + margin-left: auto; + --md-filter-chip-unselected-outline-color: var(--border-color); + --md-filter-chip-unselected-container-color: var(--elevation-2); + --md-filter-chip-container-shape: 8px; + --md-filter-chip-container-height: 20px; + + /* from @include mat.chips-overrides */ + --md-filter-chip-label-text-line-height: var( + --line-height-small, + 1.25rem + ); + --md-filter-chip-label-text-size: var(--font-size-small, 0.75rem); + --md-filter-chip-label-text-weight: var(--font-weight-medium, 500); + --md-filter-chip-label-text-color: var(--primary-default, purple); + cursor: pointer; + } + `, + ]; + + override render() { + if (!this.message) { + return html``; + } + return html` + <div class="user-info"> + <gr-avatar .account=${this.account} .imageSize=${32}></gr-avatar> + </div> + <div class="user-input-container"> + <p class="text-content">${this.content}</p> + <div class="context-chip-set"> + ${map( + this.showAllContextItems + ? this.regularContextItems + : this.regularContextItems.slice( + 0, + MAX_VISIBLE_CONTEXT_ITEMS_COLLAPSED + ), + (contextItem: ContextItem) => html` + <context-chip + class="external-context" + .text=${contextItem.title} + .contextItem=${contextItem} + .isRemovable=${false} + .tooltip=${contextItem.tooltip} + ></context-chip> + ` + )} + ${when( + this.shouldShowContextToggle, + () => html` + <md-filter-chip + class="context-toggle-chip" + .label=${this.contextToggleText} + .title=${this.contextToggleTooltip} + @click=${this.toggleShowAllContext} + ></md-filter-chip> + ` + )} + </div> + </div> + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'user-message': UserMessage; + } +}
diff --git a/polygerrit-ui/app/elements/chat-panel/user-message_test.ts b/polygerrit-ui/app/elements/chat-panel/user-message_test.ts new file mode 100644 index 0000000..fb8732f --- /dev/null +++ b/polygerrit-ui/app/elements/chat-panel/user-message_test.ts
@@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import '../shared/gr-avatar/gr-avatar'; +import './user-message'; +import {assert, fixture, html} from '@open-wc/testing'; +import {UserMessage} from './user-message'; +import { + ChatModel, + chatModelToken, + UserMessage as UserMessageState, + UserType, +} from '../../models/chat/chat-model'; +import {ContextItem} from '../../api/ai-code-review'; +import {MdFilterChip} from '@material/web/chips/filter-chip'; +import {testResolver} from '../../test/common-test-setup'; +import {pluginLoaderToken} from '../shared/gr-js-api-interface/gr-plugin-loader'; +import { + chatContextItemTypes, + chatProvider, + createAccountDetailWithIdNameAndEmail, + createChange, +} from '../../test/test-data-generators'; +import {changeModelToken} from '../../models/change/change-model'; +import {ParsedChangeInfo} from '../../types/types'; +import {userModelToken} from '../../models/user/user-model'; +import {GrAvatar} from '../shared/gr-avatar/gr-avatar'; + +suite('user-message tests', () => { + let element: UserMessage; + let chatModel: ChatModel; + + const message: UserMessageState = { + userType: UserType.USER, + content: 'Hello, world!', + contextItems: [], + }; + + setup(async () => { + const pluginLoader = testResolver(pluginLoaderToken); + pluginLoader.pluginsModel.aiCodeReviewRegister({ + pluginName: 'test-plugin', + provider: chatProvider, + }); + + const changeModel = testResolver(changeModelToken); + changeModel.updateState({ + change: createChange() as ParsedChangeInfo, + }); + + chatModel = testResolver(chatModelToken); + + // Set context items for chatModel before initial render + chatModel.updateState({ + ...chatModel.getState(), + contextItemTypes: chatContextItemTypes, + }); + + element = await fixture( + html`<user-message .message=${message}></user-message>` + ); + await element.updateComplete; + }); + + test('renders', async () => { + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="user-info"><gr-avatar hidden=""></gr-avatar></div> + <div class="user-input-container"> + <p class="text-content">Hello, world!</p> + <div class="context-chip-set"></div> + </div> + ` + ); + }); + + test('renders with content', async () => { + const content = element.shadowRoot?.querySelector('.text-content'); + assert.equal(content?.textContent?.trim(), 'Hello, world!'); + }); + + test('renders with account', async () => { + const userModel = testResolver(userModelToken); + userModel.updateState({ + account: createAccountDetailWithIdNameAndEmail(123), + }); + await element.updateComplete; + + const avatar = element.shadowRoot?.querySelector('gr-avatar') as GrAvatar; + assert.isOk(avatar); + assert.isOk(avatar.account); + assert.equal(avatar.account?.name, 'User-123'); + }); + + test('renders context items', async () => { + const contextItems: ContextItem[] = [ + {type_id: 'file', title: 'file1.ts', link: 'link1'}, + {type_id: 'file', title: 'file2.ts', link: 'link2'}, + ]; + element.message = {...message, contextItems}; + await element.updateComplete; + + const chips = element.shadowRoot?.querySelectorAll('context-chip'); + assert.equal(chips?.length, 2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.equal((chips![0] as any).text, 'file1.ts'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.equal((chips![1] as any).text, 'file2.ts'); + }); + + test('toggles context items', async () => { + const contextItems: ContextItem[] = [ + {type_id: 'file', title: 'file1.ts', link: 'link1'}, + {type_id: 'file', title: 'file2.ts', link: 'link2'}, + {type_id: 'file', title: 'file3.ts', link: 'link3'}, + {type_id: 'file', title: 'file4.ts', link: 'link4'}, + ]; + element.message = {...message, contextItems}; + await element.updateComplete; + + let chips = element.shadowRoot?.querySelectorAll('context-chip'); + assert.equal(chips?.length, 3); + + const toggleChip = element.shadowRoot?.querySelector( + '.context-toggle-chip' + ) as MdFilterChip; + assert.isOk(toggleChip); + assert.equal(toggleChip.label, '+1'); + + toggleChip.click(); + await element.updateComplete; + + chips = element.shadowRoot?.querySelectorAll('context-chip'); + assert.equal(chips?.length, 4); + assert.equal(toggleChip.label, '▲'); + }); +});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-action_test.ts new file mode 100644 index 0000000..1ed783c --- /dev/null +++ b/polygerrit-ui/app/elements/checks/gr-checks-action_test.ts
@@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {assert, fixture, html} from '@open-wc/testing'; +import '../../test/common-test-setup'; +import './gr-checks-action'; +import {GrChecksAction} from './gr-checks-action'; +import {Action} from '../../api/checks'; + +suite('gr-checks-action', () => { + let element: GrChecksAction; + + setup(async () => { + element = await fixture<GrChecksAction>( + html`<gr-checks-action + .action=${{name: 'test-action'} as Action} + ></gr-checks-action>` + ); + }); + + test('render', async () => { + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ' <gr-button class="action" link=""> test-action </gr-button> ' + ); + }); +});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts index 9677185..bba4527 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -9,7 +9,7 @@ import {ordinal} from '../../utils/string-util'; @customElement('gr-checks-attempt') -class GrChecksAttempt extends LitElement { +export class GrChecksAttempt extends LitElement { @property({attribute: false}) run?: CheckRun;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt_test.ts new file mode 100644 index 0000000..26023f0 --- /dev/null +++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt_test.ts
@@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {assert, fixture, html} from '@open-wc/testing'; +import '../../test/common-test-setup'; +import './gr-checks-attempt'; +import {GrChecksAttempt} from './gr-checks-attempt'; +import {CheckRun} from '../../models/checks/checks-model'; + +suite('gr-checks-attempt', () => { + let element: GrChecksAttempt; + + setup(async () => { + element = await fixture<GrChecksAttempt>( + html`<gr-checks-attempt></gr-checks-attempt>` + ); + }); + + test('render nothing if run is undefined', async () => { + await element.updateComplete; + assert.shadowDom.equal(element, ''); + }); + + test('render nothing if isSingleAttempt', async () => { + element.run = { + isSingleAttempt: true, + attempt: 1, + } as CheckRun; + await element.updateComplete; + assert.shadowDom.equal(element, ''); + }); + + test('render nothing if attempt is missing', async () => { + element.run = { + isSingleAttempt: false, + } as CheckRun; + await element.updateComplete; + assert.shadowDom.equal(element, ''); + }); + + test('renders attempt number if not single attempt', async () => { + element.run = { + isSingleAttempt: false, + attempt: 2, + } as CheckRun; + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <span class="attempt"> + <div class="box">2nd</div> + <div class="angle">2nd</div> + </span> + ` + ); + }); +});
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts index a808c69..020be1b 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -19,6 +19,7 @@ import './gr-checks-action'; import './gr-hovercard-run'; import '../shared/gr-tooltip-content/gr-tooltip-content'; +import {KnownExperimentId} from '../../services/flags/flags'; import { Action, Category, @@ -422,6 +423,9 @@ </tr> `; } + const aiIcon = this.result.isAiPowered + ? html`<gr-icon small icon="ai"></gr-icon>` + : nothing; return html` <tr class=${classMap({container: true, collapsed: !this.isExpanded})}> <td class="nameCol" @click=${this.toggleExpandedClick}> @@ -434,7 +438,7 @@ @click=${this.toggleExpandedClick} @keydown=${this.toggleExpandedPress} > - ${this.result.checkName} + ${this.result.checkName} ${aiIcon} </div> ${this.renderAttempt()} <div class="space"></div> @@ -625,6 +629,9 @@ } if ( + getAppContext().flagsService.isEnabled( + KnownExperimentId.ML_SUGGESTED_EDIT_GET_FIX + ) && this.getSuggestionsService()?.isGeneratedSuggestedFixEnabled( this.result?.codePointers?.[0]?.path ) && @@ -794,6 +801,9 @@ .useful gr-checks-action { display: block; } + .ai-generated { + font-weight: var(--font-weight-medium); + } `, ]; } @@ -803,6 +813,7 @@ return html` ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()} ${this.renderSecondaryLinks()} ${this.renderCodePointers()} + ${this.renderAiLabel()} <gr-endpoint-decorator name="check-result-expanded" .targetPlugin=${this.result.pluginName} @@ -822,6 +833,16 @@ `; } + private renderAiLabel() { + if (!this.result?.isAiPowered) { + return nothing; + } + return html`<div> + <gr-icon small icon="ai"></gr-icon> + <span class="ai-generated">AI Generated</span> by ${this.result.checkName} + </div>`; + } + private renderFirstPrimaryLink() { const link = firstPrimaryLink(this.result); if (!link) return;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_screenshot_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_screenshot_test.ts index 9838604..b9fdc45 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-results_screenshot_test.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-results_screenshot_test.ts
@@ -11,7 +11,7 @@ // @ts-ignore import {visualDiff} from '@web/test-runner-visual-regression'; import {checksModelToken} from '../../models/checks/checks-model'; -import {setAllFakeRuns} from '../../models/checks/checks-fakes'; +import {setAllcheckRuns} from '../../test/test-data-generators'; import {resolve} from '../../models/dependency'; import {GrChecksResults} from './gr-checks-results'; import {visualDiffDarkTheme} from '../../test/test-utils'; @@ -27,7 +27,7 @@ getChecksModel().allRunsSelectedPatchset$.subscribe( runs => (element.runs = runs) ); - setAllFakeRuns(getChecksModel()); + setAllcheckRuns(getChecksModel()); await element.updateComplete; });
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 16595d9..0c93d1f 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -14,13 +14,14 @@ import {assert, fixture} from '@open-wc/testing'; import {checksModelToken, RunResult} from '../../models/checks/checks-model'; import { - fakeRun0, - fakeRun1, - setAllFakeRuns, -} from '../../models/checks/checks-fakes'; + checkRun0, + checkRun1, + setAllcheckRuns, +} from '../../test/test-data-generators'; import {resolve} from '../../models/dependency'; import {createLabelInfo} from '../../test/test-data-generators'; import {assertIsDefined, query, queryAndAssert} from '../../utils/common-util'; +import {stubFlags} from '../../test/test-utils'; import {PatchSetNumber} from '../../api/rest-api'; import {GrDropdownList} from '../shared/gr-dropdown-list/gr-dropdown-list'; @@ -28,11 +29,12 @@ let element: GrResultRow; setup(async () => { - const result = {...fakeRun0, ...fakeRun0.results![0]}; + const result = {...checkRun0, ...checkRun0.results![0]}; element = await fixture<GrResultRow>( html`<gr-result-row .result=${result}></gr-result-row>` ); element.shouldRender = true; + stubFlags('isEnabled').returns(true); }); test('renders label association', async () => { @@ -67,6 +69,7 @@ <gr-hovercard-run> </gr-hovercard-run> <div class="name" role="button" tabindex="0"> FAKE Error Finder Finder Finder Finder Finder Finder Finder + <gr-icon custom="" icon="ai" small=""> </gr-icon> </div> <div class="space"></div> </div> @@ -132,6 +135,13 @@ ); }); + test('renders isAiPowered', async () => { + element.result = {...element.result!, isAiPowered: true}; + await element.updateComplete; + const aiIcon = queryAndAssert(element, 'gr-icon[icon="ai"]'); + assert.isOk(aiIcon); + }); + test('click summary, toggle expand', async () => { element.isExpandable = true; await element.updateComplete; @@ -160,7 +170,7 @@ }); test('renders fake result 1 of run 0', async () => { - element.result = {...fakeRun0, ...fakeRun0.results![1]} as RunResult; + element.result = {...checkRun0, ...checkRun0.results![1]} as RunResult; await element.updateComplete; assert.shadowDom.equal( @@ -242,6 +252,11 @@ <span> Link to image </span> </a> </div> + <div> + <gr-icon custom="" icon="ai" small=""> </gr-icon> + <span class="ai-generated"> AI Generated </span> + by FAKE Error Finder Finder Finder Finder Finder Finder Finder + </div> <gr-endpoint-decorator name="check-result-expanded"> <gr-endpoint-param name="run"> </gr-endpoint-param> <gr-endpoint-param name="result"> </gr-endpoint-param> @@ -257,13 +272,18 @@ }); test('renders fake result 2 of run 1', async () => { - element.result = {...fakeRun1, ...fakeRun1.results![2]} as RunResult; + element.result = {...checkRun1, ...checkRun1.results![2]} as RunResult; await element.updateComplete; assert.shadowDom.equal( element, /* HTML */ ` <div class="links"></div> + <div> + <gr-icon custom="" icon="ai" small=""> </gr-icon> + <span class="ai-generated"> AI Generated </span> + by FAKE Super Check + </div> <gr-endpoint-decorator name="check-result-expanded"> <gr-endpoint-param name="run"> </gr-endpoint-param> <gr-endpoint-param name="result"> </gr-endpoint-param> @@ -291,7 +311,7 @@ getChecksModel().allRunsSelectedPatchset$.subscribe( runs => (element.runs = runs) ); - setAllFakeRuns(getChecksModel()); + setAllcheckRuns(getChecksModel()); await element.updateComplete; });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts index bc4398f..a9a4147 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -22,33 +22,22 @@ iconFor, iconForRun, LATEST_ATTEMPT, - PRIMARY_STATUS_ACTIONS, - primaryRunAction, + primaryAction, + primaryTriggerAction, + TRIGGER_STATUS_ACTIONS, + triggerAction, } from '../../models/checks/checks-util'; import { CheckRun, ChecksPatchset, ErrorMessages, } from '../../models/checks/checks-model'; -import { - clearAllFakeRuns, - fakeActions, - fakeLinks, - fakeRun0, - fakeRun1, - fakeRun2, - fakeRun3, - fakeRun4Att, - fakeRun5, - setAllFakeRuns, -} from '../../models/checks/checks-fakes'; import {assertIsDefined} from '../../utils/common-util'; import {modifierPressed, whenVisible} from '../../utils/dom-util'; import {fireRunSelected, RunSelectedEvent} from './gr-checks-util'; import {ChecksTabState} from '../../types/events'; import {charsOnly} from '../../utils/string-util'; import {getAppContext} from '../../services/app-context'; -import {KnownExperimentId} from '../../services/flags/flags'; import {subscribe} from '../lit/subscription-controller'; import {fontStyles} from '../../styles/gr-font-styles'; import {durationString} from '../../utils/date-util'; @@ -255,7 +244,13 @@ selected: this.selected, deselected: this.deselected, }; - const action = primaryRunAction(this.run); + const action = + primaryTriggerAction(this.run) ?? + primaryAction(this.run) ?? + triggerAction(this.run); + const aiIcon = this.run.isAiPowered + ? html`<gr-icon small icon="ai"></gr-icon>` + : nothing; return html` <div @@ -274,7 +269,7 @@ ></gr-icon> ${this.renderAdditionalIcon()} <span class="name">${this.run.checkName}</span> - ${this.renderETA()} + ${aiIcon} ${this.renderETA()} </div> <div class="middle"> <gr-checks-attempt .run=${this.run}></gr-checks-attempt> @@ -459,8 +454,6 @@ private isSectionExpanded = new Map<RunStatus, boolean>(); - private flagService = getAppContext().flagsService; - private getChecksModel = resolve(this, checksModelToken); private readonly getViewModel = resolve(this, changeViewModelToken); @@ -659,7 +652,7 @@ /> ${this.renderSection(RunStatus.RUNNING)} ${this.renderSection(RunStatus.COMPLETED)} - ${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()} + ${this.renderSection(RunStatus.RUNNABLE)} `; } @@ -712,12 +705,12 @@ const run = this.runs.find( run => run.isLatestAttempt && run.checkName === selected ); - return primaryRunAction(run); + return triggerAction(run); }); const runButtonDisabled = !actions.every( action => - action?.name === PRIMARY_STATUS_ACTIONS.RUN || - action?.name === PRIMARY_STATUS_ACTIONS.RERUN + action?.name === TRIGGER_STATUS_ACTIONS.RUN || + action?.name === TRIGGER_STATUS_ACTIONS.RERUN ); return html` <gr-button @@ -891,43 +884,6 @@ if (this.collapsed) return false; return this.runs.length > 10 || !!this.filterRegExp; } - - renderFakeControls() { - if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return; - if (this.collapsed) return; - return html` - <div class="testing"> - <div>Toggle fake runs by clicking buttons:</div> - <gr-button link @click=${() => clearAllFakeRuns(this.getChecksModel())} - >None</gr-button - > - <gr-button - link - @click=${() => - this.toggle('f0', [fakeRun0], fakeActions, fakeLinks, 'ETA: 1 min')} - >0</gr-button - > - <gr-button link @click=${() => this.toggle('f1', [fakeRun1])} - >1</gr-button - > - <gr-button link @click=${() => this.toggle('f2', [fakeRun2])} - >2</gr-button - > - <gr-button link @click=${() => this.toggle('f3', [fakeRun3])} - >3</gr-button - > - <gr-button link @click=${() => this.toggle('f4', fakeRun4Att)} - >4</gr-button - > - <gr-button link @click=${() => this.toggle('f5', [fakeRun5])} - >5</gr-button - > - <gr-button link @click=${() => setAllFakeRuns(this.getChecksModel())} - >all</gr-button - > - </div> - `; - } } declare global {
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 871a598..88a1b0b 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-runs_test.ts
@@ -9,9 +9,10 @@ import {html} from 'lit'; import {assert, fixture} from '@open-wc/testing'; import {checksModelToken} from '../../models/checks/checks-model'; -import {fakeRun0, setAllFakeRuns} from '../../models/checks/checks-fakes'; +import {checkRun0, setAllcheckRuns} from '../../test/test-data-generators'; import {resolve} from '../../models/dependency'; import {queryAll} from '../../utils/common-util'; +import {RunStatus} from '../../api/checks'; suite('gr-checks-runs test', () => { let element: GrChecksRuns; @@ -21,7 +22,7 @@ html`<gr-checks-runs></gr-checks-runs>` ); const getChecksModel = resolve(element, checksModelToken); - setAllFakeRuns(getChecksModel()); + setAllcheckRuns(getChecksModel()); element.errorMessages = {'test-plugin-name': 'test-error-message'}; await element.updateComplete; }); @@ -30,7 +31,7 @@ // Without a filter all 6 fake runs (0-5) will be rendered. assert.equal(queryAll(element, 'gr-checks-run').length, 6); - // This filter will only match fakeRun2 (checkName: 'FAKE Mega Analysis'). + // This filter will only match checkRun2 (checkName: 'FAKE Mega Analysis'). element.filterRegExp = 'Mega'; await element.updateComplete; assert.equal(queryAll(element, 'gr-checks-run').length, 1); @@ -187,7 +188,7 @@ setup(async () => { element = await fixture<GrChecksRun>(html`<gr-checks-run></gr-checks-run>`); const getChecksModel = resolve(element, checksModelToken); - setAllFakeRuns(getChecksModel()); + setAllcheckRuns(getChecksModel()); await element.updateComplete; }); @@ -198,9 +199,9 @@ ); }); - test('renders fakeRun0', async () => { + test('renders checkRun0', async () => { element.shouldRender = true; - element.run = fakeRun0; + element.run = checkRun0; await element.updateComplete; assert.shadowDom.equal( element, @@ -212,6 +213,7 @@ <span class="name"> FAKE Error Finder Finder Finder Finder Finder Finder Finder </span> + <gr-icon custom="" icon="ai" small=""> </gr-icon> </div> <div class="middle"> <gr-checks-attempt> </gr-checks-attempt> @@ -244,4 +246,115 @@ ` ); }); + + suite('actions', () => { + test('renders primary trigger action when available', async () => { + element.shouldRender = true; + element.run = { + ...checkRun0, + status: RunStatus.COMPLETED, + actions: [ + { + name: 'rerun', + primary: true, + callback: () => Promise.resolve({message: 'rerun'}), + }, + { + name: 'other', + primary: true, + callback: () => Promise.resolve({message: 'other'}), + }, + ], + }; + await element.updateComplete; + const action = element.shadowRoot?.querySelector('gr-checks-action'); + assert.isOk(action); + assert.equal(action?.action?.name, 'rerun'); + }); + + test('renders primary action when no primary trigger action', async () => { + element.shouldRender = true; + element.run = { + ...checkRun0, + status: RunStatus.COMPLETED, + actions: [ + { + name: 'custom-action', + primary: true, + callback: () => Promise.resolve({message: 'custom'}), + }, + { + name: 'rerun', + primary: false, + callback: () => Promise.resolve({message: 'rerun'}), + }, + ], + }; + await element.updateComplete; + const action = element.shadowRoot?.querySelector('gr-checks-action'); + assert.isOk(action); + assert.equal(action?.action?.name, 'custom-action'); + }); + + test('renders trigger action when no primary actions', async () => { + element.shouldRender = true; + element.run = { + ...checkRun0, + status: RunStatus.COMPLETED, + actions: [ + { + name: 'rerun', + primary: false, + callback: () => Promise.resolve({message: 'rerun'}), + }, + { + name: 'other', + primary: false, + callback: () => Promise.resolve({message: 'other'}), + }, + ], + }; + await element.updateComplete; + const action = element.shadowRoot?.querySelector('gr-checks-action'); + assert.isOk(action); + assert.equal(action?.action?.name, 'rerun'); + }); + + test('renders no action when none available', async () => { + element.shouldRender = true; + element.run = { + ...checkRun0, + status: RunStatus.COMPLETED, + actions: [], + }; + await element.updateComplete; + const action = element.shadowRoot?.querySelector('gr-checks-action'); + assert.isNotOk(action); + }); + + test('skips disabled actions', async () => { + element.shouldRender = true; + element.run = { + ...checkRun0, + status: RunStatus.COMPLETED, + actions: [ + { + name: 'rerun', + primary: true, + disabled: true, + callback: () => Promise.resolve({message: 'rerun'}), + }, + { + name: 'other', + primary: true, + callback: () => Promise.resolve({message: 'other'}), + }, + ], + }; + await element.updateComplete; + const action = element.shadowRoot?.querySelector('gr-checks-action'); + assert.isOk(action); + assert.equal(action?.action?.name, 'other'); + }); + }); });
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts index 6284892..3f9c42b 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
@@ -5,8 +5,6 @@ */ import {css} from 'lit'; -const $_documentContainer = document.createElement('template'); - export const checksStyles = css` gr-icon.error { color: var(--error-foreground); @@ -21,13 +19,3 @@ color: var(--success-foreground); } `; - -$_documentContainer.innerHTML = `<dom-module id="gr-checks-styles"> - <template> - <style> - ${checksStyles.cssText} - </style> - </template> -</dom-module>`; - -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts index b0f48cd..f83466f 100644 --- a/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts +++ b/polygerrit-ui/app/elements/checks/gr-checks-tab_test.ts
@@ -9,7 +9,7 @@ import {GrChecksTab} from './gr-checks-tab'; import {assert, fixture} from '@open-wc/testing'; import {checksModelToken} from '../../models/checks/checks-model'; -import {setAllFakeRuns} from '../../models/checks/checks-fakes'; +import {setAllcheckRuns} from '../../test/test-data-generators'; import {resolve} from '../../models/dependency'; suite('gr-checks-tab test', () => { @@ -18,7 +18,7 @@ setup(async () => { element = await fixture<GrChecksTab>(html`<gr-checks-tab></gr-checks-tab>`); const getChecksModel = resolve(element, checksModelToken); - setAllFakeRuns(getChecksModel()); + setAllcheckRuns(getChecksModel()); }); test('renders', async () => {
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 aa6c957..ef9c082 100644 --- a/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts +++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result.ts
@@ -6,6 +6,7 @@ import '../shared/gr-icon/gr-icon'; import {css, html, LitElement, nothing, PropertyValues} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; +import {KnownExperimentId} from '../../services/flags/flags'; import {RunResult} from '../../models/checks/checks-model'; import { computeIsExpandable, @@ -156,6 +157,15 @@ display: flex; justify-content: flex-end; } + .ai-icon-wrapper { + margin-right: var(--spacing-s); + } + .container .ai-icon-wrapper gr-icon { + font-size: calc(var(--line-height-normal) - 4px); + position: relative; + top: 2px; + color: var(--deemphasized-text-color); + } `, ]; } @@ -190,6 +200,11 @@ if (!this.result) return; const cat = this.result.category.toLowerCase(); const icon = iconFor(this.result.category); + const aiIcon = this.result.isAiPowered + ? html`<div class="ai-icon-wrapper"> + <gr-icon small icon="ai"></gr-icon> + </div>` + : nothing; return html` <div class="${cat} container font-normal"> <div class="header" @click=${this.toggleExpandedClick}> @@ -207,6 +222,7 @@ ${this.result.checkName} </div> </div> + ${aiIcon} <!-- The is for being able to shrink a tiny amount without the text itself getting shrunk with an ellipsis. --> <div class="summary">${this.result.summary} </div> @@ -286,6 +302,13 @@ private shouldShowAIFixButton() { if ( + !getAppContext().flagsService.isEnabled( + KnownExperimentId.ML_SUGGESTED_EDIT_GET_FIX + ) + ) { + return false; + } + if ( !this.getSuggestionsService()?.isGeneratedSuggestedFixEnabled( this.result?.codePointers?.[0].path )
diff --git a/polygerrit-ui/app/elements/checks/gr-diff-check-result_screenshot_test.ts b/polygerrit-ui/app/elements/checks/gr-diff-check-result_screenshot_test.ts index 0e6e9e2..7ef3577 100644 --- a/polygerrit-ui/app/elements/checks/gr-diff-check-result_screenshot_test.ts +++ b/polygerrit-ui/app/elements/checks/gr-diff-check-result_screenshot_test.ts
@@ -11,7 +11,7 @@ import {visualDiffDarkTheme} from '../../test/test-utils'; import {GrDiffCheckResult} from './gr-diff-check-result'; import './gr-diff-check-result'; -import {fakeRun1} from '../../models/checks/checks-fakes'; +import {checkRun1} from '../../test/test-data-generators'; import {RunResult} from '../../models/checks/checks-model'; suite('gr-diff-check-result screenshot tests', () => { @@ -24,7 +24,7 @@ }); test('collapsed', async () => { - element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult; + element.result = {...checkRun1, ...checkRun1.results?.[0]} as RunResult; await element.updateComplete; await visualDiff(element, 'gr-diff-check-result-collapsed'); @@ -32,7 +32,7 @@ }); test('expanded', async () => { - element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult; + element.result = {...checkRun1, ...checkRun1.results?.[2]} as RunResult; element.isExpanded = true; await element.updateComplete;
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 42d17e1..6fad64e 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
@@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ import {assert} from '@open-wc/testing'; -import {fakeRun1} from '../../models/checks/checks-fakes'; +import {checkRun1} from '../../test/test-data-generators'; import {RunResult} from '../../models/checks/checks-model'; import '../../test/common-test-setup'; import {queryAndAssert} from '../../utils/common-util'; +import {stubFlags} from '../../test/test-utils'; import './gr-diff-check-result'; import {GrDiffCheckResult} from './gr-diff-check-result'; import {GrButton} from '../shared/gr-button/gr-button'; @@ -16,6 +17,7 @@ suite('gr-diff-check-result tests', () => { let element: GrDiffCheckResult; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let suggestionsService: any; setup(async () => { @@ -24,6 +26,7 @@ sinon .stub(suggestionsService, 'isGeneratedSuggestedFixEnabled') .returns(true); + stubFlags('isEnabled').returns(true); sinon.stub(suggestionsService, 'generateSuggestedFix').resolves({ description: 'AI suggested fix', replacements: [ @@ -50,7 +53,7 @@ }); test('renders', async () => { - element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult; + element.result = {...checkRun1, ...checkRun1.results?.[0]} as RunResult; await element.updateComplete; // cannot use /* HTML */ because formatted long message will not match. assert.shadowDom.equal( @@ -67,6 +70,9 @@ FAKE Super Check </div> </div> + <div class="ai-icon-wrapper"> + <gr-icon custom="" icon="ai" small></gr-icon> + </div> <div class="summary">We think that you could improve this.</div> <div class="message"> There is a lot to be said. A lot. I say, a lot. @@ -96,7 +102,7 @@ }); test('renders expanded', async () => { - element.result = {...fakeRun1, ...fakeRun1.results?.[2]} as RunResult; + element.result = {...checkRun1, ...checkRun1.results?.[2]} as RunResult; element.isExpanded = true; await element.updateComplete; @@ -118,7 +124,7 @@ }); test('shows please-fix button for author', async () => { - element.result = {...fakeRun1, ...fakeRun1.results?.[0]} as RunResult; + element.result = {...checkRun1, ...checkRun1.results?.[0]} as RunResult; element.isOwner = true; await element.updateComplete; const button = queryAndAssert(element, '#please-fix');
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts index f1c2023..9bb1cf1 100644 --- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts +++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -18,7 +18,7 @@ import {RunStatus} from '../../api/checks'; import {ordinal} from '../../utils/string-util'; import {HovercardMixin} from '../../mixins/hovercard-mixin/hovercard-mixin'; -import {css, html, LitElement} from 'lit'; +import {css, html, LitElement, nothing} from 'lit'; import {checksStyles} from './gr-checks-styles'; import {when} from 'lit/directives/when.js'; @@ -123,6 +123,9 @@ if (!this.run) return ''; const icon = this.computeIcon(); const chipIcon = this.computeChipIcon(); + const aiIcon = this.run.isAiPowered + ? html`<gr-icon small icon="ai"></gr-icon>` + : nothing; return html` <div id="container" role="tooltip" tabindex="-1"> <div class="section"> @@ -150,6 +153,7 @@ <div class="sectionContent"> <h3 class="name heading-3"> <span>${this.run.checkName}</span> + ${aiIcon} ${when(this.attempt, () => html`(Attempt ${this.attempt})`)} </h3> </div>
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts index e25d0d2..2be5e8b 100644 --- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts +++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -8,7 +8,7 @@ import './gr-hovercard-run'; import {assert, fixture, html} from '@open-wc/testing'; import {GrHovercardRun} from './gr-hovercard-run'; -import {fakeRun4_4, fakeRun4Att} from '../../models/checks/checks-fakes'; +import {checkRun4_4, checkRun4Att} from '../../test/test-data-generators'; import {createAttemptMap} from '../../models/checks/checks-util'; import {CheckRun} from '../../models/checks/checks-model'; @@ -17,7 +17,7 @@ setup(async () => { const fakeNow = new Date('Sep 26 2022 12:00:00'); - sinon.useFakeTimers(fakeNow); + sinon.useFakeTimers({now: fakeNow, shouldClearNativeTimers: true}); element = await fixture<GrHovercardRun>(html` <gr-hovercard-run class="hovered"></gr-hovercard-run> `); @@ -27,10 +27,21 @@ element.mouseHide(new MouseEvent('click')); }); - test('render fakeRun4', async () => { - const attemptMap = createAttemptMap(fakeRun4Att); - const attemptDetails = attemptMap.get(fakeRun4_4.checkName)!.attempts; - const run: CheckRun = {...fakeRun4_4, attemptDetails}; + test('render ai icon when isAiPowered is true', async () => { + const attemptMap = createAttemptMap(checkRun4Att); + const attemptDetails = attemptMap.get(checkRun4_4.checkName)!.attempts; + const run: CheckRun = {...checkRun4_4, attemptDetails, isAiPowered: true}; + element.run = run; + await element.updateComplete; + + const aiIcon = element.shadowRoot?.querySelector('gr-icon[icon="ai"]'); + assert.isOk(aiIcon); + }); + + test('render checkRun4', async () => { + const attemptMap = createAttemptMap(checkRun4Att); + const attemptDetails = attemptMap.get(checkRun4_4.checkName)!.attempts; + const run: CheckRun = {...checkRun4_4, attemptDetails}; element.run = run; await element.updateComplete; assert.shadowDom.equal( @@ -52,6 +63,7 @@ <div class="sectionContent"> <h3 class="heading-3 name"> <span> FAKE Elimination Long Long Long Long Long </span> + <gr-icon custom="" icon="ai" small=""></gr-icon> </h3> </div> </div>
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 70780cd..3085ecc 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
@@ -16,6 +16,7 @@ import {configModelToken} from '../../../models/config/config-model'; import {subscribe} from '../../lit/subscription-controller'; import '@material/web/icon/icon'; +import {materialStyles} from '../../../styles/gr-material-styles'; const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g; @@ -90,6 +91,7 @@ static override get styles() { return [ + materialStyles, sharedStyles, css` gr-dropdown {
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 5c9145a..8c6078b 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
@@ -35,7 +35,8 @@ import '@material/web/icon/icon'; import '@material/web/iconbutton/icon-button'; import {when} from 'lit/directives/when.js'; -import {isElementTarget} from '../../../utils/dom-util'; +import {isElementTarget, isFirefox, isSafari} from '../../../utils/dom-util'; +import {materialStyles} from '../../../styles/gr-material-styles'; type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>; @@ -203,6 +204,16 @@ this.retrieveRegisterURL(config); } ); + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + const height = entry.borderBoxSize[0].blockSize; + document.documentElement.style.setProperty( + '--main-header-height', + `${height}px` + ); + } + }); + observer.observe(this); } override connectedCallback() { @@ -230,6 +241,7 @@ static override get styles() { return [ + materialStyles, sharedStyles, css` :host { @@ -491,6 +503,9 @@ box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.26); z-index: 2; padding-bottom: 56px; + /* This is needed due to position sticky being used for gr-main-header + in gr-app-element */ + position: fixed; } .nav-sidebar.visible { left: 0px; @@ -517,7 +532,9 @@ } .modelBackground { background: rgba(0, 0, 0, 0.5); - position: absolute; + /* This is needed due to position sticky being used for gr-main-header + in gr-app-element */ + position: fixed; height: 100%; overflow: none; z-index: 199; @@ -1082,16 +1099,39 @@ } private handleSidebar() { - this.navSidebar?.classList.toggle('visible'); - if (!this.modelBackground) { - if (document.getElementsByTagName('html')) { - document.getElementsByTagName('html')[0].style.overflow = 'hidden'; - } - } else { - if (document.getElementsByTagName('html')) { - document.getElementsByTagName('html')[0].style.overflow = ''; - } - } + // Nav sidebar is only used on mobile. + // It's used to display items that would be unable + // to fit in the header. + const navSidebar = this.navSidebar; + navSidebar?.classList.toggle('visible'); + + const html = document.documentElement; + + html.style.overflow = this.modelBackground ? '' : 'hidden'; + + if (isSafari() || isFirefox()) this.fixSidebarForSafariAndFirefox(); + + // This adds a modelBackground in order to dim the background and + // to show it's not scrollable. this.hamburgerClose = !this.hamburgerClose; } + + /** + * There seems to have been a behaviour change in iOS 26, that breaks using + * just overflow hidden on <html>, to block scrolling when sidebar is open. + * Although this behaviour seems fixed in iOS 26.1. + * But issue now is that when you're half way down the page, and open the sidebar, + * it won't show without this fix for both safari and firefox. + */ + private fixSidebarForSafariAndFirefox() { + const isModal = !!this.modelBackground; + + const html = document.documentElement; + html.style.overscrollBehavior = isModal ? '' : 'none'; + + const body = document.body; + body.style.overflow = isModal ? '' : 'hidden'; + body.style.overscrollBehavior = isModal ? '' : 'none'; + body.style.position = isModal ? '' : 'relative'; + } }
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 2987d94..22cb7af 100644 --- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts +++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -271,8 +271,6 @@ const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/; -// Polymer makes `app` intrinsically defined on the window by virtue of the -// custom element having the id "pg-app", but it is made explicit here. // If you move this code to other place, please update comment about // gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed const app = document.querySelector('gr-app'); @@ -419,9 +417,7 @@ } private appElement(): AppElement { - // In Polymer2 you have to reach through the shadow root of the app - // element. This obviously breaks encapsulation. - // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element + // TODO(milutin): Make this more elegant, e.g. by exposing app-element // explicitly in app, or by delegating to it. // It is expected that application has a GrAppElement(id=='app-element')
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 3ff3df2..6c05a8f 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
@@ -126,7 +126,9 @@ // This test encodes the lists of route handler methods that gr-router // automatically checks for authentication before triggering. + // eslint-disable-next-line @typescript-eslint/no-explicit-any const requiresAuth: any = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const doesNotRequireAuth: any = {}; sinon.stub(page, 'start'); sinon @@ -266,13 +268,17 @@ let urlPromise: MockPromise<string>; setup(() => { + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); stubRestApi('addRepoNameToCache'); urlPromise = mockPromise<string>(); redirectStub = sinon .stub(router, 'redirect') .callsFake(urlPromise.resolve); router._testOnly_startRouter(); - clock = sinon.useFakeTimers(); + }); + + teardown(() => { + clock.restore(); }); test('no blockers: normal redirect', async () => {
diff --git a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts index b61c56e..852e549 100644 --- a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts +++ b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete.ts
@@ -12,7 +12,7 @@ import {AutocompleteSuggestion} from '../../../utils/autocomplete-util'; import {MergeabilityComputationBehavior} from '../../../constants/constants'; import {sharedStyles} from '../../../styles/shared-styles'; -import {css, html, LitElement, PropertyValues} from 'lit'; +import {css, html, LitElement, nothing, PropertyValues} from 'lit'; import { customElement, property, @@ -27,6 +27,7 @@ import {getDocUrl} from '../../../utils/url-util'; import '@material/web/iconbutton/icon-button'; import {when} from 'lit/directives/when.js'; +import {materialStyles} from '../../../styles/gr-material-styles'; // Possible static search options for auto complete, without negations. const SEARCH_OPERATORS: ReadonlyArray<string> = [ @@ -101,6 +102,7 @@ 'reviewedby:', 'reviewer:', 'reviewer:self', + 'reviewercount:', 'reviewerin:', 'rule:', 'size:', @@ -144,6 +146,12 @@ @property({type: String}) value = ''; + @property({type: Boolean}) + showLeadingIcon = false; + + @property({type: Number}) + verticalOffset = 31; + @property({type: Object}) projectSuggestions: SuggestionProvider = () => Promise.resolve([]); @@ -153,6 +161,9 @@ @property({type: Object}) accountSuggestions: SuggestionProvider = () => Promise.resolve([]); + @property({type: Object}) + labelSuggestions: SuggestionProvider = () => Promise.resolve([]); + @state() mergeabilityComputationBehavior?: MergeabilityComputationBehavior; @@ -185,6 +196,7 @@ static override get styles() { return [ + materialStyles, sharedStyles, css` form { @@ -200,6 +212,9 @@ flex: 1; outline: none; } + form { + padding: var(--gr-search-autocomplete-padding, 0); + } md-icon-button { --md-icon-button-icon-size: 20px; @@ -216,6 +231,7 @@ placeholder=${this.placeholder} .text=${this.inputVal} .query=${this.query} + .verticalOffset=${this.verticalOffset} allow-non-suggested-values multi skip-commit-on-item-select @@ -224,9 +240,13 @@ this.handleQueryTextChanged(e); }} > - <div slot="leading-icon"> - <slot name="leading-icon"></slot> - </div> + ${this.showLeadingIcon + ? html` + <div slot="leading-icon"> + <slot name="leading-icon"></slot> + </div> + ` + : nothing} ${when( this.inputVal?.length > 0, () => html` @@ -316,6 +336,10 @@ return this.projectSuggestions(predicate, expression); } + if (/^-?label$/.test(predicate)) { + return this.labelSuggestions(predicate, expression); + } + if ( /^-?(attention|author|cc|commentby|committer|from|owner|reviewedby|reviewer)$/.test( predicate
diff --git a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete_test.ts b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete_test.ts index 80de410..11228cb 100644 --- a/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete_test.ts +++ b/polygerrit-ui/app/elements/core/gr-search-autocomplete/gr-search-autocomplete_test.ts
@@ -65,9 +65,6 @@ skip-commit-on-item-select="" tab-complete="" > - <div slot="leading-icon"> - <slot name="leading-icon"></slot> - </div> <a class="help" href="https://mydocumentationurl.google.com/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 3723eb7..882bc51 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
@@ -106,6 +106,7 @@ this.fetchProjects(predicate, expression); return html` <gr-search-autocomplete + showLeadingIcon id="search" .value=${this.searchQuery} .projectSuggestions=${projectSuggestions}
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 3e9b15e..e9d1056 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
@@ -28,7 +28,7 @@ test('renders', () => { assert.shadowDom.equal( element, - /* HTML */ ' <gr-search-autocomplete id="search"> <gr-icon icon="search" slot="leading-icon" aria-hidden="true"></gr-icon> </gr-search-autocomplete> ' + /* HTML */ ' <gr-search-autocomplete showLeadingIcon id="search"> <gr-icon icon="search" slot="leading-icon" aria-hidden="true"></gr-icon> </gr-search-autocomplete> ' ); });
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 4aac5b1..907c764 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
@@ -607,6 +607,7 @@ this.reporting.time(Timing.DIFF_CONTENT); this.syntaxLayer.setEnabled(this.isSyntaxHighlightingEnabled()); const syntaxLayerPromise = this.syntaxLayer.process(diff); + syntaxLayerPromise.catch(() => {}); await waitForEventOnce(this, 'render'); this.subscribeToChecks(); this.reporting.timeEnd(Timing.DIFF_CONTENT, this.timingDetails());
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 efe4ab9..85cf6cb 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
@@ -530,6 +530,10 @@ .hidden { display: none; } + .headerLeft { + display: flex; + align-items: center; + } gr-patch-range-select { display: block; } @@ -539,7 +543,7 @@ .stickyHeader { background-color: var(--view-background-color); position: sticky; - top: 0; + top: var(--main-header-height); /* sidebar should outrank <footer> in GrAppElement */ z-index: 110; box-shadow: var(--elevation-level-1); @@ -560,9 +564,10 @@ color: transparent; } .headerSubject { + margin-left: var(--spacing-s); margin-right: var(--spacing-m); font-weight: var(--font-weight-medium); - whitespace: no-wrap; + white-space: nowrap; overflow: auto; } .patchRangeLeft { @@ -581,11 +586,8 @@ padding: 0 var(--spacing-xs); } .reviewed { - display: inline-block; margin: 0 var(--spacing-xs); - vertical-align: top; - position: relative; - top: 8px; + flex-shrink: 0; } .jumpToFileContainer { display: inline-block; @@ -646,6 +648,12 @@ .editButtona a { text-decoration: none; } + .checkboxDiv { + display: flex; + } + gr-dropdown-list { + --gr-dropdown-copy-clipboard-margin-left: var(--spacing-s); + } @media screen and (max-width: 50em) { header { padding: var(--spacing-s) var(--spacing-l); @@ -887,22 +895,28 @@ const formattedFiles = this.formatFilesForDropdown(); const fileNum = this.computeFileNum(formattedFiles); const fileNumClass = this.computeFileNumClass(fileNum, formattedFiles); - return html` <div> - <a href=${ifDefined(this.getChangeModel().changeUrl())} - >${this.changeNum}</a - ><span class="changeNumberColon">:</span> - <span class="headerSubject" - >${trimWithEllipsis(this.change?.subject, 80)}</span - > - <md-checkbox - id="reviewed" - class="reviewed hideOnEdit" - ?hidden=${!this.loggedIn} - title="Toggle reviewed status of file" - aria-label="file reviewed" - ?checked=${this.reviewed} - @change=${this.handleReviewedChange} - ></md-checkbox> + return html` <div class="headerLeft"> + <div> + <a href=${ifDefined(this.getChangeModel().changeUrl())} + >${this.changeNum}</a + ><span class="changeNumberColon">:</span> + </div> + <div> + <span class="headerSubject" + >${trimWithEllipsis(this.change?.subject, 80)}</span + > + </div> + <div class="checkboxDiv"> + <md-checkbox + id="reviewed" + class="reviewed hideOnEdit" + ?hidden=${!this.loggedIn} + title="Toggle reviewed status of file" + aria-label="file reviewed" + ?checked=${this.reviewed} + @change=${this.handleReviewedChange} + ></md-checkbox> + </div> <div class="jumpToFileContainer"> <gr-dropdown-list id="dropdown"
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 8b65edd..f8fd10e 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
@@ -217,16 +217,23 @@ <div class="stickyHeader"> <h1 class="assistive-tech-only">Diff of glados.txt</h1> <header> - <div> - <a href="/c/test-project/+/42"> 42 </a> - <span class="changeNumberColon"> : </span> - <span class="headerSubject"> Test subject </span> - <md-checkbox - aria-label="file reviewed" - class="hideOnEdit reviewed" - id="reviewed" - title="Toggle reviewed status of file" - ></md-checkbox> + <div class="headerLeft"> + <div> + <a href="/c/test-project/+/42"> 42 </a> + <span class="changeNumberColon"> : </span> + </div> + <div> + <span class="headerSubject"> Test subject </span> + </div> + <div class="checkboxDiv"> + <md-checkbox + class="hideOnEdit reviewed" + data-aria-label="file reviewed" + id="reviewed" + title="Toggle reviewed status of file" + > + </md-checkbox> + </div> <div class="jumpToFileContainer"> <gr-dropdown-list id="dropdown" show-copy-for-trigger-text=""> </gr-dropdown-list> @@ -276,16 +283,16 @@ <div class="rightControls"> <div class="sidebarTriggerContainer"> <gr-endpoint-decorator name="sidebarTrigger"> - <gr-endpoint-param name="onTrigger"></gr-endpoint-param> - <gr-endpoint-param name="openSidebar"></gr-endpoint-param> + <gr-endpoint-param name="onTrigger"> </gr-endpoint-param> + <gr-endpoint-param name="openSidebar"> </gr-endpoint-param> </gr-endpoint-decorator> </div> <gr-button aria-disabled="false" + id="toggleEntireFile" link="" role="button" tabindex="0" - id="toggleEntireFile" title="Toggle all diff context (shortcut: Shift+x)" > Show Entire File @@ -335,7 +342,7 @@ role="button" tabindex="0" > - <gr-icon icon="settings" filled></gr-icon> + <gr-icon filled="" icon="settings"> </gr-icon> </gr-button> </gr-tooltip-content> </span> @@ -385,7 +392,9 @@ }); test('keyboard shortcuts', async () => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({ + toFake: ['Date'], + }); element.changeNum = 42 as NumericChangeId; browserModel.setScreenWidth(0); element.patchNum = 10 as RevisionPatchSetNum; @@ -1405,7 +1414,7 @@ }; userModel.setDiffPreferences(diffPreferences); viewModel.updateState({diffView: {path: 'wheatley.md'}}); - changeModel.setState({ + changeModel.updateState({ change: createParsedChange(), reviewedFiles: [], loadingStatus: LoadingStatus.LOADED, @@ -1437,7 +1446,7 @@ }; userModel.setDiffPreferences(diffPreferences); viewModel.updateState({diffView: {path: 'wheatley.md'}}); - changeModel.setState({ + changeModel.updateState({ change: createParsedChange(), reviewedFiles: [], loadingStatus: LoadingStatus.LOADED, @@ -1458,7 +1467,7 @@ basePatchNum: PARENT, diffView: {path: '/COMMIT_MSG'}, }); - changeModel.setState({ + changeModel.updateState({ change: createParsedChange(), reviewedFiles: [], loadingStatus: LoadingStatus.LOADED,
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 72a41fe..898bd8f 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
@@ -548,8 +548,6 @@ } private handleDeleteConfirm(e: Event) { - // Get the dialog before the api call as the event will change during bubbling - // which will make Polymer.dom(e).path an empty array in polymer 2 const dialog = this.getDialogFromEvent(e); if (!this.change || !this.path) { fireAlert(this, 'You must enter a path.');
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 f348a80..7184e00 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
@@ -443,7 +443,7 @@ } // private but used in test - saveEdit() { + saveEdit(): Promise<Response> { const changeNum = this.viewState?.changeNum; const path = this.viewState?.editView?.path; assertIsDefined(changeNum, 'change number'); @@ -456,7 +456,7 @@ return Promise.reject(new Error('new content undefined')); return this.restApiService .saveChangeEdit(changeNum, path, this.newContent) - .then(res => { + .then((res: Response): Response => { this.saving = false; this.showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG); if (!res.ok) { @@ -517,16 +517,27 @@ }); }; - private handlePublishTap = () => { + // private but used in test + handlePublishTap = async () => { const changeNum = this.viewState?.changeNum; assertIsDefined(changeNum, 'change number'); - this.saveEdit().then(() => { + await this.saveEdit().then(async () => { const handleError: ErrorCallback = response => { this.showAlert(PUBLISH_FAILED_MSG); this.reporting.error('/edit:publish', new Error(response?.statusText)); }; + if ( + !(await this.getPluginLoader().jsApiService.handleBeforePublishEdit( + this.change as ChangeInfo + )) + ) { + // The event handler should notify with a more specific + // message if it blocks publishing. + return; + } + this.showAlert(PUBLISHING_EDIT_MSG); // restApiService return undefined if server response with non-200 error
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 c8b476b..690180c 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
@@ -309,6 +309,7 @@ test('file modification and publish', async () => { const saveSpy = sinon.spy(element, 'saveEdit'); const alertStub = sinon.stub(element, 'showAlert'); + const tapSpy = sinon.spy(element, 'handlePublishTap'); const changeActionsStub = stubRestApi('executeChangeAction').resolves(); saveFileStub.returns(Promise.resolve({ok: true})); element.newContent = newText; @@ -328,11 +329,12 @@ query<GrButton>(element, '#save')!.hasAttribute('disabled') ); - return saveSpy.lastCall.returnValue.then(() => { + return saveSpy.lastCall.returnValue.then(async () => { assert.isTrue(saveFileStub.called); assert.isFalse(element.saving); assert.equal(alertStub.getCall(1).args[0], 'All changes saved'); + await tapSpy.lastCall.returnValue; assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...'); assert.isTrue(
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts index acc8cf2..a1236eb 100644 --- a/polygerrit-ui/app/elements/gr-app-element.ts +++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -58,7 +58,6 @@ import {cache} from 'lit/directives/cache.js'; import {keyed} from 'lit/directives/keyed.js'; import {assertIsDefined} from '../utils/common-util'; -import './gr-css-mixins'; import {isDarkTheme, prefersDarkColorScheme} from '../utils/theme-util'; import {AppTheme} from '../constants/constants'; import {subscribe} from './lit/subscription-controller'; @@ -297,17 +296,20 @@ header should be shown on top of the sticky diff header, which has a z-index of 110. */ z-index: 111; + position: sticky; + top: 0; } footer { background: var( --footer-background, var(--footer-background-color, #eee) ); + height: var(--main-footer-height); border-top: var(--footer-border-top); display: flex; justify-content: space-between; padding: var(--spacing-m) var(--spacing-l); - z-index: 100; + z-index: 10; } main { flex: 1; @@ -350,7 +352,6 @@ override render() { return html` - <gr-css-mixins></gr-css-mixins> <gr-endpoint-decorator name="banner"></gr-endpoint-decorator> ${this.renderHeader()} <main>
diff --git a/polygerrit-ui/app/elements/gr-app-element_test.ts b/polygerrit-ui/app/elements/gr-app-element_test.ts index c9d2a6b..a6fce57 100644 --- a/polygerrit-ui/app/elements/gr-app-element_test.ts +++ b/polygerrit-ui/app/elements/gr-app-element_test.ts
@@ -22,7 +22,6 @@ assert.shadowDom.equal( element, /* HTML */ ` - <gr-css-mixins> </gr-css-mixins> <gr-endpoint-decorator name="banner"> </gr-endpoint-decorator> <gr-main-header loggedin id="mainHeader" role="banner"> </gr-main-header>
diff --git a/polygerrit-ui/app/elements/gr-app_test.ts b/polygerrit-ui/app/elements/gr-app_test.ts index b873692..d33f990 100644 --- a/polygerrit-ui/app/elements/gr-app_test.ts +++ b/polygerrit-ui/app/elements/gr-app_test.ts
@@ -29,6 +29,7 @@ ); const dispatchLocationChangeEventSpy = sinon.spy( GrRouter.prototype, + // eslint-disable-next-line @typescript-eslint/no-explicit-any <any>'dispatchLocationChangeEvent' ); setup(async () => {
diff --git a/polygerrit-ui/app/elements/gr-css-mixins.ts b/polygerrit-ui/app/elements/gr-css-mixins.ts deleted file mode 100644 index a4b01c2..0000000 --- a/polygerrit-ui/app/elements/gr-css-mixins.ts +++ /dev/null
@@ -1,76 +0,0 @@ -/** - * @license - * Copyright 2022 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import {PolymerElement} from '@polymer/polymer/polymer-element'; -import {customElement} from '@polymer/decorators'; -import {html} from '@polymer/polymer/lib/utils/html-tag'; - -@customElement('gr-css-mixins') -export class GrCssMixins extends PolymerElement { - /* eslint-disable lit/prefer-static-styles */ - static get template() { - return html` - <style> - /* prettier formatter removes semi-colons after css mixins. */ - /* prettier-ignore */ - :host { - /* If you want to use css-mixins in Lit elements, then you have to - first use them in a PolymerElement somewhere. We are collecting all - css- mixin usage here, but we may move them somewhere else later when - converting gr-app-element to Lit. In the Lit element you can then use - the css variables directly such as --paper-input-container_-_padding, - so you don't have to mess with mixins at all. - */ - --paper-input-container: { - padding: 8px 0; - }; - --paper-font-common-base: { - font-family: var(--header-font-family); - -webkit-font-smoothing: initial; - }; - --paper-input-container-input: { - font-size: var(--font-size-normal); - line-height: var(--line-height-normal); - color: var(--primary-text-color); - }; - --paper-input-container-underline: { - height: 0; - display: none; - }; - --paper-input-container-underline-focus: { - height: 0; - display: none; - }; - --paper-input-container-underline-disabled: { - height: 0; - display: none; - }; - --paper-input-container-label: { - display: none; - }; - --paper-item: { - min-height: 0; - padding: 0px 16px; - }; - --paper-item-focused-before: { - background-color: var(--selection-background-color); - }; - --paper-item-focused: { - background-color: var(--selection-background-color); - }; - --paper-listbox: { - padding: 0; - }; - } - </style> - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'gr-css-mixins': GrCssMixins; - } -}
diff --git a/polygerrit-ui/app/elements/lit/shortcut-controller.ts b/polygerrit-ui/app/elements/lit/shortcut-controller.ts index f2b1ecd..74492ed 100644 --- a/polygerrit-ui/app/elements/lit/shortcut-controller.ts +++ b/polygerrit-ui/app/elements/lit/shortcut-controller.ts
@@ -62,9 +62,6 @@ * description text and (several) bindings configured in the file * `shortcuts-config.ts`. * - * Use this method when you are migrating from Polymer to Lit. Call it for - * each entry of keyboardShortcuts(). - * * Call method in constructor of the component */ addAbstract(
diff --git a/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts new file mode 100644 index 0000000..dd7db5a --- /dev/null +++ b/polygerrit-ui/app/elements/plugins/gr-ai-code-review-api/gr-ai-code-review-api.ts
@@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {PluginApi} from '../../../api/plugin'; +import {ReportingService} from '../../../services/gr-reporting/gr-reporting'; +import {PluginsModel} from '../../../models/plugins/plugins-model'; +import { + AiCodeReviewPluginApi, + AiCodeReviewProvider, +} from '../../../api/ai-code-review'; + +enum State { + NOT_REGISTERED, + REGISTERED, +} + +/** + * Plugin API for AI Code Review. + * + * This object is returned to plugins that want to provide AI Code Review data. + * Plugins normally just call register() once at startup and then wait for + * calls on the provider interface. + */ +export class GrAiCodeReviewApi implements AiCodeReviewPluginApi { + private state = State.NOT_REGISTERED; + + constructor( + private readonly reporting: ReportingService, + private readonly pluginsModel: PluginsModel, + readonly plugin: PluginApi + ) { + this.reporting.trackApi(this.plugin, 'ai-code-review', 'constructor'); + } + + register(provider: AiCodeReviewProvider): void { + this.reporting.trackApi(this.plugin, 'ai-code-review', 'register'); + if (this.state === State.REGISTERED) { + throw new Error('Only one provider can be registered per plugin.'); + } + this.state = State.REGISTERED; + this.pluginsModel.aiCodeReviewRegister({ + pluginName: this.plugin.getPluginName(), + provider, + }); + } +}
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 deleted file mode 100644 index b34e1c3..0000000 --- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts +++ /dev/null
@@ -1,103 +0,0 @@ -/** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import {AttributeHelperPluginApi} from '../../../api/attribute-helper'; -import {PluginApi} from '../../../api/plugin'; -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>>(); - - // TODO(TS): Change any to something more like HTMLElement. - 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'); - } - - _getChangedEventName(name: string): string { - return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed'; - } - - /** - * Returns true if the property is defined on wrapped element. - */ - _elementHasProperty(name: string) { - return this.element[name] !== undefined; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - _reportValue(callback: (value: any) => void, value: any) { - try { - callback(value); - } catch (e) { - console.info(e); - } - } - - /** - * Binds callback to property updates. - * - * @param name Property name. - * @return Unbind function. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - bind(name: string, callback: (value: any) => void) { - this.reporting.trackApi(this.plugin, 'attribute', 'bind'); - const attributeChangedEventName = this._getChangedEventName(name); - const changedHandler = (e: ValueChangedEvent) => - this._reportValue(callback, e.detail.value); - const unbind = () => - this.element.removeEventListener( - attributeChangedEventName, - changedHandler - ); - this.element.addEventListener(attributeChangedEventName, changedHandler); - if (this._elementHasProperty(name)) { - this._reportValue(callback, this.element[name]); - } - return unbind; - } - - /** - * Get value of the property from wrapped object. Waits for the property - * to be initialized if it isn't defined. - */ - get(name: string): Promise<unknown> { - this.reporting.trackApi(this.plugin, 'attribute', 'get'); - if (this._elementHasProperty(name)) { - return Promise.resolve(this.element[name]); - } - if (!this._promises.has(name)) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let resolve: (value: any) => void; - const promise = new Promise(r => (resolve = r)); - const unbind = this.bind(name, value => { - resolve(value); - unbind(); - }); - this._promises.set(name, promise); - } - return this._promises.get(name)!; - } - - /** - * Sets value of property (not attribute!) and dispatches event to force - * notify. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - set(name: string, value: any) { - this.reporting.trackApi(this.plugin, 'attribute', 'set'); - this.element[name] = value; - this.element.dispatchEvent( - new CustomEvent(this._getChangedEventName(name), {detail: {value}}) - ); - } -}
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts deleted file mode 100644 index e3f6f16..0000000 --- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.ts +++ /dev/null
@@ -1,83 +0,0 @@ -/** - * @license - * Copyright 2017 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import * as sinon from 'sinon'; -import '../../../test/common-test-setup'; -import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn'; -import {assert, fixture, html} from '@open-wc/testing'; -import {PluginApi} from '../../../api/plugin'; -import {AttributeHelperPluginApi} from '../../../api/attribute-helper'; - -// Attribute helper only works on Polymer notify events, so we cannot use a Lit -// element for the test. -Polymer({ - is: 'foo-bar', - properties: { - fooBar: { - type: Object, - notify: true, - }, - }, -}); - -declare global { - interface HTMLElementTagNameMap { - 'foo-bar': HTMLElement; - } -} - -suite('gr-attribute-helper tests', () => { - let element: HTMLElement & {fooBar?: string}; - let instance: AttributeHelperPluginApi; - - setup(async () => { - let plugin: PluginApi; - window.Gerrit.install( - p => { - plugin = p; - }, - '0.1', - 'http://test.com/plugins/testplugin/static/test.js' - ); - element = await fixture(html`<foo-bar></foo-bar>`); - instance = plugin!.attributeHelper(element); - }); - - test('get resolves on value change from undefined', async () => { - const fooBarWatch = instance.get('fooBar'); - element.fooBar = 'foo! bar!'; - const value = await fooBarWatch; - - assert.equal(value, 'foo! bar!'); - }); - - test('get resolves to current attribute value', async () => { - element.fooBar = 'foo-foo-bar'; - const fooBarWatch = instance.get('fooBar'); - element.fooBar = 'no bar'; - const value = await fooBarWatch; - - assert.equal(value, 'foo-foo-bar'); - }); - - test('bind', () => { - const stub = sinon.stub(); - element.fooBar = 'bar foo'; - const unbind = instance.bind('fooBar', stub); - element.fooBar = 'partridge in a foo tree'; - element.fooBar = 'five gold bars'; - - assert.equal(stub.callCount, 3); - assert.deepEqual(stub.args[0], ['bar foo']); - assert.deepEqual(stub.args[1], ['partridge in a foo tree']); - assert.deepEqual(stub.args[2], ['five gold bars']); - - stub.reset(); - unbind(); - element.fooBar = 'ladies dancing'; - - assert.isFalse(stub.called); - }); -});
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 9cacaea..d4957a8 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
@@ -15,6 +15,8 @@ import {assertIsDefined} from '../../../utils/common-util'; import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader'; import {resolve} from '../../../models/dependency'; +import {ValueChangedEvent} from '../../../types/events'; +import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param'; const INIT_PROPERTIES_TIMEOUT_MS = 10000; @@ -139,26 +141,9 @@ if (content) { el.content = content as HTMLElement; } - const expectProperties = this.getEndpointParams().map(paramEl => { - const helper = plugin.attributeHelper(paramEl); - const paramName = paramEl.name; - if (!paramName) { - this.reporting.error( - `Plugin '${pluginName}', endpoint '${this.name}'`, - new Error( - `Plugin '${pluginName}', endpoint '${this.name}': param is missing a name.` - ) - ); - return; - } - return helper.get('value').then(() => - helper.bind('value', value => - // Note that despite the naming this sets the property, not the - // attribute. :-) - plugin.attributeHelper(el).set(paramName, value) - ) - ); - }); + const expectProperties = this.getEndpointParams().map(paramEl => + this.setupParamBinding(paramEl, el, pluginName) + ); let timeoutId: number; const timeout = new Promise( () => @@ -182,6 +167,39 @@ }); } + private setupParamBinding( + paramEl: GrEndpointParam, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pluginEl: any, + pluginName: string + ) { + const paramName = paramEl.name; + if (!paramName) { + this.reporting.error( + `Plugin '${pluginName}', endpoint '${this.name}'`, + new Error( + `Plugin '${pluginName}', endpoint '${this.name}': param is missing a name.` + ) + ); + return Promise.resolve(); + } + + let resolve: () => void; + const promise = new Promise<void>(r => (resolve = r)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const callback = (value: any) => { + resolve(); + pluginEl[paramName] = value; + }; + paramEl.addEventListener('value-changed', (e: ValueChangedEvent) => + callback(e.detail.value) + ); + if (paramEl.value !== undefined) { + callback(paramEl.value); + } + return promise; + } + private readonly initModule = ({ moduleName, plugin,
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 62419da..807ab5d 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
@@ -11,15 +11,22 @@ import {mockPromise, queryAndAssert} from '../../../test/test-utils'; import {GrEndpointDecorator} from './gr-endpoint-decorator'; import {PluginApi} from '../../../api/plugin'; +import {HookApi, PluginElement} from '../../../api/hook'; import {GrEndpointParam} from '../gr-endpoint-param/gr-endpoint-param'; +interface TestModule extends PluginElement { + 'first-param'?: string; + 'second-param'?: string; + 'banana-param'?: unknown; +} + suite('gr-endpoint-decorator', () => { let container: HTMLElement; let plugin: PluginApi; - let decorationHook: any; - let decorationHookWithSlot: any; - let replacementHook: any; + let decorationHook: HookApi<TestModule>; + let decorationHookWithSlot: HookApi<TestModule>; + let replacementHook: HookApi<TestModule>; let first: GrEndpointDecorator; let second: GrEndpointDecorator; let banana: GrEndpointDecorator; @@ -72,12 +79,15 @@ 'http://some/plugin/url.js' ); // Decoration - decorationHook = plugin.registerCustomComponent('first', 'some-module'); + decorationHook = plugin.registerCustomComponent<TestModule>( + 'first', + 'some-module' + ); const decorationHookPromise = mockPromise(); decorationHook.onAttached(() => decorationHookPromise.resolve()); // Decoration with slot - decorationHookWithSlot = plugin.registerCustomComponent( + decorationHookWithSlot = plugin.registerCustomComponent<TestModule>( 'first', 'some-module-2', {slot: 'test'} @@ -88,9 +98,13 @@ ); // Replacement - replacementHook = plugin.registerCustomComponent('second', 'other-module', { - replace: true, - }); + replacementHook = plugin.registerCustomComponent<TestModule>( + 'second', + 'other-module', + { + replace: true, + } + ); const replacementHookPromise = mockPromise(); replacementHook.onAttached(() => replacementHookPromise.resolve()); @@ -114,10 +128,11 @@ assert.equal(modules.length, 1); const [module] = modules; assert.isOk(module); - assert.equal((module as any)['first-param'], 'barbar'); + assert.equal((module as TestModule)['first-param'], 'barbar'); return decorationHook .getLastAttached() - .then((element: any) => { + + .then((element: unknown) => { assert.strictEqual(element, module); }) .then(() => { @@ -132,10 +147,11 @@ assert.equal(modules.length, 1); const [module] = modules; assert.isOk(module); - assert.equal((module as any)['first-param'], 'barbar'); + assert.equal((module as TestModule)['first-param'], 'barbar'); return decorationHookWithSlot .getLastAttached() - .then((element: any) => { + + .then((element: unknown) => { assert.strictEqual(element, module); }) .then(() => { @@ -150,10 +166,10 @@ element => element.nodeName === 'OTHER-MODULE' ); assert.isOk(module); - assert.equal((module as any)['second-param'], 'foofoo'); + assert.equal((module as TestModule)['second-param'], 'foofoo'); return replacementHook .getLastAttached() - .then((element: any) => { + .then((element: unknown) => { assert.strictEqual(element, module); }) .then(() => { @@ -163,7 +179,10 @@ }); test('late registration', async () => { - const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob'); + const bananaHook = plugin.registerCustomComponent<TestModule>( + 'banana', + 'noob-noob' + ); const bananaHookPromise = mockPromise(); bananaHook.onAttached(() => bananaHookPromise.resolve()); await bananaHookPromise; @@ -203,7 +222,10 @@ param['value'] = undefined; await param.updateComplete; - const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob'); + const bananaHook = plugin.registerCustomComponent<TestModule>( + 'banana', + 'noob-noob' + ); const bananaHookPromise = mockPromise(); bananaHook.onAttached(() => bananaHookPromise.resolve()); @@ -225,7 +247,7 @@ element => element.nodeName === 'NOOB-NOOB' ); assert.isOk(module); - assert.strictEqual((module as any)['banana-param'], value); + assert.strictEqual((module as TestModule)['banana-param'], value); }); test('param is bound', async () => { @@ -236,7 +258,10 @@ param.value = value1; await param.updateComplete; - const bananaHook = plugin.registerCustomComponent('banana', 'noob-noob'); + const bananaHook = plugin.registerCustomComponent<TestModule>( + 'banana', + 'noob-noob' + ); const bananaHookPromise = mockPromise(); bananaHook.onAttached(() => bananaHookPromise.resolve()); await bananaHookPromise; @@ -245,10 +270,10 @@ element => element.nodeName === 'NOOB-NOOB' ); assert.isOk(module); - assert.strictEqual((module as any)['banana-param'], value1); + assert.strictEqual((module as TestModule)['banana-param'], value1); param.value = value2; await param.updateComplete; - assert.strictEqual((module as any)['banana-param'], value2); + assert.strictEqual((module as TestModule)['banana-param'], value2); }); });
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 392bf03..b7f5727 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
@@ -9,7 +9,7 @@ import {PopupPluginApi} from '../../../api/popup'; import {getAppContext} from '../../../services/app-context'; -interface CustomPolymerPluginEl extends HTMLElement { +interface CustomPluginEl extends HTMLElement { plugin: PluginApi; } @@ -69,7 +69,7 @@ }); if (this.moduleName) { const el = popup.appendChild( - document.createElement(this.moduleName) as CustomPolymerPluginEl + document.createElement(this.moduleName) as CustomPluginEl ); el.plugin = this.plugin; }
diff --git a/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token.ts b/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token.ts index 1637911..e2a9eab 100644 --- a/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token.ts +++ b/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token.ts
@@ -279,7 +279,7 @@ return html`<div id="legacyPasswordNote"> This account only has a legacy HTTP password configured. The legacy HTTP password will be accepted until the first authentication token has been - created. At this point the HTTP password will be removed from the account. + created. At that point the HTTP password will be removed from the account. </div>`; }
diff --git a/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token_test.ts b/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token_test.ts index f16f4a8..05d03ff 100644 --- a/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token_test.ts +++ b/polygerrit-ui/app/elements/settings/gr-auth-token/gr-auth-token_test.ts
@@ -53,7 +53,7 @@ <div id="legacyPasswordNote"> This account only has a legacy HTTP password configured. The legacy HTTP password will be accepted until the first authentication token has been - created. At this point the HTTP password will be removed from the account. + created. At that point the HTTP password will be removed from the account. </div> </section> <fieldset id="existing">
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 9cc584c..d822b975 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
@@ -91,9 +91,6 @@ .value=${convertToString(this.editPrefs?.tab_size)} @input=${this.handleEditTabWidthInput} @beforeinput=${(e: InputEvent) => { - // In iron-input we had allowedPattern, but this is not supported - // in md-outlined-text-field. Which uses native input functionality. - // We workaround this. const data = e.data; if (data && !/^[0-9]*$/.test(data)) { e.preventDefault(); @@ -114,9 +111,6 @@ .value=${convertToString(this.editPrefs?.line_length)} @input=${this.handleEditLineLengthInput} @beforeinput=${(e: InputEvent) => { - // In iron-input we had allowedPattern, but this is not supported - // in md-outlined-text-field. Which uses native input functionality. - // We workaround this. const data = e.data; if (data && !/^[0-9]*$/.test(data)) { e.preventDefault(); @@ -137,9 +131,6 @@ .value=${convertToString(this.editPrefs?.indent_unit)} @input=${this.handleEditIndentUnitInput} @beforeinput=${(e: InputEvent) => { - // In iron-input we had allowedPattern, but this is not supported - // in md-outlined-text-field. Which uses native input functionality. - // We workaround this. const data = e.data; if (data && !/^[0-9]*$/.test(data)) { e.preventDefault();
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts index a523c20..0756f30 100644 --- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts +++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -33,6 +33,9 @@ /* private but used in test */ @state() newPreferred = ''; + /* private but used in test */ + @state() newAvatar = ''; + readonly restApiService = getAppContext().restApiService; private readonly getUserModel = resolve(this, userModelToken); @@ -64,11 +67,13 @@ min-width: 32.5em; width: auto; } - #emailTable .preferredHeader { + #emailTable .preferredHeader, + #emailTable .avatarHeader { text-align: center; width: 6em; } - #emailTable .preferredControl { + #emailTable .preferredControl, + #emailTable .avatarControl { height: auto; text-align: center; } @@ -83,6 +88,7 @@ <tr> <th class="emailColumn">Email</th> <th class="preferredHeader">Preferred</th> + <th class="avatarHeader">Avatar</th> <th></th> </tr> </thead> @@ -106,6 +112,16 @@ > </md-radio> </td> + <td class="avatarControl"> + <md-radio + class="avatarRadio" + name="avatar" + .value=${email.email} + ?checked=${!!email.avatar} + @change=${this.handleAvatarChange} + > + </md-radio> + </td> <td> <gr-button @click=${() => this.handleDeleteButton(index)} @@ -130,9 +146,14 @@ ); } + if (this.newAvatar) { + promises.push(this.restApiService.setAvatarAccountEmail(this.newAvatar)); + } + return Promise.all(promises).then(async () => { this.emailsToRemove = []; this.newPreferred = ''; + this.newAvatar = ''; await this.getUserModel().loadEmails(true); this.setHasUnsavedChanges(); }); @@ -170,6 +191,23 @@ } } + private handleAvatarChange(e: Event) { + if (!(e.target instanceof MdRadio)) return; + const avatar = e.target.value; + for (let i = 0; i < this.emails.length; i++) { + if (avatar === this.emails[i].email) { + this.emails[i].avatar = true; + this.requestUpdate(); + this.newAvatar = avatar; + this.setHasUnsavedChanges(); + } else if (this.emails[i].avatar) { + delete this.emails[i].avatar; + this.setHasUnsavedChanges(); + this.requestUpdate(); + } + } + } + private checkPreferred(preferred?: boolean) { return preferred ?? false; }
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts index 7e090fb..d298894 100644 --- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts +++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -43,6 +43,7 @@ <tr> <th class="emailColumn">Email</th> <th class="preferredHeader">Preferred</th> + <th class="avatarHeader">Avatar</th> <th></th> </tr> </thead> @@ -53,6 +54,10 @@ <md-radio class="preferredRadio" name="preferred" tabindex="-1"> </md-radio> </td> + <td class="avatarControl"> + <md-radio class="avatarRadio" name="avatar" tabindex="0"> + </md-radio> + </td> <td> <gr-button aria-disabled="false" @@ -75,6 +80,10 @@ > </md-radio> </td> + <td class="avatarControl"> + <md-radio class="avatarRadio" name="avatar" tabindex="0"> + </md-radio> + </td> <td> <gr-button aria-disabled="true" @@ -93,6 +102,10 @@ <md-radio class="preferredRadio" name="preferred" tabindex="-1"> </md-radio> </td> + <td class="avatarControl"> + <md-radio class="avatarRadio" name="avatar" tabindex="0"> + </md-radio> + </td> <td> <gr-button aria-disabled="false" @@ -123,13 +136,19 @@ assert.equal(rows.length, 3); - assert.isFalse((rows[0].querySelector('md-radio') as MdRadio).checked); + assert.isFalse( + (rows[0].querySelector('md-radio.preferredRadio') as MdRadio).checked + ); assert.isNotOk(rows[0].querySelector('gr-button')!.disabled); - assert.isTrue((rows[1].querySelector('md-radio') as MdRadio).checked); + assert.isTrue( + (rows[1].querySelector('md-radio.preferredRadio') as MdRadio).checked + ); assert.isOk(rows[1].querySelector('gr-button')!.disabled); - assert.isFalse((rows[2].querySelector('md-radio') as MdRadio).checked); + assert.isFalse( + (rows[2].querySelector('md-radio.preferredRadio') as MdRadio).checked + ); assert.isNotOk(rows[2].querySelector('gr-button')!.disabled); assert.isFalse(hasUnsavedChangesSpy.called); @@ -144,7 +163,7 @@ const radios = element .shadowRoot!.querySelector('table')! - .querySelectorAll<HTMLInputElement>('md-radio'); + .querySelectorAll<HTMLInputElement>('md-radio.preferredRadio'); await element.updateComplete; assert.isFalse(hasUnsavedChangesSpy.called); @@ -215,7 +234,7 @@ // Delete the first email and set the last as preferred. rows[0].querySelector('gr-button')!.click(); - rows[2].querySelector<HTMLInputElement>('md-radio')!.click(); + rows[2].querySelector<HTMLInputElement>('md-radio.preferredRadio')!.click(); await element.updateComplete; assert.isTrue(hasUnsavedChangesSpy.called);
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts index 203d8a2..6bb104d 100644 --- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts +++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences.ts
@@ -215,19 +215,19 @@ }} > <md-select-option value="STD"> - <div slot="headline">Jun 3 ; Jun 3, 2016</div> + <div slot="headline">Jun 3 ; Jun 3, 2016 (STD)</div> </md-select-option> <md-select-option value="US"> - <div slot="headline">06/03 ; 06/03/16</div> + <div slot="headline">06/03 ; 06/03/16 (US)</div> </md-select-option> <md-select-option value="ISO"> - <div slot="headline">06-03 ; 2016-06-03</div> + <div slot="headline">06-03 ; 2016-06-03 (ISO)</div> </md-select-option> <md-select-option value="EURO"> - <div slot="headline">3. Jun ; 03.06.2016</div> + <div slot="headline">3. Jun ; 03.06.2016 (EURO)</div> </md-select-option> <md-select-option value="UK"> - <div slot="headline">03/06 ; 03/06/2016</div> + <div slot="headline">03/06 ; 03/06/2016 (UK)</div> </md-select-option> </md-outlined-select> <md-outlined-select
diff --git a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts index d407750..7780ca9 100644 --- a/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts +++ b/polygerrit-ui/app/elements/settings/gr-preferences/gr-preferences_test.ts
@@ -136,16 +136,16 @@ <span class="value"> <md-outlined-select value="UK"> <md-select-option md-menu-item="" tabindex="0" value="STD"> - <div slot="headline">Jun 3 ; Jun 3, 2016</div> + <div slot="headline">Jun 3 ; Jun 3, 2016 (STD)</div> </md-select-option> <md-select-option md-menu-item="" tabindex="-1" value="US"> - <div slot="headline">06/03 ; 06/03/16</div> + <div slot="headline">06/03 ; 06/03/16 (US)</div> </md-select-option> <md-select-option md-menu-item="" tabindex="-1" value="ISO"> - <div slot="headline">06-03 ; 2016-06-03</div> + <div slot="headline">06-03 ; 2016-06-03 (ISO)</div> </md-select-option> <md-select-option md-menu-item="" tabindex="-1" value="EURO"> - <div slot="headline">3. Jun ; 03.06.2016</div> + <div slot="headline">3. Jun ; 03.06.2016 (EURO)</div> </md-select-option> <md-select-option data-aria-selected="true" @@ -153,7 +153,7 @@ tabindex="-1" value="UK" > - <div slot="headline">03/06 ; 03/06/2016</div> + <div slot="headline">03/06 ; 03/06/2016 (UK)</div> </md-select-option> </md-outlined-select> <md-outlined-select value="HHMM_12">
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 1b5f324..cd27153 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
@@ -180,8 +180,6 @@ override connectedCallback() { super.connectedCallback(); - // 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('Settings'); } @@ -630,7 +628,6 @@ // Handle anchor tag after dom attached const urlHash = window.location.hash; if (urlHash) { - // Use shadowRoot for Polymer 2 const elem = (this.shadowRoot || document).querySelector(urlHash); if (elem) { setTimeout(() => elem.scrollIntoView(), 0);
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 7059228..9b1baad 100644 --- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts +++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -52,16 +52,17 @@ */ .content-wrapper { padding: var(--spacing-l) var(--spacing-xl); + display: flex; + align-items: center; } .text { color: var(--tooltip-text-color); - display: inline-block; max-height: 10rem; max-width: 80vw; - vertical-align: bottom; word-break: break-all; } gr-button.action { + flex-shrink: 0; --text-color: var(--tooltip-button-text-color); --gr-button-padding: 0 var(--spacing-s); margin-left: var(--spacing-l);
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 cd6745b..7f2cf17 100644 --- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts +++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -221,6 +221,7 @@ --gr-autocomplete-text-field-border-radius, var(--border-radius) ); + padding: var(--gr-autocomplete-text-field-padding, 0); } md-outlined-text-field.borderless { --md-outlined-text-field-outline-width: 0; @@ -415,6 +416,7 @@ updateSuggestions() { if (this.text === undefined || this.threshold === undefined) return; + if (!this.isConnected) return; // Reset suggestions for every update // This will also prevent from carrying over suggestions:
diff --git a/polygerrit-ui/app/elements/shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox.ts b/polygerrit-ui/app/elements/shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox.ts new file mode 100644 index 0000000..7157a0f --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox.ts
@@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../shared/gr-icon/gr-icon'; +import '@material/web/checkbox/checkbox'; +import {MdCheckbox} from '@material/web/checkbox/checkbox'; +import {css, html, LitElement, nothing} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; +import {subscribe} from '../../lit/subscription-controller'; +import {combineLatest} from 'rxjs'; +import {flowsModelToken} from '../../../models/flows/flows-model'; +import {changeModelToken} from '../../../models/change/change-model'; +import {resolve} from '../../../models/dependency'; +import {sharedStyles} from '../../../styles/shared-styles'; +import {formStyles} from '../../../styles/form-styles'; +import {getAppContext} from '../../../services/app-context'; +import {ChangeInfo} from '../../../types/common'; +import {ParsedChangeInfo} from '../../../types/types'; +import {fire} from '../../../utils/event-util'; +import {changeIsMerged} from '../../../utils/change-util'; +import {materialStyles} from '../../../styles/gr-material-styles'; + +export interface AutosubmitCheckedChangedEventDetail { + checked: boolean; +} +export type AutosubmitCheckedChangedEvent = + CustomEvent<AutosubmitCheckedChangedEventDetail>; + +declare global { + interface HTMLElementTagNameMap { + 'gr-autosubmit-checkbox': GrAutosubmitCheckbox; + } + interface HTMLElementEventMap { + 'autosubmit-checked-changed': AutosubmitCheckedChangedEvent; + } +} + +@customElement('gr-autosubmit-checkbox') +export class GrAutosubmitCheckbox extends LitElement { + @state() + change?: ParsedChangeInfo | ChangeInfo; + + @state() + isAutosubmitEnabled = false; + + @state() + showAutosubmitInfoMessage = false; + + @state() + autosubmitChecked = false; + + readonly getFlowsModel = resolve(this, flowsModelToken); + + private flowsDocumentationLink?: string; + + readonly getChangeModel = resolve(this, changeModelToken); + + private readonly reporting = getAppContext().reportingService; + + static override get styles() { + return [ + materialStyles, + formStyles, + sharedStyles, + css` + .autosubmit, + .autosubmit-info { + display: flex; + align-items: center; + border-radius: var(--border-radius); + color: var(--info-foreground); + } + .autosubmit-label { + display: flex; + align-items: center; + background-color: var(--info-background); + } + #autosubmit, + .autosubmit-text { + margin-left: var(--spacing-m); + } + .autosubmit-info gr-icon { + color: var(--info-foreground); + margin-right: var(--spacing-m); + } + md-checkbox { + --md-checkbox-container-size: 15px; + --md-checkbox-icon-size: 15px; + } + :host { + display: block; + margin: var(--spacing-m) 0; + } + `, + ]; + } + + constructor() { + super(); + subscribe( + this, + () => this.getChangeModel().change$, + x => (this.change = x) + ); + subscribe( + this, + () => + combineLatest([ + this.getFlowsModel().isAutosubmitEnabled$, + this.getFlowsModel().enabled$, + this.getFlowsModel().flows$, + this.getChangeModel().isOwner$, + this.getChangeModel().change$, + ]), + ([isAutosubmitEnabled, isFlowsEnabled, _, isOwner, change]) => { + const oldEnabled = this.isAutosubmitEnabled; + this.isAutosubmitEnabled = + isAutosubmitEnabled && + isFlowsEnabled && + !this.getFlowsModel().hasAutosubmitFlowAlready() && + isOwner && + !changeIsMerged(change); + if (this.isAutosubmitEnabled && !oldEnabled) { + this.reporting.reportInteraction('autosubmit-checkbox-shown'); + } + this.showAutosubmitInfoMessage = + isAutosubmitEnabled && + isFlowsEnabled && + this.getFlowsModel().hasAutosubmitFlowAlready(); + } + ); + subscribe( + this, + () => this.getFlowsModel().providers$, + providers => { + this.flowsDocumentationLink = providers + .map(p => p.getDocumentation()) + .find(doc => !!doc); + } + ); + } + + override render() { + const autosubmitMessage = + 'This change will submit/merge automatically when all requirements are met.'; + if (this.showAutosubmitInfoMessage) { + return html` + <div class="autosubmit-info"> + <gr-icon icon="info"></gr-icon> + <span>${autosubmitMessage}</span> + </div> + `; + } + if (this.isAutosubmitEnabled) { + return html` + <div class="autosubmit"> + <label class="autosubmit-label"> + <md-checkbox + id="autosubmit" + @change=${this.handleAutosubmitChanged} + ?checked=${this.autosubmitChecked} + ></md-checkbox> + <span class="autosubmit-text">Enable Autosubmit</span> + ${this.renderDocumentationLink()} + </label> + </div> + `; + } + return nothing; + } + + private renderDocumentationLink() { + if (!this.flowsDocumentationLink) return nothing; + return html` <a + class="help" + slot="trailing-icon" + href=${this.flowsDocumentationLink} + target="_blank" + rel="noopener noreferrer" + tabindex="-1" + @click=${() => + this.reporting.reportInteraction('flows-documentation-link-clicked')} + > + <md-icon-button touch-target="none" type="button"> + <gr-icon icon="help" title="read documentation"></gr-icon> + </md-icon-button> + </a>`; + } + + private handleAutosubmitChanged(e: Event) { + if (!(e.target instanceof MdCheckbox)) return; + this.autosubmitChecked = e.target.checked; + this.reporting.reportInteraction('autosubmit-checkbox-clicked', { + checked: this.autosubmitChecked, + }); + fire(this, 'autosubmit-checked-changed', {checked: this.autosubmitChecked}); + } + + getIsAutosubmitChecked() { + return this.autosubmitChecked; + } + + getIsAutosubmitEnabled() { + return this.isAutosubmitEnabled; + } +}
diff --git a/polygerrit-ui/app/elements/shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox_test.ts b/polygerrit-ui/app/elements/shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox_test.ts new file mode 100644 index 0000000..726f48f --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-autosubmit-checkbox/gr-autosubmit-checkbox_test.ts
@@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {userModelToken} from '../../../models/user/user-model'; +import '../../../test/common-test-setup'; +import './gr-autosubmit-checkbox'; +import { + FlowsModel, + flowsModelToken, + getSubmitCondition, + SUBMIT_ACTION_NAME, +} from '../../../models/flows/flows-model'; +import {AccountId, FlowStageState} from '../../../api/rest-api'; +import {GrIcon} from '../../shared/gr-icon/gr-icon'; +import {testResolver} from '../../../test/common-test-setup'; +import {query, queryAndAssert} from '../../../utils/common-util'; +import {isVisible, stubReporting} from '../../../test/test-utils'; +import {GrAutosubmitCheckbox} from './gr-autosubmit-checkbox'; +import {assert, fixture, html, waitUntil} from '@open-wc/testing'; +import { + createAccountDetailWithId, + createChange, + createFlow, + createParsedChange, +} from '../../../test/test-data-generators'; +import {changeModelToken} from '../../../models/change/change-model'; +import {ParsedChangeInfo} from '../../../types/types'; +import {ChangeStatus} from '../../../constants/constants'; + +suite('gr-autosubmit-checkbox tests', () => { + let element: GrAutosubmitCheckbox; + let reportStub: sinon.SinonStub; + + setup(async () => { + reportStub = stubReporting('reportInteraction'); + }); + + suite('autosubmit checkbox rendering', () => { + let flowsModel: FlowsModel; + + setup(async () => { + element = await fixture<GrAutosubmitCheckbox>(html` + <gr-autosubmit-checkbox></gr-autosubmit-checkbox> + `); + const change = createChange(); + const userModel = testResolver(userModelToken); + userModel.setAccount(createAccountDetailWithId(change.owner._account_id)); + element.getChangeModel().updateState({ + change: change as ParsedChangeInfo, + }); + element.getFlowsModel().updateState({ + isEnabled: true, + autosubmitProviders: [ + { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => '', + getSubmitAction: () => undefined, + }, + ], + }); + await element.updateComplete; + flowsModel = testResolver(flowsModelToken); + }); + + test('checkbox rendered when isAutosubmitEnabled is true', async () => { + element.isAutosubmitEnabled = true; + await element.updateComplete; + assert.isTrue(isVisible(queryAndAssert(element, '#autosubmit'))); + }); + + test('checkbox not rendered when isAutosubmitEnabled is false', async () => { + element.isAutosubmitEnabled = false; + await element.updateComplete; + assert.isNotOk(query(element, '#autosubmit')); + }); + + test('checkbox not rendered if autosubmit flow is already present', async () => { + const flow = createFlow({ + stages: [ + { + expression: { + condition: getSubmitCondition(), + action: {name: SUBMIT_ACTION_NAME}, + }, + state: FlowStageState.DONE, + }, + ], + }); + flowsModel.setState({...flowsModel.getState(), flows: [flow]}); + await waitUntil(() => flowsModel.getState().flows.length > 0); + + await element.updateComplete; + assert.isNotOk(query(element, '#autosubmit')); + }); + + test('isAutosubmitEnabled depends on isOwner', async () => { + const userModel = testResolver(userModelToken); + const changeModel = testResolver(changeModelToken); + + flowsModel.updateState({ + isEnabled: true, + autosubmitProviders: [ + { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => '', + getSubmitAction: () => undefined, + }, + ], + flows: [], + }); + + // Case 1: user is NOT owner + userModel.setAccount(createAccountDetailWithId(123 as AccountId)); + changeModel.updateStateChange({ + ...createParsedChange(), + owner: {_account_id: 456 as AccountId}, + }); + await element.updateComplete; + assert.isFalse(element.isAutosubmitEnabled); + + // Case 2: user IS owner + userModel.setAccount(createAccountDetailWithId(456 as AccountId)); + await element.updateComplete; + assert.isTrue(element.isAutosubmitEnabled); + }); + + test('isAutosubmitEnabled is false if change is merged', async () => { + const changeModel = testResolver(changeModelToken); + const userModel = testResolver(userModelToken); + + flowsModel.updateState({ + isEnabled: true, + autosubmitProviders: [ + { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => '', + getSubmitAction: () => undefined, + }, + ], + flows: [], + }); + + const change = { + ...createParsedChange(), + status: ChangeStatus.NEW, + owner: {_account_id: 456 as AccountId}, + }; + userModel.setAccount(createAccountDetailWithId(456 as AccountId)); + changeModel.updateStateChange(change); + await element.updateComplete; + assert.isTrue(element.isAutosubmitEnabled); + + changeModel.updateStateChange({ + ...change, + status: ChangeStatus.MERGED, + }); + await element.updateComplete; + assert.isFalse(element.isAutosubmitEnabled); + }); + }); + + suite('autosubmit info message rendering', () => { + setup(async () => { + element = await fixture<GrAutosubmitCheckbox>(html` + <gr-autosubmit-checkbox></gr-autosubmit-checkbox> + `); + }); + + test('info message rendered when showAutosubmitInfoMessage is true', async () => { + element.showAutosubmitInfoMessage = true; + await element.updateComplete; + const autosubmitInfo = queryAndAssert(element, '.autosubmit-info'); + assert.isTrue(isVisible(autosubmitInfo)); + const icon = queryAndAssert<GrIcon>(autosubmitInfo, 'gr-icon'); + assert.equal(icon.icon, 'info'); + const text = queryAndAssert(autosubmitInfo, 'span'); + assert.equal( + text.textContent, + 'This change will submit/merge automatically when all requirements are met.' + ); + }); + + test('info message not rendered when showAutosubmitInfoMessage is false', async () => { + element.showAutosubmitInfoMessage = false; + await element.updateComplete; + assert.isNotOk(query(element, '.autosubmit-info')); + }); + }); + + suite('reporting', () => { + setup(async () => { + element = await fixture<GrAutosubmitCheckbox>(html` + <gr-autosubmit-checkbox></gr-autosubmit-checkbox> + `); + }); + + test('reports when checkbox is shown', async () => { + reportStub.resetHistory(); + const flowsModel = testResolver(flowsModelToken); + const changeModel = testResolver(changeModelToken); + const userModel = testResolver(userModelToken); + + const change = createChange(); + userModel.setAccount(createAccountDetailWithId(change.owner._account_id)); + changeModel.updateState({ + change: change as ParsedChangeInfo, + }); + + flowsModel.updateState({ + isEnabled: true, + autosubmitProviders: [ + { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => '', + getSubmitAction: () => undefined, + }, + ], + }); + + await waitUntil(() => reportStub.calledWith('autosubmit-checkbox-shown')); + }); + + test('reports when checkbox is clicked', async () => { + element.isAutosubmitEnabled = true; + await element.updateComplete; + reportStub.resetHistory(); + + const checkbox = queryAndAssert<HTMLElement>(element, '#autosubmit'); + checkbox.click(); + + assert.isTrue( + reportStub.calledWith('autosubmit-checkbox-clicked', {checked: true}) + ); + }); + }); +});
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts index 4a2175d..131a29a 100644 --- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -61,6 +61,25 @@ }); }); + let generatedUrl: string | undefined; + + setup(() => { + // Prevent 404s by stubbing buildAvatarURL to return data URI for local paths + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const orig = (GrAvatar.prototype as any).buildAvatarURL; + sinon + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .stub(GrAvatar.prototype as any, 'buildAvatarURL') + .callsFake(function (this: GrAvatar, account: unknown) { + const url = orig.call(this, account); + if (url.startsWith('/accounts/')) { + generatedUrl = url; + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + } + return url; + }); + }); + test('loads correct size', async () => { const accountWithId = { ...createAccountWithId(123), @@ -71,10 +90,7 @@ ); assert.isTrue(isVisible(element)); - assert.equal( - element.style.backgroundImage, - 'url("/accounts/123/avatar?s=64")' - ); + assert.equal(generatedUrl, '/accounts/123/avatar?s=64'); }); test('loads using id', async () => { @@ -87,10 +103,7 @@ ); assert.isTrue(isVisible(element)); - assert.equal( - element.style.backgroundImage, - 'url("/accounts/123/avatar?s=16")' - ); + assert.equal(generatedUrl, '/accounts/123/avatar?s=16'); }); test('loads using email', async () => { @@ -103,10 +116,7 @@ ); assert.isTrue(isVisible(element)); - assert.equal( - element.style.backgroundImage, - 'url("/accounts/foo%40gmail.com/avatar?s=16")' - ); + assert.equal(generatedUrl, '/accounts/foo%40gmail.com/avatar?s=16'); }); test('loads using name', async () => { @@ -119,10 +129,7 @@ ); assert.isTrue(isVisible(element)); - assert.equal( - element.style.backgroundImage, - 'url("/accounts/John%20Doe/avatar?s=16")' - ); + assert.equal(generatedUrl, '/accounts/John%20Doe/avatar?s=16'); }); test('loads using username', async () => { @@ -135,10 +142,7 @@ ); assert.isTrue(isVisible(element)); - assert.equal( - element.style.backgroundImage, - 'url("/accounts/John_Doe/avatar?s=16")' - ); + assert.equal(generatedUrl, '/accounts/John_Doe/avatar?s=16'); }); test('loads using custom URL from matching height', async () => { @@ -185,10 +189,7 @@ ); assert.isTrue(isVisible(element)); - assert.equal( - element.style.backgroundImage, - 'url("/accounts/123/avatar?s=16")' - ); + assert.equal(generatedUrl, '/accounts/123/avatar?s=16'); }); }); });
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 78b9ea4..eaf5c8c 100644 --- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts +++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -14,6 +14,7 @@ import {Interaction} from '../../../constants/reporting'; import '@material/web/button/elevated-button'; import '@material/web/button/text-button'; +import {materialStyles} from '../../../styles/gr-material-styles'; declare global { interface HTMLElementTagNameMap { @@ -52,13 +53,14 @@ loading = false; @property({type: Boolean, reflect: true}) - disabled: boolean | null = null; + disabled = false; // Private but used in tests. readonly reporting = getAppContext().reportingService; static override get styles() { return [ + materialStyles, votingStyles, spinnerStyles, css` @@ -115,7 +117,6 @@ a bigger width and thus a lot of space. This fixes it. */ --md-text-button-leading-space: 0; --md-text-button-trailing-space: 0; - /* Brings back the round corners we had with paper-button */ --md-text-button-container-shape: 4px; --md-elevated-button-container-shape: 4px; /* We have a variable for setting the text colour when it is disabled */ @@ -124,7 +125,6 @@ align-items: center; background-color: var(--background-color); color: var(--text-color); - /* paper-button set this but md-(elevated|text)-button does not. So we set it. */ font: inherit; /* This is also set in the button-label-(font|weight) css vars above. We keep this incase it is also needed. */ font-family: var(--font-family, inherit); @@ -210,7 +210,6 @@ class=${buttonClass} ?disabled=${this.disabled || this.loading} part="md-elevated-button" - touch-target="none" role="button" tabindex="-1" > @@ -228,7 +227,6 @@ class=${buttonClass} ?disabled=${this.disabled || this.loading} part="md-text-button" - touch-target="none" role="button" tabindex="-1" >
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts index ec48933..b95833a 100644 --- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -6,7 +6,6 @@ import * as sinon from 'sinon'; import '../../../test/common-test-setup'; import './gr-button'; -import {addListener} from '@polymer/polymer/lib/utils/gestures'; import {assert, fixture, html} from '@open-wc/testing'; import {GrButton} from './gr-button'; import {pressKey, queryAndAssert} from '../../../test/test-utils'; @@ -18,11 +17,7 @@ const addSpyOn = function (eventName: string) { const spy = sinon.spy(); - if (eventName === 'tap') { - addListener(element, eventName, spy); - } else { - element.addEventListener(eventName, spy); - } + element.addEventListener(eventName, spy); return spy; }; @@ -39,7 +34,6 @@ data-role="button" part="md-elevated-button" tabindex="-1" - touch-target="none" value="" > <div class="flex"> @@ -132,20 +126,12 @@ assert.equal(tabIndexElement.getAttribute('tabindex'), '3'); }); - // 'tap' event is tested so we don't loose backward compatibility with older - // plugins who didn't move to on-click which is faster and well supported. test('dispatches click event', () => { const spy = addSpyOn('click'); element.click(); assert.isTrue(spy.calledOnce); }); - test('dispatches tap event', () => { - const spy = addSpyOn('tap'); - element.click(); - assert.isTrue(spy.calledOnce); - }); - test('dispatches click from tap event', () => { const spy = addSpyOn('click'); element.click();
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 9c694bf..c8524a6 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
@@ -10,7 +10,7 @@ import {sharedStyles} from '../../../styles/shared-styles'; import {css, html, LitElement, PropertyValues} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; -import {createSearchUrl} from '../../../models/views/search'; +import {createChangeUrl} from '../../../models/views/change'; export const WIP_TOOLTIP = "This change isn't ready to be reviewed or submitted. " + @@ -189,7 +189,10 @@ // private but used in test getStatusLink(): string { if (this.revertedChange) { - return createSearchUrl({query: `${this.revertedChange._number}`}); + return createChangeUrl({ + changeNum: this.revertedChange._number, + repo: this.revertedChange.project, + }); } if ( this.status === ChangeStates.MERGE_CONFLICT &&
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 4106672..686e647 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
@@ -135,7 +135,10 @@ element.resolveWeblinks = []; element.status = status; assert.isTrue(element.hasStatusLink()); - assert.equal(element.getStatusLink(), `/q/${TEST_NUMERIC_CHANGE_ID}`); + assert.equal( + element.getStatusLink(), + `/c/${revertedChange.project}/+/${TEST_NUMERIC_CHANGE_ID}` + ); }); test('private', async () => {
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 ce87129..0e0dde7 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,6 +19,7 @@ } from 'lit/decorators.js'; import { computeDiffFromContext, + computeDisplayLine, createNewReply, getFirstComment, getLastComment, @@ -32,6 +33,7 @@ createDefaultDiffPrefs, SpecialFilePath, } from '../../../constants/constants'; +import {KnownExperimentId} from '../../../services/flags/flags'; import {computeDisplayPath} from '../../../utils/path-list-util'; import { AccountDetailInfo, @@ -48,7 +50,6 @@ import { CommentRangeLayer, DiffLayer, - FILE, RenderPreferences, Side, } from '../../../api/diff'; @@ -504,7 +505,7 @@ renderFilePath() { if (!this.showFilePath) return; const href = this.getUrlForFileComment(); - const line = this.computeDisplayLine(); + const line = computeDisplayLine(this.thread!); return html` ${this.renderFileName()} <div class="pathInfo"> @@ -862,15 +863,6 @@ return computeDisplayPath(this.thread?.path); } - private computeDisplayLine() { - assertIsDefined(this.thread, 'thread'); - if (this.thread.line === FILE) return this.isPatchsetLevel() ? '' : FILE; - if (this.thread.line) return `#${this.thread.line}`; - // If range is set, then lineNum equals the end line of the range. - if (this.thread.range) return `#${this.thread.range.end_line}`; - return ''; - } - private getFirstComment() { assertIsDefined(this.thread); return getFirstComment(this.thread); @@ -1007,6 +999,13 @@ } private shouldShowAIFixButton(): boolean { + if ( + !getAppContext().flagsService.isEnabled( + KnownExperimentId.ML_SUGGESTED_EDIT_GET_FIX + ) + ) { + return false; + } if (!this.thread || !this.account) return false; if (this.thread.comments.length !== 1) return false; const comment = this.thread.comments[0];
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 61c23d8..28ebd79 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
@@ -9,6 +9,7 @@ import {sortComments} from '../../../utils/comment-util'; import {GrCommentThread} from './gr-comment-thread'; import { + AccountId, CommentInfo, CommentThread, DraftInfo, @@ -24,6 +25,7 @@ mockPromise, query, queryAndAssert, + stubFlags, stubRestApi, waitUntil, waitUntilCalled, @@ -55,14 +57,14 @@ import {suggestionsServiceToken} from '../../../services/suggestions/suggestions-service'; const c1: CommentInfo = { - author: {name: 'Kermit'}, + author: {name: 'Kermit', _account_id: 1 as AccountId}, id: 'the-root' as UrlEncodedCommentId, message: 'start the conversation', updated: '2021-11-01 10:11:12.000000000' as Timestamp, }; const c2: CommentInfo = { - author: {name: 'Ms Piggy'}, + author: {name: 'Ms Piggy', _account_id: 2 as AccountId}, id: 'the-reply' as UrlEncodedCommentId, message: 'keep it going', updated: '2021-11-02 10:11:12.000000000' as Timestamp, @@ -70,7 +72,7 @@ }; const c3: DraftInfo = { - author: {name: 'Kermit'}, + author: {name: 'Kermit', _account_id: 1 as AccountId}, id: 'the-draft' as UrlEncodedCommentId, message: 'stop it', updated: '2021-11-03 10:11:12.000000000' as Timestamp, @@ -79,7 +81,7 @@ }; const commentWithContext = { - author: {name: 'Kermit'}, + author: {name: 'Kermit', _account_id: 1 as AccountId}, id: 'the-draft' as UrlEncodedCommentId, message: 'just for context', updated: '2021-11-03 10:11:12.000000000' as Timestamp, @@ -343,6 +345,7 @@ .stub(testResolver(commentsModelToken), 'saveDraft') .returns(savePromise); stubAdd = sinon.stub(testResolver(commentsModelToken), 'addNewDraft'); + sinon.stub(testResolver(commentsModelToken), 'discardDraft'); element.thread = createThread(c1, {...c2, unresolved: true}); await element.updateComplete; @@ -604,6 +607,7 @@ sinon .stub(suggestionsService, 'isGeneratedSuggestedFixEnabled') .returns(true); + stubFlags('isEnabled').returns(true); element.isOwner = true; element.account = createAccountDetailWithId(13); @@ -705,9 +709,10 @@ comments: [ { ...createComment(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any id: '123' as any, message: 'Test comment', - author: {name: 'Test User'}, + author: {name: 'Test User', _account_id: 12345 as AccountId}, patch_set: 1 as RevisionPatchSetNum, line: 10, path: 'test.txt', @@ -717,6 +722,7 @@ element.thread = thread; element.changeNum = 123 as NumericChangeId; const createReplyCommentSpy = sinon.spy( + // eslint-disable-next-line @typescript-eslint/no-explicit-any element as any, 'createReplyComment' );
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 2881ac5..14187b8 100644 --- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts +++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -1273,13 +1273,7 @@ if (!suggestion) return; this.generatedFixSuggestion = suggestion; - try { - await waitUntil(() => this.getFixSuggestions() !== undefined); - this.autoSaveTrigger$.next(); - } catch (error) { - // Error is ok in some cases like quick save by user. - console.warn(error); - } + this.autoSaveTrigger$.next(); } // private but visible for testing
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 1f5a9eb..3d57456 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
@@ -30,6 +30,7 @@ Timestamp, UrlEncodedCommentId, } from '../../../types/common'; +import {RevisionPatchSetNum} from '../../../api/rest-api'; import { createComment, createDraft, @@ -41,6 +42,7 @@ import {SinonStub, SinonStubbedMember} from 'sinon'; import {assert, fixture, html} from '@open-wc/testing'; import {GrButton} from '../gr-button/gr-button'; +import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content'; import {testResolver} from '../../../test/common-test-setup'; import { CommentsModel, @@ -384,7 +386,18 @@ <gr-confirm-delete-comment-dialog id="confirmDeleteCommentDialog"> </gr-confirm-delete-comment-dialog> </dialog> - ` + `, + {ignoreAttributes: ['title']} + ); + + const tooltip = queryAndAssert<GrTooltipContent>( + element, + '.draftTooltip' + ); + assert.equal( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tooltip.getAttribute('title') || (tooltip as any).originalTitle, + "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." ); }); }); @@ -1108,7 +1121,11 @@ '#suggestionDiffPreview' ); suggestionDiffPreview.previewed = true; - suggestionDiffPreview.previewLoadedFor = generatedFixSuggestion; + suggestionDiffPreview.previewLoadedFor = { + fixSuggestionInfo: generatedFixSuggestion, + changeNum: 42 as NumericChangeId, + patchSet: 1 as RevisionPatchSetNum, + }; await element.updateComplete; // trigger event preview-loaded on suggestionDiffPreview with detail suggestionDiffPreview.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts new file mode 100644 index 0000000..0e0638e --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar.ts
@@ -0,0 +1,171 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {customElement, property, query, state} from 'lit/decorators.js'; +import {css, html, LitElement} from 'lit'; +import {styleMap} from 'lit/directives/style-map.js'; + +const SIDEBAR_MIN_WIDTH = 400; + +/** + * A component that displays content in a main area and a resizable sidebar. + * The sidebar can be toggled between hidden and visible. + * + * slot main - The content to be displayed in the main area. + * slot side - The content to be displayed in the sidebar. + */ +@customElement('gr-content-with-sidebar') +export class GrContentWithSidebar extends LitElement { + @query('.sidebar-wrapper') sidebarWrapper?: HTMLElement; + + @state() + private sidebarWidthPx = SIDEBAR_MIN_WIDTH; + + @property() + hideSide = true; + + private isSidebarResizing = false; + + private sidebarResizingStartPosPx = 0; + + private sidebarResizingStartWidthPx = 0; + + private readonly boundResizeSidebar = (e: MouseEvent) => + this.resizeSidebar(e); + + private readonly boundStopSidebarResize = () => this.stopSidebarResize(); + + static override get styles() { + return [ + css` + :host { + display: block; + position: relative; + --sidebar-height: calc(100vh - var(--sidebar-top)); + } + .sidebar-wrapper { + z-index: 50; + position: absolute; + display: flex; + top: 0; + bottom: calc(0px - var(--sidebar-bottom-overflow)); + right: 0; + min-width: 400px; + max-width: 100%; + background-color: var(--background-color-secondary); + } + .sidebar { + position: sticky; + top: var(--sidebar-top); + height: var(--sidebar-height); + box-sizing: border-box; + overflow: auto; + flex-grow: 1; + font-size: 14px; + } + .resizer-wrapper { + position: sticky; + top: var(--sidebar-top); + height: var(--sidebar-height); + z-index: 51; + } + .resizer { + background-color: var(--background-color-secondary); + width: 7px; + border-left: 1px solid var(--border-color); + cursor: ew-resize; + position: absolute; + top: 0; + bottom: 0; + left: -7px; + box-sizing: border-box; + } + .resizer:hover { + background-color: var(--background-color-tertiary); + width: 11px; + left: -9px; + } + `, + ]; + } + + override render() { + const widthPx = this.hideSide ? 0 : this.sidebarWidthPx; + return html` + <div> + <div style=${styleMap({width: `calc(100% - ${widthPx}px)`})}> + <slot name="main"></slot> + </div> + ${this.renderSidebar()} + </div> + `; + } + + private renderSidebar() { + if (this.hideSide) return; + return html` + <div + class="sidebar-wrapper" + style=${styleMap({width: `${this.sidebarWidthPx}px`})} + > + <div class="resizer-wrapper"> + <div + class="resizer" + role="separator" + aria-orientation="vertical" + aria-valuenow=${this.sidebarWidthPx} + aria-label="Resize sidebar" + tabindex="0" + @mousedown=${this.startSidebarResize} + ></div> + </div> + <div class="sidebar"> + <slot name="side"></slot> + </div> + </div> + `; + } + + private startSidebarResize(event: MouseEvent) { + if (this.isSidebarResizing) return; + + // Disable user selection while resizing. + document.body.style.setProperty('user-select', 'none'); + this.isSidebarResizing = true; + this.sidebarResizingStartPosPx = event.clientX; + this.sidebarResizingStartWidthPx = + this.sidebarWrapper!.getBoundingClientRect().width; + window.addEventListener('mousemove', this.boundResizeSidebar); + window.addEventListener('mouseup', this.boundStopSidebarResize); + } + + private stopSidebarResize() { + if (!this.isSidebarResizing) return; + + // Re-enable user selection when resizing is done. + document.body.style.setProperty('user-select', 'auto'); + this.isSidebarResizing = false; + this.sidebarResizingStartPosPx = 0; + this.sidebarResizingStartWidthPx = 0; + window.removeEventListener('mousemove', this.boundResizeSidebar); + window.removeEventListener('mouseup', this.boundStopSidebarResize); + } + + private resizeSidebar(event: MouseEvent) { + if (!this.isSidebarResizing || event.buttons === 0) return; + + const widthDiffPx = event.clientX - this.sidebarResizingStartPosPx; + this.sidebarWidthPx = Math.max( + this.sidebarResizingStartWidthPx - widthDiffPx, + SIDEBAR_MIN_WIDTH + ); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'gr-content-with-sidebar': GrContentWithSidebar; + } +}
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts new file mode 100644 index 0000000..52ea31d --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_screenshot_test.ts
@@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import './gr-content-with-sidebar'; +import {fixture, html} from '@open-wc/testing'; +// Until https://github.com/modernweb-dev/web/issues/2804 is fixed +// @ts-ignore +import {visualDiff} from '@web/test-runner-visual-regression'; +import {visualDiffDarkTheme} from '../../../test/test-utils'; +import {GrContentWithSidebar} from './gr-content-with-sidebar'; + +suite('gr-content-with-sidebar screenshot tests', () => { + let wrapper: HTMLDivElement; + + setup(async () => { + wrapper = await fixture<HTMLDivElement>( + html` <div + style="position: relative; padding-top: 50px; background: pink; width: 800px;" + > + <div style="height: 50px; background: orange;">Header / Banner</div> + <gr-content-with-sidebar style="--sidebar-top: 50px;"> + <div + slot="main" + style="height: 500px; background: lightblue; width: 400px; padding: 20px; box-sizing: border-box;" + > + Main content area + </div> + <div + slot="side" + style="height: 600px; background: lightgray; padding: 20px; box-sizing: border-box;" + > + Sidebar content + </div> + </gr-content-with-sidebar> + </div>` + ); + const element = wrapper.querySelector<GrContentWithSidebar>( + 'gr-content-with-sidebar' + )!; + element.hideSide = false; + await element.updateComplete; + }); + + test('screenshot', async () => { + await visualDiff(wrapper, 'gr-content-with-sidebar'); + await visualDiffDarkTheme(wrapper, 'gr-content-with-sidebar'); + }); +});
diff --git a/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts new file mode 100644 index 0000000..7905c0a --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-content-with-sidebar/gr-content-with-sidebar_test.ts
@@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../../test/common-test-setup'; +import {assert, fixture, html} from '@open-wc/testing'; +import './gr-content-with-sidebar'; +import {GrContentWithSidebar} from './gr-content-with-sidebar'; + +suite('gr-content-with-sidebar tests', () => { + let element: GrContentWithSidebar; + + setup(async () => { + element = await fixture<GrContentWithSidebar>( + html`<gr-content-with-sidebar></gr-content-with-sidebar>` + ); + await element.updateComplete; + }); + + test('renders no sidebar', async () => { + element.hideSide = true; + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* HTML */ ` + <div> + <div style="width:calc(100% - 0px);"> + <slot name="main"></slot> + </div> + </div> + ` + ); + }); + + test('renders sidebar', async () => { + element.hideSide = false; + await element.updateComplete; + + assert.shadowDom.equal( + element, + /* HTML */ ` + <div> + <div style="width: calc(100% - 400px);"> + <slot name="main"> </slot> + </div> + <div class="sidebar-wrapper" style="width:400px;"> + <div class="resizer-wrapper"> + <div + aria-label="Resize sidebar" + aria-orientation="vertical" + aria-valuenow="400" + class="resizer" + role="separator" + tabindex="0" + ></div> + </div> + <div class="sidebar"> + <slot name="side"> </slot> + </div> + </div> + </div> + ` + ); + }); +});
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 8e2a791..0ee4aa3 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
@@ -49,7 +49,7 @@ @property({type: Boolean}) hasTooltip = false; - @property({type: Boolean}) + @property({type: Boolean, reflect: true}) hideInput = false; @property({type: String}) @@ -68,6 +68,14 @@ @property({type: Boolean, reflect: true}) nowrap = false; + /** + * By default, the copy icon is small (20x20px) to fit inline alongside text. + * Consumer components can set this to false to use the standard 24x24px icon + * size, which also removes the default extra button padding when hideInput is true. + */ + @property({type: Boolean}) + smallIcon = true; + @query('#icon') iconEl!: GrIcon; @@ -191,7 +199,11 @@ aria-description="Click to copy to clipboard" > <div> - <gr-icon id="icon" icon="content_copy" small></gr-icon> + <gr-icon + id="icon" + icon="content_copy" + ?small=${this.smallIcon} + ></gr-icon> </div> </gr-button> </gr-tooltip-content>
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 83adf10..b50ecfd 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
@@ -117,8 +117,6 @@ }); test('handleInputClick', () => { - // iron-input as parent should never be hidden as copy won't work - // on nested hidden elements const mdOutlinedTextField = queryAndAssert<MdOutlinedTextField>( element, 'md-outlined-text-field' @@ -131,8 +129,6 @@ }); test('hideInput', async () => { - // iron-input as parent should never be hidden as copy won't work - // on nested hidden elements const mdOutlinedTextField = queryAndAssert<MdOutlinedTextField>( element, 'md-outlined-text-field'
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 fdbfdd5..abe8fc6 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
@@ -22,6 +22,7 @@ import {resolve} from '../../../models/dependency'; import {userModelToken} from '../../../models/user/user-model'; import {subscribe} from '../../lit/subscription-controller'; +import {PeriodicUpdateManager} from '../../../utils/periodic-update-util'; const TimeFormats = { TIME_12: 'h:mm A', // 2:14 PM @@ -64,6 +65,12 @@ } } +const REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export const dateFormatterManager = new PeriodicUpdateManager<GrDateFormatter>( + REFRESH_INTERVAL_MS +); + @customElement('gr-date-formatter') export class GrDateFormatter extends LitElement { @property({type: String}) @@ -119,6 +126,16 @@ ); } + override connectedCallback() { + super.connectedCallback(); + dateFormatterManager.register(this); + } + + override disconnectedCallback() { + dateFormatterManager.unregister(this); + super.disconnectedCallback(); + } + // private but used by tests setPreferences(prefs: PreferencesInfo) { this.decideDateFormat(prefs.date_format);
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 53f0be5..225d730 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
@@ -143,9 +143,6 @@ .value=${convertToString(this.diffPrefs?.line_length)} @input=${this.handleDiffLineLengthInput} @beforeinput=${(e: InputEvent) => { - // In iron-input we had allowedPattern, but this is not supported - // in md-outlined-text-field. Which uses native input functionality. - // We workaround this. const data = e.data; if (data && !/^[0-9]*$/.test(data)) { e.preventDefault(); @@ -166,9 +163,6 @@ .value=${convertToString(this.diffPrefs?.tab_size)} @input=${this.handleDiffTabSizeInput} @beforeinput=${(e: InputEvent) => { - // In iron-input we had allowedPattern, but this is not supported - // in md-outlined-text-field. Which uses native input functionality. - // We workaround this. const data = e.data; if (data && !/^[0-9]*$/.test(data)) { e.preventDefault(); @@ -189,9 +183,6 @@ .value=${convertToString(this.diffPrefs?.font_size)} @input=${this.handleDiffFontSizeInput} @beforeinput=${(e: InputEvent) => { - // In iron-input we had allowedPattern, but this is not supported - // in md-outlined-text-field. Which uses native input functionality. - // We workaround this. const data = e.data; if (data && !/^[0-9]*$/.test(data)) { e.preventDefault();
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 65c7241..c913c8c 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
@@ -28,6 +28,7 @@ import {MdMenu} from '@material/web/menu/menu'; import {isSafari, Key} from '../../../utils/dom-util'; import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager'; +import {materialStyles} from '../../../styles/gr-material-styles'; /** * Required values are text and value. mobileText and triggerText will @@ -110,6 +111,7 @@ static override get styles() { return [ + materialStyles, sharedStyles, css` :host { @@ -138,6 +140,10 @@ --md-divider-color: var(--border-color); } md-menu-item { + -moz-user-select: text; + -ms-user-select: text; + -webkit-user-select: text; + user-select: text; max-height: 70vh; min-width: 266px; --md-sys-color-on-surface: var( @@ -202,6 +208,7 @@ .copyClipboard { display: inline-flex; vertical-align: top; + margin-left: var(--gr-dropdown-copy-clipboard-margin-left, 0); } .mobileText { display: none; @@ -367,10 +374,16 @@ private renderMdMenuItem(item: DropdownItem, index: number) { return html` <md-menu-item + keep-open ?selected=${this.value === String(item.value)} ?active=${this.value === String(item.value)} ?disabled=${!!item.disabled} - @click=${() => { + @click=${(e: Event) => { + if (document.getSelection()?.toString().length !== 0) { + e.preventDefault(); + e.stopPropagation(); + return; + } this.value = String(item.value); }} @keydown=${(e: KeyboardEvent) => { @@ -482,6 +495,9 @@ */ private handleDropdownClick() { assertIsDefined(this.dropdown); + if (document.getSelection()?.toString().length !== 0) { + return; + } this.dropdown.close(); }
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 dae104a..5fb5ffc 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
@@ -81,7 +81,7 @@ quick="" tabindex="-1" > - <md-menu-item md-menu-item="" tabindex="0"> + <md-menu-item keep-open="" md-menu-item="" tabindex="0"> <div class="topContent"> <div> <span class="desktopText"> @@ -93,7 +93,7 @@ </div> </md-menu-item> <md-divider role="separator" tabindex="-1"> </md-divider> - <md-menu-item active="" md-menu-item="" selected="" tabindex="-1"> + <md-menu-item active="" keep-open="" md-menu-item="" selected="" tabindex="-1"> <div class="topContent"> <div> <span class="desktopText"> @@ -107,7 +107,7 @@ </div> </md-menu-item> <md-divider role="separator" tabindex="-1"> </md-divider> - <md-menu-item disabled="" md-menu-item="" tabindex="-1"> + <md-menu-item disabled="" keep-open="" md-menu-item="" tabindex="-1"> <div class="topContent"> <div> <span class="desktopText"> @@ -161,6 +161,7 @@ }); test('computeMobileText', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const item: any = { value: 1, text: '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 910d481..65efb57 100644 --- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts +++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -23,6 +23,7 @@ import '@material/web/menu/menu'; import '@material/web/menu/menu-item'; import {MdMenu} from '@material/web/menu/menu'; +import {materialStyles} from '../../../styles/gr-material-styles'; const REL_NOOPENER = 'noopener'; const REL_EXTERNAL = 'external'; @@ -51,6 +52,7 @@ static override get styles() { return [ + materialStyles, sharedStyles, css` :host {
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 d490423..4b88159 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
@@ -192,6 +192,7 @@ override disconnectedCallback() { this.storeTask?.flush(); + this.formatCheckTask?.cancel(); super.disconnectedCallback(); }
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 60cfc34..1eb6bca 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
@@ -328,6 +328,11 @@ await element.updateComplete; }); + teardown(() => { + clock.runAll(); + clock.restore(); + }); + test('toggles between Format and Undo', async () => { const formatButton = queryAndAssert<GrButton>( element,
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 634a83d..6c2661f 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
@@ -23,6 +23,7 @@ import {MdMenu} from '@material/web/menu/menu'; import '@material/web/textfield/filled-text-field'; import {MdFilledTextField} from '@material/web/textfield/filled-text-field'; +import {materialStyles} from '../../../styles/gr-material-styles'; const AWAIT_MAX_ITERS = 10; const AWAIT_STEP = 5; @@ -87,6 +88,7 @@ static override get styles() { return [ + materialStyles, sharedStyles, css` :host {
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_test.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_test.ts new file mode 100644 index 0000000..acfcbf8 --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions_test.ts
@@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {assert, fixture, html} from '@open-wc/testing'; +import '../../../test/common-test-setup'; +import './gr-fix-suggestions'; +import {GrFixSuggestions} from './gr-fix-suggestions'; +import { + createComment, + createFixSuggestionInfo, +} from '../../../test/test-data-generators'; +import {PatchSetNumber} from '../../../types/common'; + +suite('gr-fix-suggestions', () => { + let element: GrFixSuggestions; + + setup(async () => { + element = await fixture<GrFixSuggestions>( + html`<gr-fix-suggestions + .generated_fix_suggestions=${[createFixSuggestionInfo()]} + .comment=${{ + ...createComment(), + id: '1', + patch_set: 1 as PatchSetNumber, + }} + ></gr-fix-suggestions>` + ); + }); + + test('render', async () => { + await element.updateComplete; + assert.shadowDom.equal( + element, + /* HTML */ ` + <div class="header"> + <div class="title"> + <span> Suggested edit </span> + <a + href="/Documentation/user-suggest-edits.html" + rel="noopener noreferrer" + target="_blank" + > + <gr-endpoint-decorator name="fix-suggestion-title-help"> + <gr-endpoint-param name="suggestion"> </gr-endpoint-param> + <gr-icon icon="help" title="read documentation"> </gr-icon> + </gr-endpoint-decorator> + </a> + </div> + <div class="headerMiddle"> + <gr-button + aria-disabled="false" + class="action show-fix" + flatten="" + role="button" + secondary="" + tabindex="0" + > + Show Edit + </gr-button> + <div class="show-hide" tabindex="0"> + <label aria-label="Collapse" class="show-hide"> + <md-checkbox class="show-hide"> </md-checkbox> + <gr-icon icon="expand_less" id="icon"> </gr-icon> + </label> + </div> + </div> + </div> + <gr-suggestion-diff-preview> </gr-suggestion-diff-preview> + ` + ); + }); +});
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 3259511..95b6851 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
@@ -7,10 +7,8 @@ import {customElement, property, state} from 'lit/decorators.js'; import { htmlEscape, - sanitizeHtml, sanitizeHtmlToFragment, } from '../../../utils/inner-html-util'; -import {unescapeHTML} from '../../../utils/syntax-util'; import {resolve} from '../../../models/dependency'; import {subscribe} from '../../lit/subscription-controller'; import {configModelToken} from '../../../models/config/config-model'; @@ -24,6 +22,7 @@ } from '../../../utils/comment-util'; import {sameOrigin} from '../../../utils/url-util'; import '../gr-marked-element/gr-marked-element'; +import {Renderer, Tokenizer, Tokens} from 'marked'; // MIME types for images we allow showing. Do not include SVG, it can contain // arbitrary JavaScript. @@ -121,6 +120,7 @@ /* 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; + overflow-wrap: break-word; } `, ]; @@ -139,6 +139,12 @@ link: '$1', enabled: true, }; + // Linkify email addresses. + this.repoCommentLinks['ALWAYS_LINK_EMAIL'] = { + match: '([\\w.+-]+@[\\w.-]+\\.[\\w]{2,})', + link: 'mailto:$1', + enabled: true, + }; // List of common TLDs to specifically match for schemeless URLs. const TLD_REGEX = [ @@ -216,51 +222,31 @@ private renderAsMarkdown() { // 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>`; - }; + // + // <gr-marked-element> internals will be in charge of calling our custom + // renderer so we write this utility function separately so that 'this' is + // preserved via closure. + const boundRewriteText = (text: string) => + linkifyUrlsAndApplyRewrite(text, this.repoCommentLinks); const allowMarkdownBase64ImagesInComments = this.allowMarkdownBase64ImagesInComments; // We are overriding some gr-marked-element renderers for a few reasons: // 1. Disable inline images as a design/policy choice. - // 2. Inline code blocks ("codespan") do not unescape HTML characters when - // rendering without <pre> and so we must do this manually. - // <gr-marked-element> is already escaping these internally. See test - // covering this. - // 3. Multiline code blocks ("code") is similarly handling escaped - // characters using <pre>. The convention is to only use <pre> for multi- - // line code blocks so it is not used for inline code blocks. See test - // for this. - // 4. Rewrite plain text ("text") to apply linking and other config-based + // 2. Rewrite plain text ("text") to apply linking and other config-based // rewrites. Text within code blocks is not passed here. - // 5. Open links in a new tab by rendering with target="_blank" attribute. - // 6. Relative links without "/" prefix are assumed to be absolute links. - function customRenderer(renderer: {[type: string]: Function}) { - renderer['link'] = (href: string, title: string, text: string) => { + // 3. Open links in a new tab by rendering with target="_blank" attribute. + // 4. Relative links without "/" prefix are assumed to be absolute links. + function patchRenderer(renderer: Renderer) { + // Use the `function` syntax, so that we can add type annotation for + // `this`, allowing the overridden methods access to all members of + // the merged Renderer object, including to `this.parser`. + renderer.link = function ( + this: Renderer, + {href, title, tokens}: Tokens.Link + ): string { + const text = this.parser.parseInline(tokens); if ( !href.startsWith('https://') && !href.startsWith('mailto:') && @@ -277,7 +263,11 @@ >${text}</a >`; }; - renderer['image'] = (href: string, title: string, text: string) => { + + renderer.image = function ( + this: Renderer, + {href, title, text}: Tokens.Image + ): string { // Check if this is a base64-encoded image if ( allowMarkdownBase64ImagesInComments && @@ -290,73 +280,94 @@ // For non-base64 images just return the markdown return ``; }; - renderer['codespan'] = (text: string) => - `<code>${unescapeHTML(text)}</code>`; - renderer['code'] = (text: string, infostring: string) => { - if (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. + + renderer.code = function (this: Renderer, token: Tokens.Code): string { + if (token.lang === USER_SUGGESTION_INFO_STRING) { + // Default sanitizer in gr-marked-element is very restrictive, so + // we need to use an existing html element to insert the content to. + // 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>`; + return `<mark>${ + token.escaped ? token.text : htmlEscape(token.text) + }</mark>`; } + // Fall back to default renderer's `code` function. + return Renderer.prototype.code.call(this, token); }; - // <gr-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; + + // Treat HTML as plaintext and don't render it. + // Assumes that inline HTML is already disabled in the tokenizer, so it + // needs to render only block-level HTML. + renderer.html = function (this: Renderer, {text}: Tokens.HTML): string { + // Keep all new lines except the trailing ones, thus respecting + // the `breaks: true` option. + text = text.replace(/\n+$/, ''); + return ( + '<p>' + htmlEscape(text).toString().replaceAll('\n', '<br>') + '</p>' + ); + }; + + renderer.text = function ( + this: Renderer, + token: Tokens.Text | Tokens.Escape + ): string { + // Don't process text in raw blocks. + if (token.type === 'escape') { + return htmlEscape(token.text).toString(); + } + // Recurse when not in a terminal node. + if (token.type === 'text' && token.tokens) { + return this.parser.parseInline(token.tokens); + } + return boundRewriteText( + token.type === 'text' && token.escaped + ? token.text + : htmlEscape(token.text).toString() + ); + }; + } + + // Disables "marked"'s default autolinking of URLs and emails, since we + // want to use our own custom linkification. Disables tokenizing of + // inline HTML tags, so that they are treated as text. + function patchTokenizer(tokenizer: Tokenizer) { + // Return undefined to skip the default autolink/url tokenizers. + tokenizer.url = () => undefined; + tokenizer.autolink = () => undefined; + + // Return undefined to skip the default tag tokenizer. This effectively + // causes _inline_ HTML tags to be treated as text, as opposed to HTML. + // + // Preventing rendering of inline HTML should better happen in the + // tokenizer (as opposed to the renderer), since the default `tag` + // tokenizer makes changes to the lexer's state for some HTML tags + // (like <a>), which is undesired. + tokenizer.tag = () => undefined; } // The child with slot is optional but allows us control over the styling. - // The `callback` property lets us do a final sanitization of the output - // HTML string before it is rendered by `<gr-marked-element>` in case any - // rewrites have been abused to attempt an XSS attack. + // No need to sanitize the output since the <gr-marked-element> component + // does that internally. return html` <gr-marked-element - .markdown=${this.escapeAllButBlockQuotes(this.content)} + .markdown=${this.content} .breaks=${true} - .renderer=${customRenderer} - .callback=${(_error: string | null, contents: string) => - sanitizeHtml(contents)} + .renderer=${patchRenderer} + .tokenizer=${patchTokenizer} > <div class="markdown-html" slot="markdown-html"></div> </gr-marked-element> `; } - private escapeAllButBlockQuotes(text: string) { - // Escaping the message should be done first to make sure user's literal - // input does not get rendered without affecting html added in later steps. - 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. - for (;;) { - const newText = text.replace( - /(^|\n)((?:\s{0,3}>)*\s{0,3})>/g, - '$1$2>' - ); - if (newText === text) { - break; - } - text = newText; - } - - return text; - } - override updated() { this.removeEventListener( 'marked-render-complete', this.markedRenderComplete ); - // When masked-element was using Polymer, it was rendered synchronously, - // compared to the lit version of the element. updated() ran before the markdown - // was inserted into the slot. this.addEventListener('marked-render-complete', this.markedRenderComplete); }
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 ad76bfc..be5c2ee 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
@@ -325,6 +325,15 @@ element.markdown = true; await element.updateComplete; }); + + test('applies overflow-wrap: break-word to markdown-html', async () => { + element.content = 'text'; + await element.updateComplete; + const div = queryAndAssert<HTMLElement>(element, 'div.markdown-html'); + const style = window.getComputedStyle(div); + assert.equal(style.overflowWrap, 'break-word'); + }); + test('renders text with links and rewrites', async () => { element.content = `text \ntext with plain link: http://google.com @@ -709,7 +718,7 @@ test('never renders typed html', async () => { element.content = `plain text <div>foo</div> \n\`inline code <div>foo</div>\` - \n\`\`\`\nmultiline code <div>foo</div>\`\`\` + \n\`\`\`\nmultiline code <div>foo</div>\n\`\`\` \n> block quote <div>foo</div> \n[inline link <div>foo</div>](http://google.com)`; await element.updateComplete; @@ -748,6 +757,98 @@ ); }); + test('treats HTML outside code blocks as text', async () => { + element.content = ` +<div> + Hello +</div> + +<div> + Hello +</div> +with text +afterwards. + + +<div>Single-line block</div> + +Text with <span>inline HTML</span> +and more text. + +Some +<div>text in div</div> +on several lines. + +Some +\\<div>text in escaped div</div> +on several lines. + +<div>**markdown** and <b>HTML</b> in HTML blocks stay as-is</div> + +An <a href="example.com">inline HTML link</a>. + +An <a href="example.com">inline HTML link with **markup**</a>. + +An <a href="example.com">inline HTML link with [markup link](http://google.com)</a>. +`; + await element.updateComplete; + + const escapedHtml = '<div><br> Hello<br></div>'; + const escapedHtmlWithMixedContent = + '<div>' + + '**markdown** and <b>HTML</b> in HTML blocks stay as-is' + + '</div>'; + assert.shadowDom.equal( + element, + /* HTML */ ` + <gr-endpoint-decorator name="formatted-text-endpoint"> + <gr-marked-element> + <div slot="markdown-html" class="markdown-html"> + <p>${escapedHtml}</p> + <p> + ${escapedHtml}<br /> + with text<br /> + afterwards. + </p> + <p><div>Single-line block</div></p> + <p> + Text with <span>inline HTML</span><br /> + and more text. + </p> + <p>Some</p> + <p> + <div>text in div</div><br /> + on several lines. + </p> + <p> + Some<br /> + <div>text in escaped div</div><br /> + on several lines. + </p> + <p>${escapedHtmlWithMixedContent}</p> + <p> + An <a href="example.com">inline HTML link</a>. + </p> + <p> + An <a href="example.com">inline HTML link with + <strong>markup</strong></a>. + </p> + <p> + An <a href="example.com">inline HTML link with + <a + href="http://google.com" + rel="noopener noreferrer" + target="_blank" + >markup link</a + ></a>. + </p> + </div> + </gr-marked-element> + </gr-endpoint-decorator> + ` + ); + }); + test('renders nested block quotes', async () => { element.content = '> > > block quote'; await element.updateComplete; @@ -859,7 +960,7 @@ }); test('renders', async () => { - element.content = '```suggestion\nHello World```'; + element.content = '```suggestion\nHello World\n```'; await element.updateComplete; assert.shadowDom.equal( element,
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/custom-icons.ts b/polygerrit-ui/app/elements/shared/gr-icon/custom-icons.ts new file mode 100644 index 0000000..829d16a --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-icon/custom-icons.ts
@@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {svg, SVGTemplateResult} from 'lit'; + +/** Gerrit logo in color. */ +const gerrit = svg`<svg width="52" height="52" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52"> +<rect ry="4" rx="4" height="40" width="40" y="0" x="0" fill="#ffaaaa"/> +<rect ry="4" rx="4" height="40" width="40" y="12" x="12" fill="#aaffaa"/> +<path d="m18,22l12,0l0,4l-12,0l0,-4z" fill="#ff0000"/> +<path d="m34,22l12,0l0,4l-12,0l0,-4z" fill="#ff0000"/> +<path d="m18,36l4,0l0,-4l4,0l0,4l4,0l0,4l-4,0l0,4l-4,0l0,-4l-4,0l0,-4z" fill="#008000"/> +<path d="m34,36l4,0l0,-4l4,0l0,4l4,0l0,4l-4,0l0,4l-4,0l0,-4l-4,0l0,-4z" fill="#008000"/> +</svg>`; + +/** AI spark icon representing Gemini tools.*/ +const spark = svg`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="#1f1f1f"><path d="M480-80q-6 0-11-4t-7-10q-17-67-51-126t-83-108q-49-49-108-83T94-462q-6-2-10-7t-4-11q0-6 4-11t10-7q67-17 126-51t108-83q49-49 83-108t51-126q2-6 7-10t11-4q6 0 10.5 4t6.5 10q18 67 52 126t83 108q49 49 108 83t126 51q6 2 10 7t4 11q0 6-4 11t-10 7q-67 17-126 51t-108 83q-49 49-83 108T498-94q-2 6-7 10t-11 4Z"/></svg>`; + +export const customIcons: {[name: string]: SVGTemplateResult} = { + // go/keep-sorted start + ai: spark, + gerrit, + spark, + // go/keep-sorted end +};
diff --git a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts index 7e716b6..4bd6d22 100644 --- a/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts +++ b/polygerrit-ui/app/elements/shared/gr-icon/gr-icon.ts
@@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import {css, html, LitElement} from 'lit'; +import {css, LitElement, nothing, SVGTemplateResult} from 'lit'; import {customElement, property} from 'lit/decorators.js'; +import {customIcons} from './custom-icons'; declare global { interface HTMLElementTagNameMap { @@ -32,6 +33,16 @@ @property({type: Boolean, reflect: true}) filled?: boolean; + @property({type: Boolean, reflect: true}) + custom = false; + + private customIcon?: SVGTemplateResult; + + override willUpdate() { + this.customIcon = this.icon ? customIcons[this.icon] : undefined; + this.custom = !!this.customIcon; + } + static override get styles() { return [ css` @@ -41,7 +52,7 @@ font-family: var(--icon-font-family, 'Material Symbols Outlined'); font-weight: normal; font-style: normal; - font-size: 20px; + font-size: var(--gr-icon-size, 20px); line-height: 1; letter-spacing: normal; text-transform: none; @@ -56,7 +67,7 @@ vertical-align: top; } :host([small]) { - font-size: 16px; + font-size: var(--gr-icon-size, 16px); position: relative; top: 2px; } @@ -66,14 +77,23 @@ /* This is the trick such that the name of the icon doesn't appear in * search */ - :host::before { + :host(:not([custom]))::before { content: attr(icon); } + svg { + width: var(--gr-icon-size, 20px); + height: var(--gr-icon-size, 20px); + fill: currentColor; + } + :host([small]) svg { + width: var(--gr-icon-size, 16px); + height: var(--gr-icon-size, 16px); + } `, ]; } override render() { - return html``; + return this.customIcon ?? nothing; } }
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 523cc59..2767a7e 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
@@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ import {PluginApi, TargetElement} from '../../../api/plugin'; +import {ChangeInfo} from '../../../api/rest-api'; import {ActionInfo, RequireProperties} from '../../../types/common'; import {getAppContext} from '../../../services/app-context'; import { @@ -179,4 +180,11 @@ el.getActionDetails(this.plugin.getPluginName() + '~' + action) ); } + + async notifyBeforeChangeAction( + key: string, + change?: ChangeInfo + ): Promise<boolean> { + return this.jsApiService.handleBeforeChangeAction(key, change); + } }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-flows-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-flows-api.ts new file mode 100644 index 0000000..027280d --- /dev/null +++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-flows-api.ts
@@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {PluginsModel} from '../../../models/plugins/plugins-model'; +import { + FlowsAutosubmitProvider, + FlowsPluginApi, + FlowsProvider, +} from '../../../api/flows'; +import {Plugin} from './gr-public-js-api'; + +export class GrFlowsApi implements FlowsPluginApi { + constructor( + private readonly plugins: PluginsModel, + private readonly plugin: Plugin + ) {} + + register(provider: FlowsProvider): void { + this.plugins.registerFlowsProvider({ + pluginName: this.plugin.getPluginName(), + provider, + }); + } + + registerAutosubmitProvider(provider: FlowsAutosubmitProvider): void { + this.plugins.registerFlowsAutosubmitProvider({ + pluginName: this.plugin.getPluginName(), + provider, + }); + } +}
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 8417a94..a2e901a 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
@@ -68,7 +68,7 @@ async handleBeforeChangeAction( key: string, - change?: ParsedChangeInfo + change?: ChangeInfo ): Promise<boolean> { let okay = true; for (const cb of this._getEventCallbacks(EventType.BEFORE_CHANGE_ACTION)) { @@ -81,6 +81,45 @@ return okay; } + async handleBeforePublishEdit(change: ChangeInfo): Promise<boolean> { + await this.waitForPluginsToLoad(); + let okay = true; + for (const cb of this._getEventCallbacks(EventType.BEFORE_PUBLISH_EDIT)) { + try { + okay = (await cb(change)) && okay; + } catch (err: unknown) { + this.reportError(err, EventType.BEFORE_PUBLISH_EDIT); + } + } + return okay; + } + + async handleBeforeRebase(change: ChangeInfo): Promise<boolean> { + await this.waitForPluginsToLoad(); + let okay = true; + for (const cb of this._getEventCallbacks(EventType.BEFORE_REBASE)) { + try { + okay = (await cb(change)) && okay; + } catch (err: unknown) { + this.reportError(err, EventType.BEFORE_REBASE); + } + } + return okay; + } + + async handleBeforeCherryPick(change: ChangeInfo): Promise<boolean> { + await this.waitForPluginsToLoad(); + let okay = true; + for (const cb of this._getEventCallbacks(EventType.BEFORE_CHERRY_PICK)) { + try { + okay = (await cb(change)) && okay; + } catch (err: unknown) { + this.reportError(err, EventType.BEFORE_CHERRY_PICK); + } + } + return okay; + } + handlePublishEdit(change: ChangeInfo, revision?: RevisionInfo | null) { for (const cb of this._getEventCallbacks(EventType.PUBLISH_EDIT)) { try {
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 index 460be3c..fd022dc 100644 --- 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
@@ -40,6 +40,7 @@ let element: GrJsApiInterface; let plugin: Plugin; let errorStub: SinonStub; + let loadJsPluginStub: SinonStub; let pluginLoader: PluginLoader; let clock: SinonFakeTimers; @@ -49,7 +50,7 @@ }; setup(() => { - clock = useFakeTimers(); + clock = useFakeTimers({shouldClearNativeTimers: true}); stubRestApi('getAccount').resolves({ name: 'Judy Hopps', @@ -71,10 +72,12 @@ 'http://test.com/plugins/testplugin/static/test.js' ); testResolver(pluginLoaderToken).loadPlugins([]); + loadJsPluginStub = stub(pluginLoader, 'loadJsPlugin'); }); teardown(() => { clock.restore(); + loadJsPluginStub.restore(); element._removeEventCallbacks(); }); @@ -294,10 +297,6 @@ assert.isTrue(loggedIn); }); - test('attributeHelper', () => { - assert.isOk(plugin.attributeHelper(document.createElement('div'))); - }); - test('getAdminMenuLinks', () => { const links = [ {text: 'a', url: 'b'},
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 9f40f7c..220fed3 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
@@ -53,10 +53,28 @@ * @param change The relevant change. * @return A promise that resolves to true if the action should proceed. */ - handleBeforeChangeAction( - key: string, - change?: ParsedChangeInfo - ): Promise<boolean>; + handleBeforeChangeAction(key: string, change?: ChangeInfo): Promise<boolean>; + /** + * This method is called before publishing a change edit. + * It allows plugins to conditionally block edits. + * @param change The relevant change. + * @return A promise that resolves to true if the action should proceed. + */ + handleBeforePublishEdit(change: ChangeInfo): Promise<boolean>; + /** + * This method is called before a rebase. + * It allows plugins to conditionally block the rebase. + * @param change The relevant change. + * @return A promise that resolves to true if the rebase should proceed. + */ + handleBeforeRebase(change: ChangeInfo): Promise<boolean>; + /** + * This method is called before a cherry-pick. + * It allows plugins to conditionally block the cherry-pick. + * @param change The relevant change. + * @return A promise that resolves to true if the cherry-pick should proceed. + */ + handleBeforeCherryPick(change: ChangeInfo): Promise<boolean>; handlePublishEdit(change: ChangeInfo, revision?: RevisionInfo | null): void; handleShowChange(detail: ShowChangeDetail): Promise<void>; handleShowRevisionActions(detail: ShowRevisionActionsDetail): void;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts index 3eb4bf3..df24ec1 100644 --- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.ts
@@ -88,8 +88,6 @@ setup(() => { 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. document.body.appendChild(button); });
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 3b07ddd..211d556 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
@@ -24,7 +24,7 @@ let bodyStub: sinon.SinonStub; setup(() => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); stubRestApi('getAccount').returns( Promise.resolve({name: 'Judy Hopps', registered_on: '' as Timestamp})
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 35c93e67..6dc62034 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
@@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ import {getBaseUrl} from '../../../utils/url-util'; -import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper'; import {GrChangeActionsInterface} from './gr-change-actions-js-api'; import {GrChangeReplyInterface} from './gr-change-reply-js-api'; import {GrDomHooksManager} from '../../plugins/gr-dom-hooks/gr-dom-hooks'; @@ -12,6 +11,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 {GrFlowsApi} from './gr-flows-api'; import {GrPluginRestApi} from './gr-plugin-rest-api'; import {EndpointType, GrPluginEndpoints} from './gr-plugin-endpoints'; import {getPluginNameFromUrl} from './gr-api-utils'; @@ -28,7 +28,6 @@ import {ChangeReplyPluginApi} from '../../../api/change-reply'; import {RestPluginApi} from '../../../api/rest'; import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook'; -import {AttributeHelperPluginApi} from '../../../api/attribute-helper'; 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'; @@ -37,6 +36,7 @@ import {StylePluginApi} from '../../../api/styles'; import {GrSuggestionsApi} from '../../plugins/gr-suggestions-api/gr-suggestions-api'; import {GrChangeUpdatesApi} from '../../plugins/gr-change-updates-api/gr-change-updates-api'; +import {GrAiCodeReviewApi} from '../../plugins/gr-ai-code-review-api/gr-ai-code-review-api'; const PLUGIN_NAME_NOT_SET = 'NULL'; @@ -179,6 +179,10 @@ return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`; } + aiCodeReview(): GrAiCodeReviewApi { + return new GrAiCodeReviewApi(this.report, this.pluginsModel, this); + } + annotationApi(): AnnotationPluginApi { return new GrAnnotationActionsInterface( this.report, @@ -205,6 +209,10 @@ return new GrChecksApi(this.report, this.pluginsModel, this); } + flows(): GrFlowsApi { + return new GrFlowsApi(this.pluginsModel, this); + } + changeUpdates(): GrChangeUpdatesApi { return new GrChangeUpdatesApi(this.pluginsModel, this); } @@ -229,10 +237,6 @@ return new GrPluginRestApi(this.restApiService, this.report, this, prefix); } - attributeHelper(element: HTMLElement): AttributeHelperPluginApi { - return new GrAttributeHelper(this.report, this, element); - } - eventHelper(element: HTMLElement): EventHelperPluginApi { return new GrEventHelper(this.report, this, element); }
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts index 112ec78..c04a70e 100644 --- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.ts
@@ -12,7 +12,9 @@ suite('gr-lib-loader tests', () => { let grLibLoader: GrLibLoader; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let resolveLoad: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let rejectLoad: any; let loadStub: sinon.SinonStub; @@ -101,6 +103,7 @@ const libraryConfig = { src: 'foo.js', + // eslint-disable-next-line @typescript-eslint/no-explicit-any configureCallback: () => (window as any).library, }; @@ -110,6 +113,7 @@ grLibLoader.getLibrary(libraryConfig).then(loaded1); grLibLoader.getLibrary(libraryConfig).then(loaded2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).library = library; resolveLoad(); await waitEventLoop(); @@ -127,18 +131,21 @@ suite('preloaded', () => { setup(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).library = { initialize: sinon.stub(), }; }); teardown(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (window as any).library; }); test('does not load library again if detected present', async () => { const libraryConfig = { src: 'foo.js', + // eslint-disable-next-line @typescript-eslint/no-explicit-any checkPresent: () => (window as any).library !== undefined, }; @@ -165,7 +172,9 @@ test('runs configuration for externally loaded library', async () => { const libraryConfig = { src: 'foo.js', + // eslint-disable-next-line @typescript-eslint/no-explicit-any checkPresent: () => (window as any).library !== undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any configureCallback: () => (window as any).library.initialize(), }; @@ -174,13 +183,16 @@ resolveLoad(); await waitEventLoop(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any assert.isTrue((window as any).library.initialize.calledOnce); }); test('loads library again if not detected present', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).library = undefined; const libraryConfig = { src: 'foo.js', + // eslint-disable-next-line @typescript-eslint/no-explicit-any checkPresent: () => (window as any).library !== undefined, };
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts index 9963aa1..ca5db33 100644 --- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.ts
@@ -56,6 +56,7 @@ await element.updateComplete; assert.isNotOk(query(element, 'gr-tooltip-content')); + // eslint-disable-next-line @typescript-eslint/no-explicit-any element.limit = null as any; await element.updateComplete; assert.isNotOk(query(element, 'gr-tooltip-content'));
diff --git a/polygerrit-ui/app/elements/shared/gr-marked-element/gr-marked-element.ts b/polygerrit-ui/app/elements/shared/gr-marked-element/gr-marked-element.ts index 5aeb2cb..2e93245 100644 --- a/polygerrit-ui/app/elements/shared/gr-marked-element/gr-marked-element.ts +++ b/polygerrit-ui/app/elements/shared/gr-marked-element/gr-marked-element.ts
@@ -10,19 +10,14 @@ property, queryAssignedElements, } from 'lit/decorators.js'; -// @ts-ignore -import * as marked from 'marked/lib/marked'; +import {Marked, Renderer, Tokenizer} from 'marked'; -if (!window.marked) { - window.marked = marked; -} +import { + sanitizeHtml, + setElementInnerHtml, +} from '../../../utils/inner-html-util'; declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - marked: any; - } - interface HTMLElementTagNameMap { 'gr-marked-element': GrMarkedElement; } @@ -42,15 +37,7 @@ @property({type: Function}) renderer: Function | null = null; - @property({type: Boolean}) sanitize = false; - - @property({type: Function}) sanitizer: - | ((html: string) => string) - | undefined = undefined; - - @property({type: Boolean}) smartypants = false; - - @property({type: Function}) callback: Function | null = null; + @property({type: Function}) tokenizer: Function | null = null; @queryAssignedElements({ flatten: true, @@ -83,12 +70,8 @@ 'breaks', 'pedantic', 'renderer', - 'sanitize', - 'sanitizer', - 'smartypants', - 'callback', + 'tokenizer', ]; - if (propsToWatch.some(prop => changedProps.has(prop))) { this.renderMarkdown(); } @@ -104,38 +87,33 @@ } if (!this.markdown) { - this.outputElement[0].innerHTML = ''; + this.outputElement[0].textContent = ''; return; } - const renderer = new window.marked.Renderer(); - if (this.renderer) this.renderer(renderer); + const renderer = new Renderer(); + if (this.renderer) { + this.renderer(renderer); + } + const tokenizer = new Tokenizer(); + if (this.tokenizer) { + this.tokenizer(tokenizer); + } - const options = { - renderer, - highlight: this.highlight.bind(this), - breaks: this.breaks, - sanitize: this.sanitize, - sanitizer: this.sanitizer, - pedantic: this.pedantic, - smartypants: this.smartypants, - }; + const marked = new Marked(); + const unsafeHtml = + marked.parse(this.markdown, { + async: false, + breaks: this.breaks, + pedantic: this.pedantic, + renderer, + tokenizer, + }) || ''; + const safeHtml = sanitizeHtml(unsafeHtml); - const output = window.marked(this.markdown, options, this.callback); - - this.outputElement[0].innerHTML = output; + setElementInnerHtml(this.outputElement[0], safeHtml); this.dispatchEvent( new CustomEvent('marked-render-complete', {bubbles: true, composed: true}) ); } - - private highlight(code: string, lang: string): string { - const event = new CustomEvent('syntax-highlight', { - detail: {code, lang}, - bubbles: true, - composed: true, - }); - this.dispatchEvent(event); - return event.detail.code || code; - } }
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts index ec79b5a..e66da3c 100644 --- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts +++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -53,6 +53,7 @@ } nav.pinned { position: fixed; + top: var(--main-header-height); } @media only screen and (max-width: 53em) { nav {
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 030b4a1..ec49dd7 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
@@ -48,7 +48,7 @@ let authService: AuthService; setup(() => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); cache = new SiteBasedCache(); fetchPromisesCache = new FetchPromisesCache();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts index d5d8a7c..1654379 100644 --- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts +++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -39,6 +39,7 @@ interface ParserBatch { author: AccountInfo; + realAuthor?: AccountInfo; date: Timestamp; type: 'REVIEWER_UPDATE'; tag: MessageTag.TAG_REVIEWER_UPDATE; @@ -97,6 +98,7 @@ this.updateItems = {}; return { author: update.updated_by, + realAuthor: update.real_updated_by, date: update.updated, type: 'REVIEWER_UPDATE', tag: MessageTag.TAG_REVIEWER_UPDATE, @@ -139,7 +141,12 @@ const reviewerId = accountKey(update.reviewer); if ( updateDate - batchUpdateDate > REVIEWER_UPDATE_THRESHOLD_MILLIS || - update.updated_by._account_id !== this.batch.author._account_id + accountKey(update.updated_by) !== accountKey(this.batch.author) || + !!update.real_updated_by !== !!this.batch.realAuthor || + (update.real_updated_by && + this.batch.realAuthor && + accountKey(update.real_updated_by) !== + accountKey(this.batch.realAuthor)) ) { // Next sequential update should form new group. this._completeBatch(this.batch);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts index 69f7792..c64e312 100644 --- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts +++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -44,8 +44,6 @@ this.nativeSelect.value = String(this.bindValue); }, 1); } - // TODO: bind-value-changed is polymer-specific. Move to a new event - // name and rely on ValueChangedEvent instead of BindValueChangeEvent. fire(this, 'bind-value-changed', {value: this.convert(this._bindValue)}); }
diff --git a/polygerrit-ui/app/elements/shared/gr-selector/gr-selector.ts b/polygerrit-ui/app/elements/shared/gr-selector/gr-selector.ts index b424f010..9da6068 100644 --- a/polygerrit-ui/app/elements/shared/gr-selector/gr-selector.ts +++ b/polygerrit-ui/app/elements/shared/gr-selector/gr-selector.ts
@@ -10,7 +10,6 @@ import {fire} from '../../../utils/event-util'; /** - * This is a replacement for iron-selector. * Based on https://github.com/chromium/chromium/blob/f7322d4ecf3ee3804ce7e80e1e9d4b98f23b9295/ui/webui/resources/cr_elements/cr_selectable_mixin.ts. * Modified for gerrit. */
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts index 837546e..72e0905 100644 --- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts +++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview.ts
@@ -90,7 +90,11 @@ * fix suggestion info currently in gr-comment. */ @state() - public previewLoadedFor?: FixSuggestionInfo; + public previewLoadedFor?: { + fixSuggestionInfo: FixSuggestionInfo; + changeNum: NumericChangeId; + patchSet: RevisionPatchSetNum; + }; @state() repo?: RepoName; @@ -239,7 +243,7 @@ changed.has('changeNum') || changed.has('patchSet') ) { - this.fetchFixPreview(); + this.fetchIfReplacementsChanged(); } } @@ -281,8 +285,18 @@ </div>`; } - private async fetchFixPreview() { + private async fetchIfReplacementsChanged() { if (!this.changeNum || !this.patchSet || !this.fixSuggestionInfo) return; + if ( + this.previewLoadedFor && + this.previewLoadedFor.changeNum === this.changeNum && + this.previewLoadedFor.patchSet === this.patchSet && + replacementsToString( + this.previewLoadedFor.fixSuggestionInfo.replacements + ) === replacementsToString(this.fixSuggestionInfo.replacements) + ) { + return; + } this.reporting.time(Timing.PREVIEW_FIX_LOAD); const res = await this.restApiService.getFixPreview( @@ -300,7 +314,12 @@ }); if (currentPreviews.length > 0) { this.preview = currentPreviews[0]; - this.previewLoadedFor = this.fixSuggestionInfo; + this.previewLoadedFor = { + fixSuggestionInfo: this.fixSuggestionInfo, + changeNum: this.changeNum, + patchSet: this.patchSet, + }; + this.previewed = true; fire(this, 'preview-loaded', {
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts index 160996a5..742900f 100644 --- a/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-suggestion-diff-preview/gr-suggestion-diff-preview_test.ts
@@ -18,6 +18,7 @@ import {getAppContext} from '../../../services/app-context'; import {GrSuggestionDiffPreview} from './gr-suggestion-diff-preview'; import {stubFlags} from '../../../test/test-utils'; +import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api'; suite('gr-suggestion-diff-preview tests', () => { let element: GrSuggestionDiffPreview; @@ -52,7 +53,11 @@ test('render diff', async () => { stubFlags('isEnabled').returns(true); - element.previewLoadedFor = createFixSuggestionInfo(); + element.previewLoadedFor = { + fixSuggestionInfo: createFixSuggestionInfo(), + changeNum: 42 as NumericChangeId, + patchSet: 1 as RevisionPatchSetNum, + }; element.codeText = ' private handleClick(e: MouseEvent) {\ne.stopPropagation();\ne.preventDefault();'; element.preview = {
diff --git a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts index ac87d07..ffdfa25 100644 --- a/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts +++ b/polygerrit-ui/app/elements/shared/gr-suggestion-textarea/gr-suggestion-textarea.ts
@@ -491,10 +491,6 @@ this.reporting.reportInteraction('select-mention', {type: text}); move = 1; } - // iron-autogrow-textarea unfortunately sets the cursor at the end when - // it's value is changed, which means the setting of selectionStart - // below needs to happen after iron-autogrow-textarea has set the - // incorrect value. await this.updateComplete; this.setCursorPosition(specialCharIndex + text.length + move); this.resetDropdown();
diff --git a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts index 3f2e666..b80b0a9 100644 --- a/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts +++ b/polygerrit-ui/app/elements/shared/gr-user-suggestion-fix/gr-user-suggestion-fix_screenshot_test.ts
@@ -17,6 +17,7 @@ import {wrapInProvider} from '../../../models/di-provider-element'; import {commentModelToken} from '../gr-comment-model/gr-comment-model'; import {CommentModel} from '../gr-comment-model/gr-comment-model'; +import {NumericChangeId, RevisionPatchSetNum} from '../../../api/rest-api'; import {getAppContext} from '../../../services/app-context'; import {stubFlags, visualDiffDarkTheme} from '../../../test/test-utils'; @@ -47,7 +48,11 @@ test('screenshot', async () => { await element.updateComplete; // mock preview because it's calculated on backend - element.suggestionDiffPreview!.previewLoadedFor = createFixSuggestionInfo(); + element.suggestionDiffPreview!.previewLoadedFor = { + fixSuggestionInfo: createFixSuggestionInfo(), + changeNum: 42 as NumericChangeId, + patchSet: 1 as RevisionPatchSetNum, + }; element.suggestionDiffPreview!.preview = { filepath: 'test.ts', preview: {
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 8eeaa84..fd79f02 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
@@ -31,9 +31,9 @@ } class MockListener { - private results: any[][] = []; + private results: unknown[][] = []; - notify(...args: any[]) { + notify(...args: unknown[]) { this.results.push(args); } @@ -109,7 +109,7 @@ suite('annotate', () => { function assertAnnotation( - args: any[], + args: unknown[], expected: { parent: HTMLElement; offset: number;
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 bddfcac..4783344 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
@@ -3,7 +3,6 @@ * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings'; import {GrAnnotation} from '../../../api/diff'; // TODO(wyatta): refactor this to be <MARK> rather than <HL>. @@ -85,12 +84,8 @@ } const wrapper = document.createElement(tagName); - const sanitizer = getSanitizeDOMValue(); - for (let [name, value] of Object.entries(attributes)) { + for (const [name, value] of Object.entries(attributes)) { if (!value) continue; - if (sanitizer) { - value = sanitizer(value, name, 'attribute', wrapper) as string; - } wrapper.setAttribute(name, value); } for (const inner of nestedNodes) { @@ -284,8 +279,14 @@ * */ export interface ElementSpec { - tagName: string; - attributes?: {[attributeName: string]: string | undefined}; + tagName: 'a' | 'span'; + attributes?: { + href?: string; + target?: string; + rel?: string; + 'data-dc-diff-link-layer'?: string; + 'data-token-hovercard-selected'?: string; + }; } export const TEST_ONLY = {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts index 11684d3..275b2d5 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff-highlight/gr-annotation_test.ts
@@ -3,7 +3,6 @@ * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import * as sinon from 'sinon'; import '../../../test/common-test-setup'; import { annotateElement, @@ -11,10 +10,6 @@ getStringLength, TEST_ONLY, } from './gr-annotation'; -import { - getSanitizeDOMValue, - setSanitizeDOMValue, -} from '@polymer/polymer/lib/utils/settings'; import {assert, fixture, html} from '@open-wc/testing'; suite('annotation', () => { @@ -148,38 +143,16 @@ suite('annotateWithElement', () => { const fullText = '01234567890123456789'; - let mockSanitize: sinon.SinonSpy; - let originalSanitizeDOMValue: ( - value: unknown, - name: string, - type: 'property' | 'attribute', - node: Node | null | undefined - ) => unknown; - - setup(() => { - setSanitizeDOMValue(p0 => p0); - originalSanitizeDOMValue = getSanitizeDOMValue()!; - assert.isDefined(originalSanitizeDOMValue); - mockSanitize = sinon.spy(originalSanitizeDOMValue); - setSanitizeDOMValue(mockSanitize); - }); - - teardown(() => { - setSanitizeDOMValue(originalSanitizeDOMValue); - }); test('annotates when fully contained', () => { const length = 10; const container = document.createElement('div'); container.textContent = fullText; annotateWithElement(container, 1, length, { - tagName: 'test-wrapper', + tagName: 'span', }); - assert.equal( - container.innerHTML, - '0<test-wrapper>1234567890</test-wrapper>123456789' - ); + assert.equal(container.innerHTML, '0<span>1234567890</span>123456789'); }); test('annotates when spanning multiple nodes', () => { @@ -188,16 +161,16 @@ container.textContent = fullText; annotateElement(container, 5, length, 'testclass'); annotateWithElement(container, 1, length, { - tagName: 'test-wrapper', + tagName: 'span', }); assert.equal( container.innerHTML, '0' + - '<test-wrapper>' + + '<span>' + '1234' + '<hl class="testclass">567890</hl>' + - '</test-wrapper>' + + '</span>' + '<hl class="testclass">1234</hl>' + '56789' ); @@ -208,13 +181,10 @@ const container = document.createElement('div'); container.textContent = fullText; annotateWithElement(container.childNodes[0], 1, length, { - tagName: 'test-wrapper', + tagName: 'span', }); - assert.equal( - container.innerHTML, - '0<test-wrapper>1234567890</test-wrapper>123456789' - ); + assert.equal(container.innerHTML, '0<span>1234567890</span>123456789'); }); test('handles zero-length nodes', () => { @@ -223,12 +193,12 @@ container.appendChild(document.createElement('span')); container.appendChild(document.createTextNode('0123456789')); annotateWithElement(container, 1, 10, { - tagName: 'test-wrapper', + tagName: 'span', }); assert.equal( container.innerHTML, - '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789' + '0<span>123456789<span></span>0</span>123456789' ); }); @@ -240,58 +210,34 @@ container.appendChild(document.createElement('span')); container.appendChild(document.createTextNode('0123456789')); annotateWithElement(container, 1, 10, { - tagName: 'test-wrapper', + tagName: 'span', }); assert.equal( container.innerHTML, '<!--comment1-->' + - '0<test-wrapper>123456789' + + '0<span>123456789' + '<!--comment2-->' + - '<span></span>0</test-wrapper>123456789' + '<span></span>0</span>123456789' ); }); - test('sets sanitized attributes', () => { + test('sets attributes', () => { const container = document.createElement('div'); container.textContent = fullText; const attributes = { href: 'foo', - 'data-foo': 'bar', - class: 'hello world', + target: 'bar', + rel: 'hello world', }; - annotateWithElement(container, 1, length, { - tagName: 'test-wrapper', + annotateWithElement(container, 1, 10, { + tagName: 'a', attributes, }); - assert( - mockSanitize.calledWith( - 'foo', - 'href', - 'attribute', - sinon.match.instanceOf(Element) - ) - ); - assert( - mockSanitize.calledWith( - 'bar', - 'data-foo', - 'attribute', - sinon.match.instanceOf(Element) - ) - ); - assert( - mockSanitize.calledWith( - 'hello world', - 'class', - 'attribute', - sinon.match.instanceOf(Element) - ) - ); - const el = container.querySelector('test-wrapper')!; + const el = container.querySelector('a')!; assert.equal(el.getAttribute('href'), 'foo'); - assert.equal(el.getAttribute('data-foo'), 'bar'); - assert.equal(el.getAttribute('class'), 'hello world'); + assert.equal(el.getAttribute('target'), 'bar'); + assert.equal(el.getAttribute('rel'), 'hello world'); }); });
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 0eea837..ed218aa 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
@@ -373,7 +373,7 @@ // In a perfect world we would only do this for double-click, but it is // extremely rare that a user would drag from the end of one line to the // start of the next and release the mouse, so we don't bother. - // TODO(brohlfs): This does not work, if the double-click is before a new + // TODO(milutin): This does not work, if the double-click is before a new // diff chunk (start will be equal to end), and neither before an "expand // the diff context" block (end line will match the first line of the new // section and thus be greater than start line + 1).
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 8c63209..843e053 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
@@ -5,9 +5,6 @@ */ import '@material/web/button/text-button'; import '@material/web/checkbox/checkbox'; -// Google internal screenshot tests are failing without this import. -// We have no idea why, but for the time being we will just keep the import. -import '@polymer/paper-item/paper-item'; import './gr-overview-image'; import './gr-zoomed-image'; import '@material/web/labs/card/filled-card';
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer_test.ts index 2ea26af..e14797f 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer_test.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-image-viewer_test.ts
@@ -12,6 +12,26 @@ let element: GrImageViewer; setup(async () => { + // Mock getLibrary to avoid 404s on loading resemblejs + // 'libLoader' is a private static property on GrImageViewer + // @ts-expect-error + const libLoader = GrImageViewer.libLoader; + sinon.stub(libLoader, 'getLibrary').resolves(); + + // Mock window.resemble that is used in GrImageViewer.computeDiffImage + // @ts-expect-error + window.resemble = sinon.stub().returns({ + compareTo: sinon.stub().returns({ + ignoreNothing: sinon.stub().returns({ + onComplete: sinon + .stub() + .callsFake(cb => + cb({getImageDataUrl: () => 'data:image/png;base64,mock'}) + ), + }), + }), + }); + element = await fixture<GrImageViewer>( html`<gr-image-viewer></gr-image-viewer>` );
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 a05b5e2..8a2f4c4 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
@@ -193,7 +193,7 @@ this.resizeObserver.observe(this.contentTransform); } - override updated(changedProperties: PropertyValues) { + override willUpdate(changedProperties: PropertyValues) { if (changedProperties.has('frameRect')) { this.updateFrameStyle(); }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts index 3b778b1..0951042 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff-image-viewer/gr-zoomed-image.ts
@@ -59,7 +59,7 @@ `; } - override updated(changedProperties: PropertyValues) { + override willUpdate(changedProperties: PropertyValues) { if (changedProperties.has('scale') || changedProperties.has('frameRect')) { this.updateImageStyles(); }
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 89bb49e..4e9e225 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
@@ -11,6 +11,7 @@ } from '../gr-diff/gr-diff-group'; import {DiffContent, DiffRangesToFocus} from '../../../types/diff'; import {Side} from '../../../constants/constants'; +import {normalizeSkipInfo} from '../../../utils/diff-util'; import {getStringLength} from '../gr-diff-highlight/gr-annotation'; import {GrDiffLineType, LineNumber} from '../../../api/diff'; import {FULL_CONTEXT, KeyLocations} from '../gr-diff/gr-diff-utils'; @@ -310,7 +311,9 @@ } private chunkLength(chunk: DiffContent, side: Side) { - if (chunk.skip || chunk.common || chunk.ab) { + if (chunk.skip) { + return normalizeSkipInfo(chunk.skip)[side]; + } else if (chunk.common || chunk.ab) { return this.commonChunkLength(chunk); } else if (side === Side.LEFT) { return this.linesLeft(chunk).length; @@ -320,9 +323,6 @@ } private commonChunkLength(chunk: DiffContent) { - if (chunk.skip) { - return chunk.skip; - } console.assert(!!chunk.ab || !!chunk.common); console.assert(
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 ccd208c..38c8d1c 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
@@ -4,9 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ import {BLANK_LINE, GrDiffLine} from './gr-diff-line'; -import {GrDiffLineType, LineNumber, LineRange, Side} from '../../../api/diff'; +import { + GrDiffLineType, + LineNumber, + LineRange, + Side, + SkipInfo, +} from '../../../api/diff'; import {assert, assertIsDefined} from '../../../utils/common-util'; import {isDefined} from '../../../types/types'; +import {normalizeSkipInfo} from '../../../utils/diff-util'; export enum GrDiffGroupType { /** A group of unchanged diff lines. */ @@ -270,7 +277,7 @@ | { type: GrDiffGroupType.BOTH | GrDiffGroupType.DELTA; lines?: undefined; - skip: number; + skip: SkipInfo; offsetLeft: number; offsetRight: number; moveDetails?: GrMoveDetails; @@ -296,14 +303,15 @@ } this.skip = options.skip; if (options.skip !== undefined) { + const skip = normalizeSkipInfo(options.skip); this.lineRange = { left: { start_line: options.offsetLeft, - end_line: options.offsetLeft + options.skip - 1, + end_line: options.offsetLeft + skip.left - 1, }, right: { start_line: options.offsetRight, - end_line: options.offsetRight + options.skip - 1, + end_line: options.offsetRight + skip.right - 1, }, }; } else { @@ -368,7 +376,7 @@ */ readonly contextGroups: GrDiffGroup[] = []; - readonly skip?: number; + readonly skip?: SkipInfo; /** Both start and end line are inclusive. */ readonly lineRange: {[side in Side]: LineRange} = {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts index d432e2a..894187c 100644 --- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts +++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-styles.ts
@@ -748,12 +748,6 @@ 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); } tbody.image-diff .gr-diff {
diff --git a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts index c67157f..02bf810 100644 --- a/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts +++ b/polygerrit-ui/app/embed/diff/gr-ranged-comment-themes/gr-ranged-comment-theme.ts
@@ -5,8 +5,6 @@ */ import {css} from 'lit'; -const $_documentContainer = document.createElement('template'); - export const grRangedCommentTheme = css` gr-diff-text hl.rangeHighlight { background-color: var(--diff-highlight-range-color); @@ -15,13 +13,3 @@ background-color: var(--diff-highlight-range-hover-color); } `; - -$_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme"> - <template> - <style> - ${grRangedCommentTheme.cssText} - </style> - </template> -</dom-module>`; - -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts index 2dd18df..bd42135 100644 --- a/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts +++ b/polygerrit-ui/app/embed/diff/gr-selection-action-box/gr-selection-action-box_test.ts
@@ -50,6 +50,7 @@ }); suite('mousedown reacts only to main button', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any let e: any; setup(() => {
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 e08a9f0..9e538f6 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
@@ -15,6 +15,7 @@ import {ReportingService} from '../../../services/gr-reporting/gr-reporting'; import {GrDiffLineType} from '../../../api/diff'; import {assert} from '../../../utils/common-util'; +import {normalizeSkipInfo} from '../../../utils/diff-util'; const LANGUAGE_MAP = new Map<string, string>([ ['application/dart', 'dart'], @@ -25,6 +26,7 @@ ['application/xquery', 'xquery'], ['application/x-epp', 'epp'], ['application/x-erb', 'erb'], + ['text/ada', 'ada'], ['text/css', 'css'], ['text/html', 'html'], ['text/javascript', 'js'], @@ -96,6 +98,7 @@ ['text/x-tcl', 'tcl'], ['text/x-toml', 'toml'], ['text/x-torque', 'torque'], + ['text/x-ttcn', 'ttcn3'], ['text/x-twig', 'twig'], ['text/x-vb', 'vb'], ['text/x-verilog', 'v'], @@ -270,10 +273,10 @@ for (const line of b) { rightContent += line + '\n'; } - const skip = chunk.skip ?? 0; - if (skip > 0) { - leftContent += '\n'.repeat(skip); - rightContent += '\n'.repeat(skip); + if (chunk.skip) { + const skip = normalizeSkipInfo(chunk.skip); + leftContent += '\n'.repeat(skip.left); + rightContent += '\n'.repeat(skip.right); } } leftContent = leftContent.trimEnd(); @@ -282,8 +285,10 @@ try { this.leftPromise = this.highlight(leftLanguage, leftContent); this.rightPromise = this.highlight(rightLanguage, rightContent); - this.leftRanges = await this.leftPromise; - this.rightRanges = await this.rightPromise; + [this.leftRanges, this.rightRanges] = await Promise.all([ + this.leftPromise, + this.rightPromise, + ]); this.notify(); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) {
diff --git a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts index 042215f..2d35577 100644 --- a/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts +++ b/polygerrit-ui/app/embed/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -5,8 +5,6 @@ */ import {css} from 'lit'; -const $_documentContainer = document.createElement('template'); - /** * HighlightJS emits the following classes that do not have styles here: * subst, symbol, class, function, doctag, meta-string, section, name, @@ -124,13 +122,3 @@ color: var(--syntax-variable-language-color); } `; - -$_documentContainer.innerHTML = `<dom-module id="gr-syntax-theme"> - <template> - <style> - ${grSyntaxTheme.cssText} - </style> - </template> -</dom-module>`; - -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts index cbb2d8c..68293f4 100644 --- a/polygerrit-ui/app/embed/gr-diff.ts +++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -3,13 +3,6 @@ * Copyright 2020 Google LLC * SPDX-License-Identifier: Apache-2.0 */ - -// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue -// https://github.com/Polymer/polymer-resin/issues/9 is resolved. -// Because gr-diff.js is a shared component, it shouldn' pollute global -// variables. If an application wants to use Polymer global variable - -// the app must assign/import it and do not rely on the Polymer variable -// exposed by shared gr-diff component. import '../api/embed'; import '../scripts/bundled-polymer'; import './diff/gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/embed/gr-textarea.ts b/polygerrit-ui/app/embed/gr-textarea.ts index 7417687..17bc528 100644 --- a/polygerrit-ui/app/embed/gr-textarea.ts +++ b/polygerrit-ui/app/embed/gr-textarea.ts
@@ -4,7 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ import {css, html, LitElement} from 'lit'; -import {customElement, property, query, queryAsync} from 'lit/decorators.js'; +import { + customElement, + property, + query, + queryAsync, + state, +} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {ifDefined} from 'lit/directives/if-defined.js'; import { @@ -168,6 +174,7 @@ return this.editableDivElement?.scrollTop ?? 0; } + @state() private innerValue: string | undefined; private innerHint: string | undefined; @@ -215,7 +222,7 @@ outline: none; } - &:empty::before { + &[data-empty='true']::before { content: attr(data-placeholder); color: var(--text-secondary, lightgrey); position: absolute; @@ -274,10 +281,12 @@ aria-multiline="true" aria-placeholder=${ifDefined(ariaPlaceholder)} data-placeholder=${ifDefined(placeholder)} + data-empty=${this.innerValue === ''} class=${classes} contenteditable=${this.contentEditableAttributeValue} dir="ltr" role="textbox" + spellcheck="true" @input=${this.onInput} @focus=${this.onFocus} @blur=${this.onBlur} @@ -663,44 +672,16 @@ private async getValue() { const editableDivElement = await this.editableDiv; if (editableDivElement) { - const [output] = this.parseText(editableDivElement, false, true); - return output; + // When you delete all text, it leaves a \n (or maybe \r\n?). + // Fix this by making it return a empty string. + if (/^\r?\n$/.test(editableDivElement.innerText)) { + return ''; + } + return editableDivElement.innerText; } return ''; } - private parseText( - node: Node, - isLastBr: boolean, - isFirst: boolean - ): [string, boolean] { - let textValue = ''; - let output = ''; - if (node.nodeName === 'BR') { - return ['\n', true]; - } - - if (node.nodeType === Node.TEXT_NODE && node.textContent) { - return [node.textContent, false]; - } - - if (node.nodeName === 'DIV' && !isLastBr && !isFirst) { - textValue = '\n'; - } - - isLastBr = false; - - for (let i = 0; i < node.childNodes?.length; i++) { - [output, isLastBr] = this.parseText( - node.childNodes[i], - isLastBr, - i === 0 - ); - textValue += output; - } - return [textValue, isLastBr]; - } - public getCursorPosition() { return this.getCursorPositionForDiv(this.editableDivElement); }
diff --git a/polygerrit-ui/app/eslint-bazel.config.js b/polygerrit-ui/app/eslint-bazel.config.js index ad7690e..9724124 100644 --- a/polygerrit-ui/app/eslint-bazel.config.js +++ b/polygerrit-ui/app/eslint-bazel.config.js
@@ -4,13 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -// This file has a special settings for bazel. -// The settings is required because bazel uses different location -// for node_modules. +// Bazel-specific ESLint wrapper. +// +// This file is intentionally thin: +// - `eslint.config.js` remains the source of truth for rules +// - this file only adapts module resolution for Bazel runfiles +// +// In Bazel test mode, npm dependencies are located under runfiles +// directories such as ui_dev_npm/node_modules. Extend the Node resolver +// so that ESLint can locate those packages. const {defineConfig, globalIgnores} = require('eslint/config'); const js = require('@eslint/js'); const {FlatCompat} = require('@eslint/eslintrc'); +const path = require('path'); +const fs = require('fs'); const compat = new FlatCompat({ baseDirectory: __dirname, @@ -18,21 +26,50 @@ allConfig: js.configs.all, }); -function getBazelSettings() { - const runFilesDir = process.env['RUNFILES_DIR']; - if (!runFilesDir) { - // eslint is executed with 'bazel run ...' to fix the source code. It runs - // against real source code, no special paths for node_modules is set. - return {}; +function pathExists(p) { + try { + return fs.existsSync(p); + // eslint-disable-next-line no-unused-vars + } catch (unusedError) { + return false; } - // eslint is executed with 'bazel test...'. Set path to required node_modules +} + +function getRunfilesRoot() { + return process.env.RUNFILES_DIR || process.env.TEST_SRCDIR || ''; +} + +function getNodeModulesPaths() { + const runfilesRoot = getRunfilesRoot(); + + if (runfilesRoot) { + // Bazel test mode: collect node_modules from runfiles + return [ + path.join(runfilesRoot, 'ui_dev_npm/node_modules'), + path.join(runfilesRoot, 'ui_npm/node_modules'), + path.join(runfilesRoot, '_main/external/ui_dev_npm/node_modules'), + path.join(runfilesRoot, '_main/external/ui_npm/node_modules'), + path.join(runfilesRoot, '_main/polygerrit-ui/app/node_modules'), + ].filter(pathExists); + } + + // Workspace mode + return [ + path.join(__dirname, 'node_modules'), + path.join(__dirname, '../../node_modules'), + path.join(process.cwd(), 'node_modules'), + path.join(process.cwd(), '../../node_modules'), + ].filter(pathExists); +} + +function getBazelSettings() { + const paths = getNodeModulesPaths(); + if (paths.length === 0) return {}; + return { 'import/resolver': { node: { - paths: [ - `${runFilesDir}/ui_npm/node_modules`, - `${runFilesDir}/ui_dev_npm/node_modules`, - ], + paths, }, }, };
diff --git a/polygerrit-ui/app/eslint.config.js b/polygerrit-ui/app/eslint.config.js index 2b69470..f44e625 100644 --- a/polygerrit-ui/app/eslint.config.js +++ b/polygerrit-ui/app/eslint.config.js
@@ -71,7 +71,6 @@ ], // https://eslint.org/docs/rules/new-cap 'new-cap': ['error', { - capIsNewExceptions: ['Polymer'], capIsNewExceptionPattern: '^.*Mixin$', }], // https://eslint.org/docs/rules/no-console @@ -168,7 +167,7 @@ 'jsdoc/check-syntax': 0, // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names 'jsdoc/check-tag-names': ['error', { - definedTags: ['attr', 'lit', 'mixinFunction', 'mixinClass', 'polymer'], + definedTags: ['attr', 'lit', 'mixinFunction', 'mixinClass'], }], // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types 'jsdoc/check-types': 0, @@ -299,9 +298,6 @@ }, { name: '@lit/reactive-element', message: 'Use lit instead', - }, { - name: '@polymer/decorators/lib/decorators', - message: 'Use @polymer/decorators instead', }], '@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-floating-promises': 'off', @@ -351,7 +347,6 @@ 'test-utils.ts', ], rules: { - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/require-await': 'off', }, }, @@ -394,7 +389,6 @@ globals: { // Settings for samples. You can add globals here if you want to use it Gerrit: 'readonly', - Polymer: 'readonly', }, }, {
diff --git a/polygerrit-ui/app/models/accounts/accounts-model_test.ts b/polygerrit-ui/app/models/accounts/accounts-model_test.ts index b618dc8..e84723c 100644 --- a/polygerrit-ui/app/models/accounts/accounts-model_test.ts +++ b/polygerrit-ui/app/models/accounts/accounts-model_test.ts
@@ -54,7 +54,7 @@ test('invalid account makes only one request', () => { const response = {...new Response(), status: 404}; const getAccountDetails = stubRestApi('getAccountDetails').callsFake( - (_: any, errFn: any) => { + (_, errFn) => { if (errFn !== undefined) { errFn(response); }
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts index a71d225..9bbd23c 100644 --- a/polygerrit-ui/app/models/change/change-model.ts +++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -59,7 +59,6 @@ import {Model} from '../base/model'; import {UserModel} from '../user/user-model'; import {define} from '../dependency'; -import {FlagsService, KnownExperimentId} from '../../services/flags/flags'; import { isOwner, isUploader, @@ -102,6 +101,7 @@ * Corresponding values in `change` are always kept in sync. */ submittabilityInfo?: SubmittabilityInfo; + submittabilityLoadingStatus: LoadingStatus; /** * 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. @@ -338,6 +338,7 @@ // Use DeepReadOnly? const initialState: ChangeState = { loadingStatus: LoadingStatus.NOT_LOADED, + submittabilityLoadingStatus: LoadingStatus.NOT_LOADED, }; export const changeModelToken = define<ChangeModel>('change-model'); @@ -373,6 +374,11 @@ changeState => changeState.submittabilityInfo ); + public readonly submittabilityLoadingStatus$ = select( + this.state$, + changeState => changeState.submittabilityLoadingStatus + ); + public readonly submittable$ = select( this.state$, changeState => changeState.submittabilityInfo?.submittable @@ -532,8 +538,7 @@ private readonly restApiService: RestApiService, private readonly userModel: UserModel, private readonly pluginLoader: PluginLoader, - private readonly reporting: ReportingService, - private readonly flagsService: FlagsService + private readonly reporting: ReportingService ) { super(initialState); this.patchNum$ = select( @@ -827,21 +832,18 @@ if (!changeNum) { // On change reload changeNum is set to undefined to reset change // state. We propagate undefined and reset the state in this case. + this.updateState({ + submittabilityLoadingStatus: LoadingStatus.NOT_LOADED, + }); return of(undefined); } + this.updateState({ + submittabilityLoadingStatus: LoadingStatus.LOADING, + }); return from(this.restApiService.getSubmittabilityInfo(changeNum)); }) ) .subscribe(submittabilityInfo => { - // TODO(b/445644919): Remove once the submit_requirements is never - // requested as part of the change detail. - if ( - !this.flagsService.isEnabled( - KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS - ) - ) { - return; - } const change = fillFromSubmittabilityInfo( this.change, submittabilityInfo @@ -849,6 +851,9 @@ this.updateState({ change, submittabilityInfo, + submittabilityLoadingStatus: submittabilityInfo + ? LoadingStatus.LOADED + : LoadingStatus.NOT_LOADED, }); }); } @@ -1125,13 +1130,7 @@ return; } change = updateRevisionsWithCommitShas(change); - // TODO(b/445644919): Remove once the submit_requirements is never requested - // as part of the change detail. - if ( - this.flagsService.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS) - ) { - change = fillFromSubmittabilityInfo(change, this.submittabilityInfo); - } + change = fillFromSubmittabilityInfo(change, this.submittabilityInfo); this.updateState({ change, loadingStatus: LoadingStatus.LOADED,
diff --git a/polygerrit-ui/app/models/change/change-model_test.ts b/polygerrit-ui/app/models/change/change-model_test.ts index ce3533e..e415729 100644 --- a/polygerrit-ui/app/models/change/change-model_test.ts +++ b/polygerrit-ui/app/models/change/change-model_test.ts
@@ -62,7 +62,6 @@ 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'; import {SubmittabilityInfo} from '../../services/gr-rest-api/gr-rest-api'; -import {FlagsService, KnownExperimentId} from '../../services/flags/flags'; suite('updateRevisionsWithCommitShas() tests', () => { test('undefined edit', async () => { @@ -222,26 +221,11 @@ }); }); -class TestFlagService implements FlagsService { - public experiments: Set<string> = new Set(); - - finalize() {} - - isEnabled(experimentId: string): boolean { - return this.experiments.has(experimentId); - } - - get enabledExperiments() { - return [...this.experiments]; - } -} - suite('change model tests', () => { let changeViewModel: ChangeViewModel; let changeModel: ChangeModel; let knownChange: ParsedChangeInfo; let knownChangeNoRevision: ChangeInfo; - let testFlagService: TestFlagService; const testCompleted = new Subject<void>(); async function waitForLoadingStatus( @@ -255,7 +239,6 @@ } setup(() => { - testFlagService = new TestFlagService(); changeViewModel = testResolver(changeViewModelToken); changeModel = new ChangeModel( testResolver(navigationToken), @@ -263,8 +246,7 @@ getAppContext().restApiService, testResolver(userModelToken), testResolver(pluginLoaderToken), - getAppContext().reportingService, - testFlagService + getAppContext().reportingService ); knownChangeNoRevision = { ...createChange(), @@ -508,9 +490,6 @@ }); test('load submit requirements (SRs load first)', async () => { - testFlagService.experiments.add( - KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS - ); const promiseDetail = mockPromise<ParsedChangeInfo | undefined>(); const stubDetail = stubRestApi('getChangeDetail').callsFake( () => promiseDetail @@ -540,10 +519,7 @@ assert.equal(stubSrs.callCount, 1); }); - test('load submit requirements (Detail load first, experiment enabled)', async () => { - testFlagService.experiments.add( - KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS - ); + test('load submit requirements (Detail load first)', async () => { const promiseDetail = mockPromise<ParsedChangeInfo | undefined>(); const stubDetail = stubRestApi('getChangeDetail').callsFake( () => promiseDetail @@ -574,34 +550,7 @@ assert.equal(stubSrs.callCount, 1); }); - test('load submit requirements (experiment disabled)', async () => { - const promiseDetail = mockPromise<ParsedChangeInfo | undefined>(); - const stubDetail = stubRestApi('getChangeDetail').callsFake( - () => promiseDetail - ); - const promiseSrs = mockPromise<SubmittabilityInfo | undefined>(); - const stubSrs = stubRestApi('getSubmittabilityInfo').callsFake( - () => promiseSrs - ); - testResolver(changeViewModelToken).setState(createChangeViewState()); - promiseSrs.resolve(undefined); - promiseDetail.resolve({ - ...knownChange, - submittable: false, - submit_requirements: [createSubmitRequirementResultInfo()], - }); - const state = await waitForLoadingStatus(LoadingStatus.LOADED); - // Validate that submit requirements didn't get reset to undefined. - assert.isTrue(state.change?.submittable === false); - assert.isTrue(state.change?.submit_requirements?.length === 1); - assert.equal(stubDetail.callCount, 1); - assert.equal(stubSrs.callCount, 1); - }); - test('reload submit requirements', async () => { - testFlagService.experiments.add( - KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS - ); // Set initial state const stubDetail = stubRestApi('getChangeDetail').resolves(knownChange); const stubSrs = stubRestApi('getSubmittabilityInfo');
diff --git a/polygerrit-ui/app/models/chat/chat-model.ts b/polygerrit-ui/app/models/chat/chat-model.ts new file mode 100644 index 0000000..619424b --- /dev/null +++ b/polygerrit-ui/app/models/chat/chat-model.ts
@@ -0,0 +1,1119 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {combineLatest, Observable} from 'rxjs'; +import {startWith} from 'rxjs/operators'; + +import { + Action, + Actions, + AiCodeReviewProvider, + ChatRequest, + ChatResponse, + ChatResponseListener, + ChatResponsePart, + ContextItem, + ContextItemType, + Conversation, + ConversationTurn, + CreateCommentAction, + ModelInfo, + Models, + Reference, +} from '../../api/ai-code-review'; +import {ChangeInfo, CommentInfo, FileInfoStatus} from '../../api/rest-api'; +import {PreferencesInfo} from '../../types/common'; +import {isDefined} from '../../types/types'; +import {assert, assertIsDefined, cryptoUuid} from '../../utils/common-util'; +import {select} from '../../utils/observable-util'; +import {Model} from '../base/model'; +import {ChangeModel} from '../change/change-model'; +import {define} from '../dependency'; +import {PluginsModel} from '../plugins/plugins-model'; +import {UserModel} from '../user/user-model'; + +import {contextItemEquals} from './context-item-util'; +import {FilesModel, NormalizedFileInfo} from '../change/files-model'; +import {isMagicPath} from '../../utils/path-list-util'; + +/** The available display modes in the chat panel. */ +export enum ChatPanelMode { + HISTORY, + CONVERSATION, +} + +/** + * The type of user sending or receiving a message. + */ +export enum UserType { + USER, + GEMINI, +} + +/** The type of a response part. */ +export enum ResponsePartType { + TEXT, + CREATE_COMMENT, +} + +/** A message from the user. */ +export declare interface UserMessage { + readonly userType: UserType.USER; + readonly content: string; + readonly actionId?: string; + // A list of additional context items included in the chat request. + readonly contextItems: readonly ContextItem[]; + // Whether the user message was triggered in the background (e.g. when + // Summarize this CL is trigger when clicking the Help me review button). This + // may affect the UI layout of the turn. + readonly isBackgroundRequest?: boolean; +} + +/** + * This is the model internal equivalent to the API interface ChatResponsePart. + */ +export declare interface ResponsePartBase { + // The part ID. Together with a conversation ID and turn ID, this uniquely + // identifies a response part. + readonly id: number; + readonly type: ResponsePartType; + readonly content: string; +} + +/** A response part from Gemini suggesting to create a comment. */ +export declare interface CreateCommentPart extends ResponsePartBase { + // A unique ID used to identify the comment to be created by this action. + // This is derived from the conversation ID, turn ID, and part ID. + readonly commentCreationId: string; + readonly type: ResponsePartType.CREATE_COMMENT; + readonly comment: Partial<CommentInfo>; +} + +/** The text part of a Gemini response. */ +export declare interface TextPart extends ResponsePartBase { + readonly type: ResponsePartType.TEXT; +} + +/** A part of a Gemini response. */ +export type GeminiResponsePart = TextPart | CreateCommentPart; + +/** A message from Gemini. */ +export declare interface GeminiMessage { + readonly userType: UserType.GEMINI; + readonly responseParts: readonly GeminiResponsePart[]; + // An index that increments whenever the user regenerates the Gemini response + // for the same turn, i.e. by clicking the refresh button. + // The default first value is 0. + readonly regenerationIndex: number; + readonly responseComplete?: boolean; + readonly errorMessage?: string; + readonly references: readonly Reference[]; + readonly citations: readonly string[]; + readonly timestamp?: Date; +} + +/** + * A turn within a Conversation. Consists of a user message and the Gemini + * response. The Gemini response is optional, as it may not have been received + * yet. Turns have an implicit turn index, which is the index of the turn within + * the conversation. + */ +export declare interface Turn { + readonly userMessage: UserMessage; + readonly geminiMessage: GeminiMessage; +} + +/** + * A unique identifier for a turn in a conversation, accounting for turn + * regeneration. + */ +export declare interface UniqueTurnId { + turnIndex: number; + regenerationIndex: number; +} + +/** Fields that are required to restore the chat history in the UI. */ +export declare interface ClientData { + /** + * When false, the FE should re-use the ClientData from the previous turn + * instead of using the fields in this message. In this case, none of the + * other fields in this message should be set. + */ + overridesPreviousTurn?: boolean; + + /** The action the user selected in the chat. */ + actionId?: string; + + contextItems?: ContextItem[]; + + /** + * Whether the turn was triggered in the background (e.g. when Summarize this + * CL is trigger when clicking the Help me review button). This affects the UI + * layout of the turn. + */ + isBackgroundRequest?: boolean; +} + +export declare interface ConvTurnId { + conversationId: string; + turnId: UniqueTurnId; +} + +export declare interface ConvTurnPartId extends ConvTurnId { + partId: number; +} + +/** State for the view state of an AI conversation. */ +export declare interface ConversationState { + /** Information if the request failed. */ + readonly errorMessage?: string; + /** messages in the chat so far. */ + readonly turns: readonly Turn[]; + + /** + * The user message that is currently being drafted, and will be issued in + * the next chat turn. + */ + readonly draftUserMessage: UserMessage; + + /** + * True iff context (or contextItems) was updated since the last request. Used + * to persist new client data during the next chat turn. + */ + readonly contextUpdated?: boolean; + + /* + * The conversation ID which uniquely identifies a conversation. May be empty + * if the conversation has not been started yet. + */ + readonly id: string; +} + +export declare interface ChatState extends ConversationState { + readonly mode: ChatPanelMode; + + /** The list of conversations for the current CL. */ + readonly conversations?: readonly Conversation[]; + + // Chat models for the current CL. + readonly models?: Models; + // Chat models for the current CL. + readonly selectedModelId?: string; + // Error message if the chat models failed to load. + readonly modelsLoadingError?: string; + + // Chat actions for the current CL. + readonly actions?: Actions; + readonly customActions?: readonly Action[]; + // Error message if the actions failed to load. + readonly actionsLoadingError?: string; + + // The list of context item types supported by the provider. + readonly contextItemTypes?: readonly ContextItemType[]; + // Error message if the context item types failed to load. + readonly contextItemTypesLoadingError?: string; + readonly provider?: AiCodeReviewProvider; +} + +export const initialConversationState: ConversationState = { + turns: [], + id: '', + draftUserMessage: { + userType: UserType.USER, + content: '', + actionId: undefined, + contextItems: [], + }, +}; + +export const chatModelToken = define<ChatModel>('chat-model'); + +export class ChatModel extends Model<ChatState> { + readonly models$: Observable<Models | undefined> = select( + this.state$, + chatState => chatState.models + ); + + readonly selectedModelId$: Observable<string | undefined>; + + readonly availableModelsMap$: Observable<ReadonlyMap<string, ModelInfo>> = + select( + this.models$, + models => + new Map( + (models?.models ?? []).map(model => [model.model_id, model]) + ) as ReadonlyMap<string, ModelInfo> + ); + + readonly selectedModel$: Observable<ModelInfo | undefined>; + + readonly modelsLoadingError$: Observable<string | undefined> = select( + this.state$, + chatState => chatState.modelsLoadingError + ); + + readonly actions$: Observable<readonly Action[]> = select( + this.state$, + chatState => chatState.actions?.actions ?? [] + ); + + readonly customActions$: Observable<readonly Action[]> = select( + this.state$, + chatState => chatState.customActions ?? [] + ); + + readonly defaultActionId$: Observable<string | undefined> = select( + this.state$, + chatState => chatState.actions?.default_action_id + ); + + readonly defaultAction$: Observable<Action | undefined> = select( + combineLatest([this.actions$, this.defaultActionId$]), + ([actions, defaultActionId]) => + actions.find(action => action.id === defaultActionId) + ); + + readonly contextItemTypes$: Observable<readonly ContextItemType[]> = select( + this.state$, + chatState => chatState.contextItemTypes ?? [] + ); + + readonly turns$: Observable<readonly Turn[] | undefined> = select( + this.state$, + chatState => chatState.turns + ); + + readonly nextTurnIndex$: Observable<number> = select( + this.turns$, + turns => turns?.length ?? 0 + ); + + readonly conversations$: Observable<readonly Conversation[]> = select( + this.state$, + chatState => chatState.conversations ?? [] + ); + + readonly conversationId$: Observable<string | undefined> = select( + this.state$, + chatState => chatState.id + ); + + readonly mode$: Observable<ChatPanelMode> = select( + this.state$, + chatState => chatState.mode + ); + + readonly errorMessage$: Observable<string | undefined> = select( + this.state$, + chatState => chatState.errorMessage + ); + + readonly capabilitiesLoaded$: Observable<boolean> = select( + this.state$, + state => + !!state.modelsLoadingError || + !!state.actionsLoadingError || + (!!state.models && !!state.actions) + ); + + readonly userInput$: Observable<string> = select( + this.state$, + chatState => chatState.draftUserMessage.content + ); + + readonly userContextItems$: Observable<readonly ContextItem[]> = select( + this.state$, + chatState => chatState.draftUserMessage.contextItems + ); + + readonly provider$: Observable<AiCodeReviewProvider | undefined> = select( + this.state$, + state => state.provider + ); + + private plugin?: AiCodeReviewProvider; + + private change?: ChangeInfo; + + private files: NormalizedFileInfo[] = []; + + constructor( + private readonly pluginsModel: PluginsModel, + private readonly changeModel: ChangeModel, + private readonly filesModel: FilesModel, + private readonly userModel: UserModel + ) { + super({ + mode: ChatPanelMode.CONVERSATION, + ...initialConversationState, + }); + + this.selectedModelId$ = select( + combineLatest([ + this.state$, + this.userModel.preferences$.pipe(startWith(undefined)), + ]), + ([chatState, preferences]) => + this.getEffectiveModelId(chatState, preferences) + ); + + this.selectedModel$ = select( + combineLatest([this.availableModelsMap$, this.selectedModelId$]), + ([availableModelsMap, selectedModelId]) => { + if (!selectedModelId) return undefined; + return availableModelsMap.get(selectedModelId); + } + ); + + this.pluginsModel.aiCodeReviewPlugins$.subscribe(plugins => { + const provider = plugins[0]?.provider; + + if (this.plugin === provider) return; + + this.plugin = provider; + this.updateState({ + provider, + }); + + // If the plugin registers after the change object was loaded, the + // initial fetch would have silently returned undefined, leaving capabilitiesLoaded + // in an infinite loading state. We must re-trigger the fetches when the plugin arrives. + if (this.change) { + this.getModels(); + this.getActions(); + this.getContextItemTypes(); + this.listConversations(); + } + }); + + this.filesModel.files$.subscribe(files => (this.files = files ?? [])); + this.changeModel.change$.subscribe(change => { + const isNewChange = change?._number !== this.change?._number; + this.change = change as ChangeInfo; + // We only want to reset the chat state and fetch models when navigating + // to a different change. Otherwise, property updates on the change + // object (e.g. submittability loaded) will trigger duplicate requests. + if (!isNewChange) return; + + this.updateState({ + ...initialConversationState, + // We need to explicitly clear these, because updateState does a shallow + // merge, and initialConversationState does not contain these fields. + models: undefined, + selectedModelId: undefined, + modelsLoadingError: undefined, + actions: undefined, + customActions: undefined, + actionsLoadingError: undefined, + contextItemTypes: undefined, + contextItemTypesLoadingError: undefined, + conversations: undefined, + }); + + if (!this.change) return; + + this.getModels(); + this.getActions(); + this.getContextItemTypes(); + this.listConversations(); + }); + } + + private getEffectiveModelId( + state: ChatState, + preferences?: PreferencesInfo + ): string | undefined { + const id = + state.selectedModelId ?? + preferences?.ai_chat_selected_model ?? + state.models?.default_model_id; + + if (!state.models?.models) return id; + + const isAvailable = state.models.models.some(m => m.model_id === id); + return isAvailable ? id : state.models.default_model_id; + } + + contextItemToType(contextItem?: ContextItem): ContextItemType | undefined { + if (!contextItem) return undefined; + const state = this.getState(); + const contextItemTypes = state.contextItemTypes; + if (!contextItemTypes) return undefined; + return contextItemTypes.find( + contextItemType => contextItemType.id === contextItem.type_id + ); + } + + regenerateMessage(turnId: UniqueTurnId) { + const state = this.getState(); + const turnIndex = turnId.turnIndex; + let turns = state.turns; + assert(turnIndex < turns.length, 'turnIndex out of bounds'); + const turn = turns[turnIndex]; + + const nextMessage = thinkingGeminiMessage( + turn.geminiMessage.errorMessage + ? turn.geminiMessage.regenerationIndex + : turnId.regenerationIndex + 1 + ); + + turns = [ + ...turns.slice(0, turnIndex), + { + ...turns[turnIndex], + geminiMessage: nextMessage, + }, + ...turns.slice(turnIndex + 1), + ]; + this.updateState({ + ...state, + turns, + // It's possible that the context changed between message n-1 and n, + // but at this point we've forgotten. An easy workaround is to just + // assume it did and persist new client data. + contextUpdated: true, + }); + + this.sendChatRequest(turnIndex); + } + + updateUserInput(content: string) { + const state = this.getState(); + this.updateState({ + ...state, + draftUserMessage: { + ...state.draftUserMessage, + content, + }, + }); + } + + chat( + userInputFreeForm: string, + actionId: string | undefined, + turnIndex: number + ) { + const action = this.getAction(actionId); + assertIsDefined(action, 'action'); + const userQuestion = userInputFreeForm || action.initial_user_prompt; + assertIsDefined(userQuestion, 'userQuestion'); + + const state = this.getState(); + const userMessage: UserMessage = { + ...state.draftUserMessage, + content: userQuestion, + actionId: action.id, + isBackgroundRequest: false, + }; + const nextTurn = { + userMessage, + geminiMessage: thinkingGeminiMessage(), + }; + + this.updateState({ + ...state, + id: state.id || cryptoUuid(), + errorMessage: undefined, + turns: [...state.turns, nextTurn], + draftUserMessage: draftFromUserMessage(userMessage), + }); + + this.sendChatRequest(turnIndex); + } + + getAction(id?: string) { + const state = this.getState(); + const actions = [ + ...(state.customActions ?? []), + ...(state.actions?.actions ?? []), + ]; + const defaultActionId = state.actions?.default_action_id; + return ( + actions.find(action => action.id === id) ?? + actions.find(action => action.id === defaultActionId) + ); + } + + sendChatRequest(turnIndex: number) { + assertIsDefined(this.change, 'change'); + const change = this.change; + const files = this.files + .map(file => { + return { + path: file.__path, + status: file.status ?? FileInfoStatus.MODIFIED, + }; + }) + .filter(file => !isMagicPath(file.path)); + const state = this.getState(); + assertIsDefined(state.models, 'state.models'); + + const turn = state.turns[turnIndex]; + assertIsDefined(turn, 'turn'); + const previousTurn = turnIndex > 0 ? state.turns[turnIndex - 1] : undefined; + const userMessage = turn.userMessage; + const turnId: UniqueTurnId = { + turnIndex, + regenerationIndex: turn.geminiMessage.regenerationIndex, + }; + const contextItems = [...userMessage.contextItems]; + const actionId = userMessage.actionId; + const action = this.getAction(actionId); + assertIsDefined(action, 'action'); + const contextUpdated = !!state.contextUpdated; + const isBackgroundRequest = !!turn.userMessage.isBackgroundRequest; + const previousTurnIsBackgroundRequest = + !!previousTurn?.userMessage.isBackgroundRequest; + const conversationId = state.id; + + const clientData: ClientData = {}; + if ( + turnIndex === 0 || + contextUpdated || + isBackgroundRequest !== previousTurnIsBackgroundRequest + ) { + clientData.overridesPreviousTurn = true; + clientData.actionId = actionId; + clientData.contextItems = contextItems; + clientData.isBackgroundRequest = isBackgroundRequest; + } + + const request: ChatRequest = { + action, + prompt: userMessage.content, + conversation_id: conversationId, + change, + files, + turn_index: turnIndex, + regeneration_index: turn.geminiMessage.regenerationIndex, + client_data: JSON.stringify(clientData), + model_name: this.getEffectiveModelId( + state, + this.userModel.getState().preferences + ), + external_contexts: contextItems, + }; + const listener: ChatResponseListener = { + emitResponse: (response: ChatResponse) => { + const state = this.getState(); + if (state.id !== conversationId) return; + if (turnIndex >= state.turns.length) return; + const geminiMessage: Partial<GeminiMessage> = { + responseParts: extractResponseParts(response, { + turnId, + conversationId: state.id, + }), + references: response.references ?? [], + citations: response.citations ?? [], + timestamp: new Date(response.timestamp_millis ?? 0), + }; + this.updateState({ + ...mergeIntoTurn(state, turnId, geminiMessage), + errorMessage: undefined, + contextUpdated: false, + }); + }, + emitError: (errorMessage: string) => { + const state = this.getState(); + if (state.id !== conversationId) return; + const turns: readonly Turn[] = state.turns; + const lastTurn: Turn | undefined = turns[turns.length - 1]; + if (!lastTurn?.geminiMessage) { + this.updateState({errorMessage}); + return; + } + this.updateState({ + ...mergeIntoTurn(state, turnId, {errorMessage}), + errorMessage, + }); + }, + done: () => { + const state = this.getState(); + if (state.id !== conversationId) return; + assert(turnIndex < state.turns.length, 'turn index out of bounds'); + const geminiMessage: Partial<GeminiMessage> = { + responseComplete: true, + }; + this.updateState({ + ...mergeIntoTurn(state, turnId, geminiMessage), + contextUpdated: false, + }); + }, + }; + this.plugin?.chat?.(request, listener); + } + + startNewChatWithPredefinedPrompt( + actionId: string | undefined, + contextItems: ContextItem[] = [], + isBackgroundRequest = false + ) { + const action = this.getAction(actionId); + assertIsDefined(action, 'action'); + const userQuestion = action.initial_user_prompt; + if (!userQuestion) return; + const message: UserMessage = { + userType: UserType.USER, + content: userQuestion ?? '', + actionId: action.id, + contextItems, + isBackgroundRequest, + }; + const turns: Turn[] = [userTurn(message)]; + + this.updateState({ + ...initialConversationState, + id: cryptoUuid(), + turns, + draftUserMessage: draftFromUserMessage(message), + }); + + this.sendChatRequest(0); + } + + startNewChatWithUserInput( + userInput: string, + actionId: string | undefined, + contextItems: ContextItem[] = [], + useCurrentContext = true + ) { + const state = this.getState(); + const message: UserMessage = { + userType: UserType.USER, + content: userInput, + actionId, + contextItems: useCurrentContext + ? state.draftUserMessage.contextItems + : contextItems, + }; + const turns: Turn[] = userInput ? [userTurn(message)] : []; + + this.updateState({ + ...initialConversationState, + id: cryptoUuid(), + turns, + draftUserMessage: draftFromUserMessage(message), + }); + + if (userInput) this.sendChatRequest(0); + } + + addContextItem(contextItem: ContextItem) { + const state = this.getState(); + const currentItems = state.draftUserMessage.contextItems; + if (currentItems.some(item => contextItemEquals(item, contextItem))) { + return; + } + this.updateState({ + ...state, + draftUserMessage: { + ...state.draftUserMessage, + contextItems: [...currentItems, contextItem], + }, + contextUpdated: true, + }); + } + + removeContextItem(contextItem: ContextItem) { + const state = this.getState(); + const currentItems = state.draftUserMessage.contextItems; + this.updateState({ + ...state, + draftUserMessage: { + ...state.draftUserMessage, + contextItems: currentItems.filter( + item => !contextItemEquals(item, contextItem) + ), + }, + contextUpdated: true, + }); + } + + startEmptyNewChat(useCurrentContext: boolean) { + const state = this.getState(); + const currentDraftUserMessage = state.draftUserMessage; + const draftUserMessage = { + ...initialConversationState.draftUserMessage, + contextItems: useCurrentContext + ? currentDraftUserMessage.contextItems + : [], + }; + + this.updateState({ + ...initialConversationState, + id: cryptoUuid(), + draftUserMessage, + turns: [], + }); + } + + setMode(mode: ChatPanelMode) { + this.updateState({mode}); + if (mode === ChatPanelMode.HISTORY) { + this.listConversations(); + } + } + + listConversations() { + if (!this.change) return; + return this.plugin + ?.listChatConversations?.(this.change) + .then((conversations: Conversation[]) => { + this.updateState({conversations}); + }) + .catch((error: Error) => { + this.updateState({errorMessage: error.message}); + console.error('Failed to list chat conversations', error); + }); + } + + loadConversation(conversationId: string) { + if (!this.change) return; + return this.plugin + ?.getChatConversation?.(this.change, conversationId) + .then((turns: ConversationTurn[]) => { + const conversationState = stateFromConversationResponse( + turns, + conversationId + ); + this.updateState({ + mode: ChatPanelMode.CONVERSATION, + ...conversationState, + }); + }) + .catch((error: Error) => { + this.updateState({errorMessage: error.message}); + console.error('Failed to load chat conversation', error); + }); + } + + selectModel(selectedModelId: string) { + this.updateState({selectedModelId}); + this.userModel.updatePreferences({ai_chat_selected_model: selectedModelId}); + } + + getModels() { + if (!this.change) return; + return this.plugin + ?.getModels?.(this.change) + .then((models: Models) => { + this.updateState({ + models, + modelsLoadingError: undefined, + customActions: models.custom_actions, + }); + }) + .catch((error: Error) => { + this.updateState({ + models: undefined, + modelsLoadingError: error.message, + }); + console.error('Failed to get chat models', error); + }); + } + + getActions() { + if (!this.change) return; + return this.plugin + ?.getActions?.(this.change) + .then((actions: Actions) => { + this.updateState({ + actions, + actionsLoadingError: undefined, + }); + }) + .catch((error: Error) => { + this.updateState({ + actions: undefined, + actionsLoadingError: error.message, + }); + console.error('Failed to get chat actions', error); + }); + } + + getContextItemTypes() { + return this.plugin + ?.getContextItemTypes?.() + .then((contextItemTypes: ContextItemType[]) => { + this.updateState({ + contextItemTypes, + contextItemTypesLoadingError: undefined, + }); + }) + .catch((error: Error) => { + this.updateState({ + contextItemTypes: undefined, + contextItemTypesLoadingError: error.message, + }); + console.error('Failed to get chat context types', error); + }); + } +} + +function buildCommentCreationId({ + conversationId, + turnId, + partId, +}: ConvTurnPartId) { + return `chat-panel-generated-comment:${conversationId}:${turnId.turnIndex}:${turnId.regenerationIndex}:${partId}`; +} + +function userTurn(userMessage: UserMessage): Turn { + return { + userMessage, + geminiMessage: thinkingGeminiMessage(), + }; +} + +/** + * Creates a Gemini message in the thinking state. The message has no response + * parts. Visible for testing. + */ +function thinkingGeminiMessage(regenerationIndex = 0): GeminiMessage { + return { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex, + references: [], + citations: [], + }; +} + +/** + * Merges the given Gemini message into the existing Gemini message of the turn + * at the given turn index. + */ +function mergeIntoTurn( + state: ConversationState, + turnId: UniqueTurnId, + geminiMessage: Partial<Omit<GeminiMessage, 'regenerationIndex'>> +): ConversationState { + const turnIndex = turnId.turnIndex; + assert(turnIndex < state.turns.length, 'turnIndex out of bounds'); + + // This merges the potentially already existing (partial) GeminiMessage of + // this turn into turnUpdate, otherwise it would be overwritten below. + const mergedMessage = mergeGeminiMessages( + turnId.regenerationIndex, + state.turns[turnIndex].geminiMessage, + geminiMessage + ); + + const turns = [ + ...state.turns.slice(0, turnIndex), + {...state.turns[turnIndex], geminiMessage: mergedMessage}, + ...state.turns.slice(turnIndex + 1), + ]; + return {...state, turns}; +} + +/** + * Merges the update into the existing message. + * + * For most GeminiMessage fields, the update will overwrite the existing + * message. However, for responseParts, this appends the parts that are not + * already present in the existing message. + */ +function mergeGeminiMessages( + regenerationIndex: number, + existingMessage?: GeminiMessage, + update?: Partial<Omit<GeminiMessage, 'regenerationIndex'>> +): GeminiMessage { + if (!existingMessage) { + existingMessage = { + userType: UserType.GEMINI, + responseParts: [], + references: [], + citations: [], + regenerationIndex, + }; + } + if (!update) return existingMessage; + // We should never merge messages with different regeneration indices. + // If this happens, it could indicate that 2 regenerations were fired in + // parallel, or that the old message was not cleared before sending a new + // request. + if (existingMessage.regenerationIndex !== regenerationIndex) { + console.error( + `Attempted to merge messages with different regeneration indices: + ${existingMessage.regenerationIndex} vs ${regenerationIndex}` + ); + return existingMessage; + } + + return { + ...existingMessage, + ...update, + responseParts: mergeResponseParts(existingMessage, update), + references: [...existingMessage.references, ...(update.references || [])], + citations: [...existingMessage.citations, ...(update.citations || [])], + }; +} + +function mergeResponseParts( + existingMessage: GeminiMessage, + update: Partial<Omit<GeminiMessage, 'regenerationIndex'>> +): GeminiResponsePart[] { + const existingParts = [...(existingMessage.responseParts ?? [])]; + const updateParts = [...(update.responseParts ?? [])]; + const mergedParts: GeminiResponsePart[] = []; + + let existingPart = existingParts.shift(); + if (!existingPart) existingPart = updateParts.shift(); + let updatePart = updateParts.shift(); + while (existingPart && updatePart) { + if (existingPart.id === updatePart.id) { + assert(existingPart.type === updatePart.type, 'part type mismatch'); + existingPart = { + ...existingPart, + content: existingPart.content + updatePart.content, + }; + updatePart = updateParts.shift(); + } else if (existingPart.id < updatePart.id) { + mergedParts.push(existingPart); + existingPart = existingParts.shift(); + } else { + // Case where existingPart.id > updatePart.id. + mergedParts.push(updatePart); + updatePart = updateParts.shift(); + } + } + // Either existing parts or update parts are exhausted. + // Append the remaining parts. + if (existingPart) mergedParts.push(existingPart, ...existingParts); + if (updatePart) mergedParts.push(updatePart, ...updateParts); + + return mergedParts; +} + +function draftFromUserMessage(userMessage: UserMessage): UserMessage { + return { + ...userMessage, + content: '', + isBackgroundRequest: false, + }; +} + +function extractResponseParts( + response: ChatResponse, + turnIdentifier: ConvTurnId +): GeminiResponsePart[] { + return response.response_parts + .map(part => asGeminiResponsePart(part, turnIdentifier)) + .filter(isDefined); +} + +function asGeminiResponsePart( + part: ChatResponsePart, + turnIdentifier: ConvTurnId +): GeminiResponsePart | undefined { + if (part.text) { + return { + id: part.id, + type: ResponsePartType.TEXT, + content: part.text, + }; + } else if (part.create_comment_action) { + return convertCreateCommentAction({ + create_comment_action: part.create_comment_action, + partId: part.id, + commentCreationId: buildCommentCreationId({ + ...turnIdentifier, + partId: part.id, + }), + }); + } else { + return undefined; + } +} + +function convertCreateCommentAction(kwargs: { + create_comment_action: CreateCommentAction; + partId: number; + commentCreationId: string; +}): CreateCommentPart | undefined { + return { + type: ResponsePartType.CREATE_COMMENT, + id: kwargs.partId, + commentCreationId: kwargs.commentCreationId, + content: kwargs.create_comment_action?.comment_text ?? '', + comment: { + ...kwargs.create_comment_action, + message: kwargs.create_comment_action.comment_text, + }, + }; +} + +function stateFromConversationResponse( + responseTurns: ConversationTurn[], + conversationId: string +): ConversationState { + // The BE returns the conversation ID as a lowercase string, even though we + // only generate uppercase strings. + conversationId = conversationId.toUpperCase(); + let latestContextItems: readonly ContextItem[] = []; + let latestIsBackgroundRequest = false; + let latestActionId: string | undefined = undefined; + + const turns: Turn[] = []; + for (let index = 0; index < responseTurns.length; index++) { + const turn = responseTurns[index]; + const userInput = turn.user_input; + const turnResponse = turn.response || turn.chat_response; + const regenerationIndex = turn.regeneration_index ?? 0; + if (!userInput || !turnResponse) { + continue; + } + + const clientData: ClientData = JSON.parse( + userInput.client_data ?? '{}' + ) as ClientData; + if (clientData.overridesPreviousTurn) { + latestContextItems = clientData.contextItems ?? []; + latestActionId = clientData.actionId; + latestIsBackgroundRequest = clientData.isBackgroundRequest ?? false; + } + + const userMessage: UserMessage = { + userType: UserType.USER, + content: userInput.user_question ?? '', + contextItems: latestContextItems, + isBackgroundRequest: latestIsBackgroundRequest, + actionId: latestActionId, + }; + const geminiMessage: GeminiMessage = { + userType: UserType.GEMINI, + responseComplete: true, + regenerationIndex, + responseParts: extractResponseParts(turnResponse, { + turnId: {turnIndex: index, regenerationIndex}, + conversationId, + }), + references: turnResponse.references, + citations: turnResponse.citations ?? [], + timestamp: turn.timestamp_millis + ? new Date(turn.timestamp_millis) + : undefined, + }; + turns.push({userMessage, geminiMessage}); + } + + const draftUserMessage: UserMessage = { + userType: UserType.USER, + actionId: undefined, + contextItems: [], + ...(turns.length > 0 ? turns[turns.length - 1].userMessage : {}), + content: '', + isBackgroundRequest: false, + }; + + return { + errorMessage: undefined, + contextUpdated: undefined, + turns, + draftUserMessage, + id: conversationId, + }; +}
diff --git a/polygerrit-ui/app/models/chat/chat-model_test.ts b/polygerrit-ui/app/models/chat/chat-model_test.ts new file mode 100644 index 0000000..9f70830 --- /dev/null +++ b/polygerrit-ui/app/models/chat/chat-model_test.ts
@@ -0,0 +1,348 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert} from '@open-wc/testing'; +import {ChatModel, Turn, UserType} from './chat-model'; +import {PluginsModel} from '../plugins/plugins-model'; +import {ChangeModel} from '../change/change-model'; +import {FilesModel} from '../change/files-model'; +import {UserModel} from '../user/user-model'; +import {BehaviorSubject} from 'rxjs'; +import {createParsedChange} from '../../test/test-data-generators'; +import {AiCodeReviewProvider, ChatRequest} from '../../api/ai-code-review'; + +import sinon from 'sinon'; +import {ParsedChangeInfo} from '../../types/types'; + +suite('chat-model tests', () => { + let model: ChatModel; + let pluginsModel: PluginsModel; + let changeModel: ChangeModel; + let filesModel: FilesModel; + let userModel: UserModel; + let updatePreferencesStub: sinon.SinonStub; + let provider: AiCodeReviewProvider; + + setup(() => { + pluginsModel = new PluginsModel(); + changeModel = { + change$: new BehaviorSubject(undefined), + } as unknown as ChangeModel; + changeModel.updateStateChange = (change?: ParsedChangeInfo) => { + ( + changeModel.change$ as BehaviorSubject<ParsedChangeInfo | undefined> + ).next(change); + }; + + filesModel = { + files$: new BehaviorSubject([]), + } as unknown as FilesModel; + updatePreferencesStub = sinon.stub(); + userModel = { + getState: () => { + return {preferences: {}}; + }, + preferences$: new BehaviorSubject({}), + updatePreferences: updatePreferencesStub, + } as unknown as UserModel; + provider = { + chat: sinon.stub(), + listChatConversations: sinon.stub().resolves([]), + getChatConversation: sinon.stub().resolves([]), + getModels: sinon.stub().resolves({models: [], default_model_id: ''}), + getActions: sinon.stub().resolves({actions: [], default_action_id: ''}), + getContextItemTypes: sinon.stub().resolves([]), + }; + sinon + .stub(pluginsModel, 'aiCodeReviewPlugins$') + .get(() => new BehaviorSubject([{pluginName: 'test-plugin', provider}])); + + model = new ChatModel(pluginsModel, changeModel, filesModel, userModel); + }); + + test('initial state', () => { + const state = model.getState(); + assert.isObject(state); + assert.isEmpty(state.turns); + }); + + test('change subscription triggers API calls', () => { + changeModel.updateStateChange(createParsedChange()); + assert.isTrue((provider.getModels as sinon.SinonStub).called); + assert.isTrue((provider.getActions as sinon.SinonStub).called); + assert.isTrue((provider.getContextItemTypes as sinon.SinonStub).called); + assert.isTrue((provider.listChatConversations as sinon.SinonStub).called); + }); + + test('updateUserInput', () => { + model.updateUserInput('test input'); + const state = model.getState(); + assert.equal(state.draftUserMessage.content, 'test input'); + }); + + test('addContextItem', () => { + const item = { + type_id: 'file', + link: 'link', + title: 'title', + identifier: 'id', + }; + model.addContextItem(item); + let state = model.getState(); + assert.lengthOf(state.draftUserMessage.contextItems, 1); + assert.deepEqual(state.draftUserMessage.contextItems[0], item); + + // Adding the same item again should not change the state. + model.addContextItem(item); + state = model.getState(); + assert.lengthOf(state.draftUserMessage.contextItems, 1); + }); + + test('removeContextItem', () => { + const item = { + type_id: 'file', + link: 'link', + title: 'title', + identifier: 'id', + }; + model.addContextItem(item); + let state = model.getState(); + assert.lengthOf(state.draftUserMessage.contextItems, 1); + + model.removeContextItem(item); + state = model.getState(); + assert.isEmpty(state.draftUserMessage.contextItems); + }); + + test('getModels with custom_actions updates actions', async () => { + const customActions = [{id: 'custom', display_text: 'Custom'}]; + (provider.getModels as sinon.SinonStub).resolves({ + models: [], + default_model_id: '', + custom_actions: customActions, + }); + + changeModel.updateStateChange(createParsedChange()); + // Wait for the promise to resolve + await new Promise(resolve => setTimeout(resolve, 0)); + + const state = model.getState(); + assert.isDefined(state.customActions); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.deepEqual(state.customActions, customActions as any); + }); + + test('chat uses selected model', async () => { + // Mock getModels to return multiple models + const models = { + models: [ + { + model_id: 'default-model', + full_display_text: 'Default Model', + short_text: 'Default', + }, + { + model_id: 'advanced-model', + full_display_text: 'Advanced Model', + short_text: 'Advanced', + }, + ], + default_model_id: 'default-model', + }; + const actions = { + actions: [], + default_action_id: 'default-action', + }; + (provider.getActions as sinon.SinonStub).resolves(actions); + (provider.getModels as sinon.SinonStub).resolves(models); + + changeModel.updateStateChange(createParsedChange()); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Select the non-default model + model.selectModel('advanced-model'); + + assert.isTrue( + updatePreferencesStub.calledWith({ + ai_chat_selected_model: 'advanced-model', + }) + ); + + // Trigger a chat + model.updateUserInput('hello'); + // We need an action to be defined. Since we defined default_action_id above, + // getAction will fallback to it if we assume it exists in actions list. + // However, our mocked actions list is empty. Let's add the default action. + actions.actions = [ + { + id: 'default-action', + display_text: 'Default Action', + initial_user_prompt: 'Hello', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + + model.chat('hello', undefined, 0); + + // Verify provider.chat was called with correct model_name + assert.isTrue((provider.chat as sinon.SinonStub).called); + const request = (provider.chat as sinon.SinonStub).lastCall + .args[0] as ChatRequest; + assert.equal(request.model_name, 'advanced-model'); + }); + + test('selectedModelId$ falls back when preferred model is unavailable', async () => { + const models = { + models: [ + { + model_id: 'default-model', + }, + ], + default_model_id: 'default-model', + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (provider.getModels as sinon.SinonStub).resolves(models as any); + + changeModel.updateStateChange(createParsedChange()); + await new Promise(resolve => setTimeout(resolve, 0)); + + model.selectModel('removed-model'); + + let selectedModelId; + const sub = model.selectedModelId$.subscribe(id => (selectedModelId = id)); + assert.equal(selectedModelId, 'default-model'); + sub.unsubscribe(); + }); + + test('chat falls back to default model when selected model is unavailable', async () => { + const models = { + models: [ + { + model_id: 'default-model', + }, + ], + default_model_id: 'default-model', + }; + const actions = { + actions: [ + { + id: 'default-action', + display_text: 'Default Action', + initial_user_prompt: 'Hello', + }, + ], + default_action_id: 'default-action', + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (provider.getActions as sinon.SinonStub).resolves(actions as any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (provider.getModels as sinon.SinonStub).resolves(models as any); + + changeModel.updateStateChange(createParsedChange()); + await new Promise(resolve => setTimeout(resolve, 0)); + + model.selectModel('removed-model'); + + model.updateUserInput('hello'); + model.chat('hello', undefined, 0); + + const request = (provider.chat as sinon.SinonStub).lastCall + .args[0] as ChatRequest; + assert.equal(request.model_name, 'default-model'); + }); + + test('change navigation resets state', () => { + model.updateUserInput('some input'); + model.selectModel('some-model'); + let state = model.getState(); + assert.equal(state.draftUserMessage.content, 'some input'); + assert.equal(state.selectedModelId, 'some-model'); + + changeModel.updateStateChange(createParsedChange()); + state = model.getState(); + assert.equal(state.draftUserMessage.content, ''); + assert.isUndefined(state.selectedModelId); + assert.isEmpty(state.turns); + }); + + test('change property update does not trigger API calls', () => { + const change = { + ...createParsedChange(), + _number: 123, + } as unknown as ParsedChangeInfo; + changeModel.updateStateChange(change); + assert.isTrue((provider.getModels as sinon.SinonStub).calledOnce); + + // Update some property but keep _number the same + const updatedChange = { + ...change, + subject: 'updated subject', + } as unknown as ParsedChangeInfo; + changeModel.updateStateChange(updatedChange); + + // API calls should not be triggered again + assert.isTrue((provider.getModels as sinon.SinonStub).calledOnce); + assert.isTrue((provider.getActions as sinon.SinonStub).calledOnce); + assert.isTrue((provider.getContextItemTypes as sinon.SinonStub).calledOnce); + }); + + test('regenerateMessage increments regenerationIndex when no error', () => { + const turn: Turn = { + userMessage: { + content: 'hello', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex: 0, + references: [], + citations: [], + }, + }; + model.updateState({ + ...model.getState(), + turns: [turn], + }); + + sinon.stub(model, 'sendChatRequest'); + + model.regenerateMessage({turnIndex: 0, regenerationIndex: 0}); + + const state = model.getState(); + assert.equal(state.turns[0].geminiMessage.regenerationIndex, 1); + }); + + test('regenerateMessage does not increment regenerationIndex when error exists', () => { + const turn: Turn = { + userMessage: { + content: 'hello', + userType: UserType.USER, + contextItems: [], + }, + geminiMessage: { + userType: UserType.GEMINI, + responseParts: [], + regenerationIndex: 0, + references: [], + citations: [], + errorMessage: 'error', + }, + }; + model.updateState({ + ...model.getState(), + turns: [turn], + }); + + sinon.stub(model, 'sendChatRequest'); + + model.regenerateMessage({turnIndex: 0, regenerationIndex: 0}); + + const state = model.getState(); + assert.equal(state.turns[0].geminiMessage.regenerationIndex, 0); + }); +});
diff --git a/polygerrit-ui/app/models/chat/context-item-util.ts b/polygerrit-ui/app/models/chat/context-item-util.ts new file mode 100644 index 0000000..85abc79 --- /dev/null +++ b/polygerrit-ui/app/models/chat/context-item-util.ts
@@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {ContextItem, ContextItemType} from '../../api/ai-code-review'; + +/** Parses all potential context items from a give text string. */ +export function searchForContextLinks( + text: string, + contextItemTypes: readonly ContextItemType[] +): ContextItem[] { + const contextItems: ContextItem[] = []; + for (const contextItemType of contextItemTypes) { + const matches = text.matchAll(new RegExp(contextItemType.regex, 'g')); + for (const match of matches) { + const url = match[0]; + const contextItem = contextItemType.parse(url); + if ( + contextItem && + !contextItems.some(item => contextItemEquals(item, contextItem)) + ) { + contextItems.push(contextItem); + } + } + } + return contextItems; +} + +/** + * Parses a link as a context item. Returns undefined if the link is not a + * supported context item. + */ +export function parseLink( + url: string, + contextItemTypes: readonly ContextItemType[] +): ContextItem | undefined { + url = url.replace(/\s+/g, ''); // Remove all whitespaces. + for (const contextItemType of contextItemTypes) { + const contextItem = contextItemType.parse(url); + if (contextItem) return contextItem; + } + return undefined; +} + +/** Implementation of the equals method for ContextItem. */ +export function contextItemEquals(a: ContextItem, b: ContextItem): boolean { + return a.type_id === b.type_id && a.identifier === b.identifier; +} + +/** + * Searches for bug numbers in a commit message that are not linked. + * It searches for keywords like "bug:", "fixes=", etc. followed by a + * long number. It ignores existing b/ links and URLs. + */ +export function searchForBugsInCommitMessage( + commitMessage: string, + contextItemTypes: readonly ContextItemType[] +): ContextItem[] { + const buganizerType = contextItemTypes.find(t => t.id === 'buganizer'); + if (!buganizerType) return []; + + const lines = commitMessage.toLowerCase().split('\n'); + // This regex is to identify lines that might contain bug numbers. + // TODO(b/484367705) support commentlinks defined by each host. + const keywordRegex = + // prettier-ignore + /\b(?:bug|fix|issue|fixed|fixes|bugfix|google-bug-id|cts-coverage-bug|crs-fixed)\b/i; + + const bugIds = new Set<string>(); + for (const line of lines) { + if (!keywordRegex.test(line)) continue; + + // Remove URLs and b/ links before searching for numbers, because they are + // handled by searchForContextLinks. + const cleanedLine = line + .replace(/https?:\/\/[^\s]+/g, '') + .replace(/b\/\d+/g, ''); + + // This regex finds nums with at least 8 digits, which are likely bug ids. + const numberRegex = /\b\d{8,}\b/g; + const numbers = cleanedLine.match(numberRegex) ?? []; + for (const num of numbers) { + bugIds.add(num); + } + } + + // Convert bugIds to ContextItems. + const contextItems: ContextItem[] = []; + for (const num of bugIds) { + const contextItem = buganizerType.parse(`b/${num}`); + if (contextItem) contextItems.push(contextItem); + } + return contextItems; +}
diff --git a/polygerrit-ui/app/models/chat/context-item-util_test.ts b/polygerrit-ui/app/models/chat/context-item-util_test.ts new file mode 100644 index 0000000..12c6c23 --- /dev/null +++ b/polygerrit-ui/app/models/chat/context-item-util_test.ts
@@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../../test/common-test-setup'; +import {assert} from '@open-wc/testing'; +import { + contextItemEquals, + parseLink, + searchForBugsInCommitMessage, + searchForContextLinks, +} from './context-item-util'; +import {ContextItem, ContextItemType} from '../../api/ai-code-review'; +import sinon from 'sinon'; + +suite('context-item-util tests', () => { + const fileContextItemType: ContextItemType = { + id: 'file', + name: 'File', + icon: 'file_copy', + regex: /file:\/\/([^\s]+)/, + placeholder: 'file://...', + parse: (input: string) => { + const match = input.match(/file:\/\/(.*)/); + if (!match) return undefined; + return { + type_id: 'file', + link: input, + title: match[1], + identifier: match[1], + }; + }, + }; + + const anotherContextItemType: ContextItemType = { + id: 'another', + name: 'Another', + icon: 'link', + regex: /another:\/\/([^\s]+)/, + placeholder: 'another://...', + parse: (input: string) => { + const match = input.match(/another:\/\/(.*)/); + if (!match) return undefined; + return { + type_id: 'another', + link: input, + title: match[1], + identifier: match[1], + }; + }, + }; + + const contextItemTypes = [fileContextItemType, anotherContextItemType]; + + suite('searchForContextLinks', () => { + test('finds single link', () => { + const text = 'Here is a file: file:///a/b/c.txt'; + const result = searchForContextLinks(text, contextItemTypes); + assert.lengthOf(result, 1); + assert.equal(result[0].type_id, 'file'); + assert.equal(result[0].identifier, '/a/b/c.txt'); + }); + + test('finds multiple links of different types', () => { + const text = 'File: file:///a/b/c.txt and another: another:///x/y/z.ts'; + const result = searchForContextLinks(text, contextItemTypes); + assert.lengthOf(result, 2); + assert.equal(result[0].type_id, 'file'); + assert.equal(result[0].identifier, '/a/b/c.txt'); + assert.equal(result[1].type_id, 'another'); + assert.equal(result[1].identifier, '/x/y/z.ts'); + }); + + test('deduplicates identical links', () => { + const text = 'File: file:///a/b/c.txt and again file:///a/b/c.txt'; + const result = searchForContextLinks(text, contextItemTypes); + assert.lengthOf(result, 1); + }); + + test('returns empty array when no links found', () => { + const text = 'No links here.'; + const result = searchForContextLinks(text, contextItemTypes); + assert.lengthOf(result, 0); + }); + }); + + suite('parseLink', () => { + test('parses a valid link', () => { + const url = 'file:///a/b/c.txt'; + const result = parseLink(url, contextItemTypes); + assert.isOk(result); + assert.equal(result.type_id, 'file'); + assert.equal(result.identifier, '/a/b/c.txt'); + }); + + test('returns undefined for an invalid link', () => { + const url = 'http://example.com'; + const result = parseLink(url, contextItemTypes); + assert.isUndefined(result); + }); + + test('removes whitespace before parsing', () => { + const url = ' file:///a/b/c.txt '; + const result = parseLink(url, contextItemTypes); + assert.isOk(result); + assert.equal(result.type_id, 'file'); + assert.equal(result.identifier, '/a/b/c.txt'); + }); + }); + + suite('contextItemEquals', () => { + test('returns true for equal items', () => { + const item1: ContextItem = { + type_id: 'file', + link: 'file:///a/b/c.txt', + title: 'c.txt', + identifier: '/a/b/c.txt', + }; + const item2: ContextItem = { + type_id: 'file', + link: 'file:///a/b/c.txt', + title: 'c.txt', + identifier: '/a/b/c.txt', + }; + assert.isTrue(contextItemEquals(item1, item2)); + }); + + test('returns false for items with different type_id', () => { + const item1: ContextItem = { + type_id: 'file', + link: 'file:///a/b/c.txt', + title: 'c.txt', + identifier: '/a/b/c.txt', + }; + const item2: ContextItem = { + type_id: 'another', + link: 'another:///a/b/c.txt', + title: 'c.txt', + identifier: '/a/b/c.txt', + }; + assert.isFalse(contextItemEquals(item1, item2)); + }); + + test('returns false for items with different identifier', () => { + const item1: ContextItem = { + type_id: 'file', + link: 'file:///a/b/c.txt', + title: 'c.txt', + identifier: '/a/b/c.txt', + }; + const item2: ContextItem = { + type_id: 'file', + link: 'file:///x/y/z.txt', + title: 'z.txt', + identifier: '/x/y/z.txt', + }; + assert.isFalse(contextItemEquals(item1, item2)); + }); + }); + + suite('searchForBugsInCommitMessage', () => { + let buganizerContextItemTypes: ContextItemType[]; + let parseSpy: sinon.SinonSpy; + + setup(() => { + parseSpy = sinon.spy((url: string) => { + const match = url.match(/b\/(\d+)/); + if (match) { + return { + type_id: 'buganizer', + title: `b/${match[1]}`, + link: `http://b/${match[1]}`, + identifier: match[1], + }; + } + return undefined; + }); + + buganizerContextItemTypes = [ + { + id: 'buganizer', + name: 'Buganizer', + icon: 'bug_report', + placeholder: 'b/...', + regex: + /(?:^|\s)(?:https:\/\/(?:b|buganizer)\.corp\.google\.com\/issues\/|b\/)([1-9]\d*)(?:\/.*)?/, + parse: parseSpy, + }, + ]; + }); + + test('finds bug in "bug: 12345678" line', () => { + const message = 'Fixes a bug.\n\nbug: 12345678'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 1); + assert.equal(result[0].identifier, '12345678'); + assert.isTrue(parseSpy.calledWith('b/12345678')); + }); + + test('finds bug in "fixes: 12345678" line', () => { + const message = 'Fixes a bug.\n\nfixes = 12345678'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 1); + assert.equal(result[0].identifier, '12345678'); + }); + + test('finds multiple bugs', () => { + const message = 'Fixes a bug.\n\nbug: 12345678, 87654321'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 2); + assert.deepEqual( + result.map(r => r.identifier), + ['12345678', '87654321'] + ); + }); + + test('does not find bug if no keyword', () => { + const message = 'This is a commit message.\n\n12345678'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 0); + }); + + test('does not find bug if keyword but no number', () => { + const message = 'This is a commit message.\n\nbug: '; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 0); + }); + + test('ignores b/ links', () => { + const message = 'Fixes a bug.\n\nbug: b/12345678'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 0); + }); + + test('ignores urls', () => { + const message = 'Fixes a bug.\n\nbug: http://b/12345678'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 0); + }); + + test('ignores short numbers', () => { + const message = 'Fixes a bug and 1 more feature on the count of 123.'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 0); + }); + + test('does not find bug in "debugging"', () => { + const message = 'Enable debugging for feature X 12345678'; + const result = searchForBugsInCommitMessage( + message, + buganizerContextItemTypes + ); + assert.equal(result.length, 0); + }); + }); +});
diff --git a/polygerrit-ui/app/models/checks/checks-fakes.ts b/polygerrit-ui/app/models/checks/checks-fakes.ts deleted file mode 100644 index 2b187f4..0000000 --- a/polygerrit-ui/app/models/checks/checks-fakes.ts +++ /dev/null
@@ -1,638 +0,0 @@ -/** - * @license - * Copyright 2021 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ -import { - Action, - ActionResult, - Category, - Link, - LinkIcon, - RunStatus, - TagColor, -} from '../../api/checks'; -import {CheckRun, ChecksModel, ChecksPatchset} from './checks-model'; - -// TODO(brohlfs): Eventually these fakes should be removed. But they have proven -// to be super convenient for testing, debugging and demoing, so I would like to -// keep them around for a few quarters. Maybe remove by EOY 2022? - -export const fakeRun0: CheckRun = { - pluginName: 'f0', - internalRunId: 'f0', - patchset: 1, - checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder', - labelName: 'Presubmit', - isSingleAttempt: true, - isLatestAttempt: true, - attemptDetails: [], - worstCategory: Category.ERROR, - results: [ - { - internalResultId: 'f0r0', - category: Category.ERROR, - summary: 'I would like to point out this error: 1 is not equal to 2!', - links: [ - {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL}, - ], - tags: [{name: 'OBSOLETE'}, {name: 'E2E'}], - }, - { - internalResultId: 'f0r1', - category: Category.ERROR, - summary: 'Running the mighty test has failed by crashing.', - message: 'Btw, 1 is also not equal to 3. Did you know?', - actions: [ - { - name: 'Ignore', - tooltip: 'Ignore this result', - primary: true, - callback: () => Promise.resolve({message: 'fake "ignore" triggered'}), - }, - { - name: 'Flag', - tooltip: 'Flag this result as totally absolutely really not useful', - primary: true, - disabled: true, - callback: () => Promise.resolve({message: 'flag "flag" triggered'}), - }, - { - name: 'Upload', - tooltip: 'Upload the result to the super cloud.', - primary: false, - callback: () => Promise.resolve({message: 'fake "upload" triggered'}), - }, - { - name: 'useful', - callback: () => - Promise.resolve({message: 'fake "useful report" triggered'}), - }, - { - name: 'not-useful', - callback: () => - Promise.resolve({message: 'fake "not useful report" triggered'}), - }, - ], - tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}], - links: [ - {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL}, - {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD}, - { - primary: true, - url: 'https://google.com', - icon: LinkIcon.DOWNLOAD_MOBILE, - }, - {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE}, - {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE}, - {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE}, - {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG}, - {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE}, - {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY}, - ], - }, - ], - status: RunStatus.COMPLETED, -}; - -export const fakeRun1: CheckRun = { - pluginName: 'f1', - internalRunId: 'f1', - checkName: 'FAKE Super Check', - startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000), - finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000), - patchset: 1, - labelName: 'Verified', - isSingleAttempt: true, - isLatestAttempt: true, - attemptDetails: [], - worstCategory: Category.ERROR, - results: [ - { - internalResultId: 'f1r0', - category: Category.WARNING, - summary: 'We think that you could improve this.', - message: `There is a lot to be said. A lot. I say, a lot. - So please keep reading.`, - tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}], - codePointers: [ - { - path: '/COMMIT_MSG', - range: { - start_line: 7, - start_character: 5, - end_line: 9, - end_character: 20, - }, - }, - ], - links: [ - {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL}, - {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD}, - { - primary: true, - url: 'https://google.com', - icon: LinkIcon.DOWNLOAD_MOBILE, - }, - {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE}, - { - primary: false, - url: 'https://google.com', - tooltip: 'look at this', - icon: LinkIcon.IMAGE, - }, - { - primary: false, - url: 'https://google.com', - tooltip: 'not at this', - icon: LinkIcon.IMAGE, - }, - ], - }, - { - internalResultId: 'f1r1', - category: Category.INFO, - summary: 'Suspicious Author', - message: 'Do you personally know this person?', - codePointers: [ - { - path: '/COMMIT_MSG', - range: { - start_line: 2, - start_character: 0, - end_line: 2, - end_character: 0, - }, - }, - ], - links: [], - }, - { - internalResultId: 'f1r2', - category: Category.ERROR, - summary: 'Test Size Checker', - message: 'The test seems to be of large size, not medium.', - codePointers: [ - { - path: 'plugins/BUILD', - range: { - start_line: 186, - start_character: 12, - end_line: 186, - end_character: 18, - }, - }, - ], - actions: [ - { - name: 'useful', - tooltip: 'This check result was helpful', - callback: () => - new Promise(resolve => { - setTimeout( - () => resolve({message: 'Feedback recorded.'} as ActionResult), - 1000 - ); - }), - }, - { - name: 'not-useful', - tooltip: 'This check result was not helpful', - callback: () => - new Promise(resolve => { - setTimeout( - () => resolve({message: 'Feedback recorded.'} as ActionResult), - 1000 - ); - }), - }, - ], - fixes: [ - { - description: 'This is the way to do it.', - replacements: [ - { - path: 'plugins/BUILD', - range: { - start_line: 186, - start_character: 12, - end_line: 186, - end_character: 18, - }, - replacement: 'large', - }, - ], - }, - ], - links: [], - }, - ], - status: RunStatus.RUNNING, -}; - -export const fakeRun2: CheckRun = { - pluginName: 'f2', - internalRunId: 'f2', - patchset: 1, - checkName: 'FAKE Mega Analysis', - statusDescription: 'This run is nearly completed, but not quite.', - statusLink: 'https://www.google.com/', - checkDescription: - 'From what the title says you can tell that this check analyses.', - checkLink: 'https://www.google.com/', - scheduledTimestamp: new Date('2021-04-01T03:14:15'), - startedTimestamp: new Date('2021-04-01T04:24:25'), - finishedTimestamp: new Date('2021-04-01T04:44:44'), - isSingleAttempt: true, - isLatestAttempt: true, - attemptDetails: [], - actions: [ - { - name: 'Re-Run', - tooltip: 'More powerful run than before', - primary: true, - callback: () => Promise.resolve({message: 'fake "re-run" triggered'}), - }, - { - name: 'Monetize', - primary: true, - disabled: true, - callback: () => Promise.resolve({message: 'fake "monetize" triggered'}), - }, - { - name: 'Delete', - primary: true, - callback: () => Promise.resolve({message: 'fake "delete" triggered'}), - }, - ], - worstCategory: Category.INFO, - results: [ - { - internalResultId: 'f2r0', - category: Category.INFO, - summary: 'This is looking a bit too large.', - message: `We are still looking into how large exactly. Stay tuned. -And have a look at https://www.google.com! - -Or have a look at change 30000. -Example code: - const constable = ''; - var variable = '';`, - tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}], - }, - ], - status: RunStatus.COMPLETED, -}; - -export const fakeRun3: CheckRun = { - pluginName: 'f3', - internalRunId: 'f3', - checkName: 'FAKE Critical Observations', - status: RunStatus.RUNNABLE, - isSingleAttempt: true, - isLatestAttempt: true, - attemptDetails: [], -}; - -export const fakeRun4_1: CheckRun = { - pluginName: 'f4', - internalRunId: 'f4', - checkName: 'FAKE Elimination Long Long Long Long Long', - status: RunStatus.RUNNABLE, - attempt: 1, - isSingleAttempt: false, - isLatestAttempt: false, - attemptDetails: [], -}; - -export const fakeRun4_2: CheckRun = { - pluginName: 'f4', - internalRunId: 'f4', - checkName: 'FAKE Elimination Long Long Long Long Long', - status: RunStatus.COMPLETED, - attempt: 2, - isSingleAttempt: false, - isLatestAttempt: false, - attemptDetails: [], - worstCategory: Category.INFO, - results: [ - { - internalResultId: 'f42r0', - category: Category.INFO, - summary: 'Please eliminate all the TODOs!', - }, - ], -}; - -export const fakeRun4_3: CheckRun = { - pluginName: 'f4', - internalRunId: 'f4', - checkName: 'FAKE Elimination Long Long Long Long Long', - status: RunStatus.COMPLETED, - attempt: 3, - isSingleAttempt: false, - isLatestAttempt: false, - attemptDetails: [], - worstCategory: Category.ERROR, - results: [ - { - internalResultId: 'f43r0', - category: Category.ERROR, - summary: 'Without eliminating all the TODOs your change will break!', - }, - ], -}; - -export const fakeRun4_4: CheckRun = { - pluginName: 'f4', - internalRunId: 'f4', - patchset: 1, - checkName: 'FAKE Elimination Long Long Long Long Long', - checkDescription: 'Shows you the possible eliminations.', - checkLink: 'https://www.google.com', - status: RunStatus.COMPLETED, - statusDescription: 'Everything was eliminated already.', - statusLink: 'https://www.google.com', - attempt: 40, - scheduledTimestamp: new Date('2021-04-02T03:14:15'), - startedTimestamp: new Date('2021-04-02T04:24:25'), - finishedTimestamp: new Date('2021-04-02T04:25:44'), - isSingleAttempt: false, - isLatestAttempt: true, - attemptDetails: [], - worstCategory: Category.INFO, - results: [ - { - internalResultId: 'f44r0', - category: Category.INFO, - summary: 'Dont be afraid. All TODOs will be eliminated.', - fixes: [ - { - description: 'This is the way to do it.', - replacements: [ - { - path: 'BUILD', - range: { - start_line: 1, - start_character: 0, - end_line: 1, - end_character: 0, - }, - replacement: '# This is now fixed.\n', - }, - ], - }, - ], - actions: [ - { - name: 'Re-Run', - tooltip: 'More powerful run than before with a long tooltip, really.', - primary: true, - callback: () => Promise.resolve({message: 'fake "re-run" triggered'}), - }, - ], - }, - ], - actions: [ - { - name: 'Re-Run', - tooltip: 'small', - primary: true, - callback: () => Promise.resolve({message: 'fake "re-run" triggered'}), - }, - ], -}; - -export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] { - const runs: CheckRun[] = []; - for (let i = from; i < to; i++) { - runs.push(fakeRun4CreateAttempt(i)); - } - return runs; -} - -export function fakeRun4CreateAttempt(attempt: number): CheckRun { - return { - pluginName: 'f4', - internalRunId: 'f4', - checkName: 'FAKE Elimination Long Long Long Long Long', - status: RunStatus.COMPLETED, - attempt, - isSingleAttempt: false, - isLatestAttempt: false, - attemptDetails: [], - worstCategory: Category.ERROR, - results: - attempt % 2 === 0 - ? [ - { - internalResultId: 'f43r0', - category: Category.ERROR, - summary: - 'Without eliminating all the TODOs your change will break!', - }, - ] - : [], - }; -} - -export const fakeRun4Att = [ - fakeRun4_1, - fakeRun4_2, - fakeRun4_3, - ...fakeRun4CreateAttempts(5, 40), - fakeRun4_4, -]; - -export const fakeActions: Action[] = [ - { - name: 'Fake Action 1', - primary: true, - disabled: true, - tooltip: 'Tooltip for Fake Action 1', - callback: () => Promise.resolve({message: 'fake action 1 triggered'}), - }, - { - name: 'Fake Action 2', - primary: false, - disabled: true, - tooltip: 'Tooltip for Fake Action 2', - callback: () => Promise.resolve({message: 'fake action 2 triggered'}), - }, - { - name: 'Fake Action 3', - summary: true, - primary: false, - tooltip: 'Tooltip for Fake Action 3', - callback: () => Promise.resolve({message: 'fake action 3 triggered'}), - }, -]; - -export const fakeLinks: Link[] = [ - { - url: 'https://www.google.com', - primary: true, - tooltip: 'Fake Bug Report 1', - icon: LinkIcon.REPORT_BUG, - }, - { - url: 'https://www.google.com', - primary: true, - tooltip: 'Fake Bug Report 2', - icon: LinkIcon.REPORT_BUG, - }, - { - url: 'https://www.google.com', - primary: true, - tooltip: 'Fake Link 1', - icon: LinkIcon.EXTERNAL, - }, - { - url: 'https://www.google.com', - primary: false, - tooltip: 'Fake Link 2', - icon: LinkIcon.EXTERNAL, - }, - { - url: 'https://www.google.com', - primary: true, - tooltip: 'Fake Code Link', - icon: LinkIcon.CODE, - }, - { - url: 'https://www.google.com', - primary: true, - tooltip: 'Fake Image Link', - icon: LinkIcon.IMAGE, - }, - { - url: 'https://www.google.com', - primary: true, - tooltip: 'Fake Help Link', - icon: LinkIcon.HELP_PAGE, - }, -]; - -export const fakeRun5: CheckRun = { - pluginName: 'f5', - internalRunId: 'f5', - checkName: 'FAKE Of Tomorrow', - status: RunStatus.SCHEDULED, - isSingleAttempt: true, - isLatestAttempt: true, - attemptDetails: [], -}; - -export function clearAllFakeRuns(model: ChecksModel) { - model.updateStateSetProvider('f0', ChecksPatchset.LATEST); - model.updateStateSetProvider('f1', ChecksPatchset.LATEST); - model.updateStateSetProvider('f2', ChecksPatchset.LATEST); - model.updateStateSetProvider('f3', ChecksPatchset.LATEST); - model.updateStateSetProvider('f4', ChecksPatchset.LATEST); - model.updateStateSetProvider('f5', ChecksPatchset.LATEST); - model.updateStateSetResults( - 'f0', - [], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f1', - [], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f2', - [], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f3', - [], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f4', - [], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f5', - [], - [], - [], - undefined, - ChecksPatchset.LATEST - ); -} - -export function setAllFakeRuns(model: ChecksModel) { - model.updateStateSetProvider('f0', ChecksPatchset.LATEST); - model.updateStateSetProvider('f1', ChecksPatchset.LATEST); - model.updateStateSetProvider('f2', ChecksPatchset.LATEST); - model.updateStateSetProvider('f3', ChecksPatchset.LATEST); - model.updateStateSetProvider('f4', ChecksPatchset.LATEST); - model.updateStateSetProvider('f5', ChecksPatchset.LATEST); - model.updateStateSetResults( - 'f0', - [fakeRun0], - fakeActions, - fakeLinks, - 'ETA: 1 min', - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f1', - [fakeRun1], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f2', - [fakeRun2], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f3', - [fakeRun3], - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f4', - fakeRun4Att, - [], - [], - undefined, - ChecksPatchset.LATEST - ); - model.updateStateSetResults( - 'f5', - [fakeRun5], - [], - [], - undefined, - ChecksPatchset.LATEST - ); -}
diff --git a/polygerrit-ui/app/models/checks/checks-model.ts b/polygerrit-ui/app/models/checks/checks-model.ts index c6bdba2..c986efe 100644 --- a/polygerrit-ui/app/models/checks/checks-model.ts +++ b/polygerrit-ui/app/models/checks/checks-model.ts
@@ -126,6 +126,7 @@ Pick<CheckRun, 'patchset'> & Pick<CheckRun, 'isLatestAttempt'> & Pick<CheckRun, 'checkName'> & + Pick<CheckRun, 'isAiPowered'> & Pick<CheckRun, 'labelName'> & Pick<CheckRun, 'status'> & Pick<CheckRun, 'statusLink'> & @@ -146,6 +147,7 @@ patchset: run.patchset, isLatestAttempt: run.isLatestAttempt, checkName: run.checkName, + isAiPowered: run.isAiPowered, labelName: run.labelName, status: run.status, statusLink: run.statusLink, @@ -833,9 +835,6 @@ register(checksPlugin: ChecksPlugin) { const {pluginName, provider, config} = checksPlugin; if (this.providers[pluginName]) { - console.warn( - `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.` - ); return; } this.providers[pluginName] = provider;
diff --git a/polygerrit-ui/app/models/checks/checks-model_test.ts b/polygerrit-ui/app/models/checks/checks-model_test.ts index c43f7a15..94b3d0f 100644 --- a/polygerrit-ui/app/models/checks/checks-model_test.ts +++ b/polygerrit-ui/app/models/checks/checks-model_test.ts
@@ -99,7 +99,7 @@ }); test('register and fetch', async () => { - const clock = sinon.useFakeTimers(); + const clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); let change: ParsedChangeInfo | undefined = undefined; testResolver(changeModelToken).change$.subscribe(c => (change = c)); const provider = createProvider(); @@ -131,7 +131,7 @@ }); test('fetch throttle', async () => { - const clock = sinon.useFakeTimers(); + const clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); let change: ParsedChangeInfo | undefined = undefined; testResolver(changeModelToken).change$.subscribe(c => (change = c)); const provider = createProvider(); @@ -337,7 +337,7 @@ }); test('polls for changes', async () => { - const clock = sinon.useFakeTimers(); + const clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); let change: ParsedChangeInfo | undefined = undefined; testResolver(changeModelToken).change$.subscribe(c => (change = c)); const provider = createProvider(); @@ -364,7 +364,7 @@ }); test('does not poll when config specifies 0 seconds', async () => { - const clock = sinon.useFakeTimers(); + const clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); let change: ParsedChangeInfo | undefined = undefined; testResolver(changeModelToken).change$.subscribe(c => (change = c)); const provider = createProvider();
diff --git a/polygerrit-ui/app/models/checks/checks-util.ts b/polygerrit-ui/app/models/checks/checks-util.ts index 51e9dfd..f997b11 100644 --- a/polygerrit-ui/app/models/checks/checks-util.ts +++ b/polygerrit-ui/app/models/checks/checks-util.ts
@@ -288,7 +288,7 @@ } } -export enum PRIMARY_STATUS_ACTIONS { +export enum TRIGGER_STATUS_ACTIONS { RERUN = 'rerun', RUN = 'run', } @@ -296,7 +296,7 @@ export function toCanonicalAction(action: Action, status: RunStatus) { let name = action.name.toLowerCase(); if (status === RunStatus.COMPLETED && (name === 'run' || name === 're-run')) { - name = PRIMARY_STATUS_ACTIONS.RERUN; + name = TRIGGER_STATUS_ACTIONS.RERUN; } return {...action, name}; } @@ -316,12 +316,12 @@ } } -function primaryActionName(status: RunStatus) { +function triggerActionName(status: RunStatus) { switch (status) { case RunStatus.COMPLETED: - return PRIMARY_STATUS_ACTIONS.RERUN; + return TRIGGER_STATUS_ACTIONS.RERUN; case RunStatus.RUNNABLE: - return PRIMARY_STATUS_ACTIONS.RUN; + return TRIGGER_STATUS_ACTIONS.RUN; case RunStatus.RUNNING: case RunStatus.SCHEDULED: return undefined; @@ -330,10 +330,27 @@ } } -export function primaryRunAction(run?: CheckRun): Action | undefined { +export function triggerAction(run?: CheckRun): Action | undefined { if (!run) return undefined; return runActions(run).filter( - action => !action.disabled && action.name === primaryActionName(run.status) + action => !action.disabled && action.name === triggerActionName(run.status) + )[0]; +} + +export function primaryAction(run?: CheckRun): Action | undefined { + if (!run) return undefined; + return runActions(run).filter( + action => !action.disabled && action.primary + )[0]; +} + +export function primaryTriggerAction(run?: CheckRun): Action | undefined { + if (!run) return undefined; + return runActions(run).filter( + action => + !action.disabled && + action.primary && + action.name === triggerActionName(run.status) )[0]; }
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts index aaa4662..f180a8e 100644 --- a/polygerrit-ui/app/models/comments/comments-model.ts +++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -888,4 +888,9 @@ .flat() .find(draft => id(draft) === commentId); } + + override finalize() { + this.draftToastTask?.cancel(); + super.finalize(); + } }
diff --git a/polygerrit-ui/app/models/flows/flows-model.ts b/polygerrit-ui/app/models/flows/flows-model.ts index 460cec6..300a1b7 100644 --- a/polygerrit-ui/app/models/flows/flows-model.ts +++ b/polygerrit-ui/app/models/flows/flows-model.ts
@@ -3,44 +3,112 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {BehaviorSubject, combineLatest, from, of} from 'rxjs'; -import {catchError, map, switchMap} from 'rxjs/operators'; +import {BehaviorSubject, combineLatest, from, Observable, of} from 'rxjs'; +import {catchError, map, shareReplay, switchMap} from 'rxjs/operators'; import {ChangeModel} from '../change/change-model'; +import {fireServerError} from '../../utils/event-util'; import {FlowInfo, FlowInput} from '../../api/rest-api'; import {Model} from '../base/model'; import {define} from '../dependency'; +import {PluginsModel} from '../plugins/plugins-model'; import {NumericChangeId} from '../../types/common'; import {getAppContext} from '../../services/app-context'; -import {KnownExperimentId} from '../../services/flags/flags'; +import {FlowsAutosubmitProvider, FlowsProvider} from '../../api/flows'; +import {select} from '../../utils/observable-util'; +import {isDefined} from '../../types/types'; export interface FlowsState { + isEnabled: boolean; flows: FlowInfo[]; loading: boolean; errorMessage?: string; + providers: FlowsProvider[]; + autosubmitProviders: FlowsAutosubmitProvider[]; } export const flowsModelToken = define<FlowsModel>('flows-model'); +export const SUBMIT_ACTION_NAME = 'submit'; + +export function getSubmitCondition() { + return getChangePrefix() + ' is is:submittable'; +} + +export function getChangePrefix() { + return window.location.origin + window.location.pathname; +} + export class FlowsModel extends Model<FlowsState> { readonly flows$ = this.state$.pipe(map(s => s.flows)); readonly loading$ = this.state$.pipe(map(s => s.loading)); + readonly providers$: Observable<FlowsProvider[]> = select( + this.state$, + state => state.providers + ); + + readonly autosubmitProviders$: Observable<FlowsAutosubmitProvider[]> = select( + this.state$, + state => state.autosubmitProviders + ); + + readonly isAutosubmitEnabled$: Observable<boolean> = select( + this.autosubmitProviders$, + autosubmitProviders => + autosubmitProviders.some( + autosubmitProvider => !!autosubmitProvider.isAutosubmitEnabled() + ) + ); + + readonly enabled$: Observable<boolean>; + private readonly reload$ = new BehaviorSubject<void>(undefined); private changeNum?: NumericChangeId; private readonly restApiService = getAppContext().restApiService; - private flagService = getAppContext().flagsService; - - constructor(private readonly changeModel: ChangeModel) { + constructor( + private readonly changeModel: ChangeModel, + private readonly pluginsModel: PluginsModel + ) { super({ + isEnabled: false, flows: [], loading: true, + providers: [], + autosubmitProviders: [], }); + this.enabled$ = this.changeModel.changeNum$.pipe( + switchMap(changeNum => { + if (!changeNum) { + return of(false); + } + const errFn = (response?: Response | null) => { + // When 404 is returned, it means that flows are not enabled. + if (response?.status === 404) return; + if (!response) return; + fireServerError(response); + }; + return from( + this.restApiService.getIfFlowsIsEnabled(changeNum, errFn) + ).pipe( + map(res => res?.enabled ?? false), + catchError(() => of(false)) + ); + }), + shareReplay(1) + ); + + this.subscriptions.push( + this.enabled$.subscribe(isEnabled => { + this.setState({...this.getState(), isEnabled}); + }) + ); + this.subscriptions.push( this.changeModel.changeNum$.subscribe(changeNum => { this.changeNum = changeNum; @@ -48,14 +116,10 @@ ); this.subscriptions.push( - combineLatest([this.changeModel.changeNum$, this.reload$]) + combineLatest([this.changeModel.changeNum$, this.reload$, this.enabled$]) .pipe( - switchMap(([changeNum]) => { - if ( - !changeNum || - !this.flagService.isEnabled(KnownExperimentId.SHOW_FLOWS_TAB) - ) - return of([]); + switchMap(([changeNum, _, enabled]) => { + if (!changeNum || !enabled) return of([]); this.setState({...this.getState(), loading: true}); return from(this.restApiService.listFlows(changeNum)).pipe( catchError(err => { @@ -77,6 +141,20 @@ }); }) ); + + this.pluginsModel.flowsAutosubmitPlugin$.subscribe(plugins => { + const providers = plugins.map(p => p.provider).filter(isDefined); + this.updateState({ + autosubmitProviders: providers, + }); + }); + + this.pluginsModel.flowsPlugins$.subscribe(plugins => { + const providers = plugins.map(p => p.provider).filter(isDefined); + this.updateState({ + providers, + }); + }); } reload() { @@ -85,12 +163,54 @@ async deleteFlow(flowId: string) { if (!this.changeNum) return; + if (!this.getState().isEnabled) return; await this.restApiService.deleteFlow(this.changeNum, flowId); this.reload(); } + hasAutosubmitFlowAlready() { + return this.getState().flows.some(flow => + flow.stages.some( + stage => + stage.expression.condition === getSubmitCondition() && + stage.expression.action?.name === SUBMIT_ACTION_NAME + ) + ); + } + + async createAutosubmitFlow() { + if (!this.changeNum) return; + if (!this.getState().isEnabled) return; + + // See if some plugin wants to modify the default submit behaviour + const autosubmitProvider = this.getState().autosubmitProviders.find( + provider => provider.getSubmitCondition && !!provider.getSubmitCondition() + ); + + const defaultAction = { + name: SUBMIT_ACTION_NAME, + }; + + let condition = getSubmitCondition(); + if (autosubmitProvider?.getSubmitCondition()) { + condition = `${getChangePrefix()} is ${autosubmitProvider.getSubmitCondition()}`; + } + + await this.restApiService.createFlow(this.changeNum, { + stage_expressions: [ + { + condition, + action: autosubmitProvider?.getSubmitAction() ?? defaultAction, + }, + ], + }); + + this.reload(); + } + async createFlow(flowInput: FlowInput) { if (!this.changeNum) return; + if (!this.getState().isEnabled) return; await this.restApiService.createFlow(this.changeNum, flowInput); this.reload(); }
diff --git a/polygerrit-ui/app/models/flows/flows-model_test.ts b/polygerrit-ui/app/models/flows/flows-model_test.ts new file mode 100644 index 0000000..2dfe5a7 --- /dev/null +++ b/polygerrit-ui/app/models/flows/flows-model_test.ts
@@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {testResolver} from '../../test/common-test-setup'; +import { + FlowsModel, + getChangePrefix, + getSubmitCondition, + SUBMIT_ACTION_NAME, +} from './flows-model'; +import {ChangeModel, changeModelToken} from '../change/change-model'; +import {PluginsModel} from '../plugins/plugins-model'; +import {stubRestApi, waitUntil} from '../../test/test-utils'; +import {NumericChangeId} from '../../types/common'; +import {assert} from '@open-wc/testing'; +import {FlowsAutosubmitProvider} from '../../api/flows'; +import {createFlow, createParsedChange} from '../../test/test-data-generators'; +import {FlowInfo, FlowStageState} from '../../api/rest-api'; + +suite('flows-model tests', () => { + let flowsModel: FlowsModel; + let changeModel: ChangeModel; + let pluginsModel: PluginsModel; + + setup(() => { + changeModel = testResolver(changeModelToken); + pluginsModel = new PluginsModel(); + + flowsModel = new FlowsModel(changeModel, pluginsModel); + }); + + test('flows$ loads flows when enabled', async () => { + stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true}); + const listFlowsStub = stubRestApi('listFlows').resolves([ + createFlow({uuid: 'flow1', stages: []}), + ]); + + let flows: FlowInfo[] = []; + flowsModel.flows$.subscribe(f => (flows = f)); + + changeModel.updateStateChange({ + ...createParsedChange(), + _number: 123 as NumericChangeId, + }); + await waitUntil(() => flows.length > 0); + + assert.equal(flows.length, 1); + assert.equal(flows[0].uuid, 'flow1'); + assert.isTrue(listFlowsStub.calledWith(123 as NumericChangeId)); + }); + + test('deleteFlow calls API and reloads', async () => { + stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true}); + stubRestApi('listFlows').resolves([]); + const deleteFlowStub = stubRestApi('deleteFlow').resolves(new Response()); + const reloadSpy = sinon.spy(flowsModel, 'reload'); + + changeModel.updateStateChange({ + ...createParsedChange(), + _number: 123 as NumericChangeId, + }); + await waitUntil(() => flowsModel.getState().isEnabled); + + await flowsModel.deleteFlow('flow1'); + + assert.isTrue(deleteFlowStub.calledWith(123 as NumericChangeId, 'flow1')); + assert.isTrue(reloadSpy.called); + }); + + test('createFlow calls API and reloads', async () => { + stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true}); + stubRestApi('listFlows').resolves([]); + const createFlowStub = stubRestApi('createFlow').resolves( + createFlow({uuid: 'new-flow', stages: []}) + ); + const reloadSpy = sinon.spy(flowsModel, 'reload'); + + changeModel.updateStateChange({ + ...createParsedChange(), + _number: 123 as NumericChangeId, + }); + await waitUntil(() => flowsModel.getState().isEnabled); + + const flowInput = {stage_expressions: []}; + await flowsModel.createFlow(flowInput); + + assert.isTrue(createFlowStub.calledWith(123 as NumericChangeId, flowInput)); + assert.isTrue(reloadSpy.called); + }); + + test('createAutosubmitFlow uses default values', async () => { + stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true}); + stubRestApi('listFlows').resolves([]); + const createFlowStub = stubRestApi('createFlow').resolves( + createFlow({uuid: 'auto-flow', stages: []}) + ); + + changeModel.updateStateChange({ + ...createParsedChange(), + _number: 123 as NumericChangeId, + }); + await waitUntil(() => flowsModel.getState().isEnabled); + + await flowsModel.createAutosubmitFlow(); + + assert.isTrue(createFlowStub.called); + const args = createFlowStub.firstCall.args; + assert.equal(args[0], 123 as NumericChangeId); + assert.deepEqual(args[1], { + stage_expressions: [ + { + condition: getSubmitCondition(), + action: {name: SUBMIT_ACTION_NAME}, + }, + ], + }); + }); + + test('createAutosubmitFlow uses provider values', async () => { + stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true}); + stubRestApi('listFlows').resolves([]); + const createFlowStub = stubRestApi('createFlow').resolves( + createFlow({uuid: 'auto-flow', stages: []}) + ); + + const provider: FlowsAutosubmitProvider = { + isAutosubmitEnabled: () => true, + getSubmitCondition: () => 'custom condition', + getSubmitAction: () => { + return {name: 'custom action'}; + }, + }; + pluginsModel.registerFlowsAutosubmitProvider({ + pluginName: 'test', + provider, + }); + + changeModel.updateStateChange({ + ...createParsedChange(), + _number: 123 as NumericChangeId, + }); + await waitUntil(() => flowsModel.getState().isEnabled); + + await flowsModel.createAutosubmitFlow(); + + assert.isTrue(createFlowStub.called); + const args = createFlowStub.firstCall.args; + assert.deepEqual(args[1], { + stage_expressions: [ + { + condition: getChangePrefix() + ' is custom condition', + action: {name: 'custom action'}, + }, + ], + }); + }); + + test('hasAutosubmitFlowAlready checks flows', async () => { + stubRestApi('getIfFlowsIsEnabled').resolves({enabled: true}); + const listFlowsStub = stubRestApi('listFlows').resolves([ + createFlow({ + uuid: 'flow1', + stages: [ + { + expression: { + condition: getSubmitCondition(), + action: {name: SUBMIT_ACTION_NAME}, + }, + state: FlowStageState.DONE, + }, + ], + }), + ]); + + changeModel.updateStateChange({ + ...createParsedChange(), + _number: 123 as NumericChangeId, + }); + await waitUntil(() => flowsModel.getState().flows.length > 0); + + assert.isTrue(flowsModel.hasAutosubmitFlowAlready()); + + listFlowsStub.resolves([ + createFlow({ + uuid: 'flow2', + stages: [ + { + expression: { + condition: 'other condition', + action: {name: 'other action'}, + }, + state: FlowStageState.DONE, + }, + ], + }), + ]); + flowsModel.reload(); + await waitUntil(() => !flowsModel.hasAutosubmitFlowAlready()); + assert.isFalse(flowsModel.hasAutosubmitFlowAlready()); + }); +});
diff --git a/polygerrit-ui/app/models/plugins/plugins-model.ts b/polygerrit-ui/app/models/plugins/plugins-model.ts index 9df61bc..8ed7cfe 100644 --- a/polygerrit-ui/app/models/plugins/plugins-model.ts +++ b/polygerrit-ui/app/models/plugins/plugins-model.ts
@@ -15,6 +15,8 @@ import {CoverageProvider, TokenHoverListener} from '../../api/annotation'; import {SuggestionsProvider} from '../../api/suggestions'; import {ChangeUpdatesPublisher} from '../../api/change-updates'; +import {AiCodeReviewProvider} from '../../api/ai-code-review'; +import {FlowsAutosubmitProvider, FlowsProvider} from '../../api/flows'; export interface CoveragePlugin { pluginName: string; @@ -32,6 +34,21 @@ publisher: ChangeUpdatesPublisher; } +export interface AiCodeReviewPlugin { + pluginName: string; + provider: AiCodeReviewProvider; +} + +export interface FlowsPlugin { + pluginName: string; + provider: FlowsProvider; +} + +export interface FlowsAutosubmitPlugin { + pluginName: string; + provider: FlowsAutosubmitProvider; +} + export interface SuggestionPlugin { pluginName: string; provider: SuggestionsProvider; @@ -70,6 +87,21 @@ checksPlugins: ChecksPlugin[]; /** + * List of plugins that have called aiCodeReview().register(). + */ + aiCodeReviewPlugins: AiCodeReviewPlugin[]; + + /** + * List of plugins that have called flows().register(). + */ + flowsPlugins: FlowsPlugin[]; + + /** + * List of plugins that have called flows().registerAutosubmitProvider(). + */ + flowsAutosubmitPlugins: FlowsAutosubmitPlugin[]; + + /** * List of plugins that have called suggestions().register(). */ suggestionsPlugins: SuggestionPlugin[]; @@ -105,6 +137,18 @@ state => state.changeUpdatesPlugins ); + public aiCodeReviewPlugins$ = select( + this.state$, + state => state.aiCodeReviewPlugins + ); + + public flowsPlugins$ = select(this.state$, state => state.flowsPlugins); + + readonly flowsAutosubmitPlugin$ = select( + this.state$, + state => state.flowsAutosubmitPlugins + ); + public suggestionsPlugins$ = select( this.state$, state => state.suggestionsPlugins @@ -118,6 +162,9 @@ coveragePlugins: [], changeUpdatesPlugins: [], checksPlugins: [], + aiCodeReviewPlugins: [], + flowsPlugins: [], + flowsAutosubmitPlugins: [], suggestionsPlugins: [], tokenHighlightPlugins: [], }); @@ -175,6 +222,54 @@ this.setState(nextState); } + aiCodeReviewRegister(plugin: AiCodeReviewPlugin) { + const nextState = {...this.getState()}; + nextState.aiCodeReviewPlugins = [...nextState.aiCodeReviewPlugins]; + const alreadyRegistered = nextState.aiCodeReviewPlugins.some( + p => p.pluginName === plugin.pluginName + ); + if (alreadyRegistered) { + console.warn( + `${plugin.pluginName} tried to register twice as a AI Code Review provider. Ignored.` + ); + return; + } + nextState.aiCodeReviewPlugins.push(plugin); + this.setState(nextState); + } + + registerFlowsProvider(plugin: FlowsPlugin) { + const nextState = {...this.getState()}; + nextState.flowsPlugins = [...nextState.flowsPlugins]; + const alreadyRegistered = nextState.flowsPlugins.some( + p => p.pluginName === plugin.pluginName + ); + if (alreadyRegistered) { + console.warn( + `${plugin.pluginName} tried to register twice as a flows provider. Ignored.` + ); + return; + } + nextState.flowsPlugins.push(plugin); + this.setState(nextState); + } + + registerFlowsAutosubmitProvider(plugin: FlowsAutosubmitPlugin) { + const nextState = {...this.getState()}; + nextState.flowsAutosubmitPlugins = [...nextState.flowsAutosubmitPlugins]; + const alreadyRegistered = nextState.flowsAutosubmitPlugins.some( + p => p.pluginName === plugin.pluginName + ); + if (alreadyRegistered) { + console.warn( + `${plugin.pluginName} tried to register twice as a flows provider. Ignored.` + ); + return; + } + nextState.flowsAutosubmitPlugins.push(plugin); + this.setState(nextState); + } + suggestionsRegister(plugin: SuggestionPlugin) { const nextState = {...this.getState()}; nextState.suggestionsPlugins = [...nextState.suggestionsPlugins];
diff --git a/polygerrit-ui/app/models/views/admin_test.ts b/polygerrit-ui/app/models/views/admin_test.ts index fff77e9..511b3f9 100644 --- a/polygerrit-ui/app/models/views/admin_test.ts +++ b/polygerrit-ui/app/models/views/admin_test.ts
@@ -23,6 +23,21 @@ Timestamp, } from '../../api/rest-api'; +interface AdminLinksExpected { + totalLength?: number; + groupListShown?: boolean; + pluginListShown?: boolean; + serverInfoShown?: boolean; + projectPageShown?: boolean; + groupPageShown?: boolean; + groupSubpageLength?: number; + pluginGeneratedLinks?: Array<{ + url: string; + text: string; + capability?: string; + }>; +} + suite('admin links', () => { let capabilityStub: sinon.SinonStub; let menuLinkStub: sinon.SinonStub; @@ -35,7 +50,7 @@ const testAdminLinks = async ( account: AccountDetailInfo | undefined, options: AdminNavLinksOption | undefined, - expected: any + expected: AdminLinksExpected ) => { const res = await getAdminLinks( account, @@ -74,7 +89,7 @@ res.links[1].subsection.children!.length, expected.groupSubpageLength ); - } else if (expected.totalLength > 1) { + } else if (expected.totalLength !== undefined && expected.totalLength > 1) { assert.isNotOk(res.links[1].subsection); } @@ -114,7 +129,7 @@ }; suite('logged out', () => { - let expected: any; + let expected: AdminLinksExpected; setup(() => { expected = { @@ -164,7 +179,7 @@ name: 'test-user', registered_on: '' as Timestamp, }; - let expected: any; + let expected: AdminLinksExpected; setup(() => { expected = { @@ -204,7 +219,7 @@ name: 'test-user', registered_on: '' as Timestamp, }; - let expected: any; + let expected: AdminLinksExpected; setup(() => { capabilityStub.returns(Promise.resolve({viewPlugins: true})); @@ -304,7 +319,7 @@ name: 'test-user', registered_on: '' as Timestamp, }; - let expected: any; + let expected: AdminLinksExpected; setup(() => { capabilityStub.returns(Promise.resolve({pluginCapability: true})); @@ -331,7 +346,7 @@ name: 'test-user', registered_on: '' as Timestamp, }; - let expected: any; + let expected: AdminLinksExpected; setup(() => { capabilityStub.returns(Promise.resolve({}));
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts index 55d3477..3189bcb 100644 --- a/polygerrit-ui/app/node_modules_licenses/licenses.ts +++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -131,54 +131,6 @@ filesFilter: fontsRobotomonoFilter, }, { - name: '@polymer/iron-a11y-keys-behavior', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/iron-behaviors', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/iron-checked-element-behavior', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/iron-flex-layout', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/iron-form-element-behavior', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/iron-meta', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/iron-validatable-behavior', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/paper-behaviors', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/paper-button', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/paper-item', - license: SharedLicenses.Polymer2015, - }, - { - name: '@polymer/paper-ripple', - license: SharedLicenses.Polymer2014, - }, - { - name: '@polymer/paper-styles', - license: SharedLicenses.Polymer2014, - }, - { name: '@polymer/polymer', license: SharedLicenses.Polymer2017, }, @@ -360,6 +312,14 @@ }, }, { + name: 'highlightjs-ttcn3', + license: { + name: 'highlightjs-ttcn3', + type: LicenseTypes.Mit, + packageLicenseFile: 'LICENSE', + }, + }, + { name: 'highlightjs-vue', license: { name: 'highlightjs-vue',
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json index 314c99d..d456a58 100644 --- a/polygerrit-ui/app/package.json +++ b/polygerrit-ui/app/package.json
@@ -3,12 +3,9 @@ "description": "Gerrit Code Review - Polygerrit dependencies", "browser": true, "dependencies": { - "@material/web": "^2.4.0", + "@material/web": "^2.4.1", "@polymer/decorators": "^3.0.0", "@polymer/font-roboto-local": "^3.0.2", - "@polymer/paper-button": "^3.0.1", - "@polymer/paper-item": "^3.0.1", - "@polymer/paper-styles": "^3.0.1", "@polymer/polymer": "3.5.2", "@types/resemblejs": "^4.1.3", "@types/resize-observer-browser": "^0.1.11", @@ -18,16 +15,17 @@ "highlightjs-closure-templates": "https://github.com/highlightjs/highlightjs-closure-templates#02fb0646e0499084f96a99b8c6f4a0d7bd1d33ba", "highlightjs-epp": "https://github.com/highlightjs/highlightjs-epp#9f9e1a92f37c217c68899c7d3bdccb4d134681b9", "highlightjs-structured-text": "https://github.com/highlightjs/highlightjs-structured-text#e68dd7aa829529fb6c40d6287585f43273605a9e", + "highlightjs-ttcn3": "https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d", "highlightjs-vue": "https://github.com/paladox/highlightjs-vue#44eed074ea0110d1ad03d2cbd77d27027cf7bb04", "immer": "^9.0.21", "lit": "^3.3.1", - "marked": "^0.5.0", + "marked": "^17.0.1", "polymer-bridges": "file:../../polymer-bridges", "polymer-resin": "^2.0.1", - "resemblejs": "^5.0.0", + "resemblejs": "rsmbl/Resemble.js#66a55c5bfc3bda2303ad632ee8ce3c727b415917", "rxjs": "^6.6.7", "safevalues": "^1.2.0", - "web-vitals": "^3.5.2" + "web-vitals": "^5.1.0" }, "dependencies // comments": { "@polymer/polymer": [
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts index 527df12..584d83a 100644 --- a/polygerrit-ui/app/scripts/polymer-resin-install.ts +++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -48,13 +48,18 @@ export const _testOnly_defaultResinReportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER; +let resinInstalled = false; export function installPolymerResin( safeTypesBridge: SafeTypeBridge, reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER ) { + if (resinInstalled) { + return; + } window.security.polymer_resin.install({ allowedIdentifierPrefixes: [''], reportHandler, safeTypesBridge, }); + resinInstalled = true; }
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts index a0ab3a3..5237688 100644 --- a/polygerrit-ui/app/services/app-context-init.ts +++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -13,6 +13,7 @@ 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 {ChatModel, chatModelToken} from '../models/chat/chat-model'; import {GrStorageService, storageServiceToken} from './storage/gr-storage_impl'; import {UserModel, userModelToken} from '../models/user/user-model'; import { @@ -91,7 +92,7 @@ restApiService: (ctx: Partial<AppContext>) => { assertIsDefined(ctx.authService, 'authService'); assertIsDefined(ctx.flagsService, 'flagsService'); - return new GrRestApiServiceImpl(ctx.authService, ctx.flagsService); + return new GrRestApiServiceImpl(ctx.authService); }, }; return create<AppContext>(appRegistry); @@ -161,8 +162,7 @@ appContext.restApiService, resolver(userModelToken), resolver(pluginLoaderToken), - appContext.reportingService, - appContext.flagsService + appContext.reportingService ), ], [ @@ -221,6 +221,16 @@ ), ], [ + chatModelToken, + () => + new ChatModel( + resolver(pluginLoaderToken).pluginsModel, + resolver(changeModelToken), + resolver(filesModelToken), + resolver(userModelToken) + ), + ], + [ shortcutsServiceToken, () => new ShortcutsService( @@ -251,6 +261,13 @@ resolver(changeModelToken) ), ], - [flowsModelToken, () => new FlowsModel(resolver(changeModelToken))], + [ + flowsModelToken, + () => + new FlowsModel( + resolver(changeModelToken), + resolver(pluginLoaderToken).pluginsModel + ), + ], ]); }
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts index 499a0bf..0b6ac9a 100644 --- a/polygerrit-ui/app/services/flags/flags.ts +++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -16,14 +16,11 @@ */ export enum KnownExperimentId { NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui', - CHECKS_DEVELOPER = 'UiFeature__checks_developer', PUSH_NOTIFICATIONS_DEVELOPER = 'UiFeature__push_notifications_developer', ML_SUGGESTED_EDIT_V2 = 'UiFeature__ml_suggested_edit_v2', - GET_AI_PROMPT = 'UiFeature__get_ai_prompt', - PARALLEL_DASHBOARD_REQUESTS = 'UiFeature__parallel_dashboard_requests', ML_SUGGESTED_EDIT_UNCHECK_BY_DEFAULT = 'UiFeature__ml_suggested_edit_uncheck_by_default', ML_SUGGESTED_EDIT_FEEDBACK = 'UiFeature__ml_suggested_edit_feedback', ML_SUGGESTED_EDIT_EDITABLE_SUGGESTION = 'UiFeature__ml_suggested_edit_editable_suggestion', - SHOW_FLOWS_TAB = 'UiFeature__show_flows_tab', - ASYNC_SUBMIT_REQUIREMENTS = 'UiFeature__async_submit_requirements', + ENABLE_AI_CHAT = 'UiFeature__enable_ai_chat', + ML_SUGGESTED_EDIT_GET_FIX = 'UiFeature__ml_suggested_edit_get_fix', }
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 28be742..8840cd2 100644 --- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts +++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.ts
@@ -56,7 +56,7 @@ let fakeFetch: sinon.SinonStub; let clock: SinonFakeTimers; setup(() => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); fakeFetch = sinon.stub(window, 'fetch'); });
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 4ce0dd7..a120f28 100644 --- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts +++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -3,7 +3,7 @@ * Copyright 2016 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import {FlagsService, KnownExperimentId} from '../flags/flags'; +import {FlagsService} from '../flags/flags'; import {EventValue, ReportingService, Timer} from './gr-reporting'; import {hasOwnProperty} from '../../utils/common-util'; import {NumericChangeId} from '../../types/common'; @@ -15,7 +15,12 @@ LifeCycle, Timing, } from '../../constants/reporting'; -import {Metric, onCLS, onINP, onLCP} from 'web-vitals'; +import { + MetricWithAttribution, + onCLS, + onINP, + onLCP, +} from 'web-vitals/attribution'; import {getEventPath, isElementTarget} from '../../utils/dom-util'; import {Finalizable} from '../../types/types'; @@ -269,7 +274,7 @@ } export function initWebVitals(reportingService: ReportingService) { - function reportWebVitalMetric(name: Timing, metric: Metric) { + function reportWebVitalMetric(name: Timing, metric: MetricWithAttribution) { 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; @@ -285,6 +290,7 @@ navigationType: metric.navigationType, rating: metric.rating, entries: metric.entries, + attribution: metric.attribution, } ); } @@ -359,7 +365,6 @@ screenSize?: {width: number; height: number}; viewport?: {width: number; height: number}; usedJSHeapSizeMb?: number; - parallelRequestsEnabled?: boolean; } interface SlowRpcCall { @@ -687,9 +692,6 @@ const details: PageLoadDetails = { rpcList: this.slowRpcSnapshot, hiddenDurationMs: this.hiddenDurationTimer.accHiddenDurationMs, - parallelRequestsEnabled: this._flagsService.isEnabled( - KnownExperimentId.PARALLEL_DASHBOARD_REQUESTS - ), }; if (window.screen) {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts index 2b34627..6228eb2 100644 --- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts +++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.ts
@@ -12,6 +12,7 @@ test('mocks all public methods', () => { const methods = Object.getOwnPropertyNames(GrReporting.prototype) .filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any name => typeof (GrReporting as any).prototype[name] === 'function' ) .filter(name => !name.startsWith('_') && name !== 'constructor')
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts index 69decf2..7107f94 100644 --- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts +++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.ts
@@ -21,9 +21,11 @@ suite('gr-reporting tests', () => { // We have to type as any because we access // private properties for testing. + // eslint-disable-next-line @typescript-eslint/no-explicit-any let service: any; let clock: SinonFakeTimers; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let fakePerformance: any; const NOW_TIME = 100; @@ -175,7 +177,6 @@ }, usedJSHeapSizeMb: 1, hiddenDurationMs: 0, - parallelRequestsEnabled: false, }) ); }); @@ -503,6 +504,7 @@ }); suite('exception logging', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any let fakeWindow: any; let reporter: sinon.SinonStub;
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 cd9fcc1..2188e70 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
@@ -130,7 +130,6 @@ import {BaseScheduler, Scheduler} from '../scheduler/scheduler'; import {MaxInFlightScheduler} from '../scheduler/max-in-flight-scheduler'; import {escapeAndWrapSearchOperatorValue} from '../../utils/string-util'; -import {FlagsService, KnownExperimentId} from '../flags/flags'; import {RetryScheduler} from '../scheduler/retry-scheduler'; import { BatchLabelInput, @@ -138,13 +137,14 @@ DeleteLabelInput, FileInfo, FixReplacementInfo, + FlowActionInfo, FlowInfo, FlowInput, - IsFlowsEnabledInfo, LabelDefinitionInfo, LabelDefinitionInput, SubmitRequirementInput, } from '../../api/rest-api'; +import {IsFlowsEnabledInfo} from '../../api/rest-api'; import { FetchParams, FetchPromisesCache, @@ -156,7 +156,6 @@ SiteBasedCache, throwingErrorCallback, } from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper'; -import {getAppContext} from '../app-context'; const MAX_PROJECT_RESULTS = 25; @@ -173,11 +172,20 @@ ANONYMIZED_CHANGE_BASE_URL + '/revisions/*'; let siteBasedCache = new SiteBasedCache(); // Shared across instances. -let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances. +// Shared across instances. Note: fetchPromisesCache only matches on URL and params, +// not request bodies, hence it does not safely support POST requests with payloads. +let fetchPromisesCache = new FetchPromisesCache(); let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances. let grEtagDecorator = new GrEtagDecorator(); // Shared across instances. // TODO: consider changing this to Map() let projectLookup: {[changeNum: string]: Promise<RepoName> | undefined} = {}; // Shared across instances. +// Shared across instances. Specifically allocated for fix previews because FetchPromisesCache +// doesn't reflect POST request bodies in its keys, and fix previews are currently the only POST requests +// that require deduplication across Lit components. +let fixPreviewCache = new Map< + string, + Promise<FilePathToDiffInfoMap | undefined> +>(); function suppress404s(res?: Response | null) { if (!res || res.status === 404) return; @@ -228,6 +236,7 @@ pendingRequest = {}; grEtagDecorator = new GrEtagDecorator(); projectLookup = {}; + fixPreviewCache = new Map(); authService.clearCache(); } @@ -270,12 +279,7 @@ // Used to serialize requests for certain RPCs readonly _serialScheduler: Scheduler<Response>; - private readonly flags = getAppContext().flagsService; - - constructor( - private readonly authService: AuthService, - private readonly flagService: FlagsService - ) { + constructor(private readonly authService: AuthService) { const readScheduler = createReadScheduler(); const writeScheduler = createWriteScheduler(); this._restApiHelper = new GrRestApiHelper( @@ -919,6 +923,32 @@ } } + async setAvatarAccountEmail(email: string): Promise<void> { + await this._restApiHelper.fetch({ + fetchOptions: { + method: HttpMethod.PUT, + }, + url: `/accounts/self/emails/${encodeURIComponent(email)}/avatar`, + anonymizedUrl: '/accounts/self/emails/*/avatar', + reportServerError: true, + }); + // If result of getAccountEmails is in cache, update it in the cache + // so we don't have to invalidate it. + const cachedEmails = this._cache.get( + '/accounts/self/emails' + ) as unknown as EmailInfo[]; + if (cachedEmails) { + const emails = cachedEmails.map(entry => { + if (entry.email === email) { + return {...entry, avatar: true}; + } else { + return {...entry, avatar: false}; + } + }); + this._cache.set('/accounts/self/emails', emails as unknown as ParsedJSON); + } + } + _updateCachedAccount(obj: Partial<AccountDetailInfo>): void { // If result of getAccount is in cache, update it in the cache // so we don't have to invalidate it. @@ -1181,9 +1211,7 @@ } /** - * Depending on an experiment this will either use `getChangesForMultipleQueries()`, which - * makes just one request to the REST API. Or it will fan out into multiple parallel - * requests and call `getChanges()` for each query. + * Runs multiple parallel requests and call `getChanges()` for each query. */ async getChangesForDashboard( changesPerPage?: number, @@ -1191,75 +1219,15 @@ offset?: 'n,z' | number, options?: string ): Promise<ChangeInfo[][] | undefined> { - // CAUTION: Before actually enabling this experiment for everyone we will have to also change - // the prefetched query in the backend. As is the experiment may help improving the - // DashboardDisplayed metric, but it will definitely make the *Startup*DashboardDisplayed - // latency worse. - const parallelRequests = this.flagService.isEnabled( - KnownExperimentId.PARALLEL_DASHBOARD_REQUESTS - ); - if (parallelRequests && queries && queries.length > 1) { - const requestPromises = queries.map(query => - this.getChanges(changesPerPage, query, offset, options) - ); - return Promise.all(requestPromises).then(results => { - if (results.includes(undefined)) return undefined; - return results as ChangeInfo[][]; - }); - } else { - return this.getChangesForMultipleQueries( - changesPerPage, - queries, - offset, - options - ); + if (!queries) { + return undefined; } - } - - /** - * For every query fetches the matching changes. - * - * If options is undefined then default options (see getListChangesOptionsHex) is - * used. - */ - getChangesForMultipleQueries( - changesPerPage?: number, - query?: string[], - offset?: 'n,z' | number, - options?: string - ): Promise<ChangeInfo[][] | undefined> { - if (!query) return Promise.resolve(undefined); - - const request = this.getRequestForGetChanges( - changesPerPage, - query, - offset, - options + const requestPromises = queries.map(query => + this.getChanges(changesPerPage, query, offset, options) ); - - return Promise.resolve( - this._restApiHelper.fetchJSON(request, true) as Promise< - ChangeInfo[] | ChangeInfo[][] | undefined - > - ).then(response => { - if (!response) { - return; - } - const iterateOverChanges = (arr: ChangeInfo[]) => { - for (const change of arr) { - this._maybeInsertInLookup(change); - } - }; - // Normalize the response to look like a multi-query response - // when there is only one query. - const responseArray: Array<ChangeInfo[]> = - query.length === 1 - ? [response as ChangeInfo[]] - : (response as ChangeInfo[][]); - for (const arr of responseArray) { - iterateOverChanges(arr); - } - return responseArray; + return Promise.all(requestPromises).then(results => { + if (results.includes(undefined)) return undefined; + return results as ChangeInfo[][]; }); } @@ -1433,10 +1401,6 @@ if (config?.receive?.enable_signed_push) { options.push(ListChangesOption.PUSH_CERTIFICATES); } - if (!this.flags.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS)) { - options.push(ListChangesOption.SUBMITTABLE); - options.push(ListChangesOption.SUBMIT_REQUIREMENTS); - } return options; } @@ -1498,9 +1462,6 @@ async getSubmittabilityInfo( changeNum: NumericChangeId ): Promise<SubmittabilityInfo | undefined> { - if (!this.flags.isEnabled(KnownExperimentId.ASYNC_SUBMIT_REQUIREMENTS)) { - return undefined; - } const optionsHex = listChangesOptionsToHex( ListChangesOption.SUBMITTABLE, ListChangesOption.SUBMIT_REQUIREMENTS, @@ -1511,16 +1472,12 @@ /* errFn=*/ undefined, optionsHex ); - if ( - !change || - change.submittable === undefined || - change.submit_requirements === undefined - ) { + if (!change || change.submit_requirements === undefined) { return undefined; } return { changeNum, - submittable: change.submittable, + submittable: !!change.submittable, submitRequirements: change.submit_requirements, }; } @@ -2384,9 +2341,7 @@ 'DETAILED_ACCOUNTS', 'MESSAGES', 'REVIEWER_UPDATES', - 'SUBMITTABLE', 'SKIP_DIFFSTAT', - 'SUBMIT_REQUIREMENTS', ]; return options; } @@ -2643,15 +2598,37 @@ patchNum: PatchSetNum, fixReplacementInfos: FixReplacementInfo[] ): Promise<FilePathToDiffInfoMap | undefined> { - const url = await this._changeBaseURL(changeNum, patchNum); - return this._restApiHelper.fetchJSON({ - fetchOptions: getFetchOptions({ - method: HttpMethod.POST, - body: {fix_replacement_infos: fixReplacementInfos}, - }), - url: `${url}/fix:preview`, - anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:preview`, - }) as Promise<FilePathToDiffInfoMap | undefined>; + const key = `${changeNum}-${patchNum}-${JSON.stringify( + fixReplacementInfos + )}`; + if (fixPreviewCache.has(key)) { + return fixPreviewCache.get(key); + } + + const promise = (async () => { + try { + const url = await this._changeBaseURL(changeNum, patchNum); + const response = (await this._restApiHelper.fetchJSON({ + fetchOptions: getFetchOptions({ + method: HttpMethod.POST, + body: {fix_replacement_infos: fixReplacementInfos}, + }), + url: `${url}/fix:preview`, + anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/fix:preview`, + })) as FilePathToDiffInfoMap | undefined; + + if (response === undefined) { + fixPreviewCache.delete(key); + } + return response; + } catch (err) { + fixPreviewCache.delete(key); + throw err; + } + })(); + + fixPreviewCache.set(key, promise); + return promise; } async applyFixSuggestion( @@ -3652,7 +3629,8 @@ async getPatchContent( changeNum: NumericChangeId, patchNum: PatchSetNum, - context?: number + context?: number, + errFn?: ErrorCallback ): Promise<string | undefined> { const url = await this._changeBaseURL(changeNum, patchNum); const params: {[key: string]: string | number} = { @@ -3666,6 +3644,7 @@ url: `${url}/patch`, params, anonymizedUrl: `${ANONYMIZED_REVISION_BASE_URL}/patch`, + errFn, }); if (!response?.ok) return undefined; return await response.text(); @@ -3790,6 +3769,18 @@ }) as Promise<IsFlowsEnabledInfo | undefined>; } + async listFlowActions( + changeNum: NumericChangeId, + errFn?: ErrorCallback + ): Promise<FlowActionInfo[] | undefined> { + const url = await this._changeBaseURL(changeNum); + return this._restApiHelper.fetchJSON({ + url: `${url}/flows-actions`, + errFn, + anonymizedUrl: `${ANONYMIZED_CHANGE_BASE_URL}/flows-actions`, + }) as Promise<FlowActionInfo[] | undefined>; + } + async createFlow( changeNum: NumericChangeId, flow: FlowInput,
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts index 78ac93f..870723d 100644 --- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts +++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api-impl_test.ts
@@ -57,7 +57,6 @@ import {assert} from '@open-wc/testing'; import {AuthService} from '../gr-auth/gr-auth'; import {GrAuthMock} from '../gr-auth/gr-auth_mock'; -import {FlagsServiceImplementation} from '../flags/flags_impl'; const EXPECTED_QUERY_OPTIONS = listChangesOptionsToHex( ListChangesOption.CHANGE_ACTIONS, @@ -86,10 +85,7 @@ // fake auth authService = new GrAuthMock(); sinon.stub(authService, 'authCheck').resolves(true); - element = new GrRestApiServiceImpl( - authService, - new FlagsServiceImplementation() - ); + element = new GrRestApiServiceImpl(authService); element._projectLookup = {}; }); @@ -1569,26 +1565,6 @@ }); 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.getRepoName(1 as NumericChangeId); - assert.equal(project1, 'test' as RepoName); - const project2 = await element.getRepoName(2 as NumericChangeId); - assert.equal(project2, 'test' as RepoName); - const project3 = await element.getRepoName(3 as NumericChangeId); - assert.equal(project3, 'test/test' as RepoName); - }); - test('no query', async () => { sinon.stub(element._restApiHelper, 'fetchJSON').resolves([ {_number: 1, project: 'test'}, @@ -2054,6 +2030,45 @@ }); }); + suite('getFixPreview', () => { + const fixReplacementInfo = createFixReplacementInfo(); + let fetchJSONStub: sinon.SinonStub; + + setup(() => { + element.addRepoNameToCache(123 as NumericChangeId, TEST_PROJECT_NAME); + fetchJSONStub = sinon + .stub(element._restApiHelper, 'fetchJSON') + .resolves({} as unknown as ParsedJSON); + }); + + test('getFixPreview caches results', async () => { + await element.getFixPreview(123 as NumericChangeId, 1 as PatchSetNum, [ + fixReplacementInfo, + ]); + assert.isTrue(fetchJSONStub.calledOnce); + + // Call again with same params + await element.getFixPreview(123 as NumericChangeId, 1 as PatchSetNum, [ + fixReplacementInfo, + ]); + // Should not call fetchJSON again + assert.isTrue(fetchJSONStub.calledOnce); + }); + + test('getFixPreview does not cache for different params', async () => { + await element.getFixPreview(123 as NumericChangeId, 1 as PatchSetNum, [ + fixReplacementInfo, + ]); + assert.isTrue(fetchJSONStub.calledOnce); + + // Call with different patchNum + await element.getFixPreview(123 as NumericChangeId, 2 as PatchSetNum, [ + fixReplacementInfo, + ]); + assert.isTrue(fetchJSONStub.calledTwice); + }); + }); + suite('flow api', () => { const changeNum = 123 as NumericChangeId; const flowId = 'test-flow-id';
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 3a37599..ccb1320 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
@@ -102,6 +102,7 @@ DeleteLabelInput, FileInfo, FixReplacementInfo, + FlowActionInfo, FlowInfo, FlowInput, IsFlowsEnabledInfo, @@ -263,6 +264,7 @@ ): Promise<EmailInfo[] | undefined>; deleteAccountEmail(email: string): Promise<Response>; setPreferredAccountEmail(email: string): Promise<void>; + setAvatarAccountEmail(email: string): Promise<void>; getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined>; deleteAccountSSHKey(key: string): void; @@ -577,12 +579,6 @@ offset?: 'n,z' | number, options?: string ): Promise<ChangeInfo[][] | undefined>; - getChangesForMultipleQueries( - changesPerPage?: number, - query?: string[], - offset?: 'n,z' | number, - options?: string - ): Promise<ChangeInfo[][] | undefined>; getDocumentationSearches(filter: string): Promise<DocResult[] | undefined>; @@ -841,7 +837,8 @@ getPatchContent( changeNum: NumericChangeId, patchNum: PatchSetNum, - context?: number + context?: number, + errFn?: ErrorCallback ): Promise<string | undefined>; getImagesForDiff( @@ -964,6 +961,11 @@ errFn?: ErrorCallback ): Promise<FlowInfo[] | undefined>; + listFlowActions( + changeNum: NumericChangeId, + errFn?: ErrorCallback + ): Promise<FlowActionInfo[] | undefined>; + getIfFlowsIsEnabled( changeNum: NumericChangeId, errFn?: ErrorCallback
diff --git a/polygerrit-ui/app/services/label-suggestions-provider.ts b/polygerrit-ui/app/services/label-suggestions-provider.ts new file mode 100644 index 0000000..7b32a52 --- /dev/null +++ b/polygerrit-ui/app/services/label-suggestions-provider.ts
@@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {RestApiService} from '../services/gr-rest-api/gr-rest-api'; +import {AutocompleteSuggestion} from '../utils/autocomplete-util'; +import {getAppContext} from '../services/app-context'; +import {RepoName} from '../types/common'; +import {LabelDefinitionInfo} from '../api/rest-api'; + +export class LabelSuggestionsProvider { + private repoName?: RepoName; + + private cachedLabelsPromise?: Promise<LabelDefinitionInfo[] | undefined>; + + constructor(readonly restApiService: RestApiService) {} + + setRepoName(repoName?: RepoName) { + if (this.repoName !== repoName) { + this.repoName = repoName; + this.cachedLabelsPromise = undefined; // Invalidate cache for new repo + } + } + + async getSuggestions( + predicate: string, + expression: string + ): Promise<AutocompleteSuggestion[]> { + if (!this.repoName) return []; + + if (!this.cachedLabelsPromise) { + this.cachedLabelsPromise = this.restApiService + .getRepoLabels(this.repoName) + .catch(err => { + getAppContext().reportingService.error( + 'LabelSuggestionsProvider', + err + ); + return undefined; // Ensure caught errors resolve safely and don't break the cache + }); + } + + return this.cachedLabelsPromise.then(labels => { + if (!labels) return []; + return labels + .map(label => label.name) + .filter(name => name.toLowerCase().includes(expression.toLowerCase())) + .map(name => { + return {text: `${predicate}:${name}`}; + }); + }); + } +}
diff --git a/polygerrit-ui/app/services/label-suggestions-provider_test.ts b/polygerrit-ui/app/services/label-suggestions-provider_test.ts new file mode 100644 index 0000000..c4a33c3 --- /dev/null +++ b/polygerrit-ui/app/services/label-suggestions-provider_test.ts
@@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../test/common-test-setup'; +import {assert} from '@open-wc/testing'; +import {LabelSuggestionsProvider} from './label-suggestions-provider'; +import {RepoName} from '../types/common'; +import {SinonStub} from 'sinon'; +import { + LabelDefinitionInfo, + LabelDefinitionInfoFunction, +} from '../api/rest-api'; +import {stubReporting, stubRestApi} from '../test/test-utils'; +import {getAppContext} from '../services/app-context'; + +suite('LabelSuggestionsProvider tests', () => { + let provider: LabelSuggestionsProvider; + let getRepoLabelsStub: SinonStub; + + const VOTE_LABELS: LabelDefinitionInfo[] = [ + { + name: 'Code-Review', + project_name: 'test-project', + function: LabelDefinitionInfoFunction.MaxWithBlock, + values: {'-1': 'Bad', ' 0': 'No score', '+1': 'Good'}, + default_value: 0, + }, + { + name: 'Verified', + project_name: 'test-project', + function: LabelDefinitionInfoFunction.MaxWithBlock, + values: {'-1': 'Fails', ' 0': 'No score', '+1': 'Verified'}, + default_value: 0, + }, + ]; + + setup(() => { + stubReporting('error'); + getRepoLabelsStub = stubRestApi('getRepoLabels').returns( + Promise.resolve(VOTE_LABELS) + ); + provider = new LabelSuggestionsProvider(getAppContext().restApiService); + }); + + test('getSuggestions returns empty array when repoName is not set', async () => { + const suggestions = await provider.getSuggestions('label', 'Code'); + assert.isEmpty(suggestions); + }); + + test('getSuggestions calls getRepoLabels with the correct repoName', async () => { + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + await provider.getSuggestions('label', 'Code'); + assert.isTrue(getRepoLabelsStub.calledOnce); + assert.isTrue(getRepoLabelsStub.calledWith(repoName)); + }); + + test('getSuggestions filters labels based on expression', async () => { + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + const suggestions = await provider.getSuggestions('label', 'Code'); + assert.deepEqual(suggestions, [{text: 'label:Code-Review'}]); + }); + + test('getSuggestions is case-insensitive', async () => { + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + const suggestions = await provider.getSuggestions('label', 'code'); + assert.deepEqual(suggestions, [{text: 'label:Code-Review'}]); + }); + + test('getSuggestions returns all labels when expression is empty', async () => { + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + const suggestions = await provider.getSuggestions('label', ''); + assert.deepEqual(suggestions, [ + {text: 'label:Code-Review'}, + {text: 'label:Verified'}, + ]); + }); + + test('getSuggestions handles API errors gracefully', async () => { + getRepoLabelsStub.returns(Promise.reject(new Error('API error'))); + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + const suggestions = await provider.getSuggestions('label', 'Code'); + assert.isEmpty(suggestions); + }); + + test('caches suggestions', async () => { + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + + await provider.getSuggestions('label', 'Code'); + assert.isTrue(getRepoLabelsStub.calledOnce); + + await provider.getSuggestions('label', 'Verified'); + assert.isTrue(getRepoLabelsStub.calledOnce); // Should not be called again + }); + + test('invalidates cache when repo name changes', async () => { + const repoName1 = 'test-repo-1' as RepoName; + provider.setRepoName(repoName1); + await provider.getSuggestions('label', 'Code'); + assert.isTrue(getRepoLabelsStub.calledOnce); + assert.isTrue(getRepoLabelsStub.calledWith(repoName1)); + + const repoName2 = 'test-repo-2' as RepoName; + provider.setRepoName(repoName2); + await provider.getSuggestions('label', 'Code'); + assert.isTrue(getRepoLabelsStub.calledTwice); + assert.isTrue(getRepoLabelsStub.calledWith(repoName2)); + }); + + test('does not invalidate cache when repo name is the same', async () => { + const repoName = 'test-repo' as RepoName; + provider.setRepoName(repoName); + await provider.getSuggestions('label', 'Code'); + assert.isTrue(getRepoLabelsStub.calledOnce); + + provider.setRepoName(repoName); // Set same repo name again + await provider.getSuggestions('label', 'Code'); + assert.isTrue(getRepoLabelsStub.calledOnce); + }); +});
diff --git a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts index 3fbc4e9..dcb295a 100644 --- a/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts +++ b/polygerrit-ui/app/services/scheduler/max-in-flight-scheduler_test.ts
@@ -73,7 +73,7 @@ test('resumes when promise fails', async () => { for (let i = 0; i < 3; ++i) { - scheduler.schedule(async () => i); + scheduler.schedule(async () => i).catch(() => {}); } assert.equal(fakeScheduler.scheduled.length, 2); fakeScheduler.reject(new Error('Fake Error'));
diff --git a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts index 2609002..1162596 100644 --- a/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts +++ b/polygerrit-ui/app/services/scheduler/retry-scheduler_test.ts
@@ -17,7 +17,7 @@ let fakeScheduler: FakeScheduler<number>; let scheduler: Scheduler<number>; setup(() => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); fakeScheduler = new FakeScheduler<number>(); scheduler = new RetryScheduler<number>(fakeScheduler, 3, 50, 1); });
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts index 2407fba..b156483 100644 --- a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts +++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -147,9 +147,7 @@ } /** - * TODO(brohlfs): Reconcile with the addShortcut() function in dom-util. - * Most likely we will just keep this one here, but that is something for a - * follow-up change. + * TODO(milutin): Reconcile with the addShortcut() function in dom-util. */ addShortcut( element: HTMLElement,
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts index 4421e47..f9a2b17 100644 --- a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts +++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -97,6 +97,7 @@ suite('binding descriptions', () => { function mapToObject<K, V>(m: Map<K, V>) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const o: any = {}; m.forEach((v: V, k: K) => (o[k] = v)); return o;
diff --git a/polygerrit-ui/app/services/storage/gr-storage_test.ts b/polygerrit-ui/app/services/storage/gr-storage_test.ts index 3022d8e..e0820c3 100644 --- a/polygerrit-ui/app/services/storage/gr-storage_test.ts +++ b/polygerrit-ui/app/services/storage/gr-storage_test.ts
@@ -15,15 +15,18 @@ function mockStorage(quotaExceeded: boolean): Storage { return { getItem(key: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any return (this as any)[key]; }, removeItem(key: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (this as any)[key]; }, setItem(key: string, value: string) { if (quotaExceeded) { throw new DOMException('error', 'QuotaExceededError'); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any (this as any)[key] = value; }, } as Storage;
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts index 0f44a33..162a8d5 100644 --- a/polygerrit-ui/app/styles/dashboard-header-styles.ts +++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -5,8 +5,6 @@ */ import {css} from 'lit'; -const $_documentContainer = document.createElement('template'); - export const dashboardHeaderStyles = css` :host { background-color: var(--view-background-color); @@ -33,13 +31,3 @@ width: 3.5em; } `; - -$_documentContainer.innerHTML = `<dom-module id="dashboard-header-styles"> - <template> - <style> - ${dashboardHeaderStyles.cssText} - </style> - </template> -</dom-module>`; - -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/form-styles.ts b/polygerrit-ui/app/styles/form-styles.ts index 1b9d3a4..583dd59 100644 --- a/polygerrit-ui/app/styles/form-styles.ts +++ b/polygerrit-ui/app/styles/form-styles.ts
@@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ import {css} from 'lit'; -import {grFormStyles} from './gr-form-styles'; export const formStyles = css` input { @@ -37,13 +36,3 @@ font: inherit; } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="form-styles"> - <template> - <style> - ${grFormStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-a11y-styles.ts b/polygerrit-ui/app/styles/gr-a11y-styles.ts index ccf8b50..c5556fe 100644 --- a/polygerrit-ui/app/styles/gr-a11y-styles.ts +++ b/polygerrit-ui/app/styles/gr-a11y-styles.ts
@@ -19,13 +19,3 @@ z-index: -1000; } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-a11y-styles"> - <template> - <style> - ${a11yStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts index 5ea56be..a73afc4 100644 --- a/polygerrit-ui/app/styles/gr-change-list-styles.ts +++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -208,13 +208,3 @@ } } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-change-list-styles"> - <template> - <style> - ${changeListStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts index 7778c08..06845d1 100644 --- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts +++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -3,10 +3,6 @@ * Copyright 2017 Google LLC * SPDX-License-Identifier: Apache-2.0 */ - -// Mark the file as a module. Otherwise typescript assumes this is a script -// and $_documentContainer is a global variable. -// See: https://www.typescriptlang.org/docs/handbook/modules.html import {css} from 'lit'; export const changeMetadataStyles = css` @@ -33,13 +29,3 @@ overflow-wrap: anywhere; } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-change-metadata-shared-styles"> - <template> - <style> - ${changeMetadataStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-font-styles.ts b/polygerrit-ui/app/styles/gr-font-styles.ts index abb08b3..d49ca76 100644 --- a/polygerrit-ui/app/styles/gr-font-styles.ts +++ b/polygerrit-ui/app/styles/gr-font-styles.ts
@@ -44,13 +44,3 @@ font-weight: var(--font-weight-medium); } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-font-styles"> - <template> - <style> - ${fontStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts index 4792dcf..739d2ae 100644 --- a/polygerrit-ui/app/styles/gr-form-styles.ts +++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -103,13 +103,3 @@ } } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-form-styles"> - <template> - <style> - ${grFormStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-hovercard-styles.ts b/polygerrit-ui/app/styles/gr-hovercard-styles.ts index 78eb3e6..4f9878a 100644 --- a/polygerrit-ui/app/styles/gr-hovercard-styles.ts +++ b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
@@ -28,13 +28,3 @@ box-shadow: var(--elevation-level-5); } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-hovercard-styles"> - <template> - <style> - ${hovercardStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-material-styles.ts b/polygerrit-ui/app/styles/gr-material-styles.ts index 0621432..201c8a3 100644 --- a/polygerrit-ui/app/styles/gr-material-styles.ts +++ b/polygerrit-ui/app/styles/gr-material-styles.ts
@@ -93,7 +93,6 @@ ); } - /* These colours come from paper-checkbox */ md-checkbox { background-color: var(--background-color-primary); --md-sys-color-primary: var(--checkbox-primary); @@ -120,14 +119,16 @@ --md-sys-color-primary: var(--select-primary); --md-sys-color-on-surface: var(--select-on-surface); } -`; -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-material-styles"> - <template> - <style> - ${materialStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content); + md-menu { + --md-menu-container-color: var(--dialog-background-color); + --md-menu-item-label-text-color: var(--primary-text-color); + --md-menu-item-icon-color: var(--primary-text-color); + --md-menu-item-hover-label-text-color: var(--primary-text-color); + --md-menu-item-focus-label-text-color: var(--primary-text-color); + --md-menu-item-pressed-label-text-color: var(--primary-text-color); + --md-menu-item-hover-icon-color: var(--primary-text-color); + --md-menu-item-focus-icon-color: var(--primary-text-color); + --md-menu-item-pressed-icon-color: var(--primary-text-color); + } +`;
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts index 17b7461..6ffd075 100644 --- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts +++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -56,15 +56,3 @@ } } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = ` - <dom-module id="gr-menu-page-styles"> - <template> - <style> - ${menuPageStyles.cssText} - </style> - </template> - </dom-module> -`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-modal-styles.ts b/polygerrit-ui/app/styles/gr-modal-styles.ts index a8b5dc4..b1bcf51 100644 --- a/polygerrit-ui/app/styles/gr-modal-styles.ts +++ b/polygerrit-ui/app/styles/gr-modal-styles.ts
@@ -28,13 +28,3 @@ opacity: var(--modal-opacity, 0.6); } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-modal-styles"> - <template> - <style> - ${modalStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts index 0c74843..f651fef 100644 --- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts +++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -5,8 +5,6 @@ */ import {css} from 'lit'; -const $_documentContainer = document.createElement('template'); - export const pageNavStyles = css` .navStyles ul { padding: var(--spacing-l) 0; @@ -54,13 +52,3 @@ margin: var(--spacing-s) 0; } `; - -$_documentContainer.innerHTML = `<dom-module id="gr-page-nav-styles"> - <template> - <style> - ${pageNavStyles.cssText} - </style> - </template> -</dom-module>`; - -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-spinner-styles.ts b/polygerrit-ui/app/styles/gr-spinner-styles.ts index 1c7adc7..4bc131b 100644 --- a/polygerrit-ui/app/styles/gr-spinner-styles.ts +++ b/polygerrit-ui/app/styles/gr-spinner-styles.ts
@@ -24,14 +24,3 @@ } } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-spinner-styles"> - <template> - <style> - ${spinnerStyles.cssText} - </style> - </template> -</dom-module>`; - -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts index 4408900..fceffc7 100644 --- a/polygerrit-ui/app/styles/gr-subpage-styles.ts +++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -19,15 +19,3 @@ display: none; } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = ` - <dom-module id="gr-subpage-styles"> - <template> - <style> - ${subpageStyles.cssText} - </style> - </template> - </dom-module> -`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts index 9fa8ffc..a3c605f 100644 --- a/polygerrit-ui/app/styles/gr-table-styles.ts +++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -93,15 +93,3 @@ display: none; } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = ` - <dom-module id="gr-table-styles"> - <template> - <style> - ${tableStyles.cssText} - </style> - </template> - </dom-module> -`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts index e905941..493705d 100644 --- a/polygerrit-ui/app/styles/gr-voting-styles.ts +++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -3,10 +3,6 @@ * Copyright 2017 Google LLC * SPDX-License-Identifier: Apache-2.0 */ - -// Mark the file as a module. Otherwise typescript assumes this is a script -// and $_documentContainer is a global variable. -// See: https://www.typescriptlang.org/docs/handbook/modules.html import {css} from 'lit'; export const votingStyles = css` @@ -20,13 +16,3 @@ color: var(--vote-text-color); } `; - -const $_documentContainer = document.createElement('template'); -$_documentContainer.innerHTML = `<dom-module id="gr-voting-styles"> - <template> - <style> - ${votingStyles.cssText} - </style> - </template> -</dom-module>`; -document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css index 4c85176..13ee241 100644 --- a/polygerrit-ui/app/styles/main.css +++ b/polygerrit-ui/app/styles/main.css
@@ -34,7 +34,7 @@ html, body { height: 100%; - transition: none; /* Override the default Polymer fade-in. */ + transition: none; } body { /*
diff --git a/polygerrit-ui/app/styles/material-icons.css b/polygerrit-ui/app/styles/material-icons.css index 0cce879..17d77ae 100644 --- a/polygerrit-ui/app/styles/material-icons.css +++ b/polygerrit-ui/app/styles/material-icons.css
@@ -1,12 +1,29 @@ /** - * This file has been produced by downloading this file on June 11, 2024: + * This file has been produced by downloading this file on Nov 15, 2025: * https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0 - * The corresponding ttf file was downloaded on June 11, 2024 from: - * https://fonts.gstatic.com/s/materialsymbolsoutlined/v192/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0.woff2 + * The corresponding ttf file was downloaded on Nov 15, 2025 from: + * https://fonts.gstatic.com/s/materialsymbolsoutlined/v296/kJF4BvYX7BgnkSrUwT8OhrdQw4oELdPIeeII9v6oDMzBwG-RpA6RzaxHMPdY40KH8nGzv3fzfVJU22ZZLsYEpzC_1ver5Y0J1Llf.woff2 */ +/* fallback */ @font-face { font-family: 'Material Symbols Outlined'; font-style: normal; - font-weight: 100 700; - src: url(../fonts/material-icons.woff2) format('woff2'); + font-weight: 400; + src: url(../fonts/material-icons.woff2) format('woff2'),; +} + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-feature-settings: 'liga'; + -webkit-font-smoothing: antialiased; } \ No newline at end of file
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts index 60b5026..a614e4d 100644 --- a/polygerrit-ui/app/styles/themes/app-theme.ts +++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -433,6 +433,9 @@ --header-text-color: black; --header-title-content: 'Gerrit'; --header-title-font-size: 1.75rem; + /* This is just an initial value. Will be updated by a ResizeObserver. */ + --main-header-height: 48px; + --main-footer-height: 36px; /* diff colors */ --dark-add-highlight-color: #aaf2aa; @@ -521,15 +524,16 @@ 0px 6px 10px 4px rgba(60, 64, 67, 0.15); --elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, 0.3), 0px 8px 12px 6px rgba(60, 64, 67, 0.15); + --elevation-color: rgba(0, 0, 0, 0.2); /* misc */ --border-radius: 4px; --line-length-indicator-color: #681da8; - /* md-filled-card (colours originate from paper-card) */ + /* md-filled-card */ --card-surface-container-highest: #f8f9fa; - /* md-checkbox (colours from paper-checkbox but adapted using material/web theme selector) */ + /* md-checkbox */ --checkbox-primary: #273a9f; --checkbox-on-primary: #ffffff; --checkbox-on-surface: #1a1b22; @@ -541,7 +545,7 @@ --radio-on-surface: #1a1b22; --radio-on-surface-variant: #3f4948; - /* md-filled-select/md-outlined-select (colour originates from paper-listbox but generated by material-web using the hex */ + /* md-filled-select/md-outlined-select */ --select-surface-container: #f8f9fa; --select-surface-container-highest: #f8f9fa; --select-on-surface: #1c1b1b; @@ -550,7 +554,7 @@ --select-secondary-container: #e5e3e3; --select-on-secondary-container: #484949; - /* md-switch (colour originates from paper-toggle-button but generated by material-web using the hex */ + /* md-switch */ --switch-color-surface-container-highest: #e0e2ea; --switch-color-on-surface: #181c22; --switch-color-on-surface-variant: #404752; @@ -578,22 +582,3 @@ styleEl.setAttribute('id', 'light-theme'); setStyleTextContent(styleEl, appThemeCss); document.head.appendChild(styleEl); - -// TODO: The following can be removed when Paper and Iron components have been -// removed from Gerrit. - -const appThemeCssPolymerLegacy = safeStyleSheet` - /* prettier formatter removes semi-colons after css mixins. */ - /* prettier-ignore */ - html { - --paper-tooltip: { - font-size: var(--font-size-small); - }; - } -`; - -const customStyleEl = document.createElement('custom-style'); -const innerStyleEl = document.createElement('style'); -setStyleTextContent(innerStyleEl, appThemeCssPolymerLegacy); -customStyleEl.appendChild(innerStyleEl); -document.head.appendChild(customStyleEl);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts index 371b4b0..8c6bb7a 100644 --- a/polygerrit-ui/app/styles/themes/dark-theme.ts +++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -161,6 +161,8 @@ --vote-chip-unselected-text-color: white; --vote-chip-selected-text-color: black; + --elevation-color: rgba(0, 0, 0, 0.5); + --outline-color-focus: var(--gray-100); /* misc colors */ @@ -287,22 +289,21 @@ /* rules applied to html */ background-color: var(--view-background-color); - /* md-filled-card (colours originate from paper-card) */ + /* md-filled-card */ --card-surface-container-highest: #2f3034; - /* md-checkbox (colours from paper-checkbox but adapted using material/web theme selector) */ + /* md-checkbox */ --checkbox-primary: #bac3ff; --checkbox-on-primary: #08218a; --checkbox-on-surface: #e3e1ea; --checkbox-on-surface-variant: #c5c5d4; - /* These colours come from paper-checkbox */ --radio-primary: #bac3ff; --radio-on-primary: #08218a; --radio-on-surface: #e3e1ea; --radio-on-surface-variant: #c5c5d4; - /* md-filled-select/md-outlined-select (colour originates from paper-listbox but generated by material-web using the hex */ + /* md-filled-select/md-outlined-select */ --select-surface-container: #201f20; --select-surface-container-highest: #2f3034; --select-on-surface: #e5e2e1; @@ -315,7 +316,7 @@ /* md-menu/md-menu-itme/md-focus-ring */ --gr-dropdown-focus-ring-color: #c8c6c7; - /* md-switch (colour originates from paper-toggle-button but generated by material-web using the hex */ + /* md-switch */ --switch-color-surface-container-highest: #000000; --switch-color-on-surface: #e0e2ea; --switch-color-on-surface-variant: #c0c7d4;
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts index 81ad5bf..dc77649 100644 --- a/polygerrit-ui/app/test/common-test-setup.ts +++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -44,12 +44,21 @@ declare global { interface Window { sinon: typeof sinon; + litIssuedWarnings?: Set<string>; } } window.sinon = sinon; +// Suppress 'Lit is in dev mode' warning. This is a development build, but we want +// to keep the test output clean from unnecessary warnings. +window.litIssuedWarnings = window.litIssuedWarnings || new Set(); +window.litIssuedWarnings.add('dev-mode'); installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => { + // Suppress 'initResin' log message from polymer-resin. + if (fmt === 'initResin') { + return; + } const log = _testOnly_defaultResinReportHandler; log(isViolation, fmt, ...args); if (isViolation) { @@ -171,7 +180,6 @@ cleanupTestUtils(); checkGlobalSpace(); removeThemeStyles(); - cancelAllTasks(); cleanUpStorage(); removeRequestDependencyListener(); injectedDependencies.clear(); @@ -179,6 +187,9 @@ for (const f of finalizers) { f.finalize(); } + // run `cancelAllTasks` after service finalizers. This allows services to clean up their own + // internal state before the global task cancellation occurs + cancelAllTasks(); const testTeardownTimestampMs = new Date().getTime(); const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs; if (elapsedMs > 1000) {
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 818dbef..083f75e 100644 --- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts +++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -7,7 +7,7 @@ RestApiService, SubmittabilityInfo, } from '../../services/gr-rest-api/gr-rest-api'; -import {FlowInfo} from '../../api/rest-api'; +import {FlowActionInfo, FlowInfo} from '../../api/rest-api'; import { AccountCapabilityInfo, AccountDetailInfo, @@ -231,7 +231,7 @@ return Promise.resolve({}); }, getChange(): Promise<ChangeInfo | undefined> { - throw new Error('getChange() not implemented by RestApiMock.'); + return Promise.resolve(undefined); }, getChangeActionURL(): Promise<string> { return Promise.resolve(''); @@ -281,9 +281,6 @@ getChangesForDashboard() { return Promise.resolve([]); }, - getChangesForMultipleQueries() { - return Promise.resolve([]); - }, getChangesSubmittedTogether(): Promise<SubmittedTogetherInfo | undefined> { return Promise.resolve(createSubmittedTogetherInfo()); }, @@ -615,6 +612,9 @@ setPreferredAccountEmail(): Promise<void> { return Promise.resolve(); }, + setAvatarAccountEmail(): Promise<void> { + return Promise.resolve(); + }, setRepoAccessRights(): Promise<Response> { return Promise.resolve(new Response()); }, @@ -633,6 +633,9 @@ listFlows(): Promise<FlowInfo[] | undefined> { return Promise.resolve([]); }, + listFlowActions(): Promise<FlowActionInfo[] | undefined> { + return Promise.resolve([]); + }, createFlow(): Promise<FlowInfo | undefined> { return Promise.resolve(undefined); },
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts index a670967..a2d8694 100644 --- a/polygerrit-ui/app/test/test-data-generators.ts +++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -97,14 +97,21 @@ import { DetailedLabelInfo, FixReplacementInfo, + FlowInfo, + FlowStageState, PatchSetNumber, QuickLabelInfo, SubmitRequirementExpressionInfo, SubmitRequirementResultInfo, SubmitRequirementStatus, } from '../api/rest-api'; -import {CheckResult, CheckRun, RunResult} from '../models/checks/checks-model'; -import {Category, Fix, Link, LinkIcon, RunStatus} from '../api/checks'; +import { + CheckResult, + CheckRun, + ChecksModel, + ChecksPatchset, + RunResult, +} from '../models/checks/checks-model'; import {DiffInfo, GrDiffLineType} from '../api/diff'; import {SearchViewState} from '../models/views/search'; import {ChangeChildView, ChangeViewState} from '../models/views/change'; @@ -118,6 +125,24 @@ GrDiffGroup, GrDiffGroupType, } from '../embed/diff/gr-diff/gr-diff-group'; +import { + Actions, + AiCodeReviewProvider, + ChatRequest, + ChatResponseListener, + ContextItemType, + Models, +} from '../api/ai-code-review'; +import { + Action, + ActionResult, + Category, + Fix, + Link, + LinkIcon, + RunStatus, + TagColor, +} from '../api/checks'; const TEST_DEFAULT_EXPRESSION = 'label:Verified=MAX -label:Verified=MIN'; export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName; @@ -877,7 +902,8 @@ return { name: 'gitiles', url: '#', - image_url: 'gitiles.jpg', + image_url: + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', }; } @@ -1227,3 +1253,661 @@ replacement: 'replacement', }; } + +export const chatModels: Models = { + models: [ + { + model_id: 'gemini-pro', + short_text: 'Gemini Pro', + full_display_text: 'Gemini Pro', + }, + { + model_id: 'gemini-ultra', + short_text: 'Gemini Ultra', + full_display_text: 'Gemini Ultra', + }, + ], + default_model_id: 'gemini-pro', + documentation_url: 'http://doc.url', + citation_url: 'http://citation.url', + privacy_url: 'http://privacy.url', +}; + +export const chatActions: Actions = { + actions: [ + { + id: 'freeform', + display_text: 'Free form chat', + }, + { + id: 'summarize', + display_text: 'Summarize', + initial_user_prompt: 'Summarize the change', + icon: 'summarize', + enable_splash_page_card: true, + enable_send_without_input: true, + }, + ], + default_action_id: 'freeform', +}; + +export const chatContextItemTypes: ContextItemType[] = [ + { + id: 'google', + name: 'google', + icon: 'bug_report', + placeholder: 'google.com', + regex: /http:\/\/www.google.com/, + + parse(link: string) { + const match = link.match(this.regex); + if (!match) return undefined; + return { + type_id: this.id, + identifier: 'google-id', + link, + title: 'google-title', + }; + }, + }, + { + id: 'gerrit_change', + name: 'Gerrit Change', + icon: 'commit', + placeholder: '', + regex: /.*/, + parse() { + return undefined; + }, + }, +]; + +export const chatProvider: AiCodeReviewProvider = { + chat: (_req: ChatRequest, _listener: ChatResponseListener) => {}, + listChatConversations: (_change: ChangeInfo) => Promise.resolve([]), + getChatConversation: (_change: ChangeInfo, _conversation_id: string) => + Promise.resolve([]), + getModels: (_change: ChangeInfo) => Promise.resolve(chatModels), + getActions: (_change: ChangeInfo) => Promise.resolve(chatActions), + getContextItemTypes: () => Promise.resolve(chatContextItemTypes), +}; + +export const checkRun0: CheckRun = { + pluginName: 'f0', + internalRunId: 'f0', + patchset: 1, + checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder', + labelName: 'Presubmit', + isSingleAttempt: true, + isLatestAttempt: true, + attemptDetails: [], + worstCategory: Category.ERROR, + isAiPowered: true, + results: [ + { + internalResultId: 'f0r0', + category: Category.ERROR, + summary: 'I would like to point out this error: 1 is not equal to 2!', + links: [ + {primary: true, url: 'https://www.google.com', icon: LinkIcon.EXTERNAL}, + ], + tags: [{name: 'OBSOLETE'}, {name: 'E2E'}], + }, + { + internalResultId: 'f0r1', + category: Category.ERROR, + summary: 'Running the mighty test has failed by crashing.', + message: 'Btw, 1 is also not equal to 3. Did you know?', + actions: [ + { + name: 'Ignore', + tooltip: 'Ignore this result', + primary: true, + callback: () => Promise.resolve({message: 'fake "ignore" triggered'}), + }, + { + name: 'Flag', + tooltip: 'Flag this result as totally absolutely really not useful', + primary: true, + disabled: true, + callback: () => Promise.resolve({message: 'flag "flag" triggered'}), + }, + { + name: 'Upload', + tooltip: 'Upload the result to the super cloud.', + primary: false, + callback: () => Promise.resolve({message: 'fake "upload" triggered'}), + }, + { + name: 'useful', + callback: () => + Promise.resolve({message: 'fake "useful report" triggered'}), + }, + { + name: 'not-useful', + callback: () => + Promise.resolve({message: 'fake "not useful report" triggered'}), + }, + ], + tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}], + links: [ + {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL}, + {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD}, + { + primary: true, + url: 'https://google.com', + icon: LinkIcon.DOWNLOAD_MOBILE, + }, + {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE}, + {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE}, + {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE}, + {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG}, + {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE}, + {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY}, + ], + }, + ], + status: RunStatus.COMPLETED, +}; + +export const checkRun1: CheckRun = { + pluginName: 'f1', + internalRunId: 'f1', + checkName: 'FAKE Super Check', + startedTimestamp: new Date(new Date().getTime() - 5 * 60 * 1000), + finishedTimestamp: new Date(new Date().getTime() + 5 * 60 * 1000), + patchset: 1, + labelName: 'Verified', + isSingleAttempt: true, + isLatestAttempt: true, + attemptDetails: [], + worstCategory: Category.ERROR, + isAiPowered: true, + results: [ + { + internalResultId: 'f1r0', + category: Category.WARNING, + summary: 'We think that you could improve this.', + message: `There is a lot to be said. A lot. I say, a lot. + So please keep reading.`, + tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}], + codePointers: [ + { + path: '/COMMIT_MSG', + range: { + start_line: 7, + start_character: 5, + end_line: 9, + end_character: 20, + }, + }, + ], + links: [ + {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL}, + {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD}, + { + primary: true, + url: 'https://google.com', + icon: LinkIcon.DOWNLOAD_MOBILE, + }, + {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE}, + { + primary: false, + url: 'https://google.com', + tooltip: 'look at this', + icon: LinkIcon.IMAGE, + }, + { + primary: false, + url: 'https://google.com', + tooltip: 'not at this', + icon: LinkIcon.IMAGE, + }, + ], + }, + { + internalResultId: 'f1r1', + category: Category.INFO, + summary: 'Suspicious Author', + message: 'Do you personally know this person?', + codePointers: [ + { + path: '/COMMIT_MSG', + range: { + start_line: 2, + start_character: 0, + end_line: 2, + end_character: 0, + }, + }, + ], + links: [], + }, + { + internalResultId: 'f1r2', + category: Category.ERROR, + summary: 'Test Size Checker', + message: 'The test seems to be of large size, not medium.', + codePointers: [ + { + path: 'plugins/BUILD', + range: { + start_line: 186, + start_character: 12, + end_line: 186, + end_character: 18, + }, + }, + ], + actions: [ + { + name: 'useful', + tooltip: 'This check result was helpful', + callback: () => + new Promise(resolve => { + setTimeout( + () => resolve({message: 'Feedback recorded.'} as ActionResult), + 1000 + ); + }), + }, + { + name: 'not-useful', + tooltip: 'This check result was not helpful', + callback: () => + new Promise(resolve => { + setTimeout( + () => resolve({message: 'Feedback recorded.'} as ActionResult), + 1000 + ); + }), + }, + ], + fixes: [ + { + description: 'This is the way to do it.', + replacements: [ + { + path: 'plugins/BUILD', + range: { + start_line: 186, + start_character: 12, + end_line: 186, + end_character: 18, + }, + replacement: 'large', + }, + ], + }, + ], + links: [], + }, + ], + status: RunStatus.RUNNING, +}; + +export const checkRun2: CheckRun = { + pluginName: 'f2', + internalRunId: 'f2', + patchset: 1, + checkName: 'FAKE Mega Analysis', + statusDescription: 'This run is nearly completed, but not quite.', + statusLink: 'https://www.google.com/', + checkDescription: + 'From what the title says you can tell that this check analyses.', + checkLink: 'https://www.google.com/', + scheduledTimestamp: new Date('2021-04-01T03:14:15'), + startedTimestamp: new Date('2021-04-01T04:24:25'), + finishedTimestamp: new Date('2021-04-01T04:44:44'), + isSingleAttempt: true, + isLatestAttempt: true, + attemptDetails: [], + actions: [ + { + name: 'Re-Run', + tooltip: 'More powerful run than before', + primary: true, + callback: () => Promise.resolve({message: 'fake "re-run" triggered'}), + }, + { + name: 'Monetize', + primary: true, + disabled: true, + callback: () => Promise.resolve({message: 'fake "monetize" triggered'}), + }, + { + name: 'Delete', + primary: true, + callback: () => Promise.resolve({message: 'fake "delete" triggered'}), + }, + ], + worstCategory: Category.INFO, + results: [ + { + internalResultId: 'f2r0', + category: Category.INFO, + summary: 'This is looking a bit too large.', + message: `We are still looking into how large exactly. Stay tuned. +And have a look at https://www.google.com! + +Or have a look at change 30000. +Example code: + const constable = ''; + var variable = '';`, + tags: [{name: 'FLAKY'}, {name: 'MAC-OS'}], + }, + ], + status: RunStatus.COMPLETED, +}; + +export const checkRun3: CheckRun = { + pluginName: 'f3', + internalRunId: 'f3', + checkName: 'FAKE Critical Observations', + status: RunStatus.RUNNABLE, + isSingleAttempt: true, + isLatestAttempt: true, + attemptDetails: [], +}; + +export const checkRun4_1: CheckRun = { + pluginName: 'f4', + internalRunId: 'f4', + checkName: 'FAKE Elimination Long Long Long Long Long', + status: RunStatus.RUNNABLE, + attempt: 1, + isSingleAttempt: false, + isLatestAttempt: false, + attemptDetails: [], +}; + +export const checkRun4_2: CheckRun = { + pluginName: 'f4', + internalRunId: 'f4', + checkName: 'FAKE Elimination Long Long Long Long Long', + status: RunStatus.COMPLETED, + attempt: 2, + isSingleAttempt: false, + isLatestAttempt: false, + attemptDetails: [], + worstCategory: Category.INFO, + results: [ + { + internalResultId: 'f42r0', + category: Category.INFO, + summary: 'Please eliminate all the TODOs!', + }, + ], +}; + +export const checkRun4_3: CheckRun = { + pluginName: 'f4', + internalRunId: 'f4', + checkName: 'FAKE Elimination Long Long Long Long Long', + status: RunStatus.COMPLETED, + attempt: 3, + isSingleAttempt: false, + isLatestAttempt: false, + attemptDetails: [], + worstCategory: Category.ERROR, + results: [ + { + internalResultId: 'f43r0', + category: Category.ERROR, + summary: 'Without eliminating all the TODOs your change will break!', + }, + ], +}; + +export const checkRun4_4: CheckRun = { + pluginName: 'f4', + internalRunId: 'f4', + patchset: 1, + checkName: 'FAKE Elimination Long Long Long Long Long', + checkDescription: 'Shows you the possible eliminations.', + checkLink: 'https://www.google.com', + status: RunStatus.COMPLETED, + statusDescription: 'Everything was eliminated already.', + statusLink: 'https://www.google.com', + attempt: 40, + scheduledTimestamp: new Date('2021-04-02T03:14:15'), + startedTimestamp: new Date('2021-04-02T04:24:25'), + finishedTimestamp: new Date('2021-04-02T04:25:44'), + isSingleAttempt: false, + isLatestAttempt: true, + attemptDetails: [], + worstCategory: Category.INFO, + isAiPowered: true, + results: [ + { + internalResultId: 'f44r0', + category: Category.INFO, + summary: 'Dont be afraid. All TODOs will be eliminated.', + fixes: [ + { + description: 'This is the way to do it.', + replacements: [ + { + path: 'BUILD', + range: { + start_line: 1, + start_character: 0, + end_line: 1, + end_character: 0, + }, + replacement: '# This is now fixed.\n', + }, + ], + }, + ], + actions: [ + { + name: 'Re-Run', + tooltip: 'More powerful run than before with a long tooltip, really.', + primary: true, + callback: () => Promise.resolve({message: 'fake "re-run" triggered'}), + }, + ], + }, + ], + actions: [ + { + name: 'Re-Run', + tooltip: 'small', + primary: true, + callback: () => Promise.resolve({message: 'fake "re-run" triggered'}), + }, + ], +}; + +export function checkRun4CreateAttempts(from: number, to: number): CheckRun[] { + const runs: CheckRun[] = []; + for (let i = from; i < to; i++) { + runs.push(checkRun4CreateAttempt(i)); + } + return runs; +} + +export function checkRun4CreateAttempt(attempt: number): CheckRun { + return { + pluginName: 'f4', + internalRunId: 'f4', + checkName: 'FAKE Elimination Long Long Long Long Long', + status: RunStatus.COMPLETED, + attempt, + isSingleAttempt: false, + isLatestAttempt: false, + attemptDetails: [], + worstCategory: Category.ERROR, + results: + attempt % 2 === 0 + ? [ + { + internalResultId: 'f43r0', + category: Category.ERROR, + summary: + 'Without eliminating all the TODOs your change will break!', + }, + ] + : [], + }; +} + +export const checkRun4Att = [ + checkRun4_1, + checkRun4_2, + checkRun4_3, + ...checkRun4CreateAttempts(5, 40), + checkRun4_4, +]; + +export const fakeActions: Action[] = [ + { + name: 'Fake Action 1', + primary: true, + disabled: true, + tooltip: 'Tooltip for Fake Action 1', + callback: () => Promise.resolve({message: 'fake action 1 triggered'}), + }, + { + name: 'Fake Action 2', + primary: false, + disabled: true, + tooltip: 'Tooltip for Fake Action 2', + callback: () => Promise.resolve({message: 'fake action 2 triggered'}), + }, + { + name: 'Fake Action 3', + summary: true, + primary: false, + tooltip: 'Tooltip for Fake Action 3', + callback: () => Promise.resolve({message: 'fake action 3 triggered'}), + }, +]; + +export const fakeLinks: Link[] = [ + { + url: 'https://www.google.com', + primary: true, + tooltip: 'Fake Bug Report 1', + icon: LinkIcon.REPORT_BUG, + }, + { + url: 'https://www.google.com', + primary: true, + tooltip: 'Fake Bug Report 2', + icon: LinkIcon.REPORT_BUG, + }, + { + url: 'https://www.google.com', + primary: true, + tooltip: 'Fake Link 1', + icon: LinkIcon.EXTERNAL, + }, + { + url: 'https://www.google.com', + primary: false, + tooltip: 'Fake Link 2', + icon: LinkIcon.EXTERNAL, + }, + { + url: 'https://www.google.com', + primary: true, + tooltip: 'Fake Code Link', + icon: LinkIcon.CODE, + }, + { + url: 'https://www.google.com', + primary: true, + tooltip: 'Fake Image Link', + icon: LinkIcon.IMAGE, + }, + { + url: 'https://www.google.com', + primary: true, + tooltip: 'Fake Help Link', + icon: LinkIcon.HELP_PAGE, + }, +]; + +export const checkRun5: CheckRun = { + pluginName: 'f5', + internalRunId: 'f5', + checkName: 'FAKE Of Tomorrow', + status: RunStatus.SCHEDULED, + isSingleAttempt: true, + isLatestAttempt: true, + attemptDetails: [], +}; + +export function setAllcheckRuns(model: ChecksModel) { + model.updateStateSetProvider('f0', ChecksPatchset.LATEST); + model.updateStateSetProvider('f1', ChecksPatchset.LATEST); + model.updateStateSetProvider('f2', ChecksPatchset.LATEST); + model.updateStateSetProvider('f3', ChecksPatchset.LATEST); + model.updateStateSetProvider('f4', ChecksPatchset.LATEST); + model.updateStateSetProvider('f5', ChecksPatchset.LATEST); + model.updateStateSetResults( + 'f0', + [checkRun0], + fakeActions, + fakeLinks, + 'ETA: 1 min', + ChecksPatchset.LATEST + ); + model.updateStateSetResults( + 'f1', + [checkRun1], + [], + [], + undefined, + ChecksPatchset.LATEST + ); + model.updateStateSetResults( + 'f2', + [checkRun2], + [], + [], + undefined, + ChecksPatchset.LATEST + ); + model.updateStateSetResults( + 'f3', + [checkRun3], + [], + [], + undefined, + ChecksPatchset.LATEST + ); + model.updateStateSetResults( + 'f4', + checkRun4Att, + [], + [], + undefined, + ChecksPatchset.LATEST + ); + model.updateStateSetResults( + 'f5', + [checkRun5], + [], + [], + undefined, + ChecksPatchset.LATEST + ); +} + +export function createFlow(partial: Partial<FlowInfo> = {}): FlowInfo { + return { + uuid: 'flow1', + owner: {name: 'owner1', _account_id: 1 as AccountId}, + created: '2025-01-01T10:00:00.000Z' as Timestamp, + stages: [ + { + expression: {condition: 'label:Code-Review=+1'}, + state: FlowStageState.DONE, + }, + ], + ...partial, + }; +}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts index a62c5d7..a70d02b 100644 --- a/polygerrit-ui/app/test/test-utils.ts +++ b/polygerrit-ui/app/test/test-utils.ts
@@ -112,6 +112,7 @@ return stub; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy< Parameters<F>, ReturnType<F> @@ -129,11 +130,6 @@ } 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) { @@ -284,6 +280,7 @@ export function logProxy<T extends object>(obj: T, name?: string): T { const handler = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any get(target: object, prop: PropertyKey, receiver: any) { const result = Reflect.get(target, prop, receiver); if (result instanceof Function) { @@ -321,12 +318,13 @@ assert.isFalse(matches); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function makePrefixedJSON(obj: any) { return JSON_PREFIX + JSON.stringify(obj); } export async function visualDiffDarkTheme( - element: LitElement | HTMLElement, + element: LitElement | HTMLElement | Element, name: string ) { applyDarkTheme();
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts index 22c1907..435be97 100644 --- a/polygerrit-ui/app/types/common.ts +++ b/polygerrit-ui/app/types/common.ts
@@ -1076,6 +1076,7 @@ allow_suggest_code_while_commenting?: boolean; allow_autocompleting_comments?: boolean; diff_page_sidebar?: DiffPageSidebar; + ai_chat_selected_model?: string; } /**
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts index 47a96e4..b3880fb 100644 --- a/polygerrit-ui/app/types/diff.ts +++ b/polygerrit-ui/app/types/diff.ts
@@ -22,7 +22,9 @@ IgnoreWhitespaceType, MarkLength, MoveDetails, + SkipInfo, SkipLength, + SkipObject, } from '../api/diff'; export type { @@ -34,6 +36,8 @@ MarkLength, MoveDetails, SkipLength, + SkipInfo, + SkipObject, WebLinkInfo, };
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts index 6e4ce86..8dde4f4 100644 --- a/polygerrit-ui/app/types/events.ts +++ b/polygerrit-ui/app/types/events.ts
@@ -11,7 +11,11 @@ PatchSetNum, } from './common'; import {FetchRequest} from './types'; -import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff'; +import { + LineNumberEventDetail, + MovedLinkClickedEventDetail, + Side, +} from '../api/diff'; import {Category, RunStatus} from '../api/checks'; // TODO: Local events that are only fired by one component should also be @@ -42,12 +46,12 @@ '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-diff-in-change-view': OpenDiffInChangeViewEvent; 'open-fix-preview': OpenFixPreviewEvent; 'reply-to-comment': ReplyToCommentEvent; // prettier-ignore @@ -165,6 +169,14 @@ } export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>; +export interface OpenDiffInChangeViewEventDetail { + path: string; + lineNum?: number; + side?: Side; +} +export type OpenDiffInChangeViewEvent = + CustomEvent<OpenDiffInChangeViewEventDetail>; + export interface ReplyToCommentEventDetail { content: string; userWantsToEdit: boolean; @@ -249,11 +261,4 @@ } export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>; -/** - * This event can be used for Polymer properties that have `notify: true` set. - * But it is also generally recommended when you want to notify your parent - * elements about a property update, also for Lit elements. - * - * The name of the event should be `prop-name-changed`. - */ export type ValueChangedEvent<T = string> = CustomEvent<{value: T}>;
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts index f48588b..e8ca8f9 100644 --- a/polygerrit-ui/app/types/types.ts +++ b/polygerrit-ui/app/types/types.ts
@@ -122,6 +122,7 @@ export interface FormattedReviewerUpdateInfo { author: AccountInfo; + realAuthor?: AccountInfo; date: Timestamp; type: 'REVIEWER_UPDATE'; tag: MessageTag.TAG_REVIEWER_UPDATE;
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts index a567dd9..1da212e 100644 --- a/polygerrit-ui/app/utils/access-util.ts +++ b/polygerrit-ui/app/utils/access-util.ts
@@ -30,6 +30,7 @@ SUBMIT_AS = 'submitAs', TOGGLE_WIP_STATE = 'toggleWipState', VIEW_PRIVATE_CHANGES = 'viewPrivateChanges', + AI_REVIEW = 'aiReview', PRIORITY = 'priority', } @@ -131,6 +132,10 @@ id: AccessPermissionId.VIEW_PRIVATE_CHANGES, name: 'View Private Changes', }, + [AccessPermissionId.AI_REVIEW]: { + id: AccessPermissionId.AI_REVIEW, + name: 'AI Review', + }, }; export interface AccessPermission { @@ -168,3 +173,51 @@ a.id.localeCompare(b.id) ); } + +/** + * The category names are defined in `gerrit/Documentation/access-control.txt`. + */ +const DocsDocAnchors: Record<string, string> = { + [AccessPermissionId.ABANDON]: 'category_abandon', + [AccessPermissionId.ADD_PATCH_SET]: 'category_add_patch_set', + [AccessPermissionId.CREATE]: 'category_create', + [AccessPermissionId.CREATE_TAG]: 'category_create_annotated', + [AccessPermissionId.CREATE_SIGNED_TAG]: 'category_create_signed', + [AccessPermissionId.DELETE]: 'category_delete', + [AccessPermissionId.DELETE_CHANGES]: 'category_delete_changes', + [AccessPermissionId.DELETE_OWN_CHANGES]: 'category_delete_own_changes', + [AccessPermissionId.EDIT_HASHTAGS]: 'category_edit_hashtags', + [AccessPermissionId.EDIT_TOPIC_NAME]: 'category_edit_topic_name', + [AccessPermissionId.FORGE_AUTHOR]: 'category_forge_author', + [AccessPermissionId.FORGE_COMMITTER]: 'category_forge_committer', + [AccessPermissionId.FORGE_SERVER_AS_COMMITTER]: 'category_forge_server', + [AccessPermissionId.OWNER]: 'category_owner', + [AccessPermissionId.PUSH]: 'category_push', + [AccessPermissionId.PUSH_MERGE]: 'category_push_merge', + [AccessPermissionId.READ]: 'category_read', + [AccessPermissionId.REBASE]: 'category_rebase', + [AccessPermissionId.REVERT]: 'category_revert', + [AccessPermissionId.REMOVE_REVIEWER]: 'category_remove_reviewer', + [AccessPermissionId.SUBMIT]: 'category_submit', + [AccessPermissionId.SUBMIT_AS]: 'category_submit_on_behalf_of', + [AccessPermissionId.TOGGLE_WIP_STATE]: + 'category_toggle_work_in_progress_state', + [AccessPermissionId.VIEW_PRIVATE_CHANGES]: 'category_view_private_changes', + [AccessPermissionId.AI_REVIEW]: 'category_ai_review', +}; + +export function getAccessDocsAnchor(permissionId: string): string | undefined { + if (permissionId in DocsDocAnchors) { + return DocsDocAnchors[permissionId]; + } + if ( + permissionId.startsWith('label-') || + permissionId.startsWith('labelAs-') + ) { + return 'category_review_labels'; + } + if (permissionId.startsWith('removeLabel-')) { + return 'category_remove_label'; + } + return undefined; +}
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts index c1a39b8..ffbebaf 100644 --- a/polygerrit-ui/app/utils/account-util_test.ts +++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -78,10 +78,10 @@ test('extractMentionedUsers', () => { let text = - 'Hi @kamilm@google.com and @brohlfs@google.com can you take a look at this?'; + 'Hi @kamilm@google.com and @milutin@google.com can you take a look at this?'; assert.deepEqual(extractMentionedUsers(text), [ {email: 'kamilm@google.com' as EmailAddress}, - {email: 'brohlfs@google.com' as EmailAddress}, + {email: 'milutin@google.com' as EmailAddress}, ]); // with extra @
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts index 4383cbd..88a694b 100644 --- a/polygerrit-ui/app/utils/async-util_test.ts +++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -32,7 +32,11 @@ suite('timeoutPromise', () => { let clock: SinonFakeTimers; setup(() => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); + }); + + teardown(() => { + clock.restore(); }); test('simple test', async () => { let resolved = false; @@ -77,7 +81,11 @@ suite('DelayedPromise', () => { let clock: SinonFakeTimers; setup(() => { - clock = sinon.useFakeTimers(); + clock = sinon.useFakeTimers({shouldClearNativeTimers: true}); + }); + + teardown(() => { + clock.restore(); }); test('It resolves after timeout', async () => { @@ -91,7 +99,8 @@ hasResolved = true; assert.equal(value, 5); }); - promise.catch((_reason?: any) => { + + promise.catch((_reason?: unknown) => { assert.fail(); }); await waitEventLoop(); @@ -119,7 +128,8 @@ hasResolved = true; assert.equal(value, 5); }); - promise.catch((_reason?: any) => { + + promise.catch((_reason?: unknown) => { assert.fail(); }); promise.flush(); @@ -137,13 +147,16 @@ 100 ); let hasCanceled = false; - promise.then((_value: number) => { - assert.fail(); - }); - promise.catch((reason?: any) => { - hasCanceled = true; - assert.strictEqual(reason, 'because'); - }); + promise.then( + (_value: number) => { + assert.fail(); + }, + + (reason?: unknown) => { + hasCanceled = true; + assert.strictEqual(reason, 'because'); + } + ); await waitEventLoop(); assert.isFalse(hasCanceled); promise.cancel('because'); @@ -165,7 +178,8 @@ hasResolved1 = true; assert.equal(value, 6); }); - promise1.catch((_reason?: any) => { + + promise1.catch((_reason?: unknown) => { assert.fail(); }); await waitEventLoop(); @@ -182,7 +196,8 @@ hasResolved2 = true; assert.equal(value, 6); }); - promise2.catch((_reason?: any) => { + + promise2.catch((_reason?: unknown) => { assert.fail(); }); clock.tick(99); @@ -209,7 +224,8 @@ hasResolved1 = true; assert.equal(value, 5); }); - promise1.catch((_reason?: any) => { + + promise1.catch((_reason?: unknown) => { assert.fail(); }); await waitEventLoop(); @@ -228,7 +244,7 @@ hasResolved2 = true; assert.equal(value, 6); }); - promise2.catch((_reason?: any) => { + promise2.catch((_reason?: unknown) => { assert.fail(); }); clock.tick(99);
diff --git a/polygerrit-ui/app/utils/autocomplete-cache_test.ts b/polygerrit-ui/app/utils/autocomplete-cache_test.ts index 970436b..9aca3ec 100644 --- a/polygerrit-ui/app/utils/autocomplete-cache_test.ts +++ b/polygerrit-ui/app/utils/autocomplete-cache_test.ts
@@ -3,6 +3,7 @@ * Copyright 2024 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import '../test/common-test-setup'; import {AutocompleteCache} from './autocomplete-cache'; import {assert} from '@open-wc/testing';
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts index 1a6f8d2..446c098 100644 --- a/polygerrit-ui/app/utils/change-util.ts +++ b/polygerrit-ui/app/utils/change-util.ts
@@ -89,17 +89,22 @@ ): ChangeStates[] { const states: ChangeStates[] = []; + if (change.is_private) { + states.push(ChangeStates.PRIVATE); + } + if (change.status === ChangeStatus.MERGED) { + states.push(ChangeStates.MERGED); if (options?.revertingChangeStatus === ChangeStatus.MERGED) { - return [ChangeStates.MERGED, ChangeStates.REVERT_SUBMITTED]; + states.push(ChangeStates.REVERT_SUBMITTED); + } else if (options?.revertingChangeStatus !== undefined) { + states.push(ChangeStates.REVERT_CREATED); } - if (options?.revertingChangeStatus !== undefined) { - return [ChangeStates.MERGED, ChangeStates.REVERT_CREATED]; - } - return [ChangeStates.MERGED]; + return states; } if (change.status === ChangeStatus.ABANDONED) { - return [ChangeStates.ABANDONED]; + states.push(ChangeStates.ABANDONED); + return states; } if (change.revert_of) { @@ -114,9 +119,6 @@ if (change.work_in_progress) { states.push(ChangeStates.WIP); } - if (change.is_private) { - states.push(ChangeStates.PRIVATE); - } // The gr-change-list table does not want READY TO SUBMIT or ACTIVE and it // does not pass options.
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts index b17cc1c..45762a7 100644 --- a/polygerrit-ui/app/utils/change-util_test.ts +++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -83,6 +83,15 @@ assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]); }); + test('Git conflict', () => { + const change = { + ...createChangeWithStatus(ChangeStatus.NEW, true), + contains_git_conflicts: true, + }; + const statuses = changeStatuses(change); + assert.deepEqual(statuses, [ChangeStates.GIT_CONFLICT]); + }); + test('Merge conflict', () => { const change = createChangeWithStatus(ChangeStatus.NEW, false); const statuses = changeStatuses(change); @@ -99,9 +108,15 @@ const change = createChangeWithStatus(ChangeStatus.MERGED); assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]); change.is_private = true; - assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]); + assert.deepEqual(changeStatuses(change), [ + ChangeStates.PRIVATE, + ChangeStates.MERGED, + ]); change.work_in_progress = true; - assert.deepEqual(changeStatuses(change), [ChangeStates.MERGED]); + assert.deepEqual(changeStatuses(change), [ + ChangeStates.PRIVATE, + ChangeStates.MERGED, + ]); }); test('Merged and Reverted status', () => { @@ -132,9 +147,15 @@ const change = createChangeWithStatus(ChangeStatus.ABANDONED, false); assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]); change.is_private = true; - assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]); + assert.deepEqual(changeStatuses(change), [ + ChangeStates.PRIVATE, + ChangeStates.ABANDONED, + ]); change.work_in_progress = true; - assert.deepEqual(changeStatuses(change), [ChangeStates.ABANDONED]); + assert.deepEqual(changeStatuses(change), [ + ChangeStates.PRIVATE, + ChangeStates.ABANDONED, + ]); }); test('Revert status', () => { @@ -145,8 +166,8 @@ assert.deepEqual(changeStatuses(change), [ChangeStates.REVERT]); change.is_private = true; assert.deepEqual(changeStatuses(change), [ - ChangeStates.REVERT, ChangeStates.PRIVATE, + ChangeStates.REVERT, ]); }); @@ -174,7 +195,7 @@ labels: {}, }; const statuses = changeStatuses(change); - assert.deepEqual(statuses, [ChangeStates.WIP, ChangeStates.PRIVATE]); + assert.deepEqual(statuses, [ChangeStates.PRIVATE, ChangeStates.WIP]); }); test('Merge conflict with private and wip', () => { @@ -190,12 +211,48 @@ }; const statuses = changeStatuses(change); assert.deepEqual(statuses, [ + ChangeStates.PRIVATE, ChangeStates.MERGE_CONFLICT, ChangeStates.WIP, - ChangeStates.PRIVATE, ]); }); + test('Private prevents READY_TO_SUBMIT', () => { + const change = { + ...createChange(), + status: ChangeStatus.NEW, + mergeable: true, + is_private: true, + submittable: true, + }; + const statuses = changeStatuses(change, {mergeable: true}); + assert.deepEqual(statuses, [ChangeStates.PRIVATE]); + }); + + test('WIP prevents READY_TO_SUBMIT', () => { + const change = { + ...createChange(), + status: ChangeStatus.NEW, + mergeable: true, + work_in_progress: true, + submittable: true, + }; + const statuses = changeStatuses(change, {mergeable: true}); + assert.deepEqual(statuses, [ChangeStates.WIP]); + }); + + test('Git conflict prevents READY_TO_SUBMIT', () => { + const change = { + ...createChange(), + status: ChangeStatus.NEW, + mergeable: true, + contains_git_conflicts: true, + submittable: true, + }; + const statuses = changeStatuses(change, {mergeable: true}); + assert.deepEqual(statuses, [ChangeStates.GIT_CONFLICT]); + }); + test('hasHumanReviewer', () => { const owner = createAccountWithId(1); const change = {
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts index c618e28..4b0c1ab 100644 --- a/polygerrit-ui/app/utils/comment-util.ts +++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -709,6 +709,23 @@ return output; } +export function computeDisplayLine(excludePath: { + line?: number | string; + range?: CommentRange; + path?: string; +}) { + if (excludePath.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) return ''; + if (excludePath.line === FILE) return FILE; + if (excludePath.line) return `#${excludePath.line}`; + if (excludePath.range) { + // If the range is wrong, we display the start line. Happens to AI generated comments. + if (excludePath.range.end_line < excludePath.range.start_line) + return `#${excludePath.range.start_line}`; + return `#${excludePath.range.end_line}`; + } + return ''; +} + export function isFileLevelComment(comment: Comment) { return !comment.line && !comment.range; }
diff --git a/polygerrit-ui/app/utils/comment-util_test.ts b/polygerrit-ui/app/utils/comment-util_test.ts index be99553..e8fe606 100644 --- a/polygerrit-ui/app/utils/comment-util_test.ts +++ b/polygerrit-ui/app/utils/comment-util_test.ts
@@ -5,6 +5,7 @@ */ import '../test/common-test-setup'; import { + computeDisplayLine, createCommentThreads, createNew, createUserFixSuggestion, @@ -687,4 +688,64 @@ assert.deepEqual(comments[1].range, comments[0].range); }); }); + suite('computeDisplayLine', () => { + test('PatchSetLevel', () => { + assert.equal( + computeDisplayLine({ + path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, + }), + '' + ); + }); + + test('FILE', () => { + assert.equal( + computeDisplayLine({ + line: 'FILE', + }), + 'FILE' + ); + }); + + test('line number', () => { + assert.equal( + computeDisplayLine({ + line: 123, + }), + '#123' + ); + }); + + test('range', () => { + assert.equal( + computeDisplayLine({ + range: { + start_line: 1, + start_character: 1, + end_line: 10, + end_character: 10, + }, + }), + '#10' + ); + }); + + test('invalid range', () => { + assert.equal( + computeDisplayLine({ + range: { + start_line: 12, + start_character: 1, + end_line: 1, + end_character: 10, + }, + }), + '#12' + ); + }); + + test('empty', () => { + assert.equal(computeDisplayLine({}), ''); + }); + }); });
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts index b14c856..4b8995f 100644 --- a/polygerrit-ui/app/utils/common-util.ts +++ b/polygerrit-ui/app/utils/common-util.ts
@@ -182,3 +182,10 @@ export function uuid() { return Math.random().toString(36).substring(2); } + +/** + * Produces strings such as `36b8f84d-df4e-4d49-b662-bcde71a8764f`. + */ +export function cryptoUuid() { + return crypto.randomUUID(); +}
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts index a078b1d..7bb8d36 100644 --- a/polygerrit-ui/app/utils/date-util.ts +++ b/polygerrit-ui/app/utils/date-util.ts
@@ -19,6 +19,10 @@ return new Date(dateStr.replace(' ', 'T') + 'Z'); } +export function dateToTimestamp(date: Date): Timestamp { + return date.toISOString().replace('T', ' ').replace('Z', '') as Timestamp; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isValidDate(date: any): date is Date { return date instanceof Date && !isNaN(date.valueOf());
diff --git a/polygerrit-ui/app/utils/deep-util_test.ts b/polygerrit-ui/app/utils/deep-util_test.ts index 9e04fa1..49ed2f5 100644 --- a/polygerrit-ui/app/utils/deep-util_test.ts +++ b/polygerrit-ui/app/utils/deep-util_test.ts
@@ -103,9 +103,11 @@ test('deepEqual recursive', () => { const a = {}; const b = {a}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (a as any)['b'] = b; const c = {}; const d = {a: c}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (c as any)['b'] = d; assert.isTrue(deepEqual(a, c)); @@ -139,8 +141,10 @@ test('deepEqual direct self recursion', () => { const a = {value: 3}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (a as any).self = a; const b = {value: 3}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (b as any).self = b; assert.isTrue(deepEqual(a, b)); @@ -156,9 +160,10 @@ }); test('deepEqual recursively deeper', () => { - const a: {link?: any} = {}; - const b: {link?: any} = {}; - const c: {link?: any} = {}; + const a: {link?: unknown} = {}; + + const b: {link?: unknown} = {}; + const c: {link?: unknown} = {}; a.link = b; b.link = c; c.link = a;
diff --git a/polygerrit-ui/app/utils/diff-util.ts b/polygerrit-ui/app/utils/diff-util.ts index f6b00d4..b6b51a0 100644 --- a/polygerrit-ui/app/utils/diff-util.ts +++ b/polygerrit-ui/app/utils/diff-util.ts
@@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ import {Side} from '../constants/constants'; -import {DiffInfo} from '../types/diff'; +import {DiffInfo, SkipInfo, SkipObject} from '../types/diff'; + +export function normalizeSkipInfo(skip: SkipInfo | undefined): SkipObject { + if (!skip) return {left: 0, right: 0}; + return typeof skip === 'number' ? {left: skip, right: skip} : skip; +} export function otherSide(side: Side) { return side === Side.LEFT ? Side.RIGHT : Side.LEFT; @@ -14,7 +19,12 @@ 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); + return ( + sum + + (sideChunk?.length ?? + chunk.ab?.length ?? + normalizeSkipInfo(chunk.skip)[side]) + ); }, 0); } @@ -27,7 +37,7 @@ let currentLine = 0; for (const chunk of diff.content) { if (chunk.skip) { - currentLine += chunk.skip; + currentLine += normalizeSkipInfo(chunk.skip)[side]; if (currentLine >= line) return false; } else if (chunk.ab) { currentLine += chunk.ab.length; @@ -51,7 +61,8 @@ let lines: string[] = []; for (const chunk of diff.content) { if (chunk.skip) { - lines = lines.concat(Array(chunk.skip).fill('')); + const skip = normalizeSkipInfo(chunk.skip); + lines = lines.concat(Array(skip[side]).fill('')); } else if (chunk.ab) { lines = lines.concat(chunk.ab); } else if (side === Side.LEFT && chunk.a) {
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts index fc527df..af35b04 100644 --- a/polygerrit-ui/app/utils/dom-util.ts +++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -457,7 +457,8 @@ (tagName === 'A' || tagName === 'BUTTON' || tagName === 'GR-BUTTON' || - tagName === 'PAPER-TAB')) + tagName === 'MD-PRIMARY-TAB' || + tagName === 'MD-SECONDARY-TAB')) ) { return true; }
diff --git a/polygerrit-ui/app/utils/flows-util.ts b/polygerrit-ui/app/utils/flows-util.ts new file mode 100644 index 0000000..cb069ee --- /dev/null +++ b/polygerrit-ui/app/utils/flows-util.ts
@@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FlowStageInfo} from '../api/rest-api'; +import {capitalizeFirstLetter} from './string-util'; + +export const STAGE_SEPARATOR = ';'; + +export interface Stage { + condition: string; + action: string; + parameterStr: string; +} + +export function computeFlowString(stages: Stage[]) { + const stageToString = (stage: Stage) => { + if (stage.action) { + if (stage.parameterStr) { + return `${stage.condition} -> ${stage.action} ${stage.parameterStr}`; + } + return `${stage.condition} -> ${stage.action}`; + } + return stage.condition; + }; + return stages.map(stageToString).join(STAGE_SEPARATOR); +} + +export function computeFlowStringFromFlowStageInfo(stages: FlowStageInfo[]) { + return computeFlowString( + stages.map(s => { + return { + condition: s.expression.condition, + action: s.expression.action?.name ?? '', + parameterStr: s.expression.action?.parameters?.join(' ') ?? '', + }; + }) + ); +} + +/** + * Formats a flow action name for display. + * Converts snake_case (e.g., 'add_reviewer') to Title Case (e.g., 'Add Reviewer'). + */ +export function formatActionName(name?: string): string { + if (!name) return ''; + return name + .split('_') + .map(word => capitalizeFirstLetter(word)) + .join(' '); +}
diff --git a/polygerrit-ui/app/utils/flows-util_test.ts b/polygerrit-ui/app/utils/flows-util_test.ts new file mode 100644 index 0000000..ed9a9ca --- /dev/null +++ b/polygerrit-ui/app/utils/flows-util_test.ts
@@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import '../test/common-test-setup'; +import {assert} from '@open-wc/testing'; +import {computeFlowString, Stage} from './flows-util'; + +suite('flows-util tests', () => { + suite('computeFlowString', () => { + test('empty stages', () => { + const stages: Stage[] = []; + assert.equal(computeFlowString(stages), ''); + }); + + test('single stage with condition only', () => { + const stages: Stage[] = [ + {condition: 'cond 1', action: '', parameterStr: ''}, + ]; + assert.equal(computeFlowString(stages), 'cond 1'); + }); + + test('single stage with condition and action', () => { + const stages: Stage[] = [ + {condition: 'cond 1', action: 'act-1', parameterStr: ''}, + ]; + assert.equal(computeFlowString(stages), 'cond 1 -> act-1'); + }); + + test('single stage with condition, action, and params', () => { + const stages: Stage[] = [ + {condition: 'cond 1', action: 'act-1', parameterStr: 'param1 param2'}, + ]; + assert.equal(computeFlowString(stages), 'cond 1 -> act-1 param1 param2'); + }); + + test('multiple stages', () => { + const stages: Stage[] = [ + {condition: 'cond 1', action: 'act-1', parameterStr: ''}, + {condition: 'cond 2', action: 'act-2', parameterStr: 'p2'}, + {condition: 'cond 3', action: '', parameterStr: ''}, + ]; + assert.equal( + computeFlowString(stages), + 'cond 1 -> act-1;cond 2 -> act-2 p2;cond 3' + ); + }); + }); +});
diff --git a/polygerrit-ui/app/utils/focusable_test.ts b/polygerrit-ui/app/utils/focusable_test.ts index ac900e1..53ae950 100644 --- a/polygerrit-ui/app/utils/focusable_test.ts +++ b/polygerrit-ui/app/utils/focusable_test.ts
@@ -36,6 +36,7 @@ // For some reason Typescript doesn't know about the `assign` method on // HTMLSlotElement. // + // eslint-disable-next-line @typescript-eslint/no-explicit-any (slot! as any).assign(slottedContent); const moreShadow = shadow .querySelector('#moreshadow')!
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts index 3590119..7b01994 100644 --- a/polygerrit-ui/app/utils/inner-html-util.ts +++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -9,6 +9,7 @@ import {BrandType} from '../types/common'; export {sanitizeHtml, htmlEscape, sanitizeHtmlToFragment} from 'safevalues'; +export {setElementInnerHtml} from 'safevalues/dom'; export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
diff --git a/polygerrit-ui/app/utils/link-util_test.ts b/polygerrit-ui/app/utils/link-util_test.ts index 1574b3e..7b09320 100644 --- a/polygerrit-ui/app/utils/link-util_test.ts +++ b/polygerrit-ui/app/utils/link-util_test.ts
@@ -3,6 +3,7 @@ * Copyright 2022 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import '../test/common-test-setup'; import {linkifyUrlsAndApplyRewrite} from './link-util'; import {assert} from '@open-wc/testing';
diff --git a/polygerrit-ui/app/utils/message-util_test.ts b/polygerrit-ui/app/utils/message-util_test.ts index bed9376..052c6a5 100644 --- a/polygerrit-ui/app/utils/message-util_test.ts +++ b/polygerrit-ui/app/utils/message-util_test.ts
@@ -3,6 +3,7 @@ * Copyright 2023 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import '../test/common-test-setup'; import { getCodeReviewVotesFromMessage, getRevertCreatedChangeIds,
diff --git a/polygerrit-ui/app/utils/periodic-update-util.ts b/polygerrit-ui/app/utils/periodic-update-util.ts new file mode 100644 index 0000000..8058b18 --- /dev/null +++ b/polygerrit-ui/app/utils/periodic-update-util.ts
@@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface Updatable { + requestUpdate(): void; +} + +export class PeriodicUpdateManager<T extends Updatable = Updatable> { + readonly components = new Set<T>(); + + refreshTimer: ReturnType<typeof setInterval> | null = null; + + constructor(private readonly refreshIntervalMs: number) {} + + register(component: T) { + this.components.add(component); + if (this.refreshTimer === null) { + this.refreshTimer = setInterval(() => { + for (const c of this.components) { + c.requestUpdate(); + } + }, this.refreshIntervalMs); + } + } + + unregister(component: T) { + this.components.delete(component); + if (this.components.size === 0 && this.refreshTimer !== null) { + clearInterval(this.refreshTimer); + this.refreshTimer = null; + } + } + + // visible for testing + _testOnly_getRefreshTimer() { + return this.refreshTimer; + } +}
diff --git a/polygerrit-ui/app/utils/periodic-update-util_test.ts b/polygerrit-ui/app/utils/periodic-update-util_test.ts new file mode 100644 index 0000000..eb8f991 --- /dev/null +++ b/polygerrit-ui/app/utils/periodic-update-util_test.ts
@@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import * as sinon from 'sinon'; +import '../test/common-test-setup'; +import {PeriodicUpdateManager, Updatable} from './periodic-update-util'; +import {assert} from '@open-wc/testing'; + +suite('periodic-update-util tests', () => { + class TestElement implements Updatable { + requestUpdate() {} + } + + let manager: PeriodicUpdateManager; + + setup(() => { + manager = new PeriodicUpdateManager(60 * 60 * 1000); + }); + + teardown(() => { + if (manager._testOnly_getRefreshTimer() !== null) { + clearInterval(manager._testOnly_getRefreshTimer()!); + manager.refreshTimer = null; + } + manager.components.clear(); + }); + + test('register and unregister', () => { + const component = new TestElement(); + assert.equal(manager.components.size, 0); + assert.isNull(manager._testOnly_getRefreshTimer()); + + manager.register(component); + assert.equal(manager.components.size, 1); + assert.isNotNull(manager._testOnly_getRefreshTimer()); + + manager.unregister(component); + assert.equal(manager.components.size, 0); + assert.isNull(manager._testOnly_getRefreshTimer()); + }); + + test('timer calls requestUpdate', () => { + const component = new TestElement(); + const requestUpdateStub = sinon.stub(component, 'requestUpdate'); + const clock = sinon.useFakeTimers(); + manager.register(component); + + assert.isFalse(requestUpdateStub.called); + + clock.tick(60 * 60 * 1000); + assert.isTrue(requestUpdateStub.calledOnce); + + clock.tick(60 * 60 * 1000); + assert.isTrue(requestUpdateStub.calledTwice); + + clock.restore(); + }); +});
diff --git a/polygerrit-ui/app/workers/service-worker-class_test.ts b/polygerrit-ui/app/workers/service-worker-class_test.ts index 5368911..fd36168 100644 --- a/polygerrit-ui/app/workers/service-worker-class_test.ts +++ b/polygerrit-ui/app/workers/service-worker-class_test.ts
@@ -24,7 +24,11 @@ registration: { showNotification: () => {}, }, - } as {} as ServiceWorkerGlobalScope; + clients: { + matchAll: () => Promise.resolve([]), + openWindow: () => Promise.resolve(undefined), + }, + } as unknown as ServiceWorkerGlobalScope; serviceWorker = new ServiceWorker(moctCtx); serviceWorker.allowBrowserNotificationsPreference = true; }); @@ -44,12 +48,16 @@ }, }, }; - sinon.useFakeTimers(t3); + const clock = sinon.useFakeTimers({ + now: t3, + shouldClearNativeTimers: true, + }); sinon .stub(serviceWorker, 'getLatestAttentionSetChanges') .returns(Promise.resolve([change])); const changes = await serviceWorker.getChangesToNotify(account); assert.equal(changes[0], change); + clock.restore(); }); test('check race condition', async () => {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock index 96881d0..c67ebb4 100644 --- a/polygerrit-ui/app/yarn.lock +++ b/polygerrit-ui/app/yarn.lock
@@ -14,25 +14,10 @@ dependencies: "@lit-labs/ssr-dom-shim" "^1.4.0" -"@mapbox/node-pre-gyp@^1.0.0": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" - integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== - dependencies: - detect-libc "^2.0.0" - https-proxy-agent "^5.0.0" - make-dir "^3.1.0" - node-fetch "^2.6.7" - nopt "^5.0.0" - npmlog "^5.0.1" - rimraf "^3.0.2" - semver "^7.3.5" - tar "^6.1.11" - -"@material/web@^2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@material/web/-/web-2.4.0.tgz#4158df5afa2a36f55db5a5b2d77146cf6bdc84a6" - integrity sha512-2jYiPIYOuP2UcWXal4VdKRlkpBX02U3rgXMWp0yFQbJaOmK8MOIA3BsyuooM3VOYI+VCO70BeH1x43y5cBLvZQ== +"@material/web@^2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@material/web/-/web-2.4.1.tgz#afd629ba350cf9485c3e19a7bfeb0476f02a0ec1" + integrity sha512-0sk9t25acJ72Qv3r0n9r0lgDbPaAKnpm0p+QmEAAwYyZomHxuVbgrrAdtNXaRm7jFyGh+WsTr8bhtvCnpPRFjw== dependencies: lit "^2.8.0 || ^3.0.0" tslib "^2.4.0" @@ -49,112 +34,7 @@ resolved "https://registry.yarnpkg.com/@polymer/font-roboto-local/-/font-roboto-local-3.0.2.tgz#563cd6cabbcaef54999d654c0f3d476bcc49ce58" integrity sha512-mCd9TcjwnCxU+7uVHCkbREGU+OmzStvYh3ru5DSaftOQDnMrLAzernEv/QCcfSPRgTMHij+pIUN4tcaGeDGcYg== -"@polymer/font-roboto@^3.0.1": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@polymer/font-roboto/-/font-roboto-3.0.2.tgz#80cdaa7225db2359130dfb2c6d9a3be1820020c3" - integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA== - -"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-keys-behavior/-/iron-a11y-keys-behavior-3.0.1.tgz#2868ea72912d2007ffab4734684a91f5aac49b84" - integrity sha512-lnrjKq3ysbBPT/74l0Fj0U9H9C35Tpw2C/tpJ8a+5g8Y3YJs1WSZYnEl1yOkw6sEyaxOq/1DkzH0+60gGu5/PQ== - dependencies: - "@polymer/polymer" "^3.0.0" - -"@polymer/iron-behaviors@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-behaviors/-/iron-behaviors-3.0.1.tgz#a3b6f876779a7f0a91a15e4423890968b6525901" - integrity sha512-IMEwcv1lhf1HSQxuyWOUIL0lOBwmeaoSTpgCJeP9IBYnuB1SPQngmfRuHKgK6/m9LQ9F9miC7p3HeQQUdKAE0w== - dependencies: - "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/iron-checked-element-behavior@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-checked-element-behavior/-/iron-checked-element-behavior-3.0.1.tgz#7a4b49646603657ab2c5e5ca7bd97f34444fdaf5" - integrity sha512-aDr0cbCNVq49q+pOqa6CZutFh+wWpwPMLpEth9swx+GkAj+gCURhuQkaUYhIo5f2egDbEioR1aeHMnPlU9dQZA== - dependencies: - "@polymer/iron-form-element-behavior" "^3.0.0-pre.26" - "@polymer/iron-validatable-behavior" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/iron-flex-layout@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-flex-layout/-/iron-flex-layout-3.0.1.tgz#36f9e1a8eb792d279b2bc75d362628721ad37f0c" - integrity sha512-7gB869czArF+HZcPTVSgvA7tXYFze9EKckvM95NB7SqYF+NnsQyhoXgKnpFwGyo95lUjUW9TFDLUwDXnCYFtkw== - dependencies: - "@polymer/polymer" "^3.0.0" - -"@polymer/iron-form-element-behavior@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-form-element-behavior/-/iron-form-element-behavior-3.0.1.tgz#4c79e1981d7796ce659e997f3b8f5e14b4a075a4" - integrity sha512-G/e2KXyL5AY7mMjmomHkGpgS0uAf4ovNpKhkuUTRnMuMJuf589bKqE85KN4ovE1Tzhv2hJoh/igyD6ekHiYU1A== - dependencies: - "@polymer/polymer" "^3.0.0" - -"@polymer/iron-meta@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-meta/-/iron-meta-3.0.1.tgz#7f140628d127b0a284f882f1bb323a261bc125f5" - integrity sha512-pWguPugiLYmWFV9UWxLWzZ6gm4wBwQdDy4VULKwdHCqR7OP7u98h+XDdGZsSlDPv6qoryV/e3tGHlTIT0mbzJA== - dependencies: - "@polymer/polymer" "^3.0.0" - -"@polymer/iron-validatable-behavior@^3.0.0-pre.26": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/iron-validatable-behavior/-/iron-validatable-behavior-3.0.1.tgz#73538f005a07741c31b6fc1e981168c3d3e0d92b" - integrity sha512-wwpYh6wOa4fNI+jH5EYKC7TVPYQ2OfgQqocWat7GsNWcsblKYhLYbwsvEY5nO0n2xKqNfZzDLrUom5INJN7msQ== - dependencies: - "@polymer/iron-meta" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/paper-behaviors@^3.0.0-pre.27": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/paper-behaviors/-/paper-behaviors-3.0.1.tgz#83f1cd06489f484c1b108a2967fb01952df722ad" - integrity sha512-6knhj69fPJejv8qR0kCSUY+Q0XjaUf0OSnkjRjmTJPAwSrRYtgqE+l6P1FfA+py1X/cUjgne9EF5rMZAKJIg1g== - dependencies: - "@polymer/iron-behaviors" "^3.0.0-pre.26" - "@polymer/iron-checked-element-behavior" "^3.0.0-pre.26" - "@polymer/paper-ripple" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/paper-button@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/paper-button/-/paper-button-3.0.1.tgz#f13b019137e3f6ccc4d04d0b1f27f4830ea5774d" - integrity sha512-JRNBc+Oj9EWnmyLr7FcCr8T1KAnEHPh6mosln9BUdkM+qYaYsudSICh3cjTIbnj6AuF5OJidoLkM1dlyj0j6Zg== - dependencies: - "@polymer/iron-flex-layout" "^3.0.0-pre.26" - "@polymer/paper-behaviors" "^3.0.0-pre.27" - "@polymer/paper-styles" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/paper-item@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/paper-item/-/paper-item-3.0.1.tgz#05b3543483e556cd5532431cd1751a84343989b5" - integrity sha512-KTk2N+GsYiI/HuubL3sxebZ6tteQbBOAp4QVLAnbjSPmwl+mJSDWk+omuadesU0bpkCwaWVs3fHuQsmXxy4pkw== - dependencies: - "@polymer/iron-behaviors" "^3.0.0-pre.26" - "@polymer/iron-flex-layout" "^3.0.0-pre.26" - "@polymer/paper-styles" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/paper-ripple@^3.0.0-pre.26": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@polymer/paper-ripple/-/paper-ripple-3.0.2.tgz#52566f5ee367942022ceaa991368105d21403de5" - integrity sha512-DnLNvYIMsiayeICroYxx6Q6Hg1cUU8HN2sbutXazlemAlGqdq80qz3TIaVdbpbt/pvjcFGX2HtntMlPstCge8Q== - dependencies: - "@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/paper-styles@^3.0.0-pre.26", "@polymer/paper-styles@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@polymer/paper-styles/-/paper-styles-3.0.1.tgz#bd4962b83ab8432cd1cf197bb5222d3a08f135e1" - integrity sha512-y6hmObLqlCx602TQiSBKHqjwkE7xmDiFkoxdYGaNjtv4xcysOTdVJsDR/R9UHwIaxJ7gHlthMSykir1nv78++g== - dependencies: - "@polymer/font-roboto" "^3.0.1" - "@polymer/iron-flex-layout" "^3.0.0-pre.26" - "@polymer/polymer" "^3.0.0" - -"@polymer/polymer@3.5.2", "@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5": +"@polymer/polymer@3.5.2", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.0.5": version "3.5.2" resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.5.2.tgz#af0e7e13976df53ace6728e841121c36aca351de" integrity sha512-fWwImY/UH4bb2534DVSaX+Azs2yKg8slkMBHOyGeU2kKx7Xmxp6Lee0jP8p6B3d7c1gFUPB2Z976dTUtX81pQA== @@ -186,150 +66,79 @@ resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.8.0.tgz#ab21f027594fa827c1889e8b646da7be27c7908a" integrity sha512-loGD63sacRzOzSJgQnB9ZAhaQGkN7wl2Zuw7tsphI5Isa0irijrRo6EnJii/GgjGefIFO8AIO7UivzRhFaEk9w== -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +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== -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== +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: - debug "4" + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -"aproba@^1.0.3 || ^2.0.0": - version "2.1.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.1.0.tgz#75500a190313d95c64e871e7e4284c6ac219f0b1" - integrity sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew== - -are-we-there-yet@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" - integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== +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: - delegates "^1.0.0" - readable-stream "^3.6.0" + base64-js "^1.3.1" + ieee754 "^1.1.13" -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== - -brace-expansion@^1.1.7: - version "1.1.12" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" - integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== +canvas@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-3.2.0.tgz#877c51aabdb99cbb5b2b378138a6cdd681e9d390" + integrity sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA== dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" + node-addon-api "^7.0.0" + prebuild-install "^7.1.3" -canvas@2.11.2: - version "2.11.2" - resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860" - integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw== +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== + +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== dependencies: - "@mapbox/node-pre-gyp" "^1.0.0" - nan "^2.17.0" - simple-get "^3.0.3" + mimic-response "^3.1.0" -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== - -color-support@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" - integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== - -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== - -console-control-strings@^1.0.0, console-control-strings@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== - -debug@4: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== - dependencies: - ms "^2.1.3" - -decompress-response@^4.2.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" - integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== - dependencies: - mimic-response "^2.0.0" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== +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== detect-libc@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" - integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -fs-minipass@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== +end-of-stream@^1.1.0, end-of-stream@^1.4.1: + version "1.4.5" + resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c" + integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== dependencies: - minipass "^3.0.0" + once "^1.4.0" -fs.realpath@^1.0.0: +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + +fs-constants@^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== + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== -gauge@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" - integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== - dependencies: - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.2" - console-control-strings "^1.0.0" - has-unicode "^2.0.1" - object-assign "^4.1.1" - signal-exit "^3.0.0" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.2" - -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" - -has-unicode@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== highlight.js@^10.4.1: version "10.7.3" @@ -359,40 +168,35 @@ dependencies: highlight.js "^10.4.1" +"highlightjs-ttcn3@https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d": + version "0.0.1" + resolved "https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d" + dependencies: + highlight.js "^11.9.0" + "highlightjs-vue@https://github.com/paladox/highlightjs-vue#44eed074ea0110d1ad03d2cbd77d27027cf7bb04": version "1.1.0" resolved "https://github.com/paladox/highlightjs-vue#44eed074ea0110d1ad03d2cbd77d27027cf7bb04" -https-proxy-agent@^5.0.0: - 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" +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== immer@^9.0.21: version "9.0.21" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== -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, inherits@^2.0.3: +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== -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +ini@~1.3.0: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== lit-element@^4.2.0: version "4.2.1" @@ -419,106 +223,50 @@ lit-element "^4.2.0" lit-html "^3.3.0" -make-dir@^3.1.0: +marked@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/marked/-/marked-17.0.1.tgz#9db34197ac145e5929572ee49ef701e37ee9b2e6" + integrity sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg== + +mimic-response@^3.1.0: version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + +minimist@^1.2.0, minimist@^1.2.3: + 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, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + +napi-build-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz#13c22c0187fcfccce1461844136372a47ddc027e" + integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + +node-abi@^3.3.0: + version "3.85.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.85.0.tgz#b115d575e52b2495ef08372b058e13d202875a7d" + integrity sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg== dependencies: - semver "^6.0.0" + semver "^7.3.5" -marked@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/marked/-/marked-0.5.2.tgz#3efdb27b1fd0ecec4f5aba362bddcd18120e5ba9" - integrity sha512-fdZvBa7/vSQIZCi4uuwo2N3q+7jJURpMVCcbaX0S1Mg65WZ5ilXvC67MviJAsdjqqgD+CEq4RKo5AYGgINkVAA== +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== -mimic-response@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" - integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== - -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" - -minipass@^3.0.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" - integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== - dependencies: - yallist "^4.0.0" - -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - -minizlib@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mkdirp@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -nan@^2.17.0: - version "2.23.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.23.0.tgz#24aa4ddffcc37613a2d2935b97683c1ec96093c6" - integrity sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ== - -node-fetch@^2.6.7: - version "2.7.0" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" - integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== - dependencies: - whatwg-url "^5.0.0" - -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - -npmlog@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" - integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== - dependencies: - are-we-there-yet "^2.0.0" - console-control-strings "^1.1.0" - gauge "^3.0.0" - set-blocking "^2.0.0" - -object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -once@^1.3.0, once@^1.3.1: +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" -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== - "polymer-bridges@file:../../polymer-bridges": version "1.0.0" @@ -530,7 +278,43 @@ "@polymer/polymer" "^3.0.2" "@webcomponents/webcomponentsjs" "^2.0.3" -readable-stream@^3.6.0: +prebuild-install@^7.1.3: + version "7.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.3.tgz#d630abad2b147443f20a212917beae68b8092eec" + integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^2.0.0" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + +pump@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d" + integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA== + dependencies: + end-of-stream "^1.1.0" + once "^1.3.1" + +rc@^1.2.7: + version "1.2.8" + resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" + integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== + dependencies: + deep-extend "^0.6.0" + ini "~1.3.0" + minimist "^1.2.0" + strip-json-comments "~2.0.1" + +readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -539,19 +323,11 @@ string_decoder "^1.1.1" util-deprecate "^1.0.1" -resemblejs@^5.0.0: +resemblejs@rsmbl/Resemble.js#66a55c5bfc3bda2303ad632ee8ce3c727b415917: version "5.0.0" - resolved "https://registry.yarnpkg.com/resemblejs/-/resemblejs-5.0.0.tgz#f5a0c6aaa59dcfb9f5192e7ab8740616cbbbf220" - integrity sha512-+B0eP9k9VDP/YhBbH+ZdYmHiotdtuc6blVI+h8wwkY2cOow+uiIpSmgkBBBtrEAL0D31/gR/AJPwDeX5TcwmIA== + resolved "https://codeload.github.com/rsmbl/Resemble.js/tar.gz/66a55c5bfc3bda2303ad632ee8ce3c727b415917" optionalDependencies: - canvas "2.11.2" - -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" + canvas "3.2.0" rxjs@^6.6.7: version "6.6.7" @@ -560,7 +336,7 @@ dependencies: tslib "^1.9.0" -safe-buffer@~5.2.0: +safe-buffer@^5.0.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== @@ -570,49 +346,25 @@ resolved "https://registry.yarnpkg.com/safevalues/-/safevalues-1.2.0.tgz#f9e646d6ebf31788004ef192d2a7d646c9896bb2" integrity sha512-zIsuhjYvJCjfsfjoim2ab6gLKFYAnTiDSJGh0cC3T44L/4kNLL90hBG2BzrXPrHA3f8Ms8FSJ1mljKH5dVR1cw== -semver@^6.0.0: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - semver@^7.3.5: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== - -signal-exit@^3.0.0: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== simple-concat@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== -simple-get@^3.0.3: - version "3.1.1" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55" - integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA== +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== dependencies: - decompress-response "^4.2.0" + decompress-response "^6.0.0" once "^1.3.1" simple-concat "^1.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.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" @@ -620,29 +372,31 @@ dependencies: safe-buffer "~5.2.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" +strip-json-comments@~2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" + integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -tar@^6.1.11: - version "6.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" - integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== +tar-fs@^2.0.0: + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +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" tslib@^1.9.0: version "1.14.1" @@ -654,42 +408,24 @@ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + 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== -web-vitals@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-3.5.2.tgz#5bb58461bbc173c3f00c2ddff8bfe6e680999ca9" - integrity sha512-c0rhqNcHXRkY/ogGDJQxZ9Im9D19hDihbzSQJrsioex+KnFgmMzBiy57Z1EjkhX/+OjyBpclDCzz2ITtjokFmg== - -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== - -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" - -wide-align@^1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" - integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== - dependencies: - string-width "^1.0.2 || 2 || 3 || 4" +web-vitals@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-5.1.0.tgz#2f117e92c8c4eeb107cb163cbb482ac20d685ebd" + integrity sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg== 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== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
diff --git a/polygerrit-ui/screenshots/Chrome/baseline/gr-label-scores-long-trigger-vote-label.png b/polygerrit-ui/screenshots/Chrome/baseline/gr-label-scores-long-trigger-vote-label.png deleted file mode 100644 index f8ddcd8..0000000 --- a/polygerrit-ui/screenshots/Chrome/baseline/gr-label-scores-long-trigger-vote-label.png +++ /dev/null Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png new file mode 100644 index 0000000..3054927 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png new file mode 100644 index 0000000..0ed2eb4 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png new file mode 100644 index 0000000..f2b9ccd --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-citations.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png new file mode 100644 index 0000000..8ff88e4 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png new file mode 100644 index 0000000..74a06c8 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-comment.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png new file mode 100644 index 0000000..22ad904 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png new file mode 100644 index 0000000..2c454b2 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-error.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png new file mode 100644 index 0000000..68831df --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png new file mode 100644 index 0000000..8ac50b1a --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode-with-references.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png new file mode 100644 index 0000000..5cf48ac --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-chat-mode.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png new file mode 100644 index 0000000..c86ef7d --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png new file mode 100644 index 0000000..1212b06 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png new file mode 100644 index 0000000..dff82cb --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history-scrolling.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png new file mode 100644 index 0000000..6b6ac64 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-history.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png new file mode 100644 index 0000000..f548ba7 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png new file mode 100644 index 0000000..a6425b7 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-models-menu-open.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png new file mode 100644 index 0000000..7068925 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png new file mode 100644 index 0000000..9f5f2ee --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-prompt-box-suggested-items.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png new file mode 100644 index 0000000..c6f1614 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png new file mode 100644 index 0000000..fa1e93c --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-custom-actions.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png new file mode 100644 index 0000000..245a38c --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png new file mode 100644 index 0000000..3a7d864 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png new file mode 100644 index 0000000..9436246 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page-private.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png new file mode 100644 index 0000000..1e89cee --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/chat-panel-splash-page.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png new file mode 100644 index 0000000..d85c67c --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png new file mode 100644 index 0000000..7406562 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-summary-with-ai-review-prompt.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png new file mode 100644 index 0000000..79a6c45 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png new file mode 100644 index 0000000..bbdd523 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-1280px-chat-open.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png new file mode 100644 index 0000000..d1a268f --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png new file mode 100644 index 0000000..4d753b1 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-801px.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png new file mode 100644 index 0000000..bf2f836 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-change-view-wrapped-statuses-801px.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png index 2c81cf5..2e46be7 100644 --- a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png index 4af5816..92d1d7e 100644 --- a/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-checks-results.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-dark.png new file mode 100644 index 0000000..c35c6ea --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png new file mode 100644 index 0000000..91a71b4 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-content-with-sidebar.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png new file mode 100644 index 0000000..201edda --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-header-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-header-dark.png new file mode 100644 index 0000000..f135b38 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-header-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-header.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-header.png new file mode 100644 index 0000000..4f8f7ea --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog-header.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png new file mode 100644 index 0000000..5844c3c --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-create-flow-dialog.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png index 5553c10..484426d 100644 --- a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png index d42627f..49002e6 100644 --- a/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-diff-check-result-expanded.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png new file mode 100644 index 0000000..28a2960 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png new file mode 100644 index 0000000..7a7b5b5 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png new file mode 100644 index 0000000..1c793ac --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-empty.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png new file mode 100644 index 0000000..054acc6 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png new file mode 100644 index 0000000..310219c --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-loading.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png new file mode 100644 index 0000000..07d1c36 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png new file mode 100644 index 0000000..32200a5 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-multiple.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png new file mode 100644 index 0000000..24a7544 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png new file mode 100644 index 0000000..948d1b1 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows-not-uploader.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png new file mode 100644 index 0000000..2f308d1 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-flows.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png index 49cbcf6..a26b67f 100644 --- a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png index 4fa6f74..ab28bdf 100644 --- a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-score-row.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png new file mode 100644 index 0000000..a8f0a98 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png new file mode 100644 index 0000000..8847764 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-label-scores-long-trigger-vote-label.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png new file mode 100644 index 0000000..69e0c32 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png new file mode 100644 index 0000000..8dfa9ed --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/gr-reply-dialog-autosubmit.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png new file mode 100644 index 0000000..5decdca --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png new file mode 100644 index 0000000..027888a --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-card.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png new file mode 100644 index 0000000..0762253 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal-dark.png Binary files differ
diff --git a/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png new file mode 100644 index 0000000..bca5f67 --- /dev/null +++ b/polygerrit-ui/screenshots/Chromium/baseline/splash-page-action-details-modal.png Binary files differ
diff --git a/polygerrit-ui/web-test-runner.config.mjs b/polygerrit-ui/web-test-runner.config.mjs index 24cfaf7..893d0fb 100644 --- a/polygerrit-ui/web-test-runner.config.mjs +++ b/polygerrit-ui/web-test-runner.config.mjs
@@ -1,17 +1,22 @@ import path from 'path'; +import fs from 'fs'; import { esbuildPlugin } from '@web/dev-server-esbuild'; import { defaultReporter, summaryReporter } from '@web/test-runner'; import { visualRegressionPlugin } from '@web/test-runner-visual-regression/plugin'; +import pixelmatch from 'pixelmatch'; +import { PNG } from 'pngjs'; import { playwrightLauncher } from '@web/test-runner-playwright'; -function testRunnerHtmlFactory() { +const runUnderBazel = !!process.env['RUNFILES_DIR']; + +function testRunnerHtmlFactory(prefix) { return (testFramework) => ` <!DOCTYPE html> <html> <head> - <link rel="stylesheet" href="polygerrit-ui/app/styles/main.css"> - <link rel="stylesheet" href="polygerrit-ui/app/styles/fonts.css"> - <link rel="stylesheet" href="polygerrit-ui/app/styles/material-icons.css"> + <link rel="stylesheet" href="${prefix}app/styles/main.css"> + <link rel="stylesheet" href="${prefix}app/styles/fonts.css"> + <link rel="stylesheet" href="${prefix}app/styles/material-icons.css"> </head> <body> <script type="module" src="${testFramework}"></script> @@ -20,20 +25,21 @@ `; } -const runUnderBazel = !!process.env['RUNFILES_DIR']; - function getModulesDir() { - return runUnderBazel - ? [ - path.join(process.cwd(), 'external/plugins_npm/node_modules'), - path.join(process.cwd(), 'external/ui_npm/node_modules'), - path.join(process.cwd(), 'external/ui_dev_npm/node_modules'), - ] - : [ - path.join(process.cwd(), 'plugins/node_modules'), - path.join(process.cwd(), 'app/node_modules'), - path.join(process.cwd(), 'node_modules'), - ]; + if (!runUnderBazel) { + return [ + path.join(process.cwd(), 'plugins/node_modules'), + path.join(process.cwd(), 'app/node_modules'), + path.join(process.cwd(), 'node_modules'), + ]; + } + + const runfilesRoot = path.dirname(process.cwd()); + return [ + path.join(runfilesRoot, 'plugins_npm', 'node_modules'), + path.join(runfilesRoot, 'ui_npm', 'node_modules'), + path.join(runfilesRoot, 'ui_dev_npm', 'node_modules'), + ]; } function getArgValue(flag) { @@ -54,6 +60,11 @@ const rootDir = getArgValue('--root-dir') ?? `${path.resolve(process.cwd())}/`; const tsConfig = getArgValue('--ts-config') ?? `${pathPrefix}app/tsconfig.json`; +// When running screenshots, we serve from the root directory, so we need to +// prepend polygerrit-ui/ to the path. +// When running under Bazel, we also need strictly fully qualified paths. +const stylePathPrefix = 'polygerrit-ui/'; + /** @type {import('@web/test-runner').TestRunnerConfig} */ const config = { // Default is CPU cores / 2. Use default @@ -77,28 +88,29 @@ ], files: runScreenshots - ? [ - // If --run-screenshots is set, ONLY run screenshot tests. - testFiles ?? `${pathPrefix}app/**/*_screenshot_test.{ts,js}`, - `!${pathPrefix}**/node_modules/**/*`, - ] - : [ - // Otherwise, run all tests EXCEPT screenshot tests - testFiles ?? `${pathPrefix}app/**/*_test.{ts,js}`, - `!${pathPrefix}**/node_modules/**/*`, - `!${pathPrefix}app/**/*_screenshot_test.{ts,js}`, - ], + ? [ + // If --run-screenshots is set, ONLY run screenshot tests. + testFiles ?? `${pathPrefix}app/**/*_screenshot_test.{ts,js}`, + `!${pathPrefix}**/node_modules/**/*`, + ] + : [ + // Otherwise, run all tests EXCEPT screenshot tests + testFiles ?? `${pathPrefix}app/**/*_test.{ts,js}`, + `!${pathPrefix}**/node_modules/**/*`, + `!${pathPrefix}app/**/*_screenshot_test.{ts,js}`, + ], port: 9876, nodeResolve: { modulePaths: getModulesDir(), + dedupe: ['lit', 'lit-html', 'lit-element'], }, testFramework: { config: { ui: 'tdd', - timeout: 2000, + timeout: runScreenshots ? 10000 : 2000, }, }, @@ -119,15 +131,62 @@ // and failureThreshold is for pixel change. We need to find a balance to allow // CI to pass, but also catch regressions. diffOptions: { threshold: 0.6 }, - failureThreshold: 1, + failureThreshold: 2, failureThresholdType: 'percent', update: process.argv.includes('--update-screenshots'), + // The visual regression plugin by default blindly overwrites all goldens + // when the --update-screenshots flag is used. This modifies file timestamps + // and pollutes version control with identical images or sub-threshold + // aliasing diffs. We intercept the saveBaseline hook to diff the image in + // memory and only save it if the visual diff exceeds our 2% threshold. + saveBaseline: async ({ filePath, content }) => { + let oldContent; + try { + oldContent = await fs.promises.readFile(filePath); + } catch (e) { + // file doesn't exist + } + + if (oldContent) { + if (content.equals(oldContent)) { + return; + } + + let basePng; + let newPng; + try { + basePng = PNG.sync.read(oldContent); + newPng = PNG.sync.read(content); + } catch(e) { + console.warn('Failed to parse PNGs for diff checking', e); + } + + if (basePng && newPng && basePng.width === newPng.width && basePng.height === newPng.height) { + const numDiffPixels = pixelmatch( + basePng.data, + newPng.data, + null, + basePng.width, + basePng.height, + { threshold: 0.6 } + ); + + const diffPercentage = (numDiffPixels / (basePng.width * basePng.height)) * 100; + if (diffPercentage <= 2) { + return; + } + } + } + + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + await fs.promises.writeFile(filePath, content); + }, }), ], // serve from gerrit root directory so that we can serve fonts from // /lib/fonts/ for screenshots tests, see middleware. - rootDir: runScreenshots ? '..' : rootDir, + rootDir: runUnderBazel ? rootDir : '..', reporters: [defaultReporter(), summaryReporter()], @@ -143,7 +202,7 @@ }, ], - testRunnerHtml: testRunnerHtmlFactory(), + testRunnerHtml: testRunnerHtmlFactory(stylePathPrefix), }; export default config;
diff --git a/polygerrit-ui/web_test_runner.sh b/polygerrit-ui/web_test_runner.sh index c0c7eb6..d8eda30 100755 --- a/polygerrit-ui/web_test_runner.sh +++ b/polygerrit-ui/web_test_runner.sh
@@ -1,3 +1,3 @@ #!/bin/bash set -euo pipefail -./$1 --config $2 +./"$1" --config "$2" "${@:3}"
diff --git a/prologtests/examples/BUILD b/prologtests/examples/BUILD index ebf2c68..83c98e2 100644 --- a/prologtests/examples/BUILD +++ b/prologtests/examples/BUILD
@@ -3,10 +3,15 @@ sh_test( name = "test_examples", srcs = ["run.sh"], - args = ["$(JAVA)"], + args = [ + "$(JAVABASE)", + "$(rlocationpath //:gerrit.war)", + ], data = glob(["*.pl"]) + [ "//:gerrit.war", + "@bazel_tools//tools/bash/runfiles", "@bazel_tools//tools/jdk:current_host_java_runtime", ], toolchains = ["@bazel_tools//tools/jdk:current_host_java_runtime"], + use_bash_launcher = True, )
diff --git a/prologtests/examples/run.sh b/prologtests/examples/run.sh index b2883ebe..ea2d515 100755 --- a/prologtests/examples/run.sh +++ b/prologtests/examples/run.sh
@@ -1,34 +1,48 @@ #!/bin/bash -# TODO(davido): Figure out what to do if running alone and not invoked from bazel -# $1 is equal to the $(JAVABASE)/bin/java make variable -JAVA=$1 +set -u -# Checks whether or not the $1 is starting with a slash: '/' and thus considered to be -# an absolute path. If it is, then it is left as is, if it isn't then "$PWD/ is prepended -# (in sh_test case it is relative and thus the runfiles directory is prepended). -[[ "$JAVA" =~ ^(/|[^/]+$) ]] || JAVA="$PWD/$JAVA" +# TODO(davido): Figure out what to do if running alone and not invoked from bazel. + +# $1 may be either $(JAVA) or $(JAVABASE) +JAVA="$1" + +if [[ -n "${TEST_SRCDIR:-}" ]]; then + case "$JAVA" in + external/*) + JAVA="${TEST_SRCDIR}/${JAVA#external/}" + ;; + /*) + if [[ "$JAVA" == "${TEST_SRCDIR}/_main/external/"* ]]; then + JAVA="${TEST_SRCDIR}/${JAVA#"${TEST_SRCDIR}/_main/external/"}" + fi + ;; + *) + [[ "$JAVA" == */* ]] && JAVA="$PWD/$JAVA" + ;; + esac +else + [[ "$JAVA" =~ ^(/|[^/]+$) ]] || JAVA="$PWD/$JAVA" +fi + +# If the resolved path is a Java home directory, use its bin/java +if [[ -d "$JAVA" ]]; then + JAVA="$JAVA/bin/java" +fi TESTS="t1 t2 t3" -# Note that both t1.pl and t2.pl test code in rules.pl. -# Unit tests are usually longer than the tested code. -# So it is common to test one source file with multiple -# unit test files. - LF=$'\n' PASS="" FAIL="" -echo "#### TEST_SRCDIR = ${TEST_SRCDIR}" - -if [ "${TEST_SRCDIR}" == "" ]; then - # Assume running alone +if [[ -z "${TEST_SRCDIR:-}" ]]; then + # Assume running standalone. GERRIT_WAR="../../bazel-bin/gerrit.war" SRCDIR="." else - # Assume running from bazel - GERRIT_WAR=`pwd`/gerrit.war + # Assume running from bazel. + GERRIT_WAR="$(pwd)/gerrit.war" SRCDIR="prologtests/examples" fi @@ -37,35 +51,26 @@ /bin/mkdir -p /tmp/gerrit export GERRIT_TMP=/tmp/gerrit -for T in $TESTS -do +for T in $TESTS; do + pushd "$SRCDIR" >/dev/null - pushd $SRCDIR - - # Unit tests do not need to define clauses in packages. - # Use one prolog-shell per unit test, to avoid name collision. echo "### Running test ${T}.pl" - echo "[$T]." | "${JAVA}" -jar ${GERRIT_WAR} prolog-shell -q -s load.pl + echo "[$T]." | "$JAVA" -jar "$GERRIT_WAR" prolog-shell -q -s load.pl + RC=$? - if [ "x$?" != "x0" ]; then + if [[ "$RC" != "0" ]]; then echo "### Test ${T}.pl failed." FAIL="${FAIL}${LF}FAIL: Test ${T}.pl" else PASS="${PASS}${LF}PASS: Test ${T}.pl" fi - popd - - # java -jar ../../bazel-bin/gerrit.war prolog-shell -s $T < /dev/null - # Calling prolog-shell with -s flag works for small files, - # but got run-time exception with t3.pl. - # com.googlecode.prolog_cafe.exceptions.ReductionLimitException: - # exceeded reduction limit of 1048576 + popd >/dev/null done echo "$PASS" -if [ "$FAIL" != "" ]; then +if [[ -n "$FAIL" ]]; then echo "$FAIL" exit 1 fi
diff --git a/proto/BUILD b/proto/BUILD index c6b5627..4b3eae4 100644 --- a/proto/BUILD +++ b/proto/BUILD
@@ -1,4 +1,4 @@ -load("@rules_java//java:defs.bzl", "java_proto_library") +load("@protobuf//bazel:java_proto_library.bzl", "java_proto_library") load("@rules_proto//proto:defs.bzl", "proto_library") proto_library(
diff --git a/proto/cache.proto b/proto/cache.proto index 4f8d1dc..19046a5 100644 --- a/proto/cache.proto +++ b/proto/cache.proto
@@ -172,11 +172,20 @@ repeated int32 past_reviewer = 12; - // Next ID: 8 + // Next ID: 9 message ReviewerStatusUpdateProto { // Epoch millis. int64 timestamp_millis = 1; + // The user that performed the reviewer update. int32 updated_by = 2; + // The real user that performed the reviewer update. + // This field is only set in case of impersonation using the RUN_AS + // permission. + // In case of impersonation, `updated_by` will store the user that is being + // impersonated, and this field will store the caller. I.e. if User X is + // impersonating User Y, then `updated_by` will be Y and `real_updated_by` + // will be X. + int32 real_updated_by = 8; // Account ID of the reviewer. // Not set if a reviewer for which no Gerrit account exists is added by email. @@ -356,6 +365,7 @@ string status = 7; string meta_id = 8; string unique_tag = 9; + string avatar_email = 10; } // Serialized form of com.google.gerrit.server.account.CachedAccountDetails.Key.
diff --git a/proto/entities.proto b/proto/entities.proto index 4f222c2..e0367b6 100644 --- a/proto/entities.proto +++ b/proto/entities.proto
@@ -110,9 +110,9 @@ // Serialized form of com.google.gerrit.extensions.client.ChangeStatus. // Next ID: 3 enum ChangeStatus { - NEW = 0; - MERGED = 1; - ABANDONED = 2; + NEW = 0; + MERGED = 1; + ABANDONED = 2; } // Serialized form of com.google.gerrit.entities.PatchSet.Conflicts. @@ -148,29 +148,29 @@ // Serialized form of com.google.gerrit.extensions.api.changes.ApplyPatchInput. // Next ID: 3 message ApplyPatchInput { - optional string patch = 1; - optional bool allow_conflicts = 2; + optional string patch = 1; + optional bool allow_conflicts = 2; } // Serialized form of com.google.gerrit.extensions.api.accounts.AccountInput. // Next ID: 9 message AccountInput { - optional string username = 1; - optional string name = 2; - optional string display_name = 3; - optional string email = 4; - optional string ssh_key = 5; - optional string http_password = 6; - repeated string groups = 7; - repeated AuthTokenInput tokens = 8; + optional string username = 1; + optional string name = 2; + optional string display_name = 3; + optional string email = 4; + optional string ssh_key = 5; + optional string http_password = 6; + repeated string groups = 7; + repeated AuthTokenInput tokens = 8; } // Serialized form of com.google.gerrit.extensions.auth.AuthTokenInput. // Next ID: 4 message AuthTokenInput { - optional string id = 1; - optional string token = 2; - optional string lifetime = 3; + optional string id = 1; + optional string token = 2; + optional string lifetime = 3; } // Serialized form of com.google.gerrit.extensions.client.ListChangesOption. @@ -403,6 +403,7 @@ optional bool allow_suggest_code_while_commenting = 24 [default = true]; optional bool allow_autocompleting_comments = 25 [default = true]; optional string diff_page_sidebar = 23 [default = "NONE"]; + optional string ai_chat_selected_model = 26; } optional GeneralPreferencesInfo general_preferences_info = 1; @@ -484,7 +485,7 @@ optional int32 end_char = 4; } - // Next Id: 5 + // Next Id: 6 message InFilePosition { optional string file_path = 1; enum Side { @@ -497,12 +498,16 @@ // http://google3/third_party/java_src/gerritcodereview/gerrit/Documentation/rest-api-changes.txt?l=7423 optional Side side = 2 [default = REVISION]; - // If neither range nor line number set, the comment is on the file level. It is possible - // (though not required) for both values to be set. in this case, it is expected that the line - // number is identical to the range's end line. + // If neither range nor line number set, the comment is on the file level. + // It is possible (though not required) for both values to be set. in this + // case, it is expected that the line number is identical to the range's end + // line. optional Range position_range = 3; // 1-based optional int32 line_number = 4 [default = 1]; + // 1-based. If side is PARENT, this field denotes which parent (1, 2, ...). + // If not set and side is PARENT, it refers to the Base (0). + optional int32 parent_number = 5; } // If not set, the comment is on the patchset level.
diff --git a/proto/testing/BUILD b/proto/testing/BUILD index f32d745..ee01e38 100644 --- a/proto/testing/BUILD +++ b/proto/testing/BUILD
@@ -1,4 +1,4 @@ -load("@rules_java//java:defs.bzl", "java_proto_library") +load("@protobuf//bazel:java_proto_library.bzl", "java_proto_library") load("@rules_proto//proto:defs.bzl", "proto_library") proto_library(
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy index eb84a2c..8335b76 100644 --- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy +++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -24,6 +24,7 @@ {@param? assetsPath: ?} /** {string} URL to static assets root, if served from CDN. */ {@param? assetsBundle: ?} /** {string} Assets bundle .html file, served from $assetsPath. */ {@param? faviconPath: ?} + {@param? manifestPath: ?} {@param? versionInfo: ?} {@param? polyfillCE: ?} {@param? useGoogleFonts: ?} @@ -46,7 +47,6 @@ </noscript> <script> - // Disable extra font load from paper-styles window.polymerSkipLoadingFontRoboto = true; window.CLOSURE_NO_DEPS = true; window.DEFAULT_DETAIL_HEXES = {lb} @@ -84,12 +84,12 @@ window.ENABLED_EXPERIMENTS = JSON.parse({$enabledExperiments}); {/if} </script>{\n} - {if $faviconPath} <link rel="icon" type="image/x-icon" href="{$canonicalPath}/{$faviconPath}">{\n} {else} <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n} {/if} + <link rel="manifest" href="{$manifestPath}">{\n} {if $changeRequestsPath} {if $defaultChangeDetailHex} <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultChangeDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n} @@ -107,7 +107,9 @@ {/if} {/if} {if $userIsAuthenticated && $defaultDashboardHex && $dashboardQuery} - <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0{for $query in $dashboardQuery}&q={$query}{/for}&allow-incomplete-results=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n} + {for $query in $dashboardQuery} + <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0&q={$query}&allow-incomplete-results=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n} + {/for} {/if} {if $useGoogleFonts}
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.service b/resources/com/google/gerrit/pgm/init/gerrit.service index dee9030..de8fd24 100644 --- a/resources/com/google/gerrit/pgm/init/gerrit.service +++ b/resources/com/google/gerrit/pgm/init/gerrit.service
@@ -12,6 +12,7 @@ User=gerritsrv SyslogIdentifier=GerritCodeReview StandardInput=socket +OOMScoreAdjust=-1000 [Install] WantedBy=multi-user.target
diff --git a/resources/com/google/gerrit/pgm/init/gerrit.sh b/resources/com/google/gerrit/pgm/init/gerrit.sh index 8c958c9..67d95b2 100755 --- a/resources/com/google/gerrit/pgm/init/gerrit.sh +++ b/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -536,7 +536,11 @@ echo -16 > "/proc/${PID}/oom_adj" fi fi - elif [ "$(uname -s)"=="Linux" ] && test -d "/proc/${PID}"; then + elif test -f "/proc/${PID}/oom_score_adj" && ! test "$(cat "/proc/${PID}/oom_score_adj")" = "0" ; then + : # Do nothing. OOM score already adjusted. + elif test -f "/proc/${PID}/oom_adj" && ! test "$(cat "/proc/${PID}/oom_adj")" = "0" ; then + : # Do nothing. OOM score already adjusted. + elif test "$(uname -s)" = "Linux" && test -d "/proc/${PID}"; then echo "WARNING: Could not adjust Gerrit's process for the kernel's out-of-memory killer." echo " This may be caused by ${0} not being run as root." echo " Consider changing the OOM score adjustment manually for Gerrit's PID=${PID} with e.g.:"
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy index efa8e5a..3e684cb4 100644 --- a/resources/com/google/gerrit/server/mail/CommentHtml.soy +++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -28,6 +28,8 @@ {@param unsatisfiedSubmitRequirements: ?} {@param oldSubmitRequirements: ?} {@param newSubmitRequirements: ?} + {@param fromName: ?} + {@param change: ?} {let $commentHeaderStyle kind="css"} margin-bottom: 4px; {/let} @@ -75,6 +77,10 @@ background-color: #ddd; {/let} + {if $fromName && $change} + <p>{$fromName} has posted comments on this change by {$change.ownerName}.</p> + {/if} + {if $patchSetCommentBlocks} {call mailTemplate.WikiFormat} {param content: $patchSetCommentBlocks /}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties index 93c88e5..66f21b9 100644 --- a/resources/com/google/gerrit/server/mime/mime-types.properties +++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -1,3 +1,5 @@ +adb = text/x-ada +ads = text/x-ada apl = text/apl as = text/x-gas asn = text/x-ttcn-asn @@ -89,6 +91,7 @@ gn = text/x-gn gni = text/x-gn go = text/x-go +gpr = text/x-ada gradle = text/x-groovy gradlew = text/x-sh groovy = text/x-groovy
diff --git a/tools/BUILD b/tools/BUILD index dc40427..9529ec7 100644 --- a/tools/BUILD +++ b/tools/BUILD
@@ -1,18 +1,20 @@ load( "@bazel_tools//tools/jdk:default_java_toolchain.bzl", - "NONPREBUILT_TOOLCHAIN_CONFIGURATION", "default_java_toolchain", ) load("@rules_java//java:defs.bzl", "java_package_configuration") load("@rules_proto//proto:defs.bzl", "proto_lang_toolchain") -exports_files(["nongoogle.bzl"]) +exports_files([ + "nongoogle.toml", + "deps.toml", +]) proto_lang_toolchain( name = "protoc_java_toolchain", command_line = "--java_out=%s", progress_message = "Generating Java proto_library %{label}", - runtime = "@protobuf-java//jar", + runtime = "@external_deps//:com_google_protobuf_protobuf_java", toolchain_type = "@rules_java//java/proto:toolchain_type", ) @@ -160,7 +162,7 @@ "-Xep:FloatingPointLiteralPrecision:ERROR", "-Xep:FloggerArgumentToString:ERROR", "-Xep:FloggerFormatString:ERROR", - "-Xep:FloggerLogString:WARN", + "-Xep:FloggerLogString:ERROR", "-Xep:FloggerLogVarargs:ERROR", "-Xep:FloggerSplitLogStatement:ERROR", "-Xep:FloggerStringConcatenation:ERROR",
diff --git a/tools/bazlets.MODULE.bazel b/tools/bazlets.MODULE.bazel new file mode 100644 index 0000000..773ed61 --- /dev/null +++ b/tools/bazlets.MODULE.bazel
@@ -0,0 +1,22 @@ +# Plugin packaging support kept outside the root module declaration: +# - bazlets dependency pin +# - generated Gerrit API version repo for artifact versioning and stamping +GERRIT_VERSION = "3.14.0-SNAPSHOT" + +bazel_dep(name = "com_googlesource_gerrit_bazlets") +git_override( + module_name = "com_googlesource_gerrit_bazlets", + commit = "01ec9c2481186396dde1bc6f5166f7d95bdae134", + remote = "https://gerrit.googlesource.com/bazlets", +) + +gerrit_api_version = use_repo_rule( + "@com_googlesource_gerrit_bazlets//:gerrit_api_version.bzl", + "gerrit_api_version", +) + +gerrit_api_version( + name = "gerrit_api_version", + version = GERRIT_VERSION, + visibility = ["//visibility:public"], +)
diff --git a/tools/bzl/BUILD b/tools/bzl/BUILD index 8f63d08..62f0adb 100644 --- a/tools/bzl/BUILD +++ b/tools/bzl/BUILD
@@ -1,7 +1,9 @@ exports_files([ + "diff_allowlist.sh", "license-map.py", "test_empty.sh", "test_license.sh", + "war_checks.bzl", ]) sh_test(
diff --git a/tools/bzl/classpath.bzl b/tools/bzl/classpath.bzl index e196c10..1473585 100644 --- a/tools/bzl/classpath.bzl +++ b/tools/bzl/classpath.bzl
@@ -1,27 +1,157 @@ +# Utility rule for IDE integration (Eclipse, IntelliJ, etc.). +# +# This rule produces metadata files: +# +# - %{name}.runtime_classpath +# One runtime jar path per line. Used to construct the IDE classpath. +# +# - %{name}.source_classpath +# One source-jar path per line. Used to attach sources to libraries in +# the IDE. +# +# - %{name}.processor_classpath +# One annotation-processor jar path per line. Used to construct Eclipse .factorypath. +# +# Important implementation details: +# +# * With rules_jvm_external, many Maven artifacts (including sources) are +# resolved lazily. A jar may appear in a provider but not exist on disk +# unless it is consumed by an action. +# +# * IDEs require real files on disk. Simply listing paths is insufficient. +# +# * To ensure jars are materialized under the Bazel execution root, the +# actions producing the metadata files declare them as inputs and validate +# that they exist. +# +# * A param file is used for source jars to avoid command-line length limits. +# Bazel does not automatically expand "@paramfile" in run_shell, so the +# script explicitly strips the '@' prefix and reads the file. + +"""Utility rule for IDE integration.""" + load("@rules_java//java:defs.bzl", "JavaInfo") -def _classpath_collector(ctx): - all = [] +def _classpath_collector_impl(ctx): + runtime_sets = [] + source_sets = [] + processor_sets = [] + for d in ctx.attr.deps: if JavaInfo in d: - all.append(d[JavaInfo].transitive_runtime_jars) - if hasattr(d[JavaInfo].compilation_info, "runtime_classpath"): - all.append(d[JavaInfo].compilation_info.runtime_classpath) - elif hasattr(d, "files"): - all.append(d.files) + j = d[JavaInfo] - as_strs = [c.path for c in depset(transitive = all).to_list()] - ctx.actions.write( - output = ctx.outputs.runtime, - content = "\n".join(sorted(as_strs)), + runtime_sets.append(j.transitive_runtime_jars) + + ci = j.compilation_info + if ci and hasattr(ci, "runtime_classpath"): + runtime_sets.append(ci.runtime_classpath) + + source_sets.append(j.transitive_source_jars) + + ap = j.annotation_processing + if ap and hasattr(ap, "processor_classpath"): + processor_sets.append(ap.processor_classpath) + + elif hasattr(d, "files"): + runtime_sets.append(d.files) + + runtime_files = depset(transitive = runtime_sets).to_list() + source_files = depset(transitive = source_sets).to_list() + processor_files = depset(transitive = processor_sets).to_list() + + # Runtime classpath: write stable sorted list, and materialize jars by + # declaring them as inputs. + pf = ctx.actions.args() + pf.set_param_file_format("multiline") + pf.use_param_file("%s", use_always = True) + pf.add_all([f.path for f in runtime_files]) + + ctx.actions.run_shell( + inputs = runtime_files, + outputs = [ctx.outputs.runtime], + mnemonic = "ClasspathCollector", + arguments = [ctx.outputs.runtime.path, pf], + command = r""" +set -euo pipefail +OUT="$1" +PF="$2" +PF="${PF#@}" +if [ -n "$PF" ] && [ -f "$PF" ]; then + while IFS= read -r f; do + test -e "$f" + done < "$PF" + sort "$PF" > "$OUT" +else + : > "$OUT" +fi +""", + ) + + # Source classpath: write stable sorted list, and materialize jars by + # declaring them as inputs. + pf = ctx.actions.args() + pf.set_param_file_format("multiline") + pf.use_param_file("%s", use_always = True) + pf.add_all([f.path for f in source_files]) + + ctx.actions.run_shell( + inputs = source_files, + outputs = [ctx.outputs.sources], + mnemonic = "ClasspathCollector", + arguments = [ctx.outputs.sources.path, pf], + command = r""" +set -euo pipefail +OUT="$1" +PF="$2" +PF="${PF#@}" +if [ -n "$PF" ] && [ -f "$PF" ]; then + while IFS= read -r f; do + test -e "$f" + done < "$PF" + sort "$PF" > "$OUT" +else + : > "$OUT" +fi +""", + ) + + # Processor classpath: write stable sorted list, and materialize jars by + # declaring them as inputs. + pf = ctx.actions.args() + pf.set_param_file_format("multiline") + pf.use_param_file("%s", use_always = True) + pf.add_all([f.path for f in processor_files]) + + ctx.actions.run_shell( + inputs = processor_files, + outputs = [ctx.outputs.processors], + mnemonic = "ClasspathCollector", + arguments = [ctx.outputs.processors.path, pf], + command = r""" +set -euo pipefail +OUT="$1" +PF="$2" +PF="${PF#@}" +if [ -n "$PF" ] && [ -f "$PF" ]; then + while IFS= read -r f; do + test -e "$f" + done < "$PF" + sort "$PF" > "$OUT" +else + : > "$OUT" +fi +""", ) classpath_collector = rule( + implementation = _classpath_collector_impl, attrs = { "deps": attr.label_list(), }, outputs = { "runtime": "%{name}.runtime_classpath", + "sources": "%{name}.source_classpath", + "processors": "%{name}.processor_classpath", }, - implementation = _classpath_collector, )
diff --git a/tools/bzl/diff_allowlist.sh b/tools/bzl/diff_allowlist.sh new file mode 100755 index 0000000..169b107 --- /dev/null +++ b/tools/bzl/diff_allowlist.sh
@@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +ALLOWLIST="${1:?missing allowlist file}" +GENERATED="${2:?missing generated manifest file}" + +if [[ ! -f "${ALLOWLIST}" ]]; then + echo "" + echo "FAIL: Missing WAR allowlist:" + echo " ${ALLOWLIST}" + echo "" + echo "To generate it from the current WAR packaging inputs:" + echo "" + echo " bazelisk build //:release.war.jars.txt" + echo " cp bazel-bin/release.war.jars.txt ${ALLOWLIST}" + echo "" + exit 1 +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "${tmpdir}"' EXIT + +# Strip comments/blank lines from allowlist; normalize both sides. +grep -vE '^\s*(#|$)' "${ALLOWLIST}" | sort -u > "${tmpdir}/allowlist.norm" +grep -vE '^\s*$' "${GENERATED}" | sort -u > "${tmpdir}/generated.norm" + +if ! diff -u "${tmpdir}/allowlist.norm" "${tmpdir}/generated.norm"; then + echo "" + echo "FAIL: WAR packaged third-party JAR set changed." + echo "" + echo "This means the set of third-party runtime dependencies that would be" + echo "packaged into release.war no longer matches the checked-in allowlist." + echo "" + echo "If this change is expected and reviewed, refresh the allowlist with:" + echo "" + echo " bazelisk build //:release.war.jars.txt" + echo " cp bazel-bin/release.war.jars.txt ${ALLOWLIST}" + echo "" + echo "Then re-run:" + echo "" + echo " bazelisk test //Documentation:check_release_war_jars" + echo "" + exit 1 +fi
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl index c7d2545..9ba7a5e 100644 --- a/tools/bzl/junit.bzl +++ b/tools/bzl/junit.bzl
@@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -# Skylark rule to generate a Junit4 TestSuite +# Starlark rule to generate a Junit4 TestSuite # Assumes srcs are all .java Test files # Assumes junit4 is already added to deps by the user.
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl index 1c3444e..ebc2c1e 100644 --- a/tools/bzl/pkg_war.bzl +++ b/tools/bzl/pkg_war.bzl
@@ -15,8 +15,6 @@ # War packaging. load("@rules_java//java:defs.bzl", "JavaInfo") -load("//tools:deps.bzl", "AUTO_VALUE_GSON_VERSION") -load("//tools:nongoogle.bzl", "AUTO_FACTORY_VERSION", "AUTO_VALUE_VERSION") jar_filetype = [".jar"] @@ -35,32 +33,140 @@ "//java/com/google/gerrit/pgm", ] -SKIP_DEPS = [ - "auto-factory-%s.jar" % AUTO_FACTORY_VERSION, - "auto-value-%s.jar" % AUTO_VALUE_VERSION, - "auto-value-annotations-%s.jar" % AUTO_VALUE_VERSION, - "auto-value-gson-runtime-%s.jar" % AUTO_VALUE_GSON_VERSION, +# Special prefix added by rules_jvm_external.jvm_import() to stamped jars +# https://github.com/bazel-contrib/rules_jvm_external/blob/6.9/private/rules/jvm_import.bzl#L32 +PROCESSED_PREFIX = "processed_" + +# Jars that must not be packaged into release.war. +# +# Keep this list prefix-based and version-agnostic so it remains stable +# across dependency upgrades. +EXCLUDE_WAR_JAR_PREFIXES = [ + # Codegen / annotation processor support libs (compile-time only). + "autotransient-", + "auto-", + "javapoet-", + "checker-qual-", + "checker-compat-qual-", + "error_prone_annotations-", + "jspecify-", + "jsinterop-annotations-", + + # Placeholder jar used to avoid conflicts with Guava. + "listenablefuture-9999.0-empty-to-avoid-conflict-with-guava", ] +# Identifiers that should not be tracked in third-party WAR allowlists. +THIRD_PARTY_EXCLUDE_ID_PREFIXES = [ + "com_google_", + "gerrit_", +] + +# Gerrit-internal jars whose normalized IDs do not retain the com_google_/gerrit_ +# namespace after normalization and should not appear in third-party allowlists. +THIRD_PARTY_EXCLUDE_ID_EXACT = [ + "index", + "libcache_proto-speed", + "libentities_proto-speed", + "libgerrit-prolog-common", + "libjgit-archive", + "libjgit-servlet", + "libquery_parser", + "libssh-apache", + "log4j-config", +] + +def war_jar_name(f): + """Return the jar file name as it will appear inside the WAR. + + Args: + f: The jar file to process. + + Returns: + The jar file name as it will appear inside the WAR. + """ + raw = f.basename + if raw.startswith(PROCESSED_PREFIX): + raw = raw[len(PROCESSED_PREFIX):] + + sp = f.short_path + + # Rename ONLY caffeine's "guava" artifact (not Google Guava) + # Matches: .../com/github/ben-manes/caffeine/guava/<ver>/processed_guava-<ver>.jar + if "/com/github/ben-manes/caffeine/guava/" in sp and raw.startswith("guava-") and raw.endswith(".jar"): + raw = "caffeine-" + raw # -> caffeine-guava-<ver>.jar + + # Keep existing Gerrit naming rules + if sp.startswith("gerrit-"): + raw = sp.split("/")[0] + "-" + raw + elif sp.startswith("java/"): + raw = sp[5:].replace("/", "_") + + return raw + +def normalize_jar_id(jar_name): + """Version-agnostic jar identity used for allowlists/inventories. + + Args: + jar_name: The original jar name. + + Returns: + The version-agnostic jar identity. + """ + n = jar_name + if n.endswith(".jar"): + n = n[:-4] + i = n.rfind("-") + + # Strip trailing "-<version-ish>" where the suffix begins with a digit. + if i > 0 and n[i + 1:i + 2].isdigit(): + n = n[:i] + return n + +def should_skip_packaged_jar(jar_name): + """Returns True if the packaged jar should be skipped. + + jar_name must be the post-processed name (war_jar_name output). + + Args: + jar_name: The post-processed jar name. + + Returns: + True if the packaged jar should be skipped, False otherwise. + """ + for pfx in EXCLUDE_WAR_JAR_PREFIXES: + if jar_name.startswith(pfx): + return True + + # Bazel 8: skip protobuf runtime shards (duplicate protobuf-java) + return jar_name in ("libcore.jar", "liblite_runtime_only.jar") + +def is_third_party_jar_id(jar_id): + """Return True if jar_id should be tracked in third-party allowlists. + + Args: + jar_id: The version-agnostic jar identity. + + Returns: + True if jar_id should be tracked in third-party allowlists, False otherwise. + """ + if jar_id in THIRD_PARTY_EXCLUDE_ID_EXACT: + return False + for pfx in THIRD_PARTY_EXCLUDE_ID_PREFIXES: + if jar_id.startswith(pfx): + return False + return True + def _add_context(in_file, output): - input_path = in_file.path return [ - "unzip -qd %s %s" % (output, input_path), + "unzip -qd %s %s" % (output, in_file.path), ] def _add_file(in_file, output): - output_path = output - input_path = in_file.path - short_path = in_file.short_path - n = in_file.basename - - if short_path.startswith("gerrit-"): - n = short_path.split("/")[0] + "-" + n - elif short_path.startswith("java/"): - n = short_path[5:].replace("/", "_") - output_path += n + raw = war_jar_name(in_file) + output_path = output + raw return [ - "test -L %s || ln -s $(pwd)/%s %s" % (output_path, input_path, output_path), + "test -L %s || ln -s $(pwd)/%s %s" % (output_path, in_file.path, output_path), ] def _make_war(input_dir, output): @@ -73,11 +179,18 @@ "zip -X -9qr ${root}/%s ." % (output.path), ]) +def _ci_sorted(xs): + return sorted(xs, key = lambda s: (s.lower(), s)) + def _war_impl(ctx): war = ctx.outputs.war build_output = war.path + ".build_output" inputs = [] + # Metadata we expose for checks/tools. + jar_entries = [] + jar_ids = [] + # Create war layout cmd = [ "set -e;rm -rf " + build_output, @@ -86,7 +199,7 @@ "mkdir -p %s/WEB-INF/pgm-lib" % build_output, ] - # Add lib + # Add runtime libs transitive_libs = [] for j in ctx.attr.libs: if JavaInfo in j: @@ -94,26 +207,40 @@ elif hasattr(j, "files"): transitive_libs.append(j.files) - transitive_lib_deps = depset(transitive = transitive_libs) - for dep in transitive_lib_deps.to_list(): - if dep.basename in SKIP_DEPS: + for dep in depset(transitive = transitive_libs).to_list(): + packaged = war_jar_name(dep) + if should_skip_packaged_jar(packaged): continue + cmd += _add_file(dep, build_output + "/WEB-INF/lib/") inputs.append(dep) - # Add pgm lib + jar_entries.append("WEB-INF/lib/" + packaged) + + jid = normalize_jar_id(packaged) + if is_third_party_jar_id(jid): + jar_ids.append(jid) + + # Add pgm libs transitive_pgmlibs = [] for j in ctx.attr.pgmlibs: transitive_pgmlibs.append(j[JavaInfo].transitive_runtime_jars) - transitive_pgmlib_deps = depset(transitive = transitive_pgmlibs) - for dep in transitive_pgmlib_deps.to_list(): - if dep.basename in SKIP_DEPS: + for dep in depset(transitive = transitive_pgmlibs).to_list(): + packaged = war_jar_name(dep) + if should_skip_packaged_jar(packaged): continue + if dep not in inputs: cmd += _add_file(dep, build_output + "/WEB-INF/pgm-lib/") inputs.append(dep) + jar_entries.append("WEB-INF/pgm-lib/" + packaged) + + jid = normalize_jar_id(packaged) + if is_third_party_jar_id(jid): + jar_ids.append(jid) + # Add context transitive_context_libs = [] if ctx.attr.context: @@ -123,11 +250,24 @@ elif hasattr(jar, "files"): transitive_context_libs.append(jar.files) - transitive_context_deps = depset(transitive = transitive_context_libs) - for dep in transitive_context_deps.to_list(): + for dep in depset(transitive = transitive_context_libs).to_list(): cmd += _add_context(dep, build_output) inputs.append(dep) + # Write deterministic manifests for checks. + # + # NOTE: The manifests are produced as independent actions. + # Bazel will only execute the actions needed for the requested output, + # so building *.war.entries.txt does not materialize the WAR. + ctx.actions.write( + output = ctx.outputs.jars, + content = "\n".join(_ci_sorted(depset(jar_ids).to_list())) + "\n", + ) + ctx.actions.write( + output = ctx.outputs.entries, + content = "\n".join(_ci_sorted(jar_entries)) + "\n", + ) + # Add zip war cmd.append(_make_war(build_output, war)) @@ -139,6 +279,10 @@ use_default_shell_env = True, ) + return [ + DefaultInfo(files = depset([war, ctx.outputs.jars, ctx.outputs.entries])), + ] + # context: go to the root directory # libs: go to the WEB-INF/lib directory # pgmlibs: go to the WEB-INF/pgm-lib directory @@ -148,11 +292,24 @@ "libs": attr.label_list(allow_files = jar_filetype), "pgmlibs": attr.label_list(allow_files = False), }, - outputs = {"war": "%{name}.war"}, + outputs = { + "war": "%{name}.war", + "jars": "%{name}.war.jars.txt", + "entries": "%{name}.war.entries.txt", + }, implementation = _war_impl, ) def pkg_war(name, ui = "polygerrit", context = [], doc = False, **kwargs): + """Rule for packaging the Gerrit WAR. + + Args: + name: The name of the target. + ui: The UI type, e.g. "polygerrit". + context: The list of context dependencies. + doc: Whether to include documentation. + **kwargs: Additional keyword arguments. + """ doc_ctx = [] doc_lib = [] ui_deps = []
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl index bfe8e87..399f1d6 100644 --- a/tools/bzl/plugin.bzl +++ b/tools/bzl/plugin.bzl
@@ -1,13 +1,6 @@ -""" -Build rules for plugins. -""" +load("@com_googlesource_gerrit_bazlets//:gerrit_plugin.bzl", _gerrit_plugin = "gerrit_plugin") -load("//:version.bzl", "GERRIT_VERSION") -load("@rules_java//java:defs.bzl", "java_binary", "java_library") -load("//tools/bzl:genrule2.bzl", "genrule2") - -IN_TREE_BUILD_MODE = True - +# Keep legacy constants for in-tree consumers. PLUGIN_DEPS = ["//plugins:plugin-lib"] PLUGIN_DEPS_NEVERLINK = ["//plugins:plugin-lib-neverlink"] @@ -32,69 +25,22 @@ target_suffix = "", deploy_env = [], **kwargs): - """Builds a Gerrit plugin. + """Compatibility wrapper for bazlets' gerrit_plugin. - Args: - name: The name of the plugin. - deps: List of additional dependencies for the plugin. - provided_deps: List of dependencies that are provided by Gerrit and should not be bundled. - srcs: List of Java source files for the plugin. - resources: List of resource files to be included in the plugin JAR. - resource_jars: List of JARs containing resources. - runtime_deps: List of runtime dependencies. - manifest_entries: List of additional lines to add to the plugin's manifest file. - dir_name: The directory name for the plugin, used in stamping. Defaults to `name`. - target_suffix: Suffix to append to the final plugin JAR name. - deploy_env: Environment variables for the deploy JAR. - **kwargs: Additional arguments passed to the underlying `java_library` and `java_binary` rules. - - This rule creates a deployable .jar file for a Gerrit plugin.""" - java_library( - name = name + "__plugin", + Preserves the legacy macro signature and constants for in-tree builds, + while delegating the implementation to bazlets. + """ + _gerrit_plugin( + name = name, + deps = deps, + provided_deps = provided_deps, srcs = srcs, resources = resources, - deps = provided_deps + deps + PLUGIN_DEPS_NEVERLINK, + resource_jars = resource_jars, runtime_deps = runtime_deps, - visibility = ["//visibility:public"], - **kwargs - ) - - if not dir_name: - dir_name = name - - java_binary( - name = "%s__non_stamped" % name, - deploy_manifest_lines = manifest_entries + [ - "Gerrit-ApiType: plugin", - "Gerrit-ApiVersion: " + GERRIT_VERSION, - ], - main_class = "Dummy", - runtime_deps = [ - ":%s__plugin" % name, - ] + runtime_deps + resource_jars, + manifest_entries = manifest_entries, + dir_name = dir_name, + target_suffix = target_suffix, deploy_env = deploy_env, - visibility = ["//visibility:public"], **kwargs ) - - # TODO(davido): Remove manual merge of manifest file when this feature - # request is implemented: https://github.com/bazelbuild/bazel/issues/2009 - # TODO(davido): Remove manual touch command when this issue is resolved: - # https://github.com/bazelbuild/bazel/issues/10789 - genrule2( - name = name + target_suffix, - stamp = 1, - srcs = ["%s__non_stamped_deploy.jar" % name], - cmd = " && ".join([ - "TZ=UTC", - "export TZ", - "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_name.upper(), - "cd $$TMP", - "unzip -qo $$ROOT/$<", - "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF", - "find . -exec touch '{}' ';'", - "zip -Xqr $$ROOT/$@ .", - ]), - outs = ["%s%s.jar" % (name, target_suffix)], - visibility = ["//visibility:public"], - )
diff --git a/tools/bzl/war_checks.bzl b/tools/bzl/war_checks.bzl new file mode 100644 index 0000000..c121b7f --- /dev/null +++ b/tools/bzl/war_checks.bzl
@@ -0,0 +1,24 @@ +"""Reusable checks for WAR content guardrails.""" + +def war_jars_allowlist_test(name, war_jars_manifest, allowlist, **kwargs): + """Checks that a WAR's normalized jar ID manifest matches an allowlist. + + Args: + name: test name + war_jars_manifest: label of the generated manifest, e.g. "//:release.war.jars.txt" + allowlist: label of the checked-in allowlist file + **kwargs: forwarded to sh_test + """ + native.sh_test( + name = name, + srcs = ["//tools/bzl:diff_allowlist.sh"], + args = [ + "$(location %s)" % allowlist, + "$(location %s)" % war_jars_manifest, + ], + data = [ + allowlist, + war_jars_manifest, + ], + **kwargs + )
diff --git a/tools/defs.bzl b/tools/defs.bzl deleted file mode 100644 index 3ebc7dc..0000000 --- a/tools/defs.bzl +++ /dev/null
@@ -1,25 +0,0 @@ -""" -Bazel definitions for tools. -""" - -load("@bazel_features//:deps.bzl", "bazel_features_deps") -load("@rules_proto//proto:repositories.bzl", "rules_proto_dependencies") -load("@toolchains_protoc//protoc:repositories.bzl", "rules_protoc_dependencies") -load("@toolchains_protoc//protoc:toolchain.bzl", "protoc_toolchains") - -def gerrit_init(): - """ - Initialize the WORKSPACE for gerrit targets - """ - rules_protoc_dependencies() - - rules_proto_dependencies() - - bazel_features_deps() - - protoc_toolchains( - name = "toolchains_protoc_hub", - version = "v25.3", - ) - - native.register_toolchains("//tools:all")
diff --git a/tools/deps.bzl b/tools/deps.bzl deleted file mode 100644 index cca3100..0000000 --- a/tools/deps.bzl +++ /dev/null
@@ -1,533 +0,0 @@ -""" -This module lists the external dependencies of the Gerrit project. -""" - -load("//tools/bzl:maven_jar.bzl", "GERRIT", "maven_jar") - -CAFFEINE_VERS = "2.9.2" -ANTLR_VERS = "3.5.2" -COMMONMARK_VERSION = "0.21.0" -GREENMAIL_VERS = "1.5.5" -MAIL_VERS = "1.6.0" -MIME4J_VERS = "0.8.1" -OW2_VERS = "9.7" -AUTO_VALUE_GSON_VERSION = "1.3.1" -PROLOG_VERS = "1.4.4" -PROLOG_REPO = GERRIT -GITILES_VERS = "1.6.0" -GITILES_REPO = GERRIT - -# When updating Bouncy Castle, also update it in bazlets. -BC_VERS = "1.80" -HTTPCOMP_VERS = "4.5.14" -JETTY_VERS = "9.4.57.v20241219" -BYTE_BUDDY_VERSION = "1.14.9" -ROARING_BITMAP_VERSION = "0.9.44" - -def java_dependencies(): - """ - This method lists the maven jars used in the Gerrit project. - """ - maven_jar( - name = "java-runtime", - artifact = "org.antlr:antlr-runtime:" + ANTLR_VERS, - sha1 = "cd9cd41361c155f3af0f653009dcecb08d8b4afd", - ) - - maven_jar( - name = "stringtemplate", - artifact = "org.antlr:stringtemplate:4.0.2", - sha1 = "e28e09e2d44d60506a7bcb004d6c23ff35c6ac08", - ) - - maven_jar( - name = "org-antlr", - artifact = "org.antlr:antlr:" + ANTLR_VERS, - sha1 = "c4a65c950bfc3e7d04309c515b2177c00baf7764", - ) - - maven_jar( - name = "antlr27", - artifact = "antlr:antlr:2.7.7", - attach_source = False, - sha1 = "83cd2cd674a217ade95a4bb83a8a14f351f48bd0", - ) - - maven_jar( - name = "aopalliance", - artifact = "aopalliance:aopalliance:1.0", - sha1 = "0235ba8b489512805ac13a8f9ea77a1ca5ebe3e8", - ) - - maven_jar( - name = "javax_inject", - artifact = "javax.inject:javax.inject:1", - sha1 = "6975da39a7040257bd51d21a231b76c915872d38", - ) - - maven_jar( - name = "servlet-api", - artifact = "javax.servlet:javax.servlet-api:3.1.0", - sha1 = "3cd63d075497751784b2fa84be59432f4905bf7c", - ) - - maven_jar( - name = "jakarta-inject-api", - artifact = "jakarta.inject:jakarta.inject-api:2.0.1", - sha1 = "4c28afe1991a941d7702fe1362c365f0a8641d1e", - ) - - # JGit's transitive dependencies - - maven_jar( - name = "javaewah", - artifact = "com.googlecode.javaewah:JavaEWAH:1.1.12", - attach_source = False, - sha1 = "9feecc2b24d6bc9ff865af8d082f192238a293eb", - ) - - maven_jar( - name = "caffeine", - artifact = "com.github.ben-manes.caffeine:caffeine:" + CAFFEINE_VERS, - sha1 = "0a17ed335e0ce2d337750772c0709b79af35a842", - ) - - maven_jar( - name = "guava-failureaccess", - artifact = "com.google.guava:failureaccess:1.0.1", - sha1 = "1dcf1de382a0bf95a3d8b0849546c88bac1292c9", - ) - - maven_jar( - name = "json-smart", - artifact = "net.minidev:json-smart:1.1.1", - sha1 = "24a2f903d25e004de30ac602c5b47f2d4e420a59", - ) - - maven_jar( - name = "args4j", - artifact = "args4j:args4j:2.33", - sha1 = "bd87a75374a6d6523de82fef51fc3cfe9baf9fc9", - ) - - maven_jar( - name = "commons-codec", - artifact = "commons-codec:commons-codec:1.15", - sha1 = "49d94806b6e3dc933dacbd8acb0fdbab8ebd1e5d", - ) - - # When upgrading commons-compress, also upgrade tukaani-xz - maven_jar( - name = "commons-compress", - artifact = "org.apache.commons:commons-compress:1.25.0", - sha1 = "9d35aec423da6c8a7f93d7e9e1c6b1d9fe14bb5e", - ) - - maven_jar( - name = "commons-lang3", - artifact = "org.apache.commons:commons-lang3:3.8.1", - sha1 = "6505a72a097d9270f7a9e7bf42c4238283247755", - ) - - maven_jar( - name = "commons-text", - artifact = "org.apache.commons:commons-text:1.2", - sha1 = "74acdec7237f576c4803fff0c1008ab8a3808b2b", - ) - - maven_jar( - name = "commons-dbcp", - artifact = "commons-dbcp:commons-dbcp:1.4", - sha1 = "30be73c965cc990b153a100aaaaafcf239f82d39", - ) - - # Transitive dependency of commons-dbcp, do not update without - # also updating commons-dbcp - maven_jar( - name = "commons-pool", - artifact = "commons-pool:commons-pool:1.5.5", - sha1 = "7d8ffbdc47aa0c5a8afe5dc2aaf512f369f1d19b", - ) - - maven_jar( - name = "commons-net", - artifact = "commons-net:commons-net:3.6", - sha1 = "b71de00508dcb078d2b24b5fa7e538636de9b3da", - ) - - maven_jar( - name = "commons-validator", - artifact = "commons-validator:commons-validator:1.6", - sha1 = "e989d1e87cdd60575df0765ed5bac65c905d7908", - ) - - maven_jar( - name = "automaton", - artifact = "dk.brics:automaton:1.12-1", - sha1 = "959a0c62f9a5c2309e0ad0b0589c74d69e101241", - ) - - # commonmark must match the version used in Gitiles - maven_jar( - name = "commonmark", - artifact = "org.commonmark:commonmark:" + COMMONMARK_VERSION, - sha1 = "c98f0473b17c87fe4fa2fc62a7c6523a2fe018f0", - ) - - maven_jar( - name = "cm-autolink", - artifact = "org.commonmark:commonmark-ext-autolink:" + COMMONMARK_VERSION, - sha1 = "55c0312cf443fa3d5af0daeeeca00d6deee3cf90", - ) - - maven_jar( - name = "gfm-strikethrough", - artifact = "org.commonmark:commonmark-ext-gfm-strikethrough:" + COMMONMARK_VERSION, - sha1 = "953f4b71e133a98fcca93f3c3f4e58b895b76d1f", - ) - - maven_jar( - name = "gfm-tables", - artifact = "org.commonmark:commonmark-ext-gfm-tables:" + COMMONMARK_VERSION, - attach_source = False, - sha1 = "fb7d65fa89a4cfcd2f51535d2549b570cf1dbd1a", - ) - - maven_jar( - name = "flexmark-all-lib", - artifact = "com.vladsch.flexmark:flexmark-all:0.64.0:lib", - attach_source = False, - sha1 = "de92cef20c1f61681a3c8f64dd5975fbd3125049", - ) - - # Transitive dependency of flexmark and gitiles - maven_jar( - name = "autolink", - artifact = "org.nibor.autolink:autolink:0.10.0", - sha1 = "6579ea7079be461e5ffa99f33222a632711cc671", - ) - - maven_jar( - name = "greenmail", - artifact = "com.icegreen:greenmail:" + GREENMAIL_VERS, - sha1 = "9ea96384ad2cb8118c22f493b529eb72c212691c", - ) - - maven_jar( - name = "mail", - artifact = "com.sun.mail:javax.mail:" + MAIL_VERS, - sha1 = "a055c648842c4954c1f7db7254f45d9ad565e278", - ) - - maven_jar( - name = "mime4j-core", - artifact = "org.apache.james:apache-mime4j-core:" + MIME4J_VERS, - sha1 = "c62dfe18a3b827a2c626ade0ffba44562ddf3f61", - ) - - maven_jar( - name = "mime4j-dom", - artifact = "org.apache.james:apache-mime4j-dom:" + MIME4J_VERS, - sha1 = "f2d653c617004193f3350330d907f77b60c88c56", - ) - - maven_jar( - name = "jsoup", - artifact = "org.jsoup:jsoup:1.14.3", - sha1 = "c43a81e18e6d0eb71951aa031d55d5c293c531a6", - ) - - maven_jar( - name = "ow2-asm", - artifact = "org.ow2.asm:asm:" + OW2_VERS, - sha1 = "073d7b3086e14beb604ced229c302feff6449723", - ) - - maven_jar( - name = "ow2-asm-analysis", - artifact = "org.ow2.asm:asm-analysis:" + OW2_VERS, - sha1 = "e4a258b7eb96107106c0599f0061cfc1832fe07a", - ) - - maven_jar( - name = "ow2-asm-commons", - artifact = "org.ow2.asm:asm-commons:" + OW2_VERS, - sha1 = "e86dda4696d3c185fcc95d8d311904e7ce38a53f", - ) - - maven_jar( - name = "ow2-asm-tree", - artifact = "org.ow2.asm:asm-tree:" + OW2_VERS, - sha1 = "e446a17b175bfb733b87c5c2560ccb4e57d69f1a", - ) - - maven_jar( - name = "ow2-asm-util", - artifact = "org.ow2.asm:asm-util:" + OW2_VERS, - sha1 = "c0655519f24d92af2202cb681cd7c1569df6ead6", - ) - - maven_jar( - name = "auto-value-gson-runtime", - artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION, - sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71", - ) - - maven_jar( - name = "auto-value-gson-extension", - artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION, - sha1 = "0c4c01a3e10e5b10df2e5f5697efa4bb3f453ac1", - ) - - maven_jar( - name = "auto-value-gson-factory", - artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION, - sha1 = "9ed8d79144ee8d60cc94cc11f847b5ed8ee9f19c", - ) - - maven_jar( - name = "javapoet", - artifact = "com.squareup:javapoet:1.13.0", - sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a", - ) - - maven_jar( - name = "autotransient", - artifact = "io.sweers.autotransient:autotransient:1.0.0", - sha1 = "38b1c630b8e76560221622289f37be40105abb3d", - ) - - maven_jar( - name = "mime-util", - artifact = "eu.medsea.mimeutil:mime-util:2.1.3", - attach_source = False, - sha1 = "0c9cfae15c74f62491d4f28def0dff1dabe52a47", - ) - - maven_jar( - name = "prolog-runtime", - artifact = "com.googlecode.prolog-cafe:prolog-runtime:" + PROLOG_VERS, - attach_source = False, - repository = PROLOG_REPO, - sha1 = "e9a364f4233481cce63239e8e68a6190c8f58acd", - ) - - maven_jar( - name = "prolog-compiler", - artifact = "com.googlecode.prolog-cafe:prolog-compiler:" + PROLOG_VERS, - attach_source = False, - repository = PROLOG_REPO, - sha1 = "570295026f6aa7b905e423d107cb2e081eecdc04", - ) - - maven_jar( - name = "prolog-io", - artifact = "com.googlecode.prolog-cafe:prolog-io:" + PROLOG_VERS, - attach_source = False, - repository = PROLOG_REPO, - sha1 = "1f25c4e27d22bdbc31481ee0c962a2a2853e4428", - ) - - maven_jar( - name = "cafeteria", - artifact = "com.googlecode.prolog-cafe:prolog-cafeteria:" + PROLOG_VERS, - attach_source = False, - repository = PROLOG_REPO, - sha1 = "0e6c2deeaf5054815a561cbd663566fd59b56c6c", - ) - - maven_jar( - name = "guava-retrying", - artifact = "com.github.rholder:guava-retrying:2.0.0", - sha1 = "974bc0a04a11cc4806f7c20a34703bd23c34e7f4", - ) - - maven_jar( - name = "jsr305", - artifact = "com.google.code.findbugs:jsr305:3.0.1", - sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d", - ) - - maven_jar( - name = "blame-cache", - artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS, - attach_source = False, - repository = GITILES_REPO, - sha1 = "e110f1129a31a0bbb76c28da2a1770b234f1a755", - ) - - maven_jar( - name = "gitiles-servlet", - artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS, - repository = GITILES_REPO, - sha1 = "52441c05b83291898da051591036d0d55e1f3501", - ) - - maven_jar( - name = "html-types", - artifact = "com.google.common.html.types:types:1.0.8", - sha1 = "9e9cf7bc4b2a60efeb5f5581fe46d17c068e0777", - ) - - maven_jar( - name = "icu4j", - artifact = "com.ibm.icu:icu4j:77.1", - sha1 = "38693cf0b1d7362a8b726af74dc06026a7c23809", - ) - - maven_jar( - name = "bcprov", - artifact = "org.bouncycastle:bcprov-jdk18on:" + BC_VERS, - sha1 = "e22100b41042decf09cab914a5af8d2c57b5ac4a", - ) - - maven_jar( - name = "bcpg", - artifact = "org.bouncycastle:bcpg-jdk18on:" + BC_VERS, - sha1 = "163889a825393854dbe7dc52f1a8667e715e9859", - ) - - maven_jar( - name = "bcpkix", - artifact = "org.bouncycastle:bcpkix-jdk18on:" + BC_VERS, - sha1 = "5277dfaaef2e92ce1d802499599a0ca7488f86e6", - ) - - maven_jar( - name = "bcutil", - artifact = "org.bouncycastle:bcutil-jdk18on:" + BC_VERS, - sha1 = "b95726d1d49a0c65010c59a3e6640311d951bfd1", - ) - - maven_jar( - name = "fluent-hc", - artifact = "org.apache.httpcomponents:fluent-hc:" + HTTPCOMP_VERS, - sha1 = "81a16abc0d5acb5016d5b46d4b197b53c3d6eb93", - ) - - maven_jar( - name = "httpclient", - artifact = "org.apache.httpcomponents:httpclient:" + HTTPCOMP_VERS, - sha1 = "1194890e6f56ec29177673f2f12d0b8e627dec98", - ) - - maven_jar( - name = "httpcore", - artifact = "org.apache.httpcomponents:httpcore:4.4.16", - sha1 = "51cf043c87253c9f58b539c9f7e44c8894223850", - ) - - # Test-only dependencies below. - maven_jar( - name = "junit", - artifact = "junit:junit:4.12", - sha1 = "2973d150c0dc1fefe998f834810d68f278ea58ec", - ) - - maven_jar( - name = "hamcrest-core", - artifact = "org.hamcrest:hamcrest-core:1.3", - sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0", - ) - - maven_jar( - name = "diffutils", - artifact = "com.googlecode.java-diff-utils:diffutils:1.3.0", - sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd", - ) - - maven_jar( - name = "jetty-servlet", - artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS, - sha1 = "3e648eebddbf5ad0c0f7698e50c6a69c4a77fd95", - ) - - maven_jar( - name = "jetty-security", - artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS, - sha1 = "2b545f68d45b947fdc6e279a0e8ae3630ec10e05", - ) - - maven_jar( - name = "jetty-server", - artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS, - sha1 = "ad3baf52b98b4a32f5714fe2e58ac0e502b4e4d8", - ) - - maven_jar( - name = "jetty-jmx", - artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS, - sha1 = "10d4f95fb162608be238c54ab7dfef28721227c0", - ) - - maven_jar( - name = "jetty-http", - artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS, - sha1 = "c7a3a9c599346708894cf355e03105937f45f427", - ) - - maven_jar( - name = "jetty-io", - artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS, - sha1 = "bd0ca6e5c4314972cd91f427fa09dedfe3b84ff5", - ) - - maven_jar( - name = "jetty-util", - artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS, - sha1 = "7bf7ea75644ac064199e1e32c66ccd312239f2dc", - ) - - maven_jar( - name = "jetty-util-ajax", - artifact = "org.eclipse.jetty:jetty-util-ajax:" + JETTY_VERS, - sha1 = "7b5f144a3d0cbfcac62f2fcb9e14ffa765048f0e", - ) - - maven_jar( - name = "asciidoctor", - artifact = "org.asciidoctor:asciidoctorj:1.5.7", - sha1 = "8e8c1d8fc6144405700dd8df3b177f2801ac5987", - ) - - maven_jar( - name = "javax-activation", - artifact = "javax.activation:activation:1.1.1", - sha1 = "485de3a253e23f645037828c07f1d7f1af40763a", - ) - - maven_jar( - name = "mockito", - artifact = "org.mockito:mockito-core:5.6.0", - sha1 = "550b7a0eb22e1d72d33dcc2e5ef6954f73100d76", - ) - - maven_jar( - name = "bytebuddy", - artifact = "net.bytebuddy:byte-buddy:" + BYTE_BUDDY_VERSION, - sha1 = "b69e7fff6c473d3ed2b489cdfd673a091fd94226", - ) - - maven_jar( - name = "bytebuddy-agent", - artifact = "net.bytebuddy:byte-buddy-agent:" + BYTE_BUDDY_VERSION, - sha1 = "dfb8707031008535048bad2b69735f46d0b6c5e5", - ) - - maven_jar( - name = "objenesis", - artifact = "org.objenesis:objenesis:3.0.1", - sha1 = "11cfac598df9dc48bb9ed9357ed04212694b7808", - ) - - maven_jar( - name = "roaringbitmap", - artifact = "org.roaringbitmap:RoaringBitmap:" + ROARING_BITMAP_VERSION, - sha1 = "d25b4bcb67193d587f6e0617da2c6f84e2d02a9c", - ) - - maven_jar( - name = "roaringbitmap-shims", - artifact = "org.roaringbitmap:shims:" + ROARING_BITMAP_VERSION, - sha1 = "e22be0d690a99c046bf9f57106065a77edad1eda", - )
diff --git a/tools/deps.toml b/tools/deps.toml new file mode 100644 index 0000000..7dfe305 --- /dev/null +++ b/tools/deps.toml
@@ -0,0 +1,98 @@ +[versions] +antlr = "3.5.2" +autoValueGson = "1.3.1" +bouncyCastle = "1.83" +byteBuddy = "1.18.5" +caffeine = "2.9.2" +commonmark = "0.24.0" +gitiles = "1.6.0" +greenmail = "1.5.5" +httpcomp = "4.5.14" +jetty = "9.4.57.v20241219" +mail = "1.6.0" +mime4j = "0.8.1" +ow2 = "9.9.1" +prolog = "1.4.4" +roaringBitmap = "0.9.44" + +[libraries] +activation = { module = "javax.activation:activation", version = "1.1.1" } +antlr = { module = "org.antlr:antlr", version.ref = "antlr" } +antlr-runtime = { module = "org.antlr:antlr-runtime", version.ref = "antlr" } +antlr27 = { module = "antlr:antlr", version = "2.7.7" } +aopalliance = { module = "aopalliance:aopalliance", version = "1.0" } +apache-mime4j-core = { module = "org.apache.james:apache-mime4j-core", version.ref = "mime4j" } +apache-mime4j-dom = { module = "org.apache.james:apache-mime4j-dom", version.ref = "mime4j" } +args4j = { module = "args4j:args4j", version = "2.33" } +asciidoctorj = { module = "org.asciidoctor:asciidoctorj", version = "1.5.8.1" } +asm = { module = "org.ow2.asm:asm", version.ref = "ow2" } +asm-analysis = { module = "org.ow2.asm:asm-analysis", version.ref = "ow2" } +asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "ow2" } +asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "ow2" } +asm-util = { module = "org.ow2.asm:asm-util", version.ref = "ow2" } +auto-value-gson-extension = { module = "com.ryanharter.auto.value:auto-value-gson-extension", version.ref = "autoValueGson" } +auto-value-gson-factory = { module = "com.ryanharter.auto.value:auto-value-gson-factory", version.ref = "autoValueGson" } +auto-value-gson-runtime = { module = "com.ryanharter.auto.value:auto-value-gson-runtime", version.ref = "autoValueGson" } +autolink = { module = "org.nibor.autolink:autolink", version = "0.11.0" } +automaton = { module = "dk.brics:automaton", version = "1.12-1" } +autotransient = { module = "io.sweers.autotransient:autotransient", version = "1.0.0" } +bcpg-jdk18on = { module = "org.bouncycastle:bcpg-jdk18on", version.ref = "bouncyCastle" } +bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bouncyCastle" } +bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } +bcutil-jdk18on = { module = "org.bouncycastle:bcutil-jdk18on", version.ref = "bouncyCastle" } +blame-cache = { module = "com.google.gitiles:blame-cache", version.ref = "gitiles" } +byte-buddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" } +byte-buddy-agent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" } +caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } +commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } +commons-codec = { module = "commons-codec:commons-codec", version = "1.18.0" } +commons-compress = { module = "org.apache.commons:commons-compress", version = "1.28.0" } +commons-dbcp = { module = "commons-dbcp:commons-dbcp", version = "1.4" } +commons-lang3 = { module = "org.apache.commons:commons-lang3", version = "3.18.0" } +commons-net = { module = "commons-net:commons-net", version = "3.6" } +commons-pool = { module = "commons-pool:commons-pool", version = "1.5.5" } +commons-text = { module = "org.apache.commons:commons-text", version = "1.15.0" } +commons-validator = { module = "commons-validator:commons-validator", version = "1.6" } +diffutils = { module = "io.github.java-diff-utils:java-diff-utils", version = "4.16" } +failureaccess = { module = "com.google.guava:failureaccess", version = "1.0.3" } +flexmark-all = { module = "com.vladsch.flexmark:flexmark-all", version = "0.64.0:lib" } +fluent-hc = { module = "org.apache.httpcomponents:fluent-hc", version.ref = "httpcomp" } +gitiles-servlet = { module = "com.google.gitiles:gitiles-servlet", version.ref = "gitiles" } +greenmail = { module = "com.icegreen:greenmail", version.ref = "greenmail" } +guava = { module = "com.github.ben-manes.caffeine:guava", version.ref = "caffeine" } +guava-retrying = { module = "com.github.rholder:guava-retrying", version = "2.0.0" } +httpclient = { module = "org.apache.httpcomponents:httpclient", version.ref = "httpcomp" } +httpcore = { module = "org.apache.httpcomponents:httpcore", version = "4.4.16" } +icu4j = { module = "com.ibm.icu:icu4j", version = "78.2" } +jakarta-inject-api = { module = "jakarta.inject:jakarta.inject-api", version = "2.0.1" } +JavaEWAH = { module = "com.googlecode.javaewah:JavaEWAH", version = "1.1.12" } +javapoet = { module = "com.squareup:javapoet", version = "1.13.0" } +javax-inject = { module = "javax.inject:javax.inject", version = "1" } +javax-mail = { module = "com.sun.mail:javax.mail", version.ref = "mail" } +javax-servlet-api = { module = "javax.servlet:javax.servlet-api", version = "4.0.1" } +jetty-http = { module = "org.eclipse.jetty:jetty-http", version.ref = "jetty" } +jetty-io = { module = "org.eclipse.jetty:jetty-io", version.ref = "jetty" } +jetty-jmx = { module = "org.eclipse.jetty:jetty-jmx", version.ref = "jetty" } +jetty-security = { module = "org.eclipse.jetty:jetty-security", version.ref = "jetty" } +jetty-server = { module = "org.eclipse.jetty:jetty-server", version.ref = "jetty" } +jetty-servlet = { module = "org.eclipse.jetty:jetty-servlet", version.ref = "jetty" } +jetty-util = { module = "org.eclipse.jetty:jetty-util", version.ref = "jetty" } +jetty-util-ajax = { module = "org.eclipse.jetty:jetty-util-ajax", version.ref = "jetty" } +json-smart = { module = "net.minidev:json-smart", version = "1.1.1" } +jsoup = { module = "org.jsoup:jsoup", version = "1.14.3" } +jsr305 = { module = "com.google.code.findbugs:jsr305", version = "3.0.1" } +junit = { module = "junit:junit", version = "4.13.2" } +mime-util = { module = "eu.medsea.mimeutil:mime-util", version = "2.1.3" } +mockito-core = { module = "org.mockito:mockito-core", version = "5.21.0" } +objenesis = { module = "org.objenesis:objenesis", version = "3.4" } +prolog-cafeteria = { module = "com.googlecode.prolog-cafe:prolog-cafeteria", version.ref = "prolog" } +prolog-compiler = { module = "com.googlecode.prolog-cafe:prolog-compiler", version.ref = "prolog" } +prolog-io = { module = "com.googlecode.prolog-cafe:prolog-io", version.ref = "prolog" } +prolog-runtime = { module = "com.googlecode.prolog-cafe:prolog-runtime", version.ref = "prolog" } +RoaringBitmap = { module = "org.roaringbitmap:RoaringBitmap", version.ref = "roaringBitmap" } +shims = { module = "org.roaringbitmap:shims", version.ref = "roaringBitmap" } +stringtemplate = { module = "org.antlr:stringtemplate", version = "4.0.2" } +types = { module = "com.google.common.html.types:types", version = "1.0.8" }
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD index 6b2a22c..a118371 100644 --- a/tools/eclipse/BUILD +++ b/tools/eclipse/BUILD
@@ -41,21 +41,39 @@ ["//plugins/%s:%s__plugin_test_deps" % (n, n) for n in CUSTOM_PLUGINS_TEST_DEPS], ) +genrule( + name = "autovalue_apt_carrier_src", + outs = ["AutoValueAptCarrier.java"], + cmd = """ +cat > "$@" <<'EOF' +package tools.eclipse; +// Generated by Bazel: exists only to attach annotation processors. +final class AutoValueAptCarrier {} +EOF +""", +) + +java_library( + name = "autovalue_apt_carrier", + srcs = [":autovalue_apt_carrier_src"], + neverlink = 1, + plugins = [ + "//lib/auto:auto-annotation-plugin", + "//lib/auto:auto-builder-plugin", + "//lib/auto:auto-value-plugin", + "//lib/auto:auto-oneof-plugin", + "//lib/auto:auto-value-gson-plugin", + "//lib/auto:auto-factory-plugin", + ], + visibility = ["//visibility:private"], + deps = [ + "//lib/auto:auto-factory", + "//lib/auto:auto-value-annotations", + "//lib/auto:auto-value-gson", + ], +) + classpath_collector( name = "autovalue_classpath_collect", - deps = [ - "//lib/auto:auto-value", - "@auto-common//jar", - "@auto-factory//jar", - "@auto-service-annotations//jar", - "@auto-value-annotations//jar", - "@auto-value-gson-extension//jar", - "@auto-value-gson-factory//jar", - "@auto-value-gson-runtime//jar", - "@autotransient//jar", - "@gson//jar", - "@guava//jar", - "@javapoet//jar", - "@javax_inject//jar", - ], + deps = [":autovalue_apt_carrier"], )
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py index 2302ead..a8584a6 100755 --- a/tools/eclipse/project.py +++ b/tools/eclipse/project.py
@@ -22,7 +22,7 @@ import sys MAIN = '//tools/eclipse:classpath' -AUTO = '//lib/auto:auto-value' +AUTO_COLLECT = '//tools/eclipse:autovalue_classpath_collect' def JRE(java_vers = '21'): return '/'.join([ @@ -33,12 +33,11 @@ # Map of targets to corresponding classpath collector rules cp_targets = { - AUTO: '//tools/eclipse:autovalue_classpath_collect', MAIN: '//tools/eclipse:main_classpath_collect', } ROOT = os.path.abspath(__file__) -while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')): +while not os.path.exists(os.path.join(ROOT, 'MODULE.bazel')): ROOT = os.path.dirname(ROOT) opts = argparse.ArgumentParser("Create Eclipse Project") @@ -87,13 +86,10 @@ def _build_bazel_cmd(*args): - build = False cmd = [bazel_exe] if batch_option: cmd.append('--batch') for arg in args: - if arg == "build": - build = True cmd.append(arg) if custom_java: cmd.append('--config=java%s' % custom_java) @@ -105,6 +101,10 @@ return subprocess.check_output(_build_bazel_cmd('info', 'output_base')).strip() +def retrieve_exec_root(): + return subprocess.check_output(_build_bazel_cmd('info', 'execution_root')).strip() + + def gen_bazel_path(ext_location): bazel = subprocess.check_output(['which', bazel_exe]).strip().decode('UTF-8') with open(os.path.join(ROOT, ".bazel_path"), 'w') as fd: @@ -114,15 +114,39 @@ def _query_classpath(target): - deps = [] t = cp_targets[target] try: subprocess.check_call(_build_bazel_cmd('build', t)) except subprocess.CalledProcessError: exit(1) - name = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + '.runtime_classpath' - deps = [line.rstrip('\n') for line in open(name)] - return deps + + base = 'bazel-bin/tools/eclipse/' + t.split(':')[1] + + runtime_name = base + '.runtime_classpath' + runtime = [line.rstrip('\n') for line in open(runtime_name)] + + sources = [] + sources_name = base + '.source_classpath' + if os.path.exists(sources_name): + sources = [line.rstrip('\n') for line in open(sources_name)] + + return runtime, sources + + +def _normalize_jar_basename(p): + b = os.path.basename(p) + for pref in ('processed_', 'header_'): + if b.startswith(pref): + b = b[len(pref):] + if b.endswith('-sources.jar'): + b = b[:-len('-sources.jar')] + '.jar' + return b + + +def _resolve_repo_path(output_base, p): + if p.startswith("external"): + return os.path.join(output_base, p) + return p def gen_project(name='gerrit', root=ROOT): @@ -165,7 +189,7 @@ </classpath>""" % {"testpath": testpath}, file=fd) -def gen_classpath(ext): +def gen_classpath(exec_root, output_base): def make_classpath(): impl = xml.dom.minidom.getDOMImplementation() return impl.createDocument(None, 'classpath', None) @@ -182,6 +206,9 @@ classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/src') classpathentry('src', 'modules/jgit/org.eclipse.jgit.ssh.apache/resources') + def import_prettify_sources(): + classpathentry('src', 'modules/java-prettify/src') + def classpathentry(kind, path, src=None, out=None, exported=None, excluding=None): e = doc.createElement('classpathentry') e.setAttribute('kind', kind) @@ -227,13 +254,19 @@ # Classpath entries are absolute for cross-cell support java_library = re.compile('bazel-out/.*?-fastbuild/bin/(.*)/[^/]+[.]jar$') proto_library = re.compile('bazel-out/.*?-fastbuild/bin/(.*)proto/(.*)_proto-speed[.]jar$') - srcs = re.compile('(.*/external/[^/]+)/jar/(.*)[.]jar') - for p in _query_classpath(MAIN): + + runtime_cp, source_cp = _query_classpath(MAIN) + + source_by_basename = {} + for p in source_cp: + source_by_basename[_normalize_jar_basename(p)] = p + + for p in runtime_cp: if p.endswith('-src.jar'): continue m = java_library.match(p) - if m: + if m and "/external/" not in p: src.add(m.group(1)) # Exceptions: both source and lib if p.endswith('libquery_parser.jar') or \ @@ -249,14 +282,14 @@ if p.endswith( "external/bazel_tools/tools/jdk/TestRunner_deploy.jar"): continue - if p.startswith("external"): - p = os.path.join(ext, p) + p = _resolve_repo_path(output_base, p) lib.add(p) classpathentry('src', 'java') classpathentry('src', 'javatests', out='eclipse-out/test') classpathentry('src', 'resources') import_jgit_sources() + import_prettify_sources() for s in sorted(src): out = None @@ -290,14 +323,31 @@ for libs in [lib]: for j in sorted(libs): + j = _prefer_unprocessed_jar("", j) + + # Some rules_jvm_external entries can be listed as processed_* jars + # that are not materialized locally. Eclipse treats them as missing + # required libraries. Skip such entries rather than generating a + # broken .classpath. + if (os.path.basename(j).startswith("processed_") or + os.path.basename(j).startswith("header_")) and not os.path.exists(j): + continue + + # java-prettify is vendored in-tree under modules/java-prettify. + # Eclipse compiles it directly from sources, so ignore the external + # libjava-prettify.jar entry produced by Bazel. + if os.path.basename(j) == "libjava-prettify.jar" and "/external/" in j: + continue + s = None - m = srcs.match(j) - if m: - prefix = m.group(1) - suffix = m.group(2) - p = os.path.join(prefix, "jar", "%s-src.jar" % suffix) - if os.path.exists(p): - s = p + + # Attach sources using the classpath_collector output from rules_jvm_external. + # This replaces the previous heuristic-based source lookup. + key = _normalize_jar_basename(j) + if key in source_by_basename: + sp = _resolve_repo_path(output_base, source_by_basename[key]) + s = sp + if args.plugins: classpathentry('lib', j, s, exported=True) else: @@ -329,13 +379,44 @@ file=sys.stderr) -def gen_factorypath(ext): +def _prefer_unprocessed_jar(ext, jar): + b = os.path.basename(jar) + if b.startswith('processed_'): + alt = os.path.join(os.path.dirname(jar), b[len('processed_'):]) + elif b.startswith('header_'): + alt = os.path.join(os.path.dirname(jar), b[len('header_'):]) + else: + return jar + if os.path.exists(os.path.join(ext, alt)): + return alt + return jar + + +def gen_factorypath(exec_root, output_base): doc = xml.dom.minidom.getDOMImplementation().createDocument( None, 'factorypath', None) - for jar in _query_classpath(AUTO): + + try: + subprocess.check_call(_build_bazel_cmd('build', AUTO_COLLECT)) + except subprocess.CalledProcessError: + exit(1) + + base = 'bazel-bin/tools/eclipse/' + AUTO_COLLECT.split(':')[1] + processors_name = base + '.processor_classpath' + + processors = [] + if os.path.exists(processors_name): + processors = [line.rstrip('\n') for line in open(processors_name)] + + for jar in processors: + jar = _prefer_unprocessed_jar(exec_root, jar) + jar = _resolve_repo_path(output_base, jar) e = doc.createElement('factorypathentry') e.setAttribute('kind', 'EXTJAR') - e.setAttribute('id', os.path.join(ext, jar)) + if os.path.isabs(jar): + e.setAttribute('id', jar) + else: + e.setAttribute('id', os.path.join(exec_root, jar)) e.setAttribute('enabled', 'true') e.setAttribute('runInBatchMode', 'false') doc.documentElement.appendChild(e) @@ -346,11 +427,12 @@ try: - ext_location = retrieve_ext_location().decode("utf-8") + output_base = retrieve_ext_location().decode("utf-8") + exec_root = retrieve_exec_root().decode("utf-8") gen_project(args.project_name) - gen_classpath(ext_location) - gen_factorypath(ext_location) - gen_bazel_path(ext_location) + gen_classpath(exec_root, output_base) + gen_factorypath(exec_root, output_base) + gen_bazel_path(output_base) try: subprocess.check_call(_build_bazel_cmd('build', MAIN)) @@ -359,3 +441,4 @@ except KeyboardInterrupt: print('Interrupted by user', file=sys.stderr) exit(1) +
diff --git a/tools/java_deps.MODULE.bazel b/tools/java_deps.MODULE.bazel new file mode 100644 index 0000000..d456542 --- /dev/null +++ b/tools/java_deps.MODULE.bazel
@@ -0,0 +1,179 @@ +# Module-file fragment for Java/Maven external dependencies. +# +# This file is included from the root MODULE.bazel via include(). +# It contains the rules_jvm_external configuration for Gerrit: +# - forced artifact versions to keep Gerrit and JGit aligned +# - excluded artifacts to prevent duplicate classes and unwanted runtimes +# - TOML-based dependency import + lockfile enforcement +# +# NOTE: MODULE/.MODULE.bazel files are evaluated in the bzlmod resolution phase +# and do not allow load() or function definitions. Keep this file self-contained. + +maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven") + +# Although flexmark is published as many small modules, the `flexmark-all` +# artifact is a shaded aggregate that already includes all required classes. +# Exclude the modular artifacts to avoid duplicate classes in the WAR. +_FLEXMARK_GROUP = "com.vladsch.flexmark" + +_FLEXMARK_MODULES = [ + "flexmark", + "flexmark-ext-abbreviation", + "flexmark-ext-admonition", + "flexmark-ext-anchorlink", + "flexmark-ext-aside", + "flexmark-ext-attributes", + "flexmark-ext-autolink", + "flexmark-ext-definition", + "flexmark-ext-emoji", + "flexmark-ext-enumerated-reference", + "flexmark-ext-escaped-character", + "flexmark-ext-footnotes", + "flexmark-ext-gfm-issues", + "flexmark-ext-gfm-strikethrough", + "flexmark-ext-gfm-tasklist", + "flexmark-ext-gfm-users", + "flexmark-ext-gitlab", + "flexmark-ext-ins", + "flexmark-ext-jekyll-front-matter", + "flexmark-ext-jekyll-tag", + "flexmark-ext-macros", + "flexmark-ext-media-tags", + "flexmark-ext-resizable-image", + "flexmark-ext-superscript", + "flexmark-ext-tables", + "flexmark-ext-toc", + "flexmark-ext-typographic", + "flexmark-ext-wikilink", + "flexmark-ext-xwiki-macros", + "flexmark-ext-yaml-front-matter", + "flexmark-ext-youtube-embedded", + "flexmark-html2md-converter", + "flexmark-jira-converter", + "flexmark-pdf-converter", + "flexmark-profile-pegdown", + "flexmark-util", + "flexmark-util-ast", + "flexmark-util-builder", + "flexmark-util-collection", + "flexmark-util-data", + "flexmark-util-dependency", + "flexmark-util-format", + "flexmark-util-html", + "flexmark-util-misc", + "flexmark-util-options", + "flexmark-util-sequence", + "flexmark-util-visitor", + "flexmark-youtrack-converter", +] + +_OTHER_EXCLUDES = [ + # Logging clashes / legacy implementations. + "log4j:log4j", + "org.slf4j:slf4j-log4j12", + + # openid4java transitively depends on commons-logging; not needed at runtime. + "commons-logging:commons-logging", + + # commons-validator: exclude unused transitive commons-* stack. + "commons-beanutils:commons-beanutils", + "commons-collections:commons-collections", + "commons-digester:commons-digester", + + # sshd-common/core bundled inside sshd-osgi in your runtime set. + "org.apache.sshd:sshd-common", + "org.apache.sshd:sshd-core", + + # Unused Lucene modules (keep WAR footprint aligned with pre-migration). + "org.apache.lucene:lucene-facet", + "org.apache.lucene:lucene-queries", + "org.apache.lucene:lucene-sandbox", + + # soy-template transitives not required at runtime. + "com.google.escapevelocity:escapevelocity", + "javax.annotation:jsr250-api", + "org.json:json", + + # Redundant legacy XML APIs already provided by JDK; pulled via html/xml parsers. + "xml-apis:xml-apis", +] + +EXCLUDED_ARTIFACTS = ( + ["%s:%s" % (_FLEXMARK_GROUP, a) for a in _FLEXMARK_MODULES] + + _OTHER_EXCLUDES +) + +# Root-level dependency versions declared by Gerrit must take precedence over +# versions contributed by layered modules (e.g. JGit) when resolving the shared +# maven.install("external_deps") repository. +# +# rules_jvm_external currently lacks a mechanism for the root module to globally +# enforce its declared versions across the layered Maven graph when using +# bzlmod. In particular, version_conflict_policy = "pinned" still fails early +# with duplicate artifacts when multiple modules contribute different versions. +# +# As a workaround, we explicitly force the root-selected versions here via +# maven.amend_artifact(force_version = "true") for GA coordinates that may also +# be introduced transitively by JGit. +# +# Versions themselves are sourced from deps.toml and the pinned lockfile. +# +# TODO(davido): Remove this workaround once global root version enforcement is +# supported in rules_jvm_external: +# https://github.com/bazel-contrib/rules_jvm_external/issues/1549 +_GERRIT_FORCED_ARTIFACTS = [ + "args4j:args4j", + "com.googlecode.javaewah:JavaEWAH", + "commons-codec:commons-codec", + "org.apache.commons:commons-lang3", + "org.eclipse.jetty:jetty-http", + "org.eclipse.jetty:jetty-io", + "org.eclipse.jetty:jetty-security", + "org.eclipse.jetty:jetty-server", + "org.eclipse.jetty:jetty-util", + "org.eclipse.jetty:jetty-util-ajax", +] + +[ + maven.amend_artifact( + name = "external_deps", + coordinates = coord, + force_version = "true", + ) + for coord in _GERRIT_FORCED_ARTIFACTS +] + +# Empty maven.install call to apply the configuration to the installation from +# toml files. +maven.install( + name = "external_deps", + artifacts = [], + # CRITICAL: Must remain "error". + # This guarantees fail-fast behavior if multiple versions of the same + # artifact are introduced by contributing modules. Downgrading this to + # "warn" would allow version skew to silently leak into release.war. + duplicate_version_warning = "error", + excluded_artifacts = EXCLUDED_ARTIFACTS, + fail_if_repin_required = True, + fail_on_missing_checksum = True, + fetch_sources = True, + known_contributing_modules = [ + "jgit", + "gerrit", + ], + lock_file = "//:external_deps.lock.json", + repositories = [ + "https://repo1.maven.org/maven2", + "https://gerrit-maven.storage.googleapis.com", + ], + version_conflict_policy = "pinned", +) +maven.from_toml( + name = "external_deps", + libs_versions_toml = "//tools:deps.toml", +) +maven.from_toml( + name = "external_deps", + libs_versions_toml = "//tools:nongoogle.toml", +) +use_repo(maven, "external_deps")
diff --git a/tools/js/eslint-chdir.js b/tools/js/eslint-chdir.js index 5aea704..d9fd479 100644 --- a/tools/js/eslint-chdir.js +++ b/tools/js/eslint-chdir.js
@@ -15,16 +15,118 @@ * limitations under the License. */ -// Eslint 7 introduced a breaking change - it uses the current workdir instead -// of the configuration file directory for resolving relative paths: -// https://eslint.org/docs/user-guide/migrating-to-7.0.0#base-path-change -// This file is loaded before the eslint and sets the current directory -// back to the location of configuration file. +// ESLint resolves relative paths from the current working directory. +// +// In workspace mode (`lint_bin`), this works with the regular Node.js module +// layout under `polygerrit-ui/app/node_modules`. +// +// In Bazel test mode (`lint_test`), npm dependencies are exposed through +// multiple runfiles trees instead. Typed linting with TypeScript does not see +// the same module and type environment from that layout as from the workspace +// layout. +// +// To align `lint_test` with `lint_bin`, synthesize a local +// `polygerrit-ui/app/node_modules` by symlinking entries from the runfiles +// npm trees, then prepend it to NODE_PATH so resolution matches workspace mode. +const fs = require('fs'); +const Module = require('module'); const path = require('path'); -const configParamIndex = - process.argv.findIndex(arg => arg === '-c' || arg === '---config'); -if (configParamIndex >= 0 && configParamIndex + 1 < process.argv.length) { - const dirName = path.dirname(process.argv[configParamIndex + 1]); - process.chdir(dirName); + +function pathExists(filePath) { + try { + return fs.existsSync(filePath); + } catch { + return false; + } } + +function readDirEntries(dirPath) { + try { + return fs.readdirSync(dirPath, {withFileTypes: true}); + } catch { + return []; + } +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, {recursive: true}); +} + +function symlinkIfMissing(targetPath, linkPath) { + if (pathExists(linkPath)) return; + + try { + fs.symlinkSync(targetPath, linkPath); + } catch { + // Ignore races and pre-existing entries. + } +} + +function mergeNodeModules(destinationDir, sourceDirs) { + ensureDir(destinationDir); + + for (const sourceDir of sourceDirs) { + for (const entry of readDirEntries(sourceDir)) { + const sourceEntry = path.join(sourceDir, entry.name); + const destEntry = path.join(destinationDir, entry.name); + + if (entry.name.startsWith('@') && entry.isDirectory()) { + ensureDir(destEntry); + + for (const scopedEntry of readDirEntries(sourceEntry)) { + symlinkIfMissing( + path.join(sourceEntry, scopedEntry.name), + path.join(destEntry, scopedEntry.name) + ); + } + continue; + } + + symlinkIfMissing(sourceEntry, destEntry); + } + } +} + +function getRunfilesRoot() { + return process.env.RUNFILES_DIR || process.env.TEST_SRCDIR || ''; +} + +function getRunfilesNodeModules(runfilesRoot) { + return [ + path.join(runfilesRoot, 'ui_dev_npm/node_modules'), + path.join(runfilesRoot, 'ui_npm/node_modules'), + path.join(runfilesRoot, '_main/node_modules'), + ].filter(pathExists); +} + +function prependNodePath(paths) { + const existing = process.env.NODE_PATH + ? process.env.NODE_PATH.split(path.delimiter).filter(Boolean) + : []; + + process.env.NODE_PATH = [...paths, ...existing].join(path.delimiter); + Module._initPaths(); +} + +function getConfigDirFromArgv(argv) { + const configArgIndex = argv.findIndex(arg => arg === '-c' || arg === '--config'); + if (configArgIndex < 0 || configArgIndex + 1 >= argv.length) return ''; + + return path.dirname(argv[configArgIndex + 1]); +} + +const configDir = getConfigDirFromArgv(process.argv); +if (!configDir) return; + +process.chdir(configDir); + +const runfilesRoot = getRunfilesRoot(); +if (!runfilesRoot) return; + +const runfilesNodeModules = getRunfilesNodeModules(runfilesRoot); +if (runfilesNodeModules.length === 0) return; + +const localNodeModules = path.join(process.cwd(), 'node_modules'); +mergeNodeModules(localNodeModules, runfilesNodeModules); +prependNodePath([localNodeModules, ...runfilesNodeModules]);
diff --git a/tools/maven/BUILD b/tools/maven/BUILD index 224b92b..b8beef3 100644 --- a/tools/maven/BUILD +++ b/tools/maven/BUILD
@@ -1,4 +1,4 @@ -load("//:version.bzl", "GERRIT_VERSION") +load("@gerrit_api_version//:version.bzl", "GERRIT_API_VERSION") load("//tools/maven:package.bzl", "maven_package") maven_package( @@ -17,6 +17,6 @@ "gerrit-extension-api": "//java/com/google/gerrit/extensions:extension-api_deploy.jar", "gerrit-plugin-api": "//plugins:plugin-api_deploy.jar", }, - version = GERRIT_VERSION, - war = {"gerrit-war": "//:release"}, + version = GERRIT_API_VERSION, + war = {"gerrit-war": "//:release.war"}, )
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml index 3844275..c88b539 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.13.6-SNAPSHOT</version> + <version>3.14.0-SNAPSHOT</version> <packaging>jar</packaging> <name>Gerrit Code Review - Acceptance Test Framework</name> <description>Framework for Gerrit's acceptance tests</description>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml index cfb867e..16e6bfe 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.13.6-SNAPSHOT</version> + <version>3.14.0-SNAPSHOT</version> <packaging>jar</packaging> <name>Gerrit Code Review - Extension API</name> <description>API for Gerrit Extensions</description>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml index f50dabe..7fd3ab4 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.13.6-SNAPSHOT</version> + <version>3.14.0-SNAPSHOT</version> <packaging>jar</packaging> <name>Gerrit Code Review - Plugin API</name> <description>API for Gerrit Plugins</description>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml index f72b9f5..ffc52bb 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.13.6-SNAPSHOT</version> + <version>3.14.0-SNAPSHOT</version> <packaging>war</packaging> <name>Gerrit Code Review - WAR</name> <description>Gerrit WAR</description>
diff --git a/tools/maven/mvn.py b/tools/maven/mvn.py index 61d2cf6..d815580 100755 --- a/tools/maven/mvn.py +++ b/tools/maven/mvn.py
@@ -60,7 +60,7 @@ exit(1) root = path.abspath(__file__) -while not path.exists(path.join(root, 'WORKSPACE')): +while not path.exists(path.join(root, 'MODULE.bazel')): root = path.dirname(root) GROUP_ID = 'com.google.gerrit'
diff --git a/tools/maven/package.bzl b/tools/maven/package.bzl index e9c5be3..f9fccfb 100644 --- a/tools/maven/package.bzl +++ b/tools/maven/package.bzl
@@ -39,7 +39,7 @@ doc = {}, war = {}): build_cmd = ["bazel_cmd", "build", "'$$@'"] - mvn_cmd = ["python", "tools/maven/mvn.py", "-v", version] + mvn_cmd = ["python3", "tools/maven/mvn.py", "-v", version] api_cmd = mvn_cmd[:] api_targets = [] for type, d in [("jar", jar), ("java-source", src), ("javadoc", doc)]:
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl deleted file mode 100644 index f8e2591..0000000 --- a/tools/nongoogle.bzl +++ /dev/null
@@ -1,397 +0,0 @@ -""" -Dependencies that are exempted from requiring a Library-Compliance approval -from a Googler. -""" - -load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") -load("@bazel_tools//tools/build_defs/repo:utils.bzl", "maybe") -load("//tools/bzl:maven_jar.bzl", "maven_jar") - -AUTO_COMMON_VERSION = "1.2.2" - -AUTO_FACTORY_VERSION = "1.0.1" - -AUTO_VALUE_VERSION = "1.11.0" - -GUAVA_VERSION = "33.4.8-jre" - -GUAVA_DOC_URL = "https://guava.dev/releases/" + GUAVA_VERSION + "/api/docs/" - -def archive_dependencies(): - return [ - { - "name": "platforms", - "urls": [ - "https://mirror.bazel.build/github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz", - "https://github.com/bazelbuild/platforms/releases/download/0.0.10/platforms-0.0.10.tar.gz", - ], - "sha256": "218efe8ee736d26a3572663b374a253c012b716d8af0c07e842e82f238a0a7ee", - }, - { - "name": "bazel_features", - "strip_prefix": "bazel_features-1.11.0", - "urls": [ - "https://github.com/bazel-contrib/bazel_features/releases/download/v1.11.0/bazel_features-v1.11.0.tar.gz", - ], - "sha256": "2cd9e57d4c38675d321731d65c15258f3a66438ad531ae09cb8bb14217dc8572", - }, - { - "name": "rules_java", - "urls": [ - "https://github.com/bazelbuild/rules_java/releases/download/7.6.1/rules_java-7.6.1.tar.gz", - ], - "sha256": "f8ae9ed3887df02f40de9f4f7ac3873e6dd7a471f9cddf63952538b94b59aeb3", - }, - { - "name": "rules_proto", - "strip_prefix": "rules_proto-6.0.0", - "urls": [ - "https://github.com/bazelbuild/rules_proto/releases/download/6.0.0/rules_proto-6.0.0.tar.gz", - ], - "sha256": "303e86e722a520f6f326a50b41cfc16b98fe6d1955ce46642a5b7a67c11c0f5d", - }, - { - "name": "toolchains_protoc", - "strip_prefix": "toolchains_protoc-0.3.0", - "urls": [ - "https://github.com/aspect-build/toolchains_protoc/releases/download/v0.3.0/toolchains_protoc-v0.3.0.tar.gz", - ], - "sha256": "117af61ee2f1b9b014dcac7c9146f374875551abb8a30e51d1b3c5946d25b142", - }, - { - "name": "ubuntu2204_jdk21", - "strip_prefix": "rbe_autoconfig-5.2.0", - "urls": [ - "https://gerrit-bazel.storage.googleapis.com/rbe_autoconfig/v5.2.0.tar.gz", - "https://github.com/davido/rbe_autoconfig/releases/download/v5.2.0/v5.2.0.tar.gz", - ], - "sha256": "294e5d4adea036da243f3c007b098d97229cc02a14bf10d256bd82d5b62a56d9", - }, - ] - -def declare_nongoogle_deps(): - """loads dependencies that are not used at Google. - - Changes to versions are exempt from library compliance review. New - dependencies must pass through library compliance review. This is - enforced by //lib:nongoogle_test. - """ - - for dependency in archive_dependencies(): - params = {} - params.update(**dependency) - maybe(http_archive, params.pop("name"), **params) - - maven_jar( - name = "log4j", - artifact = "ch.qos.reload4j:reload4j:1.2.26", - sha1 = "f9a29cea570c15844d2ec98bf8e2e523017a6a53", - ) - - SLF4J_VERS = "1.7.36" - - maven_jar( - name = "log-api", - artifact = "org.slf4j:slf4j-api:" + SLF4J_VERS, - sha1 = "6c62681a2f655b49963a5983b8b0950a6120ae14", - ) - - maven_jar( - name = "log-ext", - artifact = "org.slf4j:slf4j-ext:" + SLF4J_VERS, - sha1 = "99f282aea4b6dbca04d00f0ade6e5ed61ee7091a", - ) - - maven_jar( - name = "impl-log4j", - artifact = "org.slf4j:slf4j-reload4j:" + SLF4J_VERS, - sha1 = "db708f7d959dee1857ac524636e85ecf2e1781c1", - ) - - maven_jar( - name = "jcl-over-slf4j", - artifact = "org.slf4j:jcl-over-slf4j:" + SLF4J_VERS, - sha1 = "d877e195a05aca4a2f1ad2ff14bfec1393af4b5e", - ) - - maven_jar( - name = "j2objc", - artifact = "com.google.j2objc:j2objc-annotations:1.1", - sha1 = "ed28ded51a8b1c6b112568def5f4b455e6809019", - ) - - # Transitive dependency of commons-compress - maven_jar( - name = "tukaani-xz", - artifact = "org.tukaani:xz:1.10", - sha1 = "1be8166f89e035a56c6bfc67dbc423996fe577e2", - ) - - maven_jar( - name = "dropwizard-core", - artifact = "io.dropwizard.metrics:metrics-core:4.2.30", - sha1 = "4c0093ffbe0d6a90253e47277ce6dc4f759aff7b", - ) - - SSHD_VERS = "2.16.0" - - maven_jar( - name = "sshd-osgi", - artifact = "org.apache.sshd:sshd-osgi:" + SSHD_VERS, - sha1 = "87cab2aaa6e06c5d48d746e90f0b3635f8c06419", - ) - - maven_jar( - name = "sshd-sftp", - artifact = "org.apache.sshd:sshd-sftp:" + SSHD_VERS, - sha1 = "09d9e7024535fb4a3f74367ba7e0a2f5093af638", - ) - - maven_jar( - name = "mina-core", - artifact = "org.apache.mina:mina-core:2.2.4", - sha1 = "f76b231c8a332640a4b1deef5262c603b088be02", - ) - - maven_jar( - name = "sshd-mina", - artifact = "org.apache.sshd:sshd-mina:" + SSHD_VERS, - sha1 = "9247372c4b7fc88d69d4e1bd7de281b3b74f1b3f", - ) - - maven_jar( - name = "openid-consumer", - artifact = "org.openid4java:openid4java:1.0.0", - sha1 = "541091bb49f2c0d583544c5bb1e6df7612d31e3e", - ) - - maven_jar( - name = "nekohtml", - artifact = "net.sourceforge.nekohtml:nekohtml:1.9.10", - sha1 = "14052461031a7054aa094f5573792feb6686d3de", - ) - - maven_jar( - name = "xerces", - artifact = "xerces:xercesImpl:2.8.1", - attach_source = False, - sha1 = "25101e37ec0c907db6f0612cbf106ee519c1aef1", - ) - - maven_jar( - name = "jruby", - artifact = "org.jruby:jruby-complete:9.1.17.0", - sha1 = "76716d529710fc03d1d429b43e3cedd4419f78d4", - ) - - maven_jar( - name = "commons-io", - artifact = "commons-io:commons-io:2.18.0", - sha1 = "44084ef756763795b31c578403dd028ff4a22950", - ) - - # Google internal dependencies: these are developed at Google, so there is - # no concern about version skew. - - maven_jar( - name = "auto-common", - artifact = "com.google.auto:auto-common:" + AUTO_COMMON_VERSION, - sha1 = "9d38f10e22411681cf1d1ee3727e002af19f2c9e", - ) - - maven_jar( - name = "auto-factory", - artifact = "com.google.auto.factory:auto-factory:" + AUTO_FACTORY_VERSION, - sha1 = "f81ece06b6525085da217cd900116f44caafe877", - ) - - maven_jar( - name = "auto-service-annotations", - artifact = "com.google.auto.service:auto-service-annotations:" + AUTO_FACTORY_VERSION, - sha1 = "ac86dacc0eb9285ea9d42eee6aad8629ca3a7432", - ) - - maven_jar( - name = "auto-value", - artifact = "com.google.auto.value:auto-value:" + AUTO_VALUE_VERSION, - sha1 = "d1fd0e74d20e922145c3fede3f05e246bb6be281", - ) - - maven_jar( - name = "auto-value-annotations", - artifact = "com.google.auto.value:auto-value-annotations:" + AUTO_VALUE_VERSION, - sha1 = "f0d047931d07cfbc6fa4079854f181ff62891d6f", - ) - - maven_jar( - name = "error-prone-annotations", - artifact = "com.google.errorprone:error_prone_annotations:2.36.0", - sha1 = "227d4d4957ccc3dc5761bd897e3a0ee587e750a7", - ) - - FLOGGER_VERS = "0.8" - - maven_jar( - name = "flogger", - artifact = "com.google.flogger:flogger:" + FLOGGER_VERS, - sha1 = "753f5ef5b084dbff3ab3030158ed128711745b06", - ) - - maven_jar( - name = "flogger-log4j-backend", - artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS, - sha1 = "7486b1c0138647cd7714eccb8ce37b5f2ae20a76", - ) - - maven_jar( - name = "flogger-google-extensions", - artifact = "com.google.flogger:google-extensions:" + FLOGGER_VERS, - sha1 = "42781a3d970e18c96bb0a8d3ddd94d6237aa0612", - ) - - maven_jar( - name = "flogger-system-backend", - artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS, - sha1 = "24b2a20600b1f313540ead4b393813efa13ce14a", - ) - - maven_jar( - name = "guava", - artifact = "com.google.guava:guava:" + GUAVA_VERSION, - sha1 = "e70a3268e6cd3e7d458aa15787ce6811c34e96ae", - ) - - maven_jar( - name = "guava-testlib", - artifact = "com.google.guava:guava-testlib:" + GUAVA_VERSION, - sha1 = "7443cfac765d74b8a31bbe6c49357715e32f714c", - ) - - GUICE_VERS = "6.0.0" - - maven_jar( - name = "guice-library", - artifact = "com.google.inject:guice:" + GUICE_VERS, - sha1 = "9b422c69c4fa1ea95b2615444a94fede9b02fc40", - ) - - maven_jar( - name = "guice-assistedinject", - artifact = "com.google.inject.extensions:guice-assistedinject:" + GUICE_VERS, - sha1 = "849d991e4adf998cb9877124fe74b063c88726cf", - ) - - maven_jar( - name = "guice-servlet", - artifact = "com.google.inject.extensions:guice-servlet:" + GUICE_VERS, - sha1 = "1a505f5f1a269e01946790e863178a5055de4fa0", - ) - - # Keep this version of Soy synchronized with the version used in Gitiles. - maven_jar( - name = "soy", - artifact = "com.google.template:soy:2024-01-30", - sha1 = "6e9ccb00926325c7a9293ed05a2eaf56ea15d60e", - ) - - maven_jar( - name = "gson", - artifact = "com.google.code.gson:gson:2.12.1", - sha1 = "4e773a317740b83b43cfc3d652962856041697cb", - ) - - maven_jar( - name = "protobuf-java", - artifact = "com.google.protobuf:protobuf-java:3.25.3", - sha1 = "d3200261955f3298e0d85c9892201e70492ce8eb", - ) - - # Test-only dependencies below. - maven_jar( - name = "cglib-3_2", - artifact = "cglib:cglib-nodep:3.2.6", - sha1 = "92bf48723d277d6efd1150b2f7e9e1e92cb56caf", - ) - - maven_jar( - name = "objenesis", - artifact = "org.objenesis:objenesis:1.3", - sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50", - ) - - maven_jar( - name = "jimfs", - artifact = "com.google.jimfs:jimfs:1.2", - sha1 = "48462eb319817c90c27d377341684b6b81372e08", - ) - - TRUTH_VERS = "1.4.4" - - maven_jar( - name = "truth", - artifact = "com.google.truth:truth:" + TRUTH_VERS, - sha1 = "33810058273a2a3b6ce6d1f8c8621bfc85493f67", - ) - - maven_jar( - name = "truth-java8-extension", - artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS, - sha1 = "49129ba5889b6811e96a9d49af61122f21314670", - ) - - maven_jar( - name = "truth-liteproto-extension", - artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS, - sha1 = "b6282dbc163474900ac914c2dbeca101008f72da", - ) - - maven_jar( - name = "truth-proto-extension", - artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS, - sha1 = "4b88990178086ffdd482246b35a5a48b4d26896c", - ) - - LUCENE_VERS = "10.2.2" - - maven_jar( - name = "lucene-core", - artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS, - sha1 = "336a9c4b24e5704bd5fd71af794cce80f479a3ae", - ) - - maven_jar( - name = "lucene-analyzers-common", - artifact = "org.apache.lucene:lucene-analysis-common:" + LUCENE_VERS, - sha1 = "2c35eb96330d96b6ffb61856ce2cd886a5656c81", - ) - - maven_jar( - name = "lucene-backward-codecs", - artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS, - sha1 = "848ccaaadbcc97c84c09ad808fe4354af00449d9", - ) - - maven_jar( - name = "lucene-misc", - artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS, - sha1 = "047de3cefc3aa78ba11593d72c60f5b17a611c73", - ) - - maven_jar( - name = "lucene-queryparser", - artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS, - sha1 = "bb94dc5a00f01ccc7dc6804388bc7fe9f0070c75", - ) - - maven_jar( - name = "h2", - artifact = "com.h2database:h2:2.4.240", - sha1 = "686180ad33981ad943fdc0ab381e619b2c2fdfe5", - ) - - # JGit's transitive dependencies - maven_jar( - name = "hamcrest", - artifact = "org.hamcrest:hamcrest:2.2", - sha1 = "1820c0968dba3a11a1b30669bb1f01978a91dedc", - )
diff --git a/tools/nongoogle.toml b/tools/nongoogle.toml new file mode 100644 index 0000000..07c9353 --- /dev/null +++ b/tools/nongoogle.toml
@@ -0,0 +1,65 @@ +# Dependencies that are exempted from requiring a Library-Compliance approval +# from a Googler. + +[versions] +autoCommon = "1.2.2" +autoFactory = "1.0.1" +autoValue= "1.11.0" +flogger = "0.8" +# Also update the GUAVA version in java/com/google/gerrit/extensions/BUILD +# to keep Bazel and extension dependencies in sync. +guava = "33.5.0-jre" +guice = "6.0.0" +lucene = "10.4.0" +slf4j = "2.0.17" +sshd = "2.17.1" +truth = "1.4.4" + +[libraries] +auto-common = { module = "com.google.auto:auto-common", version.ref = "autoCommon" } +auto-factory = { module = "com.google.auto.factory:auto-factory", version.ref = "autoFactory" } +auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoFactory" } +auto-value = { module = "com.google.auto.value:auto-value", version.ref = "autoValue" } +auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version.ref = "autoValue" } +commons-io = { module = "commons-io:commons-io", version = "2.21.0" } +dropwizard-core = { module = "io.dropwizard.metrics:metrics-core", version = "4.2.37" } +error-prone-annotations = { module = "com.google.errorprone:error_prone_annotations", version = "2.46.0" } +flogger = { module = "com.google.flogger:flogger", version.ref = "flogger" } +flogger-google-extensions = { module = "com.google.flogger:google-extensions", version.ref = "flogger" } +flogger-log4j-backend = { module = "com.google.flogger:flogger-log4j-backend", version.ref = "flogger" } +flogger-system-backend = { module = "com.google.flogger:flogger-system-backend", version.ref = "flogger" } +gson = { module = "com.google.code.gson:gson", version = "2.13.2" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } +guava-testlib = { module = "com.google.guava:guava-testlib", version.ref = "guava" } +guice-assistedinject = { module = "com.google.inject.extensions:guice-assistedinject", version.ref = "guice" } +guice-library = { module = "com.google.inject:guice", version.ref = "guice" } +guice-servlet = { module = "com.google.inject.extensions:guice-servlet", version.ref = "guice" } +h2 = { module = "com.h2database:h2", version = "2.4.240" } +hamcrest = { module = "org.hamcrest:hamcrest", version = "3.0" } +impl-log4j = { module = "org.slf4j:slf4j-reload4j", version.ref = "slf4j" } +j2objc = { module = "com.google.j2objc:j2objc-annotations", version = "1.1" } +jcl-over-slf4j = { module = "org.slf4j:jcl-over-slf4j", version.ref = "slf4j" } +jimfs = { module = "com.google.jimfs:jimfs", version = "1.2" } +jruby = { module = "org.jruby:jruby-complete", version = "9.1.17.0" } +log-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +log-ext = { module = "org.slf4j:slf4j-ext", version.ref = "slf4j" } +log4j = { module = "ch.qos.reload4j:reload4j", version = "1.2.26" } +lucene-analyzers-common = { module = "org.apache.lucene:lucene-analysis-common", version.ref = "lucene" } +lucene-backward-codecs = { module = "org.apache.lucene:lucene-backward-codecs", version.ref = "lucene" } +lucene-core = { module = "org.apache.lucene:lucene-core", version.ref = "lucene" } +lucene-misc = { module = "org.apache.lucene:lucene-misc", version.ref = "lucene" } +lucene-queryparser = { module = "org.apache.lucene:lucene-queryparser", version.ref = "lucene" } +mina-core = { module = "org.apache.mina:mina-core", version = "2.2.4" } +nekohtml = { module = "net.sourceforge.nekohtml:nekohtml", version = "1.9.10" } +openid-consumer = { module = "org.openid4java:openid4java", version = "1.0.0" } +protobuf-java = { module = "com.google.protobuf:protobuf-java", version = "4.33.4" } +soy = { module = "com.google.template:soy", version = "2024-01-30" } +sshd-mina = { module = "org.apache.sshd:sshd-mina", version.ref = "sshd" } +sshd-osgi = { module = "org.apache.sshd:sshd-osgi", version.ref = "sshd" } +sshd-sftp = { module = "org.apache.sshd:sshd-sftp", version.ref = "sshd" } +truth = { module = "com.google.truth:truth", version.ref = "truth" } +truth-java8-extension = { module = "com.google.truth.extensions:truth-java8-extension", version.ref = "truth" } +truth-liteproto-extension = { module = "com.google.truth.extensions:truth-liteproto-extension", version.ref = "truth" } +truth-proto-extension = { module = "com.google.truth.extensions:truth-proto-extension", version.ref = "truth" } +tukaani-xz = { module = "org.tukaani:xz", version = "1.11" } +xerces = { module = "xerces:xercesImpl", version = "2.12.2" }
diff --git a/tools/remote-bazelrc b/tools/remote-bazelrc index 5b6bab5..8c2386e 100644 --- a/tools/remote-bazelrc +++ b/tools/remote-bazelrc
@@ -31,11 +31,11 @@ # Set several flags related to specifying the platform, toolchain and java # properties. -build:remote_shared --crosstool_top=@ubuntu2204_jdk21//cc:toolchain -build:remote_shared --extra_toolchains=@ubuntu2204_jdk21//config:cc-toolchain -build:remote_shared --extra_execution_platforms=@ubuntu2204_jdk21//config:platform -build:remote_shared --host_platform=@ubuntu2204_jdk21//config:platform -build:remote_shared --platforms=@ubuntu2204_jdk21//config:platform +build:remote_shared --crosstool_top=@rbe_autoconfig//cc:toolchain +build:remote_shared --extra_toolchains=@rbe_autoconfig//config:cc-toolchain +build:remote_shared --extra_execution_platforms=@rbe_autoconfig//config:platform +build:remote_shared --host_platform=@rbe_autoconfig//config:platform +build:remote_shared --platforms=@rbe_autoconfig//config:platform build:remote_shared --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1 # Set various strategies so that all actions execute remotely. Mixing remote
diff --git a/tools/repos.MODULE.bazel b/tools/repos.MODULE.bazel new file mode 100644 index 0000000..c2ac5af --- /dev/null +++ b/tools/repos.MODULE.bazel
@@ -0,0 +1,15 @@ +# Repository rules for in-tree or vendored sources. +# +# This module fragment is included from MODULE.bazel to keep repository +# declarations separate from the module dependency graph. + +local_repository = use_repo_rule( + "@bazel_tools//tools/build_defs/repo:local.bzl", + "local_repository", +) + +# Java-Prettify external repository consumed from git submodule. +local_repository( + name = "java-prettify", + path = "modules/java-prettify", +)
diff --git a/tools/version.py b/tools/version.py index d02fc26..d5f7bd5 100755 --- a/tools/version.py +++ b/tools/version.py
@@ -46,4 +46,4 @@ replace_in_file(pom, src_pattern) src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE) -replace_in_file('version.bzl', src_pattern) +replace_in_file('tools/bazlets.MODULE.bazel', src_pattern)
diff --git a/tools/workspace_status.py b/tools/workspace_status.py index bedc051..dca9b92 100644 --- a/tools/workspace_status.py +++ b/tools/workspace_status.py
@@ -16,7 +16,7 @@ import sys ROOT = os.path.abspath(__file__) -while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')): +while not os.path.exists(os.path.join(ROOT, 'MODULE.bazel')): ROOT = os.path.dirname(ROOT) CMD = ['git', 'describe', '--always', '--match', 'v[0-9].*', '--dirty']
diff --git a/tools/workspace_status_release.py b/tools/workspace_status_release.py index b3e72ff..def9a71 100644 --- a/tools/workspace_status_release.py +++ b/tools/workspace_status_release.py
@@ -35,7 +35,7 @@ import re ROOT = os.path.abspath(__file__) -while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')): +while not os.path.exists(os.path.join(ROOT, 'MODULE.bazel')): ROOT = os.path.dirname(ROOT) REVISION_CMD = ['git', 'describe', '--always', '--dirty']
diff --git a/version.bzl b/version.bzl deleted file mode 100644 index 53ab82d..0000000 --- a/version.bzl +++ /dev/null
@@ -1,5 +0,0 @@ -# Maven style API version (e.g. '2.x-SNAPSHOT'). -# Used by :api_install and :api_deploy targets -# when talking to the destination repository. -# -GERRIT_VERSION = "3.13.6-SNAPSHOT"
diff --git a/webapp/gerrit_icon.png b/webapp/gerrit_icon.png new file mode 100644 index 0000000..274dbec --- /dev/null +++ b/webapp/gerrit_icon.png Binary files differ
diff --git a/yarn.lock b/yarn.lock index e3c2364..9981d74 100644 --- a/yarn.lock +++ b/yarn.lock
@@ -200,43 +200,45 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== -"@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.5.0", "@eslint-community/eslint-utils@^4.7.0": - version "4.7.0" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a" - integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== +"@eslint-community/eslint-utils@^4.1.2", "@eslint-community/eslint-utils@^4.5.0", "@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== dependencies: eslint-visitor-keys "^3.4.3" "@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.12.1": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.21.0": - version "0.21.0" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.0.tgz#abdbcbd16b124c638081766392a4d6b509f72636" - integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ== +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== dependencies: - "@eslint/object-schema" "^2.1.6" + "@eslint/object-schema" "^2.1.7" debug "^4.3.1" minimatch "^3.1.2" -"@eslint/config-helpers@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.3.1.tgz#d316e47905bd0a1a931fa50e669b9af4104d1617" - integrity sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA== +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" -"@eslint/core@^0.15.2": - version "0.15.2" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.15.2.tgz#59386327d7862cc3603ebc7c78159d2dcc4a868f" - integrity sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg== +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== dependencies: "@types/json-schema" "^7.0.15" "@eslint/eslintrc@^3.3.1": - version "3.3.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" - integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== + version "3.3.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" + integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== dependencies: ajv "^6.12.4" debug "^4.3.2" @@ -244,26 +246,26 @@ globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^4.1.0" + js-yaml "^4.1.1" minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.34.0": - version "9.34.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.34.0.tgz#fc423168b9d10e08dea9088d083788ec6442996b" - integrity sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw== +"@eslint/js@9.39.1": + version "9.39.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.1.tgz#0dd59c3a9f40e3f1882975c321470969243e0164" + integrity sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw== -"@eslint/object-schema@^2.1.6": - version "2.1.6" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" - integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== -"@eslint/plugin-kit@^0.3.5": - version "0.3.5" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz#fd8764f0ee79c8ddab4da65460c641cefee017c5" - integrity sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w== +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== dependencies: - "@eslint/core" "^0.15.2" + "@eslint/core" "^0.17.0" levn "^0.4.1" "@humanfs/core@^0.19.1": @@ -272,24 +274,19 @@ integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== "@humanfs/node@^0.16.6": - version "0.16.6" - resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.6.tgz#ee2a10eaabd1131987bf0488fd9b820174cd765e" - integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== dependencies: "@humanfs/core" "^0.19.1" - "@humanwhocodes/retry" "^0.3.0" + "@humanwhocodes/retry" "^0.4.0" "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/retry@^0.3.0": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.1.tgz#c72a5c76a9fbaf3488e231b13dc52c0da7bab42a" - integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== - -"@humanwhocodes/retry@^0.4.2": +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": version "0.4.3" resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== @@ -449,51 +446,106 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.49.0.tgz#ba432433f5e7b419dba2be407d1d59fea6b8de48" integrity sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA== +"@rollup/rollup-android-arm-eabi@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb" + integrity sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w== + "@rollup/rollup-android-arm64@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.49.0.tgz#4e05c86e0fb9af6eaf52fc298dcdec577477e35c" integrity sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w== +"@rollup/rollup-android-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz#2b025510c53a5e3962d3edade91fba9368c9d71c" + integrity sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w== + "@rollup/rollup-darwin-arm64@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.49.0.tgz#788fad425b4129875639e0c14b6441c5f3b69d46" integrity sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw== +"@rollup/rollup-darwin-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz#3577c38af68ccf34c03e84f476bfd526abca10a0" + integrity sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA== + "@rollup/rollup-darwin-x64@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.49.0.tgz#d44e05bee55b781d7c2cf535d9f9169787c3599d" integrity sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg== +"@rollup/rollup-darwin-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz#2bf5f2520a1f3b551723d274b9669ba5b75ed69c" + integrity sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ== + "@rollup/rollup-freebsd-arm64@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.49.0.tgz#107786b4d604495224c3543bfd2cae33ddf76500" integrity sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA== +"@rollup/rollup-freebsd-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz#4bb9cc80252564c158efc0710153c71633f1927c" + integrity sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w== + "@rollup/rollup-freebsd-x64@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.49.0.tgz#54e105c3da27f31084ca6913fed603627755abde" integrity sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w== +"@rollup/rollup-freebsd-x64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz#2301289094d49415a380cf942219ae9d8b127440" + integrity sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q== + "@rollup/rollup-linux-arm-gnueabihf@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.49.0.tgz#725c23e0766b5d9368180bc2c427a51e31d0e147" integrity sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w== +"@rollup/rollup-linux-arm-gnueabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz#1d03d776f2065e09fc141df7d143476e94acca88" + integrity sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw== + "@rollup/rollup-linux-arm-musleabihf@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.49.0.tgz#6946b0d2f132f2baf5657945b81565d8abd51cc0" integrity sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA== +"@rollup/rollup-linux-arm-musleabihf@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz#8623de0e040b2fd52a541c602688228f51f96701" + integrity sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg== + "@rollup/rollup-linux-arm64-gnu@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.49.0.tgz#83510a6d03e748619241a17f5a879418a963c5ed" integrity sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg== +"@rollup/rollup-linux-arm64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz#ce2d1999bc166277935dde0301cde3dd0417fb6e" + integrity sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w== + "@rollup/rollup-linux-arm64-musl@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.49.0.tgz#085b98d44c10908626dd40f26bf924433bbd8471" integrity sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg== +"@rollup/rollup-linux-arm64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz#88c2523778444da952651a2219026416564a4899" + integrity sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A== + +"@rollup/rollup-linux-loong64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz#578ca2220a200ac4226c536c10c8cc6e4f276714" + integrity sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g== + "@rollup/rollup-linux-loongarch64-gnu@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.49.0.tgz#13e0a4808e9f7924f2cc8c133603f627c7a00543" @@ -504,46 +556,101 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.49.0.tgz#aeee4e47fc9ca5d6687e686fea4696202af6b2f4" integrity sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g== +"@rollup/rollup-linux-ppc64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz#aa338d3effd4168a20a5023834a74ba2c3081293" + integrity sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw== + "@rollup/rollup-linux-riscv64-gnu@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.49.0.tgz#603e4591643f1d7851a96d096cf7fcd273f7b0e1" integrity sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw== +"@rollup/rollup-linux-riscv64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz#16ba582f9f6cff58119aa242782209b1557a1508" + integrity sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g== + "@rollup/rollup-linux-riscv64-musl@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.49.0.tgz#f8fd9b01f1888e1816d5a398789d430511286c00" integrity sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw== +"@rollup/rollup-linux-riscv64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz#e404a77ebd6378483888b8064c703adb011340ab" + integrity sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A== + "@rollup/rollup-linux-s390x-gnu@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.49.0.tgz#37a1fd372d9b93d2b75b2f37c482ecf52f52849b" integrity sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A== +"@rollup/rollup-linux-s390x-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz#92ad52d306227c56bec43d96ad2164495437ffe6" + integrity sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg== + "@rollup/rollup-linux-x64-gnu@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.49.0.tgz#131e66dbf7e71cb2a389acc45319bd4c990e093a" integrity sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA== +"@rollup/rollup-linux-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz#fd0dea3bb9aa07e7083579f25e1c2285a46cb9fa" + integrity sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w== + "@rollup/rollup-linux-x64-musl@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.49.0.tgz#b7245a5ea57db9679e8bf3032c25a5d2c5f54056" integrity sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg== +"@rollup/rollup-linux-x64-musl@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz#37a3efb09f18d555f8afc490e1f0444885de8951" + integrity sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q== + +"@rollup/rollup-openharmony-arm64@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz#c489bec9f4f8320d42c9b324cca220c90091c1f7" + integrity sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw== + "@rollup/rollup-win32-arm64-msvc@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.49.0.tgz#768a128bb5da3c5472c3c56aec77507d28bc7209" integrity sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA== +"@rollup/rollup-win32-arm64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz#152832b5f79dc22d1606fac3db946283601b7080" + integrity sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw== + "@rollup/rollup-win32-ia32-msvc@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.49.0.tgz#ce3f3b2eebe585340631498666718f00983a6a62" integrity sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA== +"@rollup/rollup-win32-ia32-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz#54d91b2bb3bf3e9f30d32b72065a4e52b3a172a5" + integrity sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA== + +"@rollup/rollup-win32-x64-gnu@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz#df9df03e61a003873efec8decd2034e7f135c71e" + integrity sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg== + "@rollup/rollup-win32-x64-msvc@4.49.0": version "4.49.0" resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.49.0.tgz#c2a0e3b81262a7e9dd12ce18b350a97558dd50bc" integrity sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg== +"@rollup/rollup-win32-x64-msvc@4.53.3": + version "4.53.3" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe" + integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -756,102 +863,105 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@5.62.0", "@typescript-eslint/eslint-plugin@^8.32.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz#42209e2ce3e2274de0f5f9b75c777deedacaa558" - integrity sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw== +"@typescript-eslint/eslint-plugin@5.62.0", "@typescript-eslint/eslint-plugin@^8.32.0", "@typescript-eslint/eslint-plugin@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz#8ed8736b8415a9193989220eadb6031dbcd2260a" + integrity sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.41.0" - "@typescript-eslint/type-utils" "8.41.0" - "@typescript-eslint/utils" "8.41.0" - "@typescript-eslint/visitor-keys" "8.41.0" - graphemer "^1.4.0" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/type-utils" "8.49.0" + "@typescript-eslint/utils" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" ignore "^7.0.0" natural-compare "^1.4.0" ts-api-utils "^2.1.0" -"@typescript-eslint/parser@5.62.0", "@typescript-eslint/parser@^8.32.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.41.0.tgz#677f5b2b3fa947ee1eac4129220c051b1990d898" - integrity sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg== +"@typescript-eslint/parser@5.62.0", "@typescript-eslint/parser@^8.32.0", "@typescript-eslint/parser@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.49.0.tgz#0ede412d59e99239b770f0f08c76c42fba717fa2" + integrity sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA== dependencies: - "@typescript-eslint/scope-manager" "8.41.0" - "@typescript-eslint/types" "8.41.0" - "@typescript-eslint/typescript-estree" "8.41.0" - "@typescript-eslint/visitor-keys" "8.41.0" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" debug "^4.3.4" -"@typescript-eslint/project-service@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.41.0.tgz#08ebf882d413a038926e73fda36e00c3dba84882" - integrity sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ== +"@typescript-eslint/project-service@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.49.0.tgz#ce220525c88cb2d23792b391c07e14cb9697651a" + integrity sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.41.0" - "@typescript-eslint/types" "^8.41.0" + "@typescript-eslint/tsconfig-utils" "^8.49.0" + "@typescript-eslint/types" "^8.49.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz#c8aba12129cb9cead1f1727f58e6a0fcebeecdb5" - integrity sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ== +"@typescript-eslint/scope-manager@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz#a3496765b57fb48035d671174552e462e5bffa63" + integrity sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg== dependencies: - "@typescript-eslint/types" "8.41.0" - "@typescript-eslint/visitor-keys" "8.41.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" -"@typescript-eslint/tsconfig-utils@8.41.0", "@typescript-eslint/tsconfig-utils@^8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz#134dee36eb16cdd78095a20bca0516d10b5dda75" - integrity sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw== +"@typescript-eslint/tsconfig-utils@8.49.0", "@typescript-eslint/tsconfig-utils@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz#857777c8e35dd1e564505833d8043f544442fbf4" + integrity sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA== -"@typescript-eslint/type-utils@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz#68d401e38fccf239925447e97bdbd048a9891ae5" - integrity sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ== +"@typescript-eslint/type-utils@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz#d8118a0c1896a78a22f01d3c176e9945409b085b" + integrity sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg== dependencies: - "@typescript-eslint/types" "8.41.0" - "@typescript-eslint/typescript-estree" "8.41.0" - "@typescript-eslint/utils" "8.41.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/utils" "8.49.0" debug "^4.3.4" ts-api-utils "^2.1.0" -"@typescript-eslint/types@8.41.0", "@typescript-eslint/types@^8.11.0", "@typescript-eslint/types@^8.41.0": +"@typescript-eslint/types@8.49.0", "@typescript-eslint/types@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.49.0.tgz#c1bd3ebf956d9e5216396349ca23c58d74f06aee" + integrity sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ== + +"@typescript-eslint/types@^8.11.0": version "8.41.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.41.0.tgz#9935afeaae65e535abcbcee95383fa649c64d16d" integrity sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag== -"@typescript-eslint/typescript-estree@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz#7c9cff8b4334ce96f14e9689692e8cf426ce4d59" - integrity sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ== +"@typescript-eslint/typescript-estree@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz#99c5a53275197ccb4e849786dad68344e9924135" + integrity sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA== dependencies: - "@typescript-eslint/project-service" "8.41.0" - "@typescript-eslint/tsconfig-utils" "8.41.0" - "@typescript-eslint/types" "8.41.0" - "@typescript-eslint/visitor-keys" "8.41.0" + "@typescript-eslint/project-service" "8.49.0" + "@typescript-eslint/tsconfig-utils" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" debug "^4.3.4" - fast-glob "^3.3.2" - is-glob "^4.0.3" minimatch "^9.0.4" semver "^7.6.0" + tinyglobby "^0.2.15" ts-api-utils "^2.1.0" -"@typescript-eslint/utils@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.41.0.tgz#17cb3b766c1626311004ea41ffd8c27eb226b953" - integrity sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A== +"@typescript-eslint/utils@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.49.0.tgz#43b3b91d30afd6f6114532cf0b228f1790f43aff" + integrity sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA== dependencies: "@eslint-community/eslint-utils" "^4.7.0" - "@typescript-eslint/scope-manager" "8.41.0" - "@typescript-eslint/types" "8.41.0" - "@typescript-eslint/typescript-estree" "8.41.0" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" -"@typescript-eslint/visitor-keys@8.41.0": - version "8.41.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz#16eb99b55d207f6688002a2cf425e039579aa9a9" - integrity sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg== +"@typescript-eslint/visitor-keys@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz#8e450cc502c0d285cad9e84d400cf349a85ced6c" + integrity sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA== dependencies: - "@typescript-eslint/types" "8.41.0" + "@typescript-eslint/types" "8.49.0" eslint-visitor-keys "^4.2.1" "@vscode/l10n@^0.0.18": @@ -1410,13 +1520,20 @@ dependencies: ms "^2.1.1" -debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.6, debug@^4.4.1: +debug@^4.1.1, debug@^4.3.6, debug@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + decamelize-keys@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.1.tgz#04a2d523b2f18d80d0158a43b895d56dff8d19d8" @@ -1881,9 +1998,9 @@ semver "^7.3.8" eslint-plugin-n@^17.17.0: - version "17.21.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-17.21.3.tgz#a07592c28390ac742bf52acae89048c997a7b91c" - integrity sha512-MtxYjDZhMQgsWRm/4xYLL0i2EhusWT7itDxlJ80l1NND2AL2Vi5Mvneqv/ikG9+zpran0VsVRXTEHrpLmUZRNw== + version "17.23.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-n/-/eslint-plugin-n-17.23.1.tgz#467db2aba0ead574ea6150143d079d544c11cf48" + integrity sha512-68PealUpYoHOBh332JLLD9Sj7OQUDkFpmcfqt8R9sySfFSeuGJjMTJQvCRRB96zO3A/PELRLkPrzsHmzEFQQ5A== dependencies: "@eslint-community/eslint-utils" "^4.5.0" enhanced-resolve "^5.17.1" @@ -1958,24 +2075,23 @@ resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== -eslint@8.57.1, eslint@^9.26.0: - version "9.34.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.34.0.tgz#0ea1f2c1b5d1671db8f01aa6b8ce722302016f7b" - integrity sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg== +eslint@8.57.1, eslint@^9.26.0, eslint@^9.39.1: + version "9.39.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.1.tgz#be8bf7c6de77dcc4252b5a8dcb31c2efff74a6e5" + integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.21.0" - "@eslint/config-helpers" "^0.3.1" - "@eslint/core" "^0.15.2" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.34.0" - "@eslint/plugin-kit" "^0.3.5" + "@eslint/js" "9.39.1" + "@eslint/plugin-kit" "^0.4.1" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" "@types/estree" "^1.0.6" - "@types/json-schema" "^7.0.15" ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.6" @@ -2076,7 +2192,7 @@ resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== -fast-glob@^3.2.11, fast-glob@^3.2.2, fast-glob@^3.3.2: +fast-glob@^3.2.11, fast-glob@^3.2.2: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -2104,6 +2220,11 @@ dependencies: reusify "^1.0.4" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -2249,9 +2370,9 @@ get-intrinsic "^1.2.6" get-tsconfig@^4.8.1: - version "4.10.1" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz#d34c1c01f47d65a606c37aa7a177bc3e56ab4b2e" - integrity sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ== + version "4.13.0" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.13.0.tgz#fcdd991e6d22ab9a600f00e91c318707a5d9a0d7" + integrity sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ== dependencies: resolve-pkg-maps "^1.0.0" @@ -2319,11 +2440,6 @@ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - gts@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/gts/-/gts-6.0.2.tgz#f7ff83fd786de92384740da4409f954261a0a62c" @@ -2801,10 +2917,10 @@ resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== +js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== dependencies: argparse "^2.0.1" @@ -3482,7 +3598,7 @@ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2: +picomatch@^4.0.2, picomatch@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== @@ -3695,7 +3811,7 @@ dependencies: glob "^7.1.3" -rollup@^4.4.0, rollup@^4.49.0: +rollup@^4.4.0: version "4.49.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.49.0.tgz#9751ad9d06a47a4496c3c5c238b27b1422c8b0eb" integrity sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA== @@ -3724,6 +3840,37 @@ "@rollup/rollup-win32-x64-msvc" "4.49.0" fsevents "~2.3.2" +rollup@^4.49.0: + version "4.53.3" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.3.tgz#dbc8cd8743b38710019fb8297e8d7a76e3faa406" + integrity sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.53.3" + "@rollup/rollup-android-arm64" "4.53.3" + "@rollup/rollup-darwin-arm64" "4.53.3" + "@rollup/rollup-darwin-x64" "4.53.3" + "@rollup/rollup-freebsd-arm64" "4.53.3" + "@rollup/rollup-freebsd-x64" "4.53.3" + "@rollup/rollup-linux-arm-gnueabihf" "4.53.3" + "@rollup/rollup-linux-arm-musleabihf" "4.53.3" + "@rollup/rollup-linux-arm64-gnu" "4.53.3" + "@rollup/rollup-linux-arm64-musl" "4.53.3" + "@rollup/rollup-linux-loong64-gnu" "4.53.3" + "@rollup/rollup-linux-ppc64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-gnu" "4.53.3" + "@rollup/rollup-linux-riscv64-musl" "4.53.3" + "@rollup/rollup-linux-s390x-gnu" "4.53.3" + "@rollup/rollup-linux-x64-gnu" "4.53.3" + "@rollup/rollup-linux-x64-musl" "4.53.3" + "@rollup/rollup-openharmony-arm64" "4.53.3" + "@rollup/rollup-win32-arm64-msvc" "4.53.3" + "@rollup/rollup-win32-ia32-msvc" "4.53.3" + "@rollup/rollup-win32-x64-gnu" "4.53.3" + "@rollup/rollup-win32-x64-msvc" "4.53.3" + fsevents "~2.3.2" + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -3796,11 +3943,16 @@ resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.3.4, semver@^7.3.8, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.2: +semver@^7.0.0, semver@^7.3.4, semver@^7.3.8, semver@^7.7.2: version "7.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== +semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -4107,9 +4259,9 @@ wordwrapjs "^5.1.0" tapable@^2.2.0: - version "2.2.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.3.tgz#4b67b635b2d97578a06a2713d2f04800c237e99b" - integrity sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg== + version "2.3.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== terser@~5.39.0: version "5.39.2" @@ -4126,6 +4278,14 @@ resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -4349,20 +4509,20 @@ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -vscode-css-languageservice@4.3.0, vscode-css-languageservice@^6.3.6: - version "6.3.7" - resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-6.3.7.tgz#110d11a87973fb52fc9e1a5072b480580673daab" - integrity sha512-5TmXHKllPzfkPhW4UE9sODV3E0bIOJPOk+EERKllf2SmAczjfTmYeq5txco+N3jpF8KIZ6loj/JptpHBQuVQRA== +vscode-css-languageservice@4.3.0, vscode-css-languageservice@^6.3.9: + version "6.3.9" + resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-6.3.9.tgz#13b4af55496d9372bd3d882020d1a9a7369714d7" + integrity sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA== dependencies: "@vscode/l10n" "^0.0.18" vscode-languageserver-textdocument "^1.0.12" vscode-languageserver-types "3.17.5" vscode-uri "^3.1.0" -vscode-html-languageservice@3.1.0, vscode-html-languageservice@^5.5.0: - version "5.5.1" - resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-5.5.1.tgz#d52c578dd0198a3e651e12092f72a3398c4783bc" - integrity sha512-/ZdEtsZ3OiFSyL00kmmu7crFV9KwWR+MgpzjsxO60DQH7sIfHZM892C/E4iDd11EKocr+NYuvOA4Y7uc3QzLEA== +vscode-html-languageservice@3.1.0, vscode-html-languageservice@^5.6.1: + version "5.6.1" + resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-5.6.1.tgz#5b525b074b254b9f0e726ecaa07ae6fccaf3e3d3" + integrity sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA== dependencies: "@vscode/l10n" "^0.0.18" vscode-languageserver-textdocument "^1.0.12"